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;