mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-07 19:20:34 +00:00
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:
parent
2145c9ab1f
commit
728446fe0d
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user