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.
This commit is contained in:
Ryker_Feng 2026-07-05 00:12:16 +08:00 committed by GitHub
parent 84bbdf6e00
commit 6a4e5a3bb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 5353 additions and 606 deletions

View file

@ -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",
},
},
});

View file

@ -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 (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
isWelcomeMode
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
<SidebarTrigger className="md:hidden" />
{/* Agent badge */}
<div className="flex min-w-0 shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="hidden max-w-24 truncate text-xs font-medium sm:inline sm:max-w-none">
{agent?.name ?? agent_name}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex shrink-0 items-center sm:mr-4">
<Tooltip content={t.agents.newChat}>
<Button
className="px-2 sm:px-3"
size="sm"
variant="secondary"
onClick={() => {
router.push(`/workspace/agents/${agent_name}/chats/new`);
}}
>
<PlusSquare />
<span className="hidden sm:inline">{t.agents.newChat}</span>
</Button>
</Tooltip>
<TokenUsageIndicator
threadId={isNewThread ? undefined : threadId}
backendUsage={backendTokenUsage}
enabled={tokenUsageEnabled}
messages={thread.messages}
pendingMessages={pendingUsageMessages}
preferences={localSettings.tokenUsage}
onPreferencesChange={(preferences) =>
setLocalSettings("tokenUsage", preferences)
}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", !isWelcomeMode && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
tokenUsageInlineMode={tokenUsageInlineMode}
/>
</div>
<div
<ThreadContext.Provider value={{ thread, isMock }}>
<SidecarProvider
parentThreadId={threadId}
context={{ ...settings.context, agent_name }}
isMock={isMock}
>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
isWelcomeMode
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
<SidebarTrigger className="md:hidden" />
{/* Agent badge */}
<div className="flex min-w-0 shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="hidden max-w-24 truncate text-xs font-medium sm:inline sm:max-w-none">
{agent?.name ?? agent_name}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex shrink-0 items-center sm:mr-4">
<Tooltip content={t.agents.newChat}>
<Button
className="px-2 sm:px-3"
size="sm"
variant="secondary"
onClick={() => {
router.push(`/workspace/agents/${agent_name}/chats/new`);
}}
>
<PlusSquare />
<span className="hidden sm:inline">{t.agents.newChat}</span>
</Button>
</Tooltip>
<TokenUsageIndicator
threadId={isNewThread ? undefined : threadId}
backendUsage={backendTokenUsage}
enabled={tokenUsageEnabled}
messages={thread.messages}
pendingMessages={pendingUsageMessages}
preferences={localSettings.tokenUsage}
onPreferencesChange={(preferences) =>
setLocalSettings("tokenUsage", preferences)
}
/>
<SidecarTrigger />
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", !isWelcomeMode && "pt-10")}
testId="main-message-list"
threadId={threadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
tokenUsageInlineMode={tokenUsageInlineMode}
/>
</div>
<div
className={cn(
"relative w-full",
isWelcomeMode &&
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
isWelcomeMode
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
)}
>
{(hasGoal || hasTodos) && (
<div
className={cn(
"right-0 left-0 z-0",
isWelcomeMode ? "absolute -top-4" : "relative",
)}
>
<div
className={cn(
"relative w-full",
isWelcomeMode &&
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
isWelcomeMode
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
{(hasGoal || hasTodos) && (
<div
className={cn(
"right-0 bottom-0 left-0 flex flex-col",
isWelcomeMode ? "absolute" : "relative",
"right-0 left-0 z-0",
isWelcomeMode ? "absolute -top-4" : "relative",
)}
>
{activeGoal && <GoalStatus goal={activeGoal} />}
{hasTodos && (
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
)}
<div
className={cn(
"right-0 bottom-0 left-0 flex flex-col",
isWelcomeMode ? "absolute" : "relative",
)}
>
{activeGoal && <GoalStatus goal={activeGoal} />}
{hasTodos && (
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
)}
</div>
</div>
</div>
)}
<InputBox
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
autoFocus={isWelcomeMode}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isWelcomeMode &&
!hasGoal &&
!hasTodos && (
<AgentWelcome agent={agent} agentName={agent_name} />
)
}
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" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
<InputBox
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
autoFocus={isWelcomeMode}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isWelcomeMode &&
!hasGoal &&
!hasTodos && (
<AgentWelcome agent={agent} agentName={agent_name} />
)
}
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" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</div>
</main>
</div>
</ChatBox>
</main>
</div>
</ChatBox>
</SidecarProvider>
</ThreadContext.Provider>
);
}

View file

@ -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 (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
isWelcomeMode
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
<SidebarTrigger className="md:hidden" />
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex shrink-0 items-center gap-2">
{!isNewThread && <ThreadScheduledTasksLink threadId={threadId} />}
<TokenUsageIndicator
threadId={isNewThread ? undefined : threadId}
backendUsage={backendTokenUsage}
enabled={tokenUsageEnabled}
messages={thread.messages}
pendingMessages={pendingUsageMessages}
preferences={localSettings.tokenUsage}
onPreferencesChange={(preferences) =>
setLocalSettings("tokenUsage", preferences)
}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", !isWelcomeMode && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
tokenUsageInlineMode={tokenUsageInlineMode}
canRegenerate={
!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
!isUploading &&
!thread.isLoading
}
onRegenerateMessage={handleRegenerate}
/>
</div>
<div
<SidecarProvider
parentThreadId={threadId}
context={settings.context}
isMock={isMock}
>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
isWelcomeMode
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
<SidebarTrigger className="md:hidden" />
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex shrink-0 items-center gap-2">
{!isNewThread && (
<ThreadScheduledTasksLink threadId={threadId} />
)}
<TokenUsageIndicator
threadId={isNewThread ? undefined : threadId}
backendUsage={backendTokenUsage}
enabled={tokenUsageEnabled}
messages={thread.messages}
pendingMessages={pendingUsageMessages}
preferences={localSettings.tokenUsage}
onPreferencesChange={(preferences) =>
setLocalSettings("tokenUsage", preferences)
}
/>
<SidecarTrigger />
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", !isWelcomeMode && "pt-10")}
testId="main-message-list"
threadId={threadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
tokenUsageInlineMode={tokenUsageInlineMode}
canRegenerate={
!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
!isUploading &&
!thread.isLoading
}
onRegenerateMessage={handleRegenerate}
/>
</div>
<div
className={cn(
"relative w-full",
isWelcomeMode &&
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
isWelcomeMode
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
)}
>
{(hasGoal || hasTodos) && (
<div
className={cn(
"right-0 left-0 z-0",
isWelcomeMode ? "absolute -top-4" : "relative",
)}
>
<div
className={cn(
"relative w-full",
isWelcomeMode &&
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
isWelcomeMode
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
{(hasGoal || hasTodos) && (
<div
className={cn(
"right-0 bottom-0 left-0 flex flex-col",
isWelcomeMode ? "absolute" : "relative",
"right-0 left-0 z-0",
isWelcomeMode ? "absolute -top-4" : "relative",
)}
>
{activeGoal && <GoalStatus goal={activeGoal} />}
{hasTodos && (
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
)}
<div
className={cn(
"right-0 bottom-0 left-0 flex flex-col",
isWelcomeMode ? "absolute" : "relative",
)}
>
{activeGoal && <GoalStatus goal={activeGoal} />}
{hasTodos && (
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
)}
</div>
</div>
</div>
)}
{mountedRef.current ? (
<InputBox
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
autoFocus={isWelcomeMode}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isWelcomeMode &&
!hasGoal &&
!hasTodos && <Welcome mode={settings.context.mode} />
}
disabled={
isMock ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
onContextChange={(context) =>
setSettings("context", context)
}
onGoalChange={setLocalGoal}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full rounded-2xl",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
/>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
)}
{mountedRef.current ? (
<InputBox
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
autoFocus={isWelcomeMode}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isWelcomeMode &&
!hasGoal &&
!hasTodos && <Welcome mode={settings.context.mode} />
}
disabled={
isMock ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
onContextChange={(context) =>
setSettings("context", context)
}
onGoalChange={setLocalGoal}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full rounded-2xl",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
/>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</div>
</main>
</div>
</ChatBox>
</main>
</div>
</ChatBox>
</SidecarProvider>
</ThreadContext.Provider>
);
}

View file

@ -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);
}}
>

View file

@ -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<GroupImperativeHandle>(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<RightPanelKind | null>(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 (
<ResizablePanelGroup
<div
id={`${resizableIdBase}-panels`}
orientation="horizontal"
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
className={cn(
"[container-type:inline-size] grid size-full min-h-0 transition-[grid-template-columns] duration-[280ms] ease-out motion-reduce:transition-none",
rightPanelOpen
? "grid-cols-[minmax(0,1fr)_1px_minmax(0,40%)]"
: "grid-cols-[minmax(0,1fr)_0px_0px]",
)}
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
<div className="relative min-h-0 min-w-0" id="chat">
{children}
</ResizablePanel>
<ResizableHandle
</div>
<div
id={`${resizableIdBase}-separator`}
aria-hidden="true"
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
"bg-border opacity-33 transition-opacity duration-200 ease-out motion-reduce:transition-none",
!rightPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
<aside
aria-hidden={!rightPanelOpen}
className={cn(
"transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
"min-h-0 min-w-0 overflow-hidden transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
!rightPanelOpen && "pointer-events-none opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
"ml-auto h-full w-[40cqw] transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
renderedRightPanel === "sidecar" ? "p-0" : "p-4",
rightPanelOpen ? "opacity-100" : "opacity-0",
)}
>
{selectedArtifact ? (
{renderedRightPanel === "sidecar" ? (
<SidecarPanel />
) : renderedRightPanel === "artifacts" && selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : (
) : renderedRightPanel === "artifacts" ? (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
@ -177,10 +205,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
</div>
)}
</div>
)}
) : null}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</aside>
</div>
);
};

View file

@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export type InputBoxSubmitOptions = {
additionalKwargs?: Record<string, unknown>;
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) =>
[
`<referenced_message index="${index + 1}" label="${escapeXmlAttribute(
context.label,
)}">`,
`Role: ${context.role === "user" ? "User" : "Assistant"}`,
context.messageId ? `Message ID: ${context.messageId}` : null,
"",
context.content,
"</referenced_message>",
"",
].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<void>;
onSubmit?: (
message: PromptInputMessage,
options?: InputBoxSubmitOptions,
) => void | Promise<void>;
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<void>((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({
</div>
</div>
)}
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
<PromptInputHeader className="flex-wrap px-3 pt-3 pb-0 empty:hidden">
<PromptInputAttachments className="contents p-0">
{(attachment) => (
<div className="max-w-60">
<PromptInputAttachment data={attachment} />
</div>
)}
</PromptInputAttachments>
{sidecar && sidecar.conversationQuotes.length > 0 && (
<ReferenceAttachmentSummary
references={sidecar.conversationQuotes}
testId="conversation-quote-attachment"
onClear={() => sidecar.clearConversationQuotes()}
/>
)}
</PromptInputHeader>
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
<PromptInputTextarea
className={cn("size-full")}

View file

@ -42,11 +42,13 @@ import {
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { readReferenceMessageContexts } from "@/core/sidecar";
import { SafeReasoningContent } from "@/core/streamdown/components";
import { cn } from "@/lib/utils";
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
import { CopyButton } from "../copy-button";
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
import { MarkdownContent } from "./markdown-content";
import { createMarkdownLinkComponent } from "./markdown-link";
@ -245,7 +247,7 @@ function MessageContent_({
clientTurnDurations.set(`${threadId}:${message.id}`, rawTurnDuration);
setCachedDuration(rawTurnDuration);
}
}, [rawTurnDuration, message.id]);
}, [rawTurnDuration, message.id, threadId]);
const handleDurationChange = useCallback(
(d: number | undefined) => {
@ -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 && (
<ReferenceAttachmentSummary
className="self-end shadow-none"
references={referenceAttachments}
testId="message-reference-attachment"
/>
)}
{filesList}
{contentToDisplay && (
<AIElementMessageContent className="w-full max-w-full">

View file

@ -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<AgentThreadState>;
paddingBottom?: number;
@ -189,8 +226,14 @@ export function MessageList({
supersededMessageIds: string[],
) => void | Promise<void>;
canRegenerate?: boolean;
enableSidecarActions?: boolean;
initialScroll?: ConversationProps["initial"];
resizeScroll?: ConversationProps["resize"];
}) {
const { t } = useI18n();
const sidecar = useMaybeSidecar();
const [selectionToolbar, setSelectionToolbar] =
useState<SelectionToolbarState | null>(null);
const [turnStartTime, setTurnStartTime] = useState<number | null>(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<HTMLDivElement>,
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<HTMLElement>("[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 (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-8">
<LoadMoreHistoryIndicator
isLoading={isHistoryLoading}
hasMore={hasMoreHistory}
loadMore={loadMoreHistory}
/>
{groupedMessages.map((group, groupIndex) => {
const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex];
const groupIsLoading =
thread.isLoading && groupIndex === lastGroupIndex;
<>
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
data-testid={testId}
initial={initialScroll}
resize={resizeScroll}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-8">
<LoadMoreHistoryIndicator
isLoading={isHistoryLoading}
hasMore={hasMoreHistory}
loadMore={loadMoreHistory}
/>
{groupedMessages.map((group, groupIndex) => {
const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex];
const groupIsLoading =
thread.isLoading && groupIndex === lastGroupIndex;
if (group.type === "human" || group.type === "assistant") {
return (
<div
key={group.id}
className={cn(
"w-full",
group.type === "assistant" && "group/assistant-turn",
)}
>
{group.messages.map((msg) => {
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
message={msg}
isLoading={
thread.isLoading &&
groupIndex === groupedMessages.length - 1
}
threadId={threadId}
showCopyButton={group.type !== "assistant"}
turnStartTime={
groupIndex === groupedMessages.length - 1
? turnStartTime
: null
}
/>
);
})}
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
{group.type === "assistant" &&
renderAssistantActions(
group.messages,
isAssistantMessageGroupStreaming(
group.messages,
streamingMessages,
),
group.id === latestAssistantGroupId,
)}
</div>
);
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
if (group.type === "human" || group.type === "assistant") {
return (
<div key={group.id} className="w-full">
<MarkdownContent
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
rehypePlugins={rehypePlugins}
/>
<div
key={group.id}
data-assistant-turn={
group.type === "assistant" ? "" : undefined
}
className={cn(
"w-full",
group.type === "assistant" && "group/assistant-turn",
)}
>
{group.messages.map((msg) => {
const item = (
<MessageListItem
message={msg}
isLoading={
thread.isLoading &&
groupIndex === groupedMessages.length - 1
}
threadId={threadId}
showCopyButton={group.type !== "assistant"}
turnStartTime={
groupIndex === groupedMessages.length - 1
? turnStartTime
: null
}
/>
);
if (
group.type !== "assistant" ||
!enableSidecarActions ||
msg.type !== "ai"
) {
return <div key={`${group.id}/${msg.id}`}>{item}</div>;
}
return (
<div
key={`${group.id}/${msg.id}`}
onMouseUp={(event) =>
handleAssistantTextSelection(
event,
msg,
groupIndex + 1,
)
}
>
{item}
</div>
);
})}
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
{group.type === "assistant" &&
renderAssistantActions(
group.messages,
isAssistantMessageGroupStreaming(
group.messages,
streamingMessages,
),
group.id === latestAssistantGroupId,
)}
</div>
);
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
return (
<div key={group.id} className="w-full">
<MarkdownContent
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
rehypePlugins={rehypePlugins}
/>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
</div>
);
}
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 (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
rehypePlugins={rehypePlugins}
className="mb-4"
/>
)}
<ArtifactFileList files={files} threadId={threadId} />
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
</div>
);
}
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 (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
rehypePlugins={rehypePlugins}
className="mb-4"
/>
)}
<ArtifactFileList files={files} threadId={threadId} />
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
</div>
);
} else if (group.type === "assistant:subagent") {
const tasks = new Set<Subtask>();
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<Subtask>();
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(
<div
key="subtask-count"
className="text-muted-foreground pt-2 text-sm font-normal"
>
{t.subtasks.executing(tasks.size)}
</div>,
);
}
for (const message of group.messages.filter(
(message) => message.type === "ai",
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={groupIsLoading}
tokenDebugSteps={tokenDebugSteps.filter(
(step) => 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(
<SubtaskCard
key={"task-group-" + taskId}
taskId={taskId}
threadId={threadId}
runId={(message as { run_id?: string }).run_id}
isLoading={groupIsLoading}
/>,
);
updateSubtask({ id: taskId, ...parsed });
}
}
}
const results: React.ReactNode[] = [];
const subagentDebugMessageIds: string[] = [];
if (tasks.size > 0) {
results.push(
return (
<div
key="subtask-count"
className="text-muted-foreground pt-2 text-sm font-normal"
key={"subtask-group-" + group.id}
className="relative z-1 flex flex-col gap-2"
>
{t.subtasks.executing(tasks.size)}
</div>,
{results}
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
debugMessageIds: subagentDebugMessageIds,
})}
</div>
);
}
for (const message of group.messages.filter(
(message) => message.type === "ai",
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={groupIsLoading}
tokenDebugSteps={tokenDebugSteps.filter(
(step) => 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(
<SubtaskCard
key={"task-group-" + taskId}
taskId={taskId}
threadId={threadId}
runId={(message as { run_id?: string }).run_id}
isLoading={groupIsLoading}
/>,
);
}
}
return (
<div
key={"subtask-group-" + group.id}
className="relative z-1 flex flex-col gap-2"
>
{results}
<div key={"group-" + group.id} className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
tokenDebugSteps={tokenDebugSteps.filter((step) =>
group.messages.some(
(message) => message.id === step.messageId,
),
)}
showTokenDebugSummaries={
tokenUsageInlineMode === "step_debug"
}
/>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
debugMessageIds: subagentDebugMessageIds,
inlineDebug: false,
})}
</div>
);
}
return (
<div key={"group-" + group.id} className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
tokenDebugSteps={tokenDebugSteps.filter((step) =>
group.messages.some(
(message) => message.id === step.messageId,
),
)}
showTokenDebugSummaries={tokenUsageInlineMode === "step_debug"}
/>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
inlineDebug: false,
})}
})}
{thread.isLoading && !hasActiveAssistantText && (
<div className="w-full">
<Reasoning isStreaming={true} startTimeProp={turnStartTime}>
<ReasoningTrigger hasContent={false} />
</Reasoning>
</div>
);
})}
{thread.isLoading && !hasActiveAssistantText && (
<div className="w-full">
<Reasoning isStreaming={true} startTimeProp={turnStartTime}>
<ReasoningTrigger hasContent={false} />
</Reasoning>
</div>
)}
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
</Conversation>
)}
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
</Conversation>
{selectionToolbar && sidecar && (
<div
className={cn(
"bg-popover text-popover-foreground border-border fixed z-50 flex -translate-x-1/2 items-center gap-1 rounded-full border p-1 shadow-lg",
selectionToolbar.placement === "bottom"
? "translate-y-0"
: "-translate-y-full",
)}
data-sidecar-selection-toolbar
style={{ left: selectionToolbar.x, top: selectionToolbar.y }}
>
<Button
className="h-8 rounded-full px-2.5 text-xs"
size="sm"
type="button"
variant="ghost"
onClick={handleAddSelectionToConversation}
onMouseDown={(event) => event.preventDefault()}
>
<MessageCircleIcon className="size-3.5" />
{t.sidecar.addToConversation}
</Button>
<Button
className="h-8 rounded-full px-2.5 text-xs"
size="sm"
type="button"
variant="ghost"
onClick={handleAskSelectionInSidecar}
onMouseDown={(event) => event.preventDefault()}
>
<MessageSquarePlusIcon className="size-3.5" />
{t.sidecar.askInSideChat}
</Button>
<Button
aria-label={t.common.close}
className="size-8 rounded-full"
size="icon-sm"
type="button"
variant="ghost"
onClick={clearSelectionToolbar}
onMouseDown={(event) => event.preventDefault()}
>
<span aria-hidden="true">×</span>
</Button>
</div>
)}
</>
);
}

View file

@ -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<string | null>;
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<SidecarContextValue | null>(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<SidecarReference[]>(
[],
);
const [sidecarThreadId, setSidecarThreadId] = useState<string | null>(null);
const [sidecarContext, setSidecarContext] =
useState<ThreadStreamOptions["context"]>(context);
const [conversationQuotes, setConversationQuotes] = useState<
SidecarReference[]
>([]);
const referenceIdRef = useRef(0);
const parentThreadIdRef = useRef(parentThreadId);
const sidecarThreadIdRef = useRef<string | null>(null);
const restoreRequestRef = useRef<{
parentThreadId: string;
promise: Promise<string | null>;
} | 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<SidecarContextValue>(
() => ({
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 (
<SidecarContextObject.Provider value={value}>
{children}
</SidecarContextObject.Provider>
);
}
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;
}

View file

@ -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";

View file

@ -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 (
<div className="w-72 max-w-[80vw] space-y-1.5">
{references.map((reference) => (
<div
className="line-clamp-3 text-left text-sm leading-6 break-words"
key={reference.id}
>
{`"${formatPreviewText(reference.context.content)}"`}
</div>
))}
</div>
);
}
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 (
<div
className={cn(
"border-border bg-muted/40 text-foreground inline-flex max-w-[min(18rem,100%)] items-center gap-1.5 rounded-full border px-2.5 py-1.5 shadow-sm",
className,
)}
data-testid={testId}
>
<Tooltip content={<ReferencePreview references={references} />}>
<span className="flex min-w-0 cursor-default items-center gap-1.5">
<MessageSquareQuoteIcon className="text-muted-foreground size-4 shrink-0" />
<span className="truncate text-sm font-medium">{label}</span>
</span>
</Tooltip>
{onClear && (
<Button
aria-label={t.sidecar.clearReferences}
className="text-muted-foreground hover:text-foreground size-6 rounded-full"
size="icon-sm"
type="button"
variant="ghost"
onClick={onClear}
>
<XIcon className="size-3.5" />
</Button>
)}
</div>
);
}

View file

@ -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<ThreadStreamOptions["context"]["mode"]>;
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 (
<div
className={cn("flex size-full min-h-0 flex-col", className)}
data-testid="sidecar-panel"
>
<header className="border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-3">
<MessageSquareTextIcon className="text-muted-foreground size-4" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{t.sidecar.title}</div>
<div className="text-muted-foreground truncate text-xs">
{sidecar.activeReferences.length > 0
? referenceCountLabel
: sidecar.sidecarThreadId
? t.sidecar.continuing
: t.sidecar.noContext}
</div>
</div>
<Tooltip content={t.common.close}>
<Button
aria-label={t.common.close}
size="icon-sm"
variant="ghost"
onClick={() => sidecar.close()}
>
<XIcon />
</Button>
</Tooltip>
</header>
<div className="min-h-0 flex-1">
{sidecar.sidecarThreadId ? (
<MessageList
className="size-full"
testId="sidecar-message-list"
threadId={sidecar.sidecarThreadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM / 2}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
tokenUsageInlineMode={tokenUsageInlineMode}
initialScroll="instant"
resizeScroll="instant"
/>
) : (
<ConversationEmptyState
icon={<MessageSquareTextIcon className="size-5" />}
title={t.sidecar.emptyTitle}
description={t.sidecar.emptyDescription}
/>
)}
</div>
<div className="bg-background/95 shrink-0 px-3 pt-3 pb-4 sm:px-4">
<PromptInputProvider key={sidecar.parentThreadId}>
<PromptInput
className="bg-background/85 rounded-2xl backdrop-blur-sm *:data-[slot='input-group']:rounded-2xl"
disabled={disabled}
multiple
onSubmit={handleSubmit}
>
<PromptInputHeader className="flex-wrap px-3 pt-3 pb-0 empty:hidden">
<PromptInputAttachments className="contents p-0">
{(attachment) => (
<div className="max-w-48">
<PromptInputAttachment data={attachment} />
</div>
)}
</PromptInputAttachments>
{sidecar.activeReferences.length > 0 && (
<ReferenceAttachmentSummary
references={sidecar.activeReferences}
testId="sidecar-reference-attachment"
onClear={() => sidecar.clearActiveReferences()}
/>
)}
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
className="max-h-36 min-h-16 text-sm"
disabled={disabled}
placeholder={t.sidecar.placeholder}
/>
</PromptInputBody>
<PromptInputFooter className="@container flex flex-nowrap gap-2">
<PromptInputTools className="min-w-0 flex-1 flex-nowrap overflow-hidden">
<SidecarAddAttachmentsButton uploadLimits={uploadLimits} />
<SidecarModeMenu
context={sidecar.context}
supportThinking={supportThinking}
onModeSelect={handleModeSelect}
/>
</PromptInputTools>
<PromptInputTools className="min-w-0 justify-end">
<SidecarModelSelector
className="max-w-40 min-w-0 sm:max-w-56 @max-[240px]:hidden"
context={sidecar.context}
models={models}
open={modelDialogOpen}
selectedModel={selectedModel}
onModelSelect={handleModelSelect}
onOpenChange={setModelDialogOpen}
/>
<Tooltip content={t.sidecar.send}>
<PromptInputSubmit
className="rounded-full"
disabled={disabled}
status={
thread.isLoading || creatingThread || queuedSubmit
? "submitted"
: "ready"
}
variant="outline"
/>
</Tooltip>
</PromptInputTools>
</PromptInputFooter>
</PromptInput>
</PromptInputProvider>
</div>
</div>
);
}
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 (
<Tooltip content={<span className="block max-w-80">{tooltipContent}</span>}>
<PromptInputButton
aria-label={t.inputBox.addAttachments}
className="px-2!"
data-testid="sidecar-add-attachments-button"
onClick={() => attachments.openFileDialog()}
>
<PaperclipIcon className="size-3" />
</PromptInputButton>
</Tooltip>
);
}
function SidecarModeMenu({
context,
supportThinking,
onModeSelect,
}: {
context: ThreadStreamOptions["context"];
supportThinking: boolean;
onModeSelect: (mode: SidecarInputMode) => void;
}) {
const { t } = useI18n();
const mode = getResolvedMode(context.mode, supportThinking);
return (
<PromptInputActionMenu>
<ModeHoverGuide mode={mode}>
<PromptInputActionMenuTrigger className="max-w-20 min-w-0 gap-1! px-2!">
<div>
{mode === "flash" && <ZapIcon className="size-3" />}
{mode === "thinking" && <LightbulbIcon className="size-3" />}
{mode === "pro" && <GraduationCapIcon className="size-3" />}
{mode === "ultra" && (
<RocketIcon className="size-3 text-[#dabb5e]" />
)}
</div>
<div
className={cn(
"truncate text-xs font-normal",
mode === "ultra" && "golden-text",
)}
>
{(mode === "flash" && t.inputBox.flashMode) ||
(mode === "thinking" && t.inputBox.reasoningMode) ||
(mode === "pro" && t.inputBox.proMode) ||
(mode === "ultra" && t.inputBox.ultraMode)}
</div>
</PromptInputActionMenuTrigger>
</ModeHoverGuide>
<PromptInputActionMenuContent className="w-80">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.mode}
</DropdownMenuLabel>
<PromptInputActionMenuItem
className={cn(
mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => onModeSelect("flash")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<ZapIcon
className={cn(
"mr-2 size-4",
mode === "flash" && "text-accent-foreground",
)}
/>
{t.inputBox.flashMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.flashModeDescription}
</div>
</div>
{mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => onModeSelect("thinking")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<LightbulbIcon
className={cn(
"mr-2 size-4",
mode === "thinking" && "text-accent-foreground",
)}
/>
{t.inputBox.reasoningMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.reasoningModeDescription}
</div>
</div>
{mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
<PromptInputActionMenuItem
className={cn(
mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => onModeSelect("pro")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<GraduationCapIcon
className={cn(
"mr-2 size-4",
mode === "pro" && "text-accent-foreground",
)}
/>
{t.inputBox.proMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.proModeDescription}
</div>
</div>
{mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => onModeSelect("ultra")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<RocketIcon
className={cn(
"mr-2 size-4",
mode === "ultra" && "text-[#dabb5e]",
)}
/>
<div className={cn(mode === "ultra" && "golden-text")}>
{t.inputBox.ultraMode}
</div>
</div>
<div className="pl-7 text-xs">
{t.inputBox.ultraModeDescription}
</div>
</div>
{mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
);
}
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 (
<ModelSelector open={open} onOpenChange={onOpenChange}>
<ModelSelectorTrigger asChild>
<PromptInputButton className={cn("min-w-0 px-2!", className)}>
<div className="flex min-w-0 flex-col items-start text-left">
<ModelSelectorName className="truncate text-xs font-normal">
{selectedModel.display_name}
</ModelSelectorName>
</div>
</PromptInputButton>
</ModelSelectorTrigger>
<ModelSelectorContent>
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
<ModelSelectorList>
{models.map((model) => (
<ModelSelectorItem
key={model.name}
value={model.name}
onSelect={() => onModelSelect(model.name)}
>
<div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{model.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]">
{model.model}
</span>
</div>
{model.name === context.model_name ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
);
}

View file

@ -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 (
<Tooltip content={label}>
<Button
aria-label={label}
className="text-muted-foreground hover:text-foreground"
data-testid="sidecar-header-trigger"
disabled={isReconciling}
size="icon"
type="button"
variant={sidecar.open ? "secondary" : "ghost"}
onClick={() => {
void handleClick();
}}
>
<MessageSquareTextIcon />
</Button>
</Tooltip>
);
}

View file

@ -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",

View file

@ -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;

View file

@ -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: "渠道",

View file

@ -14,9 +14,21 @@ export function getUsageMetadata(message: Message): TokenUsage | null {
if (message.type !== "ai") {
return null;
}
const usage = (message as Record<string, unknown>).usage_metadata as
| { input_tokens?: number; output_tokens?: number; total_tokens?: number }
| undefined;
const usage =
((message as Record<string, unknown>).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;
}

View file

@ -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<string, unknown>) => Promise<AgentThread[]>;
};
};
// 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<string, Promise<AgentThread>>();
export async function createSidecarThread({
parentThreadId,
context,
}: {
parentThreadId: string;
context: SidecarContext | SidecarContext[];
}): Promise<AgentThread> {
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<AgentThread> {
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<AgentThread | null> {
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
);
}

View file

@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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
? `<parent_conversation_context message_count="${parentConversation.length}">`
: null,
...parentConversation.flatMap((message, index) =>
[
`<parent_message index="${index + 1}" role="${labelOfRole(
message.role,
)}"${
message.messageId
? ` message_id="${escapeXmlAttribute(message.messageId)}"`
: ""
}>`,
message.content,
"</parent_message>",
"",
].filter((line): line is string => line !== null),
),
parentConversation.length > 0 ? "</parent_conversation_context>" : null,
parentConversation.length > 0 ? "" : null,
...contexts.flatMap((context, index) =>
[
`<referenced_message index="${index + 1}" label="${escapeXmlAttribute(
context.label,
)}">`,
`Role: ${labelOfRole(context.role)}`,
context.messageId ? `Message ID: ${context.messageId}` : null,
"",
context.content,
"</referenced_message>",
"",
].filter((line): line is string => line !== null),
),
].filter((line): line is string => line !== null);
return lines.join("\n").trim();
}

View file

@ -0,0 +1,4 @@
export * from "./context";
export * from "./reference-metadata";
export * from "./reference-state";
export * from "./thread";

View file

@ -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<string, unknown> {
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,
},
];
});
}

View file

@ -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],
};
}

View file

@ -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<AgentThread, "metadata">
| { metadata?: Record<string, unknown> },
) {
return thread.metadata?.[SIDECAR_METADATA_KEY] === true;
}
export function shouldShowInPrimaryThreadLists(
thread:
| Pick<AgentThread, "metadata">
| { metadata?: Record<string, unknown> },
) {
return !isSidecarThread(thread);
}

View file

@ -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<string, unknown>;
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<unknown>;
search: (query: Record<string, unknown>) => Promise<AgentThread[]>;
};
};
type ThreadSidecarSearchClient = {
threads: {
search: (query: Record<string, unknown>) => Promise<AgentThread[]>;
};
};
type RegeneratePrepareResponse = {
@ -71,6 +94,35 @@ type RegeneratePrepareResponse = {
target_run_id: string;
};
export function buildThreadSubmitMessages({
text,
additionalKwargs,
additionalInputMessages = [],
filesForSubmit = [],
}: {
text: string;
additionalKwargs?: Record<string, unknown>;
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<ThreadsClient["search"]>[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<AgentThread[]> {
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<AgentThreadState>({
...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<AgentThreadState>({
...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<AgentThread[]> | undefined) =>
filterInfiniteThreadsCache(oldData, (t) => t.thread_id !== threadId),
filterInfiniteThreadsCache(
oldData,
(t) => !deletedThreadIds.has(t.thread_id),
),
);
},

View file

@ -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<ThreadSearchParams, "metadata">;
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<AgentThreadState>(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;

File diff suppressed because it is too large Load diff

View file

@ -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<string, unknown>;
} | 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<string, unknown>;
};
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<string, unknown> = {},
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: {} },

View file

@ -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 <think>) and
// answer text now lands in exactly one assistant group (#3868), so its usage

View file

@ -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<string, unknown> = {},
): 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<Response>((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);
});

View file

@ -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(
'<referenced_message index="1" label="Selected assistant text #2">',
);
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("<parent_conversation_context");
expect(prompt).toContain(
'<parent_message index="1" role="User" message_id="parent-human-1">',
);
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("<parent_conversation_context");
expect(prompt).toContain("Use a side conversation.");
expect(prompt).not.toContain("<referenced_message");
});

View file

@ -0,0 +1,75 @@
import { expect, test } from "@rstest/core";
import {
buildReferenceMessageMetadata,
readReferenceMessageContexts,
type SidecarContext,
} from "@/core/sidecar";
const contexts: SidecarContext[] = [
{
type: "referenced_message",
label: "Selected assistant text #2",
messageId: "parent-ai-1",
role: "assistant",
content: "Build it as a side conversation.",
},
{
type: "referenced_message",
label: "Selected assistant text #2",
messageId: "parent-ai-1",
role: "assistant",
content: "Keep the cited snippets compact.",
},
];
test("builds visible message reference metadata for selected contexts", () => {
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.",
},
]);
});

View file

@ -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]);
});

View file

@ -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);
});

View file

@ -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<string, unknown> = {},
): 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"],
});
});

View file

@ -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<string, unknown> = {},
): 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();

View file

@ -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",
},
],
});
});

View file

@ -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<string, unknown> = {},
): 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]);
});