From c3541ffd335b91d0b91f5a43c74fd5471250e031 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Wed, 11 Mar 2026 12:44:12 +1300 Subject: [PATCH 1/2] Fix response duration baseline across assistant message sequences - Compute per-message duration start from user boundary and prior assistant completion - Use computed start time in `ChatView` elapsed-time rendering - Add unit tests covering user/assistant/system and streaming edge cases --- .../web/src/components/ChatView.logic.test.ts | 130 ++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 32 +++++ apps/web/src/components/ChatView.tsx | 12 +- 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ChatView.logic.test.ts create mode 100644 apps/web/src/components/ChatView.logic.ts diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts new file mode 100644 index 0000000000..b7e63761e6 --- /dev/null +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { computeMessageDurationStart } from "./ChatView.logic"; + +describe("computeMessageDurationStart", () => { + it("returns message createdAt when there is no preceding user message", () => { + const result = computeMessageDurationStart([ + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:05Z", + completedAt: "2026-01-01T00:00:10Z", + }, + ]); + expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]])); + }); + + it("uses the user message createdAt for the first assistant response", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + ]); + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], // user: own createdAt + ["a1", "2026-01-01T00:00:00Z"], // assistant: user's createdAt + ]), + ); + }); + + it("uses the previous assistant completedAt for subsequent assistant responses", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:00:55Z", + completedAt: "2026-01-01T00:00:55Z", + }, + ]); + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], // user: own createdAt + ["a1", "2026-01-01T00:00:00Z"], // first assistant: from user (duration = 30s) + ["a2", "2026-01-01T00:00:30Z"], // second assistant: from first assistant's completedAt (duration = 25s) + ]), + ); + }); + + it("does not advance the boundary for a streaming message without completedAt", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, // streaming, no completedAt + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:00:55Z", + completedAt: "2026-01-01T00:00:55Z", + }, + ]); + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], // user + ["a1", "2026-01-01T00:00:00Z"], // streaming assistant: from user + ["a2", "2026-01-01T00:00:00Z"], // next assistant: still from user (boundary not advanced) + ]), + ); + }); + + it("resets the boundary on a new user message", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + { id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:01:20Z", + completedAt: "2026-01-01T00:01:20Z", + }, + ]); + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], // first user + ["a1", "2026-01-01T00:00:00Z"], // first assistant: from first user + ["u2", "2026-01-01T00:01:00Z"], // second user: own createdAt + ["a2", "2026-01-01T00:01:00Z"], // second assistant: from second user (not first assistant) + ]), + ); + }); + + it("handles system messages without affecting the boundary", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + ]); + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], // user + ["s1", "2026-01-01T00:00:00Z"], // system: inherits user boundary + ["a1", "2026-01-01T00:00:00Z"], // assistant: from user + ]), + ); + }); + + it("returns empty map for empty input", () => { + expect(computeMessageDurationStart([])).toEqual(new Map()); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts new file mode 100644 index 0000000000..f8af75e8a7 --- /dev/null +++ b/apps/web/src/components/ChatView.logic.ts @@ -0,0 +1,32 @@ +/** + * Compute the duration-start timestamp for each message in a timeline. + * + * For the first assistant response after a user message, this is the user + * message's `createdAt`. For subsequent assistant responses within the same + * turn, it advances to the previous assistant message's `completedAt` so that + * each response shows its own incremental duration rather than the cumulative + * time since the user sent the original message. + */ +export function computeMessageDurationStart( + messages: ReadonlyArray<{ + id: string; + role: "user" | "assistant" | "system"; + createdAt: string; + completedAt?: string | undefined; + }>, +): Map { + const result = new Map(); + let lastBoundary: string | null = null; + + for (const message of messages) { + if (message.role === "user") { + lastBoundary = message.createdAt; + } + result.set(message.id, lastBoundary ?? message.createdAt); + if (message.role === "assistant" && message.completedAt) { + lastBoundary = message.completedAt; + } + } + + return result; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a1529..3715e5f0a1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -119,6 +119,7 @@ import { type TurnDiffTreeNode, } from "../lib/turnDiffTree"; import BranchToolbar from "./BranchToolbar"; +import { computeMessageDurationStart } from "./ChatView.logic"; import GitActionsControl from "./GitActionsControl"; import { isOpenFavoriteEditorShortcut, @@ -5086,6 +5087,7 @@ type TimelineRow = createdAt: string; message: TimelineMessage; showCompletionDivider: boolean; + durationStart: string; } | { kind: "proposed-plan"; @@ -5153,6 +5155,11 @@ const MessagesTimeline = memo(function MessagesTimeline({ const rows = useMemo(() => { const nextRows: TimelineRow[] = []; + const messages = timelineEntries + .filter((e): e is Extract => e.kind === "message") + .map((e) => ({ ...e.message, id: e.id })); + const durationStartById = computeMessageDurationStart(messages); + for (let index = 0; index < timelineEntries.length; index += 1) { const timelineEntry = timelineEntries[index]; if (!timelineEntry) { @@ -5193,6 +5200,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ id: timelineEntry.id, createdAt: timelineEntry.createdAt, message: timelineEntry.message, + durationStart: durationStartById.get(timelineEntry.id) ?? timelineEntry.message.createdAt, showCompletionDivider: timelineEntry.message.role === "assistant" && completionDividerBeforeEntryId === timelineEntry.id, @@ -5561,8 +5569,8 @@ const MessagesTimeline = memo(function MessagesTimeline({ {formatMessageMeta( row.message.createdAt, row.message.streaming - ? formatElapsed(row.message.createdAt, nowIso) - : formatElapsed(row.message.createdAt, row.message.completedAt), + ? formatElapsed(row.durationStart, nowIso) + : formatElapsed(row.durationStart, row.message.completedAt), )}

From 0608ee1064ae9567786756f17c81982eb4412fc7 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Thu, 12 Mar 2026 21:18:03 +1300 Subject: [PATCH 2/2] refactor(web): move timeline logic next to MessagesTimeline --- apps/web/public/mockServiceWorker.js | 2 +- apps/web/src/components/ChatView.logic.ts | 29 -------------- .../MessagesTimeline.logic.test.ts} | 39 +++++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 25 ++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 2 +- 5 files changed, 49 insertions(+), 48 deletions(-) rename apps/web/src/components/{ChatView.logic.test.ts => chat/MessagesTimeline.logic.test.ts} (72%) create mode 100644 apps/web/src/components/chat/MessagesTimeline.logic.ts diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js index daa58d0f12..85e9010123 100644 --- a/apps/web/public/mockServiceWorker.js +++ b/apps/web/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.10' +const PACKAGE_VERSION = '2.12.9' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c6d432a86b..4cd64af9f1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -138,32 +138,3 @@ export function getCustomModelOptionsByProvider(settings: { codex: getAppModelOptions("codex", settings.customCodexModels), }; } - -/** - * For the first assistant response after a user message, use the user's - * timestamp. For subsequent assistant responses in the same turn, advance the - * baseline to the previous assistant completion time. - */ -export function computeMessageDurationStart( - messages: ReadonlyArray<{ - id: string; - role: "user" | "assistant" | "system"; - createdAt: string; - completedAt?: string | undefined; - }>, -): Map { - const result = new Map(); - let lastBoundary: string | null = null; - - for (const message of messages) { - if (message.role === "user") { - lastBoundary = message.createdAt; - } - result.set(message.id, lastBoundary ?? message.createdAt); - if (message.role === "assistant" && message.completedAt) { - lastBoundary = message.completedAt; - } - } - - return result; -} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts similarity index 72% rename from apps/web/src/components/ChatView.logic.test.ts rename to apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b7e63761e6..7074f46019 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart } from "./ChatView.logic"; +import { computeMessageDurationStart } from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -24,10 +24,11 @@ describe("computeMessageDurationStart", () => { completedAt: "2026-01-01T00:00:30Z", }, ]); + expect(result).toEqual( new Map([ - ["u1", "2026-01-01T00:00:00Z"], // user: own createdAt - ["a1", "2026-01-01T00:00:00Z"], // assistant: user's createdAt + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], ]), ); }); @@ -48,11 +49,12 @@ describe("computeMessageDurationStart", () => { completedAt: "2026-01-01T00:00:55Z", }, ]); + expect(result).toEqual( new Map([ - ["u1", "2026-01-01T00:00:00Z"], // user: own createdAt - ["a1", "2026-01-01T00:00:00Z"], // first assistant: from user (duration = 30s) - ["a2", "2026-01-01T00:00:30Z"], // second assistant: from first assistant's completedAt (duration = 25s) + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:30Z"], ]), ); }); @@ -60,7 +62,7 @@ describe("computeMessageDurationStart", () => { it("does not advance the boundary for a streaming message without completedAt", () => { const result = computeMessageDurationStart([ { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, - { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, // streaming, no completedAt + { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, { id: "a2", role: "assistant", @@ -68,11 +70,12 @@ describe("computeMessageDurationStart", () => { completedAt: "2026-01-01T00:00:55Z", }, ]); + expect(result).toEqual( new Map([ - ["u1", "2026-01-01T00:00:00Z"], // user - ["a1", "2026-01-01T00:00:00Z"], // streaming assistant: from user - ["a2", "2026-01-01T00:00:00Z"], // next assistant: still from user (boundary not advanced) + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:00Z"], ]), ); }); @@ -94,12 +97,13 @@ describe("computeMessageDurationStart", () => { completedAt: "2026-01-01T00:01:20Z", }, ]); + expect(result).toEqual( new Map([ - ["u1", "2026-01-01T00:00:00Z"], // first user - ["a1", "2026-01-01T00:00:00Z"], // first assistant: from first user - ["u2", "2026-01-01T00:01:00Z"], // second user: own createdAt - ["a2", "2026-01-01T00:01:00Z"], // second assistant: from second user (not first assistant) + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["u2", "2026-01-01T00:01:00Z"], + ["a2", "2026-01-01T00:01:00Z"], ]), ); }); @@ -115,11 +119,12 @@ describe("computeMessageDurationStart", () => { completedAt: "2026-01-01T00:00:30Z", }, ]); + expect(result).toEqual( new Map([ - ["u1", "2026-01-01T00:00:00Z"], // user - ["s1", "2026-01-01T00:00:00Z"], // system: inherits user boundary - ["a1", "2026-01-01T00:00:00Z"], // assistant: from user + ["u1", "2026-01-01T00:00:00Z"], + ["s1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], ]), ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts new file mode 100644 index 0000000000..45408468ca --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -0,0 +1,25 @@ +export interface TimelineDurationMessage { + id: string; + role: "user" | "assistant" | "system"; + createdAt: string; + completedAt?: string | undefined; +} + +export function computeMessageDurationStart( + messages: ReadonlyArray, +): Map { + const result = new Map(); + let lastBoundary: string | null = null; + + for (const message of messages) { + if (message.role === "user") { + lastBoundary = message.createdAt; + } + result.set(message.id, lastBoundary ?? message.createdAt); + if (message.role === "assistant" && message.completedAt) { + lastBoundary = message.completedAt; + } + } + + return result; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b4c9eb5d4d..7a89e762e3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -9,7 +9,6 @@ import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../ses import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; -import { computeMessageDurationStart } from "../ChatView.logic"; import ChatMarkdown from "../ChatMarkdown"; import { Undo2Icon } from "lucide-react"; import { Button } from "../ui/button"; @@ -20,6 +19,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; +import { computeMessageDurationStart } from "./MessagesTimeline.logic"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;