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