fix(web): find shows its match after a thread switch, and the timeline hears scrolling through one viewport signal#25462

merged1
main
effect/split-web-timeline-viewport
opened1d agobyJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 1d ago

Problem

The chat timeline's scroll input (wheel, touch, scrollbar drag, scroll keys, scroll that chains out of an embedded frame) was heard separately by the follow hook, the position restore and MessagesTimeline. Each had its own listener effect, and a "manual navigation" callback plus a cancelPositionRestoreRef were threaded through ChatView and GuestPage. One visible result: when the list switched to a thread that already had find open, the position restore and find's reveal both scrolled the list, and the restore won. You saw the remembered row, not the match.

Change

  • New timelineViewport.ts. The list's ref callback attaches it to the scroll node. It owns the input listeners and emits one TimelineNavigation signal, and it also holds the follow state machine (transitionTimelineFollow, navigationReleasesFollow) as pure functions.
  • useTimelineFollow subscribes through onViewportAttach (replacing onManualNavigation and cancelPositionRestoreRef). Follow mode lives in a small store read with useSyncExternalStore, keyed per thread visit, so a thread switch needs no reset effect.
  • useTimelinePositionRestore becomes startTimelinePositionRestore, a plain function called from one layout effect. It stops when the viewport signals navigation.
  • Find reveals its match only after the opening restore settles.
  • Opening find now releases follow from the find store's open action (announceOpening, a navigate field on the attached find timeline), instead of an effect on findOpen.
  • MessagesTimeline re-measures content size, overflow and the remembered position from LegendList's totalSize listener and the lane's resize observer, instead of effects keyed on rows.length and chatWidth. The interrupted-run fold is now opened and closed during render.

What this improves

  • Bug fixed: "reveals find's match, not the remembered row, on a thread it switches to" (MessagesTimeline.browser.test.tsx). It fails on main: the match row ends up 4500px above the viewport.
  • Sync removed: three separate input-listener effects become one viewport attachment. The findOpen -> navigation effect, the follow and restore reset effects, the settle double-rAF effect, and the rows.length/chatWidth re-measure effects are all gone.
  • New tests: timelineViewport.test.ts covers the follow transitions and the list-opening logic. threadFindStore.test.ts checks that opening find navigates once and reopening does not.

Behavior changes

  • Opening find on the thread already shown now releases follow from the open action, not from a render that saw findOpen flip. A list that starts showing a thread whose find is already open releases follow once.
  • After a thread switch, the "settling" flag clears when the new thread's first content is laid out, not after two animation frames.
  • When follow is released, the remembered position is rewritten as "not following" right away, without waiting for a scroll event.
  • On a chat-width change, the minimap gutter is re-measured by the content lane's resize observer, not by an effect on the setting.
  • Disclosure settle frames are no longer cancelled on unmount; they only clear a flag.
  • No logging changes.

Effects

MessagesTimeline.tsx 10 -> 1, useTimelineFollow.ts 6 -> 1, useTimelinePositionRestore.ts 2 -> 0. ChatView.tsx 20 -> 20, GuestPage.tsx 7 -> 7, useThreadFindPositionReader.ts 1 -> 1. Two effects are kept on purpose:

  • the layout-effect position restore, which has to land before paint and after LegendList's own layout effects apply the new data;
  • the anchor-release scroll, which has to follow the commit that drops the anchored end space.

Verification

  • vp test run timelineViewport.test.ts threadFindStore.test.ts useTimelinePositionRestore.test.ts MessagesTimeline.browser.test.tsx: 4 files, 43 tests pass.
  • I swapped in main's MessagesTimeline.tsx, useTimelinePositionRestore.ts, threadFindStore.ts and useThreadFindPositionReader.ts: "reveals find's match…" fails (expected -4500 to be greater than or equal to 0).
  • vp run check:changed: format, lint, knip, typecheck and tests (10 files, 233 tests) all pass.

Model: Claude Opus 5.5, Claude Code harness.

🤖 Generated with Claude Code

Checks

23 successful · 13 skipped · 1 neutral
All checks passed
cd5e9f9

Added 1 commit

  • fix(web): timeline viewport emits one navigation signal, and find reveals its match after a thread switchcd5e9f9
……@@ -278,217 +349,96 @@ export function useTimelineFollow(input: TimelineFollowInput) {
462- removeListeners?.();
384+ }
385+ };
386+ const handleNavigationStable = useStableCallback(handleNavigation);
MmacroscopeappBoton line 3861d ago

🟡 Medium chat/useTimelineFollow.ts:386

When a thread switch is followed by find opening, its position-restore navigation can leave the displayed thread in follow mode, so streamed content continues scrolling and new runs continue anchoring despite find releasing follow. handleNavigationStable still uses the previous render's visit when the child layout effect fires before useStableCallback refreshes its implementation in the parent layout effect; ensure the navigation handler resolves the newly attached visit at invocation time.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/chat/useTimelineFollow.ts around line 386:
2
3When a thread switch is followed by find opening, its position-restore navigation can leave the displayed thread in follow mode, so streamed content continues scrolling and new runs continue anchoring despite find releasing follow. `handleNavigationStable` still uses the previous render's `visit` when the child layout effect fires before `useStableCallback` refreshes its implementation in the parent layout effect; ensure the navigation handler resolves the newly attached visit at invocation time.

Real. Child layout effects run before the parent's, so useStableCallback (refreshed in a parent layout effect) still pointed at the previous render's handleNavigation when the position restore fired viewport.navigate from MessagesTimeline's layout effect. Fixed at the source in d5d74f184d: useStableCallback now refreshes in useInsertionEffect, which runs before every layout effect in the commit. apps/web/src/hooks/useStableCallback.browser.test.tsx fails without the fix (second->first) and passes with it.

I can’t verify this yet: d5d74f184d isn’t available from the current branch/history. Please push or share the reachable commit, and I’ll re-check the fix.

Correction: this PR merged at cd5e9f904d before d5d74f184d reached it, so main does not have this fix yet. The commit (the useStableCallback insertion-effect change plus its failing-first test) is on git.cafe branch effect/split-web-timeline-viewport and needs a follow-up PR.

Want me to open a follow-up PR against main? Reply “fix it for me” to confirm.

Synced from GitHub

Approvability

Verdict: Needs Human Approval

Macroscope's review found this PR requires human approval — This PR substantially rewrites production timeline scrolling, live-follow state, position restoration, measurement, and find behavior across both main and guest chat surfaces. The new shared viewport abstraction and cross-cutting runtime changes exceed a self-contained bug fix and merit human review.

You can add or adjust custom eligibility rules. Learn more.

Synced from GitHub

Review in Change Stack →

No actionable comments were generated in the recent review.

External image Recent review info
External image Run configuration
External image Commits

Reviewing files that changed from the base of the PR and between 071239804e375078dd3fae3b14704b4def57e048 and cd5e9f904d034964234d2554bde4bbf5f6cc8908.

External image Files selected for processing: 11
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
  • apps/web/src/components/chat/MessagesTimeline.tsx
  • apps/web/src/components/chat/threadFindStore.test.ts
  • apps/web/src/components/chat/threadFindStore.ts
  • apps/web/src/components/chat/timelineViewport.test.ts
  • apps/web/src/components/chat/timelineViewport.ts
  • apps/web/src/components/chat/useThreadFindPositionReader.ts
  • apps/web/src/components/chat/useTimelineFollow.ts
  • apps/web/src/components/chat/useTimelinePositionRestore.ts
  • apps/web/src/components/guest/GuestPage.tsx

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

Walkthrough

The timeline now centralizes navigation events in an attached viewport and uses explicit per-visit follow state. Position restoration, find navigation, and app integrations use that viewport. The changes also update list sizing and lifecycle handling, and add tests for follow transitions and find restoration.

Changes: Timeline Follow and Positioning

Layer / File(s)Summary
Viewport navigation and follow state
apps/web/src/components/chat/timelineViewport.ts, apps/web/src/components/chat/timelineViewport.test.ts
The viewport defines navigation events and follow-release rules. Follow state tracks whether the timeline follows the end, and list-opening state records thread identity.
Visit-scoped follow and viewport wiring
apps/web/src/components/chat/useTimelineFollow.ts, apps/web/src/components/ChatView.tsx, apps/web/src/components/guest/GuestPage.tsx
useTimelineFollow tracks follow and scroll state per thread visit, responds to viewport navigation, and manages anchor positioning. Chat and guest views attach the timeline viewport and composer overlay through the hook’s callbacks.
Timeline attachment and position restoration
apps/web/src/components/chat/MessagesTimeline.tsx, apps/web/src/components/chat/useTimelinePositionRestore.ts
The timeline attaches the viewport, restores remembered positions through startTimelinePositionRestore, and updates list, content-size, and viewport measurements. Find navigation becomes ready after restoration completes.
Find navigation and restoration tests
apps/web/src/components/chat/threadFindStore.ts, apps/web/src/components/chat/useThreadFindPositionReader.ts, apps/web/src/components/chat/threadFindStore.test.ts, apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Opening find in a different thread calls that timeline’s navigation callback. Tests cover follow release, remembered-position restoration, and active find-match precedence.

Priority: External image Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Bug fix

Sequence Diagram(s)

mermaid
1sequenceDiagram
2 participant User
3 participant TimelineViewport
4 participant useTimelineFollow
5 User->>TimelineViewport: Send navigation input
6 TimelineViewport->>useTimelineFollow: Notify navigation subscriber
7 useTimelineFollow->>useTimelineFollow: Update visit follow state

Suggested labels: bugfix, size:XXL

Suggested reviewers: t3dotgg, macroscopeapp

Merge Risk: External image Minimal · up to cd5e9

The change centralizes timeline scroll handling and fixes find reveal after a thread switch. No concrete merge-blocking defect was established. One edge case, opening a thread at the top when find is open on mount, could not be confirmed or ruled out and is worth a quick manual check.

Pre-merge checks: Passed 4
Check nameStatusExplanation
Title checkPassedThe title clearly summarizes both primary changes: fixing find-match reveal after thread switches and centralizing timeline scroll signals.
Description checkPassedThe description clearly explains the problem, implementation, behavior changes, affected areas, and focused verification results. It is detailed and directly aligned with the pull request objectives.
Linked Issues checkPassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes checkPassedCheck skipped because no linked issues were found for this pull request.
Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.