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.
This commit is contained in:
Mohamed Boudra
2026-09-01 23:24:20 +02:00
committed by GitHub
parent 3e59adb4dc
commit ba54575ab7
7 changed files with 52 additions and 28 deletions
@@ -65,7 +65,6 @@ function timingFor(assistantId: string): Map<string, TurnTiming> {
[
assistantId,
{
startedAt: new Date("2026-08-22T10:00:00.000Z"),
completedAt: new Date("2026-08-22T10:00:02.000Z"),
durationMs: 2_000,
},
@@ -73,7 +73,6 @@ function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought
function timingFor(...ids: string[]): Map<string, TurnTiming> {
const timing = {
startedAt: timestamp(1),
completedAt: timestamp(9),
durationMs: 8000,
};
@@ -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,
});
@@ -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,
});
+13 -10
View File
@@ -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> | 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 ? <AssistantForkMenu onFork={handleFork} /> : null}
{durationLabel ? (
{primaryLabel ? (
<Pressable
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
>
<View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */}
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
</Text>
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : durationLabel}
{showTimestamp ? timestampLabel : primaryLabel}
</Text>
</View>
</Pressable>
+27 -2
View File
@@ -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,
});
});
});
+5 -9
View File
@@ -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);