| author | |
| committer | |
| log | ed4be2c849c56c105dd7ccef6b3b5908ebbc2acd |
| tree | b4b330bfbff51107d51b4fef1af180bb1dc577c0 |
| parent | 5e23e9c7c744724b3d17d4562aa01ce64d25066b |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 169 insertions(+), 2 deletions(-)
apps/web/src/components/chat/MessagesTimeline.logic.test.ts created+135| ... | @@ -0,0 +1,135 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { computeMessageDurationStart } from "./MessagesTimeline.logic"; | ||
| 3 | |||
| 4 | describe("computeMessageDurationStart", () => { | ||
| 5 | it("returns message createdAt when there is no preceding user message", () => { | ||
| 6 | const result = computeMessageDurationStart([ | ||
| 7 | { | ||
| 8 | id: "a1", | ||
| 9 | role: "assistant", | ||
| 10 | createdAt: "2026-01-01T00:00:05Z", | ||
| 11 | completedAt: "2026-01-01T00:00:10Z", | ||
| 12 | }, | ||
| 13 | ]); | ||
| 14 | expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]])); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("uses the user message createdAt for the first assistant response", () => { | ||
| 18 | const result = computeMessageDurationStart([ | ||
| 19 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | ||
| 20 | { | ||
| 21 | id: "a1", | ||
| 22 | role: "assistant", | ||
| 23 | createdAt: "2026-01-01T00:00:30Z", | ||
| 24 | completedAt: "2026-01-01T00:00:30Z", | ||
| 25 | }, | ||
| 26 | ]); | ||
| 27 | |||
| 28 | expect(result).toEqual( | ||
| 29 | new Map([ | ||
| 30 | ["u1", "2026-01-01T00:00:00Z"], | ||
| 31 | ["a1", "2026-01-01T00:00:00Z"], | ||
| 32 | ]), | ||
| 33 | ); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("uses the previous assistant completedAt for subsequent assistant responses", () => { | ||
| 37 | const result = computeMessageDurationStart([ | ||
| 38 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | ||
| 39 | { | ||
| 40 | id: "a1", | ||
| 41 | role: "assistant", | ||
| 42 | createdAt: "2026-01-01T00:00:30Z", | ||
| 43 | completedAt: "2026-01-01T00:00:30Z", | ||
| 44 | }, | ||
| 45 | { | ||
| 46 | id: "a2", | ||
| 47 | role: "assistant", | ||
| 48 | createdAt: "2026-01-01T00:00:55Z", | ||
| 49 | completedAt: "2026-01-01T00:00:55Z", | ||
| 50 | }, | ||
| 51 | ]); | ||
| 52 | |||
| 53 | expect(result).toEqual( | ||
| 54 | new Map([ | ||
| 55 | ["u1", "2026-01-01T00:00:00Z"], | ||
| 56 | ["a1", "2026-01-01T00:00:00Z"], | ||
| 57 | ["a2", "2026-01-01T00:00:30Z"], | ||
| 58 | ]), | ||
| 59 | ); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("does not advance the boundary for a streaming message without completedAt", () => { | ||
| 63 | const result = computeMessageDurationStart([ | ||
| 64 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | ||
| 65 | { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, | ||
| 66 | { | ||
| 67 | id: "a2", | ||
| 68 | role: "assistant", | ||
| 69 | createdAt: "2026-01-01T00:00:55Z", | ||
| 70 | completedAt: "2026-01-01T00:00:55Z", | ||
| 71 | }, | ||
| 72 | ]); | ||
| 73 | |||
| 74 | expect(result).toEqual( | ||
| 75 | new Map([ | ||
| 76 | ["u1", "2026-01-01T00:00:00Z"], | ||
| 77 | ["a1", "2026-01-01T00:00:00Z"], | ||
| 78 | ["a2", "2026-01-01T00:00:00Z"], | ||
| 79 | ]), | ||
| 80 | ); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("resets the boundary on a new user message", () => { | ||
| 84 | const result = computeMessageDurationStart([ | ||
| 85 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | ||
| 86 | { | ||
| 87 | id: "a1", | ||
| 88 | role: "assistant", | ||
| 89 | createdAt: "2026-01-01T00:00:30Z", | ||
| 90 | completedAt: "2026-01-01T00:00:30Z", | ||
| 91 | }, | ||
| 92 | { id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" }, | ||
| 93 | { | ||
| 94 | id: "a2", | ||
| 95 | role: "assistant", | ||
| 96 | createdAt: "2026-01-01T00:01:20Z", | ||
| 97 | completedAt: "2026-01-01T00:01:20Z", | ||
| 98 | }, | ||
| 99 | ]); | ||
| 100 | |||
| 101 | expect(result).toEqual( | ||
| 102 | new Map([ | ||
| 103 | ["u1", "2026-01-01T00:00:00Z"], | ||
| 104 | ["a1", "2026-01-01T00:00:00Z"], | ||
| 105 | ["u2", "2026-01-01T00:01:00Z"], | ||
| 106 | ["a2", "2026-01-01T00:01:00Z"], | ||
| 107 | ]), | ||
| 108 | ); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("handles system messages without affecting the boundary", () => { | ||
| 112 | const result = computeMessageDurationStart([ | ||
| 113 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | ||
| 114 | { id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" }, | ||
| 115 | { | ||
| 116 | id: "a1", | ||
| 117 | role: "assistant", | ||
| 118 | createdAt: "2026-01-01T00:00:30Z", | ||
| 119 | completedAt: "2026-01-01T00:00:30Z", | ||
| 120 | }, | ||
| 121 | ]); | ||
| 122 | |||
| 123 | expect(result).toEqual( | ||
| 124 | new Map([ | ||
| 125 | ["u1", "2026-01-01T00:00:00Z"], | ||
| 126 | ["s1", "2026-01-01T00:00:00Z"], | ||
| 127 | ["a1", "2026-01-01T00:00:00Z"], | ||
| 128 | ]), | ||
| 129 | ); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("returns empty map for empty input", () => { | ||
| 133 | expect(computeMessageDurationStart([])).toEqual(new Map()); | ||
| 134 | }); | ||
| 135 | }); | ||
apps/web/src/components/chat/MessagesTimeline.logic.ts created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | export interface TimelineDurationMessage { | ||
| 2 | id: string; | ||
| 3 | role: "user" | "assistant" | "system"; | ||
| 4 | createdAt: string; | ||
| 5 | completedAt?: string | undefined; | ||
| 6 | } | ||
| 7 | |||
| 8 | export function computeMessageDurationStart( | ||
| 9 | messages: ReadonlyArray<TimelineDurationMessage>, | ||
| 10 | ): Map<string, string> { | ||
| 11 | const result = new Map<string, string>(); | ||
| 12 | let lastBoundary: string | null = null; | ||
| 13 | |||
| 14 | for (const message of messages) { | ||
| 15 | if (message.role === "user") { | ||
| 16 | lastBoundary = message.createdAt; | ||
| 17 | } | ||
| 18 | result.set(message.id, lastBoundary ?? message.createdAt); | ||
| 19 | if (message.role === "assistant" && message.completedAt) { | ||
| 20 | lastBoundary = message.completedAt; | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | return result; | ||
| 25 | } | ||
apps/web/src/components/chat/MessagesTimeline.tsx+9-2| ... | @@ -19,6 +19,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; | ... | @@ -19,6 +19,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 19 | import { ChangedFilesTree } from "./ChangedFilesTree"; | 19 | import { ChangedFilesTree } from "./ChangedFilesTree"; |
| 20 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | 20 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 21 | import { MessageCopyButton } from "./MessageCopyButton"; | 21 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 22 | import { computeMessageDurationStart } from "./MessagesTimeline.logic"; | ||
| 22 | 23 | ||
| 23 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | 24 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 24 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; | 25 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; |
| ... | @@ -98,6 +99,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -98,6 +99,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 98 | 99 | ||
| 99 | const rows = useMemo<TimelineRow[]>(() => { | 100 | const rows = useMemo<TimelineRow[]>(() => { |
| 100 | const nextRows: TimelineRow[] = []; | 101 | const nextRows: TimelineRow[] = []; |
| 102 | const durationStartByMessageId = computeMessageDurationStart( | ||
| 103 | timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), | ||
| 104 | ); | ||
| 101 | 105 | ||
| 102 | for (let index = 0; index < timelineEntries.length; index += 1) { | 106 | for (let index = 0; index < timelineEntries.length; index += 1) { |
| 103 | const timelineEntry = timelineEntries[index]; | 107 | const timelineEntry = timelineEntries[index]; |
| ... | @@ -139,6 +143,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -139,6 +143,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 139 | id: timelineEntry.id, | 143 | id: timelineEntry.id, |
| 140 | createdAt: timelineEntry.createdAt, | 144 | createdAt: timelineEntry.createdAt, |
| 141 | message: timelineEntry.message, | 145 | message: timelineEntry.message, |
| 146 | durationStart: | ||
| 147 | durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, | ||
| 142 | showCompletionDivider: | 148 | showCompletionDivider: |
| 143 | timelineEntry.message.role === "assistant" && | 149 | timelineEntry.message.role === "assistant" && |
| 144 | completionDividerBeforeEntryId === timelineEntry.id, | 150 | completionDividerBeforeEntryId === timelineEntry.id, |
| ... | @@ -507,8 +513,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -507,8 +513,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 507 | {formatMessageMeta( | 513 | {formatMessageMeta( |
| 508 | row.message.createdAt, | 514 | row.message.createdAt, |
| 509 | row.message.streaming | 515 | row.message.streaming |
| 510 | ? formatElapsed(row.message.createdAt, nowIso) | 516 | ? formatElapsed(row.durationStart, nowIso) |
| 511 | : formatElapsed(row.message.createdAt, row.message.completedAt), | 517 | : formatElapsed(row.durationStart, row.message.completedAt), |
| 512 | )} | 518 | )} |
| 513 | </p> | 519 | </p> |
| 514 | </div> | 520 | </div> |
| ... | @@ -605,6 +611,7 @@ type TimelineRow = | ... | @@ -605,6 +611,7 @@ type TimelineRow = |
| 605 | id: string; | 611 | id: string; |
| 606 | createdAt: string; | 612 | createdAt: string; |
| 607 | message: TimelineMessage; | 613 | message: TimelineMessage; |
| 614 | durationStart: string; | ||
| 608 | showCompletionDivider: boolean; | 615 | showCompletionDivider: boolean; |
| 609 | } | 616 | } |
| 610 | | { | 617 | | { |