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