@@ -7,6 +7,7 @@ import { frameLoop } from "@opencode/ui/motion"
77import { useLayout } from "@/shell/state/layout"
88import type { SessionModel } from "../model"
99import { useSessionHashScroll } from "../use-session-hash-scroll"
10+ import { createTimelineFollow } from "./follow"
1011import { createTimelineModel } from "./model"
1112
1213export function createSessionTimelineInteraction ( session : SessionModel ) {
@@ -18,7 +19,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
1819 messageID : string | undefined
1920 pendingMessage : string | undefined
2021 scroll : { overflow : boolean ; jump : boolean }
21- follow : { sessionKey : string ; pinned : boolean }
2222 /** The composer region's height; the timeline scrolls on underneath it and fades out at its top. */
2323 inset : number
2424 refs : {
@@ -33,10 +33,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
3333 overflow : false ,
3434 jump : false ,
3535 } ,
36- follow : {
37- sessionKey : session . identity . sessionKey ( ) ,
38- pinned : true ,
39- } ,
4036 inset : 0 ,
4137 refs : {
4238 scroller : undefined ,
@@ -45,15 +41,10 @@ export function createSessionTimelineInteraction(session: SessionModel) {
4541 } ,
4642 } )
4743
48- // The single source of truth for "follow the newest content". The virtualizer pins and unpins
49- // it from scroll geometry; everything else only expresses explicit intent.
50- const pinned = ( ) => state . follow . sessionKey !== session . identity . sessionKey ( ) || state . follow . pinned
51- const pin = ( ) => setState ( "follow" , { sessionKey : session . identity . sessionKey ( ) , pinned : true } )
52-
53- const unpin = ( ) => {
54- if ( ! scroller || scroller . scrollHeight - scroller . clientHeight <= 1 ) return
55- setState ( "follow" , { sessionKey : session . identity . sessionKey ( ) , pinned : false } )
56- }
44+ // The single source of truth for "follow the newest content". The virtualizer feeds it scroll geometry and the
45+ // reader's input; everything else only expresses explicit intent.
46+ const follow = createTimelineFollow ( session . identity . sessionKey )
47+ const detach = ( ) => follow . dispatch ( { type : "detach" } )
5748
5849 let scroller : HTMLDivElement | undefined
5950 // The history entry whose tool call was already revealed.
@@ -163,9 +154,9 @@ export function createSessionTimelineInteraction(session: SessionModel) {
163154 setPendingMessage : ( value ) => setState ( "pendingMessage" , value ) ,
164155 setActiveMessage,
165156 follow : {
166- unpin ,
157+ detach ,
167158 toBottom : ( ) => {
168- pin ( )
159+ follow . dispatch ( { type : "follow" } )
169160 scrollToEnd ( )
170161 } ,
171162 } ,
@@ -180,13 +171,17 @@ export function createSessionTimelineInteraction(session: SessionModel) {
180171
181172 const resume = ( behavior ?: ScrollBehavior ) => {
182173 setState ( "messageID" , undefined )
183- pin ( )
174+ const attempt = follow . beginReturn ( )
184175 // Scrolls on the next frame, before it paints. A submit resumes from its key handler right after changing the
185- // DOM, where reading the scroll range would force a layout that the frame then repeats.
176+ // DOM, where reading the scroll range would force a layout that the frame then repeats. Input that stops
177+ // following before then makes the attempt stale, and the machine drops it.
186178 resumeFrame ?.( )
187179 resumeFrame = frameLoop . add ( ( ) => {
188180 resumeFrame = undefined
189- scrollToEnd ( behavior , true )
181+ const before = follow . peek ( )
182+ follow . dispatch ( { type : "return.run" , attempt } )
183+
184+ if ( follow . peek ( ) !== before ) scrollToEnd ( behavior , true )
190185
191186 return false
192187 } )
@@ -211,7 +206,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
211206 return
212207 }
213208
214- unpin ( )
209+ detach ( )
215210 scrollToMessage ( messages [ target ] , "auto" )
216211 }
217212
@@ -225,12 +220,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
225220 scrollMark += 1
226221 }
227222
228- const selectionInteraction = ( ) => {
229- const selection = window . getSelection ( )
230-
231- if ( selection && selection . toString ( ) . length > 0 ) unpin ( )
232- }
233-
234223 const setScrollRef = ( element : HTMLDivElement | undefined ) => {
235224 scroller = element
236225 setState ( "refs" , "scroller" , element )
@@ -255,7 +244,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
255244
256245 if ( ! owner . current ( ) || timeline . messages ( ) . length <= before ) return
257246
258- if ( pinned ( ) || ! scroller || scroller . scrollTop >= 200 || ! timeline . history . more ( ) ) return
247+ if ( follow . following ( ) || ! scroller || scroller . scrollTop >= 200 || ! timeline . history . more ( ) ) return
259248
260249 if ( historyContinuationFrame !== undefined ) cancelAnimationFrame ( historyContinuationFrame )
261250 historyContinuationFrame = requestAnimationFrame ( ( ) => {
@@ -268,7 +257,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
268257 if (
269258 historyRequests . has ( session . ownership . key ( ) ) ||
270259 timeline . history . loading ( ) ||
271- pinned ( ) ||
260+ follow . following ( ) ||
272261 ! scroller ||
273262 scroller . scrollTop >= 200
274263 )
@@ -285,7 +274,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
285274 short =
286275 ! ! session . identity . params . id &&
287276 timeline . ready ( ) &&
288- pinned ( ) &&
277+ follow . following ( ) &&
289278 ! timeline . history . loading ( ) &&
290279 ! ! scroller &&
291280 scroller . scrollHeight <= scroller . clientHeight + 1
@@ -313,7 +302,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
313302 on (
314303 session . identity . sessionKey ,
315304 ( ) => {
316- pin ( )
317305 setState ( "messageID" , undefined )
318306 setState ( "pendingMessage" , undefined )
319307 setState ( "scroll" , { overflow : false , jump : false } )
@@ -326,14 +314,14 @@ export function createSessionTimelineInteraction(session: SessionModel) {
326314 ( ) => session . identity . params . id ,
327315 ( id , previous ) => {
328316 if ( ! id || ! previous || id === previous || state . messageID || state . pendingMessage || location . hash ) return
329- pin ( )
317+ follow . dispatch ( { type : "follow" } )
330318 scrollToEnd ( )
331319 } ,
332320 ) ,
333321 )
334322 createEffect (
335323 on (
336- pinned ,
324+ follow . following ,
337325 ( value ) => {
338326 if ( ! value ) return
339327 setState ( "messageID" , undefined )
@@ -350,7 +338,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
350338 timeline . ready ( ) ,
351339 timeline . history . more ( ) ,
352340 timeline . history . loading ( ) ,
353- pinned ( ) ,
341+ follow . following ( ) ,
354342 visibleUserMessages ( ) . length ,
355343 ] as const ,
356344 ( [ id , ready , more , loading , following ] ) => {
@@ -371,7 +359,8 @@ export function createSessionTimelineInteraction(session: SessionModel) {
371359
372360 // Measured before the new inset applies: a shrinking region also keeps a reader whose end it uncovers.
373361 const stick = scroller
374- ? pinned ( ) || scroller . scrollHeight - scroller . clientHeight - scroller . scrollTop < 10 + Math . max ( 0 , - delta )
362+ ? follow . following ( ) ||
363+ scroller . scrollHeight - scroller . clientHeight - scroller . scrollTop < 10 + Math . max ( 0 , - delta )
375364 : false
376365
377366 setState ( "inset" , next )
@@ -409,13 +398,11 @@ export function createSessionTimelineInteraction(session: SessionModel) {
409398 scroller : ( ) => state . refs . scroller ,
410399 /** The part of the timeline not covered by the composer region. */
411400 visible : ( ) => state . refs . visible ,
401+ follow,
412402 view : {
413403 anchor,
414404 markUserScroll,
415405 onHistoryScroll,
416- pin,
417- pinned,
418- selectionInteraction,
419406 scheduleScrollState,
420407 contentResized : ( ) => {
421408 if ( scroller ) scheduleScrollState ( scroller )
@@ -447,7 +434,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
447434 setPage : ( scroll : ( direction : 1 | - 1 ) => void ) => {
448435 page = scroll
449436 } ,
450- unpin,
451437 } ,
452438 }
453439}
0 commit comments