Skip to content

Commit 4c8b83f

Browse files
authored
fix(app): drive timeline follow mode with an explicit state machine (#61588)
1 parent d356b9b commit 4c8b83f

9 files changed

Lines changed: 594 additions & 177 deletions

File tree

‎packages/app/component-tests/timeline-virtualizer.fixture.tsx‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { onCleanup } from "solid-js"
33
import { createStore } from "solid-js/store"
44
import { render } from "solid-js/web"
55
import { LanguageProvider } from "../src/runtime/i18n/language"
6+
import { createTimelineFollow } from "../src/session/timeline/follow"
67
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"
78

89
export function mountTimelineVirtualizer(input: {
@@ -19,7 +20,8 @@ export function mountTimelineVirtualizer(input: {
1920
document.body.appendChild(host)
2021

2122
function Fixture() {
22-
const [state, setState] = createStore({ pinned: true, ready: false, grown: false })
23+
const [state, setState] = createStore({ ready: false, grown: false })
24+
const follow = createTimelineFollow(() => "cold-reveal-fixture")
2325

2426
const rows = Array.from(
2527
{ length: input.count },
@@ -44,7 +46,7 @@ export function mountTimelineVirtualizer(input: {
4446
messageLastRowIndex: () => indexes,
4547
},
4648
showHeader: () => false,
47-
pinned: () => state.pinned,
49+
follow,
4850
scroll: () => ({ overflow: false, jump: false }),
4951
setScrollRef: (element) => {
5052
if (!element) return
@@ -56,14 +58,11 @@ export function mountTimelineVirtualizer(input: {
5658
content = host.querySelector<HTMLDivElement>("[data-timeline-virtual-content]")!
5759
reveal.observe(content, { attributes: true, attributeFilter: ["style"] })
5860
},
59-
onPin: () => setState("pinned", true),
60-
onUnpin: () => setState("pinned", false),
6161
onScheduleScrollState: (element) => {
6262
host.dataset.scrolls = String(Number(host.dataset.scrolls) + 1)
6363
host.dataset.lastScrollTop = String(element.scrollTop)
6464
},
6565
onResumeScroll: () => {},
66-
onSelectionInteraction: () => {},
6766
onUserScroll: () => {},
6867
onHistoryScroll: () => {},
6968
canRenderImmediately: () => input.immediate ?? false,
@@ -100,7 +99,7 @@ export function mountTimelineVirtualizer(input: {
10099
})
101100

102101
return (
103-
<div data-testid="timeline-controls" data-pinned={state.pinned}>
102+
<div data-testid="timeline-controls" data-pinned={follow.following()}>
104103
<button type="button" onClick={() => setState("ready", true)}>
105104
Complete Markdown
106105
</button>

‎packages/app/src/session/screen.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ function SessionScreenContent(props: {
131131
sessionID: session.identity.sessionID,
132132
scrollRef: timeline.scroller,
133133
revealMessage: timeline.actions.revealMessage,
134-
pauseAutoScroll: timeline.view.unpin,
134+
pauseAutoScroll: () => timeline.follow.dispatch({ type: "detach" }),
135135
})
136136

137137
const messagesReady = timeline.ready
@@ -312,12 +312,9 @@ function SessionScreenContent(props: {
312312
onResumeScroll={timeline.actions.resume}
313313
setScrollRef={timeline.view.setScrollRef}
314314
onScheduleScrollState={timeline.view.scheduleScrollState}
315-
onPin={timeline.view.pin}
316-
onUnpin={timeline.view.unpin}
315+
follow={timeline.follow}
317316
onUserScroll={timeline.view.markUserScroll}
318317
onHistoryScroll={timeline.view.onHistoryScroll}
319-
onSelectionInteraction={timeline.view.selectionInteraction}
320-
pinned={timeline.view.pinned()}
321318
centered={screen.centered()}
322319
reserveReviewToggle={!sideVisible()}
323320
onContentResize={timeline.view.contentResized}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { createMemo, createSignal, untrack, type Accessor } from "solid-js"
2+
import { createFollowState, followAnchor, isFollowing, transitionFollow, type FollowEvent } from "./machine"
3+
4+
// Each session gets a fresh machine. Reading the state recomputes it for a changed key, so a view that mounts for the
5+
// new session starts out following, whatever order the flush runs its computations in.
6+
export function createTimelineFollow(sessionKey: Accessor<string>) {
7+
const machine = createMemo(() => {
8+
sessionKey()
9+
10+
return createSignal(createFollowState())
11+
})
12+
13+
const state = () => machine()[0]()
14+
const peek = () => untrack(state)
15+
const following = createMemo(() => isFollowing(state()))
16+
const anchor = createMemo(() => followAnchor(state()))
17+
let attempts = 0
18+
19+
// The signal skips an unchanged state, which the transition returns as the same object.
20+
const dispatch = (event: FollowEvent) => {
21+
untrack(machine)[1](transitionFollow(peek(), event))
22+
}
23+
24+
return {
25+
state,
26+
peek,
27+
following,
28+
anchor,
29+
dispatch,
30+
/** Queues a return to the end and returns its attempt, which the frame that scrolls hands back in `return.run`. */
31+
beginReturn: () => {
32+
attempts += 1
33+
dispatch({ type: "return.start", attempt: attempts })
34+
35+
return attempts
36+
},
37+
}
38+
}
39+
40+
export type TimelineFollow = ReturnType<typeof createTimelineFollow>

‎packages/app/src/session/timeline/interaction.ts‎

Lines changed: 24 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { frameLoop } from "@opencode/ui/motion"
77
import { useLayout } from "@/shell/state/layout"
88
import type { SessionModel } from "../model"
99
import { useSessionHashScroll } from "../use-session-hash-scroll"
10+
import { createTimelineFollow } from "./follow"
1011
import { createTimelineModel } from "./model"
1112

1213
export 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

Comments
 (0)