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);