mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user