44 computeStableMessagesTimelineRows ,
55 computeMessageDurationStart ,
66 deriveMessagesTimelineRows ,
7+ deriveMessagesTimelineRowsWithState ,
78 liveWorkEntryLabel ,
89 normalizeCompactToolLabel ,
910 resolveAssistantMessageCopyState ,
@@ -13,6 +14,260 @@ import {
1314 type MessagesTimelineRow ,
1415 workEntryDisplayLabel ,
1516} from "./MessagesTimeline.logic" ;
17+ import {
18+ deriveTimelineEntries ,
19+ deriveTimelineEntriesWithState ,
20+ type WorkLogEntry ,
21+ } from "../../session-logic" ;
22+ import type { ChatMessage , TurnDiffSummary } from "../../types" ;
23+
24+ describe ( "streaming row projection" , ( ) => {
25+ function fixture ( text = "" ) {
26+ const turnId = TurnId . make ( "live-turn" ) ;
27+ const historyTurnId = TurnId . make ( "history-turn" ) ;
28+ const time = ( second : number ) => new Date ( Date . UTC ( 2026 , 8 , 4 , 0 , 0 , second ) ) . toISOString ( ) ;
29+ const messages : ChatMessage [ ] = [
30+ {
31+ id : MessageId . make ( "history-user" ) ,
32+ role : "user" ,
33+ text : "Inspect" ,
34+ turnId : null ,
35+ createdAt : time ( 0 ) ,
36+ updatedAt : time ( 0 ) ,
37+ streaming : false ,
38+ } ,
39+ {
40+ id : MessageId . make ( "history-assistant" ) ,
41+ role : "assistant" ,
42+ text : "Done" ,
43+ turnId : historyTurnId ,
44+ createdAt : time ( 3 ) ,
45+ updatedAt : time ( 4 ) ,
46+ streaming : false ,
47+ } ,
48+ {
49+ id : MessageId . make ( "live-user" ) ,
50+ role : "user" ,
51+ text : "Continue" ,
52+ turnId : null ,
53+ createdAt : time ( 5 ) ,
54+ updatedAt : time ( 5 ) ,
55+ streaming : false ,
56+ } ,
57+ {
58+ id : MessageId . make ( "live-assistant" ) ,
59+ role : "assistant" ,
60+ text,
61+ turnId,
62+ createdAt : time ( 7 ) ,
63+ updatedAt : time ( 7 ) ,
64+ streaming : true ,
65+ } ,
66+ ] ;
67+ const work : WorkLogEntry [ ] = [
68+ {
69+ id : "history-work" ,
70+ turnId : historyTurnId ,
71+ createdAt : time ( 1 ) ,
72+ label : "Ran command" ,
73+ tone : "tool" ,
74+ command : "vp test" ,
75+ toolCallId : "history-tool" ,
76+ toolLifecycleStatus : "failed" ,
77+ sourceActivityKind : "tool.completed" ,
78+ } ,
79+ {
80+ id : "live-work" ,
81+ turnId,
82+ createdAt : time ( 6 ) ,
83+ label : "Read file" ,
84+ tone : "tool" ,
85+ changedFiles : [ "/repo/src/index.ts" ] ,
86+ toolCallId : "live-tool" ,
87+ toolLifecycleStatus : "completed" ,
88+ sourceActivityKind : "tool.completed" ,
89+ } ,
90+ ] ;
91+ const timeline = deriveTimelineEntriesWithState ( messages , [ ] , work ) ;
92+ const input = {
93+ timelineEntries : timeline . entries ,
94+ latestTurn : { turnId, state : "running" , startedAt : time ( 5 ) , completedAt : null } ,
95+ runningTurnId : turnId ,
96+ isWorking : true ,
97+ activeTurnStartedAt : time ( 5 ) ,
98+ turnDiffSummaryByAssistantMessageId : new Map < MessageId , TurnDiffSummary > ( ) ,
99+ revertTurnCountByUserMessageId : new Map < MessageId , number > ( ) ,
100+ } satisfies Parameters < typeof deriveMessagesTimelineRows > [ 0 ] ;
101+ return { messages, work, timeline, input, time, turnId, historyTurnId } ;
102+ }
103+
104+ it . each ( [
105+ [ "" , "Now visible" ] ,
106+ [ " \n" , "Now visible" ] ,
107+ [ "Visible" , "" ] ,
108+ [ "" , " \t\n" ] ,
109+ [ "Visible" , "★ Insight\nKeep these line breaks" ] ,
110+ ] ) ( "keeps row parity and history identity when text changes from %j to %j" , ( before , after ) => {
111+ const initial = fixture ( before ) ;
112+ const previous = deriveMessagesTimelineRowsWithState ( initial . input ) ;
113+ Object . freeze ( previous . rows ) ;
114+ for ( const row of previous . rows ) Object . freeze ( row ) ;
115+ const last = initial . messages . at ( - 1 ) ! ;
116+ const messages = [
117+ ...initial . messages . slice ( 0 , - 1 ) ,
118+ { ...last , text : after , updatedAt : initial . time ( 8 ) } ,
119+ ] ;
120+ const timeline = deriveTimelineEntriesWithState ( messages , [ ] , initial . work , initial . timeline ) ;
121+ const input = { ...initial . input , timelineEntries : timeline . entries } ;
122+ const next = deriveMessagesTimelineRowsWithState ( input , previous ) ;
123+
124+ expect ( next . rows ) . toEqual (
125+ deriveMessagesTimelineRows ( {
126+ ...input ,
127+ timelineEntries : deriveTimelineEntries ( messages , [ ] , initial . work ) ,
128+ } ) ,
129+ ) ;
130+ for ( const [ index , row ] of previous . rows . entries ( ) ) {
131+ if ( ( row . kind === "message" || row . kind === "assistant-meta" ) && row . message === last ) {
132+ expect ( next . rows [ index ] ) . toMatchObject ( { message : { text : after } } ) ;
133+ expect ( row . message . text ) . toBe ( before ) ;
134+ } else {
135+ expect ( next . rows [ index ] ) . toBe ( row ) ;
136+ }
137+ }
138+ } ) ;
139+
140+ it ( "keeps earlier emitted rows unchanged across two projection branches" , ( ) => {
141+ const initial = fixture ( ) ;
142+ const previous = deriveMessagesTimelineRowsWithState ( initial . input ) ;
143+ const last = initial . messages . at ( - 1 ) ! ;
144+ const branch = ( text : string ) => {
145+ const messages = [ ...initial . messages . slice ( 0 , - 1 ) , { ...last , text } ] ;
146+ const timeline = deriveTimelineEntriesWithState ( messages , [ ] , initial . work , initial . timeline ) ;
147+ const input = { ...initial . input , timelineEntries : timeline . entries } ;
148+ const projection = deriveMessagesTimelineRowsWithState ( input , previous ) ;
149+ expect ( projection . rows ) . toEqual ( deriveMessagesTimelineRows ( input ) ) ;
150+ return projection ;
151+ } ;
152+ const first = branch ( "First branch" ) ;
153+ const savedFirst = structuredClone ( first . rows ) ;
154+ branch ( "Second branch" ) ;
155+ expect ( first . rows ) . toEqual ( savedFirst ) ;
156+ expect ( previous . rows ) . toEqual ( deriveMessagesTimelineRows ( initial . input ) ) ;
157+ } ) ;
158+
159+ it . each ( [ "completion" , "turn" , "role" , "ordering" ] as const ) (
160+ "rebuilds row structure for a %s change with otherwise unchanged controls" ,
161+ ( change ) => {
162+ const initial = fixture ( "Partial" ) ;
163+ const input = {
164+ ...initial . input ,
165+ latestTurn : {
166+ ...initial . input . latestTurn ,
167+ state : "completed" as const ,
168+ completedAt : initial . time ( 9 ) ,
169+ } ,
170+ runningTurnId : null ,
171+ isWorking : false ,
172+ } ;
173+ const previous = deriveMessagesTimelineRowsWithState ( input ) ;
174+ const last = initial . messages . at ( - 1 ) ! ;
175+ const changed : ChatMessage =
176+ change === "completion"
177+ ? { ...last , streaming : false , updatedAt : initial . time ( 10 ) }
178+ : change === "turn"
179+ ? { ...last , turnId : initial . historyTurnId }
180+ : change === "role"
181+ ? { ...last , role : "user" , turnId : null }
182+ : { ...last , createdAt : initial . time ( 0 ) } ;
183+ const messages = [ ...initial . messages . slice ( 0 , - 1 ) , changed ] ;
184+ const timeline = deriveTimelineEntriesWithState ( messages , [ ] , initial . work , initial . timeline ) ;
185+ const nextInput = { ...input , timelineEntries : timeline . entries } ;
186+ const next = deriveMessagesTimelineRowsWithState ( nextInput , previous ) ;
187+ expect ( next . rows ) . toEqual (
188+ deriveMessagesTimelineRows ( {
189+ ...nextInput ,
190+ timelineEntries : deriveTimelineEntries ( messages , [ ] , initial . work ) ,
191+ } ) ,
192+ ) ;
193+ expect ( previous . rows ) . toEqual ( deriveMessagesTimelineRows ( input ) ) ;
194+ } ,
195+ ) ;
196+
197+ it ( "keeps full parity through completion, trailing failure, metadata, expansion, and paging" , ( ) => {
198+ const initial = fixture ( "Partial" ) ;
199+ let timeline = initial . timeline ;
200+ let input : Parameters < typeof deriveMessagesTimelineRows > [ 0 ] = initial . input ;
201+ let projection = deriveMessagesTimelineRowsWithState ( input ) ;
202+ let messages = initial . messages ;
203+ let work = initial . work ;
204+ const check = ( changes : Partial < typeof input > = { } ) => {
205+ const oldRows = structuredClone ( projection . rows ) ;
206+ const previous = projection ;
207+ timeline = deriveTimelineEntriesWithState ( messages , [ ] , work , timeline ) ;
208+ input = { ...input , ...changes , timelineEntries : timeline . entries } ;
209+ projection = deriveMessagesTimelineRowsWithState ( input , previous ) ;
210+ expect ( projection . rows ) . toEqual (
211+ deriveMessagesTimelineRows ( {
212+ ...input ,
213+ timelineEntries : deriveTimelineEntries ( messages , [ ] , work ) ,
214+ } ) ,
215+ ) ;
216+ expect ( previous . rows ) . toEqual ( oldRows ) ;
217+ } ;
218+
219+ messages = [
220+ ...messages . slice ( 0 , - 1 ) ,
221+ { ...messages . at ( - 1 ) ! , streaming : false , text : "Complete" , updatedAt : initial . time ( 9 ) } ,
222+ ] ;
223+ check ( {
224+ latestTurn : { ...initial . input . latestTurn , state : "completed" , completedAt : initial . time ( 9 ) } ,
225+ runningTurnId : null ,
226+ isWorking : false ,
227+ } ) ;
228+ work = [
229+ ...work ,
230+ {
231+ id : "late-failure" ,
232+ turnId : initial . turnId ,
233+ createdAt : initial . time ( 10 ) ,
234+ label : "Failed command" ,
235+ command : "vp test" ,
236+ tone : "error" ,
237+ toolCallId : "failed-tool" ,
238+ toolLifecycleStatus : "failed" ,
239+ sourceActivityKind : "tool.completed" ,
240+ } ,
241+ ] ;
242+ check ( ) ;
243+ messages = [
244+ ...messages . slice ( 0 , - 1 ) ,
245+ { ...messages . at ( - 1 ) ! , text : "Updated final text" , updatedAt : initial . time ( 11 ) } ,
246+ ] ;
247+ check ( ) ;
248+ check ( {
249+ latestTurn : {
250+ ...initial . input . latestTurn ,
251+ state : "interrupted" ,
252+ completedAt : initial . time ( 12 ) ,
253+ } ,
254+ } ) ;
255+ check ( { expandedTurnIds : new Set ( [ initial . historyTurnId , initial . turnId ] ) } ) ;
256+ const group = projection . rows . find ( ( row ) => row . kind === "work-toggle" ) ;
257+ check ( { expandedWorkGroupIds : new Set ( group ? [ group . id ] : [ ] ) } ) ;
258+ messages = [
259+ { ...messages [ 0 ] ! , id : MessageId . make ( "older-user" ) , createdAt : "2026-09-03T23:59:00.000Z" } ,
260+ ...messages ,
261+ ] ;
262+ check ( ) ;
263+ messages = messages . map ( ( message ) =>
264+ message . id === MessageId . make ( "live-assistant" )
265+ ? { ...message , role : "user" , turnId : null , createdAt : initial . time ( 0 ) }
266+ : message ,
267+ ) ;
268+ check ( { revertTurnCountByUserMessageId : new Map ( [ [ MessageId . make ( "live-user" ) , 3 ] ] ) } ) ;
269+ } ) ;
270+ } ) ;
16271
17272describe ( "expanded tool group scrolling" , ( ) => {
18273 const entries = [ { id : "first" } , { id : "second" } ] ;
0 commit comments