Skip to content

Commit 3f6aa57

Browse files
committed
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
1 parent 9309edf commit 3f6aa57

File tree

3 files changed

+132
-2
lines changed

3 files changed

+132
-2
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it } from "vitest";
2+
import { computeMessageDurationStart } from "./ChatView.logic";
3+
4+
describe("computeMessageDurationStart", () => {
5+
it("returns message createdAt when there is no preceding user message", () => {
6+
const result = computeMessageDurationStart([
7+
{ id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:05Z", completedAt: "2026-01-01T00:00:10Z" },
8+
]);
9+
expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]]));
10+
});
11+
12+
it("uses the user message createdAt for the first assistant response", () => {
13+
const result = computeMessageDurationStart([
14+
{ id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
15+
{ id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", completedAt: "2026-01-01T00:00:30Z" },
16+
]);
17+
expect(result).toEqual(
18+
new Map([
19+
["u1", "2026-01-01T00:00:00Z"], // user: own createdAt
20+
["a1", "2026-01-01T00:00:00Z"], // assistant: user's createdAt
21+
]),
22+
);
23+
});
24+
25+
it("uses the previous assistant completedAt for subsequent assistant responses", () => {
26+
const result = computeMessageDurationStart([
27+
{ id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
28+
{ id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", completedAt: "2026-01-01T00:00:30Z" },
29+
{ id: "a2", role: "assistant", createdAt: "2026-01-01T00:00:55Z", completedAt: "2026-01-01T00:00:55Z" },
30+
]);
31+
expect(result).toEqual(
32+
new Map([
33+
["u1", "2026-01-01T00:00:00Z"], // user: own createdAt
34+
["a1", "2026-01-01T00:00:00Z"], // first assistant: from user (duration = 30s)
35+
["a2", "2026-01-01T00:00:30Z"], // second assistant: from first assistant's completedAt (duration = 25s)
36+
]),
37+
);
38+
});
39+
40+
it("does not advance the boundary for a streaming message without completedAt", () => {
41+
const result = computeMessageDurationStart([
42+
{ id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
43+
{ id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, // streaming, no completedAt
44+
{ id: "a2", role: "assistant", createdAt: "2026-01-01T00:00:55Z", completedAt: "2026-01-01T00:00:55Z" },
45+
]);
46+
expect(result).toEqual(
47+
new Map([
48+
["u1", "2026-01-01T00:00:00Z"], // user
49+
["a1", "2026-01-01T00:00:00Z"], // streaming assistant: from user
50+
["a2", "2026-01-01T00:00:00Z"], // next assistant: still from user (boundary not advanced)
51+
]),
52+
);
53+
});
54+
55+
it("resets the boundary on a new user message", () => {
56+
const result = computeMessageDurationStart([
57+
{ id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
58+
{ id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", completedAt: "2026-01-01T00:00:30Z" },
59+
{ id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" },
60+
{ id: "a2", role: "assistant", createdAt: "2026-01-01T00:01:20Z", completedAt: "2026-01-01T00:01:20Z" },
61+
]);
62+
expect(result).toEqual(
63+
new Map([
64+
["u1", "2026-01-01T00:00:00Z"], // first user
65+
["a1", "2026-01-01T00:00:00Z"], // first assistant: from first user
66+
["u2", "2026-01-01T00:01:00Z"], // second user: own createdAt
67+
["a2", "2026-01-01T00:01:00Z"], // second assistant: from second user (not first assistant)
68+
]),
69+
);
70+
});
71+
72+
it("handles system messages without affecting the boundary", () => {
73+
const result = computeMessageDurationStart([
74+
{ id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" },
75+
{ id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" },
76+
{ id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", completedAt: "2026-01-01T00:00:30Z" },
77+
]);
78+
expect(result).toEqual(
79+
new Map([
80+
["u1", "2026-01-01T00:00:00Z"], // user
81+
["s1", "2026-01-01T00:00:00Z"], // system: inherits user boundary
82+
["a1", "2026-01-01T00:00:00Z"], // assistant: from user
83+
]),
84+
);
85+
});
86+
87+
it("returns empty map for empty input", () => {
88+
expect(computeMessageDurationStart([])).toEqual(new Map());
89+
});
90+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Compute the duration-start timestamp for each message in a timeline.
3+
*
4+
* For the first assistant response after a user message, this is the user
5+
* message's `createdAt`. For subsequent assistant responses within the same
6+
* turn, it advances to the previous assistant message's `completedAt` so that
7+
* each response shows its own incremental duration rather than the cumulative
8+
* time since the user sent the original message.
9+
*/
10+
export function computeMessageDurationStart(
11+
messages: ReadonlyArray<{
12+
id: string;
13+
role: "user" | "assistant" | "system";
14+
createdAt: string;
15+
completedAt?: string | undefined;
16+
}>,
17+
): Map<string, string> {
18+
const result = new Map<string, string>();
19+
let lastBoundary: string | null = null;
20+
21+
for (const message of messages) {
22+
if (message.role === "user") {
23+
lastBoundary = message.createdAt;
24+
}
25+
result.set(message.id, lastBoundary ?? message.createdAt);
26+
if (message.role === "assistant" && message.completedAt) {
27+
lastBoundary = message.completedAt;
28+
}
29+
}
30+
31+
return result;
32+
}

apps/web/src/components/ChatView.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import {
119119
type TurnDiffTreeNode,
120120
} from "../lib/turnDiffTree";
121121
import BranchToolbar from "./BranchToolbar";
122+
import { computeMessageDurationStart } from "./ChatView.logic";
122123
import GitActionsControl from "./GitActionsControl";
123124
import {
124125
isOpenFavoriteEditorShortcut,
@@ -5085,6 +5086,7 @@ type TimelineRow =
50855086
createdAt: string;
50865087
message: TimelineMessage;
50875088
showCompletionDivider: boolean;
5089+
durationStart: string;
50885090
}
50895091
| {
50905092
kind: "proposed-plan";
@@ -5152,6 +5154,11 @@ const MessagesTimeline = memo(function MessagesTimeline({
51525154
const rows = useMemo<TimelineRow[]>(() => {
51535155
const nextRows: TimelineRow[] = [];
51545156

5157+
const messages = timelineEntries
5158+
.filter((e): e is Extract<typeof e, { kind: "message" }> => e.kind === "message")
5159+
.map((e) => ({ ...e.message, id: e.id }));
5160+
const durationStartById = computeMessageDurationStart(messages);
5161+
51555162
for (let index = 0; index < timelineEntries.length; index += 1) {
51565163
const timelineEntry = timelineEntries[index];
51575164
if (!timelineEntry) {
@@ -5192,6 +5199,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
51925199
id: timelineEntry.id,
51935200
createdAt: timelineEntry.createdAt,
51945201
message: timelineEntry.message,
5202+
durationStart: durationStartById.get(timelineEntry.id) ?? timelineEntry.message.createdAt,
51955203
showCompletionDivider:
51965204
timelineEntry.message.role === "assistant" &&
51975205
completionDividerBeforeEntryId === timelineEntry.id,
@@ -5560,8 +5568,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
55605568
{formatMessageMeta(
55615569
row.message.createdAt,
55625570
row.message.streaming
5563-
? formatElapsed(row.message.createdAt, nowIso)
5564-
: formatElapsed(row.message.createdAt, row.message.completedAt),
5571+
? formatElapsed(row.durationStart, nowIso)
5572+
: formatElapsed(row.durationStart, row.message.completedAt),
55655573
)}
55665574
</p>
55675575
</div>

0 commit comments

Comments
 (0)