-
-
{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);
+});