fix(frontend): keep the live run timer on the run's server start (#7888)

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 <willem.jiang@gmail.com>
This commit is contained in:
Xinmin Zeng 2026-11-04 14:51:55 -08:00 committed by GitHub
parent 2145c9ab1f
commit 728446fe0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 267 additions and 3 deletions

View File

@ -197,6 +197,7 @@ export default function AgentChatPage() {
runInputs,
canSendInput,
activeRunId,
activeRunStartedAt,
regenerateMessage,
editAndRegenerateMessage,
isUploading,
@ -569,6 +570,7 @@ export default function AgentChatPage() {
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
activeRunId={activeRunId}
activeRunStartedAt={activeRunStartedAt}
archiveDownloadsEnabled={
isNewThread || isMock || threadMetadata.data != null
}

View File

@ -209,6 +209,7 @@ export default function ChatPage() {
runInputs,
canSendInput,
activeRunId,
activeRunStartedAt,
regenerateMessage,
editAndRegenerateMessage,
isUploading,
@ -659,6 +660,7 @@ export default function ChatPage() {
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
activeRunId={activeRunId}
activeRunStartedAt={activeRunStartedAt}
archiveDownloadsEnabled={
isNewThread || isMock || threadMetadata.data != null
}

View File

@ -337,6 +337,7 @@ export function MessageList({
onBranchTurn,
canRegenerate = false,
activeRunId,
activeRunStartedAt = null,
canEdit = false,
editLockedByGoal = false,
canBranch = false,
@ -375,6 +376,8 @@ export function MessageList({
) => void | Promise<void>;
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<number | null>(() =>
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

View File

@ -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,

View File

@ -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;
}

View File

@ -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 }) => (
<div>{children}</div>
),
ConversationContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
rs.mock("@/components/workspace/messages/virtual-message-list", () => ({
VirtualMessageList: ({
groups,
renderGroup,
renderAfterGroup,
}: {
groups: MessageGroup[];
renderGroup: (group: MessageGroup, index: number) => ReactNode;
renderAfterGroup?: (index: number) => ReactNode;
}) => (
<div>
{groups.map((group, index) => (
<div key={`${group.type}:${group.id}`}>
{renderGroup(group, index)}
{renderAfterGroup?.(index)}
</div>
))}
</div>
),
}));
rs.mock("@/components/workspace/messages/message-list-item", () => ({
MessageListItem: ({
message,
canEdit,
editLockedByGoal,
}: {
message: Message;
canEdit?: boolean;
editLockedByGoal?: boolean;
}) => (
<div
data-testid={`item-${message.type}`}
data-can-edit={canEdit ? "true" : "false"}
data-edit-locked={editLockedByGoal ? "true" : "false"}
/>
),
}));
rs.mock("@/components/workspace/messages/subtask-card", () => ({
SubtaskCard: () => null,
}));
rs.mock("@/components/workspace/messages/scheduled-task-card", () => ({
ScheduledTaskCard: ({ result }: { result: { task: { id: string } } }) => (
<div data-testid="card" data-task-id={result.task.id} />
),
taskPagePath: (id: string) => `/workspace/scheduled-tasks?task_id=${id}`,
}));
rs.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
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 (
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<MessageList
threadId="thread-1"
activeRunStartedAt={activeRunStartedAt}
thread={
{
messages: MESSAGES,
isLoading,
isThreadLoading: false,
values: {},
getMessagesMetadata: () => undefined,
} as unknown as React.ComponentProps<typeof MessageList>["thread"]
}
/>
</I18nContext.Provider>
);
}
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");
});
});

View File

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

View File

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