From 728446fe0d53e60c960f4ba2a2439dfdae603677 Mon Sep 17 00:00:00 2001 From: Xinmin Zeng <135568692+fancyboi999@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:51:55 -0800 Subject: [PATCH] fix(frontend): keep the live run timer on the run's server start (#7888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat that observes an active run again (remount, chat switch, rejoin) started its Working… timer from the browser's current time, dropping the time the run had already spent. useThreadStream now exposes the active run's created_at from the runs list, and MessageList moves its start back to it. It never moves the start forward, so a run submitted in this view keeps its local start. Fixes #7886 Co-authored-by: Willem Jiang --- .../[agent_name]/chats/[thread_id]/page.tsx | 2 + .../components/workspace/chats/chat-page.tsx | 2 + .../workspace/messages/message-list.tsx | 21 ++- frontend/src/core/messages/run-duration.ts | 21 +++ frontend/src/core/threads/hooks.ts | 14 +- .../message-list-run-activity.dom.test.tsx | 161 ++++++++++++++++++ .../unit/core/messages/run-duration.test.ts | 24 +++ .../threads/active-run-rejoin.dom.test.tsx | 25 +++ 8 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 frontend/tests/unit/components/workspace/messages/message-list-run-activity.dom.test.tsx diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 16c84d35ec..a8d50ff4a3 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -197,6 +197,7 @@ export default function AgentChatPage() { runInputs, canSendInput, activeRunId, + activeRunStartedAt, regenerateMessage, editAndRegenerateMessage, isUploading, @@ -569,6 +570,7 @@ export default function AgentChatPage() {
void | Promise; canRegenerate?: boolean; activeRunId?: string; + /** Server start (epoch ms) of the active run, when known. */ + activeRunStartedAt?: number | null; canEdit?: boolean; /** Edit would be allowed but for an active goal: show the pencil locked. */ editLockedByGoal?: boolean; @@ -501,8 +504,8 @@ export function MessageList({ const browserView = useMaybeBrowserView(); const pushBrowserFrame = browserView?.pushFrame; const messageCount = messages.length; - // The backend exposes no live start timestamp, so a mid-run mount measures - // from mount until authoritative persisted turn_duration replaces it. + // Start from mount or the loading edge, then move back to the active run's + // server start once known: a run observed mid-flight began before this view. const [turnStartTime, setTurnStartTime] = useState(() => thread.isLoading ? Date.now() : null, ); @@ -540,6 +543,20 @@ export function MessageList({ prevIsLoading.current = thread.isLoading; }, [groupedMessages, thread.error, thread.isLoading, threadId]); + useEffect(() => { + const startTime = turnStartTimeRef.current; + if ( + !thread.isLoading || + startTime === null || + activeRunStartedAt === null || + activeRunStartedAt >= startTime + ) { + return; + } + turnStartTimeRef.current = activeRunStartedAt; + setTurnStartTime(activeRunStartedAt); + }, [activeRunStartedAt, thread.isLoading]); + useEffect(() => { // Only the primary chat surface drives the shared browser panel. The // sidecar renders a different thread's messages against the same diff --git a/frontend/src/core/messages/run-duration.ts b/frontend/src/core/messages/run-duration.ts index 00e691c4d2..e6f1a2cc1c 100644 --- a/frontend/src/core/messages/run-duration.ts +++ b/frontend/src/core/messages/run-duration.ts @@ -73,6 +73,27 @@ export function getRunDurationDisplaysByGroupIndex( return displays; } +const ISO_TIMEZONE_SUFFIX = /(?:Z|[+-]\d{2}:?\d{2})$/i; + +/** + * Parse a run's `created_at` into epoch milliseconds. The backend measures + * `turn_duration` from this timestamp and reads offset-less values as UTC, so + * the live elapsed time does the same instead of using the browser's zone. + */ +export function parseRunStartTime(createdAt: unknown): number | null { + if (typeof createdAt !== "string") { + return null; + } + const value = createdAt.trim(); + if (!value) { + return null; + } + const time = Date.parse( + ISO_TIMEZONE_SUFFIX.test(value) ? value : `${value}Z`, + ); + return Number.isFinite(time) ? time : null; +} + export function formatRunDuration( value: number, formatter: RunDurationFormatter, diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 74bba60888..e1c20cc90a 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -30,7 +30,7 @@ import { getBackendBaseURL } from "../config"; import { useRunInputsEnabled } from "../features"; import { useI18n } from "../i18n/hooks"; import type { Locale } from "../i18n/locale"; -import { getMessageRunId } from "../messages/run-duration"; +import { getMessageRunId, parseRunStartTime } from "../messages/run-duration"; import { isErrorToolMessage } from "../messages/tool-error"; import { indexToolResults } from "../messages/tool-result"; import { @@ -2504,6 +2504,17 @@ export function useThreadStream({ inputActiveRunId && !completedRunIdsRef.current.has(inputActiveRunId) ? inputActiveRunId : activeRunId; + // A run observed mid-flight (remount, chat switch, rejoin) started before + // this view did. Its record carries the same start that turn_duration uses. + const knownActiveRun = knownActiveRunId + ? runsQuery.data?.find((run) => run.run_id === knownActiveRunId) + : undefined; + const knownActiveRunStartedAt = + knownActiveRun && + ACTIVE_RUN_STATUSES.has(String(knownActiveRun.status)) && + !completedRunIdsRef.current.has(knownActiveRun.run_id) + ? parseRunStartTime(knownActiveRun.created_at) + : null; // reconnectOnMount only knows the run id stored in this tab's // sessionStorage. A reopened browser or a new tab has no pointer, so recover @@ -3461,6 +3472,7 @@ export function useThreadStream({ ), canSendInput: inputCapability && !!inputQuery.data?.active_run_id, activeRunId: knownActiveRunId, + activeRunStartedAt: knownActiveRunStartedAt, } as const; } diff --git a/frontend/tests/unit/components/workspace/messages/message-list-run-activity.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-list-run-activity.dom.test.tsx new file mode 100644 index 0000000000..33f6ced0c2 --- /dev/null +++ b/frontend/tests/unit/components/workspace/messages/message-list-run-activity.dom.test.tsx @@ -0,0 +1,161 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { act, cleanup, render, screen } 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"; + +rs.mock("@/components/workspace/messages/message-group", () => ({ + MessageGroup: () => null, + getMessageGroupReasoningMessage: () => undefined, +})); +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, + renderAfterGroup, + }: { + groups: MessageGroup[]; + renderGroup: (group: MessageGroup, index: number) => ReactNode; + renderAfterGroup?: (index: number) => ReactNode; + }) => ( +
+ {groups.map((group, index) => ( +
+ {renderGroup(group, index)} + {renderAfterGroup?.(index)} +
+ ))} +
+ ), +})); +rs.mock("@/components/workspace/messages/message-list-item", () => ({ + MessageListItem: ({ + message, + canEdit, + editLockedByGoal, + }: { + message: Message; + canEdit?: boolean; + editLockedByGoal?: boolean; + }) => ( +
+ ), +})); +rs.mock("@/components/workspace/messages/subtask-card", () => ({ + SubtaskCard: () => null, +})); +rs.mock("@/components/workspace/messages/scheduled-task-card", () => ({ + ScheduledTaskCard: ({ result }: { result: { task: { id: string } } }) => ( +
+ ), + taskPagePath: (id: string) => `/workspace/scheduled-tasks?task_id=${id}`, +})); +rs.mock("next/link", () => ({ + default: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + +const NOW = Date.UTC(2026, 10, 4, 9, 0, 0); + +beforeEach(() => { + rs.useFakeTimers(); + rs.setSystemTime(NOW); +}); + +afterEach(() => { + cleanup(); + rs.useRealTimers(); +}); + +const MESSAGES = [ + { id: "human-1", type: "human", content: "Research this" }, + { id: "ai-1", type: "ai", content: "Working on it" }, +] as Message[]; + +function view({ + isLoading, + activeRunStartedAt, +}: { + isLoading: boolean; + activeRunStartedAt?: number | null; +}) { + return ( + undefined, t: enUS }} + > + undefined, + } as unknown as React.ComponentProps["thread"] + } + /> + + ); +} + +function activity() { + return screen.getByTestId("run-activity").textContent; +} + +describe("MessageList live run timer", () => { + it("counts from the run's server start when mounted mid-run", () => { + render(view({ isLoading: true, activeRunStartedAt: NOW - 42_000 })); + + expect(activity()).toContain("(42s)"); + }); + + it("keeps counting the same run when it becomes active in this view again", () => { + const { rerender } = render(view({ isLoading: false })); + + rerender(view({ isLoading: true, activeRunStartedAt: NOW - 95_000 })); + + expect(activity()).toContain("(1m 35s)"); + }); + + it("does not move a run started in this view forward to its server start", () => { + const { rerender } = render(view({ isLoading: false })); + rerender(view({ isLoading: true })); + act(() => { + rs.advanceTimersByTime(5_000); + }); + + // The server records the run slightly after the browser submitted it. + rerender(view({ isLoading: true, activeRunStartedAt: NOW + 400 })); + + expect(activity()).toContain("(5s)"); + }); + + it("records the completed duration from the corrected start", () => { + const { rerender } = render( + view({ isLoading: true, activeRunStartedAt: NOW - 42_000 }), + ); + + rerender(view({ isLoading: false, activeRunStartedAt: null })); + + expect(screen.getByTestId("run-duration").textContent).toContain("42s"); + }); +}); diff --git a/frontend/tests/unit/core/messages/run-duration.test.ts b/frontend/tests/unit/core/messages/run-duration.test.ts index cd339d7d62..2f44267ecc 100644 --- a/frontend/tests/unit/core/messages/run-duration.test.ts +++ b/frontend/tests/unit/core/messages/run-duration.test.ts @@ -6,6 +6,7 @@ import { zhCN } from "@/core/i18n/locales/zh-CN"; import { formatRunDuration, getRunDurationDisplaysByGroupIndex, + parseRunStartTime, } from "@/core/messages/run-duration"; import { getMessageGroups } from "@/core/messages/utils"; @@ -136,3 +137,26 @@ describe("run duration formatting", () => { expect(formatRunDuration(61.9, enUS.runDuration)).toBe("1m 1s"); }); }); + +describe("run start time parsing", () => { + test("reads offset timestamps with microsecond precision", () => { + const expected = Date.UTC(2026, 10, 4, 8, 51, 37, 123); + expect(parseRunStartTime("2026-11-04T08:51:37.123456+00:00")).toBe( + expected, + ); + expect(parseRunStartTime("2026-11-04T08:51:37.123456Z")).toBe(expected); + expect(parseRunStartTime("2026-11-04T16:51:37.123+08:00")).toBe(expected); + }); + + test("reads offset-less timestamps as UTC like the backend", () => { + expect(parseRunStartTime("2026-11-04T08:51:37.123456")).toBe( + Date.UTC(2026, 10, 4, 8, 51, 37, 123), + ); + }); + + test("rejects missing or malformed timestamps", () => { + expect(parseRunStartTime(undefined)).toBeNull(); + expect(parseRunStartTime("")).toBeNull(); + expect(parseRunStartTime("not a time")).toBeNull(); + }); +}); diff --git a/frontend/tests/unit/core/threads/active-run-rejoin.dom.test.tsx b/frontend/tests/unit/core/threads/active-run-rejoin.dom.test.tsx index b00c12f46a..6bae239110 100644 --- a/frontend/tests/unit/core/threads/active-run-rejoin.dom.test.tsx +++ b/frontend/tests/unit/core/threads/active-run-rejoin.dom.test.tsx @@ -508,3 +508,28 @@ test("still recovers a different active run after an earlier run finishes", asyn expect(streamMockState.joinStream).toHaveBeenCalledWith("run-next"); unmount(); }); + +test("exposes the active run's server start until that run finishes", async () => { + apiMockState.listRuns.mockResolvedValue([ + { ...ACTIVE_RUN, created_at: "2026-11-04T08:00:00.250000+00:00" }, + ]); + const { result, rerender, unmount } = renderThread(); + await flushFrames(); + + expect(result.current.activeRunStartedAt).toBe( + Date.UTC(2026, 10, 4, 8, 0, 0, 250), + ); + + // The cached runs list can still say "running" after the stream finishes. + apiMockState.listRuns.mockImplementation(() => new Promise(() => undefined)); + act(() => { + streamMockState.options?.onFinish?.( + { values: { artifacts: [], messages: [], title: "Done" } }, + ACTIVE_RUN_META, + ); + }); + rerender({ activeThreadId: "thread-1" }); + + expect(result.current.activeRunStartedAt).toBeNull(); + unmount(); +});