mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-25 07:28:07 +00:00
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:
parent
04659cc8dd
commit
d4fdc2758e
@ -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.
|
||||
|
||||
```
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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` }} />
|
||||
|
||||
59
frontend/src/components/workspace/messages/run-duration.tsx
Normal file
59
frontend/src/components/workspace/messages/run-duration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "文档",
|
||||
|
||||
104
frontend/src/core/messages/run-duration.ts
Normal file
104
frontend/src/core/messages/run-duration.ts
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
}) => {
|
||||
|
||||
138
frontend/tests/unit/core/messages/run-duration.test.ts
Normal file
138
frontend/tests/unit/core/messages/run-duration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user