@@ -11,6 +11,7 @@ import {
1111 sessionID ,
1212 setupTimeline ,
1313 status ,
14+ stepStarted ,
1415 textPart ,
1516 userMessage ,
1617 validateTimelineMessages ,
@@ -21,6 +22,221 @@ import { openSession } from "../utils/workspace"
2122test . use ( { serviceWorkers : "block" } )
2223
2324test . describe ( "timeline history" , ( ) => {
25+ test ( "reopens at the first locally unseen reply" , async ( { page } ) => {
26+ const notice = await openUnreadSession ( page )
27+
28+ await expect ( notice . getByText ( "New" , { exact : true } ) ) . toBeInViewport ( )
29+ await expect ( page . getByText ( "New paragraph 0." , { exact : true } ) ) . toBeInViewport ( )
30+ } )
31+
32+ test ( "reopens at the end when the new-messages divider is disabled" , async ( { page } ) => {
33+ const notice = await openUnreadSession ( page , { disabled : true } )
34+
35+ await expect ( page . getByText ( "New paragraph 59." , { exact : true } ) ) . toBeInViewport ( )
36+ await expect ( notice ) . toHaveCount ( 0 )
37+ } )
38+
39+ test ( "reaching the end dismisses the new-messages divider without moving the reply" , async ( { page } ) => {
40+ const notice = await openUnreadSession ( page , { paragraphs : 5 } )
41+
42+ const paragraph = page . getByText ( "New paragraph 4." , { exact : true } )
43+
44+ await expect ( notice . getByText ( "New" , { exact : true } ) ) . toBeInViewport ( )
45+ await timelineScroller ( page ) . evaluate ( ( element ) => {
46+ element . scrollTop = element . scrollHeight
47+ } )
48+ await expect ( paragraph ) . toBeInViewport ( )
49+ await expect ( notice . getByText ( "New" , { exact : true } ) ) . toBeInViewport ( )
50+ const top = await paragraph . evaluate ( ( element ) => element . getBoundingClientRect ( ) . top )
51+
52+ await timelineScroller ( page ) . dispatchEvent ( "wheel" , { deltaY : 1 } )
53+ await expect ( notice . getByText ( "New" , { exact : true } ) ) . toBeHidden ( )
54+ await expect . poll ( ( ) => paragraph . evaluate ( ( element ) => element . getBoundingClientRect ( ) . top ) ) . toBeCloseTo ( top , 0 )
55+ } )
56+
57+ test ( "returning to a cached session lands on work streamed while another tab was open" , async ( { page } ) => {
58+ const other = "ses_new_other"
59+
60+ let history = [ userMessage ( ) , assistantMessage ( [ textPart ( "prt_cached_seen" , "Read before switching" ) ] ) ]
61+
62+ const workspace = await openSession ( page , {
63+ name : "NewMessagesCached" ,
64+ sessions : [ { id : sessionID } , { id : other , directory : "C:/OpenCode/UnreadOther" } ] ,
65+ pageMessages : ( id ) => ( {
66+ items : id === other ? [ userMessage ( [ textPart ( "prt_other" , "Other session" ) ] ) ] : history ,
67+ } ) ,
68+ } )
69+
70+ await expect ( page . getByText ( "Read before switching" , { exact : true } ) ) . toBeVisible ( )
71+ await page . locator ( `[data-slot="titlebar-tabs"] a[href="${ sessionHref ( other ) } "]` ) . click ( )
72+ await expect ( page . getByText ( "Other session" , { exact : true } ) ) . toBeVisible ( )
73+
74+ const text = textPart (
75+ "prt_cached_reply" ,
76+ Array . from ( { length : 40 } , ( _ , index ) => `Cached reply ${ index } .` ) . join ( "\n\n" ) ,
77+ )
78+
79+ const reply = assistantMessage ( [ ] , { id : "msg_cached_new_reply" , completed : false , created : 1700000002000 } )
80+
81+ history = [
82+ ...history ,
83+ { ...reply , content : [ { type : "text" , text : text . text } ] , time : { ...reply . time , completed : 1700000003000 } } ,
84+ ]
85+
86+ await workspace . push ( [
87+ status ( "busy" ) ,
88+ stepStarted ( reply ) ,
89+ ...partUpdated ( {
90+ ...text ,
91+ messageID : reply . id ,
92+ } ) ,
93+ messageUpdated ( reply ) ,
94+ status ( "idle" ) ,
95+ status ( "busy" , 1 , other ) ,
96+ ] )
97+ const tab = page . locator ( `[data-slot="titlebar-tabs"] a[href="${ sessionHref ( sessionID ) } "]` )
98+
99+ // Wait for the stream to reach the active session before switching back.
100+ await expect ( page . getByRole ( "button" , { name : "Stop" , exact : true } ) ) . toBeVisible ( )
101+ await tab . click ( )
102+ await expect (
103+ page . locator ( '[data-slot="session-timeline-notice"]' ) . getByText ( "New" , { exact : true } ) ,
104+ ) . toBeInViewport ( )
105+ await expect ( page . getByText ( "Cached reply 0." , { exact : true } ) ) . toBeInViewport ( )
106+ await page . getByRole ( "button" , { name : "Jump to latest" } ) . click ( )
107+ await expect ( page . getByText ( "Cached reply 39." , { exact : true } ) ) . toBeInViewport ( )
108+ await expect ( page . locator ( '[data-timeline-row="NewMessages"]' ) ) . toHaveCount ( 0 )
109+ } )
110+
111+ test ( "marks new work above an undelivered steer" , async ( { page } ) => {
112+ const id = "ses_unread_steer"
113+
114+ let messages = [
115+ userMessage ( [ textPart ( "prt_unread_prompt" , "Read the code" ) ] , { id : "msg_1000_unread_prompt" , created : 1 } ) ,
116+ assistantMessage ( [ textPart ( "prt_unread_seen" , "Read work" ) ] , {
117+ id : "msg_1001_unread_work" ,
118+ parentID : "msg_1000_unread_prompt" ,
119+ created : 2 ,
120+ completed : false ,
121+ } ) ,
122+ ]
123+
124+ await openSession ( page , {
125+ name : "UnreadSteer" ,
126+ sessions : [ { id } ] ,
127+ sessionStatus : { [ id ] : { type : "busy" } } ,
128+ pageMessages : ( ) => ( { items : messages } ) ,
129+ inbox : [
130+ {
131+ id : "msg_1002_unread_steer" ,
132+ sessionID : id ,
133+ type : "user" ,
134+ delivery : "steer" ,
135+ payload : { text : "Waiting prompt" } ,
136+ time : { created : 3 } ,
137+ } ,
138+ ] ,
139+ } )
140+ await expect (
141+ page . locator ( '[data-timeline-row="UserMessage"]' ) . getByText ( "Waiting prompt" , { exact : true } ) ,
142+ ) . toBeVisible ( )
143+ await expect ( page . getByText ( "Read work" , { exact : true } ) ) . toBeVisible ( )
144+ await expect ( page . locator ( "[data-timeline-virtual-content]" ) ) . toHaveCSS ( "visibility" , "visible" )
145+ messages = [
146+ messages [ 0 ] ,
147+ assistantMessage (
148+ [
149+ textPart ( "prt_unread_seen" , "Read work" ) ,
150+ textPart ( "prt_unread_new" , Array . from ( { length : 60 } , ( _ , index ) => `Unseen work ${ index } .` ) . join ( "\n\n" ) ) ,
151+ ] ,
152+ { id : "msg_1001_unread_work" , parentID : "msg_1000_unread_prompt" , created : 2 , completed : false } ,
153+ ) ,
154+ ]
155+ await page . reload ( )
156+ await expect ( page . locator ( '[data-timeline-row="NewMessages"]' ) . getByText ( "New" , { exact : true } ) ) . toBeInViewport ( )
157+ await expect ( page . getByText ( "Unseen work 0." , { exact : true } ) ) . toBeInViewport ( )
158+ } )
159+
160+ test ( "lands at the true unread boundary when older unread history loads" , async ( { page } ) => {
161+ const id = "ses_unread_pages"
162+
163+ let messages : SessionMessageInfo [ ] = [
164+ userMessage ( [ textPart ( "prt_unread_pages_prompt" , "Keep working" ) ] , { id : "msg_0000_unread_pages" , created : 1 } ) ,
165+ assistantMessage ( [ textPart ( "prt_unread_pages_seen" , "Read before leaving" ) ] , {
166+ id : "msg_0001_unread_pages" ,
167+ parentID : "msg_0000_unread_pages" ,
168+ created : 2 ,
169+ } ) ,
170+ ]
171+
172+ await openSession ( page , {
173+ name : "UnreadPages" ,
174+ sessions : [ { id } ] ,
175+ pageMessages : ( _id , _limit , before ) => {
176+ const end = before ? Number ( before ) : messages . length
177+
178+ const start = Math . max ( 0 , end - 20 )
179+
180+ return { items : messages . slice ( start , end ) , cursor : start ? String ( start ) : undefined }
181+ } ,
182+ } )
183+ await expect ( page . getByText ( "Read before leaving" , { exact : true } ) ) . toBeVisible ( )
184+ await expect ( page . locator ( "[data-timeline-virtual-content]" ) ) . toHaveCSS ( "visibility" , "visible" )
185+ // The unread span exceeds the initial page and three enrichment pages.
186+ messages = [
187+ ...messages ,
188+ ...Array . from ( { length : 90 } , ( _ , index ) =>
189+ assistantMessage ( [ textPart ( `prt_unread_page_${ index } ` , `Unread response ${ index } ` ) ] , {
190+ id : `msg_${ String ( index + 2 ) . padStart ( 4 , "0" ) } _unread_pages` ,
191+ parentID : "msg_0000_unread_pages" ,
192+ created : index + 3 ,
193+ } ) ,
194+ ) ,
195+ ]
196+ await page . reload ( )
197+ const notice = page . locator ( '[data-timeline-key="new-messages:msg_0002_unread_pages:text:0"]' )
198+
199+ await expect ( notice ) . toBeInViewport ( )
200+ await expect ( page . getByText ( "Unread response 0" , { exact : true } ) ) . toBeInViewport ( )
201+ } )
202+
203+ test ( "keeps unread replies before a prompt sent from another client" , async ( { page } ) => {
204+ const id = "ses_unread_remote"
205+
206+ let messages = [
207+ userMessage ( [ textPart ( "prt_remote_prompt" , "Start working" ) ] , { id : "msg_remote_0" , created : 1 } ) ,
208+ assistantMessage ( [ textPart ( "prt_remote_seen" , "Already read" ) ] , { id : "msg_remote_1" , created : 2 } ) ,
209+ ]
210+
211+ const view = await openSession ( page , {
212+ name : "UnreadRemote" ,
213+ sessions : [ { id } ] ,
214+ pageMessages : ( ) => ( { items : messages } ) ,
215+ } )
216+
217+ await expect ( page . getByText ( "Already read" , { exact : true } ) ) . toBeVisible ( )
218+ await expect ( page . locator ( "[data-timeline-virtual-content]" ) ) . toHaveCSS ( "visibility" , "visible" )
219+ messages = [
220+ ...messages ,
221+ assistantMessage ( [ textPart ( "prt_remote_unread" , "Unread before the other client's prompt" ) ] , {
222+ id : "msg_remote_2" ,
223+ created : 3 ,
224+ } ) ,
225+ userMessage ( [ textPart ( "prt_remote_other" , "Sent from another client" ) ] , { id : "msg_remote_3" , created : 4 } ) ,
226+ assistantMessage (
227+ [ textPart ( "prt_remote_reply" , Array . from ( { length : 40 } , ( _ , index ) => `Remote reply ${ index } .` ) . join ( "\n\n" ) ) ] ,
228+ { id : "msg_remote_4" , created : 5 } ,
229+ ) ,
230+ ]
231+ await page . reload ( )
232+ await expect ( page . locator ( '[data-timeline-key="new-messages:msg_remote_2:text:0"]' ) ) . toBeInViewport ( )
233+ await expect ( page . getByText ( "Unread before the other client's prompt" , { exact : true } ) ) . toBeInViewport ( )
234+ await view . editor . fill ( "Continue from this client" )
235+ await view . editor . press ( "Enter" )
236+ await expect ( page . getByText ( "Remote reply 39." , { exact : true } ) ) . toBeInViewport ( )
237+ await expect ( page . locator ( '[data-timeline-row="NewMessages"]' ) ) . toHaveCount ( 0 )
238+ } )
239+
24240 test ( "restoring a paged transcript waits for another upward scroll before loading history" , async ( { page } ) => {
25241 const id = "ses_history_restore"
26242 const other = "ses_history_other"
@@ -510,6 +726,40 @@ declare global {
510726 }
511727}
512728
729+ async function openUnreadSession ( page : Page , input : { disabled ?: boolean ; paragraphs ?: number } = { } ) {
730+ const id = "ses_new_messages"
731+
732+ let messages = [
733+ userMessage ( [ textPart ( "prt_new_prompt" , "Explain the implementation" ) ] , { id : "msg_new_prompt" , created : 1 } ) ,
734+ assistantMessage ( [ textPart ( "prt_seen" , "Already read" ) ] , { id : "msg_seen" , created : 2 } ) ,
735+ ]
736+
737+ await openSession ( page , {
738+ name : "NewMessages" ,
739+ sessions : [ { id } ] ,
740+ pageMessages : ( ) => ( { items : messages } ) ,
741+ seed : input . disabled ? { settings : { general : { newMessagesDivider : false } } } : undefined ,
742+ } )
743+ await expect ( page . getByText ( "Already read" , { exact : true } ) ) . toBeVisible ( )
744+ await expect ( page . locator ( "[data-timeline-virtual-content]" ) ) . toHaveCSS ( "visibility" , "visible" )
745+ messages = [
746+ ...messages ,
747+ assistantMessage (
748+ [
749+ textPart (
750+ "prt_new_reply" ,
751+ Array . from ( { length : input . paragraphs ?? 60 } , ( _ , index ) => `New paragraph ${ index } .` ) . join ( "\n\n" ) ,
752+ ) ,
753+ ] ,
754+ { id : "msg_new_reply" , created : 3 } ,
755+ ) ,
756+ ]
757+ await page . reload ( )
758+ await expectSessionTitle ( page , id )
759+
760+ return page . locator ( '[data-timeline-row="NewMessages"]' )
761+ }
762+
513763async function openStressTarget ( page : Page , input : Parameters < typeof mockStressTimeline > [ 1 ] = { } ) {
514764 await mockStressTimeline ( page , input )
515765 await installTimelineSettings ( page )
0 commit comments