diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts new file mode 100644 index 0000000000..7074f46019 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { computeMessageDurationStart } from "./MessagesTimeline.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"], + ["a1", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + 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"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:30Z"], + ]), + ); + }); + + 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" }, + { + 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"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + 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"], + ["a1", "2026-01-01T00:00:00Z"], + ["u2", "2026-01-01T00:01:00Z"], + ["a2", "2026-01-01T00:01:00Z"], + ]), + ); + }); + + 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"], + ["s1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("returns empty map for empty input", () => { + expect(computeMessageDurationStart([])).toEqual(new Map()); + }); +}); 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 3bfef9d87c..7a89e762e3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -19,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; @@ -98,6 +99,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const rows = useMemo(() => { const nextRows: TimelineRow[] = []; + const durationStartByMessageId = computeMessageDurationStart( + timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + ); for (let index = 0; index < timelineEntries.length; index += 1) { const timelineEntry = timelineEntries[index]; @@ -139,6 +143,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ id: timelineEntry.id, createdAt: timelineEntry.createdAt, message: timelineEntry.message, + durationStart: + durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, showCompletionDivider: timelineEntry.message.role === "assistant" && completionDividerBeforeEntryId === timelineEntry.id, @@ -507,8 +513,8 @@ export 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), )}

@@ -605,6 +611,7 @@ type TimelineRow = id: string; createdAt: string; message: TimelineMessage; + durationStart: string; showCompletionDivider: boolean; } | {