Files
kandev/docs/specs/ui/system-design/transcript-auto-scroll.md
T
2026-09-07 21:17:02 +00:00

234 lines
12 KiB
Markdown

---
status: current
system: ui
requirements:
- REQ-UI-TRANSCRIPT-AUTO-SCROLL-001
created: 2026-08-27
updated: 2026-09-07
owners:
- kandev
---
# Transcript Auto-scroll Stability System Design
## Purpose and boundaries
This design keeps an enabled transcript pinned during streamed updates and
restores that bottom-follow intent when a persistent desktop session panel
becomes visible or a task switch rebuilds Dockview for another environment. It
does not force browser layout during each React commit. It preserves
disabled-state freezing, reader-owned positions, explicit navigation, prepend
restoration, and catch-up after the user enables auto-scroll again.
## Requirement mapping
| Requirement | Design section |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REQ-UI-TRANSCRIPT-AUTO-SCROLL-001` | [Bottom placement](#bottom-placement), [Persistent-panel visibility lifecycle](#persistent-panel-visibility-lifecycle), [Environment-change rebuild lifecycle](#environment-change-rebuild-lifecycle), [Interaction boundaries](#interaction-boundaries), [Responsive behavior](#responsive-behavior) |
## Bottom placement
`message-list-native-scroll.ts` owns one helper that places the native scroll
container at its maximum vertical offset with a write-only scroll command. The
common message-update and work-start paths call this helper. They do not read
`scrollHeight`, `clientHeight`, a bounding rectangle, or computed style before
the write.
The helper writes `2_147_483_647`, which is the largest signed 32-bit integer.
Chromium, Gecko, and WebKit clamp this positive offset to the current maximum.
The helper must not write `Number.MAX_SAFE_INTEGER`. WebKit resolves that value
to the top of the scroll container.
The existing near-bottom reference remains the decision input. The helper does
not add a second animation frame, smooth scrolling, or a resize observer.
Tests instrument the common append path. A `scrollHeight` getter fails the test
if that path reads it. The scroll setter records the WebKit-safe offset.
## Persistent-panel visibility lifecycle
Desktop Dockview renders panel content through persistent portals. An inactive
session transcript can therefore be mounted and receive messages while its
scroll container has zero or detached geometry. `usePanelActive` supplies the
authoritative group-local visibility signal to `TaskChatPanel`, which passes it
to the native transcript scroll coordinator as `isVisible`.
Dockview's panel `isVisible` property means the panel is the selected tab in
its own group. Its `isActive` property additionally requires that group to own
global Dockview focus. The transcript uses `isVisible` and
`onDidVisibilityChange`; clicking a side-by-side Files or Changes group cannot
make a still-rendered Chat panel ineligible for initial placement or read
tracking.
Geometry-based initialization and divider placement do not consume their
one-time completion state while `isVisible` is false. When a transcript first
becomes visible after an inactive mount, its placement runs after the generic
`SessionPanelContent` absolute-offset restore. The coordinator uses two
animation frames because that generic restore uses one frame after its
`ResizeObserver` reports non-zero geometry.
The unread-divider settling deadline starts when the transcript is first
visible and resets for each hidden-to-visible activation. Time spent in an
inactive persistent portal does not consume the four-second reassertion window.
The deadline remains fixed during that visit, so later layout changes can
reconcile the divider only while the activation is still settling.
`SessionPanelContent` remains a generic panel component. Its queued restore
captures the element's `scrollTop` when scheduled and applies the saved offset
only if that value is unchanged when the frame runs. This cooperative stale-
write guard lets a newer transcript or user scroll remain the active owner.
The post-restore placement follows this ownership order:
1. A pending Dockview layout restore or explicit message-navigation target
retains ownership and suppresses automatic placement.
2. A visit-scoped unread divider is placed at its target and marks the reader
away from the bottom.
3. A disabled auto-scroll preference or an enabled reader who previously moved
away from the bottom keeps the saved absolute offset.
4. An enabled reader with bottom-follow intent receives the shared write-only
bottom placement.
Hidden message and work-state updates do not write to a zero-geometry scroll
container and do not recompute near-bottom state from hidden geometry. If the
reader had bottom-follow intent before the panel became inactive, activation
performs one guarded catch-up after the panel restore. If the reader had moved
away from the bottom, activation leaves the generic absolute-offset restore in
place.
The visibility lifecycle uses component refs only. It does not add persisted
scroll state or change the existing per-session disabled-position storage.
## Environment-change rebuild lifecycle
`buildEnvSwitchAction` distinguishes an environment-changing task switch from
same-environment session activation before it mutates Dockview. A cross-
environment switch arms a tokened initial-placement request for the incoming
session. The request is independent of `pendingChatScrollTop`: the latter keeps
owning same-transcript layout rebuilds, while the environment-switch request
must never capture or replay the outgoing session's absolute offset.
The incoming native transcript treats a matching request as a reactivation even
when its panel remains logically visible through the rebuild. The request owns
two bounded placement phases whenever cached rows are already available and no
unread-divider target owns entry:
1. After Dockview restoration makes the panel measurable, provisional
placement resolves against the incoming session's cached rows. Enabled
auto-scroll selects the newest cached message; disabled auto-scroll selects
the incoming session's saved offset. This phase does not clear the request.
2. After the latest-window refresh settles, final placement resolves against
the reconciled rows and clears the request conditionally.
Both phases use `scheduleAfterPanelRestore` and re-check the live session,
visibility, and competing owners before writing. This prevents cached content
from appearing at the browser's default `scrollTop = 0` while preserving a
final correction when the refresh changes the current window. A transcript
without cached rows keeps the existing loading presentation and performs only
the final phase. An active unread-divider target keeps its existing
visit-scoped placement and does not receive the provisional bottom or saved-
offset write.
Each placement resolves ownership in this order:
1. A pending explicit navigation target or same-transcript layout restore keeps
priority.
2. Disabled auto-scroll restores
`transcriptAutoScroll.scrollTopBySessionId[sessionId]`, with
`getStoredAutoScrollTop(sessionId)` as its storage fallback.
3. Enabled auto-scroll places the transcript at its current native maximum.
Final completion conditionally clears the same token so a superseded switch
cannot consume a newer request. While a matching request remains pending, the
older-history intersection sentinel is blocked. Releasing that block is an
eligibility transition, not proof that the sentinel remains at the top. Before
replaying an intersection observed while blocked, `useLazyLoadSentinel` invokes
the consumer's current-geometry predicate. The transcript starts pagination
only if the current sentinel and scroll root remain inside the preload region.
The same current-geometry rule applies when a stale request hands an observed
intersection to a replacement view.
Same-environment session switches return before arming this lifecycle.
Maximize, un-maximize, preset, and custom-layout rebuilds retain the existing
`pendingChatScrollTop` path and do not request session initialization.
## Interaction boundaries
The optimization does not replace geometry reads that answer a user-action
question. Scroll events can still read geometry to decide whether the reader
is near the bottom. Re-enabling auto-scroll can still compare the viewport with
content to decide whether catch-up is necessary. Pagination and prepend
restoration retain their existing measurements.
The write-only helper runs only when all existing guards allow automatic
placement:
- auto-scroll is enabled.
- The transcript is visible, or a visibility-activation catch-up is running
after panel restoration.
- The reader is near the bottom.
- No user programmatic scroll lock is active.
- No layout restoration is pending.
- No environment-switch initial placement is pending for the session, except
when that matching request is executing its own provisional or final write.
Disabled auto-scroll continues to restore the captured `scrollTop`. New
content cannot move that frozen position through application code or browser
overflow anchoring.
## Responsive behavior
Desktop and mobile use the same native transcript, store state, and bottom
placement helper. The transcript remains the only vertical scroll owner. No
control, copy, layout, or touch target changes.
The inactive persistent-portal state exists only in the desktop Dockview
workbench. Mobile renders one selected `TaskChatPanel` and replaces its session
input instead of keeping inactive session panels mounted. Mobile therefore
keeps `isVisible` true and retains its initial, enabled, and disabled scroll
behavior through the shared coordinator. A mobile task switch with cached rows
must also place the incoming transcript before a background refresh can expose
the browser-default top, but it does not consume Dockview's environment-switch
placement request.
The nearest mobile exemplar is the current full-height task Chat tab selected
by `TaskLayout` in `apps/web/components/task/task-layout.tsx`. The transcript is
the only vertical scroll region; the task header and composer retain their
existing safe-area handling. Desktop and mobile Playwright scenarios must prove
immediate cached placement, final refresh reconciliation, enabled bottom
pinning, and disabled position restoration.
## Failure modes
- If the container is not mounted, the helper performs no work.
- If a transcript is mounted but hidden behind another tab in its group,
one-time placement remains pending until the panel becomes visible and
measurable.
- If a panel becomes hidden again before the post-restore frames complete, the
scheduled placement is cancelled.
- Chromium, Gecko, and WebKit clamp the signed 32-bit maximum to the native
maximum scroll position.
- An offset outside WebKit's safe range can resolve to zero and move the
transcript to the top.
- If a layout restore or explicit navigation owns the scroll, the existing
guards suppress bottom placement until that owner releases control.
- If a newer environment switch supersedes a pending placement, the older
token cannot clear or position the newer session.
- If the latest-window refresh is slow, cached rows remain at their provisional
position and automatic older-history pagination stays blocked.
- If the incoming history refresh fails, the request resolves against the
session history that remains available; it does not fall back to the outgoing
session's offset.
## Observability
Debug logging records provisional placement, final placement, and request
completion with the session identifier, placement token, selected owner, and
scroll geometry. Pagination start logging retains its trigger and geometry so
diagnostics can distinguish a real top reach from a rejected stale
intersection. Message content and saved offsets are not logged.
## Related decisions
- [Isolate Replaceable Session Stream Traffic](../../../decisions/2026-08-02-isolate-replaceable-session-stream-traffic.md)