| 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,16 +93,18 @@ import { |
| 93 | parseStandaloneComposerSlashCommand, | 93 | parseStandaloneComposerSlashCommand, |
| 94 | } from "../composer-logic"; | 94 | } from "../composer-logic"; |
| 95 | import { | 95 | import { |
| 96 | createMessageAttachmentPreviewProjector, | ||
| 96 | derivePendingApprovals, | 97 | derivePendingApprovals, |
| 97 | derivePendingUserInputs, | 98 | derivePendingUserInputs, |
| 98 | derivePhase, | 99 | derivePhase, |
| 99 | deriveTimelineEntries, | 100 | deriveTimelineEntriesWithState, |
| 100 | deriveActiveWorkStartedAt, | 101 | deriveActiveWorkStartedAt, |
| 101 | deriveActivePlanState, | 102 | deriveActivePlanState, |
| 102 | findLatestProposedPlan, | 103 | findLatestProposedPlan, |
| 103 | deriveWorkLogEntries, | 104 | deriveWorkLogEntries, |
| 104 | hasActionableProposedPlan, | 105 | hasActionableProposedPlan, |
| 105 | isLatestTurnSettled, | 106 | isLatestTurnSettled, |
| 107 | type TimelineEntriesProjection, | ||
| 106 | } from "../session-logic"; | 108 | } from "../session-logic"; |
| 107 | import { type LegendListRef } from "@legendapp/list/react"; | 109 | import { type LegendListRef } from "@legendapp/list/react"; |
| 108 | import { | 110 | import { |
| ... | @@ -2706,6 +2708,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2706,6 +2708,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2706 | }); | 2708 | }); |
| 2707 | }, []); | 2709 | }, []); |
| 2708 | const serverMessages = activeThread?.messages; | 2710 | const serverMessages = activeThread?.messages; |
| 2711 | const [projectServerMessagePreviews] = useState(createMessageAttachmentPreviewProjector); | ||
| 2712 | const [projectHandoffMessagePreviews] = useState(createMessageAttachmentPreviewProjector); | ||
| 2709 | const downloadFileAttachment = useCallback( | 2713 | const downloadFileAttachment = useCallback( |
| 2710 | async (attachment: ChatFileAttachment) => { | 2714 | async (attachment: ChatFileAttachment) => { |
| 2711 | const connection = readPreparedConnection(environmentId); | 2715 | const connection = readPreparedConnection(environmentId); |
| ... | @@ -2777,19 +2781,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2777,19 +2781,12 @@ export default function ChatView(props: ChatViewProps) { |
| 2777 | ); | 2781 | ); |
| 2778 | const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => { | 2782 | const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => { |
| 2779 | if (!serverMessages) return []; | 2783 | if (!serverMessages) return []; |
| 2780 | return serverMessages.map((message) => { | 2784 | return serverMessages.map((message) => |
| 2781 | if (!message.attachments || message.attachments.length === 0) { | 2785 | projectServerMessagePreviews(message, (attachment) => |
| 2782 | return message; | 2786 | serverAttachmentUrlById.get(attachment.id), |
| 2783 | } | 2787 | ), |
| 2784 | return { | 2788 | ); |
| 2785 | ...message, | 2789 | }, [projectServerMessagePreviews, serverAttachmentUrlById, serverMessages]); |
| 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]); | ||
| 2793 | useEffect(() => { | 2790 | useEffect(() => { |
| 2794 | if (typeof Image === "undefined" || displayServerMessages.length === 0) { | 2791 | if (typeof Image === "undefined" || displayServerMessages.length === 0) { |
| 2795 | return; | 2792 | return; |
| ... | @@ -2879,10 +2876,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2879,10 +2876,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2879 | const serverMessagesWithPreviewHandoff = | 2876 | const serverMessagesWithPreviewHandoff = |
| 2880 | Object.keys(attachmentPreviewHandoffByMessageId).length === 0 | 2877 | Object.keys(attachmentPreviewHandoffByMessageId).length === 0 |
| 2881 | ? messages | 2878 | ? messages |
| 2882 | : // Spread only fires for the few messages that actually changed; | 2879 | : messages.map((message) => { |
| 2883 | // unchanged ones early-return their original reference. | ||
| 2884 | // In-place mutation would break React's immutable state contract. | ||
| 2885 | messages.map((message) => { | ||
| 2886 | if ( | 2880 | if ( |
| 2887 | message.role !== "user" || | 2881 | message.role !== "user" || |
| 2888 | !message.attachments || | 2882 | !message.attachments || |
| ... | @@ -2895,25 +2889,15 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2895,25 +2889,15 @@ export default function ChatView(props: ChatViewProps) { |
| 2895 | return message; | 2889 | return message; |
| 2896 | } | 2890 | } |
| 2897 | 2891 | ||
| 2898 | let changed = false; | ||
| 2899 | let imageIndex = 0; | 2892 | let imageIndex = 0; |
| 2900 | const attachments = message.attachments.map((attachment) => { | 2893 | return projectHandoffMessagePreviews(message, (attachment) => { |
| 2901 | if (!isImageAttachment(attachment)) { | 2894 | if (!isImageAttachment(attachment)) { |
| 2902 | return attachment; | 2895 | return undefined; |
| 2903 | } | 2896 | } |
| 2904 | const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; | 2897 | const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; |
| 2905 | imageIndex += 1; | 2898 | imageIndex += 1; |
| 2906 | if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { | 2899 | return handoffPreviewUrl; |
| 2907 | return attachment; | ||
| 2908 | } | ||
| 2909 | changed = true; | ||
| 2910 | return { | ||
| 2911 | ...attachment, | ||
| 2912 | previewUrl: handoffPreviewUrl, | ||
| 2913 | }; | ||
| 2914 | }); | 2900 | }); |
| 2915 | |||
| 2916 | return changed ? { ...message, attachments } : message; | ||
| 2917 | }); | 2901 | }); |
| 2918 | 2902 | ||
| 2919 | const localMessages = [ | 2903 | const localMessages = [ |
| ... | @@ -2938,12 +2922,29 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2938,12 +2922,29 @@ export default function ChatView(props: ChatViewProps) { |
| 2938 | displayServerMessages, | 2922 | displayServerMessages, |
| 2939 | feedbackSubmissions, | 2923 | feedbackSubmissions, |
| 2940 | optimisticUserMessages, | 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 | const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); | 2948 | const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); |
| 2948 | const draftHeroDockRequested = | 2949 | const draftHeroDockRequested = |
| 2949 | activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; | 2950 | activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+341| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | computeStableMessagesTimelineRows, | 4 | computeStableMessagesTimelineRows, |
| 5 | computeMessageDurationStart, | 5 | computeMessageDurationStart, |
| 6 | deriveMessagesTimelineRows, | 6 | deriveMessagesTimelineRows, |
| 7 | deriveMessagesTimelineRowsWithState, | ||
| 7 | liveWorkEntryLabel, | 8 | liveWorkEntryLabel, |
| 8 | normalizeCompactToolLabel, | 9 | normalizeCompactToolLabel, |
| 9 | resolveAssistantMessageCopyState, | 10 | resolveAssistantMessageCopyState, |
| ... | @@ -13,6 +14,346 @@ import { | ... | @@ -13,6 +14,346 @@ import { |
| 13 | type MessagesTimelineRow, | 14 | type MessagesTimelineRow, |
| 14 | workEntryDisplayLabel, | 15 | workEntryDisplayLabel, |
| 15 | } from "./MessagesTimeline.logic"; | 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 | describe("expanded tool group scrolling", () => { | 358 | describe("expanded tool group scrolling", () => { |
| 18 | const entries = [{ id: "first" }, { id: "second" }]; | 359 | const entries = [{ id: "first" }, { id: "second" }]; |
apps/web/src/components/chat/MessagesTimeline.logic.ts+55| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import * as Equal from "effect/Equal"; | 1 | import * as Equal from "effect/Equal"; |
| 2 | import { shallow } from "zustand/vanilla/shallow"; | ||
| 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; | 3 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 3 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; | 4 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; |
| 4 | import { | 5 | import { |
| ... | @@ -19,6 +20,7 @@ export { | ... | @@ -19,6 +20,7 @@ export { |
| 19 | } from "@t3tools/client-runtime/work-log/presentation"; | 20 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 20 | import { | 21 | import { |
| 21 | formatDuration, | 22 | formatDuration, |
| 23 | isStreamingMessageTextUpdate, | ||
| 22 | workEntryDisplayIndicatesToolFailure, | 24 | workEntryDisplayIndicatesToolFailure, |
| 23 | workEntryIndicatesToolSuccess, | 25 | workEntryIndicatesToolSuccess, |
| 24 | workEntryIndicatesToolNeutralStatus, | 26 | workEntryIndicatesToolNeutralStatus, |
| ... | @@ -1153,6 +1155,59 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -1153,6 +1155,59 @@ export function deriveMessagesTimelineRows(input: { |
| 1153 | return attachTrailingToolGroupsToAssistant(nextRows); | 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 | export function computeStableMessagesTimelineRows( | 1211 | export function computeStableMessagesTimelineRows( |
| 1157 | rows: MessagesTimelineRow[], | 1212 | rows: MessagesTimelineRow[], |
| 1158 | previous: StableMessagesTimelineRowsState, | 1213 | previous: StableMessagesTimelineRowsState, |
apps/web/src/components/chat/MessagesTimeline.tsx+32-17| ... | @@ -123,7 +123,8 @@ import { | ... | @@ -123,7 +123,8 @@ import { |
| 123 | import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget"; | 123 | import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget"; |
| 124 | import { | 124 | import { |
| 125 | computeStableMessagesTimelineRows, | 125 | computeStableMessagesTimelineRows, |
| 126 | deriveMessagesTimelineRows, | 126 | deriveMessagesTimelineRowsWithState, |
| 127 | type MessagesTimelineRowsProjection, | ||
| 127 | liveWorkEntryLabel, | 128 | liveWorkEntryLabel, |
| 128 | resolveAssistantMessageCopyState, | 129 | resolveAssistantMessageCopyState, |
| 129 | resolveTimelineIsAtEnd, | 130 | resolveTimelineIsAtEnd, |
| ... | @@ -504,9 +505,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -504,9 +505,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 504 | }); | 505 | }); |
| 505 | }, [latestTurn]); | 506 | }, [latestTurn]); |
| 506 | 507 | ||
| 507 | const rawRows = useMemo( | 508 | const rowsProjectionRef = useRef<{ |
| 508 | () => | 509 | threadKey: string; |
| 509 | deriveMessagesTimelineRows({ | 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 | timelineEntries, | 517 | timelineEntries, |
| 511 | latestTurn, | 518 | latestTurn, |
| 512 | runningTurnId, | 519 | runningTurnId, |
| ... | @@ -516,19 +523,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -516,19 +523,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 516 | activeTurnStartedAt, | 523 | activeTurnStartedAt, |
| 517 | turnDiffSummaryByAssistantMessageId, | 524 | turnDiffSummaryByAssistantMessageId, |
| 518 | revertTurnCountByUserMessageId, | 525 | revertTurnCountByUserMessageId, |
| 519 | }), | 526 | }, |
| 520 | [ | 527 | previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot |
| 521 | timelineEntries, | 528 | ? previous.projection |
| 522 | latestTurn, | 529 | : null, |
| 523 | runningTurnId, | 530 | ); |
| 524 | expandedTurnIds, | 531 | rowsProjectionRef.current = { threadKey: routeThreadKey, workspaceRoot, projection }; |
| 525 | expandedWorkGroupIds, | 532 | return projection.rows; |
| 526 | isWorking, | 533 | }, [ |
| 527 | activeTurnStartedAt, | 534 | rowsProjectionRef, |
| 528 | turnDiffSummaryByAssistantMessageId, | 535 | routeThreadKey, |
| 529 | revertTurnCountByUserMessageId, | 536 | workspaceRoot, |
| 530 | ], | 537 | timelineEntries, |
| 531 | ); | 538 | latestTurn, |
| 539 | runningTurnId, | ||
| 540 | expandedTurnIds, | ||
| 541 | expandedWorkGroupIds, | ||
| 542 | isWorking, | ||
| 543 | activeTurnStartedAt, | ||
| 544 | turnDiffSummaryByAssistantMessageId, | ||
| 545 | revertTurnCountByUserMessageId, | ||
| 546 | ]); | ||
| 532 | const rows = useStableRows(rawRows); | 547 | const rows = useStableRows(rawRows); |
| 533 | const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); | 548 | const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); |
| 534 | const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>( | 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,11 +10,13 @@ import { describe, expect, it } from "vite-plus/test"; |
| 10 | import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation"; | 10 | import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation"; |
| 11 | 11 | ||
| 12 | import { | 12 | import { |
| 13 | createMessageAttachmentPreviewProjector, | ||
| 13 | deriveActiveWorkStartedAt, | 14 | deriveActiveWorkStartedAt, |
| 14 | deriveActivePlanState, | 15 | deriveActivePlanState, |
| 15 | derivePendingApprovals, | 16 | derivePendingApprovals, |
| 16 | derivePendingUserInputs, | 17 | derivePendingUserInputs, |
| 17 | deriveTimelineEntries, | 18 | deriveTimelineEntries, |
| 19 | deriveTimelineEntriesWithState, | ||
| 18 | deriveWorkLogEntries, | 20 | deriveWorkLogEntries, |
| 19 | findLatestProposedPlan, | 21 | findLatestProposedPlan, |
| 20 | hasActionableProposedPlan, | 22 | hasActionableProposedPlan, |
| ... | @@ -2074,6 +2076,172 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -2074,6 +2076,172 @@ describe("deriveWorkLogEntries", () => { |
| 2074 | }); | 2076 | }); |
| 2075 | 2077 | ||
| 2076 | describe("deriveTimelineEntries", () => { | 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 | it("includes proposed plans alongside messages and work entries in chronological order", () => { | 2245 | it("includes proposed plans alongside messages and work entries in chronological order", () => { |
| 2078 | const entries = deriveTimelineEntries( | 2246 | const entries = deriveTimelineEntries( |
| 2079 | [ | 2247 | [ |
apps/web/src/session-logic.ts+241-17| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as Option from "effect/Option"; | 1 | import * as Option from "effect/Option"; |
| 2 | import * as Arr from "effect/Array"; | 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | import { shallow } from "zustand/vanilla/shallow"; | ||
| 4 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; | 5 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 5 | import { | 6 | import { |
| 6 | commandDetailRepeatsCommand, | 7 | commandDetailRepeatsCommand, |
| ... | @@ -24,6 +25,7 @@ import { | ... | @@ -24,6 +25,7 @@ import { |
| 24 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 25 | 26 | ||
| 26 | import type { | 27 | import type { |
| 28 | ChatAttachment, | ||
| 27 | ChatMessage, | 29 | ChatMessage, |
| 28 | ProposedPlan, | 30 | ProposedPlan, |
| 29 | SessionPhase, | 31 | SessionPhase, |
| ... | @@ -192,6 +194,13 @@ export type TimelineEntry = | ... | @@ -192,6 +194,13 @@ export type TimelineEntry = |
| 192 | entry: WorkLogEntry; | 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 | export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { | 204 | export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { |
| 196 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { | 205 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { |
| 197 | return true; | 206 | return true; |
| ... | @@ -1774,32 +1783,247 @@ function compareActivityLifecycleRank(kind: string): number { | ... | @@ -1774,32 +1783,247 @@ function compareActivityLifecycleRank(kind: string): number { |
| 1774 | return 1; | 1783 | return 1; |
| 1775 | } | 1784 | } |
| 1776 | 1785 | ||
| 1777 | export function deriveTimelineEntries( | 1786 | function timelineEntryFromMessage(message: ChatMessage): TimelineEntry { |
| 1778 | messages: ReadonlyArray<ChatMessage>, | 1787 | return { |
| 1779 | proposedPlans: ReadonlyArray<ProposedPlan>, | ||
| 1780 | workEntries: ReadonlyArray<WorkLogEntry>, | ||
| 1781 | ): TimelineEntry[] { | ||
| 1782 | const messageRows: TimelineEntry[] = messages.map((message) => ({ | ||
| 1783 | id: message.id, | 1788 | id: message.id, |
| 1784 | kind: "message", | 1789 | kind: "message", |
| 1785 | createdAt: message.createdAt, | 1790 | createdAt: message.createdAt, |
| 1786 | message, | 1791 | message, |
| 1787 | })); | 1792 | }; |
| 1788 | const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({ | 1793 | } |
| 1794 | |||
| 1795 | function timelineEntryFromProposedPlan(proposedPlan: ProposedPlan): TimelineEntry { | ||
| 1796 | return { | ||
| 1789 | id: proposedPlan.id, | 1797 | id: proposedPlan.id, |
| 1790 | kind: "proposed-plan", | 1798 | kind: "proposed-plan", |
| 1791 | createdAt: proposedPlan.createdAt, | 1799 | createdAt: proposedPlan.createdAt, |
| 1792 | proposedPlan, | 1800 | proposedPlan, |
| 1793 | })); | 1801 | }; |
| 1794 | const workRows: TimelineEntry[] = workEntries.map((entry) => ({ | 1802 | } |
| 1795 | id: entry.id, | 1803 | |
| 1804 | function timelineEntryFromWork(workEntry: WorkLogEntry): TimelineEntry { | ||
| 1805 | return { | ||
| 1806 | id: workEntry.id, | ||
| 1796 | kind: "work", | 1807 | kind: "work", |
| 1797 | createdAt: entry.createdAt, | 1808 | createdAt: workEntry.createdAt, |
| 1798 | entry, | 1809 | entry: workEntry, |
| 1799 | })); | 1810 | }; |
| 1800 | return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => | 1811 | } |
| 1801 | a.createdAt.localeCompare(b.createdAt), | 1812 | |
| 1802 | ); | 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 | export function inferCheckpointTurnCountByTurnId( | 2029 | export function inferCheckpointTurnCountByTurnId( |