fix(frontend): clarify run duration display (#4348)

* fix(frontend): clarify run duration display

* fix(frontend): preserve run metadata across history merge

* ci: retrigger workflows
This commit is contained in:
Huixin615 2026-07-23 14:57:16 +08:00 committed by GitHub
parent 04659cc8dd
commit d4fdc2758e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 614 additions and 122 deletions

View File

@ -691,6 +691,8 @@ Streaming Markdown responses animate only newly arrived words; text that is alre
In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint. Because workspace files are not checkpointed, the branch only receives a best-effort copy of the current workspace when you branch from the latest turn; branching from an older turn keeps just the restored message history so the branch never inherits files that were created in a later part of the conversation.
The Web UI reports completed task time once per run. This is total wall-clock time—including model reasoning, tool calls, and waiting—not a per-step or model-only thinking duration. Reasoning content remains available through its own separate disclosure.
Web UI chat links percent-encode custom thread identifiers before placing them in route segments, so reserved URL characters such as `#` and `?` do not change which conversation is opened.
```

View File

@ -71,6 +71,8 @@ The frontend is a stateful chat application. Users create **threads** (conversat
5. TanStack Query manages server state; localStorage stores user settings
6. Components subscribe to thread state and render updates
Run duration is run-scoped UI metadata even though the compatibility field `additional_kwargs.turn_duration` is repeated on historical AI messages. `core/messages/run-duration.ts` folds those copies into one display anchored after the run's last visible message group. `MessageList` owns the temporary client-side duration for a just-completed live turn until authoritative history arrives. The duration is total run wall-clock time, not per-message reasoning time; reasoning disclosure and run activity/duration are rendered separately.
Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores only text plus the selected slash-skill name in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. `InputBox` waits for enabled skills before restoring a skill chip, degrades a missing/disabled skill back to editable slash text, and clears the stored draft through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted.
Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned.

View File

@ -10,7 +10,6 @@ import {
useCallback,
useMemo,
useState,
useEffect,
type ImgHTMLAttributes,
} from "react";
@ -24,6 +23,7 @@ import {
Reasoning,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import {
@ -139,7 +139,6 @@ export function MessageListItem({
threadId,
artifactPaths = [],
showCopyButton = true,
turnStartTime,
}: {
className?: string;
message: Message;
@ -149,7 +148,6 @@ export function MessageListItem({
feedback?: FeedbackData | null;
runId?: string;
showCopyButton?: boolean;
turnStartTime?: number | null;
}) {
const isHuman = message.type === "human";
return (
@ -164,7 +162,6 @@ export function MessageListItem({
threadId={threadId}
artifactPaths={artifactPaths}
runId={runId}
turnStartTime={turnStartTime}
/>
{!isLoading && showCopyButton && (
<MessageToolbar
@ -239,8 +236,6 @@ function MessageImage({
);
}
const clientTurnDurations = new Map<string, number>();
function HumanMessageText({ content }: { content: string }) {
// `parseSlashSkillReference` is a pure regex gate (no data subscription), so
// the overwhelmingly common plain-text human message never subscribes to the
@ -284,7 +279,6 @@ function MessageContent_({
threadId,
artifactPaths,
runId,
turnStartTime,
}: {
className?: string;
message: Message;
@ -292,52 +286,18 @@ function MessageContent_({
threadId: string;
artifactPaths: readonly string[];
runId?: string;
turnStartTime?: number | null;
}) {
const { t } = useI18n();
const isHuman = message.type === "human";
const rawTurnDuration = message.additional_kwargs?.turn_duration as
| number
| undefined;
const [cachedDuration, setCachedDuration] = useState<number | undefined>(
() =>
message.id
? clientTurnDurations.get(`${threadId}:${message.id}`)
: undefined,
const getReasoningMessage = useCallback(
(isStreaming: boolean) =>
isStreaming ? (
<Shimmer duration={1}>{t.runDuration.reasoning}</Shimmer>
) : (
t.runDuration.reasoning
),
[t.runDuration.reasoning],
);
const turnDuration = rawTurnDuration ?? cachedDuration;
useEffect(() => {
if (rawTurnDuration !== undefined && message.id) {
clientTurnDurations.set(`${threadId}:${message.id}`, rawTurnDuration);
setCachedDuration(rawTurnDuration);
}
}, [rawTurnDuration, message.id, threadId]);
const handleDurationChange = useCallback(
(d: number | undefined) => {
if (d !== undefined && message.id) {
clientTurnDurations.set(`${threadId}:${message.id}`, d);
setCachedDuration(d);
}
},
[message.id, threadId],
);
useEffect(() => {
return () => {
for (const key of clientTurnDurations.keys()) {
if (key.startsWith(`${threadId}:`)) {
clientTurnDurations.delete(key);
}
}
};
}, [threadId]);
const [wasLoading, setWasLoading] = useState(isLoading);
useEffect(() => {
if (isLoading) setWasLoading(true);
}, [isLoading]);
const components = useMemo(
() => ({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
@ -414,13 +374,8 @@ function MessageContent_({
if (!isHuman && reasoningContent && !rawContent) {
return (
<AIElementMessageContent className={className}>
<Reasoning
isStreaming={isLoading}
startTimeProp={turnStartTime}
duration={turnDuration}
onTurnDurationChange={handleDurationChange}
>
<ReasoningTrigger />
<Reasoning isStreaming={isLoading}>
<ReasoningTrigger getThinkingMessage={getReasoningMessage} />
<SafeReasoningContent>{reasoningContent}</SafeReasoningContent>
</Reasoning>
</AIElementMessageContent>
@ -459,20 +414,12 @@ function MessageContent_({
return (
<AIElementMessageContent className={className}>
{filesList}
{!isHuman &&
(!!reasoningContent || wasLoading || turnDuration !== undefined) && (
<Reasoning
isStreaming={isLoading}
startTimeProp={turnStartTime}
duration={turnDuration}
onTurnDurationChange={handleDurationChange}
>
<ReasoningTrigger hasContent={!!reasoningContent} />
{reasoningContent && (
<SafeReasoningContent>{reasoningContent}</SafeReasoningContent>
)}
</Reasoning>
)}
{reasoningContent && (
<Reasoning isStreaming={isLoading}>
<ReasoningTrigger getThinkingMessage={getReasoningMessage} />
<SafeReasoningContent>{reasoningContent}</SafeReasoningContent>
</Reasoning>
)}
<MarkdownContent
content={contentToDisplay}
isLoading={isLoading}

View File

@ -15,6 +15,7 @@ import {
useRef,
useState,
type MouseEvent,
type ReactNode,
} from "react";
import { toast } from "sonner";
@ -23,10 +24,6 @@ import {
ConversationContent,
type ConversationProps,
} from "@/components/ai-elements/conversation";
import {
Reasoning,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Button } from "@/components/ui/button";
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
@ -37,6 +34,10 @@ import {
type HumanInputRequest,
type HumanInputResponse,
} from "@/core/messages/human-input";
import {
getMessageRunId,
getRunDurationDisplaysByGroupIndex,
} from "@/core/messages/run-duration";
import {
buildTokenDebugSteps,
type TokenDebugStep,
@ -88,6 +89,7 @@ import {
MessageTokenUsageDebugList,
MessageTokenUsageList,
} from "./message-token-usage";
import { RunActivity, RunDuration } from "./run-duration";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
@ -120,6 +122,16 @@ function sameMessageIdentity(previous: Message, next: Message) {
return previousKey !== null && previousKey === nextKey;
}
function sameRunDurationMetadata(previous: Message, next: Message) {
return (
getMessageRunId(previous) === getMessageRunId(next) &&
Object.is(
previous.additional_kwargs?.turn_duration,
next.additional_kwargs?.turn_duration,
)
);
}
function canReuseMessageGroup(
previous: ThreadMessageGroup | undefined,
next: ThreadMessageGroup,
@ -135,7 +147,8 @@ function canReuseMessageGroup(
return previous.messages.every(
(message, index) =>
next.messages[index] !== undefined &&
sameMessageIdentity(message, next.messages[index]),
sameMessageIdentity(message, next.messages[index]) &&
sameRunDurationMetadata(message, next.messages[index]),
);
}
@ -360,19 +373,50 @@ export function MessageList({
const sidecar = useMaybeSidecar();
const [selectionToolbar, setSelectionToolbar] =
useState<SelectionToolbarState | null>(null);
const [turnStartTime, setTurnStartTime] = useState<number | null>(null);
const messages = thread.messages;
const groupedMessages = useStableMessageGroups(messages, thread.isLoading);
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.
const [turnStartTime, setTurnStartTime] = useState<number | null>(() =>
thread.isLoading ? Date.now() : null,
);
const turnStartTimeRef = useRef(turnStartTime);
const [clientDurationsByGroupId, setClientDurationsByGroupId] = useState<
ReadonlyMap<string, number>
>(() => new Map());
const prevIsLoading = useRef(thread.isLoading);
useEffect(() => {
if (thread.isLoading && !prevIsLoading.current) {
setTurnStartTime(Date.now());
const now = Date.now();
turnStartTimeRef.current = now;
setTurnStartTime(now);
} else if (!thread.isLoading && prevIsLoading.current) {
const startTime = turnStartTimeRef.current;
const lastAssistantGroup = [...groupedMessages]
.reverse()
.find((group) => group.type !== "human" && group.id);
if (startTime !== null && lastAssistantGroup?.id && !thread.error) {
const duration = Math.max(
0,
Math.floor((Date.now() - startTime) / 1000),
);
const key = `${threadId}:${lastAssistantGroup.id}`;
setClientDurationsByGroupId((current) => {
const next = new Map(current);
next.set(key, duration);
return next;
});
}
turnStartTimeRef.current = null;
setTurnStartTime(null);
}
prevIsLoading.current = thread.isLoading;
}, [thread.isLoading]);
const messages = thread.messages;
const browserView = useMaybeBrowserView();
const pushBrowserFrame = browserView?.pushFrame;
const messageCount = messages.length;
}, [groupedMessages, thread.error, thread.isLoading, threadId]);
useEffect(() => {
// Only the primary chat surface drives the shared browser panel. The
// sidecar renders a different thread's messages against the same
@ -410,7 +454,6 @@ export function MessageList({
// repeatedly scan long history looking for the last browser frame.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messageCount, pushBrowserFrame, sidecarSurface]);
const groupedMessages = useStableMessageGroups(messages, thread.isLoading);
const [regeneratingMessageId, setRegeneratingMessageId] = useState<
string | null
>(null);
@ -437,6 +480,28 @@ export function MessageList({
const lastGroupIndex = groupedMessages.length - 1;
const turnUsageMessagesByGroupIndex =
getAssistantTurnUsageMessages(groupedMessages);
const runDurationDisplaysByGroupIndex = useMemo(
() => getRunDurationDisplaysByGroupIndex(groupedMessages),
[groupedMessages],
);
useEffect(() => {
setClientDurationsByGroupId((current) => {
let next: Map<string, number> | undefined;
runDurationDisplaysByGroupIndex.forEach((displays, groupIndex) => {
const groupId = groupedMessages[groupIndex]?.id;
if (displays.length === 0 || !groupId) {
return;
}
const key = `${threadId}:${groupId}`;
if (!current.has(key)) {
return;
}
next ??= new Map(current);
next.delete(key);
});
return next ?? current;
});
}, [groupedMessages, runDurationDisplaysByGroupIndex, threadId]);
const tokenDebugSteps = useMemo(
() =>
tokenUsageInlineMode === "step_debug"
@ -886,6 +951,47 @@ export function MessageList({
return <MessageListSkeleton />;
}
const withRunDuration = (
group: (typeof groupedMessages)[number],
groupIndex: number,
content: ReactNode,
) => {
const persistedDisplays = runDurationDisplaysByGroupIndex[groupIndex] ?? [];
const clientDuration =
!thread.error && group.id
? clientDurationsByGroupId.get(`${threadId}:${group.id}`)
: undefined;
const displays =
persistedDisplays.length > 0
? persistedDisplays
: clientDuration !== undefined
? [
{
runId: `client:${group.id}`,
durationSeconds: clientDuration,
},
]
: [];
if (!content && displays.length === 0) {
return null;
}
return (
<div
key={`duration-group:${group.id ?? groupIndex}`}
className="flex w-full flex-col gap-2"
>
{content}
{displays.map((display) => (
<RunDuration
key={display.runId}
durationSeconds={display.durationSeconds}
/>
))}
</div>
);
};
return (
<>
<Conversation
@ -906,9 +1012,10 @@ export function MessageList({
thread.isLoading && groupIndex === lastGroupIndex;
if (group.type === "human" || group.type === "assistant") {
return (
return withRunDuration(
group,
groupIndex,
<div
key={group.id}
data-assistant-turn={
group.type === "assistant" ? "" : undefined
}
@ -933,11 +1040,6 @@ export function MessageList({
: undefined
}
showCopyButton={group.type !== "assistant"}
turnStartTime={
groupIndex === groupedMessages.length - 1
? turnStartTime
: null
}
/>
);
@ -979,7 +1081,7 @@ export function MessageList({
branchableAssistantGroupIds.has(group.id),
group.id === latestAssistantGroupId,
)}
</div>
</div>,
);
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
@ -996,8 +1098,10 @@ export function MessageList({
const pending = pendingHumanInputRequestIds.has(
humanInputRequest.request_id,
);
return (
<div key={group.id} className="w-full">
return withRunDuration(
group,
groupIndex,
<div className="w-full">
<HumanInputCard
answeredResponse={answeredResponse}
disabled={
@ -1024,13 +1128,15 @@ export function MessageList({
messages: group.messages,
turnUsageMessages,
})}
</div>
</div>,
);
}
if (hasContent(message)) {
return (
<div key={group.id} className="w-full">
return withRunDuration(
group,
groupIndex,
<div className="w-full">
<MarkdownContent
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
@ -1039,10 +1145,10 @@ export function MessageList({
messages: group.messages,
turnUsageMessages,
})}
</div>
</div>,
);
}
return null;
return withRunDuration(group, groupIndex, null);
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@ -1051,8 +1157,10 @@ export function MessageList({
files.push(...presentFiles);
}
}
return (
<div className="w-full" key={group.id}>
return withRunDuration(
group,
groupIndex,
<div className="w-full">
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
@ -1065,7 +1173,7 @@ export function MessageList({
messages: group.messages,
turnUsageMessages,
})}
</div>
</div>,
);
} else if (group.type === "assistant:subagent") {
const tasks = new Set<Subtask>();
@ -1152,22 +1260,23 @@ export function MessageList({
);
}
}
return (
<div
key={"subtask-group-" + group.id}
className="relative z-1 flex flex-col gap-2"
>
return withRunDuration(
group,
groupIndex,
<div className="relative z-1 flex flex-col gap-2">
{results}
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
debugMessageIds: subagentDebugMessageIds,
})}
</div>
</div>,
);
}
return (
<div key={"group-" + group.id} className="w-full">
return withRunDuration(
group,
groupIndex,
<div className="w-full">
<MessageGroup
messages={group.messages}
isLoading={groupIsLoading}
@ -1183,14 +1292,12 @@ export function MessageList({
turnUsageMessages,
inlineDebug: false,
})}
</div>
</div>,
);
})}
{thread.isLoading && !hasActiveAssistantText && (
<div className="w-full">
<Reasoning isStreaming={true} startTimeProp={turnStartTime}>
<ReasoningTrigger hasContent={false} />
</Reasoning>
<RunActivity startTime={turnStartTime} />
</div>
)}
<div style={{ height: `${paddingBottom}px` }} />

View File

@ -0,0 +1,59 @@
"use client";
import { Clock3Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { useI18n } from "@/core/i18n/hooks";
import { formatRunDuration } from "@/core/messages/run-duration";
export function RunActivity({ startTime }: { startTime: number | null }) {
const { t } = useI18n();
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (startTime === null) {
setElapsed(0);
return;
}
const updateElapsed = () => {
setElapsed(Math.max(0, Math.floor((Date.now() - startTime) / 1000)));
};
updateElapsed();
const interval = setInterval(updateElapsed, 1000);
return () => clearInterval(interval);
}, [startTime]);
const formatted = formatRunDuration(elapsed, t.runDuration);
return (
<div
className="text-muted-foreground flex items-center gap-2 text-sm"
data-testid="run-activity"
>
<Clock3Icon className="size-4" />
<Shimmer duration={1}>{t.runDuration.working}</Shimmer>
{formatted && <span aria-hidden="true">({formatted})</span>}
</div>
);
}
export function RunDuration({ durationSeconds }: { durationSeconds: number }) {
const { t } = useI18n();
const formatted = formatRunDuration(durationSeconds, t.runDuration);
if (!formatted) {
return null;
}
return (
<div
className="text-muted-foreground flex items-center gap-2 text-sm"
data-testid="run-duration"
title={t.runDuration.description}
>
<Clock3Icon className="size-4" />
<span>{t.runDuration.completedIn(formatted)}</span>
</div>
);
}

View File

@ -57,6 +57,19 @@ export const enUS: Translations = {
showBrowser: "Open browser panel",
},
runDuration: {
reasoning: "Reasoning",
working: "Working…",
completedIn: (duration) => `Completed in ${duration}`,
description:
"Total task time, including model reasoning, tool calls, and waiting.",
lessThanSecond: "<1s",
hours: (value) => `${value}h`,
minutes: (value) => `${value}m`,
seconds: (value) => `${value}s`,
separator: " ",
},
// Home
home: {
docs: "Docs",

View File

@ -46,6 +46,18 @@ export interface Translations {
showBrowser: string;
};
runDuration: {
reasoning: string;
working: string;
completedIn: (duration: string) => string;
description: string;
lessThanSecond: string;
hours: (value: number) => string;
minutes: (value: number) => string;
seconds: (value: number) => string;
separator: string;
};
home: {
docs: string;
blog: string;

View File

@ -57,6 +57,18 @@ export const zhCN: Translations = {
showBrowser: "打开浏览器面板",
},
runDuration: {
reasoning: "思考过程",
working: "执行中…",
completedIn: (duration) => `本次任务耗时 ${duration}`,
description: "任务总耗时,包括模型推理、工具调用和等待时间。",
lessThanSecond: "不足 1 秒",
hours: (value) => `${value} 小时`,
minutes: (value) => `${value}`,
seconds: (value) => `${value}`,
separator: " ",
},
// Home
home: {
docs: "文档",

View File

@ -0,0 +1,104 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { MessageGroup } from "./utils";
export interface RunDurationDisplay {
runId: string;
durationSeconds: number;
}
export interface RunDurationFormatter {
lessThanSecond: string;
hours: (value: number) => string;
minutes: (value: number) => string;
seconds: (value: number) => string;
separator: string;
}
type MessageWithRunId = Message & { run_id?: unknown };
export function getMessageRunId(message: Message): string | undefined {
const runId = (message as MessageWithRunId).run_id;
return typeof runId === "string" && runId.length > 0 ? runId : undefined;
}
function normalizeDuration(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return Math.floor(value);
}
/**
* Locate the single UI position that owns each completed run's wall-clock
* duration. The backend keeps the value on every AI message for compatibility,
* but the UI treats it as run-scoped metadata and renders it after the last
* visible group belonging to that run.
*/
export function getRunDurationDisplaysByGroupIndex(
groups: MessageGroup[],
): RunDurationDisplay[][] {
const displays = groups.map(() => [] as RunDurationDisplay[]);
const durationByRunId = new Map<string, number>();
const lastGroupIndexByRunId = new Map<string, number>();
groups.forEach((group, groupIndex) => {
for (const message of group.messages) {
const runId = getMessageRunId(message);
if (!runId) {
continue;
}
lastGroupIndexByRunId.set(runId, groupIndex);
if (message.type !== "ai") {
continue;
}
const duration = normalizeDuration(
message.additional_kwargs?.turn_duration,
);
if (duration !== undefined) {
durationByRunId.set(runId, duration);
}
}
});
for (const [runId, durationSeconds] of durationByRunId) {
const groupIndex = lastGroupIndexByRunId.get(runId);
if (groupIndex !== undefined) {
displays[groupIndex]?.push({ runId, durationSeconds });
}
}
return displays;
}
export function formatRunDuration(
value: number,
formatter: RunDurationFormatter,
): string | null {
const duration = normalizeDuration(value);
if (duration === undefined) {
return null;
}
if (duration === 0) {
return formatter.lessThanSecond;
}
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
const parts: string[] = [];
if (hours > 0) {
parts.push(formatter.hours(hours));
}
if (minutes > 0) {
parts.push(formatter.minutes(minutes));
}
if (seconds > 0) {
parts.push(formatter.seconds(seconds));
}
return parts.join(formatter.separator);
}

View File

@ -18,6 +18,7 @@ import { getAPIClient } from "../api";
import { fetch } from "../api/fetcher";
import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import { getMessageRunId } from "../messages/run-duration";
import { isHiddenFromUIMessage } from "../messages/utils";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
@ -316,8 +317,13 @@ export function mergeMessages(
optimisticMessages: Message[],
): Message[] {
const savedTurnDurations = new Map<string, number>();
const savedRunIds = new Map<string, string>();
for (const msg of historyMessages) {
const identity = messageIdentity(msg);
const runId = getMessageRunId(msg);
if (identity && runId) {
savedRunIds.set(identity, runId);
}
if (identity && msg.additional_kwargs?.turn_duration !== undefined) {
savedTurnDurations.set(
identity,
@ -412,17 +418,26 @@ export function mergeMessages(
return merged.map((message) => {
const identity = messageIdentity(message);
if (
identity &&
if (!identity) {
return message;
}
const shouldRestoreRunId =
savedRunIds.has(identity) && !getMessageRunId(message);
const shouldRestoreTurnDuration =
savedTurnDurations.has(identity) &&
message.additional_kwargs?.turn_duration === undefined
) {
message.additional_kwargs?.turn_duration === undefined;
if (shouldRestoreRunId || shouldRestoreTurnDuration) {
return {
...message,
additional_kwargs: {
...message.additional_kwargs,
turn_duration: savedTurnDurations.get(identity),
},
...(shouldRestoreRunId ? { run_id: savedRunIds.get(identity) } : {}),
...(shouldRestoreTurnDuration
? {
additional_kwargs: {
...message.additional_kwargs,
turn_duration: savedTurnDurations.get(identity),
},
}
: {}),
} as Message;
}
return message;

View File

@ -94,6 +94,54 @@ test.describe("Thread history", () => {
).toBeVisible({ timeout: 15_000 });
});
test("shows a completed run duration once after multi-step history", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Multi-step duration",
updated_at: "2025-06-03T12:00:00Z",
messages: [
{
type: "human",
id: "msg-human-duration",
content: [{ type: "text", text: "Complete several steps" }],
},
{
type: "ai",
id: "msg-ai-duration-1",
content: "Intermediate result",
additional_kwargs: { turn_duration: 114 },
},
{
type: "ai",
id: "msg-ai-duration-2",
content: "Final result",
additional_kwargs: {
turn_duration: 114,
reasoning_content: "Final synthesis reasoning",
},
},
],
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page.getByText("Final result")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByTestId("run-duration")).toHaveCount(1);
await expect(page.getByText("Completed in 1m 54s")).toBeVisible();
await expect(
page.getByRole("button", { name: "Reasoning", exact: true }),
).toBeVisible();
await expect(page.getByText("Thought for 114 seconds")).toHaveCount(0);
});
test("input box recalls previous prompts with arrow keys", async ({
page,
}) => {

View File

@ -0,0 +1,138 @@
import type { Message } from "@langchain/langgraph-sdk";
import { describe, expect, test } from "@rstest/core";
import { enUS } from "@/core/i18n/locales/en-US";
import { zhCN } from "@/core/i18n/locales/zh-CN";
import {
formatRunDuration,
getRunDurationDisplaysByGroupIndex,
} from "@/core/messages/run-duration";
import { getMessageGroups } from "@/core/messages/utils";
function message(
id: string,
type: Message["type"],
content: string,
runId?: string,
duration?: unknown,
): Message {
return {
id,
type,
content,
...(runId ? { run_id: runId } : {}),
...(type === "ai" && duration !== undefined
? { additional_kwargs: { turn_duration: duration } }
: {}),
} as Message;
}
describe("run duration display placement", () => {
test("shows one duration after the final visible group for a multi-step run", () => {
const groups = getMessageGroups([
message("human-1", "human", "Research this"),
{
...message("ai-tool-1", "ai", "", "run-1", 114),
tool_calls: [{ id: "call-1", name: "read_file", args: {} }],
} as Message,
{
...message("tool-1", "tool", "file contents", "run-1"),
tool_call_id: "call-1",
} as Message,
message("ai-middle", "ai", "Intermediate summary", "run-1", 114),
{
...message("ai-tool-2", "ai", "", "run-1", 114),
tool_calls: [{ id: "call-2", name: "write_todos", args: {} }],
} as Message,
{
...message("tool-2", "tool", "todos updated", "run-1"),
tool_call_id: "call-2",
} as Message,
message("ai-final", "ai", "Final answer", "run-1", 114),
]);
expect(
getRunDurationDisplaysByGroupIndex(groups).map((displays) =>
displays.map(({ runId, durationSeconds }) => ({
runId,
durationSeconds,
})),
),
).toEqual([[], [], [], [], [{ runId: "run-1", durationSeconds: 114 }]]);
});
test("keeps equal durations independent across runs", () => {
const groups = getMessageGroups([
message("human-1", "human", "First"),
message("ai-1", "ai", "First answer", "run-1", 114),
message("human-2", "human", "Second"),
message("ai-2", "ai", "Second answer", "run-2", 114),
]);
expect(getRunDurationDisplaysByGroupIndex(groups)).toEqual([
[],
[{ runId: "run-1", durationSeconds: 114 }],
[],
[{ runId: "run-2", durationSeconds: 114 }],
]);
});
test("carries an earlier AI duration to the run's final tool-bearing group", () => {
const groups = getMessageGroups([
message("human-1", "human", "Do it"),
message("ai-answer", "ai", "Initial answer", "run-1", 9),
{
...message("ai-tool", "ai", "", "run-1"),
tool_calls: [{ id: "call-1", name: "write_todos", args: {} }],
} as Message,
{
...message("tool-1", "tool", "done", "run-1"),
tool_call_id: "call-1",
} as Message,
]);
expect(getRunDurationDisplaysByGroupIndex(groups)).toEqual([
[],
[],
[{ runId: "run-1", durationSeconds: 9 }],
]);
});
test("keeps zero but ignores missing, negative, and non-finite durations", () => {
const groups = getMessageGroups([
message("ai-zero", "ai", "Zero", "run-zero", 0),
message("ai-missing", "ai", "Missing", "run-missing"),
message("ai-negative", "ai", "Negative", "run-negative", -1),
message("ai-infinite", "ai", "Infinite", "run-infinite", Infinity),
]);
expect(getRunDurationDisplaysByGroupIndex(groups)).toEqual([
[{ runId: "run-zero", durationSeconds: 0 }],
[],
[],
[],
]);
});
});
describe("run duration formatting", () => {
test("formats sub-second, second, minute, and hour durations in English", () => {
expect(formatRunDuration(0, enUS.runDuration)).toBe("<1s");
expect(formatRunDuration(59, enUS.runDuration)).toBe("59s");
expect(formatRunDuration(114, enUS.runDuration)).toBe("1m 54s");
expect(formatRunDuration(3723, enUS.runDuration)).toBe("1h 2m 3s");
});
test("formats durations in Chinese", () => {
expect(formatRunDuration(0, zhCN.runDuration)).toBe("不足 1 秒");
expect(formatRunDuration(114, zhCN.runDuration)).toBe("1 分 54 秒");
expect(formatRunDuration(3723, zhCN.runDuration)).toBe("1 小时 2 分 3 秒");
});
test("rejects invalid durations and floors fractional seconds", () => {
expect(formatRunDuration(-1, enUS.runDuration)).toBeNull();
expect(formatRunDuration(Infinity, enUS.runDuration)).toBeNull();
expect(formatRunDuration(Number.NaN, enUS.runDuration)).toBeNull();
expect(formatRunDuration(61.9, enUS.runDuration)).toBe("1m 1s");
});
});

View File

@ -96,6 +96,39 @@ test("mergeMessages lets live thread messages replace overlapping history", () =
]);
});
test("mergeMessages preserves historical run metadata on a live checkpoint replacement", () => {
const persistedAi = {
id: "ai-1",
type: "ai",
content: "persisted",
additional_kwargs: { turn_duration: 114 },
} as Message;
const history = buildVisibleHistoryMessages(
[
{
run_id: "run-1",
content: persistedAi,
metadata: { caller: "lead_agent" },
created_at: "2026-07-21T00:00:00Z",
},
],
new Set(),
);
const checkpointAi = {
id: "ai-1",
type: "ai",
content: "live checkpoint",
} as Message;
expect(mergeMessages(history, [checkpointAi], [])).toEqual([
{
...checkpointAi,
run_id: "run-1",
additional_kwargs: { turn_duration: 114 },
},
]);
});
test("mergeMessages keeps a protected pre-compression input at its canonical position", () => {
const canonicalInput = {
id: "input-1",