diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index 8af3e5603..9eefcbebc 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -173,6 +173,8 @@ interface AgentActivityClusterProps { /** True while the session turn is still running (drives “Working…” copy + header sheen). */ isTurnStreaming: boolean; hasBodyBelow: boolean; + /** Persisted end-to-end turn latency from the assistant answer, used for history replay. */ + turnLatencyMs?: number; cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; } @@ -185,6 +187,7 @@ export function AgentActivityCluster({ messages, isTurnStreaming, hasBodyBelow, + turnLatencyMs, cliApps = [], mcpPresets = [], }: AgentActivityClusterProps) { @@ -242,12 +245,15 @@ export function AgentActivityCluster({ const singleFilePath = fileCount === 1 ? primaryFilePath : undefined; const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined; const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0; - const activityDuration = formatActivityDuration(activityDurationMs(messages, isTurnStreaming, now)); + const durationMs = activityDurationMs(messages, isTurnStreaming, now, turnLatencyMs); + const activityDuration = formatActivityDuration(durationMs); const thoughtLabel = isTurnStreaming ? t("message.activityThinkingFor", { duration: activityDuration, defaultValue: "Thinking for {{duration}}", }) + : durationMs <= 0 + ? t("message.activityThought", { defaultValue: "Thought" }) : t("message.activityThoughtFor", { duration: activityDuration, defaultValue: "Thought for {{duration}}", @@ -500,7 +506,15 @@ function shortFileName(path: string): string { return path.split(/[\\/]/).pop() || path; } -function activityDurationMs(messages: UIMessage[], active: boolean, now: number): number { +function activityDurationMs( + messages: UIMessage[], + active: boolean, + now: number, + completedLatencyMs?: number, +): number { + if (!active && Number.isFinite(completedLatencyMs) && completedLatencyMs! >= 0) { + return Math.round(completedLatencyMs!); + } const timestamps = messages .map((message) => message.createdAt) .filter((value) => Number.isFinite(value)); @@ -513,7 +527,7 @@ function activityDurationMs(messages: UIMessage[], active: boolean, now: number) } function formatActivityDuration(ms: number): string { - const seconds = Math.max(0, Math.round(ms / 1000)); + const seconds = ms > 0 && ms < 1000 ? 1 : Math.max(0, Math.round(ms / 1000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const rest = seconds % 60; diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index ffd81b264..5aa050862 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -134,6 +134,7 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage { reasoningStreaming: message.reasoningStreaming, isStreaming: message.reasoningStreaming, activitySegmentId: message.activitySegmentId, + latencyMs: message.latencyMs, }; } @@ -204,6 +205,8 @@ export function ThreadMessages({ unit.type === "cluster" && next?.type === "single" && next.message.role === "assistant"; + const turnLatencyMs = + unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined; return (
@@ -212,6 +215,7 @@ export function ThreadMessages({ messages={unit.messages} isTurnStreaming={index === liveActivityClusterIndex} hasBodyBelow={hasBodyBelow} + turnLatencyMs={turnLatencyMs} cliApps={cliApps} mcpPresets={mcpPresets} /> @@ -234,6 +238,28 @@ export function ThreadMessages({ ); } +function activityClusterTurnLatencyMs( + messages: UIMessage[], + next: DisplayUnit | undefined, +): number | undefined { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const latency = messages[i].latencyMs; + if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) { + return latency; + } + } + if ( + next?.type === "single" + && next.message.role === "assistant" + && typeof next.message.latencyMs === "number" + && Number.isFinite(next.message.latencyMs) + && next.message.latencyMs >= 0 + ) { + return next.message.latencyMs; + } + return undefined; +} + function currentActivityClusterIndex(units: DisplayUnit[]): number { const last = units.length - 1; return units[last]?.type === "cluster" ? last : -1; diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 76d4dc030..ec9525bfa 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -619,6 +619,7 @@ "agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls", "agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls", "activityThinkingFor": "Thinking for {{duration}}", + "activityThought": "Thought", "activityThoughtFor": "Thought for {{duration}}", "cliActivityRunningOne": "Using @{{name}}", "cliActivityRanOne": "Used @{{name}}", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 263e1df8d..dd26d2141 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -623,6 +623,7 @@ "copiedReply": "Respuesta copiada", "turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)", "activityThinkingFor": "Pensando durante {{duration}}", + "activityThought": "Pensamiento completado", "activityThoughtFor": "Pensó durante {{duration}}", "cliActivityRunningOne": "Usando @{{name}}", "cliActivityRanOne": "Usó @{{name}}", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 3fa4c205d..37503a491 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -623,6 +623,7 @@ "copiedReply": "Réponse copiée", "turnLatencyTitle": "Temps de réponse (de bout en bout)", "activityThinkingFor": "Réflexion pendant {{duration}}", + "activityThought": "Réflexion terminée", "activityThoughtFor": "Réflexion terminée en {{duration}}", "cliActivityRunningOne": "Utilisation de @{{name}}", "cliActivityRanOne": "@{{name}} utilisé", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 5c5432c7b..2fb331f31 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -623,6 +623,7 @@ "copiedReply": "Balasan disalin", "turnLatencyTitle": "Waktu respons (ujung ke ujung)", "activityThinkingFor": "Berpikir selama {{duration}}", + "activityThought": "Selesai berpikir", "activityThoughtFor": "Selesai berpikir dalam {{duration}}", "cliActivityRunningOne": "Menggunakan @{{name}}", "cliActivityRanOne": "Menggunakan @{{name}} selesai", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 36091db37..6bd159d99 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -623,6 +623,7 @@ "copiedReply": "返信をコピーしました", "turnLatencyTitle": "応答時間(全行程)", "activityThinkingFor": "{{duration}}考えています", + "activityThought": "思考しました", "activityThoughtFor": "{{duration}}考えました", "cliActivityRunningOne": "@{{name}} を使用中", "cliActivityRanOne": "@{{name}} を使用しました", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 76430df9a..11a54d09f 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -623,6 +623,7 @@ "copiedReply": "답변이 복사됨", "turnLatencyTitle": "응답 시간(엔드투엔드)", "activityThinkingFor": "{{duration}} 동안 생각 중", + "activityThought": "생각함", "activityThoughtFor": "{{duration}} 동안 생각함", "cliActivityRunningOne": "@{{name}} 사용 중", "cliActivityRanOne": "@{{name}} 사용함", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index d29068f74..e20f48c65 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -623,6 +623,7 @@ "copiedReply": "Đã sao chép trả lời", "turnLatencyTitle": "Thời gian phản hồi (end-to-end)", "activityThinkingFor": "Đang suy nghĩ trong {{duration}}", + "activityThought": "Đã suy nghĩ", "activityThoughtFor": "Đã suy nghĩ trong {{duration}}", "cliActivityRunningOne": "Đang dùng @{{name}}", "cliActivityRanOne": "Đã dùng @{{name}}", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 9e9454f62..db4a9ef26 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -619,6 +619,7 @@ "agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用", "agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用", "activityThinkingFor": "思考中 {{duration}}", + "activityThought": "已思考", "activityThoughtFor": "思考了 {{duration}}", "cliActivityRunningOne": "正在使用 @{{name}}", "cliActivityRanOne": "已使用 @{{name}}", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index f4596bfdc..eea26fc0c 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -623,6 +623,7 @@ "copiedReply": "已複製回覆", "turnLatencyTitle": "本輪耗時(端到端)", "activityThinkingFor": "思考中,已 {{duration}}", + "activityThought": "已思考", "activityThoughtFor": "已思考 {{duration}}", "cliActivityRunningOne": "正在使用 @{{name}}", "cliActivityRanOne": "已使用 @{{name}}", diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index ba151b631..43a406ce8 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -340,6 +340,44 @@ describe("AgentActivityCluster", () => { } }); + it("uses persisted turn latency for completed history instead of replay timestamps", () => { + render( + , + ); + + expect(screen.getByText("Thought for 12s")).toBeInTheDocument(); + }); + + it("omits the duration when completed history has no reliable timing", () => { + render( + , + ); + + expect(screen.getByText("Thought")).toBeInTheDocument(); + expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument(); + }); + it("renders file edit totals and a compact expanded file list", async () => { const restoreMotion = installReducedMotion(); try { diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index 7b3f2150c..10aece145 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -226,6 +226,7 @@ describe("ThreadMessages", () => { content: "final answer", reasoning: "summarize results", reasoningStreaming: false, + latencyMs: 9_200, createdAt: 3, }, ]; @@ -239,6 +240,7 @@ describe("ThreadMessages", () => { "t1", "a1-reasoning", ]); + expect(units[0].type === "cluster" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200); expect(units[1]).toMatchObject({ type: "single", message: { @@ -252,9 +254,43 @@ describe("ThreadMessages", () => { render(); expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument(); + expect(screen.getByText("Thought for 9s")).toBeInTheDocument(); expect(screen.getByText("final answer")).toBeInTheDocument(); }); + it("passes assistant turn latency to the preceding completed activity cluster", () => { + const messages: UIMessage[] = [ + { + id: "r1", + role: "assistant", + content: "", + reasoning: "search plan", + reasoningStreaming: false, + createdAt: 1, + }, + { + id: "t1", + role: "tool", + kind: "trace", + content: "web_search()", + traces: ["web_search()"], + createdAt: 1, + }, + { + id: "a1", + role: "assistant", + content: "final answer", + latencyMs: 14_800, + createdAt: 1, + }, + ]; + + render(); + + expect(screen.getByText("Thought for 15s")).toBeInTheDocument(); + expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument(); + }); + it("shows copy only on the last assistant slice before the next user turn", () => { const messages: UIMessage[] = [ {