From c4e2fcaf0c112a0b167fdacbbdb45b333bf408ca Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 24 May 2026 18:38:08 +0800
Subject: [PATCH] fix(webui): preserve activity duration on replay
---
.../thread/AgentActivityCluster.tsx | 20 ++++++++--
.../src/components/thread/ThreadMessages.tsx | 26 +++++++++++++
webui/src/i18n/locales/en/common.json | 1 +
webui/src/i18n/locales/es/common.json | 1 +
webui/src/i18n/locales/fr/common.json | 1 +
webui/src/i18n/locales/id/common.json | 1 +
webui/src/i18n/locales/ja/common.json | 1 +
webui/src/i18n/locales/ko/common.json | 1 +
webui/src/i18n/locales/vi/common.json | 1 +
webui/src/i18n/locales/zh-CN/common.json | 1 +
webui/src/i18n/locales/zh-TW/common.json | 1 +
.../src/tests/agent-activity-cluster.test.tsx | 38 +++++++++++++++++++
webui/src/tests/thread-messages.test.tsx | 36 ++++++++++++++++++
13 files changed, 126 insertions(+), 3 deletions(-)
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[] = [
{