diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 36bfe16ad6..53211a0f0d 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -115,7 +115,9 @@ client preserves structured SkillScan findings on `SkillRequestError`, and the settings page renders a compact file/rule/line summary so security rejections remain actionable; a proxy-generated 413 is mapped to the localized size error. -Run duration is run-scoped UI metadata even though the compatibility field `additional_kwargs.turn_duration` is repeated on historical AI messages. `core/messages/run-duration.ts` folds those copies into one display anchored after the run's last visible message group. `MessageList` owns the temporary client-side duration for a just-completed live turn until authoritative history arrives. The duration is total run wall-clock time, not per-message reasoning time; reasoning disclosure and run activity/duration are rendered separately. +Run duration is run-scoped UI metadata even though the compatibility field `additional_kwargs.turn_duration` is repeated on historical AI messages. `core/messages/run-duration.ts` folds those copies into one display owned by the run's last visible message group. `MessageList` owns the temporary client-side duration for a just-completed live turn until authoritative history arrives. The duration is total run wall-clock time, not per-message reasoning time. At that anchor, a completed reasoning disclosure uses the duration as its visible label with a divider underneath and a visually hidden localized reasoning cue for its accessible name. This applies to assistant bubbles, processing groups, and subagent groups. `getMessageGroupReasoningMessage` shares the processing renderer’s trailing-reasoning selection so reasoning before the final tool cannot consume the duration of a disclosure that is not rendered. Groups without a matching rendered reasoning disclosure use a standalone duration header above their content. Historical reasoning without duration retains its generic label. Never repeat the duration on every AI message. + +`MessageReasoning` controls its disclosure state: completed mounts start collapsed, live streams open and close once after completion, and history expansion stays under user control. Keep this lifecycle in the workspace wrapper rather than editing generated AI elements. Duration-target selection is lazy for mounted, completed groups with a duration and cached by stable group identity; streaming updates must not re-convert unchanged historical processing/subagent groups. Changed groups must invalidate that cache. The workspace-change card follows the same rule: it is resolved from `(threadId, runId)` alone, so every AI message of a run would render an identical copy. A run ends in more than one terminal assistant bubble whenever the model emits answer text that never gains a tool call, so `core/messages/workspace-change-anchor.ts` picks the run's last assistant bubble and `MessageListItem` renders the badge only for that anchor (#4555). Any future run-scoped display belongs in the same place — do not hang one off every message. The two anchor helpers deliberately differ in which group types they accept as a run's last position, because an anchor is only useful where the display is actually rendered: run duration is emitted by `MessageList` around every group, so it accepts any type, while the workspace-change card comes from `MessageListItem` and so restricts to `assistant`. Keep a new helper's candidate set matched to its own render site rather than unifying them. diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index 3e1da094c5..7b5a02fb7b 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -52,12 +52,14 @@ import { Tooltip } from "../tooltip"; import { MarkdownContent } from "./markdown-content"; import { isSafeHref, UnsafeLink } from "./markdown-link"; +import { MessageReasoning } from "./message-reasoning"; import { ToolCallDetails } from "./tool-call-details"; interface MessageGroupProps { className?: string; messages: Message[]; isLoading?: boolean; + durationSeconds?: number; deferBrowserPreviews?: boolean; tokenDebugSteps?: TokenDebugStep[]; showTokenDebugSummaries?: boolean; @@ -69,6 +71,7 @@ function MessageGroupComponent({ className, messages, isLoading = false, + durationSeconds, deferBrowserPreviews = false, tokenDebugSteps = [], showTokenDebugSummaries = false, @@ -154,15 +157,10 @@ function MessageGroupComponent({ aboveLastToolCallSteps.filter((step) => step.type !== "assistantText"), [aboveLastToolCallSteps], ); - const lastReasoningStep = useMemo(() => { - if (lastToolCallStep) { - const index = stepIndexByStep.get(lastToolCallStep) ?? -1; - return steps.slice(index + 1).find((step) => step.type === "reasoning"); - } else { - const filteredSteps = steps.filter((step) => step.type === "reasoning"); - return filteredSteps[filteredSteps.length - 1]; - } - }, [lastToolCallStep, stepIndexByStep, steps]); + const lastReasoningStep = useMemo( + () => getTrailingReasoningStep(steps), + [steps], + ); // Assistant text emitted after the trailing reasoning is the answer that // reasoning produced, so it renders below the reasoning disclosure. The // settled assistant bubble always paints reasoning above content, and the @@ -414,55 +412,77 @@ function MessageGroupComponent({ lastReasoningStep.messageId, stepIndexByStep.get(lastReasoningStep) ?? -1, )} - - {showLastThinking && ( - - + {lastReasoningStep.reasoning ?? ""} + + ) : ( + <> + + {showLastThinking && ( + + + } + > + + )} + )} {belowLastReasoningAssistantTextSteps.length > 0 && ( @@ -501,6 +521,7 @@ function areMessageGroupPropsEqual( } return ( previous.className === next.className && + previous.durationSeconds === next.durationSeconds && Boolean(previous.isLoading) === Boolean(next.isLoading) && Boolean(previous.deferBrowserPreviews) === Boolean(next.deferBrowserPreviews) && @@ -1053,6 +1074,7 @@ interface GenericCoTStep { } interface CoTReasoningStep extends GenericCoTStep<"reasoning"> { + message: Message; reasoning: string | null; } @@ -1140,6 +1162,7 @@ function convertToSteps( id: message.id, messageId: message.id, type: "reasoning", + message, reasoning, }; steps.push(step); @@ -1194,3 +1217,21 @@ function convertToSteps( } return steps; } + +// Use the same selection for rendering and duration ownership: reasoning that +// precedes the final tool remains inside the execution timeline, not this header. +function getTrailingReasoningStep(steps: CoTStep[]) { + const lastToolCall = [...steps] + .reverse() + .find((step) => step.type === "toolCall"); + if (lastToolCall) { + return steps + .slice(steps.indexOf(lastToolCall) + 1) + .find((step) => step.type === "reasoning"); + } + return [...steps].reverse().find((step) => step.type === "reasoning"); +} + +export function getMessageGroupReasoningMessage(messages: Message[]) { + return getTrailingReasoningStep(convertToSteps(messages))?.message; +} diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 4a0b2717a0..7a1748c79c 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -22,11 +22,6 @@ import { MessageContent as AIElementMessageContent, MessageToolbar, } from "@/components/ai-elements/message"; -import { - Reasoning, - ReasoningTrigger, -} from "@/components/ai-elements/reasoning"; -import { Shimmer } from "@/components/ai-elements/shimmer"; import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -57,7 +52,6 @@ import { resolveSlashSkillDisplay, } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; -import { SafeReasoningContent } from "@/core/streamdown/components"; import { pathOfThread } from "@/core/threads/utils"; import { cn } from "@/lib/utils"; @@ -73,6 +67,7 @@ import { Tooltip } from "../tooltip"; import { KnowledgeScopeSummary } from "./knowledge-scope-summary"; import { MarkdownContent } from "./markdown-content"; import { createMarkdownLinkComponent } from "./markdown-link"; +import { MessageReasoning } from "./message-reasoning"; function FeedbackButtons({ threadId, @@ -151,6 +146,7 @@ export function MessageListItem({ artifactPaths = [], showCopyButton = true, showWorkspaceChanges = false, + durationSeconds, canEdit = false, isEditPending = false, onEditAndRegenerate, @@ -164,6 +160,7 @@ export function MessageListItem({ runId?: string; showCopyButton?: boolean; showWorkspaceChanges?: boolean; + durationSeconds?: number; canEdit?: boolean; isEditPending?: boolean; onEditAndRegenerate?: (replacementText: string) => void | Promise; @@ -230,6 +227,7 @@ export function MessageListItem({ artifactPaths={artifactPaths} runId={runId} showWorkspaceChanges={showWorkspaceChanges} + durationSeconds={durationSeconds} editState={ isHuman && isEditing ? { @@ -380,6 +378,7 @@ function MessageContent_({ artifactPaths, runId, showWorkspaceChanges = false, + durationSeconds, editState, }: { className?: string; @@ -389,6 +388,7 @@ function MessageContent_({ artifactPaths: readonly string[]; runId?: string; showWorkspaceChanges?: boolean; + durationSeconds?: number; editState?: { draft: string; disabled: boolean; @@ -400,15 +400,6 @@ function MessageContent_({ }) { const { t } = useI18n(); const isHuman = message.type === "human"; - const getReasoningMessage = useCallback( - (isStreaming: boolean) => - isStreaming ? ( - {t.runDuration.reasoning} - ) : ( - t.runDuration.reasoning - ), - [t.runDuration.reasoning], - ); const components = useMemo( () => ({ img: (props: ImgHTMLAttributes) => ( @@ -497,10 +488,12 @@ function MessageContent_({ if (!isHuman && reasoningContent && !rawContent) { return ( - - - {reasoningContent} - + + {reasoningContent} + ); } @@ -607,10 +600,12 @@ function MessageContent_({ {filesList} {reasoningContent && ( - - - {reasoningContent} - + + {reasoningContent} + )} + extractReasoningContentFromMessage(message), + ); + } + if (group.type === "assistant:subagent") { + return group.messages.filter( + (message) => + hasReasoning(message) && + getMessageGroupReasoningMessage([message]) === message, + ); + } + if (group.type === "assistant:processing") { + const message = getMessageGroupReasoningMessage(group.messages); + return message ? [message] : []; + } + return []; +} + const LOAD_MORE_HISTORY_THROTTLE_MS = 1200; const SELECTION_TOOLBAR_MARGIN = 8; @@ -345,6 +370,11 @@ export function MessageList({ useState(null); const messages = thread.messages; const groupedMessages = useStableMessageGroups(messages, thread.isLoading); + // Stable historical groups survive streaming updates. Weak keys also release + // cached targets when pagination or a thread change removes those groups. + const reasoningTargetsCache = useRef( + new WeakMap(), + ); const chapters = useMemo( () => buildConversationChapters( @@ -1067,28 +1097,36 @@ export function MessageList({ return ; } - const withRunDuration = ( + const getGroupRunDurations = ( group: (typeof groupedMessages)[number], groupIndex: number, - content: ReactNode, - ) => { + ): RunDurationDisplay[] => { const persistedDisplays = runDurationDisplaysByGroupIndex[groupIndex] ?? []; const clientDuration = !thread.error && group.id ? clientDurationsByGroupId.get(`${threadId}:${group.id}`) : undefined; - const displays = - persistedDisplays.length > 0 - ? persistedDisplays - : clientDuration !== undefined - ? [ - { - runId: `client:${group.id}`, - durationSeconds: clientDuration, - }, - ] - : []; + return persistedDisplays.length > 0 + ? persistedDisplays + : clientDuration !== undefined + ? [ + { + runId: `client:${group.id}`, + durationSeconds: clientDuration, + }, + ] + : []; + }; + const withRunDuration = ( + group: (typeof groupedMessages)[number], + groupIndex: number, + content: ReactNode, + inlineDurationRunIds: string[] = [], + ) => { + const displays = getGroupRunDurations(group, groupIndex).filter( + (display) => !inlineDurationRunIds.includes(display.runId), + ); if (!content && displays.length === 0) { return null; } @@ -1098,13 +1136,13 @@ export function MessageList({ key={`duration-group:${group.id ?? groupIndex}`} className="flex w-full flex-col gap-2" > - {content} {displays.map((display) => ( ))} + {content} ); }; @@ -1135,6 +1173,29 @@ export function MessageList({ const groupIsLoading = thread.isLoading && groupIndex === lastGroupIndex; + const reasoningDurations = new Map(); + const displays = groupIsLoading + ? [] + : getGroupRunDurations(group, groupIndex); + if (displays.length > 0) { + let reasoningTargets = reasoningTargetsCache.current.get(group); + if (!reasoningTargets) { + reasoningTargets = getRenderedReasoningMessages(group); + reasoningTargetsCache.current.set(group, reasoningTargets); + } + for (const display of displays) { + const target = reasoningTargets.find( + (message) => + getMessageRunId(message) === display.runId || + display.runId === `client:${group.id}`, + ); + if (target) reasoningDurations.set(target, display); + } + } + const inlineDurationRunIds = [...reasoningDurations.values()].map( + (display) => display.runId, + ); + if (group.type === "human" || group.type === "assistant") { return withRunDuration( group, @@ -1163,6 +1224,9 @@ export function MessageList({ ? (msg as { run_id?: string }).run_id : undefined } + durationSeconds={ + reasoningDurations.get(msg)?.durationSeconds + } showCopyButton={group.type !== "assistant"} showWorkspaceChanges={workspaceChangeAnchorGroupIndices.has( groupIndex, @@ -1242,6 +1306,7 @@ export function MessageList({ : skillUsageByGroupIndex.get(groupIndex), )} , + inlineDurationRunIds, ); } else if (group.type === "assistant:clarification") { const message = group.messages[0]; @@ -1382,6 +1447,9 @@ export function MessageList({ , + inlineDurationRunIds, ); } return withRunDuration( @@ -1435,6 +1504,9 @@ export function MessageList({
, + inlineDurationRunIds, ); }} /> diff --git a/frontend/src/components/workspace/messages/message-reasoning.tsx b/frontend/src/components/workspace/messages/message-reasoning.tsx new file mode 100644 index 0000000000..51f537fa13 --- /dev/null +++ b/frontend/src/components/workspace/messages/message-reasoning.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { ChevronRightIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { + Reasoning, + ReasoningTrigger, +} from "@/components/ai-elements/reasoning"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { useI18n } from "@/core/i18n/hooks"; +import { SafeReasoningContent } from "@/core/streamdown/components"; + +import { RunDurationLabel } from "./run-duration"; + +export function MessageReasoning({ + children, + isLoading, + durationSeconds, + tokenLabel, +}: { + children: string; + isLoading: boolean; + durationSeconds?: number; + tokenLabel?: string | null; +}) { + const { t } = useI18n(); + const [open, setOpen] = useState(isLoading); + const hasStreamed = useRef(isLoading); + + useEffect(() => { + if (isLoading) { + hasStreamed.current = true; + setOpen(true); + return; + } + if (!hasStreamed.current) return; + + // Close only after a live stream, never after mounting historical content. + const timer = setTimeout(() => setOpen(false), 1000); + return () => clearTimeout(timer); + }, [isLoading]); + + return ( + + + {!isLoading && durationSeconds !== undefined ? ( + <> + + {t.runDuration.reasoning} + + ) : isLoading ? ( + {t.runDuration.reasoning} + ) : ( + {t.runDuration.reasoning} + )} + {tokenLabel && ( + {tokenLabel} + )} + + + {children} + + ); +} diff --git a/frontend/src/components/workspace/messages/run-duration.tsx b/frontend/src/components/workspace/messages/run-duration.tsx index 25dfb1e2e5..d2f2b671ab 100644 --- a/frontend/src/components/workspace/messages/run-duration.tsx +++ b/frontend/src/components/workspace/messages/run-duration.tsx @@ -39,7 +39,11 @@ export function RunActivity({ startTime }: { startTime: number | null }) { ); } -export function RunDuration({ durationSeconds }: { durationSeconds: number }) { +export function RunDurationLabel({ + durationSeconds, +}: { + durationSeconds: number; +}) { const { t } = useI18n(); const formatted = formatRunDuration(durationSeconds, t.runDuration); if (!formatted) { @@ -47,13 +51,16 @@ export function RunDuration({ durationSeconds }: { durationSeconds: number }) { } return ( -
- - {t.runDuration.completedIn(formatted)} + + {t.runDuration.completedIn(formatted)} + + ); +} + +export function RunDuration({ durationSeconds }: { durationSeconds: number }) { + return ( +
+
); } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 376e275d3b..e4f211ccac 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -201,7 +201,7 @@ export const enUS: Translations = { runDuration: { reasoning: "Reasoning", working: "Working…", - completedIn: (duration) => `Completed in ${duration}`, + completedIn: (duration) => `Took ${duration}`, description: "Total task time, including model reasoning, tool calls, and waiting.", lessThanSecond: "<1s", diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 5b3b1b35b9..bf072fcd15 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -194,7 +194,7 @@ export const zhCN: Translations = { runDuration: { reasoning: "思考过程", working: "执行中…", - completedIn: (duration) => `本次任务耗时 ${duration}`, + completedIn: (duration) => `用时 ${duration}`, description: "任务总耗时,包括模型推理、工具调用和等待时间。", lessThanSecond: "不足 1 秒", hours: (value) => `${value} 小时`, diff --git a/frontend/src/core/messages/run-duration.ts b/frontend/src/core/messages/run-duration.ts index b62ac4e726..00e691c4d2 100644 --- a/frontend/src/core/messages/run-duration.ts +++ b/frontend/src/core/messages/run-duration.ts @@ -32,8 +32,8 @@ function normalizeDuration(value: unknown): number | undefined { /** * Locate the single UI position that owns each completed run's wall-clock * duration. The backend keeps the value on every AI message for compatibility, - * but the UI treats it as run-scoped metadata and renders it after the last - * visible group belonging to that run. + * but the UI treats it as run-scoped metadata owned by the last visible + * group belonging to that run. */ export function getRunDurationDisplaysByGroupIndex( groups: MessageGroup[], diff --git a/frontend/tests/e2e/reasoning-duration.spec.ts b/frontend/tests/e2e/reasoning-duration.spec.ts new file mode 100644 index 0000000000..20a352872e --- /dev/null +++ b/frontend/tests/e2e/reasoning-duration.spec.ts @@ -0,0 +1,497 @@ +import type { AddressInfo } from "node:net"; + +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api"; + +const answer = "你好!有什么可以帮你的吗?"; +const reasoning = "先理解用户的需求,再给出简洁的回答。"; + +async function freezeHistoryClock(page: Page) { + await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); + await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z")); +} + +for (const encoding of ["provider", "inline", "none"] as const) { + test(`shows one compact Chinese duration header: ${encoding}`, async ({ + page, + }, testInfo) => { + await freezeHistoryClock(page); + await page + .context() + .addCookies([ + { name: "locale", value: "zh-CN", url: testInfo.project.use.baseURL! }, + ]); + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "耗时标题预览", + messages: [ + { type: "human", id: "human-duration", content: "你好" }, + { + type: "ai", + id: "ai-duration", + content: + encoding === "inline" + ? `${reasoning}${answer}` + : answer, + additional_kwargs: { + turn_duration: 31, + ...(encoding === "provider" + ? { reasoning_content: reasoning } + : {}), + }, + }, + ], + }, + ], + }); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + const label = page.getByTestId("run-duration"); + await expect(label).toHaveCount(1); + await expect(label).toHaveText("用时 31 秒"); + await expect(page.getByText(answer, { exact: true })).toBeVisible(); + await expect(page.getByText("本次任务耗时", { exact: false })).toHaveCount( + 0, + ); + + const trigger = page.getByRole("button", { + name: "用时 31 秒 思考过程", + exact: true, + }); + if (encoding !== "none") { + // Timers are paused: retries cannot hide an initially expanded panel. + expect(await trigger.getAttribute("aria-expanded")).toBe("false"); + await trigger.click(); + await expect(page.getByText(reasoning, { exact: true })).toBeVisible(); + await trigger.press("Enter"); + await expect( + page.getByText(reasoning, { exact: true }), + ).not.toBeVisible(); + } else { + await expect(trigger).toHaveCount(0); + } + + const labelBox = await label.boundingBox(); + const answerBox = await page + .getByText(answer, { exact: true }) + .boundingBox(); + expect(labelBox!.y + labelBox!.height).toBeLessThan(answerBox!.y); + await page.reload(); + await expect(label).toHaveCount(1); + await expect(label).toHaveText("用时 31 秒"); + if (encoding !== "none") { + expect(await trigger.getAttribute("aria-expanded")).toBe("false"); + } + await page.mouse.move(0, 0); + if (encoding !== "none") { + // Resolve the theme token in this browser; Chromium versions serialize + // the same color differently (e.g. oklch vs lab). + const mutedColor = await trigger.evaluate((element) => { + const probe = document.createElement("span"); + probe.style.color = "var(--muted-foreground)"; + element.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }); + await expect(trigger).toHaveCSS("color", mutedColor); + } + await page.screenshot({ + animations: "disabled", + path: testInfo.outputPath(`duration-${encoding}.png`), + }); + }); +} + +for (const kind of ["processing", "subagent"] as const) { + test(`binds completed ${kind} duration to its reasoning disclosure`, async ({ + page, + }) => { + await freezeHistoryClock(page); + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + messages: [ + { + type: "human", + id: "human-duration", + content: "Think about this", + }, + { + type: "ai", + id: "ai-duration", + content: "", + additional_kwargs: { + reasoning_content: reasoning, + turn_duration: 31, + }, + ...(kind === "subagent" + ? { + tool_calls: [ + { + id: "task-duration", + name: "task", + args: { + description: "Research", + prompt: "Research", + subagent_type: "general-purpose", + }, + }, + ], + } + : {}), + }, + ], + }, + ], + }); + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + const trigger = page.getByRole("button", { + name: "Took 31s Reasoning", + exact: true, + }); + await expect(page.getByTestId("run-duration")).toHaveCount(1); + await expect(trigger).toBeVisible(); + expect(await trigger.getAttribute("aria-expanded")).toBe("false"); + await trigger.click(); + await expect(page.getByText(reasoning, { exact: true })).toBeVisible(); + await trigger.press("Enter"); + await expect(page.getByText(reasoning, { exact: true })).not.toBeVisible(); + await expect( + page.getByRole("button", { name: "Thinking", exact: true }), + ).toHaveCount(0); + await page.reload(); + await expect(page.getByTestId("run-duration")).toHaveCount(1); + expect(await trigger.getAttribute("aria-expanded")).toBe("false"); + }); +} + +test("keeps completed reasoning collapsed when virtualized history remounts", async ({ + page, +}) => { + const messages = Array.from({ length: 40 }, (_, index) => [ + { type: "human", id: `human-${index}`, content: `Question ${index}` }, + { + type: "ai", + id: `ai-${index}`, + content: `Answer ${index}`, + additional_kwargs: { + reasoning_content: `Reasoning ${index}`, + turn_duration: 31 + index, + }, + }, + ]).flat(); + await page.addInitScript(() => { + const states: { label: string; expanded: string | null }[] = []; + Object.assign(window, { initialReasoningStates: states }); + const seen = new WeakSet(); + new MutationObserver(() => { + for (const trigger of document.querySelectorAll( + "button[aria-expanded]", + )) { + const label = trigger.querySelector('[data-testid="run-duration"]'); + if (!label || seen.has(trigger)) continue; + seen.add(trigger); + states.push({ + label: label.textContent ?? "", + expanded: trigger.getAttribute("aria-expanded"), + }); + } + }).observe(document, { childList: true, subtree: true }); + }); + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + messages, + }, + ], + }); + // Supply all 80 groups so pagination cannot keep the list below its 60-group + // virtualization threshold. + await page.route(/\/api\/threads\/[^/]+\/messages\/page/, (route) => + route.fulfill({ + json: { + data: messages.map((content, index) => ({ + content, + run_id: `duration-run-${Math.floor(index / 2)}`, + seq: index + 1, + metadata: { caller: "lead_agent" }, + })), + has_more: false, + next_before_seq: null, + }, + }), + ); + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + const lastTrigger = page.getByRole("button", { + name: "Took 1m 10s Reasoning", + exact: true, + }); + await expect(lastTrigger).toBeVisible(); + const initialStates = () => + page.evaluate( + () => + ( + window as unknown as { + initialReasoningStates: { + label: string; + expanded: string | null; + }[]; + } + ).initialReasoningStates, + ); + const lastMountsBefore = (await initialStates()).filter( + (state) => state.label === "Took 1m 10s", + ).length; + const scroller = page.getByRole("log").locator(":scope > div").first(); + await scroller.dispatchEvent("wheel", { deltaY: -1000 }); + await scroller.evaluate((element) => { + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll")); + }); + await expect( + page.getByRole("button", { name: "Took 31s Reasoning", exact: true }), + ).toBeVisible(); + await expect(lastTrigger).toHaveCount(0); + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll")); + }); + await expect(lastTrigger).toBeVisible(); + const states = await initialStates(); + expect( + states.filter((state) => state.label === "Took 1m 10s").length, + ).toBeGreaterThan(lastMountsBefore); + expect(states.every((state) => state.expanded === "false")).toBe(true); +}); + +// Keep the history response pending until the client fallback has painted, then +// release the persisted duration without remounting the chat. +for (const content of [answer, ""]) { + test(`hands a completed live ${content ? "answer" : "processing"} header from client time to persisted time`, async ({ + page, + }) => { + const { createServer } = await import("node:http"); + const runId = "00000000-0000-0000-0000-000000000099"; + const human = { + type: "human", + id: "human-live-duration", + content: "Hello", + }; + const ai = { + type: "ai", + id: "ai-live-duration", + content, + run_id: runId, + additional_kwargs: { reasoning_content: reasoning }, + }; + let complete = false; + let persistedReady = false; + let releaseHistory!: () => void; + const historyReady = new Promise((resolve) => { + releaseHistory = resolve; + }); + let finishStream!: () => void; + const server = createServer((_request, response) => { + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Content-Type": "text/event-stream", + }); + const frame = (event: string, data: unknown) => + response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + frame("metadata", { run_id: runId, thread_id: MOCK_THREAD_ID }); + frame("values", { messages: [human, ai] }); + finishStream = () => { + complete = true; + frame("end", {}); + response.end(); + }; + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const { port } = server.address() as AddressInfo; + mockLangGraphAPI(page, { + threads: [{ thread_id: MOCK_THREAD_ID, messages: [] }], + }); + await page.route("**/api/langgraph/threads/*/runs/stream", (route) => + route.continue({ url: `http://127.0.0.1:${port}/stream` }), + ); + const persisted = [ + human, + { + ...ai, + additional_kwargs: { ...ai.additional_kwargs, turn_duration: 47 }, + }, + ]; + await page.route(/\/api\/threads\/[^/]+\/messages\/page/, async (route) => { + if (!complete) return route.fallback(); + await historyReady; + await route.fulfill({ + json: { + data: persisted.map((content, index) => ({ + run_id: runId, + seq: index + 1, + content, + metadata: { caller: "lead_agent" }, + })), + has_more: false, + next_before_seq: null, + }, + }); + }); + await page.route("**/api/langgraph/threads/*/state", async (route) => { + if (!complete) return route.fallback(); + await route.fulfill({ + json: { + values: { messages: persistedReady ? persisted : [human, ai] }, + next: [], + tasks: [], + }, + }); + }); + await page.route("**/api/langgraph/threads/*/history", async (route) => { + if (!complete) return route.fallback(); + await route.fulfill({ + json: [ + { + values: { messages: persistedReady ? persisted : [human, ai] }, + next: [], + tasks: [], + metadata: {}, + created_at: "2026-01-01T00:00:31Z", + parent_config: null, + }, + ], + }); + }); + try { + const start = new Date("2026-01-01T00:00:00Z"); + await page.clock.setFixedTime(start); + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + const input = page.getByPlaceholder(/how can i assist you/i); + await input.fill("Hello"); + await input.press("Enter"); + await expect( + content + ? page.getByText(answer, { exact: true }) + : page.getByRole("button", { name: "Thinking", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("run-duration")).toHaveCount(0); + await page.clock.setFixedTime(new Date(start.getTime() + 31_000)); + finishStream(); + const clientTrigger = page.getByRole("button", { + name: "Took 31s Reasoning", + exact: true, + }); + await expect(clientTrigger).toBeVisible(); + await expect(page.getByTestId("run-duration")).toHaveCount(1); + persistedReady = true; + releaseHistory(); + const persistedTrigger = page.getByRole("button", { + name: "Took 47s Reasoning", + exact: true, + }); + await expect(persistedTrigger).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByTestId("run-duration")).toHaveCount(1); + await persistedTrigger.click(); + await expect(page.getByText(reasoning, { exact: true })).toBeVisible(); + await page.reload(); + await expect(persistedTrigger).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByTestId("run-duration")).toHaveCount(1); + } finally { + releaseHistory(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +} + +for (const trailingReasoning of [false, true]) { + test(`preserves duration ownership around the last tool: trailing reasoning ${trailingReasoning}`, async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + messages: [ + { type: "human", id: "human-tools", content: "Research this" }, + { + type: "ai", + id: "ai-earlier", + run_id: "run-earlier", + content: "", + additional_kwargs: { + reasoning_content: "Earlier reasoning", + turn_duration: 31, + }, + }, + { + type: "ai", + id: "ai-tool", + run_id: "run-later", + content: "", + tool_calls: [ + { + id: "search-duration", + name: "web_search", + args: { query: "DeerFlow" }, + }, + ], + additional_kwargs: { turn_duration: 17 }, + }, + { + type: "tool", + id: "result-duration", + run_id: "run-later", + tool_call_id: "search-duration", + content: "[]", + }, + ...(trailingReasoning + ? [ + { + type: "ai", + id: "ai-trailing", + run_id: "run-later", + content: "", + additional_kwargs: { + reasoning_content: "Trailing reasoning", + turn_duration: 17, + }, + }, + ] + : []), + ], + }, + ], + }); + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page.getByTestId("run-duration")).toHaveCount(2); + await expect(page.getByTestId("run-duration")).toHaveText([ + "Took 31s", + "Took 17s", + ]); + await expect( + page.getByRole("button", { name: "Took 31s Reasoning", exact: true }), + ).toHaveCount(0); + const trigger = page.getByRole("button", { + name: "Took 17s Reasoning", + exact: true, + }); + if (trailingReasoning) { + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + await trigger.click(); + await expect( + page.getByText("Trailing reasoning", { exact: true }), + ).toBeVisible(); + } else { + await expect(trigger).toHaveCount(0); + } + }); +} diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts index 155098caa7..c20c94f1d0 100644 --- a/frontend/tests/e2e/thread-history.spec.ts +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -361,7 +361,7 @@ test.describe("Thread history", () => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); }); - await expect(page.getByText("Completed in 11m 44s")).toBeVisible(); + await expect(page.getByText("Took 11m 44s")).toBeVisible(); const latestPageRequestsBeforeSubmit = latestPageRequestCount; const textarea = page.locator("textarea[name='message']"); @@ -380,7 +380,7 @@ test.describe("Thread history", () => { (element.scrollHeight - element.clientHeight) * ratio; element.dispatchEvent(new Event("scroll")); }, step / 12); - if (await page.getByText("Completed in 11m 44s").isVisible()) { + if (await page.getByText("Took 11m 44s").isVisible()) { preservedDurationFound = true; break; } @@ -399,7 +399,7 @@ test.describe("Thread history", () => { await expect(page.getByText(followUpPrompt)).toBeVisible(); }); - test("shows a completed run duration once after multi-step history", async ({ + test("shows a completed run duration once in the final reasoning header", async ({ page, }) => { mockLangGraphAPI(page, { @@ -440,11 +440,25 @@ test.describe("Thread history", () => { }); await expect(page.getByTestId("run-duration")).toHaveCount(1); - await expect(page.getByText("Completed in 1m 54s")).toBeVisible(); + await expect(page.getByText("Took 1m 54s")).toBeVisible(); + const disclosure = page.getByRole("button", { + name: "Took 1m 54s Reasoning", + exact: true, + }); + await expect(disclosure).toBeVisible(); + await expect(disclosure).toHaveAttribute("aria-expanded", "false"); + await disclosure.click(); + await expect(page.getByText("Final synthesis reasoning")).toBeVisible(); + await disclosure.press("Enter"); + await expect(page.getByText("Final synthesis reasoning")).not.toBeVisible(); + const headerBox = await disclosure.boundingBox(); + const answerBox = await page + .getByText("Final result", { exact: true }) + .boundingBox(); + expect(headerBox!.y + headerBox!.height).toBeLessThan(answerBox!.y); await expect( page.getByRole("button", { name: "Reasoning", exact: true }), - ).toBeVisible(); - await expect(page.getByText("Thought for 114 seconds")).toHaveCount(0); + ).toHaveCount(0); }); test("input box recalls previous prompts with arrow keys", async ({ diff --git a/frontend/tests/unit/components/workspace/messages/message-group-duration.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-group-duration.dom.test.tsx new file mode 100644 index 0000000000..4a0440062c --- /dev/null +++ b/frontend/tests/unit/components/workspace/messages/message-group-duration.dom.test.tsx @@ -0,0 +1,42 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { afterEach, expect, it, rs } from "@rstest/core"; +import { cleanup, render, screen } from "@testing-library/react"; + +import { MessageGroup } from "@/components/workspace/messages/message-group"; +import { I18nContext } from "@/core/i18n/context"; +import { enUS } from "@/core/i18n/locales/en-US"; + +rs.mock("@/components/workspace/artifacts", () => ({ + useArtifacts: () => ({ artifacts: [], autoOpen: false, autoSelect: false }), +})); +afterEach(cleanup); + +it("updates a memoized reasoning header when only its duration changes", () => { + const messages: Message[] = [ + { + id: "reasoning", + type: "ai", + content: "", + additional_kwargs: { reasoning_content: "Consider the question." }, + }, + ]; + const view = (durationSeconds: number) => ( + undefined, t: enUS }} + > + + + ); + const { rerender } = render(view(31)); + expect( + screen.getByRole("button", { name: "Took 31s Reasoning" }), + ).toBeTruthy(); + rerender(view(47)); + expect( + screen.getByRole("button", { name: "Took 47s Reasoning" }), + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Took 31s Reasoning" }), + ).toBeNull(); + expect(screen.getAllByTestId("run-duration")).toHaveLength(1); +}); diff --git a/frontend/tests/unit/components/workspace/messages/message-list-reasoning.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-list-reasoning.dom.test.tsx new file mode 100644 index 0000000000..7f6182c7f5 --- /dev/null +++ b/frontend/tests/unit/components/workspace/messages/message-list-reasoning.dom.test.tsx @@ -0,0 +1,151 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { afterEach, expect, it, rs } from "@rstest/core"; +import { cleanup, render } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { MessageList } from "@/components/workspace/messages/message-list"; +import { I18nContext } from "@/core/i18n/context"; +import { enUS } from "@/core/i18n/locales/en-US"; +import type { MessageGroup } from "@/core/messages/utils"; + +const selectReasoning = rs.hoisted(() => + rs.fn((messages: Message[]) => + messages.find((message) => message.additional_kwargs?.reasoning_content), + ), +); + +rs.mock("@/components/workspace/messages/message-group", () => ({ + MessageGroup: () => null, + getMessageGroupReasoningMessage: selectReasoning, +})); +rs.mock("@/components/ai-elements/conversation", () => ({ + Conversation: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + ConversationContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); +rs.mock("@/components/workspace/messages/virtual-message-list", () => ({ + VirtualMessageList: ({ + groups, + renderGroup, + }: { + groups: MessageGroup[]; + renderGroup: (group: MessageGroup, index: number) => ReactNode; + }) => ( +
+ {groups.map((group, index) => ( +
{renderGroup(group, index)}
+ ))} +
+ ), +})); +rs.mock("@/components/workspace/messages/message-list-item", () => ({ + MessageListItem: () => null, +})); +rs.mock("@/components/workspace/messages/subtask-card", () => ({ + SubtaskCard: () => null, +})); + +afterEach(() => { + cleanup(); + selectReasoning.mockClear(); +}); + +function reasoningMessage(kind: "processing" | "subagent", duration?: number) { + return { + id: "reasoning", + run_id: "historical-run", + type: "ai", + content: "", + additional_kwargs: { + reasoning_content: "Consider the question.", + ...(duration === undefined ? {} : { turn_duration: duration }), + }, + ...(kind === "subagent" + ? { + tool_calls: [ + { + id: "task", + name: "task", + args: { prompt: "Research", subagent_type: "general-purpose" }, + }, + ], + } + : {}), + } satisfies Message & { run_id: string }; +} + +const getMessagesMetadata = () => undefined; +function view(messages: Message[], isLoading: boolean, revision = 0) { + return ( + undefined, t: enUS }} + > + ["thread"] + } + /> + + ); +} + +for (const kind of ["processing", "subagent"] as const) { + for (const isLoading of [true, false]) { + it(`skips unused ${kind} targets: loading=${isLoading}`, () => { + render(view([reasoningMessage(kind)], isLoading)); + expect(selectReasoning).not.toHaveBeenCalled(); + }); + } + + it(`reuses ${kind} targets and invalidates changed messages`, () => { + const message = reasoningMessage(kind, 31); + const messages = [message]; + const { rerender } = render(view(messages, false)); + expect(selectReasoning).toHaveBeenCalledTimes(1); + for (let revision = 1; revision <= 5; revision++) { + rerender(view(messages, false, revision)); + } + expect(selectReasoning).toHaveBeenCalledTimes(1); + const changed = { + ...message, + additional_kwargs: { + ...message.additional_kwargs, + reasoning_content: "Updated reasoning.", + }, + }; + rerender(view([changed], false)); + expect(selectReasoning).toHaveBeenCalledTimes(2); + expect(selectReasoning.mock.calls.at(-1)?.[0][0]).toBe(changed); + }); +} + +it("reuses historical targets across actual streaming message updates", () => { + const history: Message[] = [ + { id: "old-human", type: "human", content: "Previous question" }, + reasoningMessage("processing", 31), + { id: "new-human", type: "human", content: "Next question" }, + ]; + const live = (chunk: number): Message => ({ + id: "live-reasoning", + type: "ai", + content: "", + additional_kwargs: { reasoning_content: `Live reasoning ${chunk}` }, + }); + const { rerender } = render(view([...history, live(0)], true)); + expect(selectReasoning).toHaveBeenCalledTimes(1); + for (let chunk = 1; chunk <= 5; chunk++) { + rerender(view([...history, live(chunk)], true)); + } + expect(selectReasoning).toHaveBeenCalledTimes(1); +}); diff --git a/frontend/tests/unit/components/workspace/messages/message-reasoning.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-reasoning.dom.test.tsx new file mode 100644 index 0000000000..ffb15e573c --- /dev/null +++ b/frontend/tests/unit/components/workspace/messages/message-reasoning.dom.test.tsx @@ -0,0 +1,112 @@ +import { afterEach, expect, it, rs } from "@rstest/core"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; + +import { MessageReasoning } from "@/components/workspace/messages/message-reasoning"; +import { I18nContext } from "@/core/i18n/context"; +import { enUS } from "@/core/i18n/locales/en-US"; + +afterEach(() => { + cleanup(); + rs.useRealTimers(); +}); + +function view(isLoading = false, durationSeconds?: number) { + return ( + undefined, t: enUS }} + > + + Consider the question. + + + ); +} + +function advance(milliseconds: number) { + act(() => { + void rs.advanceTimersByTime(milliseconds); + }); +} + +for (const durationSeconds of [undefined, 31]) { + it(`starts completed reasoning collapsed, including remounts: ${durationSeconds}`, () => { + rs.useFakeTimers(); + const first = render(view(false, durationSeconds)); + expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe( + "false", + ); + first.unmount(); + render(view(false, durationSeconds)); + expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe( + "false", + ); + }); +} + +it("keeps manually expanded history open without an auto-close timer", () => { + rs.useFakeTimers(); + render(view(false, 31)); + const trigger = screen.getByRole("button"); + fireEvent.click(trigger); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + advance(2000); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); +}); + +it("opens a later stream and closes once after completion", () => { + rs.useFakeTimers(); + const { rerender } = render(view()); + rerender(view(true)); + const trigger = screen.getByRole("button"); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + rerender(view(false, 31)); + advance(999); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + advance(1); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(trigger); + advance(2000); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); +}); + +it("preserves live completion when persisted duration replaces client time", () => { + rs.useFakeTimers(); + const { rerender } = render(view(true)); + rerender(view(false, 31)); + advance(500); + rerender(view(false, 47)); + advance(500); + expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe( + "false", + ); + expect(screen.getByTestId("run-duration").textContent).toBe("Took 47s"); +}); + +it("cancels a pending close when streaming resumes", () => { + rs.useFakeTimers(); + const { rerender } = render(view(true)); + rerender(view(false, 31)); + advance(500); + rerender(view(true)); + advance(1000); + expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("true"); + rerender(view(false, 47)); + advance(1000); + expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe( + "false", + ); +}); + +it("clears the close timer on unmount", () => { + rs.useFakeTimers(); + const { rerender, unmount } = render(view(true)); + rerender(view(false, 31)); + unmount(); + expect(rs.getTimerCount()).toBe(0); +});