fix(webui): preserve activity duration on replay

This commit is contained in:
Xubin Ren 2026-05-24 18:38:08 +08:00
parent 8fedee276b
commit c4e2fcaf0c
13 changed files with 126 additions and 3 deletions

View File

@ -173,6 +173,8 @@ interface AgentActivityClusterProps {
/** True while the session turn is still running (drives “Working…” copy + header sheen). */ /** True while the session turn is still running (drives “Working…” copy + header sheen). */
isTurnStreaming: boolean; isTurnStreaming: boolean;
hasBodyBelow: boolean; hasBodyBelow: boolean;
/** Persisted end-to-end turn latency from the assistant answer, used for history replay. */
turnLatencyMs?: number;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
} }
@ -185,6 +187,7 @@ export function AgentActivityCluster({
messages, messages,
isTurnStreaming, isTurnStreaming,
hasBodyBelow, hasBodyBelow,
turnLatencyMs,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
}: AgentActivityClusterProps) { }: AgentActivityClusterProps) {
@ -242,12 +245,15 @@ export function AgentActivityCluster({
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined; const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined; const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0; 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 const thoughtLabel = isTurnStreaming
? t("message.activityThinkingFor", { ? t("message.activityThinkingFor", {
duration: activityDuration, duration: activityDuration,
defaultValue: "Thinking for {{duration}}", defaultValue: "Thinking for {{duration}}",
}) })
: durationMs <= 0
? t("message.activityThought", { defaultValue: "Thought" })
: t("message.activityThoughtFor", { : t("message.activityThoughtFor", {
duration: activityDuration, duration: activityDuration,
defaultValue: "Thought for {{duration}}", defaultValue: "Thought for {{duration}}",
@ -500,7 +506,15 @@ function shortFileName(path: string): string {
return path.split(/[\\/]/).pop() || path; 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 const timestamps = messages
.map((message) => message.createdAt) .map((message) => message.createdAt)
.filter((value) => Number.isFinite(value)); .filter((value) => Number.isFinite(value));
@ -513,7 +527,7 @@ function activityDurationMs(messages: UIMessage[], active: boolean, now: number)
} }
function formatActivityDuration(ms: number): string { 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`; if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const rest = seconds % 60; const rest = seconds % 60;

View File

@ -134,6 +134,7 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
reasoningStreaming: message.reasoningStreaming, reasoningStreaming: message.reasoningStreaming,
isStreaming: message.reasoningStreaming, isStreaming: message.reasoningStreaming,
activitySegmentId: message.activitySegmentId, activitySegmentId: message.activitySegmentId,
latencyMs: message.latencyMs,
}; };
} }
@ -204,6 +205,8 @@ export function ThreadMessages({
unit.type === "cluster" unit.type === "cluster"
&& next?.type === "single" && next?.type === "single"
&& next.message.role === "assistant"; && next.message.role === "assistant";
const turnLatencyMs =
unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined;
return ( return (
<div key={unitKey(unit, index)} className={marginTop}> <div key={unitKey(unit, index)} className={marginTop}>
@ -212,6 +215,7 @@ export function ThreadMessages({
messages={unit.messages} messages={unit.messages}
isTurnStreaming={index === liveActivityClusterIndex} isTurnStreaming={index === liveActivityClusterIndex}
hasBodyBelow={hasBodyBelow} hasBodyBelow={hasBodyBelow}
turnLatencyMs={turnLatencyMs}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} 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 { function currentActivityClusterIndex(units: DisplayUnit[]): number {
const last = units.length - 1; const last = units.length - 1;
return units[last]?.type === "cluster" ? last : -1; return units[last]?.type === "cluster" ? last : -1;

View File

@ -619,6 +619,7 @@
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls", "agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls", "agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
"activityThinkingFor": "Thinking for {{duration}}", "activityThinkingFor": "Thinking for {{duration}}",
"activityThought": "Thought",
"activityThoughtFor": "Thought for {{duration}}", "activityThoughtFor": "Thought for {{duration}}",
"cliActivityRunningOne": "Using @{{name}}", "cliActivityRunningOne": "Using @{{name}}",
"cliActivityRanOne": "Used @{{name}}", "cliActivityRanOne": "Used @{{name}}",

View File

@ -623,6 +623,7 @@
"copiedReply": "Respuesta copiada", "copiedReply": "Respuesta copiada",
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)", "turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
"activityThinkingFor": "Pensando durante {{duration}}", "activityThinkingFor": "Pensando durante {{duration}}",
"activityThought": "Pensamiento completado",
"activityThoughtFor": "Pensó durante {{duration}}", "activityThoughtFor": "Pensó durante {{duration}}",
"cliActivityRunningOne": "Usando @{{name}}", "cliActivityRunningOne": "Usando @{{name}}",
"cliActivityRanOne": "Usó @{{name}}", "cliActivityRanOne": "Usó @{{name}}",

View File

@ -623,6 +623,7 @@
"copiedReply": "Réponse copiée", "copiedReply": "Réponse copiée",
"turnLatencyTitle": "Temps de réponse (de bout en bout)", "turnLatencyTitle": "Temps de réponse (de bout en bout)",
"activityThinkingFor": "Réflexion pendant {{duration}}", "activityThinkingFor": "Réflexion pendant {{duration}}",
"activityThought": "Réflexion terminée",
"activityThoughtFor": "Réflexion terminée en {{duration}}", "activityThoughtFor": "Réflexion terminée en {{duration}}",
"cliActivityRunningOne": "Utilisation de @{{name}}", "cliActivityRunningOne": "Utilisation de @{{name}}",
"cliActivityRanOne": "@{{name}} utilisé", "cliActivityRanOne": "@{{name}} utilisé",

View File

@ -623,6 +623,7 @@
"copiedReply": "Balasan disalin", "copiedReply": "Balasan disalin",
"turnLatencyTitle": "Waktu respons (ujung ke ujung)", "turnLatencyTitle": "Waktu respons (ujung ke ujung)",
"activityThinkingFor": "Berpikir selama {{duration}}", "activityThinkingFor": "Berpikir selama {{duration}}",
"activityThought": "Selesai berpikir",
"activityThoughtFor": "Selesai berpikir dalam {{duration}}", "activityThoughtFor": "Selesai berpikir dalam {{duration}}",
"cliActivityRunningOne": "Menggunakan @{{name}}", "cliActivityRunningOne": "Menggunakan @{{name}}",
"cliActivityRanOne": "Menggunakan @{{name}} selesai", "cliActivityRanOne": "Menggunakan @{{name}} selesai",

View File

@ -623,6 +623,7 @@
"copiedReply": "返信をコピーしました", "copiedReply": "返信をコピーしました",
"turnLatencyTitle": "応答時間(全行程)", "turnLatencyTitle": "応答時間(全行程)",
"activityThinkingFor": "{{duration}}考えています", "activityThinkingFor": "{{duration}}考えています",
"activityThought": "思考しました",
"activityThoughtFor": "{{duration}}考えました", "activityThoughtFor": "{{duration}}考えました",
"cliActivityRunningOne": "@{{name}} を使用中", "cliActivityRunningOne": "@{{name}} を使用中",
"cliActivityRanOne": "@{{name}} を使用しました", "cliActivityRanOne": "@{{name}} を使用しました",

View File

@ -623,6 +623,7 @@
"copiedReply": "답변이 복사됨", "copiedReply": "답변이 복사됨",
"turnLatencyTitle": "응답 시간(엔드투엔드)", "turnLatencyTitle": "응답 시간(엔드투엔드)",
"activityThinkingFor": "{{duration}} 동안 생각 중", "activityThinkingFor": "{{duration}} 동안 생각 중",
"activityThought": "생각함",
"activityThoughtFor": "{{duration}} 동안 생각함", "activityThoughtFor": "{{duration}} 동안 생각함",
"cliActivityRunningOne": "@{{name}} 사용 중", "cliActivityRunningOne": "@{{name}} 사용 중",
"cliActivityRanOne": "@{{name}} 사용함", "cliActivityRanOne": "@{{name}} 사용함",

View File

@ -623,6 +623,7 @@
"copiedReply": "Đã sao chép trả lời", "copiedReply": "Đã sao chép trả lời",
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)", "turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}", "activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
"activityThought": "Đã suy nghĩ",
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}", "activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
"cliActivityRunningOne": "Đang dùng @{{name}}", "cliActivityRunningOne": "Đang dùng @{{name}}",
"cliActivityRanOne": "Đã dùng @{{name}}", "cliActivityRanOne": "Đã dùng @{{name}}",

View File

@ -619,6 +619,7 @@
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用", "agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用", "agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
"activityThinkingFor": "思考中 {{duration}}", "activityThinkingFor": "思考中 {{duration}}",
"activityThought": "已思考",
"activityThoughtFor": "思考了 {{duration}}", "activityThoughtFor": "思考了 {{duration}}",
"cliActivityRunningOne": "正在使用 @{{name}}", "cliActivityRunningOne": "正在使用 @{{name}}",
"cliActivityRanOne": "已使用 @{{name}}", "cliActivityRanOne": "已使用 @{{name}}",

View File

@ -623,6 +623,7 @@
"copiedReply": "已複製回覆", "copiedReply": "已複製回覆",
"turnLatencyTitle": "本輪耗時(端到端)", "turnLatencyTitle": "本輪耗時(端到端)",
"activityThinkingFor": "思考中,已 {{duration}}", "activityThinkingFor": "思考中,已 {{duration}}",
"activityThought": "已思考",
"activityThoughtFor": "已思考 {{duration}}", "activityThoughtFor": "已思考 {{duration}}",
"cliActivityRunningOne": "正在使用 @{{name}}", "cliActivityRunningOne": "正在使用 @{{name}}",
"cliActivityRanOne": "已使用 @{{name}}", "cliActivityRanOne": "已使用 @{{name}}",

View File

@ -340,6 +340,44 @@ describe("AgentActivityCluster", () => {
} }
}); });
it("uses persisted turn latency for completed history instead of replay timestamps", () => {
render(
<AgentActivityCluster
messages={[{
id: "r-history",
role: "assistant",
content: "",
reasoning: "historical thought",
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow
turnLatencyMs={12_400}
/>,
);
expect(screen.getByText("Thought for 12s")).toBeInTheDocument();
});
it("omits the duration when completed history has no reliable timing", () => {
render(
<AgentActivityCluster
messages={[{
id: "r-old-history",
role: "assistant",
content: "",
reasoning: "old historical thought",
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow
/>,
);
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 () => { it("renders file edit totals and a compact expanded file list", async () => {
const restoreMotion = installReducedMotion(); const restoreMotion = installReducedMotion();
try { try {

View File

@ -226,6 +226,7 @@ describe("ThreadMessages", () => {
content: "final answer", content: "final answer",
reasoning: "summarize results", reasoning: "summarize results",
reasoningStreaming: false, reasoningStreaming: false,
latencyMs: 9_200,
createdAt: 3, createdAt: 3,
}, },
]; ];
@ -239,6 +240,7 @@ describe("ThreadMessages", () => {
"t1", "t1",
"a1-reasoning", "a1-reasoning",
]); ]);
expect(units[0].type === "cluster" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "single", type: "single",
message: { message: {
@ -252,9 +254,43 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />); render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument();
expect(screen.getByText("Thought for 9s")).toBeInTheDocument();
expect(screen.getByText("final answer")).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(<ThreadMessages messages={messages} isStreaming={false} />);
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", () => { it("shows copy only on the last assistant slice before the next user turn", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {