From ba54575ab703acbeff30f5b956b9e27a004dfe03 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 1 Sep 2026 23:24:20 +0200 Subject: [PATCH] Show completion times for hidden-prompt turns (#4170) System-injected prompts have canonical turns but no visible user row, so completed timing was discarded despite an exact end timestamp. Preserve completion metadata with an explicitly unknown duration so the footer can show the truthful timestamp. --- .../src/agent-stream/footer-spacing.test.ts | 1 - packages/app/src/agent-stream/layout.test.ts | 1 - packages/app/src/agent-stream/model.test.ts | 3 -- .../src/agent-stream/turn-membership.test.ts | 9 ++++-- packages/app/src/components/message.tsx | 23 ++++++++------- packages/app/src/timeline/turn-time.test.ts | 29 +++++++++++++++++-- packages/app/src/timeline/turn-time.ts | 14 ++++----- 7 files changed, 52 insertions(+), 28 deletions(-) diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts index f18d7ea27f..a2414fff5c 100644 --- a/packages/app/src/agent-stream/footer-spacing.test.ts +++ b/packages/app/src/agent-stream/footer-spacing.test.ts @@ -65,7 +65,6 @@ function timingFor(assistantId: string): Map { [ assistantId, { - startedAt: new Date("2026-08-22T10:00:00.000Z"), completedAt: new Date("2026-08-22T10:00:02.000Z"), durationMs: 2_000, }, diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index 9cc463590d..cfb379345d 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -73,7 +73,6 @@ function thought(id: string, seed: number): Extract { const timing = { - startedAt: timestamp(1), completedAt: timestamp(9), durationMs: 8000, }; diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index 1aec54824a..d339acfe63 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -69,7 +69,6 @@ describe("buildAgentStreamRenderModel", () => { expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false); expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({ - startedAt: tail[2]?.timestamp, completedAt: tail[3]?.timestamp, durationMs: 1000, }); @@ -176,7 +175,6 @@ describe("buildAgentStreamRenderModel", () => { expect(model.turnTiming.runningStartedAt).toBe(null); expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({ - startedAt: tail[0]?.timestamp, completedAt: head[0]?.timestamp, durationMs: 3000, }); @@ -196,7 +194,6 @@ describe("buildAgentStreamRenderModel", () => { expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ - startedAt: tail[0]?.timestamp, completedAt: tail[1]?.timestamp, durationMs: 3000, }); diff --git a/packages/app/src/agent-stream/turn-membership.test.ts b/packages/app/src/agent-stream/turn-membership.test.ts index 171b9c060d..4483e4e1a7 100644 --- a/packages/app/src/agent-stream/turn-membership.test.ts +++ b/packages/app/src/agent-stream/turn-membership.test.ts @@ -86,7 +86,13 @@ describe("canonical turn membership", () => { assistant("heartbeat-2-reply", 6, "turn-3"), ]; - expect(completedFooterIds(layoutFor(items, false).layout)).toEqual(["heartbeat-2-reply"]); + const completed = layoutFor(items, false); + + expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]); + expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({ + completedAt: at(6), + durationMs: null, + }); }); it.each([ @@ -118,7 +124,6 @@ describe("canonical turn membership", () => { const completed = layoutFor(completedItems, false); expect(completedFooterIds(completed.layout)).toEqual(["done"]); expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({ - startedAt: at(1), completedAt: at(9), durationMs: 8000, }); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 54459ecda8..9ca8eaed10 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -578,7 +578,7 @@ export const UserMessage = memo(function UserMessage({ interface AssistantTurnFooterProps { getContent: () => string; completedAt?: Date; - durationMs?: number; + durationMs?: number | null; onFork?: (target: AssistantForkTarget) => Promise | void; } @@ -616,9 +616,8 @@ const TIMESTAMP_REVEAL_MS = 3000; /** * Footer rendered next to the copy button at the end of an assistant turn. - * Always shows the turn duration; swaps to the end timestamp on hover (web) - * or tap (native). The hidden sizer keeps the label width stable while the - * visible text swaps. + * Shows the turn duration and swaps to the end timestamp when both are known. + * A turn without a visible start shows its end timestamp directly. */ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, @@ -640,7 +639,10 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ }, []); const durationLabel = useMemo( - () => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""), + () => + durationMs !== undefined && durationMs !== null + ? `Worked for ${formatDuration(durationMs)}` + : "", [durationMs], ); const timestampLabel = useMemo( @@ -648,7 +650,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ [completedAt], ); - const canSwap = Boolean(timestampLabel); + const primaryLabel = durationLabel || timestampLabel; + const canSwap = Boolean(durationLabel && timestampLabel); const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal); const handleHoverIn = useCallback(() => setHovered(true), []); @@ -679,22 +682,22 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ containerStyle={assistantTurnFooterStylesheet.copyButton} /> {canFork ? : null} - {durationLabel ? ( + {primaryLabel ? ( {/* Sizer reserves space for whichever label is longer so the container width is stable across hover transitions. */} - {durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel} + {primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel} - {showTimestamp ? timestampLabel : durationLabel} + {showTimestamp ? timestampLabel : primaryLabel} diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index b5c13ee00c..35dacf397b 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -70,7 +70,6 @@ describe("deriveStreamTurnTiming", () => { }); assert.deepEqual(timing.byAssistantId.get("a1"), { - startedAt: userAt, completedAt: assistantAt, durationMs: 7000, }); @@ -93,11 +92,37 @@ describe("deriveStreamTurnTiming", () => { }); const expected = { - startedAt: userAt, completedAt: lastAssistantAt, durationMs: 7000, }; assert.deepEqual(timing.byAssistantId.get("a1"), expected); assert.deepEqual(timing.byAssistantId.get("a2"), expected); }); + + it("preserves the completion timestamp when a canonical turn has no visible prompt", () => { + const firstTurnAt = new Date("2026-05-15T00:00:00.000Z"); + const hiddenPromptTurnAt = new Date("2026-05-15T00:01:07.000Z"); + const timing = deriveStreamTurnTiming({ + isTurnActive: false, + activeTurnStartedAt: null, + tail: [ + { ...user("u1", firstTurnAt), turnId: "turn-1" }, + { + ...assistant("a1", new Date("2026-05-15T00:00:07.000Z")), + turnId: "turn-1", + }, + { + ...assistant("hidden-prompt-a1", new Date("2026-05-15T00:01:03.000Z")), + turnId: "turn-2", + }, + { ...assistant("hidden-prompt-a2", hiddenPromptTurnAt), turnId: "turn-2" }, + ], + head: [], + }); + + assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), { + completedAt: hiddenPromptTurnAt, + durationMs: null, + }); + }); }); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index f090e7bfe2..4542962af4 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -2,9 +2,8 @@ import type { StreamItem } from "@/types/stream"; import { startsNewTurn } from "@/agent-stream/turn-membership"; export interface TurnTiming { - startedAt: Date; completedAt: Date; - durationMs: number; + durationMs: number | null; } export interface StreamTurnTiming { @@ -25,13 +24,14 @@ export function deriveStreamTurnTiming(params: { let previousItem: StreamItem | null = null; const flushCompletedTurn = () => { - if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) { + if (!currentLastItemAt || currentAssistantIds.length === 0) { return; } const timing: TurnTiming = { - startedAt: currentUserAt, completedAt: currentLastItemAt, - durationMs: Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()), + durationMs: currentUserAt + ? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()) + : null, }; for (const id of currentAssistantIds) { byAssistantId.set(id, timing); @@ -45,10 +45,6 @@ export function deriveStreamTurnTiming(params: { currentLastItemAt = null; currentAssistantIds = []; } - if (!currentUserAt) { - previousItem = item; - return; - } currentLastItemAt = item.timestamp; if (item.kind === "assistant_message") { currentAssistantIds.push(item.id);