Skip to content

perf(flow-chat): smooth session switching, scrolling and running tool groups - #3888

Merged
bobleer merged 9 commits into
openBitFun:mainfrom
bobleer:bob/flowchat-switch-scroll-groups
Nov 12, 2026
Merged

bobleer merged 9 commits into
openBitFun:mainfrom
bobleer:bob/flowchat-switch-scroll-groups

Conversation

@bobleer

@bobleer bobleer commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

FlowChat performance and presentation fixes found while two or more sessions stream at once.

Session switching while several sessions stream

  • The viewport ResizeObserver in VirtualMessageList is bound to the scroller lifetime and reads its callbacks through a ref, and the Turn-boundary list is kept stable. Before, every streamed flush rebuilt the observer, whose initial callback re-measured every mounted row (forced layout ~30×/s).
  • Streaming Markdown section boundaries use a separate small cache, so intermediate prefixes no longer evict settled replies' sections (re-tokenized on every switch back).
  • The timeline prewarm walk skips unchanged sources; the agent companion snippet cleans only a bounded tail (regression-tested to match the whole-text result on representative replies; regex pairs that span the window start, such as a stray < thousands of characters earlier, can differ in the 512-character snippet).
  • Reopening one of the 3 most recently viewed sessions reuses measured row sizes and the timeline projection; the turn rail no longer rebuilds on every flush. Retained sizes record their content width: reopened at another width they only reserve space, and a retained key that leaves and returns starts from an estimate.

Scroll jitter

  • A row's first measurement replaces an estimate, so a row that starts above the viewport now owes its whole delta. Judging it by its estimated end moved the visible transcript by the estimate error (104/166/441px single-frame jumps in a real-controller fixture; zero after). Rows that were already measured keep the existing visible-row rule. Retained sizes count as measured.

Running tool groups

  • While a group runs, its automatic disclosure lists only the tool calls in flight below a collapsed header (serial runs show one call, parallel batches the running ones, the latest call stays between serial steps, approvals always stay visible). Settled thinking folds too; the assistant's text stays. Explicit expansion, expanded capsules and failed file revisions still list every member; settled groups fold as before.
  • Reveal/reader holds keep a live group live instead of expanding it, which previously flipped the group on every streamed reveal and toggled the scrollbar thumb. A member holding focus, a selection or a live instance stays visible, so a settling call does not drop the reader's caret or selection. Trade-off: a reader scrolled up watching a running group still sees settled calls fold away (that is the requested presentation); the background-run details card keeps listing every member because the reader opened it explicitly.

Verification

  • src/web-ui: 5382 flow_chat and markdown tests pass; pnpm run type-check:web, pnpm run theme:color-audit:all, pnpm run check:repo-hygiene pass.
  • New regression tests: one viewport observer across streamed flushes; visible rows held still while wheeling into never-measured rows (including scrolls delivered after the gesture window); streaming section entries do not evict settled ones; companion tail equals the whole-text result; running-group presentation (serial, parallel, between steps, folded approval, holds, narrative, thinking, pinned focus/selection); retained sizes at another width and retired/returning keys. The observer, scroll and group tests fail on the previous code.
  • pnpm run check:web stops at the existing typography audit violation in design-system/packages/ui/src/components/Dialog/Dialog.module.css:77, which is also on main.

Remote scenarios: exercised only through local and simulated Peer surfaces in DOM fixtures; no live SSH workspace, Remote Control, Peer Device or Detached Dispatch run. No persisted shape, transport or IPC contract changes.

AI-assisted. Testing level: lightly tested — automated fixtures plus a short manual desktop run by the author; native WebView frame timing is not measured.

Not covered: the bottom-origin (native history) layout does not get the first-measurement rule; it is unchanged from main.

Known gap left as is: a scroll without an intent event (overlay scrollbar drag on WebKit) is still pulled back by follow-output while it holds the tail (documented in FLOWCHAT_SCROLL_STABILITY.md).

…session

Switching sessions remounts the transcript, which used to discard the
virtualizer's measured sizes and the timeline projection. A reopened session
then re-reserved estimates, and the opening reveal corrected every mounted row
and dropped rows it had selected from those estimates.

Keep the projection and the measured row sizes (keyed by stable item key) for
the three most recently left views, keyed by the view's reader store. A mount
claims its entry and returns it on unmount. Retained sizes seed the rows'
reservations, so mounted rows are still measured and still replace them.
Follow, anchor, opening and viewport ownership stay per mount, and scroll
restoration is unchanged.

In the supplied-geometry fixture, a native-history reopen goes from
7 corrections / 6 commits / 7 mounts / 3 dropped rows to 0 / 5 / 3 / 0, and a
live reopen goes from 6 corrections / 6 mounts to 0 / 5.

The Turn rail no longer rebuilds on every streamed flush: its dialog-turn
input is kept while Turn ids and user messages are unchanged, capsule previews
are parsed once per stored presentation, and FlowChatTurnRail is memoized with
a stable navigation callback.
A row restored from a recently left session was measured by an earlier
mount. Cover that it costs no correction, is handed back on unmount, and
that its next resize while partly visible does not shift the viewport,
while a row's real first measurement above the reader still does.
… groups

Session switching with several streaming sessions:
- Bind the viewport ResizeObserver to the scroller lifetime and keep the
  Turn-boundary list stable, so a streamed flush no longer rebuilds the
  observer and re-measures every mounted row.
- Keep streaming Markdown section boundaries in a separate small cache so
  intermediate prefixes do not evict settled replies' sections.
- Skip unchanged sources in the timeline prewarm walk.
- Clean only a bounded tail for the agent companion snippet.

Scroll jitter:
- A row's first measurement replaces an estimate, so a row that starts
  above the viewport owes its whole delta instead of being judged by its
  estimated end.

Running tool groups:
- Automatic disclosure of a running group lists only in-flight members;
  settled calls fold into the header summary. Explicit expansion, held
  groups, expanded capsules and failed file revisions still list all.
…at-switch-scroll-groups

# Conflicts:
#	src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts
…rrative

A reveal or reader hold expanded a running group to every finished call,
then folded it back when the hold released. The transcript crossed the
viewport edge on each streamed reveal, flashing the scrollbar thumb and
moving the content. Holds now keep a live group live, and only settled
tool calls fold; the assistant's text and thinking stay visible.
Settled thoughts accumulated as rows while a group ran. Fold them with the
finished calls; keep a streaming thought and one that just finished with
nothing after it, and keep the assistant's text.
…izes

- A running group keeps the member row that holds focus, a selection or a
  live instance, so a settling call no longer drops the reader's caret or
  selection.
- Retained row sizes record their content width; at another width they
  only reserve space and their rows owe the first-measurement repair.
- A retained key that leaves and returns no longer hands its estimate to
  the next mount as a measurement.
@bobleer
bobleer merged commit 6c00f83 into openBitFun:main Nov 12, 2026
9 checks passed
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.

1 participant