Skip to content

fix(ui): stabilize virtual follow autoscroll#406

Open
pascalandr wants to merge 10 commits into
NeuralNomadsAI:devfrom
Pagecran:autoscroll-state-machine
Open

fix(ui): stabilize virtual follow autoscroll#406
pascalandr wants to merge 10 commits into
NeuralNomadsAI:devfrom
Pagecran:autoscroll-state-machine

Conversation

@pascalandr
Copy link
Copy Markdown
Contributor

@pascalandr pascalandr commented May 8, 2026

Summary

  • Stabilize virtualized message autoscroll by keeping DOM/Virtua orchestration in VirtualFollowList and moving pure follow/hold behavior into a colocated tested module.
  • Restore per-session scroll positions using Virtua metrics and anchor snapshots while preventing hidden/inactive panes from overwriting good snapshots.
  • Gate streaming magnetization on active streaming, hold state, and the bottom-of-viewport item so long assistant replies can still rejoin near their tail without using stale top-of-viewport position.

Supersedes / fixes

Validation

  • node --test packages/ui/src/components/virtual-follow-behavior.test.ts
  • npm run typecheck --workspace @codenomad/ui
  • npm run build --workspace @codenomad/tauri-app
  • Many manual release executable testing.

Keep DOM and Virtua timing in VirtualFollowList while moving pure follow and hold transitions into a colocated behavior module with focused tests. The integration restores hidden session scroll snapshots safely, gates streaming magnetization on active streaming and hold state, and preserves explicit latest/submit bottom jumps without reintroducing fast-down heuristics.

Use Virtua offsets for scroll snapshots and decide streaming rejoin from the item at the bottom of the viewport so long assistant messages can still magnetize near their tail without using stale top-of-viewport position. Remove the unused generic scroll cache hook now that message scroll restore is handled by the virtual follow integration.

Validation: node --test packages/ui/src/components/virtual-follow-behavior.test.ts, npm run typecheck --workspace @codenomad/ui, npm run build --workspace @codenomad/tauri-app, and manual release-exe testing.
@pascalandr
Copy link
Copy Markdown
Contributor Author

@CodeNomadBot

I want you to perform the gatekeeper checks for latest PR state

check for regressions
check for better possible implementations
Be the PR gatekeeper
Be ruthless about code quality

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot Bot commented May 8, 2026

Understood request: gatekeeper review of the latest PR #406 state, focused on regressions, implementation quality, and whether this should be accepted as-is. Assumption: review only; I did not make code changes.

Gatekeeper result: not ready as-is.

Findings:

  1. High: session scroll snapshots can be written to the wrong session during session switches.

packages/ui/src/components/message-section.tsx:660-666 updates the module-local lastGoodScrollSnapshot when props.sessionId changes. A later effect cleanup persists the previous session id at packages/ui/src/components/message-section.tsx:729-733, and persistMessageScrollSnapshot() falls back to lastGoodScrollSnapshot at packages/ui/src/components/message-section.tsx:711-713 when it cannot safely capture. Because these effects are registered in this order, a session change can refresh lastGoodScrollSnapshot for the new session before the old-session cleanup persists, causing the new session’s snapshot to be stored under the old session id whenever capture is blocked or unavailable. That directly undermines the PR’s stated goal of preserving per-session scroll positions and avoiding hidden/inactive panes overwriting good snapshots.

A safer implementation would pass the old session’s last-good snapshot into the cleanup, or store lastGoodScrollSnapshot keyed by session id instead of as a single mutable variable.

  1. Medium: the new streaming rejoin logic is effectively disabled for the default hold preference.

packages/ui/src/components/virtual-follow-list.tsx:393-405 has the new “near tail” rejoin gate, but it immediately returns false when autoPinHoldEnabled() is true at line 395. In MessageSection, that prop is wired to holdLongAssistantRepliesEnabled at packages/ui/src/components/message-section.tsx:1313-1314, which defaults to true. That means the PR’s advertised behavior, “long assistant replies can still rejoin near their tail,” does not happen in the default configuration unless the user reaches exact bottom. The more precise gate should be the active hold state/suspension state, not the global preference being enabled.

This needs an integration-level test because the pure controller tests do not cover the canRejoinFollowFromDownScroll() wrapper that currently blocks the feature.

  1. Low: changed file exceeds the project’s stated refactor threshold.

packages/ui/src/components/virtual-follow-list.tsx is now about 923 lines. Per AGENTS.md, this should be highlighted when touched. I would not block solely on this, but this file now mixes controller wiring, snapshot capture/restore, DOM scroll orchestration, intent listeners, hold alignment, and controls. If this area keeps changing, the next step should be extracting snapshot restore/capture or DOM intent handling into focused helpers.

Validation run:

  • node --test packages/ui/src/components/virtual-follow-behavior.test.ts passed: 26/26.
  • npm run typecheck --workspace @codenomad/ui could not validate this checkout because dependencies are missing in the local environment (solid-js, Tauri plugins, SDK packages, etc.), producing broad module-resolution failures unrelated to the reviewed diff.

I would request changes before merging, specifically for findings 1 and 2.

--
Yours,
CodeNomadBot

Keep last-good scroll snapshots keyed by session so cleanup during session switches cannot write another session's snapshot under the previous session id. Also allow streaming rejoin while the hold preference is enabled when no hold target is actively suspending bottom pinning.

Validation: node --test packages/ui/src/components/virtual-follow-behavior.test.ts and npm run typecheck --workspace @codenomad/ui.
@pascalandr
Copy link
Copy Markdown
Contributor Author

Addressed in ba05b8e0:

  • Fixed the session-switch snapshot risk by storing lastGoodScrollSnapshot per session id instead of using one mutable variable shared across sessions. Cleanup for an old session can no longer fall back to a snapshot loaded for the new session.
  • Removed the global autoPinHoldEnabled rejoin block. Streaming rejoin is now blocked by the actual active/suspended hold state, not merely by the Hold preference being enabled.

Validation rerun:

  • node --test packages/ui/src/components/virtual-follow-behavior.test.ts
  • npm run typecheck --workspace @codenomad/ui

The file-size note is acknowledged; VirtualFollowList is oversized and should be split in a later refactor, but I kept this PR focused on the behavior fix.

@pascalandr
Copy link
Copy Markdown
Contributor Author

@CodeNomadBot

same review again

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/25557064452

Artifacts expire in 7 days.
Artifacts: (none found on this run)

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot Bot commented May 8, 2026

Understood request: repeat the same gatekeeper review on the latest PR state after ba05b8e0. Assumption: review only; I did not make code changes.

Gatekeeper result: no blocking findings in this pass.

Findings:

None found that I would block merge on after the ba05b8e0 fixes.

Prior findings check:

  • The session snapshot fallback issue is addressed. lastGoodScrollSnapshots is now keyed by session id in packages/ui/src/components/message-section.tsx:657-667, and persistence now falls back via getLastGoodScrollSnapshot(sessionId) at packages/ui/src/components/message-section.tsx:721-724. This removes the single mutable cross-session fallback that could write a new session snapshot under an old session id.
  • The default hold preference no longer disables streaming rejoin. autoPinHoldEnabled was removed from VirtualFollowList, and canRejoinFollowFromDownScroll() now gates on actual active/suspended hold state at packages/ui/src/components/virtual-follow-list.tsx:391-402.

Residual risks / non-blocking notes:

  • The integration boundary is still under-tested. The pure controller test suite is good, but the wrapper logic around canRejoinFollowFromDownScroll(), session snapshot capture/restore, hidden panes, and Virtua metrics remains mostly untested at component level. Given the complexity of this behavior, I would strongly prefer follow-up component/integration coverage, but I am not blocking this fix solely on that.
  • Oversized touched files remain: packages/ui/src/components/message-section.tsx is about 1,726 lines and packages/ui/src/components/virtual-follow-list.tsx is about 920 lines. Per AGENTS.md, these should be highlighted. I agree with keeping this PR focused, but this area is now large enough that future behavior changes should extract snapshot persistence/restore and DOM scroll orchestration into smaller units.

Validation run:

  • node --test packages/ui/src/components/virtual-follow-behavior.test.ts passed: 26/26.
  • npm run typecheck --workspace @codenomad/ui still could not validate in this local checkout because dependencies/modules are missing (solid-js, Tauri plugins, SDK packages, etc.), causing broad module-resolution failures unrelated to the specific diff.

Recommendation: acceptable to proceed if CI typecheck/build is green in the proper dependency environment and the manual scroll scenarios from the PR description were rerun after ba05b8e0.

--
Yours,
CodeNomadBot

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/25557064452

Artifacts expire in 7 days.
Artifacts: (none found on this run)

1 similar comment
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/25557064452

Artifacts expire in 7 days.
Artifacts: (none found on this run)

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 9, 2026

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/25597432431

Artifacts expire in 7 days.
Artifacts: (none found on this run)

# Conflicts:
#	packages/ui/src/components/session/session-view.tsx
@shantur
Copy link
Copy Markdown
Collaborator

shantur commented May 16, 2026

@pascalandr - Are we good with this implementation?

@pascalandr
Copy link
Copy Markdown
Contributor Author

Yes

@shantur
Copy link
Copy Markdown
Collaborator

shantur commented May 16, 2026

@CodeNomadBot

I want you to perform the gatekeeper checks for latest PR state

check for regressions
check for better possible implementations
Be the PR gatekeeper
Be ruthless about code quality

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot Bot commented May 16, 2026

Understood request: fresh gatekeeper review of the latest PR #406 state after the current dev merge (132b902), checking for regressions and better implementation options. Assumption: review only; I did not make code changes.

Gatekeeper result: no blocking findings in this pass.

Findings:

None found that I would block merge on in the current diff.

What I checked:

  • Re-reviewed the current dev...HEAD diff after the latest merge. The PR still touches the virtual follow behavior/controller, VirtualFollowList, message scroll persistence/restore, submit-time bottom pinning in SessionView, and the step-finish render notification.
  • Rechecked the prior issues. Session scroll snapshots are still keyed per session id, and the streaming rejoin gate is still based on actual active/suspended hold state rather than the global Hold preference.
  • Looked for regressions from the newer dev merge around session preview rendering and the no-session draft MessageSection usage. I did not find a blocker there.

Validation run:

  • node --test packages/ui/src/components/virtual-follow-behavior.test.ts passed: 26/26.
  • npm run typecheck --workspace @codenomad/ui still cannot provide a useful signal in this local checkout because dependency/type modules are missing (solid-js, SDK packages, Tauri plugins, Node test types, etc.), producing broad module-resolution and cascading type failures outside this PR’s specific changes.

Non-blocking quality notes:

  • The integration boundary remains the biggest residual risk. The pure state-machine tests are solid, but component-level behavior involving Virtua metrics, hidden cached panes, preview toggles, snapshot restore, and submit-time repinning still depends mostly on manual validation. If this code changes again, I would want component/integration coverage before adding more behavior.
  • Oversized touched files remain above the project’s warning thresholds: packages/ui/src/components/message-section.tsx is about 1,747 lines and packages/ui/src/components/virtual-follow-list.tsx is about 920 lines. This should be a planned follow-up refactor, not bundled into this behavior fix.

Recommendation: acceptable to proceed if CI typecheck/build passes in the proper dependency environment and the manual release scroll scenarios were rerun after the latest dev merge.

--
Yours,
CodeNomadBot

@shantur
Copy link
Copy Markdown
Collaborator

shantur commented May 16, 2026

@CodeNomadBot

  1. Does the PR introduce more work per animation frame for fast streaming chats?
  2. Do you think the implementation could be simplified.

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot Bot commented May 16, 2026

Understood request: answer the two focused review questions against the latest PR state. Assumption: analysis only; I did not make code changes.

  1. Does this introduce more work per animation frame for fast streaming chats?

Yes. The PR does add more work on content-render notifications, and in fast streaming that can translate into more per-frame work.

The hot path is MessageSection.handleContentRendered() -> VirtualFollowList.notifyContentRendered():

  • packages/ui/src/components/message-section.tsx:950 forwards every content-render notification to the list.
  • packages/ui/src/components/virtual-follow-list.tsx:747-760 now runs follow-state reconciliation, hold checks, possible bottom pinning, and updateScrollButtons() on each notification.
  • updateScrollButtons() at packages/ui/src/components/virtual-follow-list.tsx:564-584 reads Virtua metrics and runs controller observation.
  • If following, content-grew can trigger performScrollToBottom() and pinDomBottomAfterLayout() at packages/ui/src/components/virtual-follow-list.tsx:587-615, which can do immediate scroll work plus up to two additional RAF passes.
  • The PR also added StepCard finish notifications at packages/ui/src/components/message-block.tsx:1322-1326, but that is turn-bound rather than token-bound, so it is not the main fast-streaming concern.

This is not necessarily catastrophic because some upstream renderers already coalesce before calling onContentRendered (for example reasoning output uses a pending RAF at packages/ui/src/components/message-block.tsx:1525-1533). But the list itself does not coalesce notifyContentRendered(), so multiple sources in the same frame can still repeat DOM/Virtua reads and pin attempts. I would call this a real performance risk for very fast streams or tool output bursts, not a proven blocker without profiling.

The first improvement I would ask for if we want to harden performance is to coalesce VirtualFollowList.notifyContentRendered() itself into one pending RAF per list. That keeps the controller behavior but prevents duplicate same-frame metric reads, hold geometry checks, updateScrollButtons(), and bottom-pin attempts.

  1. Could the implementation be simplified?

Yes. The current implementation is doing the right kind of state separation by moving pure follow behavior into virtual-follow-behavior.ts, but VirtualFollowList has become an orchestration hub for too many concerns: scroll intent tracking, Virtua metrics, DOM snapshots, anchor restore retries, hold alignment, button state, bottom pin retries, and controller dispatch.

Best simplification options, in order of impact:

  • Coalesce content-render work in one place. Add a single pending-RAF gate around notifyContentRendered() inside VirtualFollowList; this both simplifies callers and reduces performance risk.
  • Separate snapshot restore/capture from follow behavior. The snapshot code around captureScrollSnapshot(), restoreScrollSnapshot(), retryAnchorRestore(), and pixel/anchor fallback is large enough to extract into a small local helper/module without changing behavior.
  • Reduce duplicate bottom-pin triggers. There are multiple paths that can pin bottom: submit scheduling in SessionView, item-length effects in VirtualFollowList, follow-token effects, content-render notifications, and bottom restore. Some are needed, but the current overlap makes behavior harder to reason about.
  • Keep the controller pure and integration tests closer to the wrapper. The pure tests are useful, but the risky logic now lives in wrapper decisions like canRejoinFollowFromDownScroll(), active/hidden-pane capture gating, and Virtua metric use.

My gatekeeper position: I would not block this PR solely on performance/simplification if manual fast-stream testing was good and CI is green, because it fixes real scroll correctness problems. But I would strongly prefer at least the notifyContentRendered() RAF coalescing before merge if fast streaming performance is a release concern. It is a small, targeted change that lowers risk without a broad refactor.

--
Yours,
CodeNomadBot

@shantur
Copy link
Copy Markdown
Collaborator

shantur commented May 16, 2026

@CodeNomadBot

How about onCreateEffect or any other effects?

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot Bot commented May 16, 2026

Understood request: assess the PR’s Solid effects specifically, including createEffect/on(...), for extra work and simplification opportunities. Assumption: analysis only; I did not make code changes.

Short answer: most added effects are event/state-transition effects and are not frame-hot by themselves, but there are two places I would tighten.

Effect review:

  • VirtualFollowList effects at packages/ui/src/components/virtual-follow-list.tsx:773-841 are mostly fine from a frame-cost perspective. They are keyed to resetKey, holdTargetKey, items().length, followToken, and isActive, so they should not run on every streamed token unless one of those inputs changes. The items().length effect only reacts to new message ids, not every text delta.
  • SessionView’s added message-count effect at packages/ui/src/components/session/session-view.tsx:210-223 is also not frame-hot in the normal case. It exits unless pendingSubmitBottomScrollTargetCount() is set, so it is scoped to the submit path.
  • The restore/persist effects in MessageSection around packages/ui/src/components/message-section.tsx:672-839 are mostly lifecycle/session/active-state work. They are not the main fast-streaming risk.
  • The StepCard finish effect at packages/ui/src/components/message-block.tsx:1322-1326 can be simplified. It calls usageStats() inside a broad createEffect, so it tracks whatever usageStats() reads. It is turn-bound rather than token-bound, but I would still prefer an explicit on(...) keyed to the finish/usage identity, or avoid an effect and notify from a render/mount path that cannot repeatedly fire for equivalent usage data.

The one effect-adjacent concern I would call out is actually a memo, not a createEffect:

  • streamingAssistantTextMessageId at packages/ui/src/components/message-section.tsx:753-760 scans messageIds() backward and calls isStreamingAssistantTextMessage().
  • isStreamingAssistantTextMessage() at packages/ui/src/components/message-section.tsx:774-790 reads the message record, message info, builds display data, and checks text parts.
  • Depending on the store’s fine-grained subscriptions, this can rerun during streaming message updates. In the happy path it scans from the tail and returns quickly, but it still may rebuild display data for the streaming assistant message on token/part changes.

I would not call that a blocker, but if the question is “where could effects/memos be simplified for fast streaming,” this is my top candidate after notifyContentRendered() coalescing. Possible simplification: derive the hold target from already-known session status plus the latest assistant/message id metadata, and avoid calling buildRecordDisplayData() just to prove there is text on every reactive pass. A cheaper predicate like “assistant + streaming + has a text part id/data” would be enough for the hold target.

Simplification recommendations:

  • Coalesce VirtualFollowList.notifyContentRendered() into one pending RAF. This remains the biggest performance win because it caps the expensive follow/measure/pin path regardless of how many effects/renderers notify.
  • Change StepCard’s finish notification effect to an explicit on(...) or a guarded one-shot per part/revision so it cannot re-notify due to unrelated reactive reads.
  • Make streamingAssistantTextMessageId cheaper by avoiding buildRecordDisplayData() in the predicate.
  • Combine the two resetKey effects in VirtualFollowList (packages/ui/src/components/virtual-follow-list.tsx:773-778 and 823-828) if you want local simplification. They are not a performance problem, but splitting reset bookkeeping and controller reset makes the state machine harder to audit.

Gatekeeper position: I still would not block solely on these effects if manual fast-stream testing is good, but I would strongly prefer the notifyContentRendered() RAF gate before merge. If you want one more small cleanup beyond that, make streamingAssistantTextMessageId cheaper; that is more relevant to streaming than most of the createEffect usage.

--
Yours,
CodeNomadBot

@shantur
Copy link
Copy Markdown
Collaborator

shantur commented May 16, 2026

Hey @pascalandr

That's a lot of work for just fixing leaving autoscroll when trying to scroll up.

@pascalandr
Copy link
Copy Markdown
Contributor Author

pascalandr commented May 16, 2026

@shantur Lol that's not the only point, check summary.
But the main reason for the larger change is that smaller patches kept fixing one path while regressing another:

  • user scroll-up should reliably exit follow
  • submit/new message should still force bottom
  • switching sessions should not overwrite good scroll snapshots from hidden panes
  • long streaming replies should be able to rejoin near the tail without stale top-of-viewport checks
  • hold mode should not globally block streaming rejoin unless a hold is actually active

Do you want me to make the improvements recommended by CodenomadBot now ?

@shantur
Copy link
Copy Markdown
Collaborator

shantur commented May 16, 2026

Do you want me to make the improvements recommended by CodenomadBot now ?

If you think those are valid, please do.
I will merge this in my local build and try for few days before merging as it's fairly big to review it all.

Fast streaming renders can notify VirtualFollowList multiple times in the same animation frame, repeating follow reconciliation, Virtua metric reads, and bottom-pin checks. Coalescing notifyContentRendered behind a single RAF keeps the existing follow and hold behavior while capping duplicate same-frame work during rapid updates.

The change preserves the current notification path, falls back to immediate execution when requestAnimationFrame is unavailable, and cancels pending frames during cleanup so cached panes do not flush stale work after teardown.

Validation:\n- node --test packages/ui/src/components/virtual-follow-behavior.test.ts\n- npm run typecheck --workspace @codenomad/ui
omercnet and others added 2 commits May 16, 2026 16:07
…s open (NeuralNomadsAI#459)

## Summary

Fixes a mobile UX bug where, with the session list (left drawer) open on
phone layout, the still-visible instance/project tab bar at the top was
non-interactive. Tapping a tab did nothing — users had to close the
drawer first and then switch projects/instances.

After this change, tapping a tab in that bar switches the
instance/project **and** the drawer auto-closes in a single gesture,
matching user expectation.

## Root cause

On phone (`max-width: 767px`), `InstanceShell` renders the left session
sidebar via MUI's `Drawer variant="temporary"`. The drawer paper is
offset down to `floatingTopPx()` so the instance tab bar remains
visually visible above it. However, MUI's `Modal` Backdrop is `position:
fixed; inset: 0` and covers the entire viewport — including the area
over the tab bar. The backdrop is styled `backgroundColor: transparent`
(so it's invisible) but it still captures pointer events at `z-index:
60`. Taps over the tab bar hit the transparent backdrop → MUI calls
`onClose` (closing the drawer) but the tab's click handler never fires.

## Implementation

1. **`packages/ui/src/components/instance/instance-shell2.tsx`** —
Constrain the MUI Drawer Backdrop via `sx` overrides on both the left
(session list) and right drawers so the backdrop is bound to the drawer
paper's vertical range (`top: floatingTopPx(); height:
floatingHeight();`) instead of fullscreen. Taps over the tab bar now
reach the tab buttons.

2. **`packages/ui/src/styles/panels/tabs.css`** — Lift
`.tab-bar-instance` to `position: relative; z-index: 70` so it stacks
deterministically above the drawer (z-index 60) across browsers as
defense-in-depth.

3. **`packages/ui/src/components/instance/shell/useDrawerChrome.ts` +
`instance-shell2.tsx`** — Expose `closeFloatingDrawersIfAny` from
`useDrawerChrome` and call it from an `InstanceShell` effect that fires
when `props.isActiveInstance` flips `true → false`. This closes any open
floating drawer on the instance the user just switched away from, so its
previously-open state doesn't bleed back when the user returns to that
tab later.

Tablet (>=768px) and desktop (>=1280px) layouts use pinned drawers (no
temporary modal), so the backdrop constraint and z-index lift are inert
there. The fix applies symmetrically to the right drawer on phone.

## Verification

- `tsc --noEmit` clean.
- `vite build` clean.
- Manual mobile-emulation checklist in the task file
(`tasks/done/058-mobile-session-list-blocks-tab-switch.md`) and SUMMARY
in `evidences/058-mobile-session-list-blocks-tab-switch/`.

## Reviewer manual check (phone viewport <=767px)

- Open session list drawer. Tap a different instance tab → it activates
AND drawer closes.
- Tap "+" while drawer open → folder picker opens, drawer closes.
- Tap Settings / Notifications / Remote while drawer open → action
fires, drawer closes.
- Same checks against the right drawer.
- Tap area below tab bar but outside drawer paper → drawer still closes
(existing backdrop dismissal preserved).
- Resize to tablet and desktop widths → pinned drawers unaffected.
- No visual regression in light or dark theme.

---------

Co-authored-by: Ubuntu <omer@Omer.dn3uxh3znnmu5eefnjnut0i1af.tlvx.internal.cloudapp.net>
Co-authored-by: Shantur Rathore <i@shantur.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: can't scroll up when agent still thinking

3 participants