mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 08:28:00 +00:00
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:
parent
84bbdf6e00
commit
6a4e5a3bb2
@ -1,5 +1,8 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
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({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
@ -10,7 +13,7 @@ export default defineConfig({
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://localhost:3000",
|
baseURL,
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
@ -22,14 +25,17 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
webServer: {
|
webServer: skipWebServer
|
||||||
command: "./node_modules/.bin/next build && ./node_modules/.bin/next start",
|
? undefined
|
||||||
url: "http://localhost:3000",
|
: {
|
||||||
reuseExistingServer: !process.env.CI,
|
command:
|
||||||
timeout: 120_000,
|
"./node_modules/.bin/next build && ./node_modules/.bin/next start",
|
||||||
env: {
|
url: baseURL,
|
||||||
SKIP_ENV_VALIDATION: "1",
|
reuseExistingServer: !process.env.CI,
|
||||||
DEER_FLOW_AUTH_DISABLED: "1",
|
timeout: 120_000,
|
||||||
},
|
env: {
|
||||||
},
|
SKIP_ENV_VALIDATION: "1",
|
||||||
|
DEER_FLOW_AUTH_DISABLED: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,12 +12,19 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
|||||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||||
import { GoalStatus } from "@/components/workspace/goal-status";
|
import { GoalStatus } from "@/components/workspace/goal-status";
|
||||||
import { InputBox } from "@/components/workspace/input-box";
|
import {
|
||||||
|
InputBox,
|
||||||
|
type InputBoxSubmitOptions,
|
||||||
|
} from "@/components/workspace/input-box";
|
||||||
import {
|
import {
|
||||||
MessageList,
|
MessageList,
|
||||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||||
} from "@/components/workspace/messages";
|
} from "@/components/workspace/messages";
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
|
import {
|
||||||
|
SidecarProvider,
|
||||||
|
SidecarTrigger,
|
||||||
|
} from "@/components/workspace/sidecar";
|
||||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||||
import { TodoList } from "@/components/workspace/todo-list";
|
import { TodoList } from "@/components/workspace/todo-list";
|
||||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||||
@ -146,8 +153,13 @@ export default function AgentChatPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage, options?: InputBoxSubmitOptions) => {
|
||||||
const sendPromise = sendMessage(threadId, message, { agent_name });
|
const sendPromise = sendMessage(
|
||||||
|
threadId,
|
||||||
|
message,
|
||||||
|
{ agent_name },
|
||||||
|
options,
|
||||||
|
);
|
||||||
if (message.files.length > 0) {
|
if (message.files.length > 0) {
|
||||||
return sendPromise;
|
return sendPromise;
|
||||||
}
|
}
|
||||||
@ -170,156 +182,166 @@ export default function AgentChatPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread }}>
|
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||||
<ChatBox threadId={threadId}>
|
<SidecarProvider
|
||||||
<div className="relative flex size-full min-h-0 justify-between">
|
parentThreadId={threadId}
|
||||||
<header
|
context={{ ...settings.context, agent_name }}
|
||||||
className={cn(
|
isMock={isMock}
|
||||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
|
>
|
||||||
isWelcomeMode
|
<ChatBox threadId={threadId}>
|
||||||
? "bg-background/0 backdrop-blur-none"
|
<div className="relative flex size-full min-h-0 justify-between">
|
||||||
: "bg-background/80 shadow-xs backdrop-blur",
|
<header
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-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 ? "absolute" : "relative shrink-0 pb-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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full",
|
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
|
||||||
isWelcomeMode &&
|
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
|
||||||
"-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
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
"relative w-full",
|
||||||
"right-0 left-0 z-0",
|
isWelcomeMode &&
|
||||||
isWelcomeMode ? "absolute -top-4" : "relative",
|
"-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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"right-0 bottom-0 left-0 flex flex-col",
|
"right-0 left-0 z-0",
|
||||||
isWelcomeMode ? "absolute" : "relative",
|
isWelcomeMode ? "absolute -top-4" : "relative",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
<div
|
||||||
{hasTodos && (
|
className={cn(
|
||||||
<TodoList
|
"right-0 bottom-0 left-0 flex flex-col",
|
||||||
className="bg-background/5"
|
isWelcomeMode ? "absolute" : "relative",
|
||||||
todos={thread.values.todos ?? []}
|
)}
|
||||||
hidden={false}
|
>
|
||||||
/>
|
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||||
)}
|
{hasTodos && (
|
||||||
|
<TodoList
|
||||||
|
className="bg-background/5"
|
||||||
|
todos={thread.values.todos ?? []}
|
||||||
|
hidden={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<InputBox
|
|
||||||
className={cn(
|
|
||||||
"bg-background/5 w-full",
|
|
||||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
|
||||||
)}
|
)}
|
||||||
isWelcomeMode={isWelcomeMode}
|
|
||||||
threadId={threadId}
|
<InputBox
|
||||||
autoFocus={isWelcomeMode}
|
className={cn(
|
||||||
status={
|
"bg-background/5 w-full",
|
||||||
thread.error
|
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||||
? "error"
|
)}
|
||||||
: thread.isLoading
|
isWelcomeMode={isWelcomeMode}
|
||||||
? "streaming"
|
threadId={threadId}
|
||||||
: "ready"
|
autoFocus={isWelcomeMode}
|
||||||
}
|
status={
|
||||||
context={settings.context}
|
thread.error
|
||||||
extraHeader={
|
? "error"
|
||||||
isWelcomeMode &&
|
: thread.isLoading
|
||||||
!hasGoal &&
|
? "streaming"
|
||||||
!hasTodos && (
|
: "ready"
|
||||||
<AgentWelcome agent={agent} agentName={agent_name} />
|
}
|
||||||
)
|
context={settings.context}
|
||||||
}
|
extraHeader={
|
||||||
disabled={
|
isWelcomeMode &&
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
!hasGoal &&
|
||||||
isUploading
|
!hasTodos && (
|
||||||
}
|
<AgentWelcome agent={agent} agentName={agent_name} />
|
||||||
onContextChange={(context) => setSettings("context", context)}
|
)
|
||||||
onGoalChange={setLocalGoal}
|
}
|
||||||
onSubmit={handleSubmit}
|
disabled={
|
||||||
onStop={handleStop}
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||||
/>
|
isUploading
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
}
|
||||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
onContextChange={(context) =>
|
||||||
{t.common.notAvailableInDemoMode}
|
setSettings("context", context)
|
||||||
</div>
|
}
|
||||||
)}
|
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>
|
||||||
</div>
|
</main>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</ChatBox>
|
||||||
</ChatBox>
|
</SidecarProvider>
|
||||||
</ThreadContext.Provider>
|
</ThreadContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,12 +13,19 @@ import {
|
|||||||
} from "@/components/workspace/chats";
|
} from "@/components/workspace/chats";
|
||||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||||
import { GoalStatus } from "@/components/workspace/goal-status";
|
import { GoalStatus } from "@/components/workspace/goal-status";
|
||||||
import { InputBox } from "@/components/workspace/input-box";
|
import {
|
||||||
|
InputBox,
|
||||||
|
type InputBoxSubmitOptions,
|
||||||
|
} from "@/components/workspace/input-box";
|
||||||
import {
|
import {
|
||||||
MessageList,
|
MessageList,
|
||||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||||
} from "@/components/workspace/messages";
|
} from "@/components/workspace/messages";
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
|
import {
|
||||||
|
SidecarProvider,
|
||||||
|
SidecarTrigger,
|
||||||
|
} from "@/components/workspace/sidecar";
|
||||||
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
|
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
|
||||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||||
import { TodoList } from "@/components/workspace/todo-list";
|
import { TodoList } from "@/components/workspace/todo-list";
|
||||||
@ -151,8 +158,8 @@ export default function ChatPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage, options?: InputBoxSubmitOptions) => {
|
||||||
const sendPromise = sendMessage(threadId, message);
|
const sendPromise = sendMessage(threadId, message, undefined, options);
|
||||||
if (message.files.length > 0) {
|
if (message.files.length > 0) {
|
||||||
return sendPromise;
|
return sendPromise;
|
||||||
}
|
}
|
||||||
@ -180,151 +187,161 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||||
<ChatBox threadId={threadId}>
|
<SidecarProvider
|
||||||
<div className="relative flex size-full min-h-0 justify-between">
|
parentThreadId={threadId}
|
||||||
<header
|
context={settings.context}
|
||||||
className={cn(
|
isMock={isMock}
|
||||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
|
>
|
||||||
isWelcomeMode
|
<ChatBox threadId={threadId}>
|
||||||
? "bg-background/0 backdrop-blur-none"
|
<div className="relative flex size-full min-h-0 justify-between">
|
||||||
: "bg-background/80 shadow-xs backdrop-blur",
|
<header
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-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 ? "absolute" : "relative shrink-0 pb-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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full",
|
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
|
||||||
isWelcomeMode &&
|
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
|
||||||
"-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
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
"relative w-full",
|
||||||
"right-0 left-0 z-0",
|
isWelcomeMode &&
|
||||||
isWelcomeMode ? "absolute -top-4" : "relative",
|
"-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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"right-0 bottom-0 left-0 flex flex-col",
|
"right-0 left-0 z-0",
|
||||||
isWelcomeMode ? "absolute" : "relative",
|
isWelcomeMode ? "absolute -top-4" : "relative",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
<div
|
||||||
{hasTodos && (
|
className={cn(
|
||||||
<TodoList
|
"right-0 bottom-0 left-0 flex flex-col",
|
||||||
className="bg-background/5"
|
isWelcomeMode ? "absolute" : "relative",
|
||||||
todos={thread.values.todos ?? []}
|
)}
|
||||||
hidden={false}
|
>
|
||||||
/>
|
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||||
)}
|
{hasTodos && (
|
||||||
|
<TodoList
|
||||||
|
className="bg-background/5"
|
||||||
|
todos={thread.values.todos ?? []}
|
||||||
|
hidden={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{mountedRef.current ? (
|
||||||
{mountedRef.current ? (
|
<InputBox
|
||||||
<InputBox
|
className={cn(
|
||||||
className={cn(
|
"bg-background/5 w-full",
|
||||||
"bg-background/5 w-full",
|
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
)}
|
||||||
)}
|
isWelcomeMode={isWelcomeMode}
|
||||||
isWelcomeMode={isWelcomeMode}
|
threadId={threadId}
|
||||||
threadId={threadId}
|
autoFocus={isWelcomeMode}
|
||||||
autoFocus={isWelcomeMode}
|
status={
|
||||||
status={
|
thread.error
|
||||||
thread.error
|
? "error"
|
||||||
? "error"
|
: thread.isLoading
|
||||||
: thread.isLoading
|
? "streaming"
|
||||||
? "streaming"
|
: "ready"
|
||||||
: "ready"
|
}
|
||||||
}
|
context={settings.context}
|
||||||
context={settings.context}
|
extraHeader={
|
||||||
extraHeader={
|
isWelcomeMode &&
|
||||||
isWelcomeMode &&
|
!hasGoal &&
|
||||||
!hasGoal &&
|
!hasTodos && <Welcome mode={settings.context.mode} />
|
||||||
!hasTodos && <Welcome mode={settings.context.mode} />
|
}
|
||||||
}
|
disabled={
|
||||||
disabled={
|
isMock ||
|
||||||
isMock ||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
isUploading
|
||||||
isUploading
|
}
|
||||||
}
|
onContextChange={(context) =>
|
||||||
onContextChange={(context) =>
|
setSettings("context", context)
|
||||||
setSettings("context", context)
|
}
|
||||||
}
|
onGoalChange={setLocalGoal}
|
||||||
onGoalChange={setLocalGoal}
|
onSubmit={handleSubmit}
|
||||||
onSubmit={handleSubmit}
|
onStop={handleStop}
|
||||||
onStop={handleStop}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<div
|
||||||
<div
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
className={cn(
|
||||||
className={cn(
|
"bg-background/5 h-32 w-full rounded-2xl",
|
||||||
"bg-background/5 h-32 w-full rounded-2xl",
|
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
{t.common.notAvailableInDemoMode}
|
||||||
{t.common.notAvailableInDemoMode}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</ChatBox>
|
||||||
</ChatBox>
|
</SidecarProvider>
|
||||||
</ThreadContext.Provider>
|
</ThreadContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,14 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
|
||||||
|
import { useMaybeSidecar } from "../sidecar/context";
|
||||||
|
|
||||||
import { useArtifacts } from "./context";
|
import { useArtifacts } from "./context";
|
||||||
|
|
||||||
export const ArtifactTrigger = () => {
|
export const ArtifactTrigger = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen: setArtifactsOpen } = useArtifacts();
|
const { artifacts, setOpen: setArtifactsOpen } = useArtifacts();
|
||||||
|
const sidecar = useMaybeSidecar();
|
||||||
|
|
||||||
if (!artifacts || artifacts.length === 0) {
|
if (!artifacts || artifacts.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@ -19,6 +22,7 @@ export const ArtifactTrigger = () => {
|
|||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
sidecar?.close();
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
import { FilesIcon, XIcon } from "lucide-react";
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
|
||||||
|
|
||||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
ResizableHandle,
|
|
||||||
ResizablePanel,
|
|
||||||
ResizablePanelGroup,
|
|
||||||
} from "@/components/ui/resizable";
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@ -19,9 +13,11 @@ import {
|
|||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "../artifacts";
|
} from "../artifacts";
|
||||||
import { useThread } from "../messages/context";
|
import { useThread } from "../messages/context";
|
||||||
|
import { SidecarPanel, useMaybeSidecar } from "../sidecar";
|
||||||
|
|
||||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
const RIGHT_PANEL_ANIMATION_MS = 280;
|
||||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
|
||||||
|
type RightPanelKind = "sidecar" | "artifacts";
|
||||||
|
|
||||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||||
children,
|
children,
|
||||||
@ -30,7 +26,6 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
artifacts,
|
artifacts,
|
||||||
@ -41,6 +36,8 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
deselect,
|
deselect,
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
|
const sidecar = useMaybeSidecar();
|
||||||
|
const sidecarOpen = sidecar?.open ?? false;
|
||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -87,63 +84,94 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const artifactPanelOpen = useMemo(() => {
|
const artifactPanelOpen = useMemo(() => {
|
||||||
|
if (sidecarOpen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||||
return artifactsOpen && artifacts?.length > 0;
|
return artifactsOpen && artifacts?.length > 0;
|
||||||
}
|
}
|
||||||
return artifactsOpen;
|
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(() => {
|
const resizableIdBase = useMemo(() => {
|
||||||
return pathname.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
return pathname.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
if (activeRightPanel) {
|
||||||
if (artifactPanelOpen) {
|
setRenderedRightPanel(activeRightPanel);
|
||||||
layoutRef.current.setLayout(OPEN_MODE);
|
return;
|
||||||
} else {
|
|
||||||
layoutRef.current.setLayout(CLOSE_MODE);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [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 (
|
return (
|
||||||
<ResizablePanelGroup
|
<div
|
||||||
id={`${resizableIdBase}-panels`}
|
id={`${resizableIdBase}-panels`}
|
||||||
orientation="horizontal"
|
className={cn(
|
||||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
"[container-type:inline-size] grid size-full min-h-0 transition-[grid-template-columns] duration-[280ms] ease-out motion-reduce:transition-none",
|
||||||
groupRef={layoutRef}
|
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}
|
{children}
|
||||||
</ResizablePanel>
|
</div>
|
||||||
<ResizableHandle
|
<div
|
||||||
id={`${resizableIdBase}-separator`}
|
id={`${resizableIdBase}-separator`}
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"opacity-33 hover:opacity-100",
|
"bg-border opacity-33 transition-opacity duration-200 ease-out motion-reduce:transition-none",
|
||||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
!rightPanelOpen && "pointer-events-none opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ResizablePanel
|
<aside
|
||||||
|
aria-hidden={!rightPanelOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300 ease-in-out",
|
"min-h-0 min-w-0 overflow-hidden transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
|
||||||
!artifactsOpen && "opacity-0",
|
!rightPanelOpen && "pointer-events-none opacity-0",
|
||||||
)}
|
)}
|
||||||
id="artifacts"
|
id="artifacts"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full p-4 transition-transform duration-300 ease-in-out",
|
"ml-auto h-full w-[40cqw] transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
|
||||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
renderedRightPanel === "sidecar" ? "p-0" : "p-4",
|
||||||
|
rightPanelOpen ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedArtifact ? (
|
{renderedRightPanel === "sidecar" ? (
|
||||||
|
<SidecarPanel />
|
||||||
|
) : renderedRightPanel === "artifacts" && selectedArtifact ? (
|
||||||
<ArtifactFileDetail
|
<ArtifactFileDetail
|
||||||
className="size-full"
|
className="size-full"
|
||||||
filepath={selectedArtifact}
|
filepath={selectedArtifact}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : renderedRightPanel === "artifacts" ? (
|
||||||
<div className="relative flex size-full justify-center">
|
<div className="relative flex size-full justify-center">
|
||||||
<div className="absolute top-1 right-1 z-30">
|
<div className="absolute top-1 right-1 z-30">
|
||||||
<Button
|
<Button
|
||||||
@ -177,10 +205,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</aside>
|
||||||
</ResizablePanelGroup>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@ -37,6 +38,7 @@ import {
|
|||||||
PromptInputBody,
|
PromptInputBody,
|
||||||
PromptInputButton,
|
PromptInputButton,
|
||||||
PromptInputFooter,
|
PromptInputFooter,
|
||||||
|
PromptInputHeader,
|
||||||
PromptInputSubmit,
|
PromptInputSubmit,
|
||||||
PromptInputTextarea,
|
PromptInputTextarea,
|
||||||
PromptInputTools,
|
PromptInputTools,
|
||||||
@ -64,6 +66,10 @@ import { getBackendBaseURL } from "@/core/config";
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
|
import {
|
||||||
|
buildReferenceMessageMetadata,
|
||||||
|
type SidecarContext,
|
||||||
|
} from "@/core/sidecar";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
||||||
import type { AgentThreadContext, GoalState } from "@/core/threads";
|
import type { AgentThreadContext, GoalState } from "@/core/threads";
|
||||||
@ -112,6 +118,7 @@ import {
|
|||||||
} from "./input-box-helpers";
|
} from "./input-box-helpers";
|
||||||
import { useThread } from "./messages/context";
|
import { useThread } from "./messages/context";
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
|
import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||||
@ -129,6 +136,68 @@ function getResolvedMode(
|
|||||||
return supportsThinking ? "pro" : "flash";
|
return supportsThinking ? "pro" : "flash";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeXmlAttribute(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
export function InputBox({
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
@ -176,7 +245,10 @@ export function InputBox({
|
|||||||
) => void;
|
) => void;
|
||||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||||
onGoalChange?: (goal: GoalState | null) => void;
|
onGoalChange?: (goal: GoalState | null) => void;
|
||||||
onSubmit?: (message: PromptInputMessage) => void | Promise<void>;
|
onSubmit?: (
|
||||||
|
message: PromptInputMessage,
|
||||||
|
options?: InputBoxSubmitOptions,
|
||||||
|
) => void | Promise<void>;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -185,6 +257,7 @@ export function InputBox({
|
|||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
const { thread, isMock } = useThread();
|
const { thread, isMock } = useThread();
|
||||||
const { attachments, textInput } = usePromptInputController();
|
const { attachments, textInput } = usePromptInputController();
|
||||||
|
const sidecar = useMaybeSidecar();
|
||||||
const attachmentParts = attachments.files;
|
const attachmentParts = attachments.files;
|
||||||
const removeAttachment = attachments.remove;
|
const removeAttachment = attachments.remove;
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
@ -559,6 +632,26 @@ export function InputBox({
|
|||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
setFollowupsHidden(false);
|
setFollowupsHidden(false);
|
||||||
setFollowupsLoading(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
|
// Guard against submitting before the initial model auto-selection
|
||||||
// effect has flushed thread settings to storage/state.
|
// effect has flushed thread settings to storage/state.
|
||||||
@ -573,12 +666,12 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
Promise.resolve(onSubmit?.(message)).then(resolve).catch(reject);
|
Promise.resolve(submit()).then(resolve).catch(reject);
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return onSubmit?.(message);
|
return submit();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
context,
|
context,
|
||||||
@ -587,6 +680,7 @@ export function InputBox({
|
|||||||
reportUploadLimitViolations,
|
reportUploadLimitViolations,
|
||||||
resolvedModelName,
|
resolvedModelName,
|
||||||
selectedModel?.supports_thinking,
|
selectedModel?.supports_thinking,
|
||||||
|
sidecar,
|
||||||
t.inputBox.suggestionPlaceholderRequired,
|
t.inputBox.suggestionPlaceholderRequired,
|
||||||
uploadLimits,
|
uploadLimits,
|
||||||
],
|
],
|
||||||
@ -629,7 +723,13 @@ export function InputBox({
|
|||||||
}
|
}
|
||||||
return submitThreadMessage(message);
|
return submitThreadMessage(message);
|
||||||
},
|
},
|
||||||
[handleGoalCommand, onStop, status, submitThreadMessage],
|
[
|
||||||
|
handleGoalCommand,
|
||||||
|
onStop,
|
||||||
|
status,
|
||||||
|
submitThreadMessage,
|
||||||
|
t.inputBox.pleaseWaitStreaming,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestFormSubmit = useCallback(() => {
|
const requestFormSubmit = useCallback(() => {
|
||||||
@ -1073,9 +1173,22 @@ export function InputBox({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PromptInputAttachments>
|
<PromptInputHeader className="flex-wrap px-3 pt-3 pb-0 empty:hidden">
|
||||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
<PromptInputAttachments className="contents p-0">
|
||||||
</PromptInputAttachments>
|
{(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">
|
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
|
||||||
<PromptInputTextarea
|
<PromptInputTextarea
|
||||||
className={cn("size-full")}
|
className={cn("size-full")}
|
||||||
|
|||||||
@ -42,11 +42,13 @@ import {
|
|||||||
type FileInMessage,
|
type FileInMessage,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
|
import { readReferenceMessageContexts } from "@/core/sidecar";
|
||||||
import { SafeReasoningContent } from "@/core/streamdown/components";
|
import { SafeReasoningContent } from "@/core/streamdown/components";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
|
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { createMarkdownLinkComponent } from "./markdown-link";
|
import { createMarkdownLinkComponent } from "./markdown-link";
|
||||||
@ -245,7 +247,7 @@ function MessageContent_({
|
|||||||
clientTurnDurations.set(`${threadId}:${message.id}`, rawTurnDuration);
|
clientTurnDurations.set(`${threadId}:${message.id}`, rawTurnDuration);
|
||||||
setCachedDuration(rawTurnDuration);
|
setCachedDuration(rawTurnDuration);
|
||||||
}
|
}
|
||||||
}, [rawTurnDuration, message.id]);
|
}, [rawTurnDuration, message.id, threadId]);
|
||||||
|
|
||||||
const handleDurationChange = useCallback(
|
const handleDurationChange = useCallback(
|
||||||
(d: number | undefined) => {
|
(d: number | undefined) => {
|
||||||
@ -254,7 +256,7 @@ function MessageContent_({
|
|||||||
setCachedDuration(d);
|
setCachedDuration(d);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[message.id],
|
[message.id, threadId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -295,6 +297,16 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
return files as FileInMessage[];
|
return files as FileInMessage[];
|
||||||
}, [message.additional_kwargs?.files, rawContent]);
|
}, [message.additional_kwargs?.files, rawContent]);
|
||||||
|
const referenceAttachments = useMemo(
|
||||||
|
() =>
|
||||||
|
readReferenceMessageContexts(message.additional_kwargs).map(
|
||||||
|
(context, index) => ({
|
||||||
|
id: index,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[message.additional_kwargs],
|
||||||
|
);
|
||||||
|
|
||||||
const contentToDisplay = useMemo(() => {
|
const contentToDisplay = useMemo(() => {
|
||||||
if (isHuman) {
|
if (isHuman) {
|
||||||
@ -357,6 +369,13 @@ function MessageContent_({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{referenceAttachments.length > 0 && (
|
||||||
|
<ReferenceAttachmentSummary
|
||||||
|
className="self-end shadow-none"
|
||||||
|
references={referenceAttachments}
|
||||||
|
testId="message-reference-attachment"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{filesList}
|
{filesList}
|
||||||
{contentToDisplay && (
|
{contentToDisplay && (
|
||||||
<AIElementMessageContent className="w-full max-w-full">
|
<AIElementMessageContent className="w-full max-w-full">
|
||||||
|
|||||||
@ -1,11 +1,26 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { ChevronUpIcon, Loader2Icon, RefreshCcwIcon } from "lucide-react";
|
import {
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
ChevronUpIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
MessageCircleIcon,
|
||||||
|
MessageSquarePlusIcon,
|
||||||
|
RefreshCcwIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type MouseEvent,
|
||||||
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
|
type ConversationProps,
|
||||||
} from "@/components/ai-elements/conversation";
|
} from "@/components/ai-elements/conversation";
|
||||||
import {
|
import {
|
||||||
Reasoning,
|
Reasoning,
|
||||||
@ -31,6 +46,10 @@ import {
|
|||||||
isAssistantMessageGroupStreaming,
|
isAssistantMessageGroupStreaming,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
|
import {
|
||||||
|
buildMessageSidecarContext,
|
||||||
|
type SidecarContext,
|
||||||
|
} from "@/core/sidecar";
|
||||||
import type { Subtask } from "@/core/tasks";
|
import type { Subtask } from "@/core/tasks";
|
||||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||||
import {
|
import {
|
||||||
@ -42,7 +61,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
import { StreamingIndicator } from "../streaming-indicator";
|
import { useMaybeSidecar } from "../sidecar/context";
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
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 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({
|
function LoadMoreHistoryIndicator({
|
||||||
isLoading,
|
isLoading,
|
||||||
hasMore,
|
hasMore,
|
||||||
@ -166,6 +198,7 @@ function LoadMoreHistoryIndicator({
|
|||||||
|
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
className,
|
className,
|
||||||
|
testId,
|
||||||
threadId,
|
threadId,
|
||||||
thread,
|
thread,
|
||||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||||
@ -175,8 +208,12 @@ export function MessageList({
|
|||||||
isHistoryLoading,
|
isHistoryLoading,
|
||||||
onRegenerateMessage,
|
onRegenerateMessage,
|
||||||
canRegenerate = false,
|
canRegenerate = false,
|
||||||
|
enableSidecarActions = true,
|
||||||
|
initialScroll = "smooth",
|
||||||
|
resizeScroll = "smooth",
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
testId?: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
thread: BaseStream<AgentThreadState>;
|
thread: BaseStream<AgentThreadState>;
|
||||||
paddingBottom?: number;
|
paddingBottom?: number;
|
||||||
@ -189,8 +226,14 @@ export function MessageList({
|
|||||||
supersededMessageIds: string[],
|
supersededMessageIds: string[],
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
canRegenerate?: boolean;
|
canRegenerate?: boolean;
|
||||||
|
enableSidecarActions?: boolean;
|
||||||
|
initialScroll?: ConversationProps["initial"];
|
||||||
|
resizeScroll?: ConversationProps["resize"];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const sidecar = useMaybeSidecar();
|
||||||
|
const [selectionToolbar, setSelectionToolbar] =
|
||||||
|
useState<SelectionToolbarState | null>(null);
|
||||||
const [turnStartTime, setTurnStartTime] = useState<number | null>(null);
|
const [turnStartTime, setTurnStartTime] = useState<number | null>(null);
|
||||||
const prevIsLoading = useRef(thread.isLoading);
|
const prevIsLoading = useRef(thread.isLoading);
|
||||||
|
|
||||||
@ -250,6 +293,133 @@ export function MessageList({
|
|||||||
return null;
|
return null;
|
||||||
}, [groupedMessages, thread.isLoading]);
|
}, [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(
|
const renderAssistantActions = useCallback(
|
||||||
(
|
(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
@ -264,7 +434,6 @@ export function MessageList({
|
|||||||
.filter((message) => message.type === "ai" && message.id)
|
.filter((message) => message.type === "ai" && message.id)
|
||||||
.map((message) => message.id)
|
.map((message) => message.id)
|
||||||
.filter((id): id is string => typeof id === "string");
|
.filter((id): id is string => typeof id === "string");
|
||||||
|
|
||||||
if (!clipboardData && !regenerateTarget) {
|
if (!clipboardData && !regenerateTarget) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -370,237 +539,315 @@ export function MessageList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Conversation
|
<>
|
||||||
className={cn("flex size-full flex-col justify-center", className)}
|
<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">
|
data-testid={testId}
|
||||||
<LoadMoreHistoryIndicator
|
initial={initialScroll}
|
||||||
isLoading={isHistoryLoading}
|
resize={resizeScroll}
|
||||||
hasMore={hasMoreHistory}
|
>
|
||||||
loadMore={loadMoreHistory}
|
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-8">
|
||||||
/>
|
<LoadMoreHistoryIndicator
|
||||||
{groupedMessages.map((group, groupIndex) => {
|
isLoading={isHistoryLoading}
|
||||||
const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex];
|
hasMore={hasMoreHistory}
|
||||||
const groupIsLoading =
|
loadMore={loadMoreHistory}
|
||||||
thread.isLoading && groupIndex === lastGroupIndex;
|
/>
|
||||||
|
{groupedMessages.map((group, groupIndex) => {
|
||||||
|
const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex];
|
||||||
|
const groupIsLoading =
|
||||||
|
thread.isLoading && groupIndex === lastGroupIndex;
|
||||||
|
|
||||||
if (group.type === "human" || group.type === "assistant") {
|
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)) {
|
|
||||||
return (
|
return (
|
||||||
<div key={group.id} className="w-full">
|
<div
|
||||||
<MarkdownContent
|
key={group.id}
|
||||||
content={extractContentFromMessage(message)}
|
data-assistant-turn={
|
||||||
isLoading={thread.isLoading}
|
group.type === "assistant" ? "" : undefined
|
||||||
rehypePlugins={rehypePlugins}
|
}
|
||||||
/>
|
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({
|
{renderTokenUsage({
|
||||||
messages: group.messages,
|
messages: group.messages,
|
||||||
turnUsageMessages,
|
turnUsageMessages,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
} else if (group.type === "assistant:subagent") {
|
||||||
return null;
|
const tasks = new Set<Subtask>();
|
||||||
} else if (group.type === "assistant:present-files") {
|
for (const message of group.messages) {
|
||||||
const files: string[] = [];
|
if (message.type === "ai") {
|
||||||
for (const message of group.messages) {
|
for (const toolCall of message.tool_calls ?? []) {
|
||||||
if (hasPresentFiles(message)) {
|
if (toolCall.name === "task") {
|
||||||
const presentFiles = extractPresentFilesFromMessage(message);
|
const taskId = toolCall.id;
|
||||||
files.push(...presentFiles);
|
if (!taskId) {
|
||||||
}
|
continue;
|
||||||
}
|
}
|
||||||
return (
|
const status = derivePendingSubtaskStatus(
|
||||||
<div className="w-full" key={group.id}>
|
taskId,
|
||||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
group.messages,
|
||||||
<MarkdownContent
|
groupIsLoading,
|
||||||
content={extractContentFromMessage(group.messages[0])}
|
);
|
||||||
isLoading={thread.isLoading}
|
const task: Subtask = {
|
||||||
rehypePlugins={rehypePlugins}
|
id: taskId,
|
||||||
className="mb-4"
|
subagent_type: toolCall.args.subagent_type,
|
||||||
/>
|
description: toolCall.args.description,
|
||||||
)}
|
prompt: toolCall.args.prompt,
|
||||||
<ArtifactFileList files={files} threadId={threadId} />
|
status,
|
||||||
{renderTokenUsage({
|
...(status === "failed"
|
||||||
messages: group.messages,
|
? { error: t.subtasks.failed }
|
||||||
turnUsageMessages,
|
: {}),
|
||||||
})}
|
};
|
||||||
</div>
|
updateSubtask(task);
|
||||||
);
|
tasks.add(task);
|
||||||
} 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,
|
} else if (message.type === "tool") {
|
||||||
group.messages,
|
const taskId = message.tool_call_id;
|
||||||
groupIsLoading,
|
if (taskId) {
|
||||||
|
const parsed = parseSubtaskResult(
|
||||||
|
extractTextFromMessage(message),
|
||||||
|
message.additional_kwargs,
|
||||||
);
|
);
|
||||||
const task: Subtask = {
|
updateSubtask({ id: taskId, ...parsed });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (message.type === "tool") {
|
}
|
||||||
const taskId = message.tool_call_id;
|
|
||||||
if (taskId) {
|
const results: React.ReactNode[] = [];
|
||||||
const parsed = parseSubtaskResult(
|
const subagentDebugMessageIds: string[] = [];
|
||||||
extractTextFromMessage(message),
|
if (tasks.size > 0) {
|
||||||
message.additional_kwargs,
|
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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return (
|
||||||
|
|
||||||
const results: React.ReactNode[] = [];
|
|
||||||
const subagentDebugMessageIds: string[] = [];
|
|
||||||
if (tasks.size > 0) {
|
|
||||||
results.push(
|
|
||||||
<div
|
<div
|
||||||
key="subtask-count"
|
key={"subtask-group-" + group.id}
|
||||||
className="text-muted-foreground pt-2 text-sm font-normal"
|
className="relative z-1 flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
{t.subtasks.executing(tasks.size)}
|
{results}
|
||||||
</div>,
|
{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 (
|
return (
|
||||||
<div
|
<div key={"group-" + group.id} className="w-full">
|
||||||
key={"subtask-group-" + group.id}
|
<MessageGroup
|
||||||
className="relative z-1 flex flex-col gap-2"
|
messages={group.messages}
|
||||||
>
|
isLoading={thread.isLoading}
|
||||||
{results}
|
tokenDebugSteps={tokenDebugSteps.filter((step) =>
|
||||||
|
group.messages.some(
|
||||||
|
(message) => message.id === step.messageId,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
showTokenDebugSummaries={
|
||||||
|
tokenUsageInlineMode === "step_debug"
|
||||||
|
}
|
||||||
|
/>
|
||||||
{renderTokenUsage({
|
{renderTokenUsage({
|
||||||
messages: group.messages,
|
messages: group.messages,
|
||||||
turnUsageMessages,
|
turnUsageMessages,
|
||||||
debugMessageIds: subagentDebugMessageIds,
|
inlineDebug: false,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
return (
|
{thread.isLoading && !hasActiveAssistantText && (
|
||||||
<div key={"group-" + group.id} className="w-full">
|
<div className="w-full">
|
||||||
<MessageGroup
|
<Reasoning isStreaming={true} startTimeProp={turnStartTime}>
|
||||||
messages={group.messages}
|
<ReasoningTrigger hasContent={false} />
|
||||||
isLoading={thread.isLoading}
|
</Reasoning>
|
||||||
tokenDebugSteps={tokenDebugSteps.filter((step) =>
|
|
||||||
group.messages.some(
|
|
||||||
(message) => message.id === step.messageId,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
showTokenDebugSummaries={tokenUsageInlineMode === "step_debug"}
|
|
||||||
/>
|
|
||||||
{renderTokenUsage({
|
|
||||||
messages: group.messages,
|
|
||||||
turnUsageMessages,
|
|
||||||
inlineDebug: false,
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
<div style={{ height: `${paddingBottom}px` }} />
|
||||||
{thread.isLoading && !hasActiveAssistantText && (
|
</ConversationContent>
|
||||||
<div className="w-full">
|
</Conversation>
|
||||||
<Reasoning isStreaming={true} startTimeProp={turnStartTime}>
|
{selectionToolbar && sidecar && (
|
||||||
<ReasoningTrigger hasContent={false} />
|
<div
|
||||||
</Reasoning>
|
className={cn(
|
||||||
</div>
|
"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"
|
||||||
<div style={{ height: `${paddingBottom}px` }} />
|
? "translate-y-0"
|
||||||
</ConversationContent>
|
: "-translate-y-full",
|
||||||
</Conversation>
|
)}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
286
frontend/src/components/workspace/sidecar/context.tsx
Normal file
286
frontend/src/components/workspace/sidecar/context.tsx
Normal 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;
|
||||||
|
}
|
||||||
4
frontend/src/components/workspace/sidecar/index.ts
Normal file
4
frontend/src/components/workspace/sidecar/index.ts
Normal 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";
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
817
frontend/src/components/workspace/sidecar/sidecar-panel.tsx
Normal file
817
frontend/src/components/workspace/sidecar/sidecar-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -422,6 +422,28 @@ export const enUS: Translations = {
|
|||||||
loadOlderChats: "Load older chats",
|
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
|
||||||
channels: {
|
channels: {
|
||||||
title: "Channels",
|
title: "Channels",
|
||||||
|
|||||||
@ -333,6 +333,27 @@ export interface Translations {
|
|||||||
loadOlderChats: string;
|
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
|
||||||
channels: {
|
channels: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@ -406,6 +406,28 @@ export const zhCN: Translations = {
|
|||||||
loadOlderChats: "加载更早的对话",
|
loadOlderChats: "加载更早的对话",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Sidecar
|
||||||
|
sidecar: {
|
||||||
|
title: "侧边对话",
|
||||||
|
open: "打开侧边对话",
|
||||||
|
close: "关闭侧边对话",
|
||||||
|
addToConversation: "添加到对话",
|
||||||
|
askInSideChat: "在侧边聊天中提问",
|
||||||
|
reference: "引用",
|
||||||
|
selectedTextFragment: "{count} 个已选文本片段",
|
||||||
|
selectedTextFragments: "{count} 个已选文本片段",
|
||||||
|
clearReferences: "清除已选引用",
|
||||||
|
emptyTitle: "继续深入追问",
|
||||||
|
emptyDescription: "基于引用内容单独追问。",
|
||||||
|
placeholder: "继续深入追问...",
|
||||||
|
send: "发送",
|
||||||
|
sendFailed: "侧边对话发送失败。",
|
||||||
|
noContext: "未选择上下文",
|
||||||
|
continuing: "继续当前侧边对话",
|
||||||
|
selectionCrossesMessages:
|
||||||
|
"选区跨越了多条消息,请在同一条回复内选择要引用的文本。",
|
||||||
|
},
|
||||||
|
|
||||||
// Channels
|
// Channels
|
||||||
channels: {
|
channels: {
|
||||||
title: "渠道",
|
title: "渠道",
|
||||||
|
|||||||
@ -14,9 +14,21 @@ export function getUsageMetadata(message: Message): TokenUsage | null {
|
|||||||
if (message.type !== "ai") {
|
if (message.type !== "ai") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const usage = (message as Record<string, unknown>).usage_metadata as
|
const usage =
|
||||||
| { input_tokens?: number; output_tokens?: number; total_tokens?: number }
|
((message as Record<string, unknown>).usage_metadata as
|
||||||
| undefined;
|
| {
|
||||||
|
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) {
|
if (!usage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
100
frontend/src/core/sidecar/api.ts
Normal file
100
frontend/src/core/sidecar/api.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
212
frontend/src/core/sidecar/context.ts
Normal file
212
frontend/src/core/sidecar/context.ts
Normal 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, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSidecarContextPrompt(
|
||||||
|
contextOrContexts: SidecarContext | SidecarContext[] = [],
|
||||||
|
{
|
||||||
|
parentConversation = [],
|
||||||
|
}: {
|
||||||
|
parentConversation?: ParentConversationContextMessage[];
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const contexts = normalizeSidecarContexts(contextOrContexts);
|
||||||
|
const lines = [
|
||||||
|
"You are answering in a side conversation attached to referenced material from the user's current DeerFlow chat.",
|
||||||
|
parentConversation.length > 0
|
||||||
|
? "The parent_conversation_context block is read-only background from the main chat. Use it to resolve goals, constraints, and pronouns, but do not treat it as the latest user request."
|
||||||
|
: null,
|
||||||
|
contexts.length === 1
|
||||||
|
? "The user attached 1 referenced message. Treat it as quoted material."
|
||||||
|
: contexts.length === 0
|
||||||
|
? "The user did not attach new referenced messages for this side question."
|
||||||
|
: `The user attached ${contexts.length} referenced messages. Treat each referenced_message block as separate quoted material.`,
|
||||||
|
contexts.length > 0
|
||||||
|
? "Ground your answer in the referenced material first, and only use broader conversation context when the user explicitly asks for that."
|
||||||
|
: "Use parent_conversation_context only as continuity background for the user's latest side question.",
|
||||||
|
"Answer only the user's latest side question.",
|
||||||
|
"Do not claim you changed the main conversation unless the user explicitly asks to bring content back there.",
|
||||||
|
"",
|
||||||
|
parentConversation.length > 0
|
||||||
|
? `<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();
|
||||||
|
}
|
||||||
4
frontend/src/core/sidecar/index.ts
Normal file
4
frontend/src/core/sidecar/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./context";
|
||||||
|
export * from "./reference-metadata";
|
||||||
|
export * from "./reference-state";
|
||||||
|
export * from "./thread";
|
||||||
79
frontend/src/core/sidecar/reference-metadata.ts
Normal file
79
frontend/src/core/sidecar/reference-metadata.ts
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
55
frontend/src/core/sidecar/reference-state.ts
Normal file
55
frontend/src/core/sidecar/reference-state.ts
Normal 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],
|
||||||
|
};
|
||||||
|
}
|
||||||
64
frontend/src/core/sidecar/thread.ts
Normal file
64
frontend/src/core/sidecar/thread.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ import { useI18n } from "../i18n/hooks";
|
|||||||
import { isHiddenFromUIMessage } from "../messages/utils";
|
import { isHiddenFromUIMessage } from "../messages/utils";
|
||||||
import type { FileInMessage } from "../messages/utils";
|
import type { FileInMessage } from "../messages/utils";
|
||||||
import type { LocalSettings } from "../settings";
|
import type { LocalSettings } from "../settings";
|
||||||
|
import { isSidecarThread, SIDECAR_METADATA_KEY } from "../sidecar/thread";
|
||||||
import { useUpdateSubtask } from "../tasks/context";
|
import { useUpdateSubtask } from "../tasks/context";
|
||||||
import { messageToStep } from "../tasks/steps";
|
import { messageToStep } from "../tasks/steps";
|
||||||
import type { UploadedFileInfo } from "../uploads";
|
import type { UploadedFileInfo } from "../uploads";
|
||||||
@ -30,6 +31,7 @@ import { fetchThreadTokenUsage } from "./api";
|
|||||||
import {
|
import {
|
||||||
buildThreadsSearchQueryOptions,
|
buildThreadsSearchQueryOptions,
|
||||||
DEFAULT_THREAD_SEARCH_PARAMS,
|
DEFAULT_THREAD_SEARCH_PARAMS,
|
||||||
|
filterThreadSearchResults,
|
||||||
type ThreadSearchParams,
|
type ThreadSearchParams,
|
||||||
} from "./thread-search-query";
|
} from "./thread-search-query";
|
||||||
import { threadTokenUsageQueryKey } from "./token-usage";
|
import { threadTokenUsageQueryKey } from "./token-usage";
|
||||||
@ -58,6 +60,27 @@ export type ThreadStreamOptions = {
|
|||||||
|
|
||||||
type SendMessageOptions = {
|
type SendMessageOptions = {
|
||||||
additionalKwargs?: Record<string, unknown>;
|
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 = {
|
type RegeneratePrepareResponse = {
|
||||||
@ -71,6 +94,35 @@ type RegeneratePrepareResponse = {
|
|||||||
target_run_id: string;
|
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 = {
|
const EMPTY_THREAD_VALUES: AgentThreadState = {
|
||||||
title: "",
|
title: "",
|
||||||
messages: [],
|
messages: [],
|
||||||
@ -1165,6 +1217,10 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
sendInFlightRef.current = true;
|
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();
|
const text = message.text.trim();
|
||||||
|
|
||||||
// Capture the current human message count before showing optimistic
|
// Capture the current human message count before showing optimistic
|
||||||
@ -1297,23 +1353,12 @@ export function useThreadStream({
|
|||||||
|
|
||||||
await thread.submit(
|
await thread.submit(
|
||||||
{
|
{
|
||||||
messages: [
|
messages: buildThreadSubmitMessages({
|
||||||
{
|
text,
|
||||||
type: "human",
|
additionalKwargs: options?.additionalKwargs,
|
||||||
content: [
|
additionalInputMessages: options?.additionalInputMessages,
|
||||||
{
|
filesForSubmit,
|
||||||
type: "text",
|
}),
|
||||||
text,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
additional_kwargs: {
|
|
||||||
...options?.additionalKwargs,
|
|
||||||
...(filesForSubmit.length > 0
|
|
||||||
? { files: filesForSubmit }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
@ -1747,16 +1792,80 @@ export const INFINITE_THREADS_QUERY_KEY_PREFIX = [
|
|||||||
"searchInfinite",
|
"searchInfinite",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const INFINITE_THREADS_NEXT_PAGE_PARAM = Symbol(
|
||||||
|
"deerflow.infiniteThreads.nextPageParam",
|
||||||
|
);
|
||||||
|
|
||||||
type InfiniteThreadsParams = Omit<
|
type InfiniteThreadsParams = Omit<
|
||||||
Parameters<ThreadsClient["search"]>[0],
|
Parameters<ThreadsClient["search"]>[0],
|
||||||
"limit" | "offset"
|
"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(
|
export function getInfiniteThreadsNextPageParam(
|
||||||
lastPage: AgentThread[],
|
lastPage: AgentThread[],
|
||||||
allPages: AgentThread[][],
|
allPages: AgentThread[][],
|
||||||
pageSize: number = INFINITE_THREADS_PAGE_SIZE,
|
pageSize: number = INFINITE_THREADS_PAGE_SIZE,
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
|
const annotatedNextPageParam = Reflect.get(
|
||||||
|
lastPage as InfiniteThreadsPageWithNextParam,
|
||||||
|
INFINITE_THREADS_NEXT_PAGE_PARAM,
|
||||||
|
);
|
||||||
|
if (typeof annotatedNextPageParam === "number") {
|
||||||
|
return annotatedNextPageParam;
|
||||||
|
}
|
||||||
|
|
||||||
if (lastPage.length < pageSize) {
|
if (lastPage.length < pageSize) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -1806,14 +1915,13 @@ export function useInfiniteThreads(
|
|||||||
>({
|
>({
|
||||||
queryKey: [...INFINITE_THREADS_QUERY_KEY_PREFIX, params],
|
queryKey: [...INFINITE_THREADS_QUERY_KEY_PREFIX, params],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) =>
|
||||||
const response = (await apiClient.threads.search<AgentThreadState>({
|
fetchInfiniteThreadsPage(
|
||||||
...params,
|
apiClient,
|
||||||
limit: INFINITE_THREADS_PAGE_SIZE,
|
params,
|
||||||
offset: pageParam,
|
pageParam,
|
||||||
})) as AgentThread[];
|
INFINITE_THREADS_PAGE_SIZE,
|
||||||
return response;
|
),
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage, allPages) =>
|
getNextPageParam: (lastPage, allPages) =>
|
||||||
getInfiniteThreadsNextPageParam(lastPage, allPages),
|
getInfiniteThreadsNextPageParam(lastPage, allPages),
|
||||||
refetchOnWindowFocus: false,
|
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() {
|
export function useDeleteThread() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const apiClient = getAPIClient();
|
const apiClient = getAPIClient() as ThreadDeleteClient;
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
threadId,
|
threadId,
|
||||||
@ -1910,24 +2127,17 @@ export function useDeleteThread() {
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
onRemoteDeleted?: () => void;
|
onRemoteDeleted?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
const deletedSidecarThreadIds = await deleteSidecarThreadsForParent(
|
||||||
|
apiClient,
|
||||||
|
threadId,
|
||||||
|
);
|
||||||
await apiClient.threads.delete(threadId);
|
await apiClient.threads.delete(threadId);
|
||||||
onRemoteDeleted?.();
|
onRemoteDeleted?.();
|
||||||
|
await deleteLocalThreadData(threadId);
|
||||||
const response = await fetch(
|
return deletedSidecarThreadIds;
|
||||||
`${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.");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSuccess(_, { threadId }) {
|
onSuccess(deletedSidecarThreadIds, { threadId }) {
|
||||||
|
const deletedThreadIds = new Set([threadId, ...deletedSidecarThreadIds]);
|
||||||
queryClient.setQueriesData(
|
queryClient.setQueriesData(
|
||||||
{
|
{
|
||||||
queryKey: ["threads", "search"],
|
queryKey: ["threads", "search"],
|
||||||
@ -1937,7 +2147,7 @@ export function useDeleteThread() {
|
|||||||
if (oldData == null) {
|
if (oldData == null) {
|
||||||
return oldData;
|
return oldData;
|
||||||
}
|
}
|
||||||
return oldData.filter((t) => t.thread_id !== threadId);
|
return oldData.filter((t) => !deletedThreadIds.has(t.thread_id));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
queryClient.setQueriesData(
|
queryClient.setQueriesData(
|
||||||
@ -1946,7 +2156,10 @@ export function useDeleteThread() {
|
|||||||
exact: false,
|
exact: false,
|
||||||
},
|
},
|
||||||
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
||||||
filterInfiniteThreadsCache(oldData, (t) => t.thread_id !== threadId),
|
filterInfiniteThreadsCache(
|
||||||
|
oldData,
|
||||||
|
(t) => !deletedThreadIds.has(t.thread_id),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SIDECAR_METADATA_KEY,
|
||||||
|
shouldShowInPrimaryThreadLists,
|
||||||
|
} from "@/core/sidecar/thread";
|
||||||
|
|
||||||
import type { AgentThread, AgentThreadState } from "./types";
|
import type { AgentThread, AgentThreadState } from "./types";
|
||||||
|
|
||||||
type ThreadsSearchClient = {
|
type ThreadsSearchClient = {
|
||||||
@ -21,6 +26,28 @@ export const DEFAULT_THREAD_SEARCH_PARAMS: ThreadSearchParams = {
|
|||||||
|
|
||||||
export const THREAD_SEARCH_REFETCH_INTERVAL_MS = 5000;
|
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(
|
export function buildThreadsSearchQueryOptions(
|
||||||
apiClient: ThreadsSearchClient,
|
apiClient: ThreadsSearchClient,
|
||||||
params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS,
|
params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS,
|
||||||
@ -37,7 +64,7 @@ export function buildThreadsSearchQueryOptions(
|
|||||||
if (maxResults !== undefined && maxResults <= 0) {
|
if (maxResults !== undefined && maxResults <= 0) {
|
||||||
const response =
|
const response =
|
||||||
await apiClient.threads.search<AgentThreadState>(params);
|
await apiClient.threads.search<AgentThreadState>(params);
|
||||||
return response as AgentThread[];
|
return filterThreadSearchResults(response as AgentThread[], params);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSize =
|
const pageSize =
|
||||||
@ -68,7 +95,7 @@ export function buildThreadsSearchQueryOptions(
|
|||||||
offset,
|
offset,
|
||||||
})) as AgentThread[];
|
})) as AgentThread[];
|
||||||
|
|
||||||
threads.push(...response);
|
threads.push(...filterThreadSearchResults(response, params));
|
||||||
|
|
||||||
if (response.length < currentLimit) {
|
if (response.length < currentLimit) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
1270
frontend/tests/e2e/sidecar-chat.spec.ts
Normal file
1270
frontend/tests/e2e/sidecar-chat.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 = "00000000-0000-0000-0000-000000000001";
|
||||||
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
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";
|
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
||||||
|
|
||||||
const MOCK_AUTH_USER = {
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
type: "human",
|
type: "human",
|
||||||
id: "msg-human-1",
|
id: "msg-human-1",
|
||||||
content: [{ type: "text", text: "Hello" }],
|
content: [{ type: "text", text: "Hello" }],
|
||||||
},
|
},
|
||||||
{
|
responseMessage,
|
||||||
type: "ai",
|
|
||||||
id: "msg-ai-1",
|
|
||||||
content: "Hello from DeerFlow!",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// mockLangGraphAPI
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -473,7 +538,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
|
|
||||||
// Thread search — sidebar thread list & chats list page
|
// Thread search — sidebar thread list & chats list page
|
||||||
void page.route("**/api/langgraph/threads/search", async (route) => {
|
void page.route("**/api/langgraph/threads/search", async (route) => {
|
||||||
const body = threads.map(threadSearchResult);
|
let body = threads.map(threadSearchResult);
|
||||||
|
|
||||||
let limit: number | undefined;
|
let limit: number | undefined;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
@ -481,6 +546,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
const postData = route.request().postDataJSON() as {
|
const postData = route.request().postDataJSON() as {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
} | null;
|
} | null;
|
||||||
if (postData) {
|
if (postData) {
|
||||||
if (typeof postData.limit === "number") {
|
if (typeof postData.limit === "number") {
|
||||||
@ -489,6 +555,13 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
if (typeof postData.offset === "number") {
|
if (typeof postData.offset === "number") {
|
||||||
offset = postData.offset;
|
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 {
|
} catch {
|
||||||
// No / invalid JSON body — fall back to returning the full list.
|
// 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();
|
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) => {
|
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
||||||
if (route.request().method() === "DELETE") {
|
if (route.request().method() === "DELETE") {
|
||||||
return route.fulfill({
|
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
|
// Run stream — returns a minimal SSE response with an AI message
|
||||||
const handleMockRunStream = (route: Route) => {
|
const handleMockRunStream = (route: Route) => {
|
||||||
const requestUrl = route.request().url();
|
const threadId = runStreamThreadId(route);
|
||||||
const matchingThread = threads.find((thread) =>
|
const existingThread = threads.find(
|
||||||
requestUrl.includes(encodeURIComponent(thread.thread_id)),
|
(thread) => thread.thread_id === threadId,
|
||||||
);
|
);
|
||||||
const fallbackGoal = threads.find((thread) => thread.goal)?.goal ?? null;
|
const fallbackGoal = threads.find((thread) => thread.goal)?.goal ?? null;
|
||||||
const goal = matchingThread?.goal ?? fallbackGoal;
|
const goal = existingThread?.goal ?? fallbackGoal;
|
||||||
upsertThread({
|
upsertThread({
|
||||||
thread_id: MOCK_THREAD_ID,
|
thread_id: threadId,
|
||||||
title: "New Chat",
|
title: threadId === MOCK_SIDECAR_THREAD_ID ? "Side chat" : "New Chat",
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
goal,
|
goal,
|
||||||
messages: mockStreamMessages(),
|
metadata: existingThread?.metadata,
|
||||||
|
messages: mockStreamMessages(route),
|
||||||
});
|
});
|
||||||
return handleRunStream(route, { goal });
|
return handleRunStream(route, { goal });
|
||||||
};
|
};
|
||||||
@ -906,17 +1010,19 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
export function handleRunStream(
|
export function handleRunStream(
|
||||||
route: Route,
|
route: Route,
|
||||||
values: Record<string, unknown> = {},
|
values: Record<string, unknown> = {},
|
||||||
|
inputMessages?: unknown[],
|
||||||
) {
|
) {
|
||||||
|
const threadId = runStreamThreadId(route);
|
||||||
const events = [
|
const events = [
|
||||||
{
|
{
|
||||||
event: "metadata",
|
event: "metadata",
|
||||||
data: { run_id: MOCK_RUN_ID, thread_id: MOCK_THREAD_ID },
|
data: { run_id: MOCK_RUN_ID, thread_id: threadId },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: "values",
|
event: "values",
|
||||||
data: {
|
data: {
|
||||||
...values,
|
...values,
|
||||||
messages: mockStreamMessages(),
|
messages: mockStreamMessages(route, inputMessages),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ event: "end", data: {} },
|
{ event: "end", data: {} },
|
||||||
|
|||||||
@ -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", () => {
|
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
|
// 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
|
// answer text now lands in exactly one assistant group (#3868), so its usage
|
||||||
|
|||||||
150
frontend/tests/unit/core/sidecar/api.test.ts
Normal file
150
frontend/tests/unit/core/sidecar/api.test.ts
Normal 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);
|
||||||
|
});
|
||||||
175
frontend/tests/unit/core/sidecar/context.test.ts
Normal file
175
frontend/tests/unit/core/sidecar/context.test.ts
Normal 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");
|
||||||
|
});
|
||||||
75
frontend/tests/unit/core/sidecar/reference-metadata.test.ts
Normal file
75
frontend/tests/unit/core/sidecar/reference-metadata.test.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
52
frontend/tests/unit/core/sidecar/reference-state.test.ts
Normal file
52
frontend/tests/unit/core/sidecar/reference-state.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
106
frontend/tests/unit/core/sidecar/thread.test.ts
Normal file
106
frontend/tests/unit/core/sidecar/thread.test.ts
Normal 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);
|
||||||
|
});
|
||||||
55
frontend/tests/unit/core/threads/delete-thread.test.ts
Normal file
55
frontend/tests/unit/core/threads/delete-thread.test.ts
Normal 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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
fetchInfiniteThreadsPage,
|
||||||
filterInfiniteThreadsCache,
|
filterInfiniteThreadsCache,
|
||||||
getInfiniteThreadsNextPageParam,
|
getInfiniteThreadsNextPageParam,
|
||||||
INFINITE_THREADS_PAGE_SIZE,
|
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
|
// / stream-finish in sync with both the legacy array cache and the new
|
||||||
// infinite cache.
|
// infinite cache.
|
||||||
|
|
||||||
function makeThread(id: string, title = `Title ${id}`): AgentThread {
|
function makeThread(
|
||||||
|
id: string,
|
||||||
|
title = `Title ${id}`,
|
||||||
|
metadata: Record<string, unknown> = {},
|
||||||
|
): AgentThread {
|
||||||
return {
|
return {
|
||||||
thread_id: id,
|
thread_id: id,
|
||||||
created_at: "2025-01-01T00:00:00Z",
|
created_at: "2025-01-01T00:00:00Z",
|
||||||
updated_at: "2025-01-01T00:00:00Z",
|
updated_at: "2025-01-01T00:00:00Z",
|
||||||
metadata: {},
|
metadata,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
values: { title },
|
values: { title },
|
||||||
} as unknown as AgentThread;
|
} 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", () => {
|
describe("mapInfiniteThreadsCache", () => {
|
||||||
test("returns undefined when oldData is undefined", () => {
|
test("returns undefined when oldData is undefined", () => {
|
||||||
expect(mapInfiniteThreadsCache(undefined, (t) => t)).toBeUndefined();
|
expect(mapInfiniteThreadsCache(undefined, (t) => t)).toBeUndefined();
|
||||||
|
|||||||
62
frontend/tests/unit/core/threads/send-message.test.ts
Normal file
62
frontend/tests/unit/core/threads/send-message.test.ts
Normal 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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -5,6 +5,21 @@ import {
|
|||||||
DEFAULT_THREAD_SEARCH_PARAMS,
|
DEFAULT_THREAD_SEARCH_PARAMS,
|
||||||
THREAD_SEARCH_REFETCH_INTERVAL_MS,
|
THREAD_SEARCH_REFETCH_INTERVAL_MS,
|
||||||
} from "@/core/threads/thread-search-query";
|
} 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", () => {
|
test("thread search query refreshes so IM-created sessions appear in the sidebar", () => {
|
||||||
const search = rs.fn();
|
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.refetchIntervalInBackground).toBe(false);
|
||||||
expect(options.refetchOnWindowFocus).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]);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user