diff --git a/echo/api.http b/echo/api.http deleted file mode 100644 index c4e652ec2..000000000 --- a/echo/api.http +++ /dev/null @@ -1,7 +0,0 @@ -### -POST http://localhost:8000/api/stateless/summarize -Content-Type: application/json - -{ - "transcript": "Hallo. Zo. Gebruiken jullie Whisper over de API? Ja, op dit moment gebruiken we het over de API. Oké. Is het de officiële API? Dus we hebben een paar deployments voor klanten die Azure nodig hebben, gehosted in de EU. We doen dat, maar we gebruiken ook gewoon OpenAI's gehosted versie, om te testen en een aantal pilot klanten te bieden. Dus alles wat OpenAI-compatibel is, kunnen we eigenlijk gebruiken. Ja. Ik heb het zelf getest, vooral de LLM-stack, met mijn lokale OLAMA's. Ja, ja, dat is... Wat is de range van de compatibiliteit? 100% compatibel. Ja, maar als je zegt dat het allemaal OpenAI is... Oké. Dus er zijn een paar specifieke OpenAI-featuren die niet alle zelf-hosted-modellen hebben. Bijvoorbeeld een response-format, met gestructureerde uitgangen, dat is specifiek voor OpenAI. Maar op het moment van de afgelopen tijd zijn er nieuwe ontwikkelingen aan het gebeuren. Bijvoorbeeld LightLLM, dat deze overlaagde patternen creëert, die iedereen kan gebruiken. Oké. En dat is hetgeen dat jij gebruikt? Ja. Oké. Oké, dus we hebben een soort integraal overleg. Als iedereen het oké vindt, zullen we Engels spreken? Ja. Omdat we je Nederlandse stijl hebben gehoord. Zo, zo. Ja, ja. Is het oké voor iedereen dat we in het Nederlands proberen? Dus wat we dachten, het kan een goede idee zijn, is dat we iedereen zijn gedachten geven over hoe we dit aanpakken. Misschien één of twee minuten. Eén minuut, twee minuten. En dan laat ons weten wat je zou willen doen. Zodat we iedereen's visie hebben, vanuit een hoog niveau perspectief. Maar ook weten hoe we de groep kunnen delen. We zijn een grote groep, want we zijn zes, acht, tien, twaalf, veertien. Het is een grote groep. Een zeer populaire uitdaging. Ja, een charismatische coördinator. Dus hoe klinkt dat? Is dat een goede idee? Ja. Ik denk dat wat ons echt uniek maakt, is dat we, ik heb het al met wat van jullie gesproken, we hebben een repo beschikbaar voor ons. Het is een soort asset. En ik voorstel dat we het, als zo, ten minste een kijkje nemen. Zodat we misschien de groep kunnen delen. En dan een kijkje nemen naar de repo, zien wat we kunnen gebruiken. En misschien kan de andere groep zijn eigen neerleggen. Had je een vraag? Nee. Sorry. Nee, sorry. Dus misschien, zoals ik al zei, laat me" -} \ No newline at end of file diff --git a/echo/check-later.md b/echo/check-later.md deleted file mode 100644 index b6f725845..000000000 --- a/echo/check-later.md +++ /dev/null @@ -1,8 +0,0 @@ -- prod worker cpu using watch -- in the regular worker removed watch -- 8001? let's use devcontainers -- many local imports python -- pr size -- dockerfile update - -- can probably run ruff / mypy in parallel \ No newline at end of file diff --git a/echo/frontend/AGENTS.md b/echo/frontend/AGENTS.md index beebb935d..06accd952 100644 --- a/echo/frontend/AGENTS.md +++ b/echo/frontend/AGENTS.md @@ -12,6 +12,13 @@ Cross-cutting rules (brand, UI, Directus, BFF, architecture, translations) live - **2FA flow**: Directus surfaces it by returning `INVALID_OTP` — toggle a Mantine `PinInput` field and retry the same mutation. See `src/routes/auth/Login.tsx` - **Transitions**: login/logout flows call `useTransitionCurtain().runTransition()` before navigation — animations expect the Directus mutation promise to be awaited +## Sidebar Navigation + +- The sketch is canonical for the first-layer sidebar: Search and Inbox live at the top; Home and Organisations are the primary body; user-level settings sits directly below organisations; Help is an expanded footer utility list, not a pushed view. +- Bottom footer actions must not change the top sidebar context. Scope changes should come from body rows like organisations, workspaces, projects, inbox, or user settings. +- User settings are global. Organisation, workspace, and project settings stay inside their scope views. +- Do not link to `status.dembrane.com` until a status surface exists. The future status page should cover queue depth and backend health. + ## Analytics (PostHog) - `posthog-js` + `@posthog/react` are initialized in `src/main.tsx`; the app is wrapped in `PostHogProvider` diff --git a/echo/frontend/src/Router.tsx b/echo/frontend/src/Router.tsx index 2bc1f4be8..8c1421085 100644 --- a/echo/frontend/src/Router.tsx +++ b/echo/frontend/src/Router.tsx @@ -4,24 +4,23 @@ import { createLazyRoute, } from "./components/common/LazyRoute"; import { Protected } from "./components/common/Protected"; -import { WorkspaceRedirect } from "./components/common/WorkspaceRedirect"; import { ErrorPage } from "./components/error/ErrorPage"; import { AuthLayout } from "./components/layout/AuthLayout"; // Layout components - keep as regular imports since they're used frequently import { BaseLayout } from "./components/layout/BaseLayout"; -import { WorkspaceLayout } from "./components/layout/WorkspaceLayout"; import { LanguageLayout } from "./components/layout/LanguageLayout"; import { ParticipantLayout } from "./components/layout/ParticipantLayout"; import { ProjectConversationLayout } from "./components/layout/ProjectConversationLayout"; import { ProjectLayout } from "./components/layout/ProjectLayout"; -import { ProjectAccessGuard } from "./components/project/ProjectAccessGuard"; import { ProjectLibraryLayout } from "./components/layout/ProjectLibraryLayout"; import { ProjectOverviewLayout } from "./components/layout/ProjectOverviewLayout"; +import { WorkspaceLayout } from "./components/layout/WorkspaceLayout"; import { ParticipantConversationAudioContent } from "./components/participant/ParticipantConversationAudioContent"; import { RefineSelection } from "./components/participant/refine/RefineSelection"; import { Verify } from "./components/participant/verify/Verify"; import { VerifyArtefact } from "./components/participant/verify/VerifyArtefact"; import { VerifySelection } from "./components/participant/verify/VerifySelection"; +import { ProjectAccessGuard } from "./components/project/ProjectAccessGuard"; import { ParticipantConversationAudioRoute, ParticipantConversationTextRoute, @@ -30,12 +29,18 @@ import { ParticipantPostConversation } from "./routes/participant/ParticipantPos import { ParticipantStartRoute } from "./routes/participant/ParticipantStart"; import { ProjectConversationOverviewRoute } from "./routes/project/conversation/ProjectConversationOverview"; import { ProjectConversationTranscript } from "./routes/project/conversation/ProjectConversationTranscript"; +import { ProjectHomeRoute } from "./routes/project/ProjectHomeRoute"; // Tab-based routes - import directly for now to debug import { ProjectAccessRoute, + ProjectConversationsRoute, + ProjectIntegrationsRoute, ProjectPortalSettingsRoute, ProjectSettingsRoute, + ProjectUploadRoute, } from "./routes/project/ProjectRoutes"; +import { SidebarPreviewLayout } from "./routes/sidebar-preview/SidebarPreviewLayout"; +import { SidebarPreviewRoute } from "./routes/sidebar-preview/SidebarPreviewRoute"; // Lazy-loaded route components const ProjectsHomeRoute = createLazyNamedRoute( @@ -150,7 +155,7 @@ const AdminSettingsRoute = createLazyNamedRoute( // Project route children — shared between /projects and /w/:workspaceId/projects const projectRouteChildren = [ { - element: , + element: , index: true, }, { @@ -161,10 +166,14 @@ const projectRouteChildren = [ children: [ { children: [ + { + element: , + path: "home", + }, { children: [ { - element: , + element: , index: true, }, { @@ -226,7 +235,6 @@ const projectRouteChildren = [ element: , path: "conversation/:conversationId", }, - { children: [ { @@ -249,6 +257,22 @@ const projectRouteChildren = [ element: , path: "report", }, + { + element: , + path: "conversations", + }, + { + element: , + path: "upload", + }, + { + element: , + path: "export", + }, + { + element: , + path: "integrations", + }, { element: , path: "debug", @@ -269,7 +293,9 @@ export const mainRouter = createBrowserRouter([ { children: [ { - element: , + // Root → workspace selector. Legacy /projects routes are gone; + // the canonical "home" for an authed user is /w. + element: , path: "", }, { @@ -355,17 +381,6 @@ export const mainRouter = createBrowserRouter([ element: , path: "new", }, - { - element: , - path: ":workspaceId", - }, - { - // Splat so the tab lives in the path - // (/w/:workspaceId/settings/:tab). The component parses - // the trailing segment. - element: , - path: ":workspaceId/settings/*", - }, ], element: ( @@ -393,23 +408,37 @@ export const mainRouter = createBrowserRouter([ ), path: "o", }, - { - // Host Guide - standalone page, protected but no header/layout - element: ( - - - - ), - path: "projects/:projectId/host-guide", - }, { // Workspace-scoped projects: /w/:workspaceId/projects/... - // This is the PRIMARY route — workspace ID in URL makes it shareable + // SINGLE canonical shape — legacy /projects/:projectId is gone. children: [ { - children: projectRouteChildren, + element: , + index: true, + }, + { + element: , + path: "projects/:projectId/host-guide", + }, + { + children: [ + { + element: , + path: "home", + }, + { + // Splat so the tab lives in the path + // (/w/:workspaceId/settings/:tab). The component parses + // the trailing segment. + element: , + path: "settings/*", + }, + { + children: projectRouteChildren, + path: "projects", + }, + ], element: , - path: "projects", }, ], element: ( @@ -420,28 +449,14 @@ export const mainRouter = createBrowserRouter([ path: "w/:workspaceId", }, { - // Legacy /projects — redirects to /w/:workspaceId/projects - // Kept for backward compat (bookmarks, existing links) children: [ { - element: , + element: , index: true, }, - // Direct project access still works (falls through to v1) - ...projectRouteChildren.slice(1), - ], - element: ( - - - - ), - path: "projects", - }, - { - children: [ { element: , - index: true, + path: ":section", }, ], element: ( @@ -456,7 +471,7 @@ export const mainRouter = createBrowserRouter([ // Client-side guard lives inside AdminSettingsRoute (reads // meV2.is_staff); backend /v2/admin/* also gates on is_admin. children: [ - { element: , index: true }, + { element: , index: true }, { element: , path: ":tab" }, ], element: ( @@ -466,6 +481,47 @@ export const mainRouter = createBrowserRouter([ ), path: "admin", }, + { + // Sidebar preview — feature/sidebar work-in-progress. No auth gate + // so the design can be poked at without sign-in friction. Remove + // or fold into real layouts once the sidebar replaces production + // chrome. + children: [ + { element: , index: true }, + { element: , path: "settings/:section" }, + { element: , path: "o/:orgId" }, + { + element: , + path: "o/:orgId/settings/:section", + }, + { + element: , + path: "w/:workspaceId/home", + }, + { + element: , + path: "w/:workspaceId/projects", + }, + { + element: , + path: "w/:workspaceId/settings/:section", + }, + { + element: , + path: "w/:workspaceId/projects/:projectId/home", + }, + { + element: , + path: "w/:workspaceId/projects/:projectId/conversations", + }, + { + element: , + path: "w/:workspaceId/projects/:projectId/settings/:section", + }, + ], + element: , + path: "sidebar-preview", + }, { element: , path: "*", diff --git a/echo/frontend/src/components/aspect/AspectCard.tsx b/echo/frontend/src/components/aspect/AspectCard.tsx index c7f54d0b7..7af595fbe 100644 --- a/echo/frontend/src/components/aspect/AspectCard.tsx +++ b/echo/frontend/src/components/aspect/AspectCard.tsx @@ -13,7 +13,7 @@ export const AspectCard = ({ data: Aspect; className?: string; }) => { - const { projectId } = useParams(); + const { projectId, workspaceId } = useParams(); const project = useProjectById({ projectId: projectId ?? "", @@ -26,7 +26,7 @@ export const AspectCard = ({ { ); }, onSuccess: async () => { + queryClient.removeQueries({ queryKey: ["users", "me"] }); + queryClient.removeQueries({ queryKey: ["v2", "workspaces"] }); + queryClient.removeQueries({ queryKey: ["v2", "workspaces-context"] }); + if (typeof window !== "undefined") { + try { + sessionStorage.removeItem("dembrane_ws_selected"); + } catch {} + } + emitAuthCacheBoundary(); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["auth", "session"] }), queryClient.invalidateQueries({ queryKey: ["users", "me"] }), + queryClient.invalidateQueries({ queryKey: ["v2", "workspaces"] }), + queryClient.invalidateQueries({ + queryKey: ["v2", "workspaces-context"], + }), ]); }, }); @@ -241,6 +255,7 @@ export const useLogoutMutation = () => { sessionStorage.removeItem("dembrane_ws_selected"); } catch {} } + emitAuthCacheBoundary(); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["auth", "session"] }); diff --git a/echo/frontend/src/components/chat/ChatAccordion.tsx b/echo/frontend/src/components/chat/ChatAccordion.tsx index a7dd2e465..3f6eb062b 100644 --- a/echo/frontend/src/components/chat/ChatAccordion.tsx +++ b/echo/frontend/src/components/chat/ChatAccordion.tsx @@ -107,6 +107,7 @@ export const ChatAccordionItemMenu = ({ const deleteChatMutation = useDeleteChatMutation(); const updateChatMutation = useUpdateChatMutation(); const navigate = useI18nNavigate(); + const { workspaceId } = useParams(); const [ deleteConfirmOpened, { open: openDeleteConfirm, close: closeDeleteConfirm }, @@ -183,7 +184,7 @@ export const ChatAccordionItemMenu = ({ chatId: chat.id ?? "", projectId: (chat.project_id as string) ?? "", }); - navigate(`/projects/${chat.project_id}/overview`); + navigate(`/w/${workspaceId}/projects/${chat.project_id}/overview`); closeDeleteConfirm(); }} data-testid="chat-delete-modal" @@ -194,7 +195,7 @@ export const ChatAccordionItemMenu = ({ // Chat Accordion export const ChatAccordionMain = ({ projectId }: { projectId: string }) => { - const { chatId: activeChatId } = useParams(); + const { chatId: activeChatId, workspaceId } = useParams(); const { ref: loadMoreRef, inView } = useInView(); const chatsQuery = useInfiniteProjectChats( @@ -309,7 +310,7 @@ export const ChatAccordionMain = ({ projectId }: { projectId: string }) => { return ( diff --git a/echo/frontend/src/components/chat/References.tsx b/echo/frontend/src/components/chat/References.tsx index bef2e637b..f4f85cdf9 100644 --- a/echo/frontend/src/components/chat/References.tsx +++ b/echo/frontend/src/components/chat/References.tsx @@ -1,5 +1,6 @@ import { Trans } from "@lingui/react/macro"; import { Badge, Box, Text } from "@mantine/core"; +import { useParams } from "react-router"; import { I18nLink } from "@/components/common/i18nLink"; export const References = ({ @@ -10,6 +11,7 @@ export const References = ({ metadata: any[]; projectId: string | undefined; }) => { + const { workspaceId } = useParams(); const citations = metadata.filter((m) => m.type === "citation"); if (citations.length === 0) return null; @@ -29,7 +31,7 @@ export const References = ({ {citation.reference_text} { + const { workspaceId } = useParams(); const references = metadata.filter((m) => m.type === "reference"); if (references.length === 0) return null; @@ -29,7 +31,7 @@ export const Sources = ({ {ref?.conversation_title || diff --git a/echo/frontend/src/components/common/WorkspaceRedirect.tsx b/echo/frontend/src/components/common/WorkspaceRedirect.tsx deleted file mode 100644 index 89a20a25d..000000000 --- a/echo/frontend/src/components/common/WorkspaceRedirect.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Loader, Stack } from "@mantine/core"; -import { Trans } from "@lingui/react/macro"; -import { Navigate } from "react-router"; -import { FetchErrorPanel } from "@/components/common/FetchErrorPanel"; -import { useWorkspace } from "@/hooks/useWorkspace"; - -// Routes legacy /projects: resolved workspace → /w/:id/projects, multi-workspace → /w, none → fall through. -export const WorkspaceRedirect = () => { - const { workspaceId, workspaces, isLoading, isError, refetch } = - useWorkspace(); - - if (isLoading) { - return ( - - - - ); - } - - // Without this, a failed fetch reads as "no workspaces" — same UI as a brand-new account. - if (isError) { - return ( - - We couldn't load your workspaces. Check your connection and try - again. - - } - /> - ); - } - - if (workspaceId) { - return ; - } - - if (workspaces.length > 0) { - return ; - } - - return null; -}; diff --git a/echo/frontend/src/components/conversation/ConversationAccordion.tsx b/echo/frontend/src/components/conversation/ConversationAccordion.tsx index f252773d8..2e7f953a7 100644 --- a/echo/frontend/src/components/conversation/ConversationAccordion.tsx +++ b/echo/frontend/src/components/conversation/ConversationAccordion.tsx @@ -71,10 +71,10 @@ import { useProjectById, } from "@/components/project/hooks"; import { ENABLE_CHAT_AUTO_SELECT, ENABLE_CHAT_SELECT_ALL } from "@/config"; +import { useWorkspaceUsage } from "@/hooks/useWorkspaceUsage"; import { analytics } from "@/lib/analytics"; import { AnalyticsEvents as events } from "@/lib/analyticsEvents"; import { testId } from "@/lib/testUtils"; -import { useWorkspaceUsage } from "@/hooks/useWorkspaceUsage"; import { BaseSkeleton } from "../common/BaseSkeleton"; import { NavigationButton } from "../common/NavigationButton"; import { UploadConversationDropzone } from "../dropzone/UploadConversationDropzone"; @@ -513,7 +513,7 @@ const ConversationAccordionItem = ({ const inChatMode = location.pathname.includes("/chats/"); const isNewChatRoute = location.pathname.includes("/chats/new"); - const { chatId } = useParams(); + const { chatId, workspaceId } = useParams(); const chatContextQuery = useProjectChatContext(chatId ?? ""); // Don't show loading skeleton for new chat route (no chat exists yet) @@ -544,7 +544,7 @@ const ConversationAccordionItem = ({ return ( )} - {conversation.is_anonymized && ( - - - - - - )} + {conversation.is_anonymized && ( + + + + + + )} - {conversation.locked && ( - - } + {conversation.locked && ( + - {t`Locked`} - - - )} + } + > + {t`Locked`} + + + )} No conversations found. Start a conversation using the participation invite link from the{" "} - + { if (qrCodeRef?.current && isMobile) { diff --git a/echo/frontend/src/components/conversation/ConversationDangerZone.tsx b/echo/frontend/src/components/conversation/ConversationDangerZone.tsx index 198441ff7..9b5dbda0f 100644 --- a/echo/frontend/src/components/conversation/ConversationDangerZone.tsx +++ b/echo/frontend/src/components/conversation/ConversationDangerZone.tsx @@ -24,7 +24,7 @@ export const ConversationDangerZone = ({ }) => { const deleteConversationByIdMutation = useDeleteConversationByIdMutation(); const navigate = useI18nNavigate(); - const { projectId } = useParams(); + const { projectId, workspaceId } = useParams(); const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false); @@ -103,7 +103,7 @@ export const ConversationDangerZone = ({ console.warn("Analytics tracking failed:", error); } deleteConversationByIdMutation.mutate(conversation.id); - navigate(`/projects/${projectId}/overview`); + navigate(`/w/${workspaceId}/projects/${projectId}/overview`); closeConfirm(); }} /> diff --git a/echo/frontend/src/components/conversation/ConversationLink.tsx b/echo/frontend/src/components/conversation/ConversationLink.tsx index 9ec851310..ef9da25b4 100644 --- a/echo/frontend/src/components/conversation/ConversationLink.tsx +++ b/echo/frontend/src/components/conversation/ConversationLink.tsx @@ -1,5 +1,6 @@ import { Trans } from "@lingui/react/macro"; import { Anchor, Group, List, Stack } from "@mantine/core"; +import { useParams } from "react-router"; import { I18nLink } from "@/components/common/i18nLink"; interface ConversationLinkProps { @@ -19,6 +20,7 @@ export const ConversationLink = ({ conversation, projectId, }: ConversationLinkProps) => { + const { workspaceId } = useParams(); const linkingConversation = conversation .linking_conversations[0] as unknown as ConversationLink; const linkedConversations = @@ -39,7 +41,7 @@ export const ConversationLink = ({ ( +}: ConversationListProps) => { + const { workspaceId } = useParams(); + return ( {conversations.map((conversation, index) => ( @@ -55,7 +57,8 @@ const ConversationList = ({ ))} -); + ); +}; type ConversationsModalProps = { opened: boolean; @@ -118,7 +121,7 @@ export const ConversationLinks = ({ color?: string; hoverUnderlineColor?: string; }) => { - const { projectId } = useParams(); + const { projectId, workspaceId } = useParams(); const [modalOpened, setModalOpened] = useState(false); // an error could occur if the conversation is deleted and not filtered in ChatHistoryMessage.tsx @@ -140,7 +143,7 @@ export const ConversationLinks = ({ {visibleConversations.map((conversation) => ( diff --git a/echo/frontend/src/components/conversation/MoveConversationButton.tsx b/echo/frontend/src/components/conversation/MoveConversationButton.tsx index 90ccfc02d..08698c59f 100644 --- a/echo/frontend/src/components/conversation/MoveConversationButton.tsx +++ b/echo/frontend/src/components/conversation/MoveConversationButton.tsx @@ -99,9 +99,7 @@ export const MoveConversationButton = ({ onSuccess: () => { close(); navigate( - workspaceId - ? `/w/${workspaceId}/projects/${data.targetProjectId}/conversation/${conversation.id}/overview` - : `/projects/${data.targetProjectId}/conversation/${conversation.id}/overview`, + `/w/${workspaceId}/projects/${data.targetProjectId}/conversation/${conversation.id}/overview`, ); }, }, diff --git a/echo/frontend/src/components/conversation/OngoingConversationsSummaryCard.tsx b/echo/frontend/src/components/conversation/OngoingConversationsSummaryCard.tsx index 9b0328733..07a329fb5 100644 --- a/echo/frontend/src/components/conversation/OngoingConversationsSummaryCard.tsx +++ b/echo/frontend/src/components/conversation/OngoingConversationsSummaryCard.tsx @@ -52,8 +52,9 @@ export const OngoingConversationsSummaryCard = ({ w="100%" className="relative" > - {t`Ongoing Conversations`} + {t`Ongoing conversations`} } - loading={conversationChunksQuery.isFetching} + loading={conversationChunksQuery.isLoading} /> ); }; diff --git a/echo/frontend/src/components/conversation/OpenForParticipationSummaryCard.tsx b/echo/frontend/src/components/conversation/OpenForParticipationSummaryCard.tsx index e680a8ffe..523240b2c 100644 --- a/echo/frontend/src/components/conversation/OpenForParticipationSummaryCard.tsx +++ b/echo/frontend/src/components/conversation/OpenForParticipationSummaryCard.tsx @@ -38,7 +38,7 @@ export const OpenForParticipationSummaryCard = ({ } - label={t`Open for Participation?`} + label={t`Open for participation`} value={ { + const projectTag = tag.project_tag_id as ProjectTag | string | null; + return typeof projectTag === "object" && projectTag ? projectTag.text : null; +}; + +const hasTranscriptContent = (conversation: Conversation) => + conversation.chunks?.some((chunk) => { + const transcript = (chunk as ConversationChunk).transcript; + return typeof transcript === "string" && transcript.trim().length > 0; + }) ?? false; + +const hasVerifiedArtifacts = (conversation: Conversation) => + conversation.conversation_artifacts?.some( + (artifact) => (artifact as ConversationArtifact).approved_at, + ) ?? false; + +const formatCreatedAt = (createdAt: string | null) => { + if (!createdAt) return t`Unknown date`; + return t`${formatDistanceToNowStrict(new Date(createdAt), { + addSuffix: true, + })}`; +}; + +const ConversationSelectionCheckbox = ({ + conversation, + chatId, +}: { + conversation: Conversation; + chatId: string; +}) => { + const chatContextQuery = useProjectChatContext(chatId); + const addChatContextMutation = useAddChatContextMutation(); + const deleteChatContextMutation = useDeleteChatContextMutation(); + const isSelected = !!chatContextQuery.data?.conversations?.some( + (c) => c.conversation_id === conversation.id, + ); + const isChatLocked = !!chatContextQuery.data?.conversations?.some( + (c) => c.conversation_id === conversation.id && c.locked, + ); + const isOverCapLocked = !!conversation.locked; + const hasContent = hasTranscriptContent(conversation); + const isDisabled = isChatLocked || isOverCapLocked || !hasContent; + const isPending = + chatContextQuery.isLoading || + addChatContextMutation.isPending || + deleteChatContextMutation.isPending; + + const tooltipLabel = isOverCapLocked + ? t`Conversation locked. Upgrade to add it.` + : isChatLocked + ? t`Already used in this chat` + : !hasContent + ? t`This conversation has no transcript yet` + : isSelected + ? t`Remove from chat` + : t`Add to chat`; + + const handleChange = () => { + if (isPending) return; + if (isSelected) { + deleteChatContextMutation.mutate({ + chatId, + conversationId: conversation.id, + }); + return; + } + if (!isDisabled) { + addChatContextMutation.mutate({ + chatId, + conversationId: conversation.id, + }); + } + }; + + return ( + + + + + + ); +}; + +const MODE_COLOR = "blue"; + +type ConversationRowProps = { + conversation: Conversation & { live?: boolean }; + isActive?: boolean; + isSelected?: boolean; + onEdit: (conversation: Conversation) => void; + onOpen: (conversation: Conversation) => void; + selectionChatId?: string; + selectionMode?: boolean; +}; + +const ConversationRow = ({ + conversation, + isActive, + isSelected, + onEdit, + onOpen, + selectionChatId, + selectionMode, +}: ConversationRowProps) => { + const primary = + conversation.title?.trim() || + conversation.participant_name?.trim() || + t`Untitled conversation`; + const participantLabel = conversation.participant_name?.trim() || t`No name`; + const summary = conversation.summary?.trim(); + const tags = + (conversation.tags as ConversationProjectTag[] | undefined) ?? []; + const verified = hasVerifiedArtifacts(conversation); + + return ( + + + {selectionMode && selectionChatId && ( + + + + )} + + + + + + + {primary} + + {conversation.title && conversation.participant_name && ( + + + + )} + {verified && ( + + + + + + )} + {conversation.is_anonymized && ( + + + + + + )} + {conversation.locked && ( + + } + > + Locked + + + )} + + + + + + {participantLabel} + + + + {formatCreatedAt(conversation.created_at)} + + {conversation.live && ( + + Ongoing + + )} + + + + + + {!selectionMode && ( + + onEdit(conversation)} + > + + + + )} + + onOpen(conversation)} + > + + + + + + + + {summary || No summary yet} + + + {tags.length > 0 && ( + + {tags.map((tag) => { + const tagText = getTagText(tag); + if (!tagText) return null; + return ( + + {tagText} + + ); + })} + + )} + + + + ); +}; + +export const ProjectConversationsPanel = ({ + projectId, + workspaceId, + selectionChatId, + selectionMode = false, + showUpload = false, + maxHeight, +}: ProjectConversationsPanelProps) => { + const navigate = useI18nNavigate(); + const { ref: loadMoreRef, inView } = useInView(); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 200); + const [sortBy, setSortBy] = useState("-created_at"); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const [showOnlyVerified, setShowOnlyVerified] = useState(false); + const [selectAllModalOpened, setSelectAllModalOpened] = useState(false); + const [selectAllResult, setSelectAllResult] = + useState(null); + const [selectAllLoading, setSelectAllLoading] = useState(false); + const [editOpened, editHandlers] = useDisclosure(false); + const [editingConversation, setEditingConversation] = + useState(null); + const [editValues, setEditValues] = useState({ + participant_name: "", + summary: "", + title: "", + }); + + const projectQuery = useProjectById({ + projectId, + query: { + deep: { + tags: { + _sort: "sort", + }, + }, + fields: [ + "id", + "workspace_id", + { + tags: ["id", "text", "sort"], + }, + ], + }, + }); + const resolvedWorkspaceId = + workspaceId ?? + (projectQuery.data as { workspace_id?: string | null } | undefined) + ?.workspace_id ?? + null; + const { usageGates } = useWorkspaceUsage(resolvedWorkspaceId); + const updateConversationMutation = useUpdateConversationByIdMutation(); + const selectAllMutation = useSelectAllContextMutation(); + + const allProjectTags = useMemo( + () => + ((projectQuery.data as Project | undefined)?.tags as ProjectTag[]) ?? [], + [projectQuery.data], + ); + const tagOptions = useMemo(() => { + const options: { label: string; value: string }[] = []; + for (const tag of allProjectTags) { + if (tag.id && tag.text) { + options.push({ label: tag.text, value: tag.id }); + } + } + return options; + }, [allProjectTags]); + + const conversationQuery = useMemo( + () => + ({ + filter: { + project_id: { _eq: projectId }, + ...(selectedTagIds.length > 0 && { + tags: { + _some: { + project_tag_id: { + id: { _in: selectedTagIds }, + }, + }, + }, + }), + ...(showOnlyVerified && { + conversation_artifacts: { + _some: { + approved_at: { + _nnull: true, + }, + }, + }, + }), + }, + search: debouncedSearch, + sort: sortBy, + }) as Partial>, + [projectId, selectedTagIds, showOnlyVerified, debouncedSearch, sortBy], + ); + + const conversationsQuery = useInfiniteConversationsByProjectId( + projectId, + true, + false, + conversationQuery, + undefined, + { + initialLimit: selectionMode ? 12 : 20, + }, + ); + const conversationsCountQuery = useConversationsCountByProjectId( + projectId, + conversationQuery, + ); + + const allConversations = + conversationsQuery.data?.pages.flatMap((page) => page.conversations) ?? []; + + useEffect(() => { + if ( + inView && + conversationsQuery.hasNextPage && + !conversationsQuery.isFetchingNextPage + ) { + conversationsQuery.fetchNextPage(); + } + }, [ + inView, + conversationsQuery.hasNextPage, + conversationsQuery.isFetchingNextPage, + conversationsQuery.fetchNextPage, + ]); + + const chatContextQuery = useProjectChatContext(selectionChatId ?? ""); + const selectedConversationIds = useMemo( + () => + new Set( + (chatContextQuery.data?.conversations ?? []).map( + (c) => c.conversation_id, + ), + ), + [chatContextQuery.data?.conversations], + ); + const chatMode = chatContextQuery.data?.chat_mode; + const hasActiveFilters = + selectedTagIds.length > 0 || showOnlyVerified || debouncedSearch !== ""; + const selectedTagNames = useMemo(() => { + return selectedTagIds + .map((id) => allProjectTags.find((tag) => tag.id === id)?.text) + .filter(Boolean) as string[]; + }, [selectedTagIds, allProjectTags]); + + const remainingCountQuery = useRemainingConversationsCount( + projectId, + selectionChatId, + { + searchText: debouncedSearch || undefined, + tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined, + verifiedOnly: showOnlyVerified || undefined, + }, + { + enabled: + selectionMode && + ENABLE_CHAT_SELECT_ALL && + chatMode === "deep_dive" && + !!selectionChatId, + }, + ); + const remainingCount = + remainingCountQuery.data ?? + allConversations.filter((c) => !selectedConversationIds.has(c.id)).length; + + const openConversation = (conversation: Conversation) => { + if (!resolvedWorkspaceId) return; + navigate( + `/w/${resolvedWorkspaceId}/projects/${projectId}/conversation/${conversation.id}/overview`, + ); + }; + + const openEdit = (conversation: Conversation) => { + setEditingConversation(conversation); + setEditValues({ + participant_name: conversation.participant_name ?? "", + summary: conversation.summary ?? "", + title: conversation.title ?? "", + }); + editHandlers.open(); + }; + + const closeEdit = () => { + editHandlers.close(); + setEditingConversation(null); + }; + + const saveConversation = async () => { + if (!editingConversation) return; + try { + await updateConversationMutation.mutateAsync({ + id: editingConversation.id, + payload: { + participant_name: editValues.participant_name.trim(), + summary: editValues.summary.trim(), + title: editValues.title.trim(), + }, + }); + toast.success(t`Conversation saved`); + closeEdit(); + } catch (_error) { + toast.error(t`Failed to save conversation`); + } + }; + + const resetFilters = () => { + setSearch(""); + setSelectedTagIds([]); + setShowOnlyVerified(false); + setSortBy("-created_at"); + }; + + const handleSelectAllConfirm = async () => { + if (!selectionChatId) return; + setSelectAllLoading(true); + try { + const result = await selectAllMutation.mutateAsync({ + chatId: selectionChatId, + projectId, + searchText: debouncedSearch || undefined, + tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined, + verifiedOnly: showOnlyVerified || undefined, + }); + setSelectAllResult(result); + } catch (_error) { + toast.error(t`Failed to add conversations to context`); + setSelectAllModalOpened(false); + } finally { + setSelectAllLoading(false); + } + }; + + const activeFiltersCount = + selectedTagIds.length + (showOnlyVerified ? 1 : 0) + (search ? 1 : 0); + + return ( + + + + + + + <Trans>Conversations</Trans> + + {conversationsCountQuery.isLoading ? ( + + ) : ( + + {conversationsCountQuery.data ?? 0} + + )} + + + {selectionMode ? ( + + Search and choose the conversations for this chat. + + ) : ( + + Review, edit, and open every conversation in this project. + + )} + + + + {showUpload && ( + + {usageGates.uploads_locked ? ( + + + + ) : ( + + )} + + )} + + + {showUpload && usageGates.uploads_locked && resolvedWorkspaceId && ( + + )} + + {selectionMode && + ENABLE_CHAT_AUTO_SELECT && + chatMode !== "overview" && + (conversationsCountQuery.data ?? 0) > 0 && ( + + )} + + + + } + rightSection={ + search ? ( + setSearch("")} + > + + + ) : undefined + } + value={search} + onChange={(event) => setSearch(event.currentTarget.value)} + style={{ flex: "1 1 260px" }} + {...testId("project-conversations-search")} + /> +