| author | |
| committer | |
| log | 19c1710a88a2c87c159d76269dacdf0b17ddd9f4 |
| tree | 4ffe8e2c34b1bc26a621e51054ac25a42ad93f33 |
| parent | c7bf3115f2223ede5cb2316613dc7369155dbeb1 |
| signature | Signed by PGP key B5690EEEBB952194 |
Reuse ordered timeline entries and raw rows for safe streaming text updates. Keep full derivation for structural, metadata, activity, and control changes.
Preserve immutable attachment-preview objects when their current URLs are unchanged. Cover URL renewal/removal, completion, grouping, pagination, and earlier-row immutability.
Continue extoci's ordered timeline projection work with separate integration and attachment corrections. The original contribution remains unchanged.
Created with GPT-6 Astra (preview) in Codex.
Co-Authored-By: extoci <hi@extoci.lol>6 files changed, 874 insertions(+), 70 deletions(-)
apps/web/src/components/ChatView.tsx+37-36| ... | ... | @@ -93,16 +93,18 @@ import { |
| 93 | 93 | parseStandaloneComposerSlashCommand, |
| 94 | 94 | } from "../composer-logic"; |
| 95 | 95 | import { |
| 96 | createMessageAttachmentPreviewProjector, | |
| 96 | 97 | derivePendingApprovals, |
| 97 | 98 | derivePendingUserInputs, |
| 98 | 99 | derivePhase, |
| 99 | deriveTimelineEntries, | |
| 100 | deriveTimelineEntriesWithState, | |
| 100 | 101 | deriveActiveWorkStartedAt, |
| 101 | 102 | deriveActivePlanState, |
| 102 | 103 | findLatestProposedPlan, |
| 103 | 104 | deriveWorkLogEntries, |
| 104 | 105 | hasActionableProposedPlan, |
| 105 | 106 | isLatestTurnSettled, |
| 107 | type TimelineEntriesProjection, | |
| 106 | 108 | } from "../session-logic"; |
| 107 | 109 | import { type LegendListRef } from "@legendapp/list/react"; |
| 108 | 110 | import { |
| ... | ... | @@ -2706,6 +2708,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2706 | 2708 | }); |
| 2707 | 2709 | }, []); |
| 2708 | 2710 | const serverMessages = activeThread?.messages; |
| 2711 | const [projectServerMessagePreviews] = useState(createMessageAttachmentPreviewProjector); | |
| 2712 | const [projectHandoffMessagePreviews] = useState(createMessageAttachmentPreviewProjector); | |
| 2709 | 2713 | const downloadFileAttachment = useCallback( |
| 2710 | 2714 | async (attachment: ChatFileAttachment) => { |
| 2711 | 2715 | const connection = readPreparedConnection(environmentId); |
| ... | ... | @@ -2777,19 +2781,12 @@ export default function ChatView(props: ChatViewProps) { |
| 2777 | 2781 | ); |
| 2778 | 2782 | const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => { |
| 2779 | 2783 | if (!serverMessages) return []; |
| 2780 | return serverMessages.map((message) => { | |
| 2781 | if (!message.attachments || message.attachments.length === 0) { | |
| 2782 | return message; | |
| 2783 | } | |
| 2784 | return { | |
| 2785 | ...message, | |
| 2786 | attachments: message.attachments.map((attachment) => { | |
| 2787 | const previewUrl = serverAttachmentUrlById.get(attachment.id); | |
| 2788 | return previewUrl ? { ...attachment, previewUrl } : attachment; | |
| 2789 | }), | |
| 2790 | }; | |
| 2791 | }); | |
| 2792 | }, [serverAttachmentUrlById, serverMessages]); | |
| 2784 | return serverMessages.map((message) => | |
| 2785 | projectServerMessagePreviews(message, (attachment) => | |
| 2786 | serverAttachmentUrlById.get(attachment.id), | |
| 2787 | ), | |
| 2788 | ); | |
| 2789 | }, [projectServerMessagePreviews, serverAttachmentUrlById, serverMessages]); | |
| 2793 | 2790 | useEffect(() => { |
| 2794 | 2791 | if (typeof Image === "undefined" || displayServerMessages.length === 0) { |
| 2795 | 2792 | return; |
| ... | ... | @@ -2879,10 +2876,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2879 | 2876 | const serverMessagesWithPreviewHandoff = |
| 2880 | 2877 | Object.keys(attachmentPreviewHandoffByMessageId).length === 0 |
| 2881 | 2878 | ? messages |
| 2882 | : // Spread only fires for the few messages that actually changed; | |
| 2883 | // unchanged ones early-return their original reference. | |
| 2884 | // In-place mutation would break React's immutable state contract. | |
| 2885 | messages.map((message) => { | |
| 2879 | : messages.map((message) => { | |
| 2886 | 2880 | if ( |
| 2887 | 2881 | message.role !== "user" || |
| 2888 | 2882 | !message.attachments || |
| ... | ... | @@ -2895,25 +2889,15 @@ export default function ChatView(props: ChatViewProps) { |
| 2895 | 2889 | return message; |
| 2896 | 2890 | } |
| 2897 | 2891 | |
| 2898 | let changed = false; | |
| 2899 | 2892 | let imageIndex = 0; |
| 2900 | const attachments = message.attachments.map((attachment) => { | |
| 2893 | return projectHandoffMessagePreviews(message, (attachment) => { | |
| 2901 | 2894 | if (!isImageAttachment(attachment)) { |
| 2902 | return attachment; | |
| 2895 | return undefined; | |
| 2903 | 2896 | } |
| 2904 | 2897 | const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; |
| 2905 | 2898 | imageIndex += 1; |
| 2906 | if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { | |
| 2907 | return attachment; | |
| 2908 | } | |
| 2909 | changed = true; | |
| 2910 | return { | |
| 2911 | ...attachment, | |
| 2912 | previewUrl: handoffPreviewUrl, | |
| 2913 | }; | |
| 2899 | return handoffPreviewUrl; | |
| 2914 | 2900 | }); |
| 2915 | ||
| 2916 | return changed ? { ...message, attachments } : message; | |
| 2917 | 2901 | }); |
| 2918 | 2902 | |
| 2919 | 2903 | const localMessages = [ |
| ... | ... | @@ -2938,12 +2922,29 @@ export default function ChatView(props: ChatViewProps) { |
| 2938 | 2922 | displayServerMessages, |
| 2939 | 2923 | feedbackSubmissions, |
| 2940 | 2924 | optimisticUserMessages, |
| 2925 | projectHandoffMessagePreviews, | |
| 2926 | ]); | |
| 2927 | const timelineProjectionRef = useRef<{ | |
| 2928 | threadKey: string | null; | |
| 2929 | projection: TimelineEntriesProjection; | |
| 2930 | } | null>(null); | |
| 2931 | const timelineEntries = useMemo(() => { | |
| 2932 | const previous = timelineProjectionRef.current; | |
| 2933 | const projection = deriveTimelineEntriesWithState( | |
| 2934 | timelineMessages, | |
| 2935 | activeThread?.proposedPlans ?? [], | |
| 2936 | workLogEntries, | |
| 2937 | previous?.threadKey === activeThreadKey ? previous.projection : null, | |
| 2938 | ); | |
| 2939 | timelineProjectionRef.current = { threadKey: activeThreadKey, projection }; | |
| 2940 | return projection.entries; | |
| 2941 | }, [ | |
| 2942 | timelineProjectionRef, | |
| 2943 | activeThreadKey, | |
| 2944 | activeThread?.proposedPlans, | |
| 2945 | timelineMessages, | |
| 2946 | workLogEntries, | |
| 2941 | 2947 | ]); |
| 2942 | const timelineEntries = useMemo( | |
| 2943 | () => | |
| 2944 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), | |
| 2945 | [activeThread?.proposedPlans, timelineMessages, workLogEntries], | |
| 2946 | ); | |
| 2947 | 2948 | const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); |
| 2948 | 2949 | const draftHeroDockRequested = |
| 2949 | 2950 | activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+341| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | computeStableMessagesTimelineRows, |
| 5 | 5 | computeMessageDurationStart, |
| 6 | 6 | deriveMessagesTimelineRows, |
| 7 | deriveMessagesTimelineRowsWithState, | |
| 7 | 8 | liveWorkEntryLabel, |
| 8 | 9 | normalizeCompactToolLabel, |
| 9 | 10 | resolveAssistantMessageCopyState, |
| ... | ... | @@ -13,6 +14,346 @@ import { |
| 13 | 14 | type MessagesTimelineRow, |
| 14 | 15 | workEntryDisplayLabel, |
| 15 | 16 | } from "./MessagesTimeline.logic"; |
| 17 | import { | |
| 18 | createMessageAttachmentPreviewProjector, | |
| 19 | deriveTimelineEntries, | |
| 20 | deriveTimelineEntriesWithState, | |
| 21 | type WorkLogEntry, | |
| 22 | } from "../../session-logic"; | |
| 23 | import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types"; | |
| 24 | ||
| 25 | describe("streaming row projection", () => { | |
| 26 | function fixture(text = "") { | |
| 27 | const turnId = TurnId.make("live-turn"); | |
| 28 | const historyTurnId = TurnId.make("history-turn"); | |
| 29 | const time = (second: number) => new Date(Date.UTC(2026, 8, 4, 0, 0, second)).toISOString(); | |
| 30 | const messages: ChatMessage[] = [ | |
| 31 | { | |
| 32 | id: MessageId.make("history-user"), | |
| 33 | role: "user", | |
| 34 | text: "Inspect", | |
| 35 | turnId: null, | |
| 36 | createdAt: time(0), | |
| 37 | updatedAt: time(0), | |
| 38 | streaming: false, | |
| 39 | }, | |
| 40 | { | |
| 41 | id: MessageId.make("history-assistant"), | |
| 42 | role: "assistant", | |
| 43 | text: "Done", | |
| 44 | turnId: historyTurnId, | |
| 45 | createdAt: time(3), | |
| 46 | updatedAt: time(4), | |
| 47 | streaming: false, | |
| 48 | }, | |
| 49 | { | |
| 50 | id: MessageId.make("live-user"), | |
| 51 | role: "user", | |
| 52 | text: "Continue", | |
| 53 | turnId: null, | |
| 54 | createdAt: time(5), | |
| 55 | updatedAt: time(5), | |
| 56 | streaming: false, | |
| 57 | }, | |
| 58 | { | |
| 59 | id: MessageId.make("live-assistant"), | |
| 60 | role: "assistant", | |
| 61 | text, | |
| 62 | turnId, | |
| 63 | createdAt: time(7), | |
| 64 | updatedAt: time(7), | |
| 65 | streaming: true, | |
| 66 | }, | |
| 67 | ]; | |
| 68 | const work: WorkLogEntry[] = [ | |
| 69 | { | |
| 70 | id: "history-work", | |
| 71 | turnId: historyTurnId, | |
| 72 | createdAt: time(1), | |
| 73 | label: "Ran command", | |
| 74 | tone: "tool", | |
| 75 | command: "vp test", | |
| 76 | toolCallId: "history-tool", | |
| 77 | toolLifecycleStatus: "failed", | |
| 78 | sourceActivityKind: "tool.completed", | |
| 79 | }, | |
| 80 | { | |
| 81 | id: "live-work", | |
| 82 | turnId, | |
| 83 | createdAt: time(6), | |
| 84 | label: "Read file", | |
| 85 | tone: "tool", | |
| 86 | changedFiles: ["/repo/src/index.ts"], | |
| 87 | toolCallId: "live-tool", | |
| 88 | toolLifecycleStatus: "completed", | |
| 89 | sourceActivityKind: "tool.completed", | |
| 90 | }, | |
| 91 | ]; | |
| 92 | const timeline = deriveTimelineEntriesWithState(messages, [], work); | |
| 93 | const input = { | |
| 94 | timelineEntries: timeline.entries, | |
| 95 | latestTurn: { turnId, state: "running", startedAt: time(5), completedAt: null }, | |
| 96 | runningTurnId: turnId, | |
| 97 | isWorking: true, | |
| 98 | activeTurnStartedAt: time(5), | |
| 99 | turnDiffSummaryByAssistantMessageId: new Map<MessageId, TurnDiffSummary>(), | |
| 100 | revertTurnCountByUserMessageId: new Map<MessageId, number>(), | |
| 101 | } satisfies Parameters<typeof deriveMessagesTimelineRows>[0]; | |
| 102 | return { messages, work, timeline, input, time, turnId, historyTurnId }; | |
| 103 | } | |
| 104 | ||
| 105 | it.each([ | |
| 106 | ["", "Now visible"], | |
| 107 | [" \n", "Now visible"], | |
| 108 | ["Visible", ""], | |
| 109 | ["", " \t\n"], | |
| 110 | ["Visible", "★ Insight\nKeep these line breaks"], | |
| 111 | ])("keeps row parity and history identity when text changes from %j to %j", (before, after) => { | |
| 112 | const initial = fixture(before); | |
| 113 | const previous = deriveMessagesTimelineRowsWithState(initial.input); | |
| 114 | Object.freeze(previous.rows); | |
| 115 | for (const row of previous.rows) Object.freeze(row); | |
| 116 | const last = initial.messages.at(-1)!; | |
| 117 | const messages = [ | |
| 118 | ...initial.messages.slice(0, -1), | |
| 119 | { ...last, text: after, updatedAt: initial.time(8) }, | |
| 120 | ]; | |
| 121 | const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline); | |
| 122 | const input = { ...initial.input, timelineEntries: timeline.entries }; | |
| 123 | const next = deriveMessagesTimelineRowsWithState(input, previous); | |
| 124 | ||
| 125 | expect(next.rows).toEqual( | |
| 126 | deriveMessagesTimelineRows({ | |
| 127 | ...input, | |
| 128 | timelineEntries: deriveTimelineEntries(messages, [], initial.work), | |
| 129 | }), | |
| 130 | ); | |
| 131 | for (const [index, row] of previous.rows.entries()) { | |
| 132 | if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) { | |
| 133 | expect(next.rows[index]).toMatchObject({ message: { text: after } }); | |
| 134 | expect(row.message.text).toBe(before); | |
| 135 | } else { | |
| 136 | expect(next.rows[index]).toBe(row); | |
| 137 | } | |
| 138 | } | |
| 139 | }); | |
| 140 | ||
| 141 | it("keeps earlier emitted rows unchanged across two projection branches", () => { | |
| 142 | const initial = fixture(); | |
| 143 | const previous = deriveMessagesTimelineRowsWithState(initial.input); | |
| 144 | const last = initial.messages.at(-1)!; | |
| 145 | const branch = (text: string) => { | |
| 146 | const messages = [...initial.messages.slice(0, -1), { ...last, text }]; | |
| 147 | const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline); | |
| 148 | const input = { ...initial.input, timelineEntries: timeline.entries }; | |
| 149 | const projection = deriveMessagesTimelineRowsWithState(input, previous); | |
| 150 | expect(projection.rows).toEqual(deriveMessagesTimelineRows(input)); | |
| 151 | return projection; | |
| 152 | }; | |
| 153 | const first = branch("First branch"); | |
| 154 | const savedFirst = structuredClone(first.rows); | |
| 155 | branch("Second branch"); | |
| 156 | expect(first.rows).toEqual(savedFirst); | |
| 157 | expect(previous.rows).toEqual(deriveMessagesTimelineRows(initial.input)); | |
| 158 | }); | |
| 159 | ||
| 160 | it.each(["file", "image", "handoff", "streaming-image"] as const)( | |
| 161 | "preserves stream reuse through the real %s preview materializer", | |
| 162 | (kind) => { | |
| 163 | const initial = fixture("Partial"); | |
| 164 | const image = { | |
| 165 | type: "image" as const, | |
| 166 | id: "image", | |
| 167 | name: "image.png", | |
| 168 | mimeType: "image/png", | |
| 169 | sizeBytes: 42, | |
| 170 | }; | |
| 171 | const file = { | |
| 172 | type: "file" as const, | |
| 173 | id: "file", | |
| 174 | name: "file.txt", | |
| 175 | mimeType: "text/plain", | |
| 176 | sizeBytes: 8, | |
| 177 | }; | |
| 178 | const source = initial.messages.map((message, index) => | |
| 179 | index === 0 || (kind === "streaming-image" && index === initial.messages.length - 1) | |
| 180 | ? { ...message, attachments: kind === "file" ? [file] : [image, file] } | |
| 181 | : message, | |
| 182 | ); | |
| 183 | const server = createMessageAttachmentPreviewProjector(); | |
| 184 | const handoff = createMessageAttachmentPreviewProjector(); | |
| 185 | const materialize = (messages: ReadonlyArray<ChatMessage>, url: string) => { | |
| 186 | const urls = new Map(kind === "handoff" ? [] : [[image.id, url]]); | |
| 187 | return messages.map((message) => { | |
| 188 | const displayed = server(message, (attachment) => urls.get(attachment.id)); | |
| 189 | return kind === "handoff" && message.role === "user" | |
| 190 | ? handoff(displayed, (attachment) => | |
| 191 | isImageAttachment(attachment) ? "blob:handoff" : undefined, | |
| 192 | ) | |
| 193 | : displayed; | |
| 194 | }); | |
| 195 | }; | |
| 196 | const firstMessages = materialize(source, "https://first.test/image"); | |
| 197 | const firstTimeline = deriveTimelineEntriesWithState(firstMessages, [], initial.work); | |
| 198 | const input = { ...initial.input, timelineEntries: firstTimeline.entries }; | |
| 199 | const previous = deriveMessagesTimelineRowsWithState(input); | |
| 200 | const saved = structuredClone(previous.rows); | |
| 201 | const nextSource = [...source.slice(0, -1), { ...source.at(-1)!, text: "Next token" }]; | |
| 202 | const nextMessages = materialize(nextSource, "https://first.test/image"); | |
| 203 | const nextTimeline = deriveTimelineEntriesWithState( | |
| 204 | nextMessages, | |
| 205 | [], | |
| 206 | initial.work, | |
| 207 | firstTimeline, | |
| 208 | ); | |
| 209 | const nextInput = { ...input, timelineEntries: nextTimeline.entries }; | |
| 210 | const next = deriveMessagesTimelineRowsWithState(nextInput, previous); | |
| 211 | ||
| 212 | expect(nextMessages[0]).toBe(firstMessages[0]); | |
| 213 | expect(next.rows).toEqual(deriveMessagesTimelineRows(nextInput)); | |
| 214 | for (const [index, row] of previous.rows.entries()) { | |
| 215 | if ( | |
| 216 | (row.kind === "message" || row.kind === "assistant-meta") && | |
| 217 | row.message.id === source.at(-1)?.id | |
| 218 | ) | |
| 219 | continue; | |
| 220 | expect(next.rows[index]).toBe(row); | |
| 221 | } | |
| 222 | expect(previous.rows).toEqual(saved); | |
| 223 | ||
| 224 | if (kind === "image" || kind === "streaming-image") { | |
| 225 | const renewed = materialize(nextSource, "https://renewed.test/image"); | |
| 226 | const renewedTimeline = deriveTimelineEntriesWithState( | |
| 227 | renewed, | |
| 228 | [], | |
| 229 | initial.work, | |
| 230 | nextTimeline, | |
| 231 | ); | |
| 232 | const renewedInput = { ...input, timelineEntries: renewedTimeline.entries }; | |
| 233 | const renewedRows = deriveMessagesTimelineRowsWithState(renewedInput, next); | |
| 234 | expect(renewedRows.rows).toEqual(deriveMessagesTimelineRows(renewedInput)); | |
| 235 | expect(renewed[0]?.attachments?.[0]).toMatchObject({ | |
| 236 | previewUrl: "https://renewed.test/image", | |
| 237 | }); | |
| 238 | expect(firstMessages[0]?.attachments?.[0]).toMatchObject({ | |
| 239 | previewUrl: "https://first.test/image", | |
| 240 | }); | |
| 241 | } | |
| 242 | }, | |
| 243 | ); | |
| 244 | ||
| 245 | it.each(["completion", "turn", "role", "ordering"] as const)( | |
| 246 | "rebuilds row structure for a %s change with otherwise unchanged controls", | |
| 247 | (change) => { | |
| 248 | const initial = fixture("Partial"); | |
| 249 | const input = { | |
| 250 | ...initial.input, | |
| 251 | latestTurn: { | |
| 252 | ...initial.input.latestTurn, | |
| 253 | state: "completed" as const, | |
| 254 | completedAt: initial.time(9), | |
| 255 | }, | |
| 256 | runningTurnId: null, | |
| 257 | isWorking: false, | |
| 258 | }; | |
| 259 | const previous = deriveMessagesTimelineRowsWithState(input); | |
| 260 | const last = initial.messages.at(-1)!; | |
| 261 | const changed: ChatMessage = | |
| 262 | change === "completion" | |
| 263 | ? { ...last, streaming: false, updatedAt: initial.time(10) } | |
| 264 | : change === "turn" | |
| 265 | ? { ...last, turnId: initial.historyTurnId } | |
| 266 | : change === "role" | |
| 267 | ? { ...last, role: "user", turnId: null } | |
| 268 | : { ...last, createdAt: initial.time(0) }; | |
| 269 | const messages = [...initial.messages.slice(0, -1), changed]; | |
| 270 | const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline); | |
| 271 | const nextInput = { ...input, timelineEntries: timeline.entries }; | |
| 272 | const next = deriveMessagesTimelineRowsWithState(nextInput, previous); | |
| 273 | expect(next.rows).toEqual( | |
| 274 | deriveMessagesTimelineRows({ | |
| 275 | ...nextInput, | |
| 276 | timelineEntries: deriveTimelineEntries(messages, [], initial.work), | |
| 277 | }), | |
| 278 | ); | |
| 279 | expect(previous.rows).toEqual(deriveMessagesTimelineRows(input)); | |
| 280 | }, | |
| 281 | ); | |
| 282 | ||
| 283 | it("keeps full parity through completion, trailing failure, metadata, expansion, and paging", () => { | |
| 284 | const initial = fixture("Partial"); | |
| 285 | let timeline = initial.timeline; | |
| 286 | let input: Parameters<typeof deriveMessagesTimelineRows>[0] = initial.input; | |
| 287 | let projection = deriveMessagesTimelineRowsWithState(input); | |
| 288 | let messages = initial.messages; | |
| 289 | let work = initial.work; | |
| 290 | const check = (changes: Partial<typeof input> = {}) => { | |
| 291 | const oldRows = structuredClone(projection.rows); | |
| 292 | const previous = projection; | |
| 293 | timeline = deriveTimelineEntriesWithState(messages, [], work, timeline); | |
| 294 | input = { ...input, ...changes, timelineEntries: timeline.entries }; | |
| 295 | projection = deriveMessagesTimelineRowsWithState(input, previous); | |
| 296 | expect(projection.rows).toEqual( | |
| 297 | deriveMessagesTimelineRows({ | |
| 298 | ...input, | |
| 299 | timelineEntries: deriveTimelineEntries(messages, [], work), | |
| 300 | }), | |
| 301 | ); | |
| 302 | expect(previous.rows).toEqual(oldRows); | |
| 303 | }; | |
| 304 | ||
| 305 | messages = [ | |
| 306 | ...messages.slice(0, -1), | |
| 307 | { ...messages.at(-1)!, streaming: false, text: "Complete", updatedAt: initial.time(9) }, | |
| 308 | ]; | |
| 309 | check({ | |
| 310 | latestTurn: { ...initial.input.latestTurn, state: "completed", completedAt: initial.time(9) }, | |
| 311 | runningTurnId: null, | |
| 312 | isWorking: false, | |
| 313 | }); | |
| 314 | work = [ | |
| 315 | ...work, | |
| 316 | { | |
| 317 | id: "late-failure", | |
| 318 | turnId: initial.turnId, | |
| 319 | createdAt: initial.time(10), | |
| 320 | label: "Failed command", | |
| 321 | command: "vp test", | |
| 322 | tone: "error", | |
| 323 | toolCallId: "failed-tool", | |
| 324 | toolLifecycleStatus: "failed", | |
| 325 | sourceActivityKind: "tool.completed", | |
| 326 | }, | |
| 327 | ]; | |
| 328 | check(); | |
| 329 | messages = [ | |
| 330 | ...messages.slice(0, -1), | |
| 331 | { ...messages.at(-1)!, text: "Updated final text", updatedAt: initial.time(11) }, | |
| 332 | ]; | |
| 333 | check(); | |
| 334 | check({ | |
| 335 | latestTurn: { | |
| 336 | ...initial.input.latestTurn, | |
| 337 | state: "interrupted", | |
| 338 | completedAt: initial.time(12), | |
| 339 | }, | |
| 340 | }); | |
| 341 | check({ expandedTurnIds: new Set([initial.historyTurnId, initial.turnId]) }); | |
| 342 | const group = projection.rows.find((row) => row.kind === "work-toggle"); | |
| 343 | check({ expandedWorkGroupIds: new Set(group ? [group.id] : []) }); | |
| 344 | messages = [ | |
| 345 | { ...messages[0]!, id: MessageId.make("older-user"), createdAt: "2026-09-03T23:59:00.000Z" }, | |
| 346 | ...messages, | |
| 347 | ]; | |
| 348 | check(); | |
| 349 | messages = messages.map((message) => | |
| 350 | message.id === MessageId.make("live-assistant") | |
| 351 | ? { ...message, role: "user", turnId: null, createdAt: initial.time(0) } | |
| 352 | : message, | |
| 353 | ); | |
| 354 | check({ revertTurnCountByUserMessageId: new Map([[MessageId.make("live-user"), 3]]) }); | |
| 355 | }); | |
| 356 | }); | |
| 16 | 357 | |
| 17 | 358 | describe("expanded tool group scrolling", () => { |
| 18 | 359 | const entries = [{ id: "first" }, { id: "second" }]; |
apps/web/src/components/chat/MessagesTimeline.logic.ts+55| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | import { shallow } from "zustand/vanilla/shallow"; | |
| 2 | 3 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 3 | 4 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; |
| 4 | 5 | import { |
| ... | ... | @@ -19,6 +20,7 @@ export { |
| 19 | 20 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 20 | 21 | import { |
| 21 | 22 | formatDuration, |
| 23 | isStreamingMessageTextUpdate, | |
| 22 | 24 | workEntryDisplayIndicatesToolFailure, |
| 23 | 25 | workEntryIndicatesToolSuccess, |
| 24 | 26 | workEntryIndicatesToolNeutralStatus, |
| ... | ... | @@ -1153,6 +1155,59 @@ export function deriveMessagesTimelineRows(input: { |
| 1153 | 1155 | return attachTrailingToolGroupsToAssistant(nextRows); |
| 1154 | 1156 | } |
| 1155 | 1157 | |
| 1158 | type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0]; | |
| 1159 | ||
| 1160 | export interface MessagesTimelineRowsProjection { | |
| 1161 | readonly input: MessagesTimelineRowsInput; | |
| 1162 | readonly rows: MessagesTimelineRow[]; | |
| 1163 | } | |
| 1164 | ||
| 1165 | function replaceStreamingMessageRows( | |
| 1166 | input: MessagesTimelineRowsInput, | |
| 1167 | previous: MessagesTimelineRowsProjection, | |
| 1168 | ): MessagesTimelineRow[] | null { | |
| 1169 | const { timelineEntries: previousEntries, ...previousContext } = previous.input; | |
| 1170 | const { timelineEntries, ...context } = input; | |
| 1171 | if (timelineEntries.length !== previousEntries.length || !shallow(previousContext, context)) { | |
| 1172 | return null; | |
| 1173 | } | |
| 1174 | const replacements = new Map<ChatMessage, ChatMessage>(); | |
| 1175 | for (const [index, entry] of timelineEntries.entries()) { | |
| 1176 | const previousEntry = previousEntries[index]!; | |
| 1177 | if (entry === previousEntry) continue; | |
| 1178 | if ( | |
| 1179 | entry.kind !== "message" || | |
| 1180 | previousEntry.kind !== "message" || | |
| 1181 | entry.id !== previousEntry.id || | |
| 1182 | entry.createdAt !== previousEntry.createdAt | |
| 1183 | ) { | |
| 1184 | return null; | |
| 1185 | } | |
| 1186 | if (entry.message === previousEntry.message) continue; | |
| 1187 | if (!isStreamingMessageTextUpdate(previousEntry.message, entry.message)) return null; | |
| 1188 | replacements.set(previousEntry.message, entry.message); | |
| 1189 | } | |
| 1190 | if (replacements.size === 0) return previous.rows; | |
| 1191 | return previous.rows.map((row) => { | |
| 1192 | if (row.kind !== "message" && row.kind !== "assistant-meta") return row; | |
| 1193 | const message = replacements.get(row.message); | |
| 1194 | return message ? { ...row, message } : row; | |
| 1195 | }); | |
| 1196 | } | |
| 1197 | ||
| 1198 | /** Keep one projection per timeline. Reuse rows only when streaming content changes. */ | |
| 1199 | export function deriveMessagesTimelineRowsWithState( | |
| 1200 | input: MessagesTimelineRowsInput, | |
| 1201 | previous: MessagesTimelineRowsProjection | null = null, | |
| 1202 | ): MessagesTimelineRowsProjection { | |
| 1203 | return { | |
| 1204 | input, | |
| 1205 | rows: | |
| 1206 | (previous === null ? null : replaceStreamingMessageRows(input, previous)) ?? | |
| 1207 | deriveMessagesTimelineRows(input), | |
| 1208 | }; | |
| 1209 | } | |
| 1210 | ||
| 1156 | 1211 | export function computeStableMessagesTimelineRows( |
| 1157 | 1212 | rows: MessagesTimelineRow[], |
| 1158 | 1213 | previous: StableMessagesTimelineRowsState, |
apps/web/src/components/chat/MessagesTimeline.tsx+32-17| ... | ... | @@ -123,7 +123,8 @@ import { |
| 123 | 123 | import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget"; |
| 124 | 124 | import { |
| 125 | 125 | computeStableMessagesTimelineRows, |
| 126 | deriveMessagesTimelineRows, | |
| 126 | deriveMessagesTimelineRowsWithState, | |
| 127 | type MessagesTimelineRowsProjection, | |
| 127 | 128 | liveWorkEntryLabel, |
| 128 | 129 | resolveAssistantMessageCopyState, |
| 129 | 130 | resolveTimelineIsAtEnd, |
| ... | ... | @@ -504,9 +505,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 504 | 505 | }); |
| 505 | 506 | }, [latestTurn]); |
| 506 | 507 | |
| 507 | const rawRows = useMemo( | |
| 508 | () => | |
| 509 | deriveMessagesTimelineRows({ | |
| 508 | const rowsProjectionRef = useRef<{ | |
| 509 | threadKey: string; | |
| 510 | workspaceRoot: string | undefined; | |
| 511 | projection: MessagesTimelineRowsProjection; | |
| 512 | } | null>(null); | |
| 513 | const rawRows = useMemo(() => { | |
| 514 | const previous = rowsProjectionRef.current; | |
| 515 | const projection = deriveMessagesTimelineRowsWithState( | |
| 516 | { | |
| 510 | 517 | timelineEntries, |
| 511 | 518 | latestTurn, |
| 512 | 519 | runningTurnId, |
| ... | ... | @@ -516,19 +523,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 516 | 523 | activeTurnStartedAt, |
| 517 | 524 | turnDiffSummaryByAssistantMessageId, |
| 518 | 525 | revertTurnCountByUserMessageId, |
| 519 | }), | |
| 520 | [ | |
| 521 | timelineEntries, | |
| 522 | latestTurn, | |
| 523 | runningTurnId, | |
| 524 | expandedTurnIds, | |
| 525 | expandedWorkGroupIds, | |
| 526 | isWorking, | |
| 527 | activeTurnStartedAt, | |
| 528 | turnDiffSummaryByAssistantMessageId, | |
| 529 | revertTurnCountByUserMessageId, | |
| 530 | ], | |
| 531 | ); | |
| 526 | }, | |
| 527 | previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot | |
| 528 | ? previous.projection | |
| 529 | : null, | |
| 530 | ); | |
| 531 | rowsProjectionRef.current = { threadKey: routeThreadKey, workspaceRoot, projection }; | |
| 532 | return projection.rows; | |
| 533 | }, [ | |
| 534 | rowsProjectionRef, | |
| 535 | routeThreadKey, | |
| 536 | workspaceRoot, | |
| 537 | timelineEntries, | |
| 538 | latestTurn, | |
| 539 | runningTurnId, | |
| 540 | expandedTurnIds, | |
| 541 | expandedWorkGroupIds, | |
| 542 | isWorking, | |
| 543 | activeTurnStartedAt, | |
| 544 | turnDiffSummaryByAssistantMessageId, | |
| 545 | revertTurnCountByUserMessageId, | |
| 546 | ]); | |
| 532 | 547 | const rows = useStableRows(rawRows); |
| 533 | 548 | const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); |
| 534 | 549 | const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>( |
apps/web/src/session-logic.test.ts+168| ... | ... | @@ -10,11 +10,13 @@ import { describe, expect, it } from "vite-plus/test"; |
| 10 | 10 | import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation"; |
| 11 | 11 | |
| 12 | 12 | import { |
| 13 | createMessageAttachmentPreviewProjector, | |
| 13 | 14 | deriveActiveWorkStartedAt, |
| 14 | 15 | deriveActivePlanState, |
| 15 | 16 | derivePendingApprovals, |
| 16 | 17 | derivePendingUserInputs, |
| 17 | 18 | deriveTimelineEntries, |
| 19 | deriveTimelineEntriesWithState, | |
| 18 | 20 | deriveWorkLogEntries, |
| 19 | 21 | findLatestProposedPlan, |
| 20 | 22 | hasActionableProposedPlan, |
| ... | ... | @@ -2074,6 +2076,172 @@ describe("deriveWorkLogEntries", () => { |
| 2074 | 2076 | }); |
| 2075 | 2077 | |
| 2076 | 2078 | describe("deriveTimelineEntries", () => { |
| 2079 | const streamingMessage = { | |
| 2080 | id: MessageId.make("streaming-message"), | |
| 2081 | role: "assistant" as const, | |
| 2082 | text: "", | |
| 2083 | turnId: TurnId.make("streaming-turn"), | |
| 2084 | createdAt: "2026-02-23T00:00:03.000Z", | |
| 2085 | updatedAt: "2026-02-23T00:00:03.000Z", | |
| 2086 | streaming: true, | |
| 2087 | }; | |
| 2088 | ||
| 2089 | it("reuses preview objects while preserving URL and attachment metadata changes", () => { | |
| 2090 | const image = { | |
| 2091 | type: "image" as const, | |
| 2092 | id: "image", | |
| 2093 | name: "image.png", | |
| 2094 | mimeType: "image/png", | |
| 2095 | sizeBytes: 42, | |
| 2096 | }; | |
| 2097 | const file = { | |
| 2098 | type: "file" as const, | |
| 2099 | id: "file", | |
| 2100 | name: "file.txt", | |
| 2101 | mimeType: "text/plain", | |
| 2102 | sizeBytes: 8, | |
| 2103 | }; | |
| 2104 | const message = { ...streamingMessage, attachments: Object.freeze([image, file]) }; | |
| 2105 | const project = createMessageAttachmentPreviewProjector(); | |
| 2106 | const urls = new Map([[image.id, "https://first.test/image"]]); | |
| 2107 | const first = project(message, (attachment) => urls.get(attachment.id)); | |
| 2108 | Object.freeze(first.attachments); | |
| 2109 | expect(project(message, (attachment) => new Map(urls).get(attachment.id))).toBe(first); | |
| 2110 | const streamed = project({ ...message, text: "Next" }, (attachment) => urls.get(attachment.id)); | |
| 2111 | expect(streamed.attachments).toBe(first.attachments); | |
| 2112 | expect(streamed.text).toBe("Next"); | |
| 2113 | expect(first.text).toBe(""); | |
| 2114 | expect(first.attachments?.[1]).toBe(file); | |
| 2115 | ||
| 2116 | urls.set(image.id, "https://second.test/image"); | |
| 2117 | const renewed = project(message, (attachment) => urls.get(attachment.id)); | |
| 2118 | expect(renewed.attachments?.[0]).toMatchObject({ previewUrl: "https://second.test/image" }); | |
| 2119 | expect(first.attachments?.[0]).toMatchObject({ previewUrl: "https://first.test/image" }); | |
| 2120 | const renamed = project( | |
| 2121 | { ...message, attachments: [{ ...image, name: "renamed.png" }, file] }, | |
| 2122 | (attachment) => urls.get(attachment.id), | |
| 2123 | ); | |
| 2124 | expect(renamed.attachments?.[0]).toMatchObject({ | |
| 2125 | name: "renamed.png", | |
| 2126 | previewUrl: "https://second.test/image", | |
| 2127 | }); | |
| 2128 | expect(project(message, () => undefined)).toBe(message); | |
| 2129 | }); | |
| 2130 | ||
| 2131 | it("keeps pending preview handoffs stable and restores the current server URL", () => { | |
| 2132 | const message = { | |
| 2133 | ...streamingMessage, | |
| 2134 | role: "user" as const, | |
| 2135 | streaming: false, | |
| 2136 | attachments: [ | |
| 2137 | { | |
| 2138 | type: "image" as const, | |
| 2139 | id: "image", | |
| 2140 | name: "image.png", | |
| 2141 | mimeType: "image/png", | |
| 2142 | sizeBytes: 42, | |
| 2143 | }, | |
| 2144 | ], | |
| 2145 | }; | |
| 2146 | const server = createMessageAttachmentPreviewProjector(); | |
| 2147 | const handoff = createMessageAttachmentPreviewProjector(); | |
| 2148 | const first = handoff( | |
| 2149 | server(message, () => undefined), | |
| 2150 | () => "blob:handoff", | |
| 2151 | ); | |
| 2152 | expect( | |
| 2153 | handoff( | |
| 2154 | server(message, () => undefined), | |
| 2155 | () => "blob:handoff", | |
| 2156 | ), | |
| 2157 | ).toBe(first); | |
| 2158 | const ready = server(message, () => "https://server.test/image"); | |
| 2159 | expect(handoff(ready, () => "blob:handoff").attachments?.[0]).toMatchObject({ | |
| 2160 | previewUrl: "blob:handoff", | |
| 2161 | }); | |
| 2162 | expect(handoff(ready, () => undefined)).toBe(ready); | |
| 2163 | expect(ready.attachments?.[0]).toMatchObject({ previewUrl: "https://server.test/image" }); | |
| 2164 | expect(first.attachments?.[0]).toMatchObject({ previewUrl: "blob:handoff" }); | |
| 2165 | }); | |
| 2166 | ||
| 2167 | it("reuses ordered history without changing an earlier projection", () => { | |
| 2168 | const history = { ...streamingMessage, id: MessageId.make("history"), streaming: false }; | |
| 2169 | const work = [ | |
| 2170 | { id: "work", createdAt: history.createdAt, label: "Ran tests", tone: "tool" as const }, | |
| 2171 | ]; | |
| 2172 | const first = deriveTimelineEntriesWithState([history, streamingMessage], [], work); | |
| 2173 | Object.freeze(first.entries); | |
| 2174 | for (const entry of first.entries) Object.freeze(entry); | |
| 2175 | ||
| 2176 | const firstMessage = { | |
| 2177 | ...streamingMessage, | |
| 2178 | text: "First", | |
| 2179 | updatedAt: "2026-02-23T00:00:04.000Z", | |
| 2180 | }; | |
| 2181 | const secondMessage = { | |
| 2182 | ...streamingMessage, | |
| 2183 | text: "Second", | |
| 2184 | updatedAt: "2026-02-23T00:00:05.000Z", | |
| 2185 | }; | |
| 2186 | const firstBranch = deriveTimelineEntriesWithState([history, firstMessage], [], work, first); | |
| 2187 | const secondBranch = deriveTimelineEntriesWithState([history, secondMessage], [], work, first); | |
| 2188 | ||
| 2189 | expect(firstBranch.entries).toEqual(deriveTimelineEntries([history, firstMessage], [], work)); | |
| 2190 | expect(secondBranch.entries).toEqual(deriveTimelineEntries([history, secondMessage], [], work)); | |
| 2191 | expect(firstBranch.entries[0]).toBe(first.entries[0]); | |
| 2192 | expect(firstBranch.entries[2]).toBe(first.entries[2]); | |
| 2193 | expect(first.entries[1]).toMatchObject({ message: { text: "" } }); | |
| 2194 | expect(firstBranch.entries[1]).toMatchObject({ message: { text: "First" } }); | |
| 2195 | }); | |
| 2196 | ||
| 2197 | it("preserves stable source ordering for ties, append, and older pages", () => { | |
| 2198 | const plan = { | |
| 2199 | id: "plan:thread:turn", | |
| 2200 | turnId: streamingMessage.turnId, | |
| 2201 | planMarkdown: "Plan", | |
| 2202 | implementedAt: null, | |
| 2203 | implementationThreadId: null, | |
| 2204 | createdAt: streamingMessage.createdAt, | |
| 2205 | updatedAt: streamingMessage.createdAt, | |
| 2206 | }; | |
| 2207 | const firstWork = { | |
| 2208 | id: "work-1", | |
| 2209 | createdAt: streamingMessage.createdAt, | |
| 2210 | label: "Ran tests", | |
| 2211 | tone: "tool" as const, | |
| 2212 | }; | |
| 2213 | const first = deriveTimelineEntriesWithState([streamingMessage], [plan], [firstWork]); | |
| 2214 | const appendedMessage = { ...streamingMessage, id: MessageId.make("appended") }; | |
| 2215 | const appendedWork = { ...firstWork, id: "work-2" }; | |
| 2216 | const messages = [streamingMessage, appendedMessage]; | |
| 2217 | const work = [firstWork, appendedWork]; | |
| 2218 | const appended = deriveTimelineEntriesWithState(messages, [plan], work, first); | |
| 2219 | expect(appended.entries.map((entry) => entry.id)).toEqual([ | |
| 2220 | streamingMessage.id, | |
| 2221 | appendedMessage.id, | |
| 2222 | plan.id, | |
| 2223 | firstWork.id, | |
| 2224 | appendedWork.id, | |
| 2225 | ]); | |
| 2226 | expect(appended.entries[0]).toBe(first.entries[0]); | |
| 2227 | ||
| 2228 | const older = { | |
| 2229 | ...streamingMessage, | |
| 2230 | id: MessageId.make("older"), | |
| 2231 | createdAt: "2026-02-22T00:00:00.000Z", | |
| 2232 | }; | |
| 2233 | const prepended = deriveTimelineEntriesWithState([older, ...messages], [plan], work, appended); | |
| 2234 | expect(prepended.entries).toEqual(deriveTimelineEntries([older, ...messages], [plan], work)); | |
| 2235 | const corrected = { | |
| 2236 | ...streamingMessage, | |
| 2237 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 2238 | streaming: false, | |
| 2239 | }; | |
| 2240 | expect( | |
| 2241 | deriveTimelineEntriesWithState([corrected, appendedMessage], [plan], work, appended).entries, | |
| 2242 | ).toEqual(deriveTimelineEntries([corrected, appendedMessage], [plan], work)); | |
| 2243 | }); | |
| 2244 | ||
| 2077 | 2245 | it("includes proposed plans alongside messages and work entries in chronological order", () => { |
| 2078 | 2246 | const entries = deriveTimelineEntries( |
| 2079 | 2247 | [ |
apps/web/src/session-logic.ts+241-17| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Option from "effect/Option"; |
| 2 | 2 | import * as Arr from "effect/Array"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | import { shallow } from "zustand/vanilla/shallow"; | |
| 4 | 5 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 5 | 6 | import { |
| 6 | 7 | commandDetailRepeatsCommand, |
| ... | ... | @@ -24,6 +25,7 @@ import { |
| 24 | 25 | } from "@t3tools/contracts"; |
| 25 | 26 | |
| 26 | 27 | import type { |
| 28 | ChatAttachment, | |
| 27 | 29 | ChatMessage, |
| 28 | 30 | ProposedPlan, |
| 29 | 31 | SessionPhase, |
| ... | ... | @@ -192,6 +194,13 @@ export type TimelineEntry = |
| 192 | 194 | entry: WorkLogEntry; |
| 193 | 195 | }; |
| 194 | 196 | |
| 197 | export interface TimelineEntriesProjection { | |
| 198 | readonly messages: ReadonlyArray<ChatMessage>; | |
| 199 | readonly proposedPlans: ReadonlyArray<ProposedPlan>; | |
| 200 | readonly workEntries: ReadonlyArray<WorkLogEntry>; | |
| 201 | readonly entries: TimelineEntry[]; | |
| 202 | } | |
| 203 | ||
| 195 | 204 | export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { |
| 196 | 205 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { |
| 197 | 206 | return true; |
| ... | ... | @@ -1774,32 +1783,247 @@ function compareActivityLifecycleRank(kind: string): number { |
| 1774 | 1783 | return 1; |
| 1775 | 1784 | } |
| 1776 | 1785 | |
| 1777 | export function deriveTimelineEntries( | |
| 1778 | messages: ReadonlyArray<ChatMessage>, | |
| 1779 | proposedPlans: ReadonlyArray<ProposedPlan>, | |
| 1780 | workEntries: ReadonlyArray<WorkLogEntry>, | |
| 1781 | ): TimelineEntry[] { | |
| 1782 | const messageRows: TimelineEntry[] = messages.map((message) => ({ | |
| 1786 | function timelineEntryFromMessage(message: ChatMessage): TimelineEntry { | |
| 1787 | return { | |
| 1783 | 1788 | id: message.id, |
| 1784 | 1789 | kind: "message", |
| 1785 | 1790 | createdAt: message.createdAt, |
| 1786 | 1791 | message, |
| 1787 | })); | |
| 1788 | const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({ | |
| 1792 | }; | |
| 1793 | } | |
| 1794 | ||
| 1795 | function timelineEntryFromProposedPlan(proposedPlan: ProposedPlan): TimelineEntry { | |
| 1796 | return { | |
| 1789 | 1797 | id: proposedPlan.id, |
| 1790 | 1798 | kind: "proposed-plan", |
| 1791 | 1799 | createdAt: proposedPlan.createdAt, |
| 1792 | 1800 | proposedPlan, |
| 1793 | })); | |
| 1794 | const workRows: TimelineEntry[] = workEntries.map((entry) => ({ | |
| 1795 | id: entry.id, | |
| 1801 | }; | |
| 1802 | } | |
| 1803 | ||
| 1804 | function timelineEntryFromWork(workEntry: WorkLogEntry): TimelineEntry { | |
| 1805 | return { | |
| 1806 | id: workEntry.id, | |
| 1796 | 1807 | kind: "work", |
| 1797 | createdAt: entry.createdAt, | |
| 1798 | entry, | |
| 1799 | })); | |
| 1800 | return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => | |
| 1801 | a.createdAt.localeCompare(b.createdAt), | |
| 1802 | ); | |
| 1808 | createdAt: workEntry.createdAt, | |
| 1809 | entry: workEntry, | |
| 1810 | }; | |
| 1811 | } | |
| 1812 | ||
| 1813 | function compareTimelineEntriesByCreatedAt(left: TimelineEntry, right: TimelineEntry): number { | |
| 1814 | return left.createdAt.localeCompare(right.createdAt); | |
| 1815 | } | |
| 1816 | ||
| 1817 | function timelineEntrySourceOrder(entry: TimelineEntry): number { | |
| 1818 | switch (entry.kind) { | |
| 1819 | case "message": | |
| 1820 | return 0; | |
| 1821 | case "proposed-plan": | |
| 1822 | return 1; | |
| 1823 | case "work": | |
| 1824 | return 2; | |
| 1825 | } | |
| 1826 | } | |
| 1827 | ||
| 1828 | function shouldTakePreviousTimelineEntry(previous: TimelineEntry, suffix: TimelineEntry): boolean { | |
| 1829 | const createdAtComparison = compareTimelineEntriesByCreatedAt(previous, suffix); | |
| 1830 | if (createdAtComparison !== 0) return createdAtComparison < 0; | |
| 1831 | // The original full derivation sorts a source-ordered array with a stable | |
| 1832 | // comparator. On a tie, messages precede plans, plans precede work, and an | |
| 1833 | // older item in the same source array precedes a newly appended item. | |
| 1834 | return timelineEntrySourceOrder(previous) <= timelineEntrySourceOrder(suffix); | |
| 1835 | } | |
| 1836 | ||
| 1837 | function hasExactArrayPrefix<T>(previous: ReadonlyArray<T>, next: ReadonlyArray<T>): boolean { | |
| 1838 | if (previous === next) return true; | |
| 1839 | if (next.length < previous.length) return false; | |
| 1840 | for (let index = 0; index < previous.length; index += 1) { | |
| 1841 | if (previous[index] !== next[index]) return false; | |
| 1842 | } | |
| 1843 | return true; | |
| 1844 | } | |
| 1845 | ||
| 1846 | function mergeTimelineEntrySuffix( | |
| 1847 | previous: ReadonlyArray<TimelineEntry>, | |
| 1848 | suffix: ReadonlyArray<TimelineEntry>, | |
| 1849 | ): TimelineEntry[] { | |
| 1850 | if (suffix.length === 0) return [...previous]; | |
| 1851 | const previousLast = previous.at(-1); | |
| 1852 | let suffixIsOrdered = true; | |
| 1853 | for (let index = 1; index < suffix.length; index += 1) { | |
| 1854 | if (compareTimelineEntriesByCreatedAt(suffix[index - 1]!, suffix[index]!) > 0) { | |
| 1855 | suffixIsOrdered = false; | |
| 1856 | break; | |
| 1857 | } | |
| 1858 | } | |
| 1859 | if ( | |
| 1860 | suffixIsOrdered && | |
| 1861 | (previousLast === undefined || shouldTakePreviousTimelineEntry(previousLast, suffix[0]!)) | |
| 1862 | ) { | |
| 1863 | return [...previous, ...suffix]; | |
| 1864 | } | |
| 1865 | ||
| 1866 | const merged: TimelineEntry[] = []; | |
| 1867 | let previousIndex = 0; | |
| 1868 | let suffixIndex = 0; | |
| 1869 | while (previousIndex < previous.length || suffixIndex < suffix.length) { | |
| 1870 | const previousEntry = previous[previousIndex]; | |
| 1871 | const suffixEntry = suffix[suffixIndex]; | |
| 1872 | if ( | |
| 1873 | previousEntry !== undefined && | |
| 1874 | (suffixEntry === undefined || shouldTakePreviousTimelineEntry(previousEntry, suffixEntry)) | |
| 1875 | ) { | |
| 1876 | merged.push(previousEntry); | |
| 1877 | previousIndex += 1; | |
| 1878 | } else if (suffixEntry !== undefined) { | |
| 1879 | merged.push(suffixEntry); | |
| 1880 | suffixIndex += 1; | |
| 1881 | } | |
| 1882 | } | |
| 1883 | return merged; | |
| 1884 | } | |
| 1885 | ||
| 1886 | /** Own one mapper per preview stage. Immutable messages retain unchanged preview objects. */ | |
| 1887 | export function createMessageAttachmentPreviewProjector() { | |
| 1888 | const attachmentsBySource = new WeakMap< | |
| 1889 | ReadonlyArray<ChatAttachment>, | |
| 1890 | ReadonlyArray<ChatAttachment> | |
| 1891 | >(); | |
| 1892 | const messagesBySource = new WeakMap<ChatMessage, ChatMessage>(); | |
| 1893 | return ( | |
| 1894 | message: ChatMessage, | |
| 1895 | previewUrlFor: (attachment: ChatAttachment) => string | undefined, | |
| 1896 | ): ChatMessage => { | |
| 1897 | const source = message.attachments; | |
| 1898 | if (!source || source.length === 0) return message; | |
| 1899 | const previous = attachmentsBySource.get(source) ?? source; | |
| 1900 | let changed: ChatAttachment[] | undefined; | |
| 1901 | let hasOverrides = false; | |
| 1902 | for (const [index, attachment] of source.entries()) { | |
| 1903 | const previewUrl = previewUrlFor(attachment); | |
| 1904 | const sourceUrl = "previewUrl" in attachment ? attachment.previewUrl : undefined; | |
| 1905 | const previousAttachment = previous[index]!; | |
| 1906 | const previousUrl = | |
| 1907 | "previewUrl" in previousAttachment ? previousAttachment.previewUrl : undefined; | |
| 1908 | const next = | |
| 1909 | !previewUrl || previewUrl === sourceUrl | |
| 1910 | ? attachment | |
| 1911 | : previewUrl === previousUrl | |
| 1912 | ? previousAttachment | |
| 1913 | : { ...attachment, previewUrl }; | |
| 1914 | hasOverrides ||= next !== attachment; | |
| 1915 | if (next !== previousAttachment) { | |
| 1916 | changed ??= previous.slice(); | |
| 1917 | changed[index] = next; | |
| 1918 | } | |
| 1919 | } | |
| 1920 | const attachments = hasOverrides ? (changed ?? previous) : source; | |
| 1921 | attachmentsBySource.set(source, attachments); | |
| 1922 | if (attachments === source) { | |
| 1923 | messagesBySource.delete(message); | |
| 1924 | return message; | |
| 1925 | } | |
| 1926 | const previousMessage = messagesBySource.get(message); | |
| 1927 | if (previousMessage?.attachments === attachments) return previousMessage; | |
| 1928 | const result = { ...message, attachments }; | |
| 1929 | messagesBySource.set(message, result); | |
| 1930 | return result; | |
| 1931 | }; | |
| 1932 | } | |
| 1933 | ||
| 1934 | /** Text and update time do not change a streaming assistant message's timeline structure. */ | |
| 1935 | export function isStreamingMessageTextUpdate(previous: ChatMessage, next: ChatMessage): boolean { | |
| 1936 | if ( | |
| 1937 | previous.role !== "assistant" || | |
| 1938 | next.role !== "assistant" || | |
| 1939 | !previous.streaming || | |
| 1940 | !next.streaming | |
| 1941 | ) { | |
| 1942 | return false; | |
| 1943 | } | |
| 1944 | const { text: _previousText, updatedAt: _previousUpdatedAt, ...previousMetadata } = previous; | |
| 1945 | const { text: _nextText, updatedAt: _nextUpdatedAt, ...nextMetadata } = next; | |
| 1946 | return shallow(previousMetadata, nextMetadata); | |
| 1947 | } | |
| 1948 | ||
| 1949 | function replaceStreamingTimelineMessages( | |
| 1950 | messages: ReadonlyArray<ChatMessage>, | |
| 1951 | previous: TimelineEntriesProjection, | |
| 1952 | ): TimelineEntry[] | null { | |
| 1953 | if (messages.length !== previous.messages.length) return null; | |
| 1954 | const replacements = new Map<ChatMessage, ChatMessage>(); | |
| 1955 | for (const [index, message] of messages.entries()) { | |
| 1956 | const previousMessage = previous.messages[index]!; | |
| 1957 | if (message === previousMessage) continue; | |
| 1958 | if (!isStreamingMessageTextUpdate(previousMessage, message)) return null; | |
| 1959 | replacements.set(previousMessage, message); | |
| 1960 | } | |
| 1961 | if (replacements.size === 0) return previous.entries; | |
| 1962 | return previous.entries.map((entry) => { | |
| 1963 | const replacement = entry.kind === "message" ? replacements.get(entry.message) : undefined; | |
| 1964 | return replacement ? timelineEntryFromMessage(replacement) : entry; | |
| 1965 | }); | |
| 1966 | } | |
| 1967 | ||
| 1968 | /** Reuse ordered entries across immutable stream updates. Other changes keep the full sort. */ | |
| 1969 | export function deriveTimelineEntriesWithState( | |
| 1970 | messages: ReadonlyArray<ChatMessage>, | |
| 1971 | proposedPlans: ReadonlyArray<ProposedPlan>, | |
| 1972 | workEntries: ReadonlyArray<WorkLogEntry>, | |
| 1973 | previous: TimelineEntriesProjection | null = null, | |
| 1974 | ): TimelineEntriesProjection { | |
| 1975 | if ( | |
| 1976 | previous !== null && | |
| 1977 | previous.proposedPlans.length === proposedPlans.length && | |
| 1978 | previous.workEntries.length === workEntries.length && | |
| 1979 | hasExactArrayPrefix(previous.proposedPlans, proposedPlans) && | |
| 1980 | hasExactArrayPrefix(previous.workEntries, workEntries) | |
| 1981 | ) { | |
| 1982 | const entries = replaceStreamingTimelineMessages(messages, previous); | |
| 1983 | if (entries !== null) return { messages, proposedPlans, workEntries, entries }; | |
| 1984 | } | |
| 1985 | const canAppend = | |
| 1986 | previous !== null && | |
| 1987 | hasExactArrayPrefix(previous.messages, messages) && | |
| 1988 | hasExactArrayPrefix(previous.proposedPlans, proposedPlans) && | |
| 1989 | hasExactArrayPrefix(previous.workEntries, workEntries); | |
| 1990 | ||
| 1991 | if (canAppend) { | |
| 1992 | const messageRows = messages.slice(previous.messages.length).map(timelineEntryFromMessage); | |
| 1993 | const proposedPlanRows = proposedPlans | |
| 1994 | .slice(previous.proposedPlans.length) | |
| 1995 | .map(timelineEntryFromProposedPlan); | |
| 1996 | const workRows = workEntries.slice(previous.workEntries.length).map(timelineEntryFromWork); | |
| 1997 | const suffix = [...messageRows, ...proposedPlanRows, ...workRows].toSorted( | |
| 1998 | compareTimelineEntriesByCreatedAt, | |
| 1999 | ); | |
| 2000 | return { | |
| 2001 | messages, | |
| 2002 | proposedPlans, | |
| 2003 | workEntries, | |
| 2004 | entries: mergeTimelineEntrySuffix(previous.entries, suffix), | |
| 2005 | }; | |
| 2006 | } | |
| 2007 | ||
| 2008 | const messageRows = messages.map(timelineEntryFromMessage); | |
| 2009 | const proposedPlanRows = proposedPlans.map(timelineEntryFromProposedPlan); | |
| 2010 | const workRows = workEntries.map(timelineEntryFromWork); | |
| 2011 | return { | |
| 2012 | messages, | |
| 2013 | proposedPlans, | |
| 2014 | workEntries, | |
| 2015 | entries: [...messageRows, ...proposedPlanRows, ...workRows].toSorted( | |
| 2016 | compareTimelineEntriesByCreatedAt, | |
| 2017 | ), | |
| 2018 | }; | |
| 2019 | } | |
| 2020 | ||
| 2021 | export function deriveTimelineEntries( | |
| 2022 | messages: ReadonlyArray<ChatMessage>, | |
| 2023 | proposedPlans: ReadonlyArray<ProposedPlan>, | |
| 2024 | workEntries: ReadonlyArray<WorkLogEntry>, | |
| 2025 | ): TimelineEntry[] { | |
| 2026 | return deriveTimelineEntriesWithState(messages, proposedPlans, workEntries).entries; | |
| 1803 | 2027 | } |
| 1804 | 2028 | |
| 1805 | 2029 | export function inferCheckpointTurnCountByTurnId( |