From 6a4e5a3bb239642c022cd9b2b91f163c9f33bcb8 Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:12:16 +0800 Subject: [PATCH] feat(frontend): add side conversations for quoted follow-ups (#3934) * feat(frontend): add side conversations for quoted follow-ups * style(frontend): apply prettier formatting to sidecar-chat files * fix(frontend): surface sidecar cascade cleanup failures via console.warn Previously deleteSidecarThreadsForParent silently swallowed both lookup errors and per-thread deletion failures, so parent thread deletions could succeed while orphaning sidecar threads with no signal to the caller. Log a warning that includes the parent id and the failed thread ids/reasons so the leak is discoverable in telemetry, matching the existing console.warn/error pattern in this file. * fix(frontend): address all sidecar review feedback Resolve every reviewer comment on PR #3934: - input-box/hooks/sidecar-panel: clear quoted references only via an `onSent` callback that fires after the in-flight guard, so a dropped send no longer silently discards quotes (willem-bd #3550). - message-list: flip the selection toolbar below the selection when it would clip above the viewport (willem-bd #3551). - reference-metadata/thread/input-box: keep referenced ids, roles, and count arrays 1:1 parallel instead of deduping ids (willem-bd #3552). - message-list: widen selection containment to the shared assistant-turn container and hint when a selection crosses messages (willem-bd #3553). - sidecar/api: coalesce concurrent sidecar creates for one parent behind a single in-flight promise to prevent duplicates (willem-bd #3554). - sidecar-trigger/context: force-restore on trigger click so a sidecar deleted elsewhere self-heals instead of opening a dead thread (willem-bd #3555). - threads/hooks: surface sidecar cascade cleanup failures via console.warn for both lookup and per-thread deletes (Copilot). Add unit + e2e coverage for parallel metadata, atomic create, and trigger self-healing. --- frontend/playwright.config.ts | 28 +- .../[agent_name]/chats/[thread_id]/page.tsx | 300 ++-- .../app/workspace/chats/[thread_id]/page.tsx | 287 ++-- .../workspace/artifacts/artifact-trigger.tsx | 4 + .../components/workspace/chats/chat-box.tsx | 100 +- .../src/components/workspace/input-box.tsx | 127 +- .../workspace/messages/message-list-item.tsx | 23 +- .../workspace/messages/message-list.tsx | 665 ++++++--- .../components/workspace/sidecar/context.tsx | 286 ++++ .../src/components/workspace/sidecar/index.ts | 4 + .../sidecar/reference-attachments.tsx | 98 ++ .../workspace/sidecar/sidecar-panel.tsx | 817 +++++++++++ .../workspace/sidecar/sidecar-trigger.tsx | 64 + frontend/src/core/i18n/locales/en-US.ts | 22 + frontend/src/core/i18n/locales/types.ts | 21 + frontend/src/core/i18n/locales/zh-CN.ts | 24 +- frontend/src/core/messages/usage.ts | 18 +- frontend/src/core/sidecar/api.ts | 100 ++ frontend/src/core/sidecar/context.ts | 212 +++ frontend/src/core/sidecar/index.ts | 4 + .../src/core/sidecar/reference-metadata.ts | 79 + frontend/src/core/sidecar/reference-state.ts | 55 + frontend/src/core/sidecar/thread.ts | 64 + frontend/src/core/threads/hooks.ts | 299 +++- .../src/core/threads/thread-search-query.ts | 31 +- frontend/tests/e2e/sidecar-chat.spec.ts | 1270 +++++++++++++++++ frontend/tests/e2e/utils/mock-api.ts | 138 +- .../tests/unit/core/messages/usage.test.ts | 21 + frontend/tests/unit/core/sidecar/api.test.ts | 150 ++ .../tests/unit/core/sidecar/context.test.ts | 175 +++ .../core/sidecar/reference-metadata.test.ts | 75 + .../unit/core/sidecar/reference-state.test.ts | 52 + .../tests/unit/core/sidecar/thread.test.ts | 106 ++ .../unit/core/threads/delete-thread.test.ts | 55 + .../tests/unit/core/threads/infinite.test.ts | 69 +- .../unit/core/threads/send-message.test.ts | 62 + .../core/threads/thread-search-query.test.ts | 54 + 37 files changed, 5353 insertions(+), 606 deletions(-) create mode 100644 frontend/src/components/workspace/sidecar/context.tsx create mode 100644 frontend/src/components/workspace/sidecar/index.ts create mode 100644 frontend/src/components/workspace/sidecar/reference-attachments.tsx create mode 100644 frontend/src/components/workspace/sidecar/sidecar-panel.tsx create mode 100644 frontend/src/components/workspace/sidecar/sidecar-trigger.tsx create mode 100644 frontend/src/core/sidecar/api.ts create mode 100644 frontend/src/core/sidecar/context.ts create mode 100644 frontend/src/core/sidecar/index.ts create mode 100644 frontend/src/core/sidecar/reference-metadata.ts create mode 100644 frontend/src/core/sidecar/reference-state.ts create mode 100644 frontend/src/core/sidecar/thread.ts create mode 100644 frontend/tests/e2e/sidecar-chat.spec.ts create mode 100644 frontend/tests/unit/core/sidecar/api.test.ts create mode 100644 frontend/tests/unit/core/sidecar/context.test.ts create mode 100644 frontend/tests/unit/core/sidecar/reference-metadata.test.ts create mode 100644 frontend/tests/unit/core/sidecar/reference-state.test.ts create mode 100644 frontend/tests/unit/core/sidecar/thread.test.ts create mode 100644 frontend/tests/unit/core/threads/delete-thread.test.ts create mode 100644 frontend/tests/unit/core/threads/send-message.test.ts diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 76f816ee8..3f5afebe1 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,5 +1,8 @@ import { defineConfig, devices } from "@playwright/test"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000"; +const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEB_SERVER === "1"; + export default defineConfig({ testDir: "./tests/e2e", fullyParallel: true, @@ -10,7 +13,7 @@ export default defineConfig({ timeout: 30_000, use: { - baseURL: "http://localhost:3000", + baseURL, locale: "en-US", trace: "on-first-retry", }, @@ -22,14 +25,17 @@ export default defineConfig({ }, ], - webServer: { - command: "./node_modules/.bin/next build && ./node_modules/.bin/next start", - url: "http://localhost:3000", - reuseExistingServer: !process.env.CI, - timeout: 120_000, - env: { - SKIP_ENV_VALIDATION: "1", - DEER_FLOW_AUTH_DISABLED: "1", - }, - }, + webServer: skipWebServer + ? undefined + : { + command: + "./node_modules/.bin/next build && ./node_modules/.bin/next start", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + SKIP_ENV_VALIDATION: "1", + DEER_FLOW_AUTH_DISABLED: "1", + }, + }, }); diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 09867e7a0..d725bf3e4 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -12,12 +12,19 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ExportTrigger } from "@/components/workspace/export-trigger"; import { GoalStatus } from "@/components/workspace/goal-status"; -import { InputBox } from "@/components/workspace/input-box"; +import { + InputBox, + type InputBoxSubmitOptions, +} from "@/components/workspace/input-box"; import { MessageList, MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; +import { + SidecarProvider, + SidecarTrigger, +} from "@/components/workspace/sidecar"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; @@ -146,8 +153,13 @@ export default function AgentChatPage() { ]); const handleSubmit = useCallback( - (message: PromptInputMessage) => { - const sendPromise = sendMessage(threadId, message, { agent_name }); + (message: PromptInputMessage, options?: InputBoxSubmitOptions) => { + const sendPromise = sendMessage( + threadId, + message, + { agent_name }, + options, + ); if (message.files.length > 0) { return sendPromise; } @@ -170,156 +182,166 @@ export default function AgentChatPage() { ); return ( - - -
-
- - {/* Agent badge */} -
- - - {agent?.name ?? agent_name} - -
- -
- -
-
- - - - - setLocalSettings("tokenUsage", preferences) - } - /> - - -
-
- -
-
- -
- -
+ + +
+
+ + {/* Agent badge */} +
+ + + {agent?.name ?? agent_name} + +
+ +
+ +
+
+ + + + + setLocalSettings("tokenUsage", preferences) + } + /> + + + +
+
+ +
+
+ +
+
- {(hasGoal || hasTodos) && ( -
+
+ {(hasGoal || hasTodos) && (
- {activeGoal && } - {hasTodos && ( -
-
- )} - - - ) - } - disabled={ - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || - isUploading - } - onContextChange={(context) => setSettings("context", context)} - onGoalChange={setLocalGoal} - onSubmit={handleSubmit} - onStop={handleStop} - /> - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( -
- {t.common.notAvailableInDemoMode} -
- )} + + + ) + } + disabled={ + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || + isUploading + } + onContextChange={(context) => + setSettings("context", context) + } + onGoalChange={setLocalGoal} + onSubmit={handleSubmit} + onStop={handleStop} + /> + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( +
+ {t.common.notAvailableInDemoMode} +
+ )} +
-
-
-
-
+ + + +
); } diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 368ffcbf8..909d9c57a 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -13,12 +13,19 @@ import { } from "@/components/workspace/chats"; import { ExportTrigger } from "@/components/workspace/export-trigger"; import { GoalStatus } from "@/components/workspace/goal-status"; -import { InputBox } from "@/components/workspace/input-box"; +import { + InputBox, + type InputBoxSubmitOptions, +} from "@/components/workspace/input-box"; import { MessageList, MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; +import { + SidecarProvider, + SidecarTrigger, +} from "@/components/workspace/sidecar"; import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; @@ -151,8 +158,8 @@ export default function ChatPage() { ]); const handleSubmit = useCallback( - (message: PromptInputMessage) => { - const sendPromise = sendMessage(threadId, message); + (message: PromptInputMessage, options?: InputBoxSubmitOptions) => { + const sendPromise = sendMessage(threadId, message, undefined, options); if (message.files.length > 0) { return sendPromise; } @@ -180,151 +187,161 @@ export default function ChatPage() { return ( - -
-
- -
- -
-
- {!isNewThread && } - - setLocalSettings("tokenUsage", preferences) - } - /> - - -
-
-
-
- -
-
+ +
+
+ +
+ +
+
+ {!isNewThread && ( + + )} + + setLocalSettings("tokenUsage", preferences) + } + /> + + + +
+
+
+
+ +
- {(hasGoal || hasTodos) && ( -
+
+ {(hasGoal || hasTodos) && (
- {activeGoal && } - {hasTodos && ( -
-
- )} - {mountedRef.current ? ( - - } - disabled={ - isMock || - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || - isUploading - } - onContextChange={(context) => - setSettings("context", context) - } - onGoalChange={setLocalGoal} - onSubmit={handleSubmit} - onStop={handleStop} - /> - ) : ( - -
-
-
-
+
+
+
+
); } diff --git a/frontend/src/components/workspace/artifacts/artifact-trigger.tsx b/frontend/src/components/workspace/artifacts/artifact-trigger.tsx index df1fe6846..40d353559 100644 --- a/frontend/src/components/workspace/artifacts/artifact-trigger.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-trigger.tsx @@ -4,11 +4,14 @@ import { Button } from "@/components/ui/button"; import { Tooltip } from "@/components/workspace/tooltip"; import { useI18n } from "@/core/i18n/hooks"; +import { useMaybeSidecar } from "../sidecar/context"; + import { useArtifacts } from "./context"; export const ArtifactTrigger = () => { const { t } = useI18n(); const { artifacts, setOpen: setArtifactsOpen } = useArtifacts(); + const sidecar = useMaybeSidecar(); if (!artifacts || artifacts.length === 0) { return null; @@ -19,6 +22,7 @@ export const ArtifactTrigger = () => { className="text-muted-foreground hover:text-foreground" variant="ghost" onClick={() => { + sidecar?.close(); setArtifactsOpen(true); }} > diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index 628ed3211..6fa8c97bd 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -1,15 +1,9 @@ import { FilesIcon, XIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; -import type { GroupImperativeHandle } from "react-resizable-panels"; import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; import { env } from "@/env"; import { cn } from "@/lib/utils"; @@ -19,9 +13,11 @@ import { useArtifacts, } from "../artifacts"; import { useThread } from "../messages/context"; +import { SidecarPanel, useMaybeSidecar } from "../sidecar"; -const CLOSE_MODE = { chat: 100, artifacts: 0 }; -const OPEN_MODE = { chat: 60, artifacts: 40 }; +const RIGHT_PANEL_ANIMATION_MS = 280; + +type RightPanelKind = "sidecar" | "artifacts"; const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ children, @@ -30,7 +26,6 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ const { thread } = useThread(); const pathname = usePathname(); const threadIdRef = useRef(threadId); - const layoutRef = useRef(null); const { artifacts, @@ -41,6 +36,8 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ deselect, selectedArtifact, } = useArtifacts(); + const sidecar = useMaybeSidecar(); + const sidecarOpen = sidecar?.open ?? false; const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); useEffect(() => { @@ -87,63 +84,94 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ ]); const artifactPanelOpen = useMemo(() => { + if (sidecarOpen) { + return false; + } if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { return artifactsOpen && artifacts?.length > 0; } return artifactsOpen; - }, [artifactsOpen, artifacts]); + }, [artifactsOpen, artifacts, sidecarOpen]); + + const activeRightPanel: RightPanelKind | null = sidecarOpen + ? "sidecar" + : artifactPanelOpen + ? "artifacts" + : null; + const rightPanelOpen = activeRightPanel !== null; + const [renderedRightPanel, setRenderedRightPanel] = + useState(activeRightPanel); const resizableIdBase = useMemo(() => { return pathname.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); }, [pathname]); useEffect(() => { - if (layoutRef.current) { - if (artifactPanelOpen) { - layoutRef.current.setLayout(OPEN_MODE); - } else { - layoutRef.current.setLayout(CLOSE_MODE); - } + if (activeRightPanel) { + setRenderedRightPanel(activeRightPanel); + return; } - }, [artifactPanelOpen]); + + const timeout = window.setTimeout(() => { + setRenderedRightPanel(null); + }, RIGHT_PANEL_ANIMATION_MS); + + return () => { + window.clearTimeout(timeout); + }; + }, [activeRightPanel]); + + useEffect(() => { + if (sidecarOpen && artifactsOpen) { + setArtifactsOpen(false); + } + }, [artifactsOpen, setArtifactsOpen, sidecarOpen]); return ( - - +
{children} - - + ); }; diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 1d53c00f0..fcd4cb016 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -1,5 +1,6 @@ "use client"; +import type { Message } from "@langchain/langgraph-sdk"; import type { ChatStatus } from "ai"; import { CheckIcon, @@ -37,6 +38,7 @@ import { PromptInputBody, PromptInputButton, PromptInputFooter, + PromptInputHeader, PromptInputSubmit, PromptInputTextarea, PromptInputTools, @@ -64,6 +66,10 @@ import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { useModels } from "@/core/models/hooks"; +import { + buildReferenceMessageMetadata, + type SidecarContext, +} from "@/core/sidecar"; import { useSkills } from "@/core/skills/hooks"; import { useSuggestionsConfig } from "@/core/suggestions/hooks"; import type { AgentThreadContext, GoalState } from "@/core/threads"; @@ -112,6 +118,7 @@ import { } from "./input-box-helpers"; import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; +import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar"; import { Tooltip } from "./tooltip"; type InputMode = "flash" | "thinking" | "pro" | "ultra"; @@ -129,6 +136,68 @@ function getResolvedMode( return supportsThinking ? "pro" : "flash"; } +function escapeXmlAttribute(value: string) { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +export type InputBoxSubmitOptions = { + additionalKwargs?: Record; + additionalInputMessages?: Message[]; + onSent?: () => void; +}; + +function buildHiddenConversationQuoteMessage({ + contexts, +}: { + contexts: SidecarContext[]; +}): Message { + return { + type: "human", + content: [ + { + type: "text", + text: [ + contexts.length === 1 + ? "The user added the following quoted context to this conversation." + : `The user added the following ${contexts.length} quoted contexts to this conversation.`, + "Use the referenced_message blocks as reference material for the user's next message.", + "", + ...contexts.flatMap((context, index) => + [ + ``, + `Role: ${context.role === "user" ? "User" : "Assistant"}`, + context.messageId ? `Message ID: ${context.messageId}` : null, + "", + context.content, + "", + "", + ].filter((line): line is string => line !== null), + ), + ] + .filter((line): line is string => line !== null) + .join("\n"), + }, + ], + additional_kwargs: { + hide_from_ui: true, + conversation_quote_context: true, + // Keep ids/roles/count 1:1 parallel with `contexts` so consumers can zip + // them safely; do not dedupe ids here. + referenced_message_ids: contexts.map( + (context) => context.messageId ?? "", + ), + referenced_message_roles: contexts.map((context) => context.role), + quote_context_count: contexts.length, + }, + } as Message; +} + export function InputBox({ className, disabled, @@ -176,7 +245,10 @@ export function InputBox({ ) => void; onFollowupsVisibilityChange?: (visible: boolean) => void; onGoalChange?: (goal: GoalState | null) => void; - onSubmit?: (message: PromptInputMessage) => void | Promise; + onSubmit?: ( + message: PromptInputMessage, + options?: InputBoxSubmitOptions, + ) => void | Promise; onStop?: () => void; }) { const { t } = useI18n(); @@ -185,6 +257,7 @@ export function InputBox({ const { models } = useModels(); const { thread, isMock } = useThread(); const { attachments, textInput } = usePromptInputController(); + const sidecar = useMaybeSidecar(); const attachmentParts = attachments.files; const removeAttachment = attachments.remove; const { skills } = useSkills(); @@ -559,6 +632,26 @@ export function InputBox({ setFollowups([]); setFollowupsHidden(false); setFollowupsLoading(false); + const quotes = sidecar?.conversationQuotes ?? []; + const quoteIds = quotes.map((quote) => quote.id); + const quoteContexts = quotes.map((quote) => quote.context); + const submitOptions: InputBoxSubmitOptions | undefined = quotes.length + ? { + additionalKwargs: buildReferenceMessageMetadata(quoteContexts), + additionalInputMessages: [ + buildHiddenConversationQuoteMessage({ + contexts: quoteContexts, + }), + ], + // Clear quotes only once the send genuinely proceeds. If the send + // is dropped by the in-flight guard, `onSent` never fires and the + // quotes stay attached so they aren't silently lost. + onSent: () => { + sidecar?.clearConversationQuotes(quoteIds); + }, + } + : undefined; + const submit = () => onSubmit?.(message, submitOptions); // Guard against submitting before the initial model auto-selection // effect has flushed thread settings to storage/state. @@ -573,12 +666,12 @@ export function InputBox({ }); return new Promise((resolve, reject) => { setTimeout(() => { - Promise.resolve(onSubmit?.(message)).then(resolve).catch(reject); + Promise.resolve(submit()).then(resolve).catch(reject); }, 0); }); } - return onSubmit?.(message); + return submit(); }, [ context, @@ -587,6 +680,7 @@ export function InputBox({ reportUploadLimitViolations, resolvedModelName, selectedModel?.supports_thinking, + sidecar, t.inputBox.suggestionPlaceholderRequired, uploadLimits, ], @@ -629,7 +723,13 @@ export function InputBox({ } return submitThreadMessage(message); }, - [handleGoalCommand, onStop, status, submitThreadMessage], + [ + handleGoalCommand, + onStop, + status, + submitThreadMessage, + t.inputBox.pleaseWaitStreaming, + ], ); const requestFormSubmit = useCallback(() => { @@ -1073,9 +1173,22 @@ export function InputBox({
)} - - {(attachment) => } - + + + {(attachment) => ( +
+ +
+ )} +
+ {sidecar && sidecar.conversationQuotes.length > 0 && ( + sidecar.clearConversationQuotes()} + /> + )} +
{ @@ -254,7 +256,7 @@ function MessageContent_({ setCachedDuration(d); } }, - [message.id], + [message.id, threadId], ); useEffect(() => { @@ -295,6 +297,16 @@ function MessageContent_({ } return files as FileInMessage[]; }, [message.additional_kwargs?.files, rawContent]); + const referenceAttachments = useMemo( + () => + readReferenceMessageContexts(message.additional_kwargs).map( + (context, index) => ({ + id: index, + context, + }), + ), + [message.additional_kwargs], + ); const contentToDisplay = useMemo(() => { if (isHuman) { @@ -357,6 +369,13 @@ function MessageContent_({ className, )} > + {referenceAttachments.length > 0 && ( + + )} {filesList} {contentToDisplay && ( diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 2d979352e..8feedf1f9 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -1,11 +1,26 @@ import type { Message } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk/react"; -import { ChevronUpIcon, Loader2Icon, RefreshCcwIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ChevronUpIcon, + Loader2Icon, + MessageCircleIcon, + MessageSquarePlusIcon, + RefreshCcwIcon, +} from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent, +} from "react"; +import { toast } from "sonner"; import { Conversation, ConversationContent, + type ConversationProps, } from "@/components/ai-elements/conversation"; import { Reasoning, @@ -31,6 +46,10 @@ import { isAssistantMessageGroupStreaming, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { + buildMessageSidecarContext, + type SidecarContext, +} from "@/core/sidecar"; import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import { @@ -42,7 +61,7 @@ import { cn } from "@/lib/utils"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { CopyButton } from "../copy-button"; -import { StreamingIndicator } from "../streaming-indicator"; +import { useMaybeSidecar } from "../sidecar/context"; import { Tooltip } from "../tooltip"; import { MarkdownContent } from "./markdown-content"; @@ -59,6 +78,19 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 24; const LOAD_MORE_HISTORY_THROTTLE_MS = 1200; +const SELECTION_TOOLBAR_MARGIN = 8; +// Approximate rendered height of the pill (p-1 padding + h-8 button). Used only +// to decide whether the toolbar fits above the selection; exact height isn't +// needed because we flip below when space is tight. +const SELECTION_TOOLBAR_ESTIMATED_HEIGHT = 48; + +type SelectionToolbarState = { + context: SidecarContext; + x: number; + y: number; + placement: "top" | "bottom"; +}; + function LoadMoreHistoryIndicator({ isLoading, hasMore, @@ -166,6 +198,7 @@ function LoadMoreHistoryIndicator({ export function MessageList({ className, + testId, threadId, thread, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, @@ -175,8 +208,12 @@ export function MessageList({ isHistoryLoading, onRegenerateMessage, canRegenerate = false, + enableSidecarActions = true, + initialScroll = "smooth", + resizeScroll = "smooth", }: { className?: string; + testId?: string; threadId: string; thread: BaseStream; paddingBottom?: number; @@ -189,8 +226,14 @@ export function MessageList({ supersededMessageIds: string[], ) => void | Promise; canRegenerate?: boolean; + enableSidecarActions?: boolean; + initialScroll?: ConversationProps["initial"]; + resizeScroll?: ConversationProps["resize"]; }) { const { t } = useI18n(); + const sidecar = useMaybeSidecar(); + const [selectionToolbar, setSelectionToolbar] = + useState(null); const [turnStartTime, setTurnStartTime] = useState(null); const prevIsLoading = useRef(thread.isLoading); @@ -250,6 +293,133 @@ export function MessageList({ return null; }, [groupedMessages, thread.isLoading]); + const clearSelectionToolbar = useCallback(() => { + setSelectionToolbar(null); + }, []); + + useEffect(() => { + if (!selectionToolbar) { + return; + } + + const hideOnScroll = () => { + setSelectionToolbar(null); + }; + const hideOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setSelectionToolbar(null); + } + }; + + window.addEventListener("scroll", hideOnScroll, true); + document.addEventListener("keydown", hideOnEscape); + return () => { + window.removeEventListener("scroll", hideOnScroll, true); + document.removeEventListener("keydown", hideOnEscape); + }; + }, [selectionToolbar]); + + const handleAssistantTextSelection = useCallback( + ( + event: MouseEvent, + message: Message, + displayIndex: number, + ) => { + if ( + !enableSidecarActions || + thread.isLoading || + !sidecar || + message.type !== "ai" + ) { + return; + } + + const selection = window.getSelection(); + const selectedText = selection?.toString().trim(); + if ( + !selection || + selection.isCollapsed || + !selectedText || + selection.rangeCount === 0 + ) { + setSelectionToolbar(null); + return; + } + + if (!selection.anchorNode || !selection.focusNode) { + return; + } + + // Widen containment to the shared assistant-turn container so a selection + // that spans multiple AI messages within the same turn still yields a + // toolbar (#3553). Fall back to the per-message wrapper if the turn + // container can't be found. + const turnContainer = + event.currentTarget.closest("[data-assistant-turn]") ?? + event.currentTarget; + if (!turnContainer.contains(selection.anchorNode)) { + return; + } + if (!turnContainer.contains(selection.focusNode)) { + // The selection leaked into another turn/message; the quote would be + // ambiguous, so surface a hint instead of failing silently. + toast.info(t.sidecar.selectionCrossesMessages); + setSelectionToolbar(null); + return; + } + + const nextContext = buildMessageSidecarContext(message, displayIndex, { + selectedText, + }); + if (!nextContext) { + return; + } + + // The pill is rendered with `-translate-y-full`, so anchoring it at + // `rect.top` moves it up by its own height. When the selection sits near + // the viewport top there isn't room above, so flip it below the selection + // to keep both actions reachable (#3551). + const rect = selection.getRangeAt(0).getBoundingClientRect(); + const fitsAbove = + rect.top - + SELECTION_TOOLBAR_MARGIN - + SELECTION_TOOLBAR_ESTIMATED_HEIGHT >= + 0; + setSelectionToolbar({ + context: nextContext, + x: rect.left + rect.width / 2, + y: fitsAbove + ? rect.top - SELECTION_TOOLBAR_MARGIN + : rect.bottom + SELECTION_TOOLBAR_MARGIN, + placement: fitsAbove ? "top" : "bottom", + }); + }, + [ + enableSidecarActions, + sidecar, + t.sidecar.selectionCrossesMessages, + thread.isLoading, + ], + ); + + const handleAddSelectionToConversation = useCallback(() => { + if (!selectionToolbar) { + return; + } + sidecar?.addContextToConversation(selectionToolbar.context); + window.getSelection()?.removeAllRanges(); + setSelectionToolbar(null); + }, [selectionToolbar, sidecar]); + + const handleAskSelectionInSidecar = useCallback(() => { + if (!selectionToolbar) { + return; + } + sidecar?.openContext(selectionToolbar.context); + window.getSelection()?.removeAllRanges(); + setSelectionToolbar(null); + }, [selectionToolbar, sidecar]); + const renderAssistantActions = useCallback( ( messages: Message[], @@ -264,7 +434,6 @@ export function MessageList({ .filter((message) => message.type === "ai" && message.id) .map((message) => message.id) .filter((id): id is string => typeof id === "string"); - if (!clipboardData && !regenerateTarget) { return null; } @@ -370,237 +539,315 @@ export function MessageList({ } return ( - - - - {groupedMessages.map((group, groupIndex) => { - const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex]; - const groupIsLoading = - thread.isLoading && groupIndex === lastGroupIndex; + <> + + + + {groupedMessages.map((group, groupIndex) => { + const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex]; + const groupIsLoading = + thread.isLoading && groupIndex === lastGroupIndex; - if (group.type === "human" || group.type === "assistant") { - return ( -
- {group.messages.map((msg) => { - return ( - - ); - })} - {renderTokenUsage({ - messages: group.messages, - turnUsageMessages, - })} - {group.type === "assistant" && - renderAssistantActions( - group.messages, - isAssistantMessageGroupStreaming( - group.messages, - streamingMessages, - ), - group.id === latestAssistantGroupId, - )} -
- ); - } else if (group.type === "assistant:clarification") { - const message = group.messages[0]; - if (message && hasContent(message)) { + if (group.type === "human" || group.type === "assistant") { return ( -
- +
+ {group.messages.map((msg) => { + const item = ( + + ); + + if ( + group.type !== "assistant" || + !enableSidecarActions || + msg.type !== "ai" + ) { + return
{item}
; + } + + return ( +
+ handleAssistantTextSelection( + event, + msg, + groupIndex + 1, + ) + } + > + {item} +
+ ); + })} + {renderTokenUsage({ + messages: group.messages, + turnUsageMessages, + })} + {group.type === "assistant" && + renderAssistantActions( + group.messages, + isAssistantMessageGroupStreaming( + group.messages, + streamingMessages, + ), + group.id === latestAssistantGroupId, + )} +
+ ); + } else if (group.type === "assistant:clarification") { + const message = group.messages[0]; + if (message && hasContent(message)) { + return ( +
+ + {renderTokenUsage({ + messages: group.messages, + turnUsageMessages, + })} +
+ ); + } + return null; + } else if (group.type === "assistant:present-files") { + const files: string[] = []; + for (const message of group.messages) { + if (hasPresentFiles(message)) { + const presentFiles = extractPresentFilesFromMessage(message); + files.push(...presentFiles); + } + } + return ( +
+ {group.messages[0] && hasContent(group.messages[0]) && ( + + )} + {renderTokenUsage({ messages: group.messages, turnUsageMessages, })}
); - } - return null; - } else if (group.type === "assistant:present-files") { - const files: string[] = []; - for (const message of group.messages) { - if (hasPresentFiles(message)) { - const presentFiles = extractPresentFilesFromMessage(message); - files.push(...presentFiles); - } - } - return ( -
- {group.messages[0] && hasContent(group.messages[0]) && ( - - )} - - {renderTokenUsage({ - messages: group.messages, - turnUsageMessages, - })} -
- ); - } else if (group.type === "assistant:subagent") { - const tasks = new Set(); - for (const message of group.messages) { - if (message.type === "ai") { - for (const toolCall of message.tool_calls ?? []) { - if (toolCall.name === "task") { - const taskId = toolCall.id; - if (!taskId) { - continue; + } else if (group.type === "assistant:subagent") { + const tasks = new Set(); + for (const message of group.messages) { + if (message.type === "ai") { + for (const toolCall of message.tool_calls ?? []) { + if (toolCall.name === "task") { + const taskId = toolCall.id; + if (!taskId) { + continue; + } + const status = derivePendingSubtaskStatus( + taskId, + group.messages, + groupIsLoading, + ); + const task: Subtask = { + id: taskId, + subagent_type: toolCall.args.subagent_type, + description: toolCall.args.description, + prompt: toolCall.args.prompt, + status, + ...(status === "failed" + ? { error: t.subtasks.failed } + : {}), + }; + updateSubtask(task); + tasks.add(task); } - const status = derivePendingSubtaskStatus( - taskId, - group.messages, - groupIsLoading, + } + } else if (message.type === "tool") { + const taskId = message.tool_call_id; + if (taskId) { + const parsed = parseSubtaskResult( + extractTextFromMessage(message), + message.additional_kwargs, ); - const task: Subtask = { - id: taskId, - subagent_type: toolCall.args.subagent_type, - description: toolCall.args.description, - prompt: toolCall.args.prompt, - status, - ...(status === "failed" - ? { error: t.subtasks.failed } - : {}), - }; - updateSubtask(task); - tasks.add(task); + updateSubtask({ id: taskId, ...parsed }); } } - } else if (message.type === "tool") { - const taskId = message.tool_call_id; - if (taskId) { - const parsed = parseSubtaskResult( - extractTextFromMessage(message), - message.additional_kwargs, + } + + const results: React.ReactNode[] = []; + const subagentDebugMessageIds: string[] = []; + if (tasks.size > 0) { + results.push( +
+ {t.subtasks.executing(tasks.size)} +
, + ); + } + for (const message of group.messages.filter( + (message) => message.type === "ai", + )) { + if (hasReasoning(message)) { + results.push( + step.messageId === message.id, + )} + showTokenDebugSummaries={ + tokenUsageInlineMode === "step_debug" + } + />, + ); + } else if (message.id) { + subagentDebugMessageIds.push(message.id); + } + const taskIds = message.tool_calls?.flatMap((toolCall) => + toolCall.name === "task" && toolCall.id ? [toolCall.id] : [], + ); + for (const taskId of taskIds ?? []) { + results.push( + , ); - updateSubtask({ id: taskId, ...parsed }); } } - } - - const results: React.ReactNode[] = []; - const subagentDebugMessageIds: string[] = []; - if (tasks.size > 0) { - results.push( + return (
- {t.subtasks.executing(tasks.size)} -
, + {results} + {renderTokenUsage({ + messages: group.messages, + turnUsageMessages, + debugMessageIds: subagentDebugMessageIds, + })} +
); } - for (const message of group.messages.filter( - (message) => message.type === "ai", - )) { - if (hasReasoning(message)) { - results.push( - step.messageId === message.id, - )} - showTokenDebugSummaries={ - tokenUsageInlineMode === "step_debug" - } - />, - ); - } else if (message.id) { - subagentDebugMessageIds.push(message.id); - } - const taskIds = message.tool_calls?.flatMap((toolCall) => - toolCall.name === "task" && toolCall.id ? [toolCall.id] : [], - ); - for (const taskId of taskIds ?? []) { - results.push( - , - ); - } - } return ( -
- {results} +
+ + group.messages.some( + (message) => message.id === step.messageId, + ), + )} + showTokenDebugSummaries={ + tokenUsageInlineMode === "step_debug" + } + /> {renderTokenUsage({ messages: group.messages, turnUsageMessages, - debugMessageIds: subagentDebugMessageIds, + inlineDebug: false, })}
); - } - return ( -
- - group.messages.some( - (message) => message.id === step.messageId, - ), - )} - showTokenDebugSummaries={tokenUsageInlineMode === "step_debug"} - /> - {renderTokenUsage({ - messages: group.messages, - turnUsageMessages, - inlineDebug: false, - })} + })} + {thread.isLoading && !hasActiveAssistantText && ( +
+ + +
- ); - })} - {thread.isLoading && !hasActiveAssistantText && ( -
- - - -
- )} -
- - + )} +
+ + + {selectionToolbar && sidecar && ( +
+ + + +
+ )} + ); } diff --git a/frontend/src/components/workspace/sidecar/context.tsx b/frontend/src/components/workspace/sidecar/context.tsx new file mode 100644 index 000000000..ff2eb78e9 --- /dev/null +++ b/frontend/src/components/workspace/sidecar/context.tsx @@ -0,0 +1,286 @@ +"use client"; + +import type { Message } from "@langchain/langgraph-sdk"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { + appendSidecarReference, + buildMessageSidecarContext, + getNextSidecarOpenState, + type SidecarContext, + type SidecarReferenceStateItem, +} from "@/core/sidecar"; +import { findLatestSidecarThread } from "@/core/sidecar/api"; +import type { ThreadStreamOptions } from "@/core/threads/hooks"; + +export type SidecarReference = SidecarReferenceStateItem; + +type SidecarContextValue = { + open: boolean; + activeReferences: SidecarReference[]; + conversationQuotes: SidecarReference[]; + parentThreadId: string; + context: ThreadStreamOptions["context"]; + setContext: (context: ThreadStreamOptions["context"]) => void; + isMock?: boolean; + sidecarThreadId: string | null; + setSidecarThreadId: (threadId: string | null) => void; + restoreSidecarThread: (options?: { + force?: boolean; + }) => Promise; + addContextToConversation: (context: SidecarContext) => void; + clearConversationQuotes: (ids?: number[]) => void; + clearActiveReferences: () => void; + openSidecar: () => void; + openContext: (context: SidecarContext) => void; + openSelectedText: ( + message: Message, + selectedText: string, + displayIndex?: number, + ) => void; + close: () => void; +}; + +const SidecarContextObject = createContext(null); + +export function SidecarProvider({ + children, + parentThreadId, + context, + isMock, +}: { + children: ReactNode; + parentThreadId: string; + context: ThreadStreamOptions["context"]; + isMock?: boolean; +}) { + const [open, setOpen] = useState(false); + const [activeReferences, setActiveReferences] = useState( + [], + ); + const [sidecarThreadId, setSidecarThreadId] = useState(null); + const [sidecarContext, setSidecarContext] = + useState(context); + const [conversationQuotes, setConversationQuotes] = useState< + SidecarReference[] + >([]); + const referenceIdRef = useRef(0); + const parentThreadIdRef = useRef(parentThreadId); + const sidecarThreadIdRef = useRef(null); + const restoreRequestRef = useRef<{ + parentThreadId: string; + promise: Promise; + } | null>(null); + + const updateSidecarThreadId = useCallback((threadId: string | null) => { + sidecarThreadIdRef.current = threadId; + setSidecarThreadId(threadId); + }, []); + + const createReference = useCallback((nextContext: SidecarContext) => { + referenceIdRef.current += 1; + return { + id: referenceIdRef.current, + context: nextContext, + }; + }, []); + + useEffect(() => { + if (parentThreadIdRef.current === parentThreadId) { + return; + } + parentThreadIdRef.current = parentThreadId; + setOpen(false); + setActiveReferences([]); + setSidecarContext(context); + updateSidecarThreadId(null); + setConversationQuotes([]); + }, [context, parentThreadId, updateSidecarThreadId]); + + const restoreSidecarThread = useCallback( + async (options?: { force?: boolean }) => { + // A non-forced restore trusts the cached id; a forced restore always + // re-queries the backend so a sidecar deleted elsewhere reconciles to + // null instead of pointing the trigger at a dead thread (#3555). + if (!options?.force && sidecarThreadIdRef.current) { + return sidecarThreadIdRef.current; + } + + const restoreRequest = restoreRequestRef.current; + if (restoreRequest?.parentThreadId === parentThreadId) { + return restoreRequest.promise; + } + + const promise = findLatestSidecarThread({ + parentThreadId, + isMock, + }) + .then((thread) => { + const threadId = thread?.thread_id ?? null; + if (parentThreadIdRef.current !== parentThreadId) { + return null; + } + // Reconcile the cache with the backend: adopt a freshly found + // thread, and on a forced refresh clear a stale id when the backend + // no longer has a matching sidecar thread. + if (threadId) { + if (!sidecarThreadIdRef.current) { + updateSidecarThreadId(threadId); + } + } else if (options?.force && sidecarThreadIdRef.current) { + updateSidecarThreadId(null); + } + return threadId; + }) + .catch(() => null) + .finally(() => { + if (restoreRequestRef.current?.promise === promise) { + restoreRequestRef.current = null; + } + }); + + restoreRequestRef.current = { + parentThreadId, + promise, + }; + + return promise; + }, + [isMock, parentThreadId, updateSidecarThreadId], + ); + + useEffect(() => { + void restoreSidecarThread(); + }, [restoreSidecarThread]); + + const openContext = useCallback( + (nextContext: SidecarContext) => { + const nextReference = createReference(nextContext); + + setActiveReferences( + (references) => + getNextSidecarOpenState({ + open, + sidecarThreadId, + activeReferences: references, + nextReference, + }).activeReferences, + ); + setOpen(true); + }, + [createReference, open, sidecarThreadId], + ); + + const addContextToConversation = useCallback( + (nextContext: SidecarContext) => { + const nextReference = createReference(nextContext); + setConversationQuotes((references) => + appendSidecarReference(references, nextReference), + ); + }, + [createReference], + ); + + const clearConversationQuotes = useCallback((ids?: number[]) => { + if (!ids) { + setConversationQuotes([]); + return; + } + const idsToClear = new Set(ids); + setConversationQuotes((quotes) => + quotes.filter((quote) => !idsToClear.has(quote.id)), + ); + }, []); + + const clearActiveReferences = useCallback(() => { + setActiveReferences([]); + }, []); + + const openSidecar = useCallback(() => { + setOpen(true); + }, []); + + const openSelectedText = useCallback( + (message: Message, selectedText: string, displayIndex?: number) => { + const nextContext = buildMessageSidecarContext(message, displayIndex, { + selectedText, + }); + if (!nextContext) { + return; + } + openContext(nextContext); + }, + [openContext], + ); + + const close = useCallback(() => { + setOpen(false); + }, []); + + const value = useMemo( + () => ({ + open, + activeReferences, + conversationQuotes, + parentThreadId, + context: sidecarContext, + setContext: setSidecarContext, + isMock, + sidecarThreadId, + setSidecarThreadId: updateSidecarThreadId, + restoreSidecarThread, + addContextToConversation, + clearConversationQuotes, + clearActiveReferences, + openSidecar, + openContext, + openSelectedText, + close, + }), + [ + activeReferences, + addContextToConversation, + clearActiveReferences, + clearConversationQuotes, + close, + conversationQuotes, + isMock, + open, + openContext, + openSelectedText, + openSidecar, + parentThreadId, + restoreSidecarThread, + sidecarContext, + sidecarThreadId, + updateSidecarThreadId, + ], + ); + + return ( + + {children} + + ); +} + +export function useMaybeSidecar() { + return useContext(SidecarContextObject); +} + +export function useSidecar() { + const context = useMaybeSidecar(); + if (!context) { + throw new Error("useSidecar must be used within a SidecarProvider"); + } + return context; +} diff --git a/frontend/src/components/workspace/sidecar/index.ts b/frontend/src/components/workspace/sidecar/index.ts new file mode 100644 index 000000000..1ecdb92e5 --- /dev/null +++ b/frontend/src/components/workspace/sidecar/index.ts @@ -0,0 +1,4 @@ +export { SidecarProvider, useMaybeSidecar, useSidecar } from "./context"; +export { ReferenceAttachmentSummary } from "./reference-attachments"; +export { SidecarPanel } from "./sidecar-panel"; +export { SidecarTrigger } from "./sidecar-trigger"; diff --git a/frontend/src/components/workspace/sidecar/reference-attachments.tsx b/frontend/src/components/workspace/sidecar/reference-attachments.tsx new file mode 100644 index 000000000..7cce5c4a2 --- /dev/null +++ b/frontend/src/components/workspace/sidecar/reference-attachments.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { MessageSquareQuoteIcon, XIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useI18n } from "@/core/i18n/hooks"; +import { cn } from "@/lib/utils"; + +import { Tooltip } from "../tooltip"; + +import type { SidecarReference } from "./context"; + +function formatReferenceCount({ + count, + one, + many, +}: { + count: number; + one: string; + many: string; +}) { + return (count === 1 ? one : many).replace("{count}", String(count)); +} + +function formatPreviewText(content: string) { + return content.replace(/\s+/g, " ").trim(); +} + +function ReferencePreview({ references }: { references: SidecarReference[] }) { + if (references.length === 0) { + return null; + } + + return ( +
+ {references.map((reference) => ( +
+ {`"${formatPreviewText(reference.context.content)}"`} +
+ ))} +
+ ); +} + +export function ReferenceAttachmentSummary({ + references, + onClear, + className, + testId, +}: { + references: SidecarReference[]; + onClear?: () => void; + className?: string; + testId?: string; +}) { + const { t } = useI18n(); + if (references.length === 0) { + return null; + } + + const label = formatReferenceCount({ + count: references.length, + one: t.sidecar.selectedTextFragment, + many: t.sidecar.selectedTextFragments, + }); + + return ( +
+ }> + + + {label} + + + {onClear && ( + + )} +
+ ); +} diff --git a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx new file mode 100644 index 000000000..2d449ab86 --- /dev/null +++ b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx @@ -0,0 +1,817 @@ +"use client"; + +import type { Message } from "@langchain/langgraph-sdk"; +import { + CheckIcon, + GraduationCapIcon, + LightbulbIcon, + MessageSquareTextIcon, + PaperclipIcon, + RocketIcon, + XIcon, + ZapIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { ConversationEmptyState } from "@/components/ai-elements/conversation"; +import { + PromptInput, + PromptInputActionMenu, + PromptInputActionMenuContent, + PromptInputActionMenuItem, + PromptInputActionMenuTrigger, + PromptInputAttachment, + PromptInputAttachments, + PromptInputBody, + PromptInputButton, + PromptInputFooter, + PromptInputHeader, + PromptInputProvider, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + usePromptInputAttachments, + type PromptInputMessage, +} from "@/components/ai-elements/prompt-input"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenuGroup, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { useI18n } from "@/core/i18n/hooks"; +import { useModels } from "@/core/models/hooks"; +import type { Model } from "@/core/models/types"; +import { useLocalSettings } from "@/core/settings"; +import { + buildParentConversationContext, + buildReferenceMessageMetadata, + buildSidecarContextPrompt, +} from "@/core/sidecar"; +import { createSidecarThread } from "@/core/sidecar/api"; +import { + useThreadStream, + type ThreadStreamOptions, +} from "@/core/threads/hooks"; +import { + formatUploadSize, + useUploadLimits, + validateUploadLimits, + type UploadLimits, + type UploadLimitViolation, +} from "@/core/uploads"; +import { env } from "@/env"; +import { cn } from "@/lib/utils"; + +import { + ModelSelector, + ModelSelectorContent, + ModelSelectorInput, + ModelSelectorItem, + ModelSelectorList, + ModelSelectorName, + ModelSelectorTrigger, +} from "../../ai-elements/model-selector"; +import { MessageList, MESSAGE_LIST_DEFAULT_PADDING_BOTTOM } from "../messages"; +import { useThread as useParentThread } from "../messages/context"; +import { ModeHoverGuide } from "../mode-hover-guide"; +import { Tooltip } from "../tooltip"; + +import { type SidecarReference, useSidecar } from "./context"; +import { ReferenceAttachmentSummary } from "./reference-attachments"; + +function buildHiddenSidecarContextMessage({ + prompt, + parentThreadId, +}: { + prompt: string; + parentThreadId: string; +}): Message { + return { + type: "human", + content: [{ type: "text", text: prompt }], + additional_kwargs: { + hide_from_ui: true, + sidecar_context: true, + parent_thread_id: parentThreadId, + }, + } as Message; +} + +type SidecarInputMode = NonNullable; + +function getResolvedMode( + mode: ThreadStreamOptions["context"]["mode"], + supportsThinking: boolean, +): SidecarInputMode { + if (!supportsThinking && mode !== "flash") { + return "flash"; + } + if (mode) { + return mode; + } + return supportsThinking ? "pro" : "flash"; +} + +function reasoningEffortForMode(mode: SidecarInputMode) { + return mode === "ultra" + ? "high" + : mode === "pro" + ? "medium" + : mode === "thinking" + ? "low" + : "minimal"; +} + +function promptMessageFiles(message: PromptInputMessage) { + return message.files.flatMap((file) => + file.file instanceof File ? [file.file] : [], + ); +} + +export function SidecarPanel({ className }: { className?: string }) { + const { t } = useI18n(); + const sidecar = useSidecar(); + const { thread: parentThread } = useParentThread(); + const [localSettings] = useLocalSettings(); + const { models, tokenUsageEnabled } = useModels(); + const [modelDialogOpen, setModelDialogOpen] = useState(false); + const [creatingThread, setCreatingThread] = useState(false); + const [queuedSubmit, setQueuedSubmit] = useState<{ + message: PromptInputMessage; + references: SidecarReference[]; + } | null>(null); + const { data: uploadLimits } = useUploadLimits( + sidecar.sidecarThreadId ?? sidecar.parentThreadId, + ); + + const selectedModel = useMemo(() => { + if (models.length === 0) { + return undefined; + } + return ( + models.find((model) => model.name === sidecar.context.model_name) ?? + models[0] + ); + }, [models, sidecar.context.model_name]); + + const supportThinking = selectedModel?.supports_thinking ?? false; + + const { + thread, + sendMessage, + isUploading, + isHistoryLoading, + hasMoreHistory, + loadMoreHistory, + } = useThreadStream({ + threadId: sidecar.sidecarThreadId ?? undefined, + displayThreadId: sidecar.sidecarThreadId ?? undefined, + context: sidecar.context, + isMock: sidecar.isMock, + onStart: (createdThreadId) => { + sidecar.setSidecarThreadId(createdThreadId); + }, + }); + + const referenceCountLabel = useMemo(() => { + const count = sidecar.activeReferences.length; + const template = + count === 1 + ? t.sidecar.selectedTextFragment + : t.sidecar.selectedTextFragments; + return template.replace("{count}", String(count)); + }, [ + sidecar.activeReferences.length, + t.sidecar.selectedTextFragment, + t.sidecar.selectedTextFragments, + ]); + + const hasPendingReferences = sidecar.activeReferences.length > 0; + const hasSidecarThread = Boolean(sidecar.sidecarThreadId); + const tokenUsageInlineMode = tokenUsageEnabled + ? localSettings.tokenUsage.inlineMode + : "off"; + const disabled = + (!hasSidecarThread && !hasPendingReferences) || + thread.isLoading || + creatingThread || + Boolean(queuedSubmit) || + isUploading || + (sidecar.isMock ?? false) || + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; + + useEffect(() => { + if (models.length === 0) { + return; + } + + const currentModel = models.find( + (model) => model.name === sidecar.context.model_name, + ); + const fallbackModel = currentModel ?? models[0]!; + const nextModelName = fallbackModel.name; + const nextMode = getResolvedMode( + sidecar.context.mode, + fallbackModel.supports_thinking ?? false, + ); + const modeChanged = sidecar.context.mode !== nextMode; + + if (sidecar.context.model_name === nextModelName && !modeChanged) { + return; + } + + sidecar.setContext({ + ...sidecar.context, + model_name: nextModelName, + mode: nextMode, + reasoning_effort: modeChanged + ? reasoningEffortForMode(nextMode) + : sidecar.context.reasoning_effort, + }); + }, [models, sidecar]); + + const reportUploadLimitViolations = useCallback( + (violations: UploadLimitViolation[]) => { + for (const violation of violations) { + if (violation.code === "max_file_size") { + toast.error( + t.uploads.filesTooLarge( + violation.files.map((file) => file.name).join(", "), + formatUploadSize(violation.limit), + ), + ); + } else if (violation.code === "max_files") { + toast.error( + t.uploads.tooManyFiles(violation.files.length, violation.limit), + ); + } else { + toast.error( + t.uploads.totalSizeTooLarge( + violation.files.length, + formatUploadSize(violation.limit), + ), + ); + } + } + }, + [t.uploads], + ); + + const handleModelSelect = useCallback( + (modelName: string) => { + const model = models.find((candidate) => candidate.name === modelName); + if (!model) { + return; + } + const nextMode = getResolvedMode( + sidecar.context.mode, + model.supports_thinking ?? false, + ); + const modeChanged = sidecar.context.mode !== nextMode; + sidecar.setContext({ + ...sidecar.context, + model_name: modelName, + mode: nextMode, + reasoning_effort: modeChanged + ? reasoningEffortForMode(nextMode) + : sidecar.context.reasoning_effort, + }); + setModelDialogOpen(false); + }, + [models, sidecar], + ); + + const handleModeSelect = useCallback( + (mode: SidecarInputMode) => { + const nextMode = getResolvedMode(mode, supportThinking); + sidecar.setContext({ + ...sidecar.context, + mode: nextMode, + reasoning_effort: reasoningEffortForMode(nextMode), + }); + }, + [sidecar, supportThinking], + ); + + const ensureSidecarThread = useCallback( + async (references: SidecarReference[]) => { + if (sidecar.sidecarThreadId) { + return sidecar.sidecarThreadId; + } + const restoredThreadId = await sidecar.restoreSidecarThread(); + if (restoredThreadId) { + return restoredThreadId; + } + if (references.length === 0) { + throw new Error(t.sidecar.noContext); + } + setCreatingThread(true); + try { + const created = await createSidecarThread({ + parentThreadId: sidecar.parentThreadId, + context: references.map((reference) => reference.context), + }); + sidecar.setSidecarThreadId(created.thread_id); + return created.thread_id; + } finally { + setCreatingThread(false); + } + }, + [sidecar, t.sidecar.noContext], + ); + + const submitToSidecarThread = useCallback( + async ( + threadId: string, + message: PromptInputMessage, + references: SidecarReference[], + onSent?: () => void, + ) => { + const contexts = references.map((reference) => reference.context); + const parentConversation = buildParentConversationContext( + parentThread.messages, + ); + const hiddenContextPrompt = + contexts.length > 0 || parentConversation.length > 0 + ? buildSidecarContextPrompt(contexts, { parentConversation }) + : null; + await sendMessage(threadId, message, undefined, { + additionalInputMessages: hiddenContextPrompt + ? [ + buildHiddenSidecarContextMessage({ + prompt: hiddenContextPrompt, + parentThreadId: sidecar.parentThreadId, + }), + ] + : [], + additionalKwargs: { + sidecar_visible_message: true, + ...(contexts.length > 0 + ? buildReferenceMessageMetadata(contexts) + : {}), + }, + onSent, + }); + }, + [parentThread.messages, sendMessage, sidecar.parentThreadId], + ); + + useEffect(() => { + if (!queuedSubmit || !sidecar.sidecarThreadId || thread.isLoading) { + return; + } + + const nextSubmit = queuedSubmit; + setQueuedSubmit(null); + void submitToSidecarThread( + sidecar.sidecarThreadId, + nextSubmit.message, + nextSubmit.references, + // Clear references only once the send genuinely proceeds; a send dropped + // by the in-flight guard leaves them attached instead of losing them. + () => { + if (nextSubmit.references.length > 0) { + sidecar.clearActiveReferences(); + } + }, + ).catch((error) => { + toast.error( + error instanceof Error ? error.message : t.sidecar.sendFailed, + ); + }); + }, [ + queuedSubmit, + sidecar, + sidecar.sidecarThreadId, + submitToSidecarThread, + t.sidecar.sendFailed, + thread.isLoading, + ]); + + const handleSubmit = useCallback( + async (message: PromptInputMessage) => { + const text = message.text.trim(); + const files = promptMessageFiles(message); + if ((!text && message.files.length === 0) || disabled) { + return; + } + const uploadValidation = validateUploadLimits([], files, uploadLimits); + if (uploadValidation.violations.length > 0) { + reportUploadLimitViolations(uploadValidation.violations); + return Promise.reject(new Error("Attachment limits exceeded.")); + } + + const pendingReferences = [...sidecar.activeReferences]; + try { + if (!sidecar.sidecarThreadId) { + await ensureSidecarThread(pendingReferences); + setQueuedSubmit({ message, references: pendingReferences }); + return; + } + + await submitToSidecarThread( + sidecar.sidecarThreadId, + message, + pendingReferences, + () => { + if (pendingReferences.length > 0) { + sidecar.clearActiveReferences(); + } + }, + ); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t.sidecar.sendFailed, + ); + } + }, + [ + disabled, + ensureSidecarThread, + reportUploadLimitViolations, + sidecar, + submitToSidecarThread, + t.sidecar.sendFailed, + uploadLimits, + ], + ); + + return ( +
+
+ +
+
{t.sidecar.title}
+
+ {sidecar.activeReferences.length > 0 + ? referenceCountLabel + : sidecar.sidecarThreadId + ? t.sidecar.continuing + : t.sidecar.noContext} +
+
+ + + +
+ +
+ {sidecar.sidecarThreadId ? ( + + ) : ( + } + title={t.sidecar.emptyTitle} + description={t.sidecar.emptyDescription} + /> + )} +
+ +
+ + + + + {(attachment) => ( +
+ +
+ )} +
+ {sidecar.activeReferences.length > 0 && ( + sidecar.clearActiveReferences()} + /> + )} +
+ + + + + + + + + + + + + + + +
+
+
+
+ ); +} + +function SidecarAddAttachmentsButton({ + uploadLimits, +}: { + uploadLimits?: UploadLimits; +}) { + const { t } = useI18n(); + const attachments = usePromptInputAttachments(); + const tooltipContent = uploadLimits + ? t.uploads.limitsHint( + uploadLimits.max_files, + formatUploadSize(uploadLimits.max_file_size), + formatUploadSize(uploadLimits.max_total_size), + ) + : t.inputBox.addAttachments; + + return ( + {tooltipContent}}> + attachments.openFileDialog()} + > + + + + ); +} + +function SidecarModeMenu({ + context, + supportThinking, + onModeSelect, +}: { + context: ThreadStreamOptions["context"]; + supportThinking: boolean; + onModeSelect: (mode: SidecarInputMode) => void; +}) { + const { t } = useI18n(); + const mode = getResolvedMode(context.mode, supportThinking); + + return ( + + + +
+ {mode === "flash" && } + {mode === "thinking" && } + {mode === "pro" && } + {mode === "ultra" && ( + + )} +
+
+ {(mode === "flash" && t.inputBox.flashMode) || + (mode === "thinking" && t.inputBox.reasoningMode) || + (mode === "pro" && t.inputBox.proMode) || + (mode === "ultra" && t.inputBox.ultraMode)} +
+
+
+ + + + {t.inputBox.mode} + + onModeSelect("flash")} + > +
+
+ + {t.inputBox.flashMode} +
+
+ {t.inputBox.flashModeDescription} +
+
+ {mode === "flash" ? ( + + ) : ( +
+ )} + + {supportThinking && ( + onModeSelect("thinking")} + > +
+
+ + {t.inputBox.reasoningMode} +
+
+ {t.inputBox.reasoningModeDescription} +
+
+ {mode === "thinking" ? ( + + ) : ( +
+ )} + + )} + onModeSelect("pro")} + > +
+
+ + {t.inputBox.proMode} +
+
+ {t.inputBox.proModeDescription} +
+
+ {mode === "pro" ? ( + + ) : ( +
+ )} + + onModeSelect("ultra")} + > +
+
+ +
+ {t.inputBox.ultraMode} +
+
+
+ {t.inputBox.ultraModeDescription} +
+
+ {mode === "ultra" ? ( + + ) : ( +
+ )} + + + + + ); +} + +function SidecarModelSelector({ + className, + context, + models, + open, + selectedModel, + onModelSelect, + onOpenChange, +}: { + className?: string; + context: ThreadStreamOptions["context"]; + models: Model[]; + open: boolean; + selectedModel?: Model; + onModelSelect: (modelName: string) => void; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n(); + + if (!selectedModel) { + return null; + } + + return ( + + + +
+ + {selectedModel.display_name} + +
+
+
+ + + + {models.map((model) => ( + onModelSelect(model.name)} + > +
+ {model.display_name} + + {model.model} + +
+ {model.name === context.model_name ? ( + + ) : ( +
+ )} + + ))} + + + + ); +} diff --git a/frontend/src/components/workspace/sidecar/sidecar-trigger.tsx b/frontend/src/components/workspace/sidecar/sidecar-trigger.tsx new file mode 100644 index 000000000..2359a24ef --- /dev/null +++ b/frontend/src/components/workspace/sidecar/sidecar-trigger.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { MessageSquareTextIcon } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { useI18n } from "@/core/i18n/hooks"; + +import { Tooltip } from "../tooltip"; + +import { useMaybeSidecar } from "./context"; + +export function SidecarTrigger() { + const { t } = useI18n(); + const sidecar = useMaybeSidecar(); + const [isReconciling, setIsReconciling] = useState(false); + + if (!sidecar?.sidecarThreadId) { + return null; + } + + const label = sidecar.open ? t.sidecar.close : t.sidecar.open; + + const handleClick = async () => { + if (sidecar.open) { + sidecar.close(); + return; + } + // The cached id may point at a sidecar thread deleted elsewhere. Re-query + // the backend before opening; if it's gone, the forced restore clears the + // id and this trigger unmounts (self-heals) instead of opening a dead + // thread (#3555). + setIsReconciling(true); + try { + const restoredThreadId = await sidecar.restoreSidecarThread({ + force: true, + }); + if (restoredThreadId) { + sidecar.openSidecar(); + } + } finally { + setIsReconciling(false); + } + }; + + return ( + + + + ); +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 3e90001da..8e9663233 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -422,6 +422,28 @@ export const enUS: Translations = { loadOlderChats: "Load older chats", }, + // Sidecar + sidecar: { + title: "Side chat", + open: "Open side chat", + close: "Close side chat", + addToConversation: "Add to conversation", + askInSideChat: "Ask in side chat", + reference: "Reference", + selectedTextFragment: "{count} selected text fragment", + selectedTextFragments: "{count} selected text fragments", + clearReferences: "Clear selected references", + emptyTitle: "Ask a follow-up", + emptyDescription: "Ask a follow-up grounded in the referenced text.", + placeholder: "Ask a deeper follow-up...", + send: "Send", + sendFailed: "Failed to send side chat message.", + noContext: "No context selected", + continuing: "Continue in this side chat", + selectionCrossesMessages: + "Selection spans multiple messages. Select text within a single reply to quote it.", + }, + // Channels channels: { title: "Channels", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index b2a1aa1ed..8fa4f1beb 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -333,6 +333,27 @@ export interface Translations { loadOlderChats: string; }; + // Sidecar + sidecar: { + title: string; + open: string; + close: string; + addToConversation: string; + askInSideChat: string; + reference: string; + selectedTextFragment: string; + selectedTextFragments: string; + clearReferences: string; + emptyTitle: string; + emptyDescription: string; + placeholder: string; + send: string; + sendFailed: string; + noContext: string; + continuing: string; + selectionCrossesMessages: string; + }; + // Channels channels: { title: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 74281dba6..7e3eaec7f 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1,4 +1,4 @@ -import { +import { CompassIcon, GraduationCapIcon, ImageIcon, @@ -406,6 +406,28 @@ export const zhCN: Translations = { loadOlderChats: "加载更早的对话", }, + // Sidecar + sidecar: { + title: "侧边对话", + open: "打开侧边对话", + close: "关闭侧边对话", + addToConversation: "添加到对话", + askInSideChat: "在侧边聊天中提问", + reference: "引用", + selectedTextFragment: "{count} 个已选文本片段", + selectedTextFragments: "{count} 个已选文本片段", + clearReferences: "清除已选引用", + emptyTitle: "继续深入追问", + emptyDescription: "基于引用内容单独追问。", + placeholder: "继续深入追问...", + send: "发送", + sendFailed: "侧边对话发送失败。", + noContext: "未选择上下文", + continuing: "继续当前侧边对话", + selectionCrossesMessages: + "选区跨越了多条消息,请在同一条回复内选择要引用的文本。", + }, + // Channels channels: { title: "渠道", diff --git a/frontend/src/core/messages/usage.ts b/frontend/src/core/messages/usage.ts index 01e3a59e1..2f5405001 100644 --- a/frontend/src/core/messages/usage.ts +++ b/frontend/src/core/messages/usage.ts @@ -14,9 +14,21 @@ export function getUsageMetadata(message: Message): TokenUsage | null { if (message.type !== "ai") { return null; } - const usage = (message as Record).usage_metadata as - | { input_tokens?: number; output_tokens?: number; total_tokens?: number } - | undefined; + const usage = + ((message as Record).usage_metadata as + | { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + } + | undefined) ?? + (message.additional_kwargs?.usage_metadata as + | { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + } + | undefined); if (!usage) { return null; } diff --git a/frontend/src/core/sidecar/api.ts b/frontend/src/core/sidecar/api.ts new file mode 100644 index 000000000..486d1a780 --- /dev/null +++ b/frontend/src/core/sidecar/api.ts @@ -0,0 +1,100 @@ +import { getAPIClient } from "@/core/api"; +import { fetch as fetchWithAuth } from "@/core/api/fetcher"; +import { getBackendBaseURL } from "@/core/config"; +import type { AgentThread } from "@/core/threads"; + +import type { SidecarContext } from "./context"; +import { + SIDECAR_METADATA_KEY, + buildSidecarThreadMetadata, + isSidecarThread, +} from "./thread"; + +type SidecarThreadSearchClient = { + threads: { + search: (query: Record) => Promise; + }; +}; + +// The find-then-create flow is two independent round-trips with no backend +// upsert, so a double-click on "Ask in side chat" or two callers racing on the +// same parent thread can each create a duplicate sidecar thread. Coalesce +// concurrent creates for the same parent behind a single in-flight promise so +// only one thread is created; the entry is cleared once it settles. +const inFlightCreates = new Map>(); + +export async function createSidecarThread({ + parentThreadId, + context, +}: { + parentThreadId: string; + context: SidecarContext | SidecarContext[]; +}): Promise { + const inFlight = inFlightCreates.get(parentThreadId); + if (inFlight) { + return inFlight; + } + + const request = createSidecarThreadRequest({ parentThreadId, context }); + inFlightCreates.set(parentThreadId, request); + try { + return await request; + } finally { + if (inFlightCreates.get(parentThreadId) === request) { + inFlightCreates.delete(parentThreadId); + } + } +} + +async function createSidecarThreadRequest({ + parentThreadId, + context, +}: { + parentThreadId: string; + context: SidecarContext | SidecarContext[]; +}): Promise { + const response = await fetchWithAuth(`${getBackendBaseURL()}/api/threads`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: buildSidecarThreadMetadata(parentThreadId, context), + }), + }); + + if (!response.ok) { + throw new Error("Failed to create side conversation."); + } + + return (await response.json()) as AgentThread; +} + +export async function findLatestSidecarThread({ + parentThreadId, + isMock, + apiClient = getAPIClient(isMock) as SidecarThreadSearchClient, +}: { + parentThreadId: string; + isMock?: boolean; + apiClient?: SidecarThreadSearchClient; +}): Promise { + const response = await apiClient.threads.search({ + metadata: { + [SIDECAR_METADATA_KEY]: true, + parent_thread_id: parentThreadId, + }, + limit: 1, + offset: 0, + sortBy: "updated_at", + sortOrder: "desc", + }); + + return ( + response.find( + (thread) => + isSidecarThread(thread) && + thread.metadata?.parent_thread_id === parentThreadId, + ) ?? null + ); +} diff --git a/frontend/src/core/sidecar/context.ts b/frontend/src/core/sidecar/context.ts new file mode 100644 index 000000000..8a1502210 --- /dev/null +++ b/frontend/src/core/sidecar/context.ts @@ -0,0 +1,212 @@ +import type { Message } from "@langchain/langgraph-sdk"; + +import { + extractTextFromMessage, + isHiddenFromUIMessage, +} from "@/core/messages/utils"; + +export type SidecarContextRole = "user" | "assistant"; + +export type ReferencedMessageSidecarContext = { + type: "referenced_message"; + label: string; + messageId?: string; + role: SidecarContextRole; + content: string; +}; + +export type SidecarContext = ReferencedMessageSidecarContext; + +export type ParentConversationContextMessage = { + messageId?: string; + role: SidecarContextRole; + content: string; +}; + +export function normalizeSidecarContexts( + contextOrContexts: SidecarContext | SidecarContext[], +): SidecarContext[] { + return Array.isArray(contextOrContexts) + ? contextOrContexts + : [contextOrContexts]; +} + +function roleOfMessage(message: Message): SidecarContextRole | null { + if (message.type === "human") { + return "user"; + } + if (message.type === "ai") { + return "assistant"; + } + return null; +} + +function labelOfRole(role: SidecarContextRole) { + return role === "user" ? "User" : "Assistant"; +} + +function truncateContextText(content: string, maxChars: number) { + if (content.length <= maxChars) { + return content; + } + return `${content.slice(0, maxChars).trimEnd()}\n[truncated]`; +} + +export function buildParentConversationContext( + messages: Message[], + { + maxMessages = 8, + maxCharsPerMessage = 1200, + maxTotalChars = 6000, + }: { + maxMessages?: number; + maxCharsPerMessage?: number; + maxTotalChars?: number; + } = {}, +): ParentConversationContextMessage[] { + const visibleMessages = messages.flatMap((message) => { + const role = roleOfMessage(message); + if (!role || isHiddenFromUIMessage(message)) { + return []; + } + const content = extractTextFromMessage(message).trim(); + if (!content) { + return []; + } + return [ + { + messageId: message.id, + role, + content, + }, + ]; + }); + + const recentMessages = visibleMessages.slice(-maxMessages); + const selectedMessages: ParentConversationContextMessage[] = []; + let selectedChars = 0; + + for (let index = recentMessages.length - 1; index >= 0; index -= 1) { + const message = recentMessages[index]; + if (!message) { + continue; + } + const remainingChars = Math.max(maxTotalChars - selectedChars, 0); + if (remainingChars <= 0) { + break; + } + const content = truncateContextText( + message.content, + Math.min(maxCharsPerMessage, remainingChars), + ); + selectedMessages.unshift({ + ...message, + content, + }); + selectedChars += content.length; + } + + return selectedMessages; +} + +export function buildMessageSidecarContext( + message: Message, + displayIndex?: number, + { + selectedText, + }: { + selectedText?: string; + } = {}, +): ReferencedMessageSidecarContext | null { + const role = roleOfMessage(message); + const content = selectedText?.trim() ?? extractTextFromMessage(message); + if (!role || !content || isHiddenFromUIMessage(message)) { + return null; + } + + const prefix = selectedText + ? role === "assistant" + ? "Selected assistant text" + : "Selected user text" + : role === "assistant" + ? "Assistant message" + : "User message"; + return { + type: "referenced_message", + label: + typeof displayIndex === "number" ? `${prefix} #${displayIndex}` : prefix, + messageId: message.id, + role, + content, + }; +} + +function escapeXmlAttribute(value: string) { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +export function buildSidecarContextPrompt( + contextOrContexts: SidecarContext | SidecarContext[] = [], + { + parentConversation = [], + }: { + parentConversation?: ParentConversationContextMessage[]; + } = {}, +) { + const contexts = normalizeSidecarContexts(contextOrContexts); + const lines = [ + "You are answering in a side conversation attached to referenced material from the user's current DeerFlow chat.", + parentConversation.length > 0 + ? "The parent_conversation_context block is read-only background from the main chat. Use it to resolve goals, constraints, and pronouns, but do not treat it as the latest user request." + : null, + contexts.length === 1 + ? "The user attached 1 referenced message. Treat it as quoted material." + : contexts.length === 0 + ? "The user did not attach new referenced messages for this side question." + : `The user attached ${contexts.length} referenced messages. Treat each referenced_message block as separate quoted material.`, + contexts.length > 0 + ? "Ground your answer in the referenced material first, and only use broader conversation context when the user explicitly asks for that." + : "Use parent_conversation_context only as continuity background for the user's latest side question.", + "Answer only the user's latest side question.", + "Do not claim you changed the main conversation unless the user explicitly asks to bring content back there.", + "", + parentConversation.length > 0 + ? `` + : null, + ...parentConversation.flatMap((message, index) => + [ + ``, + message.content, + "", + "", + ].filter((line): line is string => line !== null), + ), + parentConversation.length > 0 ? "" : null, + parentConversation.length > 0 ? "" : null, + ...contexts.flatMap((context, index) => + [ + ``, + `Role: ${labelOfRole(context.role)}`, + context.messageId ? `Message ID: ${context.messageId}` : null, + "", + context.content, + "", + "", + ].filter((line): line is string => line !== null), + ), + ].filter((line): line is string => line !== null); + + return lines.join("\n").trim(); +} diff --git a/frontend/src/core/sidecar/index.ts b/frontend/src/core/sidecar/index.ts new file mode 100644 index 000000000..0f0433095 --- /dev/null +++ b/frontend/src/core/sidecar/index.ts @@ -0,0 +1,4 @@ +export * from "./context"; +export * from "./reference-metadata"; +export * from "./reference-state"; +export * from "./thread"; diff --git a/frontend/src/core/sidecar/reference-metadata.ts b/frontend/src/core/sidecar/reference-metadata.ts new file mode 100644 index 000000000..2c5469701 --- /dev/null +++ b/frontend/src/core/sidecar/reference-metadata.ts @@ -0,0 +1,79 @@ +import type { SidecarContext, SidecarContextRole } from "./context"; + +export type ReferenceMessageContextMetadata = { + label: string; + message_id?: string; + role: SidecarContextRole; + content: string; +}; + +export type ReferenceMessageMetadata = { + referenced_message_count: number; + referenced_message_ids: string[]; + referenced_message_roles: SidecarContextRole[]; + referenced_message_contexts: ReferenceMessageContextMetadata[]; +}; + +function isSidecarContextRole(value: unknown): value is SidecarContextRole { + return value === "user" || value === "assistant"; +} + +export function buildReferenceMessageMetadata( + contexts: SidecarContext[], +): ReferenceMessageMetadata { + // `referenced_message_count`, `referenced_message_ids`, and + // `referenced_message_roles` are kept 1:1 parallel with `contexts` so + // consumers can safely zip them. Do not dedupe ids here: two fragments of the + // same source message would otherwise leave the arrays non-parallel. + return { + referenced_message_count: contexts.length, + referenced_message_ids: contexts.map((context) => context.messageId ?? ""), + referenced_message_roles: contexts.map((context) => context.role), + referenced_message_contexts: contexts.map((context) => ({ + label: context.label, + ...(context.messageId ? { message_id: context.messageId } : {}), + role: context.role, + content: context.content, + })), + }; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function readReferenceMessageContexts( + additionalKwargs: unknown, +): SidecarContext[] { + if (!isObjectRecord(additionalKwargs)) { + return []; + } + + const rawContexts = additionalKwargs.referenced_message_contexts; + if (!Array.isArray(rawContexts)) { + return []; + } + + return rawContexts.flatMap((rawContext) => { + if ( + !isObjectRecord(rawContext) || + typeof rawContext.label !== "string" || + typeof rawContext.content !== "string" || + !isSidecarContextRole(rawContext.role) + ) { + return []; + } + + return [ + { + type: "referenced_message", + label: rawContext.label, + ...(typeof rawContext.message_id === "string" + ? { messageId: rawContext.message_id } + : {}), + role: rawContext.role, + content: rawContext.content, + }, + ]; + }); +} diff --git a/frontend/src/core/sidecar/reference-state.ts b/frontend/src/core/sidecar/reference-state.ts new file mode 100644 index 000000000..2449061f2 --- /dev/null +++ b/frontend/src/core/sidecar/reference-state.ts @@ -0,0 +1,55 @@ +import type { SidecarContext } from "./context"; + +export type SidecarReferenceStateItem = { + id: number; + context: SidecarContext; +}; + +export function isSameSidecarContext( + left: SidecarContext, + right: SidecarContext, +) { + return ( + left.type === right.type && + left.role === right.role && + left.messageId === right.messageId && + left.content === right.content + ); +} + +export function appendSidecarReference< + TReference extends SidecarReferenceStateItem, +>(references: TReference[], nextReference: TReference) { + if ( + references.some((reference) => + isSameSidecarContext(reference.context, nextReference.context), + ) + ) { + return references; + } + return [...references, nextReference]; +} + +export function getNextSidecarOpenState< + TReference extends SidecarReferenceStateItem, +>({ + open, + sidecarThreadId, + activeReferences, + nextReference, +}: { + open: boolean; + sidecarThreadId: string | null; + activeReferences: TReference[]; + nextReference: TReference; +}) { + const shouldAppendToCurrentSidecar = + open && (Boolean(sidecarThreadId) || activeReferences.length > 0); + + return { + sidecarThreadId, + activeReferences: shouldAppendToCurrentSidecar + ? appendSidecarReference(activeReferences, nextReference) + : [nextReference], + }; +} diff --git a/frontend/src/core/sidecar/thread.ts b/frontend/src/core/sidecar/thread.ts new file mode 100644 index 000000000..2323f37ca --- /dev/null +++ b/frontend/src/core/sidecar/thread.ts @@ -0,0 +1,64 @@ +import type { AgentThread } from "@/core/threads"; + +import { normalizeSidecarContexts, type SidecarContext } from "./context"; + +export const SIDECAR_METADATA_KEY = "deerflow_sidecar"; + +export type SidecarThreadMetadata = { + [SIDECAR_METADATA_KEY]: true; + parent_thread_id: string; + sidecar_context_type: SidecarContext["type"]; + sidecar_context_label: string; + sidecar_context_count: number; + referenced_message_id?: string; + referenced_message_ids: string[]; + referenced_message_role: SidecarContext["role"]; + referenced_message_roles: SidecarContext["role"][]; +}; + +export function buildSidecarThreadMetadata( + parentThreadId: string, + contextOrContexts: SidecarContext | SidecarContext[], +): SidecarThreadMetadata { + const contexts = normalizeSidecarContexts(contextOrContexts); + const primaryContext = contexts[0]; + if (!primaryContext) { + throw new Error("At least one sidecar context is required."); + } + + // Keep `referenced_message_ids`, `referenced_message_roles`, and + // `sidecar_context_count` 1:1 parallel with `contexts` so consumers can zip + // them safely (two fragments of the same source message would otherwise make + // a deduped id array shorter than the role array). + const referencedMessageIds = contexts.map( + (context) => context.messageId ?? "", + ); + + return { + [SIDECAR_METADATA_KEY]: true, + parent_thread_id: parentThreadId, + sidecar_context_type: primaryContext.type, + sidecar_context_label: primaryContext.label, + sidecar_context_count: contexts.length, + referenced_message_id: primaryContext.messageId, + referenced_message_ids: referencedMessageIds, + referenced_message_role: primaryContext.role, + referenced_message_roles: contexts.map((context) => context.role), + }; +} + +export function isSidecarThread( + thread: + | Pick + | { metadata?: Record }, +) { + return thread.metadata?.[SIDECAR_METADATA_KEY] === true; +} + +export function shouldShowInPrimaryThreadLists( + thread: + | Pick + | { metadata?: Record }, +) { + return !isSidecarThread(thread); +} diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 8cc99d8c7..6230d7bf6 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -21,6 +21,7 @@ import { useI18n } from "../i18n/hooks"; import { isHiddenFromUIMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils"; import type { LocalSettings } from "../settings"; +import { isSidecarThread, SIDECAR_METADATA_KEY } from "../sidecar/thread"; import { useUpdateSubtask } from "../tasks/context"; import { messageToStep } from "../tasks/steps"; import type { UploadedFileInfo } from "../uploads"; @@ -30,6 +31,7 @@ import { fetchThreadTokenUsage } from "./api"; import { buildThreadsSearchQueryOptions, DEFAULT_THREAD_SEARCH_PARAMS, + filterThreadSearchResults, type ThreadSearchParams, } from "./thread-search-query"; import { threadTokenUsageQueryKey } from "./token-usage"; @@ -58,6 +60,27 @@ export type ThreadStreamOptions = { type SendMessageOptions = { additionalKwargs?: Record; + additionalInputMessages?: Message[]; + /** + * Invoked exactly once when the send passes the in-flight guard and is + * genuinely dispatched. It never fires on the early-return path, so callers + * can safely perform one-time cleanup (e.g. clearing quoted references) + * without losing state when a concurrent send is dropped. + */ + onSent?: () => void; +}; + +type ThreadDeleteClient = { + threads: { + delete: (threadId: string) => Promise; + search: (query: Record) => Promise; + }; +}; + +type ThreadSidecarSearchClient = { + threads: { + search: (query: Record) => Promise; + }; }; type RegeneratePrepareResponse = { @@ -71,6 +94,35 @@ type RegeneratePrepareResponse = { target_run_id: string; }; +export function buildThreadSubmitMessages({ + text, + additionalKwargs, + additionalInputMessages = [], + filesForSubmit = [], +}: { + text: string; + additionalKwargs?: Record; + additionalInputMessages?: Message[]; + filesForSubmit?: FileInMessage[]; +}): Message[] { + return [ + ...additionalInputMessages, + { + type: "human", + content: [ + { + type: "text", + text, + }, + ], + additional_kwargs: { + ...additionalKwargs, + ...(filesForSubmit.length > 0 ? { files: filesForSubmit } : {}), + }, + } as Message, + ]; +} + const EMPTY_THREAD_VALUES: AgentThreadState = { title: "", messages: [], @@ -1165,6 +1217,10 @@ export function useThreadStream({ } sendInFlightRef.current = true; + // The send has genuinely proceeded past the in-flight guard, so callers + // can now run one-time cleanup that must not fire on the dropped path. + options?.onSent?.(); + const text = message.text.trim(); // Capture the current human message count before showing optimistic @@ -1297,23 +1353,12 @@ export function useThreadStream({ await thread.submit( { - messages: [ - { - type: "human", - content: [ - { - type: "text", - text, - }, - ], - additional_kwargs: { - ...options?.additionalKwargs, - ...(filesForSubmit.length > 0 - ? { files: filesForSubmit } - : {}), - }, - }, - ], + messages: buildThreadSubmitMessages({ + text, + additionalKwargs: options?.additionalKwargs, + additionalInputMessages: options?.additionalInputMessages, + filesForSubmit, + }), }, { threadId: threadId, @@ -1747,16 +1792,80 @@ export const INFINITE_THREADS_QUERY_KEY_PREFIX = [ "searchInfinite", ] as const; +const INFINITE_THREADS_NEXT_PAGE_PARAM = Symbol( + "deerflow.infiniteThreads.nextPageParam", +); + type InfiniteThreadsParams = Omit< Parameters[0], "limit" | "offset" >; +type InfiniteThreadsSearchClient = { + threads: { + search: ThreadsClient["search"]; + }; +}; + +type InfiniteThreadsPageWithNextParam = AgentThread[] & { + [INFINITE_THREADS_NEXT_PAGE_PARAM]?: number; +}; + +function annotateInfiniteThreadsPage( + page: AgentThread[], + nextPageParam: number | undefined, +): AgentThread[] { + if (nextPageParam !== undefined) { + Reflect.set(page, INFINITE_THREADS_NEXT_PAGE_PARAM, nextPageParam); + } + return page; +} + +export async function fetchInfiniteThreadsPage( + apiClient: InfiniteThreadsSearchClient, + params: InfiniteThreadsParams, + pageParam: number, + pageSize: number = INFINITE_THREADS_PAGE_SIZE, +): Promise { + const threads: AgentThread[] = []; + let offset = pageParam; + let nextPageParam: number | undefined; + + while (threads.length < pageSize) { + const currentLimit = pageSize - threads.length; + const response = (await apiClient.threads.search({ + ...params, + limit: currentLimit, + offset, + })) as AgentThread[]; + + threads.push(...filterThreadSearchResults(response, params)); + offset += response.length; + + if (response.length < currentLimit) { + nextPageParam = undefined; + break; + } + + nextPageParam = offset; + } + + return annotateInfiniteThreadsPage(threads, nextPageParam); +} + export function getInfiniteThreadsNextPageParam( lastPage: AgentThread[], allPages: AgentThread[][], pageSize: number = INFINITE_THREADS_PAGE_SIZE, ): number | undefined { + const annotatedNextPageParam = Reflect.get( + lastPage as InfiniteThreadsPageWithNextParam, + INFINITE_THREADS_NEXT_PAGE_PARAM, + ); + if (typeof annotatedNextPageParam === "number") { + return annotatedNextPageParam; + } + if (lastPage.length < pageSize) { return undefined; } @@ -1806,14 +1915,13 @@ export function useInfiniteThreads( >({ queryKey: [...INFINITE_THREADS_QUERY_KEY_PREFIX, params], initialPageParam: 0, - queryFn: async ({ pageParam }) => { - const response = (await apiClient.threads.search({ - ...params, - limit: INFINITE_THREADS_PAGE_SIZE, - offset: pageParam, - })) as AgentThread[]; - return response; - }, + queryFn: async ({ pageParam }) => + fetchInfiniteThreadsPage( + apiClient, + params, + pageParam, + INFINITE_THREADS_PAGE_SIZE, + ), getNextPageParam: (lastPage, allPages) => getInfiniteThreadsNextPageParam(lastPage, allPages), refetchOnWindowFocus: false, @@ -1899,9 +2007,118 @@ export function useRunDetail(threadId: string, runId: string) { }); } +async function deleteLocalThreadData(threadId: string) { + const response = await fetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`, + { + method: "DELETE", + }, + ); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "Failed to delete local thread data." })); + throw new Error(error.detail ?? "Failed to delete local thread data."); + } +} + +async function deleteThreadEverywhere( + apiClient: ThreadDeleteClient, + threadId: string, +) { + await apiClient.threads.delete(threadId); + await deleteLocalThreadData(threadId); +} + +export async function findSidecarThreadIdsForParent( + apiClient: ThreadSidecarSearchClient, + parentThreadId: string, +) { + const threadIds: string[] = []; + const limit = 100; + let offset = 0; + + while (true) { + const response = await apiClient.threads.search({ + metadata: { + [SIDECAR_METADATA_KEY]: true, + parent_thread_id: parentThreadId, + }, + limit, + offset, + sortBy: "updated_at", + sortOrder: "desc", + select: ["thread_id", "metadata"], + }); + + for (const thread of response) { + if ( + isSidecarThread(thread) && + thread.metadata?.parent_thread_id === parentThreadId + ) { + threadIds.push(thread.thread_id); + } + } + + if (response.length < limit) { + break; + } + offset += response.length; + } + + return threadIds; +} + +async function deleteSidecarThreadsForParent( + apiClient: ThreadDeleteClient, + parentThreadId: string, +) { + let sidecarThreadIds: string[]; + try { + sidecarThreadIds = await findSidecarThreadIdsForParent( + apiClient, + parentThreadId, + ); + } catch (err) { + console.warn( + `Failed to look up sidecar threads for parent ${parentThreadId}; skipping cascade cleanup. Orphaned sidecar threads may remain.`, + err, + ); + return []; + } + + const results = await Promise.allSettled( + sidecarThreadIds.map((threadId) => + deleteThreadEverywhere(apiClient, threadId), + ), + ); + + const failedDeletions = results + .map((result, index) => + result.status === "rejected" + ? { threadId: sidecarThreadIds[index], reason: result.reason } + : null, + ) + .filter((entry): entry is { threadId: string; reason: unknown } => + Boolean(entry), + ); + + if (failedDeletions.length > 0) { + console.warn( + `Failed to delete ${failedDeletions.length} sidecar thread(s) for parent ${parentThreadId}; orphaned sidecar threads may remain.`, + failedDeletions, + ); + } + + return sidecarThreadIds.filter((_, index) => { + return results[index]?.status === "fulfilled"; + }); +} + export function useDeleteThread() { const queryClient = useQueryClient(); - const apiClient = getAPIClient(); + const apiClient = getAPIClient() as ThreadDeleteClient; return useMutation({ mutationFn: async ({ threadId, @@ -1910,24 +2127,17 @@ export function useDeleteThread() { threadId: string; onRemoteDeleted?: () => void; }) => { + const deletedSidecarThreadIds = await deleteSidecarThreadsForParent( + apiClient, + threadId, + ); await apiClient.threads.delete(threadId); onRemoteDeleted?.(); - - const response = await fetch( - `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`, - { - method: "DELETE", - }, - ); - - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: "Failed to delete local thread data." })); - throw new Error(error.detail ?? "Failed to delete local thread data."); - } + await deleteLocalThreadData(threadId); + return deletedSidecarThreadIds; }, - onSuccess(_, { threadId }) { + onSuccess(deletedSidecarThreadIds, { threadId }) { + const deletedThreadIds = new Set([threadId, ...deletedSidecarThreadIds]); queryClient.setQueriesData( { queryKey: ["threads", "search"], @@ -1937,7 +2147,7 @@ export function useDeleteThread() { if (oldData == null) { return oldData; } - return oldData.filter((t) => t.thread_id !== threadId); + return oldData.filter((t) => !deletedThreadIds.has(t.thread_id)); }, ); queryClient.setQueriesData( @@ -1946,7 +2156,10 @@ export function useDeleteThread() { exact: false, }, (oldData: InfiniteData | undefined) => - filterInfiniteThreadsCache(oldData, (t) => t.thread_id !== threadId), + filterInfiniteThreadsCache( + oldData, + (t) => !deletedThreadIds.has(t.thread_id), + ), ); }, diff --git a/frontend/src/core/threads/thread-search-query.ts b/frontend/src/core/threads/thread-search-query.ts index 25783fe41..83fc788ca 100644 --- a/frontend/src/core/threads/thread-search-query.ts +++ b/frontend/src/core/threads/thread-search-query.ts @@ -1,5 +1,10 @@ import type { ThreadsClient } from "@langchain/langgraph-sdk/client"; +import { + SIDECAR_METADATA_KEY, + shouldShowInPrimaryThreadLists, +} from "@/core/sidecar/thread"; + import type { AgentThread, AgentThreadState } from "./types"; type ThreadsSearchClient = { @@ -21,6 +26,28 @@ export const DEFAULT_THREAD_SEARCH_PARAMS: ThreadSearchParams = { export const THREAD_SEARCH_REFETCH_INTERVAL_MS = 5000; +type ThreadSearchFilterParams = Pick; + +export function shouldIncludeSidecarThreads(params: ThreadSearchFilterParams) { + const metadata = params.metadata; + return ( + typeof metadata === "object" && + metadata !== null && + !Array.isArray(metadata) && + Reflect.get(metadata, SIDECAR_METADATA_KEY) === true + ); +} + +export function filterThreadSearchResults( + threads: AgentThread[], + params: ThreadSearchFilterParams, +) { + if (shouldIncludeSidecarThreads(params)) { + return threads; + } + return threads.filter(shouldShowInPrimaryThreadLists); +} + export function buildThreadsSearchQueryOptions( apiClient: ThreadsSearchClient, params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS, @@ -37,7 +64,7 @@ export function buildThreadsSearchQueryOptions( if (maxResults !== undefined && maxResults <= 0) { const response = await apiClient.threads.search(params); - return response as AgentThread[]; + return filterThreadSearchResults(response as AgentThread[], params); } const pageSize = @@ -68,7 +95,7 @@ export function buildThreadsSearchQueryOptions( offset, })) as AgentThread[]; - threads.push(...response); + threads.push(...filterThreadSearchResults(response, params)); if (response.length < currentLimit) { break; diff --git a/frontend/tests/e2e/sidecar-chat.spec.ts b/frontend/tests/e2e/sidecar-chat.spec.ts new file mode 100644 index 000000000..b40264608 --- /dev/null +++ b/frontend/tests/e2e/sidecar-chat.spec.ts @@ -0,0 +1,1270 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +import { + mockLangGraphAPI, + MOCK_SIDECAR_THREAD_ID, + MOCK_THREAD_ID, + MOCK_THREAD_ID_2, +} from "./utils/mock-api"; + +function textFromContent(content: unknown) { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + return content + .map((part) => + typeof part === "object" && + part !== null && + "text" in part && + typeof part.text === "string" + ? part.text + : "", + ) + .join(""); +} + +async function selectTextOnPage( + page: Page, + text: string, + scopeTestId = "main-message-list", +) { + await page.evaluate( + ({ targetText, scopeTestId }) => { + window.getSelection()?.removeAllRanges(); + const root = + document.querySelector(`[data-testid="${scopeTestId}"]`) ?? + document.body; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + const value = node.textContent ?? ""; + const start = value.indexOf(targetText); + if (start >= 0) { + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + targetText.length); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const rect = range.getBoundingClientRect(); + node.parentElement?.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }), + ); + return; + } + node = walker.nextNode(); + } + throw new Error(`Unable to find text: ${targetText}`); + }, + { targetText: text, scopeTestId }, + ); + await expect(page.locator("[data-sidecar-selection-toolbar]")).toBeVisible(); +} + +async function clickSelectionToolbarButton(page: Page, label: string) { + const clicked = await page.evaluate((buttonLabel) => { + const button = Array.from( + document.querySelectorAll( + "[data-sidecar-selection-toolbar] button", + ), + ).find((candidate) => + candidate.textContent?.toLowerCase().includes(buttonLabel.toLowerCase()), + ); + if (button?.offsetParent == null) { + return false; + } + button.click(); + return true; + }, label); + expect(clicked).toBe(true); +} + +async function selectTextAndClickToolbarButton( + page: Page, + text: string, + label: string, + scopeTestId?: string, +) { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await selectTextOnPage(page, text, scopeTestId); + await clickSelectionToolbarButton(page, label); + return; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +async function expectComposerHeightsEqual(page: Page) { + const metrics = await page.evaluate(() => { + const findFormByPlaceholder = (pattern: RegExp) => { + const textarea = Array.from(document.querySelectorAll("textarea")).find( + (element) => pattern.test(element.getAttribute("placeholder") ?? ""), + ); + const form = textarea?.closest("form"); + if (!form) { + return null; + } + const box = form.getBoundingClientRect(); + return { + height: Math.round(box.height), + bottomGap: Math.round(window.innerHeight - box.bottom), + }; + }; + + return { + main: findFormByPlaceholder(/how can i assist you/i), + sidecar: findFormByPlaceholder(/deeper follow-up/i), + }; + }); + + expect(metrics.main).not.toBeNull(); + expect(metrics.sidecar).not.toBeNull(); + expect(metrics.sidecar?.height).toBe(metrics.main?.height); + expect(metrics.sidecar?.bottomGap).toBe(metrics.main?.bottomGap); +} + +async function expectSidecarModelPinnedToSubmit(page: Page) { + const metrics = await page.evaluate(() => { + const getComposerMetrics = (placeholderPattern: RegExp) => { + const textarea = Array.from(document.querySelectorAll("textarea")).find( + (element) => + placeholderPattern.test(element.getAttribute("placeholder") ?? ""), + ); + const form = textarea?.closest("form"); + if (!form) { + return null; + } + const formBox = form.getBoundingClientRect(); + const buttons = Array.from(form.querySelectorAll("button")).map( + (button) => { + const box = button.getBoundingClientRect(); + const text = button.textContent?.trim(); + return { + label: text ? text : (button.getAttribute("aria-label") ?? ""), + left: Math.round(box.left), + right: Math.round(box.right), + }; + }, + ); + const model = buttons.find( + (button) => button.label === "DeepSeek V4 Pro", + ); + const submit = buttons.find((button) => button.label === "Submit"); + + return { + formLeft: Math.round(formBox.left), + formRight: Math.round(formBox.right), + mode: buttons.find((button) => button.label === "Pro"), + model, + submit, + gap: model && submit ? submit.left - model.right : null, + overflows: buttons.some( + (button) => + button.left < Math.round(formBox.left) || + button.right > Math.round(formBox.right), + ), + }; + }; + + return { + main: getComposerMetrics(/how can i assist you/i), + sidecar: getComposerMetrics(/deeper follow-up/i), + }; + }); + + const main = metrics?.main; + const sidecar = metrics?.sidecar; + const mainModel = main?.model; + const mainSubmit = main?.submit; + const sidecarMode = sidecar?.mode; + const sidecarModel = sidecar?.model; + const sidecarSubmit = sidecar?.submit; + + if ( + !main || + !sidecar || + !mainModel || + !mainSubmit || + !sidecarMode || + !sidecarModel || + !sidecarSubmit + ) { + throw new Error("Unable to measure composer model and submit controls."); + } + + expect(sidecarModel.left).toBeGreaterThan(sidecarMode.right); + expect(sidecar.gap).toBe(main.gap); + expect(sidecarModel.left).toBeGreaterThanOrEqual(sidecar.formLeft); + expect(sidecarSubmit.right).toBeLessThanOrEqual(sidecar.formRight); + expect(sidecar.overflows).toBe(false); +} + +async function expectSidecarModelHiddenWhenCompact(page: Page) { + const metrics = await page.evaluate(() => { + const sideTextarea = Array.from(document.querySelectorAll("textarea")).find( + (element) => + /deeper follow-up/i.test(element.getAttribute("placeholder") ?? ""), + ); + const form = sideTextarea?.closest("form"); + if (!form) { + return null; + } + + const formBox = form.getBoundingClientRect(); + const visibleButtons = Array.from(form.querySelectorAll("button")) + .map((button) => { + const box = button.getBoundingClientRect(); + const styles = window.getComputedStyle(button); + const visible = + styles.display !== "none" && + styles.visibility !== "hidden" && + box.width > 0 && + box.height > 0; + const text = button.textContent?.trim(); + return { + label: text ? text : (button.getAttribute("aria-label") ?? ""), + left: Math.round(box.left), + right: Math.round(box.right), + visible, + }; + }) + .filter((button) => button.visible); + + return { + formLeft: Math.round(formBox.left), + formRight: Math.round(formBox.right), + labels: visibleButtons.map((button) => button.label), + overflows: visibleButtons.some( + (button) => + button.left < Math.round(formBox.left) || + button.right > Math.round(formBox.right), + ), + }; + }); + + expect(metrics).not.toBeNull(); + expect(metrics!.labels).toContain("Pro"); + expect(metrics!.labels).toContain("Submit"); + expect(metrics!.labels).not.toContain("DeepSeek V4 Pro"); + expect(metrics!.overflows).toBe(false); +} + +async function expectSidecarScrollDoesNotAnimateAfterOpen(page: Page) { + await page.waitForFunction(() => { + const root = document.querySelector('[data-testid="sidecar-message-list"]'); + const scrollElement = root?.firstElementChild; + if (!(scrollElement instanceof HTMLElement)) { + return false; + } + return ( + scrollElement.scrollHeight > scrollElement.clientHeight && + scrollElement.scrollTop > 0 + ); + }); + + const firstScrollTop = await page.evaluate(() => { + const root = document.querySelector('[data-testid="sidecar-message-list"]'); + const scrollElement = root?.firstElementChild; + if (!(scrollElement instanceof HTMLElement)) { + throw new Error("Sidecar scroll container was not found."); + } + return Math.round(scrollElement.scrollTop); + }); + + await page.waitForTimeout(220); + + const secondScrollTop = await page.evaluate(() => { + const root = document.querySelector('[data-testid="sidecar-message-list"]'); + const scrollElement = root?.firstElementChild; + if (!(scrollElement instanceof HTMLElement)) { + throw new Error("Sidecar scroll container was not found."); + } + return Math.round(scrollElement.scrollTop); + }); + + expect(secondScrollTop).toBe(firstScrollTop); +} + +async function openSidecarAndExpectNoAnimatedScroll(page: Page) { + await page.evaluate(() => { + const targetWindow = window as Window & { + __sidecarScrollTops?: number[]; + __sidecarScrollListener?: (event: Event) => void; + }; + if (targetWindow.__sidecarScrollListener) { + document.removeEventListener( + "scroll", + targetWindow.__sidecarScrollListener, + true, + ); + } + targetWindow.__sidecarScrollTops = []; + Reflect.set(targetWindow, "__sidecarPanelTransforms", []); + targetWindow.__sidecarScrollListener = (event: Event) => { + const target = event.target; + if ( + target instanceof HTMLElement && + target.parentElement?.matches('[data-testid="sidecar-message-list"]') + ) { + targetWindow.__sidecarScrollTops?.push(Math.round(target.scrollTop)); + } + }; + document.addEventListener( + "scroll", + targetWindow.__sidecarScrollListener, + true, + ); + }); + + await page.getByTestId("sidecar-header-trigger").click(); + await expect(page.getByTestId("sidecar-message-list")).toBeVisible(); + for (let index = 0; index < 20; index += 1) { + await page.waitForTimeout(25); + await page.evaluate(() => { + const targetWindow = window as Window & { + __sidecarPanelTransforms?: string[]; + }; + const panel = document.querySelector('[data-testid="sidecar-panel"]'); + const shell = panel?.parentElement; + if (shell) { + targetWindow.__sidecarPanelTransforms?.push( + window.getComputedStyle(shell).transform, + ); + } + }); + } + + const { distinctScrollTops, panelTransforms } = await page.evaluate(() => { + const targetWindow = window as Window & { + __sidecarScrollTops?: number[]; + __sidecarScrollListener?: (event: Event) => void; + __sidecarPanelTransforms?: string[]; + }; + if (targetWindow.__sidecarScrollListener) { + document.removeEventListener( + "scroll", + targetWindow.__sidecarScrollListener, + true, + ); + targetWindow.__sidecarScrollListener = undefined; + } + return { + distinctScrollTops: Array.from( + new Set(targetWindow.__sidecarScrollTops ?? []), + ), + panelTransforms: targetWindow.__sidecarPanelTransforms ?? [], + }; + }); + + expect(distinctScrollTops.length).toBeLessThanOrEqual(1); + expect( + panelTransforms.every( + (transform) => + transform === "none" || transform === "matrix(1, 0, 0, 1, 0, 0)", + ), + ).toBe(true); +} + +test.describe("Side chat", () => { + test("creates a hidden sidecar thread from selected quoted text", async ({ + page, + }) => { + const parentMessages = [ + { + type: "human", + id: "parent-human-1", + content: [{ type: "text", text: "Plan the feature." }], + }, + { + type: "ai", + id: "parent-ai-1", + content: + "Build it as a side conversation. Keep the cited snippets compact.", + }, + ]; + let createdThreadBody: { metadata?: Record } | undefined; + let sidecarThreadCreateCount = 0; + let streamBody: + | { + input?: { + messages?: Array<{ + type?: string; + content?: unknown; + additional_kwargs?: Record; + }>; + }; + context?: Record; + } + | undefined; + let sidecarThreadMessages: Array<{ + type?: string; + id?: string; + content?: unknown; + additional_kwargs?: Record; + }> = []; + + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Main conversation", + messages: parentMessages, + }, + { + thread_id: MOCK_THREAD_ID_2, + title: "Second conversation", + messages: [ + { + type: "human", + id: "second-human-1", + content: "Switch target.", + }, + ], + }, + ], + }); + await page.route("**/api/models", (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + models: [ + { + id: "deepseek-v4-pro", + name: "deepseek-v4-pro", + model: "deepseek-v4-pro", + display_name: "DeepSeek V4 Pro", + supports_thinking: true, + supports_reasoning_effort: true, + }, + { + id: "fast-model", + name: "fast-model", + model: "fast-model", + display_name: "Fast Model", + supports_thinking: false, + supports_reasoning_effort: false, + }, + ], + token_usage: { enabled: false }, + }), + }); + }); + + await page.route("**/api/threads", (route) => { + if (route.request().method() !== "POST") { + return route.fallback(); + } + sidecarThreadCreateCount += 1; + createdThreadBody = route.request().postDataJSON() as { + metadata?: Record; + }; + return route.fallback(); + }); + await page.route( + `**/api/langgraph/threads/${MOCK_THREAD_ID}/state`, + (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + values: { + title: "Main conversation", + messages: parentMessages, + artifacts: [], + }, + next: [], + metadata: {}, + created_at: "2025-01-01T00:00:00Z", + }), + }); + }, + ); + await page.route( + `**/api/langgraph/threads/${MOCK_THREAD_ID}/history`, + (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + values: { + title: "Main conversation", + messages: parentMessages, + artifacts: [], + }, + next: [], + metadata: {}, + created_at: "2025-01-01T00:00:00Z", + parent_config: null, + }, + ]), + }); + }, + ); + await page.route( + new RegExp(`/api/langgraph/threads/${MOCK_THREAD_ID}/runs(?:\\?|$)`), + (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + run_id: `run-${MOCK_THREAD_ID}`, + thread_id: MOCK_THREAD_ID, + assistant_id: "lead_agent", + status: "success", + metadata: {}, + kwargs: {}, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + }, + ]), + }); + }, + ); + await page.route( + new RegExp(`/api/threads/${MOCK_THREAD_ID}/runs/[^/]+/messages`), + (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: parentMessages.map((message, index) => ({ + run_id: `run-${MOCK_THREAD_ID}`, + content: message, + metadata: { caller: "lead_agent" }, + created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`, + })), + hasMore: false, + }), + }); + }, + ); + await page.route( + `**/api/langgraph/threads/${MOCK_SIDECAR_THREAD_ID}/state`, + (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + values: { + title: "Side chat", + messages: sidecarThreadMessages, + artifacts: [], + }, + next: [], + metadata: {}, + created_at: "2025-01-01T00:00:00Z", + }), + }); + }, + ); + await page.route( + `**/api/langgraph/threads/${MOCK_SIDECAR_THREAD_ID}/history`, + (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + values: { + title: "Side chat", + messages: sidecarThreadMessages, + artifacts: [], + }, + next: [], + metadata: {}, + created_at: "2025-01-01T00:00:00Z", + parent_config: null, + }, + ]), + }); + }, + ); + await page.route( + new RegExp( + `/api/langgraph/threads/${MOCK_SIDECAR_THREAD_ID}/runs(?:\\?|$)`, + ), + (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + sidecarThreadMessages.length > 0 + ? [ + { + run_id: `run-${MOCK_SIDECAR_THREAD_ID}`, + thread_id: MOCK_SIDECAR_THREAD_ID, + assistant_id: "lead_agent", + status: "success", + metadata: {}, + kwargs: {}, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + }, + ] + : [], + ), + }); + }, + ); + await page.route( + new RegExp(`/api/threads/${MOCK_SIDECAR_THREAD_ID}/runs/[^/]+/messages`), + (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: sidecarThreadMessages.map((message, index) => ({ + run_id: `run-${MOCK_SIDECAR_THREAD_ID}`, + content: message, + metadata: { caller: "lead_agent" }, + created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`, + })), + hasMore: false, + }), + }); + }, + ); + const fulfillSidecarRunStream = (route: Route) => { + const body = route.request().postDataJSON() as typeof streamBody; + if (body?.input?.messages) { + streamBody = body; + sidecarThreadMessages = [ + ...sidecarThreadMessages, + ...body.input.messages, + { + type: "ai", + id: `msg-ai-sidecar-${sidecarThreadMessages.length}`, + content: "Hello from DeerFlow!", + }, + ]; + } + const events = [ + { + event: "metadata", + data: { + run_id: `run-${MOCK_SIDECAR_THREAD_ID}`, + thread_id: MOCK_SIDECAR_THREAD_ID, + }, + }, + { + event: "values", + data: { + messages: sidecarThreadMessages, + }, + }, + { event: "end", data: {} }, + ]; + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: events + .map((event) => { + return `event: ${event.event}\ndata: ${JSON.stringify( + event.data, + )}\n\n`; + }) + .join(""), + }); + }; + await page.route( + "**/api/langgraph/threads/*/runs/stream", + fulfillSidecarRunStream, + ); + await page.route("**/api/langgraph/runs/stream", fulfillSidecarRunStream); + await page.route("**/runs/stream", fulfillSidecarRunStream); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect( + page.getByText("Build it as a side conversation."), + ).toBeVisible({ timeout: 15_000 }); + + await selectTextAndClickToolbarButton( + page, + "Build it as a side conversation.", + "Add to conversation", + ); + const quoteAttachment = page.getByTestId("conversation-quote-attachment"); + const mainInputForm = page.locator("form").filter({ + has: page.getByPlaceholder(/how can i assist you/i), + }); + await expect(quoteAttachment).toBeVisible(); + await expect( + mainInputForm.getByTestId("conversation-quote-attachment"), + ).toBeVisible(); + await expect(quoteAttachment).toContainText("1 selected text fragment"); + await selectTextAndClickToolbarButton( + page, + "Keep the cited snippets compact.", + "Add to conversation", + ); + await expect(quoteAttachment).toContainText("2 selected text fragments"); + await expect(page.locator("textarea[name='message']").first()).toHaveValue( + "", + ); + await quoteAttachment + .getByRole("button", { name: /clear selected references/i }) + .click(); + await expect(quoteAttachment).toBeHidden(); + + await selectTextAndClickToolbarButton( + page, + "Build it as a side conversation.", + "Ask in side chat", + ); + await expect( + page.getByRole("heading", { name: "Ask a follow-up" }), + ).toBeVisible(); + const sidecarReference = page.getByTestId("sidecar-reference-attachment"); + const sidecarInputForm = page.locator("form").filter({ + has: page.getByPlaceholder(/deeper follow-up/i), + }); + await expect(sidecarReference).toBeVisible(); + await expect( + sidecarInputForm.getByTestId("sidecar-reference-attachment"), + ).toBeVisible(); + await expect(sidecarReference).toContainText("1 selected text fragment"); + await expect( + sidecarInputForm.getByTestId("sidecar-add-attachments-button"), + ).toBeVisible(); + await expect( + sidecarInputForm.getByRole("button", { name: "Pro", exact: true }), + ).toBeVisible(); + await expect( + sidecarInputForm.getByRole("button", { name: /DeepSeek V4 Pro/i }), + ).toBeVisible(); + await expectSidecarModelPinnedToSubmit(page); + const originalViewport = page.viewportSize(); + await page.setViewportSize({ + width: 820, + height: originalViewport?.height ?? 720, + }); + await expectSidecarModelHiddenWhenCompact(page); + await page.setViewportSize( + originalViewport ?? { width: 1280, height: 720 }, + ); + await expect( + sidecarInputForm.getByRole("button", { name: /DeepSeek V4 Pro/i }), + ).toBeVisible(); + await expectSidecarModelPinnedToSubmit(page); + await sidecarInputForm + .getByRole("button", { name: "Pro", exact: true }) + .click(); + await page.getByRole("menuitem").filter({ hasText: "Flash" }).click(); + await expect( + sidecarInputForm.getByRole("button", { name: "Flash", exact: true }), + ).toBeVisible(); + await sidecarInputForm + .getByRole("button", { name: /DeepSeek V4 Pro/i }) + .click(); + await page.getByText("Fast Model").click(); + await expect( + sidecarInputForm.getByRole("button", { name: /Fast Model/i }), + ).toBeVisible(); + const mainInput = page.getByPlaceholder(/how can i assist you/i); + const sidecarInput = page.getByPlaceholder(/deeper follow-up/i); + await mainInput.fill("Left draft"); + await expect(sidecarInput).toHaveValue(""); + await sidecarInput.fill("Right draft"); + await expect(mainInput).toHaveValue("Left draft"); + + await selectTextAndClickToolbarButton( + page, + "Keep the cited snippets compact.", + "Ask in side chat", + ); + await expect(sidecarReference).toContainText("2 selected text fragments"); + await selectTextAndClickToolbarButton( + page, + "Build it as a side conversation.", + "Add to conversation", + ); + await expect(quoteAttachment).toContainText("1 selected text fragment"); + await expectComposerHeightsEqual(page); + await quoteAttachment + .getByRole("button", { name: /clear selected references/i }) + .click(); + await expect(quoteAttachment).toBeHidden(); + + await sidecarInput.fill("What tradeoffs should we consider?"); + await sidecarInput.press("Enter"); + + await expect + .poll(() => createdThreadBody?.metadata, { timeout: 10_000 }) + .toMatchObject({ + deerflow_sidecar: true, + parent_thread_id: MOCK_THREAD_ID, + sidecar_context_type: "referenced_message", + sidecar_context_label: "Selected assistant text #2", + referenced_message_id: "parent-ai-1", + referenced_message_role: "assistant", + sidecar_context_count: 2, + referenced_message_ids: ["parent-ai-1", "parent-ai-1"], + referenced_message_roles: ["assistant", "assistant"], + }); + + await expect + .poll(() => streamBody?.input?.messages?.length, { timeout: 10_000 }) + .toBe(2); + expect(streamBody?.context).toMatchObject({ + model_name: "fast-model", + thinking_enabled: false, + is_plan_mode: false, + subagent_enabled: false, + reasoning_effort: "minimal", + thread_id: MOCK_SIDECAR_THREAD_ID, + }); + + const messages = streamBody?.input?.messages ?? []; + expect(messages[0]?.additional_kwargs).toMatchObject({ + hide_from_ui: true, + sidecar_context: true, + parent_thread_id: MOCK_THREAD_ID, + }); + expect(textFromContent(messages[0]?.content)).toContain( + "You are answering in a side conversation", + ); + expect(textFromContent(messages[0]?.content)).toContain( + "', + ); + expect(textFromContent(messages[0]?.content)).toContain( + "Plan the feature.", + ); + expect(textFromContent(messages[0]?.content)).toContain( + '', + ); + expect(textFromContent(messages[0]?.content)).toContain( + '', + ); + expect(textFromContent(messages[0]?.content)).toContain( + "Build it as a side conversation.", + ); + expect(textFromContent(messages[0]?.content)).toContain( + 'referenced_message index="2"', + ); + expect(textFromContent(messages[0]?.content)).toContain( + "Keep the cited snippets compact.", + ); + expect(textFromContent(messages[1]?.content)).toBe( + "What tradeoffs should we consider?", + ); + expect(messages[1]?.additional_kwargs).toMatchObject({ + sidecar_visible_message: true, + referenced_message_count: 2, + referenced_message_ids: ["parent-ai-1", "parent-ai-1"], + referenced_message_roles: ["assistant", "assistant"], + referenced_message_contexts: [ + { + label: "Selected assistant text #2", + message_id: "parent-ai-1", + role: "assistant", + content: "Build it as a side conversation.", + }, + { + label: "Selected assistant text #2", + message_id: "parent-ai-1", + role: "assistant", + content: "Keep the cited snippets compact.", + }, + ], + }); + + await expect(sidecarInput).toHaveValue(""); + await expect(sidecarReference).toBeHidden(); + await expectComposerHeightsEqual(page); + await expect(page.getByTestId("sidecar-header-trigger")).toBeVisible(); + + await page + .getByTestId("sidecar-panel") + .getByRole("button", { name: /close/i }) + .click(); + await expect(page.getByTestId("sidecar-panel")).toBeHidden(); + await expect(page.getByTestId("sidecar-header-trigger")).toBeVisible(); + await page.getByTestId("sidecar-header-trigger").click(); + await expect(page.getByTestId("sidecar-panel")).toBeVisible(); + await expect( + page.getByTestId("sidecar-header-trigger"), + ).toHaveAccessibleName("Close side chat"); + await page.getByTestId("sidecar-header-trigger").click(); + await expect(page.getByTestId("sidecar-panel")).toBeHidden(); + await expect( + page.getByTestId("sidecar-header-trigger"), + ).toHaveAccessibleName("Open side chat"); + await page.getByTestId("sidecar-header-trigger").click(); + await expect(page.getByTestId("sidecar-panel")).toBeVisible(); + + await expect( + page + .getByTestId("sidecar-message-list") + .getByText("Hello from DeerFlow!") + .first(), + ).toBeVisible(); + + await selectTextAndClickToolbarButton( + page, + "Hello from DeerFlow!", + "Add to conversation", + "sidecar-message-list", + ); + await expect( + mainInputForm.getByTestId("conversation-quote-attachment"), + ).toContainText("1 selected text fragment"); + await expect(sidecarReference).toBeHidden(); + await mainInputForm + .getByTestId("conversation-quote-attachment") + .getByRole("button", { name: /clear selected references/i }) + .click(); + await expect( + mainInputForm.getByTestId("conversation-quote-attachment"), + ).toBeHidden(); + + await selectTextAndClickToolbarButton( + page, + "Hello from DeerFlow!", + "Ask in side chat", + "sidecar-message-list", + ); + await expect(sidecarReference).toContainText("1 selected text fragment"); + await expect( + mainInputForm.getByTestId("conversation-quote-attachment"), + ).toBeHidden(); + + await sidecarInput.fill("What did the side answer say?"); + await sidecarInput.press("Enter"); + await expect + .poll( + () => textFromContent(streamBody?.input?.messages?.at(1)?.content), + { timeout: 10_000 }, + ) + .toBe("What did the side answer say?"); + + const sidecarSelectionMessages = streamBody?.input?.messages ?? []; + expect(textFromContent(sidecarSelectionMessages[0]?.content)).toContain( + ' textFromContent(streamBody?.input?.messages?.at(1)?.content), + { timeout: 10_000 }, + ) + .toBe("Can you continue with the left context?"); + + const followUpMessages = streamBody?.input?.messages ?? []; + expect(textFromContent(followUpMessages[0]?.content)).toContain( + " sidecarThreadCreateCount, { timeout: 10_000 }) + .toBe(1); + }); + + test("shows reference summary on visible messages with reference metadata", async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Referenced conversation", + messages: [ + { + type: "human", + id: "human-with-references", + content: [ + { type: "text", text: "What tradeoffs should we consider?" }, + ], + additional_kwargs: { + referenced_message_count: 2, + referenced_message_ids: ["parent-ai-1"], + referenced_message_roles: ["assistant", "assistant"], + referenced_message_contexts: [ + { + label: "Selected assistant text #2", + message_id: "parent-ai-1", + role: "assistant", + content: "Build it as a side conversation.", + }, + { + label: "Selected assistant text #2", + message_id: "parent-ai-1", + role: "assistant", + content: "Keep the cited snippets compact.", + }, + ], + }, + }, + { + type: "ai", + id: "ai-after-references", + content: "Use the selected snippets as constraints.", + }, + ], + }, + ], + }); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + + await expect( + page.getByText("What tradeoffs should we consider?"), + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.getByTestId("message-reference-attachment"), + ).toContainText("2 selected text fragments"); + await expect(page.getByTestId("message-reference-attachment")).toHaveClass( + /max-w-\[min\(18rem,100%\)\]/, + ); + }); + + test("opens restored side chat history without animated scroll", async ({ + page, + }) => { + const tallSidecarMessages = Array.from({ length: 12 }).flatMap( + (_, index) => [ + { + type: "human", + id: `side-human-${index}`, + content: [{ type: "text", text: `Follow-up ${index + 1}` }], + }, + { + type: "ai", + id: `side-ai-${index}`, + content: [ + `Restored side answer ${index + 1}.`, + "This paragraph gives the side chat enough height to require scrolling.", + "Opening the panel should reveal the existing history without a visible scroll animation.", + ].join(" "), + }, + ], + ); + + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Main conversation", + messages: [ + { + type: "human", + id: "parent-human-1", + content: [{ type: "text", text: "Plan the feature." }], + }, + { + type: "ai", + id: "parent-ai-1", + content: "Build it as a side conversation.", + }, + ], + }, + { + thread_id: MOCK_SIDECAR_THREAD_ID, + title: "Restored side chat", + updated_at: "2025-01-01T00:00:01Z", + metadata: { + deerflow_sidecar: true, + parent_thread_id: MOCK_THREAD_ID, + sidecar_context_type: "referenced_message", + sidecar_context_label: "Selected assistant text #2", + sidecar_context_count: 1, + referenced_message_id: "parent-ai-1", + referenced_message_ids: ["parent-ai-1"], + referenced_message_role: "assistant", + referenced_message_roles: ["assistant"], + }, + messages: tallSidecarMessages, + }, + ], + }); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect( + page.getByText("Build it as a side conversation."), + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("sidecar-header-trigger")).toBeVisible({ + timeout: 10_000, + }); + + await openSidecarAndExpectNoAnimatedScroll(page); + await expectSidecarScrollDoesNotAnimateAfterOpen(page); + await page.getByTestId("sidecar-header-trigger").click(); + await expect(page.getByTestId("sidecar-panel")).toBeHidden(); + await page.waitForTimeout(350); + await openSidecarAndExpectNoAnimatedScroll(page); + }); + + test("self-heals the trigger when the sidecar thread is deleted elsewhere", async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Main conversation", + messages: [ + { + type: "human", + id: "parent-human-1", + content: [{ type: "text", text: "Plan the feature." }], + }, + { + type: "ai", + id: "parent-ai-1", + content: "Build it as a side conversation.", + }, + ], + }, + { + thread_id: MOCK_SIDECAR_THREAD_ID, + title: "Restored side chat", + updated_at: "2025-01-01T00:00:01Z", + metadata: { + deerflow_sidecar: true, + parent_thread_id: MOCK_THREAD_ID, + sidecar_context_type: "referenced_message", + sidecar_context_label: "Selected assistant text #2", + sidecar_context_count: 1, + referenced_message_id: "parent-ai-1", + referenced_message_ids: ["parent-ai-1"], + referenced_message_role: "assistant", + referenced_message_roles: ["assistant"], + }, + messages: [ + { + type: "ai", + id: "side-ai-1", + content: "Restored side answer.", + }, + ], + }, + ], + }); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect( + page.getByText("Build it as a side conversation."), + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("sidecar-header-trigger")).toBeVisible({ + timeout: 10_000, + }); + + // Simulate the sidecar thread being deleted from another surface: the + // backend search now returns no matching sidecar thread. + await page.route("**/api/langgraph/threads/search", (route) => { + if (route.request().method() !== "POST") { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + // Clicking the (still-cached) trigger forces a re-query; because the thread + // is gone the trigger hides itself instead of opening a dead thread (#3555). + await page.getByTestId("sidecar-header-trigger").click(); + await expect(page.getByTestId("sidecar-panel")).toBeHidden(); + await expect(page.getByTestId("sidecar-header-trigger")).toBeHidden({ + timeout: 10_000, + }); + }); +}); diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index 50cdd2319..b428e181f 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -14,6 +14,7 @@ import type { Page, Route } from "@playwright/test"; export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001"; export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002"; +export const MOCK_SIDECAR_THREAD_ID = "00000000-0000-0000-0000-0000000000aa"; export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099"; const MOCK_AUTH_USER = { @@ -109,21 +110,85 @@ const DEFAULT_SKILLS: MockSkill[] = [ }, ]; -function mockStreamMessages() { +function isHiddenInputMessage(message: unknown) { + if (typeof message !== "object" || message === null) { + return false; + } + const additionalKwargs = Reflect.get(message, "additional_kwargs"); + return ( + typeof additionalKwargs === "object" && + additionalKwargs !== null && + Reflect.get(additionalKwargs, "hide_from_ui") === true + ); +} + +function visibleInputMessages(messages: unknown[]) { + return messages.filter((message) => !isHiddenInputMessage(message)); +} + +function visibleRunInputMessages(route: Route) { + try { + const body = route.request().postDataJSON() as { + input?: { messages?: unknown[] }; + }; + return visibleInputMessages(body.input?.messages ?? []); + } catch { + return []; + } +} + +function mockStreamMessages(route?: Route, inputMessages?: unknown[]) { + const submittedMessages = inputMessages + ? visibleInputMessages(inputMessages) + : route + ? visibleRunInputMessages(route) + : []; + const responseMessage = { + type: "ai", + id: "msg-ai-1", + content: "Hello from DeerFlow!", + }; + if (submittedMessages.length > 0) { + return [...submittedMessages, responseMessage]; + } + return [ { type: "human", id: "msg-human-1", content: [{ type: "text", text: "Hello" }], }, - { - type: "ai", - id: "msg-ai-1", - content: "Hello from DeerFlow!", - }, + responseMessage, ]; } +function runStreamThreadId(route: Route) { + const pathThreadId = /\/threads\/([^/]+)\/runs\/stream/.exec( + new URL(route.request().url()).pathname, + )?.[1]; + if (pathThreadId) { + return pathThreadId; + } + + try { + const body = route.request().postDataJSON() as { + thread_id?: string; + threadId?: string; + context?: { thread_id?: string }; + config?: { configurable?: { thread_id?: string } }; + }; + return ( + body.thread_id ?? + body.threadId ?? + body.context?.thread_id ?? + body.config?.configurable?.thread_id ?? + MOCK_THREAD_ID + ); + } catch { + return MOCK_THREAD_ID; + } +} + // --------------------------------------------------------------------------- // mockLangGraphAPI // --------------------------------------------------------------------------- @@ -473,7 +538,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { // Thread search — sidebar thread list & chats list page void page.route("**/api/langgraph/threads/search", async (route) => { - const body = threads.map(threadSearchResult); + let body = threads.map(threadSearchResult); let limit: number | undefined; let offset = 0; @@ -481,6 +546,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { const postData = route.request().postDataJSON() as { limit?: number; offset?: number; + metadata?: Record; } | null; if (postData) { if (typeof postData.limit === "number") { @@ -489,6 +555,13 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { if (typeof postData.offset === "number") { offset = postData.offset; } + if (postData.metadata && typeof postData.metadata === "object") { + body = body.filter((thread) => + Object.entries(postData.metadata ?? {}).every( + ([key, value]) => thread.metadata?.[key] === value, + ), + ); + } } } catch { // No / invalid JSON body — fall back to returning the full list. @@ -567,6 +640,36 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { return route.fallback(); }); + void page.route("**/api/threads", (route) => { + if (route.request().method() === "POST") { + const body = route.request().postDataJSON() as { + thread_id?: string; + metadata?: Record; + }; + const threadId = body.thread_id ?? MOCK_SIDECAR_THREAD_ID; + upsertThread({ + thread_id: threadId, + title: "Side chat", + updated_at: new Date().toISOString(), + metadata: body.metadata ?? {}, + messages: [], + }); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + thread_id: threadId, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + metadata: body.metadata ?? {}, + status: "idle", + values: {}, + }), + }); + } + return route.fallback(); + }); + void page.route(/\/api\/threads\/[^/]+$/, (route) => { if (route.request().method() === "DELETE") { return route.fulfill({ @@ -787,18 +890,19 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { // Run stream — returns a minimal SSE response with an AI message const handleMockRunStream = (route: Route) => { - const requestUrl = route.request().url(); - const matchingThread = threads.find((thread) => - requestUrl.includes(encodeURIComponent(thread.thread_id)), + const threadId = runStreamThreadId(route); + const existingThread = threads.find( + (thread) => thread.thread_id === threadId, ); const fallbackGoal = threads.find((thread) => thread.goal)?.goal ?? null; - const goal = matchingThread?.goal ?? fallbackGoal; + const goal = existingThread?.goal ?? fallbackGoal; upsertThread({ - thread_id: MOCK_THREAD_ID, - title: "New Chat", + thread_id: threadId, + title: threadId === MOCK_SIDECAR_THREAD_ID ? "Side chat" : "New Chat", updated_at: new Date().toISOString(), goal, - messages: mockStreamMessages(), + metadata: existingThread?.metadata, + messages: mockStreamMessages(route), }); return handleRunStream(route, { goal }); }; @@ -906,17 +1010,19 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { export function handleRunStream( route: Route, values: Record = {}, + inputMessages?: unknown[], ) { + const threadId = runStreamThreadId(route); const events = [ { event: "metadata", - data: { run_id: MOCK_RUN_ID, thread_id: MOCK_THREAD_ID }, + data: { run_id: MOCK_RUN_ID, thread_id: threadId }, }, { event: "values", data: { ...values, - messages: mockStreamMessages(), + messages: mockStreamMessages(route, inputMessages), }, }, { event: "end", data: {} }, diff --git a/frontend/tests/unit/core/messages/usage.test.ts b/frontend/tests/unit/core/messages/usage.test.ts index 90be6c6a8..9f62bd6d5 100644 --- a/frontend/tests/unit/core/messages/usage.test.ts +++ b/frontend/tests/unit/core/messages/usage.test.ts @@ -42,6 +42,27 @@ test("counts later usage-bearing snapshots for the same AI message id", () => { }); }); +test("reads usage metadata from additional kwargs when the SDK nests it there", () => { + const aiMessage = { + id: "ai-1", + type: "ai", + content: "Answer", + additional_kwargs: { + usage_metadata: { + input_tokens: 8, + output_tokens: 3, + total_tokens: 11, + }, + }, + } as unknown as Message; + + expect(accumulateUsage([aiMessage])).toEqual({ + inputTokens: 8, + outputTokens: 3, + totalTokens: 11, + }); +}); + test("keeps header and per-turn aggregation consistent for a reasoning+answer message", () => { // A single AI message carrying both reasoning (here via inline ) and // answer text now lands in exactly one assistant group (#3868), so its usage diff --git a/frontend/tests/unit/core/sidecar/api.test.ts b/frontend/tests/unit/core/sidecar/api.test.ts new file mode 100644 index 000000000..4daa707c7 --- /dev/null +++ b/frontend/tests/unit/core/sidecar/api.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, expect, rs, test } from "@rstest/core"; + +rs.mock("@/core/api/fetcher", () => ({ + fetch: rs.fn(), +})); + +rs.mock("@/core/config", () => ({ + getBackendBaseURL: () => "http://localhost", +})); + +import { fetch as fetcher } from "@/core/api/fetcher"; +import type { SidecarContext } from "@/core/sidecar"; +import { + createSidecarThread, + findLatestSidecarThread, +} from "@/core/sidecar/api"; +import type { AgentThread } from "@/core/threads"; + +const fetchWithAuth = rs.mocked(fetcher); + +beforeEach(() => { + fetchWithAuth.mockReset(); +}); + +function makeThread( + threadId: string, + metadata: Record = {}, +): AgentThread { + return { + thread_id: threadId, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + metadata, + status: "idle", + values: { title: threadId, messages: [] }, + } as unknown as AgentThread; +} + +function threadResponse(threadId: string): Response { + return new Response(JSON.stringify(makeThread(threadId)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +const context: SidecarContext = { + type: "referenced_message", + label: "Assistant message", + messageId: "msg-1", + role: "assistant", + content: "Answer", +}; + +test("finds the latest sidecar thread for a parent thread", async () => { + const sidecar = makeThread("sidecar-1", { + deerflow_sidecar: true, + parent_thread_id: "parent-1", + }); + const search = rs.fn().mockResolvedValue([sidecar]); + + await expect( + findLatestSidecarThread({ + parentThreadId: "parent-1", + apiClient: { threads: { search } }, + }), + ).resolves.toBe(sidecar); + + expect(search).toHaveBeenCalledWith({ + metadata: { + deerflow_sidecar: true, + parent_thread_id: "parent-1", + }, + limit: 1, + offset: 0, + sortBy: "updated_at", + sortOrder: "desc", + }); +}); + +test("ignores malformed sidecar search results", async () => { + const search = rs.fn().mockResolvedValue([makeThread("primary-1")]); + + await expect( + findLatestSidecarThread({ + parentThreadId: "parent-1", + apiClient: { threads: { search } }, + }), + ).resolves.toBeNull(); +}); + +test("ignores sidecar search results from another parent thread", async () => { + const search = rs.fn().mockResolvedValue([ + makeThread("sidecar-1", { + deerflow_sidecar: true, + parent_thread_id: "parent-2", + }), + ]); + + await expect( + findLatestSidecarThread({ + parentThreadId: "parent-1", + apiClient: { threads: { search } }, + }), + ).resolves.toBeNull(); +}); + +test("coalesces concurrent creates for the same parent into one request", async () => { + let resolveFetch: ((value: Response) => void) | undefined; + fetchWithAuth.mockReturnValueOnce( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const first = createSidecarThread({ parentThreadId: "parent-1", context }); + const second = createSidecarThread({ parentThreadId: "parent-1", context }); + + resolveFetch?.(threadResponse("sidecar-1")); + + const [firstThread, secondThread] = await Promise.all([first, second]); + + expect(fetchWithAuth).toHaveBeenCalledTimes(1); + expect(firstThread).toEqual(secondThread); +}); + +test("allows a new create after the in-flight request settles", async () => { + fetchWithAuth + .mockResolvedValueOnce(threadResponse("s-1")) + .mockResolvedValueOnce(threadResponse("s-2")); + + await createSidecarThread({ parentThreadId: "parent-1", context }); + await createSidecarThread({ parentThreadId: "parent-1", context }); + + expect(fetchWithAuth).toHaveBeenCalledTimes(2); +}); + +test("clears the in-flight entry when a create fails", async () => { + fetchWithAuth + .mockResolvedValueOnce(new Response(null, { status: 500 })) + .mockResolvedValueOnce(threadResponse("s-1")); + + await expect( + createSidecarThread({ parentThreadId: "parent-1", context }), + ).rejects.toThrow("Failed to create side conversation."); + + await expect( + createSidecarThread({ parentThreadId: "parent-1", context }), + ).resolves.toMatchObject({ thread_id: "s-1" }); + expect(fetchWithAuth).toHaveBeenCalledTimes(2); +}); diff --git a/frontend/tests/unit/core/sidecar/context.test.ts b/frontend/tests/unit/core/sidecar/context.test.ts new file mode 100644 index 000000000..d5d9196d1 --- /dev/null +++ b/frontend/tests/unit/core/sidecar/context.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from "@rstest/core"; + +import { + buildMessageSidecarContext, + buildParentConversationContext, + buildSidecarContextPrompt, +} from "@/core/sidecar/context"; + +test("builds message sidecar context with a readable label", () => { + const context = buildMessageSidecarContext( + { type: "ai", id: "msg-1", content: "A focused answer." }, + 3, + ); + + expect(context).toMatchObject({ + type: "referenced_message", + label: "Assistant message #3", + messageId: "msg-1", + role: "assistant", + content: "A focused answer.", + }); +}); + +test("builds sidecar context from selected text", () => { + const context = buildMessageSidecarContext( + { + type: "ai", + id: "msg-1", + content: "The full answer includes more detail.", + }, + 2, + { selectedText: "includes more detail" }, + ); + + expect(context).toMatchObject({ + type: "referenced_message", + label: "Selected assistant text #2", + messageId: "msg-1", + role: "assistant", + content: "includes more detail", + }); +}); + +test("renders hidden sidecar context prompt around quoted material", () => { + const prompt = buildSidecarContextPrompt({ + type: "referenced_message", + label: "Selected assistant text #2", + messageId: "msg-1", + role: "assistant", + content: "A side conversation panel.", + }); + + expect(prompt).toContain("You are answering in a side conversation"); + expect(prompt).toContain( + '', + ); + expect(prompt).toContain("Message ID: msg-1"); + expect(prompt).toContain("A side conversation panel."); +}); + +test("renders hidden sidecar context prompt around multiple quoted materials", () => { + const prompt = buildSidecarContextPrompt([ + { + type: "referenced_message", + label: "Selected assistant text #2", + messageId: "msg-1", + role: "assistant", + content: "First quoted fragment.", + }, + { + type: "referenced_message", + label: "Selected user text #3", + messageId: "msg-2", + role: "user", + content: "Second quoted fragment.", + }, + ] as never); + + expect(prompt).toContain('referenced_message index="1"'); + expect(prompt).toContain('referenced_message index="2"'); + expect(prompt).toContain("First quoted fragment."); + expect(prompt).toContain("Second quoted fragment."); +}); + +test("builds compact parent conversation context from visible messages", () => { + const parentContext = buildParentConversationContext([ + { + type: "human", + id: "parent-human-1", + content: "Plan the feature.", + }, + { + type: "human", + id: "hidden-context", + content: "Hidden implementation note.", + additional_kwargs: { hide_from_ui: true }, + }, + { + type: "ai", + id: "parent-ai-1", + content: "Use a side conversation with cited snippets.", + }, + ] as never); + + expect(parentContext).toEqual([ + { + role: "user", + messageId: "parent-human-1", + content: "Plan the feature.", + }, + { + role: "assistant", + messageId: "parent-ai-1", + content: "Use a side conversation with cited snippets.", + }, + ]); +}); + +test("renders parent conversation as read-only background in sidecar prompt", () => { + const prompt = buildSidecarContextPrompt( + [ + { + type: "referenced_message", + label: "Selected assistant text #2", + messageId: "msg-1", + role: "assistant", + content: "Quoted fragment.", + }, + ] as never, + { + parentConversation: [ + { + role: "user", + messageId: "parent-human-1", + content: "Plan the feature.", + }, + { + role: "assistant", + messageId: "parent-ai-1", + content: "Use a side conversation.", + }, + ], + }, + ); + + expect(prompt).toContain("', + ); + expect(prompt).toContain("Plan the feature."); + expect(prompt).toContain("Use a side conversation."); + expect(prompt).toContain('referenced_message index="1"'); +}); + +test("renders parent conversation for sidecar follow-ups without new references", () => { + const prompt = buildSidecarContextPrompt([], { + parentConversation: [ + { + role: "assistant", + messageId: "parent-ai-1", + content: "Use a side conversation.", + }, + ], + }); + + expect(prompt).toContain( + "The user did not attach new referenced messages for this side question.", + ); + expect(prompt).toContain( + "Use parent_conversation_context only as continuity background", + ); + expect(prompt).toContain(" { + expect(buildReferenceMessageMetadata(contexts)).toEqual({ + referenced_message_count: 2, + referenced_message_ids: ["parent-ai-1", "parent-ai-1"], + referenced_message_roles: ["assistant", "assistant"], + referenced_message_contexts: [ + { + label: "Selected assistant text #2", + message_id: "parent-ai-1", + role: "assistant", + content: "Build it as a side conversation.", + }, + { + label: "Selected assistant text #2", + message_id: "parent-ai-1", + role: "assistant", + content: "Keep the cited snippets compact.", + }, + ], + }); +}); + +test("reads visible message reference metadata defensively", () => { + expect( + readReferenceMessageContexts({ + referenced_message_contexts: [ + { + label: "Selected assistant text #2", + message_id: "parent-ai-1", + role: "assistant", + content: "Build it as a side conversation.", + }, + { + label: 42, + message_id: "ignored", + role: "assistant", + content: "Bad reference", + }, + ], + }), + ).toEqual([ + { + type: "referenced_message", + label: "Selected assistant text #2", + messageId: "parent-ai-1", + role: "assistant", + content: "Build it as a side conversation.", + }, + ]); +}); diff --git a/frontend/tests/unit/core/sidecar/reference-state.test.ts b/frontend/tests/unit/core/sidecar/reference-state.test.ts new file mode 100644 index 000000000..5c2eb9886 --- /dev/null +++ b/frontend/tests/unit/core/sidecar/reference-state.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@rstest/core"; + +import { + getNextSidecarOpenState, + type SidecarReferenceStateItem, +} from "@/core/sidecar/reference-state"; + +const firstReference: SidecarReferenceStateItem = { + id: 1, + context: { + type: "referenced_message", + label: "Selected assistant text #1", + messageId: "msg-1", + role: "assistant", + content: "First selected text.", + }, +}; + +const secondReference: SidecarReferenceStateItem = { + id: 2, + context: { + type: "referenced_message", + label: "Selected assistant text #1", + messageId: "msg-1", + role: "assistant", + content: "Second selected text.", + }, +}; + +test("keeps the existing sidecar thread when adding a new reference", () => { + const nextState = getNextSidecarOpenState({ + open: true, + sidecarThreadId: "sidecar-thread-1", + activeReferences: [], + nextReference: secondReference, + }); + + expect(nextState.sidecarThreadId).toBe("sidecar-thread-1"); + expect(nextState.activeReferences).toEqual([secondReference]); +}); + +test("accumulates references while drafting a new sidecar thread", () => { + const nextState = getNextSidecarOpenState({ + open: true, + sidecarThreadId: null, + activeReferences: [firstReference], + nextReference: secondReference, + }); + + expect(nextState.sidecarThreadId).toBeNull(); + expect(nextState.activeReferences).toEqual([firstReference, secondReference]); +}); diff --git a/frontend/tests/unit/core/sidecar/thread.test.ts b/frontend/tests/unit/core/sidecar/thread.test.ts new file mode 100644 index 000000000..9d51032bd --- /dev/null +++ b/frontend/tests/unit/core/sidecar/thread.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@rstest/core"; + +import { + SIDECAR_METADATA_KEY, + buildSidecarThreadMetadata, + isSidecarThread, + shouldShowInPrimaryThreadLists, +} from "@/core/sidecar/thread"; + +test("builds sidecar thread metadata from parent thread and context", () => { + const metadata = buildSidecarThreadMetadata("parent-1", { + type: "referenced_message", + label: "Assistant message", + messageId: "msg-1", + role: "assistant", + content: "Answer", + }); + + expect(metadata).toEqual({ + [SIDECAR_METADATA_KEY]: true, + parent_thread_id: "parent-1", + sidecar_context_type: "referenced_message", + sidecar_context_label: "Assistant message", + sidecar_context_count: 1, + referenced_message_id: "msg-1", + referenced_message_ids: ["msg-1"], + referenced_message_role: "assistant", + referenced_message_roles: ["assistant"], + }); +}); + +test("builds searchable sidecar thread metadata from multiple contexts", () => { + const metadata = buildSidecarThreadMetadata("parent-1", [ + { + type: "referenced_message", + label: "Assistant message #1", + messageId: "msg-1", + role: "assistant", + content: "First answer", + }, + { + type: "referenced_message", + label: "User message #2", + messageId: "msg-2", + role: "user", + content: "Follow-up request", + }, + ] as never); + + expect(metadata).toMatchObject({ + [SIDECAR_METADATA_KEY]: true, + parent_thread_id: "parent-1", + sidecar_context_type: "referenced_message", + sidecar_context_label: "Assistant message #1", + referenced_message_id: "msg-1", + referenced_message_role: "assistant", + sidecar_context_count: 2, + referenced_message_ids: ["msg-1", "msg-2"], + referenced_message_roles: ["assistant", "user"], + }); +}); + +test("keeps referenced ids/roles parallel when quoting one message twice", () => { + const metadata = buildSidecarThreadMetadata("parent-1", [ + { + type: "referenced_message", + label: "Selected assistant text #1", + messageId: "msg-1", + role: "assistant", + content: "First fragment", + }, + { + type: "referenced_message", + label: "Selected assistant text #1", + messageId: "msg-1", + role: "assistant", + content: "Second fragment", + }, + ] as never); + + expect(metadata.sidecar_context_count).toBe(2); + expect(metadata.referenced_message_ids).toEqual(["msg-1", "msg-1"]); + expect(metadata.referenced_message_roles).toEqual(["assistant", "assistant"]); + expect(metadata.referenced_message_ids).toHaveLength( + metadata.referenced_message_roles.length, + ); + expect(metadata.referenced_message_ids).toHaveLength( + metadata.sidecar_context_count, + ); +}); + +test("identifies sidecar threads and hides them from primary thread lists", () => { + const sidecar = { + thread_id: "sidecar-1", + metadata: { [SIDECAR_METADATA_KEY]: true }, + }; + const primary = { + thread_id: "primary-1", + metadata: {}, + }; + + expect(isSidecarThread(sidecar)).toBe(true); + expect(shouldShowInPrimaryThreadLists(sidecar)).toBe(false); + expect(isSidecarThread(primary)).toBe(false); + expect(shouldShowInPrimaryThreadLists(primary)).toBe(true); +}); diff --git a/frontend/tests/unit/core/threads/delete-thread.test.ts b/frontend/tests/unit/core/threads/delete-thread.test.ts new file mode 100644 index 000000000..6cbb93981 --- /dev/null +++ b/frontend/tests/unit/core/threads/delete-thread.test.ts @@ -0,0 +1,55 @@ +import { expect, rs, test } from "@rstest/core"; + +import { findSidecarThreadIdsForParent } from "@/core/threads/hooks"; +import type { AgentThread } from "@/core/threads/types"; + +function makeThread( + threadId: string, + metadata: Record = {}, +): AgentThread { + return { + thread_id: threadId, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + metadata, + status: "idle", + values: { title: threadId, messages: [] }, + } as unknown as AgentThread; +} + +test("finds only sidecar threads attached to the deleted parent thread", async () => { + const search = rs.fn().mockResolvedValueOnce([ + makeThread("sidecar-1", { + deerflow_sidecar: true, + parent_thread_id: "parent-1", + }), + makeThread("sidecar-other-parent", { + deerflow_sidecar: true, + parent_thread_id: "parent-2", + }), + makeThread("primary-1"), + ]); + + await expect( + findSidecarThreadIdsForParent( + { + threads: { + search, + }, + }, + "parent-1", + ), + ).resolves.toEqual(["sidecar-1"]); + + expect(search).toHaveBeenCalledWith({ + metadata: { + deerflow_sidecar: true, + parent_thread_id: "parent-1", + }, + limit: 100, + offset: 0, + sortBy: "updated_at", + sortOrder: "desc", + select: ["thread_id", "metadata"], + }); +}); diff --git a/frontend/tests/unit/core/threads/infinite.test.ts b/frontend/tests/unit/core/threads/infinite.test.ts index 0ac0fee3a..43be4cc35 100644 --- a/frontend/tests/unit/core/threads/infinite.test.ts +++ b/frontend/tests/unit/core/threads/infinite.test.ts @@ -6,6 +6,7 @@ import { } from "@tanstack/react-query"; import { + fetchInfiniteThreadsPage, filterInfiniteThreadsCache, getInfiniteThreadsNextPageParam, INFINITE_THREADS_PAGE_SIZE, @@ -25,12 +26,16 @@ import type { AgentThread } from "@/core/threads/types"; // / stream-finish in sync with both the legacy array cache and the new // infinite cache. -function makeThread(id: string, title = `Title ${id}`): AgentThread { +function makeThread( + id: string, + title = `Title ${id}`, + metadata: Record = {}, +): AgentThread { return { thread_id: id, created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z", - metadata: {}, + metadata, status: "idle", values: { title }, } as unknown as AgentThread; @@ -86,6 +91,66 @@ describe("getInfiniteThreadsNextPageParam", () => { }); }); +describe("fetchInfiniteThreadsPage", () => { + test("fills a visible page while advancing offsets by raw backend rows", async () => { + const search = rs + .fn() + .mockResolvedValueOnce([ + makeThread("sidecar-1", "Sidecar", { deerflow_sidecar: true }), + makeThread("primary-1"), + ]) + .mockResolvedValueOnce([makeThread("primary-2")]); + + const page = await fetchInfiniteThreadsPage( + { threads: { search } }, + { sortBy: "updated_at", sortOrder: "desc" }, + 0, + 2, + ); + + expect(page.map((thread) => thread.thread_id)).toEqual([ + "primary-1", + "primary-2", + ]); + expect(search).toHaveBeenNthCalledWith(1, { + sortBy: "updated_at", + sortOrder: "desc", + limit: 2, + offset: 0, + }); + expect(search).toHaveBeenNthCalledWith(2, { + sortBy: "updated_at", + sortOrder: "desc", + limit: 1, + offset: 2, + }); + expect(getInfiniteThreadsNextPageParam(page, [page], 2)).toBe(3); + }); + + test("keeps sidecar rows when the caller explicitly searches for sidecars", async () => { + const search = rs.fn().mockResolvedValueOnce([ + makeThread("sidecar-1", "Sidecar", { + deerflow_sidecar: true, + parent_thread_id: "parent-1", + }), + ]); + + const page = await fetchInfiniteThreadsPage( + { threads: { search } }, + { + sortBy: "updated_at", + sortOrder: "desc", + metadata: { deerflow_sidecar: true, parent_thread_id: "parent-1" }, + }, + 0, + 2, + ); + + expect(page.map((thread) => thread.thread_id)).toEqual(["sidecar-1"]); + expect(getInfiniteThreadsNextPageParam(page, [page], 2)).toBeUndefined(); + }); +}); + describe("mapInfiniteThreadsCache", () => { test("returns undefined when oldData is undefined", () => { expect(mapInfiniteThreadsCache(undefined, (t) => t)).toBeUndefined(); diff --git a/frontend/tests/unit/core/threads/send-message.test.ts b/frontend/tests/unit/core/threads/send-message.test.ts new file mode 100644 index 000000000..217499c9a --- /dev/null +++ b/frontend/tests/unit/core/threads/send-message.test.ts @@ -0,0 +1,62 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { expect, test } from "@rstest/core"; + +import { buildThreadSubmitMessages } from "@/core/threads/hooks"; + +test("builds thread submit messages with hidden sidecar context before the visible user message", () => { + const hiddenContext = { + type: "human", + content: "Hidden sidecar context", + additional_kwargs: { + hide_from_ui: true, + sidecar_context: true, + }, + } as Message; + + const messages = buildThreadSubmitMessages({ + text: "What should we do next?", + additionalInputMessages: [hiddenContext], + }); + + expect(messages).toEqual([ + hiddenContext, + { + type: "human", + content: [{ type: "text", text: "What should we do next?" }], + additional_kwargs: {}, + }, + ]); +}); + +test("keeps uploaded files on the visible user message only", () => { + const messages = buildThreadSubmitMessages({ + text: "Use this file", + additionalInputMessages: [ + { + type: "human", + content: "Hidden sidecar context", + additional_kwargs: { hide_from_ui: true }, + } as Message, + ], + filesForSubmit: [ + { + filename: "report.pdf", + size: 42, + path: "/uploads/report.pdf", + status: "uploaded", + }, + ], + }); + + expect(messages[0]?.additional_kwargs).toEqual({ hide_from_ui: true }); + expect(messages[1]?.additional_kwargs).toEqual({ + files: [ + { + filename: "report.pdf", + size: 42, + path: "/uploads/report.pdf", + status: "uploaded", + }, + ], + }); +}); diff --git a/frontend/tests/unit/core/threads/thread-search-query.test.ts b/frontend/tests/unit/core/threads/thread-search-query.test.ts index f6b35bfc2..ac2b65609 100644 --- a/frontend/tests/unit/core/threads/thread-search-query.test.ts +++ b/frontend/tests/unit/core/threads/thread-search-query.test.ts @@ -5,6 +5,21 @@ import { DEFAULT_THREAD_SEARCH_PARAMS, THREAD_SEARCH_REFETCH_INTERVAL_MS, } from "@/core/threads/thread-search-query"; +import type { AgentThread } from "@/core/threads/types"; + +function makeThread( + threadId: string, + metadata: Record = {}, +): AgentThread { + return { + thread_id: threadId, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + metadata, + status: "idle", + values: { title: threadId, messages: [] }, + } as unknown as AgentThread; +} test("thread search query refreshes so IM-created sessions appear in the sidebar", () => { const search = rs.fn(); @@ -17,3 +32,42 @@ test("thread search query refreshes so IM-created sessions appear in the sidebar expect(options.refetchIntervalInBackground).toBe(false); expect(options.refetchOnWindowFocus).toBe(false); }); + +test("thread search hides sidecar threads from primary lists by default", async () => { + const search = rs + .fn() + .mockResolvedValue([ + makeThread("primary-1"), + makeThread("sidecar-1", { deerflow_sidecar: true }), + makeThread("primary-2"), + ]); + const options = buildThreadsSearchQueryOptions( + { threads: { search } }, + DEFAULT_THREAD_SEARCH_PARAMS, + ); + + await expect(options.queryFn()).resolves.toEqual([ + makeThread("primary-1"), + makeThread("primary-2"), + ]); +}); + +test("thread search can explicitly include sidecar threads for parent lookup", async () => { + const sidecar = makeThread("sidecar-1", { + deerflow_sidecar: true, + parent_thread_id: "parent-1", + }); + const search = rs.fn().mockResolvedValue([sidecar]); + const options = buildThreadsSearchQueryOptions( + { threads: { search } }, + { + ...DEFAULT_THREAD_SEARCH_PARAMS, + metadata: { + deerflow_sidecar: true, + parent_thread_id: "parent-1", + }, + }, + ); + + await expect(options.queryFn()).resolves.toEqual([sidecar]); +});