Two scroll owners were writing the chat container's scrollTop concurrently
during pinned content growth and on send, fighting frame-to-frame and
producing the reported flicker/jiggle (after a pause, from the queue, on user
interruptions) plus a visible double scroll on a normal user send.
Enforce a single-writer invariant in useChatAutoFollow:
- The easing follow loop and the instant settle burst now mutually exclude:
starting one stops the other, so they can never write scrollTop in the same
frame. The isFollowingProgrammatically flag (which suppresses the overlay
scrollbar) is owned by whichever loop is active and cleared only when both
are idle, including the settle burst's natural 280ms end.
Stop the redundant re-pin storm in useChatTimelineController:
- While pinned, route goToBottom('instant') only for a prepend (history loaded
above), not on every bottom append / streaming part. Normal growth is owned
by the follow loop (kicked by the content ResizeObserver and chunk handlers).
Remove the double movement on send:
- Add scrollToBottomOnSend: when already following, just (re)kick the follow
loop for a single smooth movement instead of also firing an instant
goToBottom that raced the ResizeObserver-driven loop. When released (scrolled
up), keep the instant jump to the just-sent message.
Release auto-follow based on position (the user has left the near-bottom zone)
instead of scroll-delta direction. The old `currentTop < previousTop` check
treated the tiny scrollTop clamp the browser applies when the composer grows —
which keeps you at the bottom — as a user scroll-up and released follow, so
content finishing loading then drifted the view backward.
Also always return to the bottom on session switch, dropping the saved-ratio
restore: it had a low success rate and, by landing 'released' partway up,
produced the same visible backward jump as content finished loading.
overflow-anchor is already disabled on the chat scroll container, so no
delta-threshold workaround is needed; this is a net simplification.
Replace useEffect with useLayoutEffect in the pendingInitialRestoreRef
replay so restoreSnapshot runs synchronously after DOM commit, before
the browser paints. Prevents visible flash of content at the wrong
scroll position when the scroll container mounts after session
hydration.
Adapted from openchamber/openchamber#1553 (Fix 2). The virtualVersion
counter (Fix 1) is not applicable: virtua (post #1651) does not use
useVirtualizer's useState-based instance pattern that motivated it.
Validation:
- bun --cwd packages/ui type-check - no new errors in useChatAutoFollow.ts
- bun --cwd packages/ui lint - passed
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
When switching to a session with long context (especially in Electron
desktop when changing servers), the chat viewport could render blank
until the user scrolled. Two interacting issues caused this:
1. historyVirtualRows memo never recomputed after the first render
because historyVirtualizer (from useVirtualizer's useState) is a
stable reference. Frozen range meant items rendered at the top
while paddingBottom filled the visible viewport after scrolling.
2. pendingInitialRestoreRef replay ran in useEffect (after paint),
showing a frame at scrollTop:0 with the stale virtualizer range.
Fixed by: adding a virtualVersion counter driven by useVirtualizer's
onChange to bust the memo; switching the replay to useLayoutEffect
so scroll position is set before the browser paints.
Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.
Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.
Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.
Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.
Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.
Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
Bottom-zone threshold now scales with the scroll container's clientHeight
rather than window.innerHeight. The threshold is meant to mirror the empty
spacer at the end of the chat, but what users perceive as "the empty tail"
is relative to the visible chat area, not the whole viewport — on desktop
the chat is shorter than the window because of the input row below it.
This keeps the scroll-to-bottom button hidden until the user has actually
scrolled past the spacer they see, regardless of viewport size.
Wheel and touch release detection no longer treats every nested
[data-scrollable] as a blocker. The old check returned true for any
nested scrollable ancestor, so wheel-up over a code block already at its
own scrollTop=0 produced no effect — the inner element could not consume
the gesture and the outer chat refused to release auto-follow. The new
check returns true only when the nested element can actually scroll up
(scrollTop > 0); otherwise the gesture bubbles to the chat and releases
auto-follow as expected.
The previous system layered three hooks (useScrollEngine, useChatScrollManager,
useChatTimelineController) with overlapping responsibilities, four parallel
ResizeObservers/MutationObservers, and six entry points to "scroll to bottom"
(force-flag combinations, persistent follow loops, materialization recovery).
This produced bugs where users could not break free of auto-follow during
streaming: scrollbar drag, keyboard scrolling and find-in-page were not
detected as user intent, and observers kept restarting the follow loop on
every DOM mutation.
The new architecture replaces the two low-level hooks with a single
useChatAutoFollow that owns scroll behaviour end to end:
- One state: 'following' or 'released'. No follow modes, no pin flags,
no marker pixels.
- One scroll writer: a lerp loop that runs only while the session is
streaming and state is 'following'. Idle sessions never write scrollTop
programmatically.
- One user-intent detector: wheel up, touch drag down, keyboard
(PageUp / Home / ArrowUp), pointerdown on the OverlayScrollbar thumb,
and explicit releaseAutoFollow() calls all flip the state to 'released'.
- A 1.2s grace period after explicit release: re-pin will not auto-engage
inside this window, so a small wheel up cannot snap the user back even
while they remain near the bottom spacer.
- Re-pin and the scroll-to-bottom button share the same threshold: the
height of the empty bottom spacer (10vh on desktop, 40px on mobile).
Released users see the button only after they have scrolled past the
spacer that already exists at the end of the chat.
- Save/restore of scroll position uses ratio mapping, debounced at 150ms
on user-driven scroll events; programmatic writes are masked via a short
window so they never persist as user positions.
- Container reattachment is detected via a useLayoutEffect probe over
scrollRef.current. Listeners and observers re-bind when ChatViewport
mounts after hydration or after the first message promotes a draft
session into a real chat.
- A pending-restore queue replays restoreSnapshot once the scroll
container appears, fixing the case where a hydrating session landed at
the top instead of the bottom.
Removed: useScrollEngine.ts, useChatScrollManager.ts, the persistent
follow loop with its own ResizeObserver+MutationObserver pair, the
materialization-recovery .finally resume that yanked idle users to the
bottom on transient sync gaps, and the openchamber:session-reselected
event (re-select still works through the existing onSessionSelected
callback). The openchamber:chat-force-scroll-bottom event remains for
synthetic-message paths like git-message generation.
Net change: ~1300 lines removed, two hooks replaced with one, one
observer pair instead of four.