Skip to content

feat(tui): make session tab switching fast for long transcripts - #39568

Merged
kitlangton merged 1 commit into
v2from
session-switch-speed
Jul 30, 2026
Merged

kitlangton merged 1 commit into
v2from
session-switch-speed

Conversation

@kitlangton

@kitlangton kitlangton commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

What

Switching session tabs could visibly stall on long transcripts. This PR makes tab switching roughly constant-time — the visible switch mounts a fixed-size tail regardless of transcript size.

Front-end only by design: an earlier revision also added data-context prefetch and sync-dedup plumbing, but paging/prefetch on the data layer deserves its own API design (and is being looked at separately), so this version touches nothing outside the session route.

Before / After

Measured with an isolated opencode-drive harness (three sessions of 51 / 123 / 243 transcript rows with realistic markdown + code-fence content, 160×45 viewport; sync = blocked main thread inside the switch, paint = next flushed frame):

rows before (warm switch) after (warm switch)
51 ~47ms ~38ms
123 ~91ms ~38ms
243 ~167ms ~43ms

Warm-switch numbers were measured with an earlier revision that also skipped a post-sync re-reduce; this version keeps that re-reduce (it needs a data-context change), adding ~6–23ms of CPU after the switch paints — the visible switch is unaffected.

Before: every switch tore down and rebuilt the entire <Session /> tree under the keyed route Show, eagerly mounting every transcript row — markdown parsing, tree-sitter highlighting, wrapping, and layout for up to 200 messages, scaling ~0.65ms/row.

After: a switch mounts only the newest 40 rows and backfills the rest in 60-row chunks shortly after, invisibly (bottom-anchored readers are held by sticky scroll; scrolled-up readers get scroll compensation). The switch cost no longer depends on transcript length.

Cold first visits still fetch inside the switch gesture (unchanged from before); hiding that behind prefetch is the data-layer follow-up.

How

  • packages/tui/src/routes/session/index.tsx — tail-first transcript mounting. hidden derives from the row count until backfill pins it (no effect-ordering dependence), clamped so a shrinking re-reduce always leaves a tail visible. A 120ms timer drains 60 rows per chunk, pausing while streaming appends reset it. afterLayout consolidates the existing double-rAF “wait for layout” pattern (also adopted by alignMessage). jumpToMessage/scrollToMessage wrap in ensureAllRows, which force-mounts the full transcript before message navigation walks or jumps.
  • packages/tui/src/routes/session/rows.ts — defers the first run of the four reactive re-reduce effects, which each duplicated the mount reduce.
sequenceDiagram
    participant U as user
    participant T as SessionTabs
    participant S as Session route
    participant D as DataProvider

    Note over U,S: on tab switch
    U->>T: leader+digit / click
    T->>S: route.navigate (keyed remount)
    S->>D: rows from store (cached if visited)
    S->>S: mount newest 40 rows (~40ms)
    loop every 120ms until drained
        S->>S: mount 60 older rows above viewport
    end
Loading

Scope

  • No data-context changes: no prefetch, no sync return-type change, no plugin Data type change. Paging/prefetch belongs in a designed data-layer API (follow-up).
  • No virtualization: rows above the viewport still mount (after backfill). Bounded by the 200-message fetch limit, so permanent windowing wasn’t warranted.
  • One accepted edge: switching into an actively-streaming session before its rows settle can mount fully once (the pre-change behavior).
  • The boundaryID index mapping stays positional; backfill briefly re-evaluates it per mounted row per chunk (bounded small at the message cap).
  • The measurement harness (perf marks + opencode-drive benchmark scripts) is deliberately not included.

Testing

  • bun typecheck in packages/tui
  • bun run test in packages/tui: 562 pass, 5 skip, 1 fail — restores queued compaction from durable pending input fails identically on clean v2 HEAD (3c259fc552), pre-existing and unrelated
  • oxlint + repo-pinned Prettier clean on changed files
  • Benchmarked end-to-end with an isolated opencode-drive instance and JSONL perf marks (numbers above; light/heavy/scale content sweeps)
  • Dogfooded against a live six-tab setup with 200–450-message sessions — switches read as instant
  • Screenshot-verified: switch paints the bottom-anchored tail immediately and identically through backfill; a full row re-reduce mid-backfill renders correctly
  • Review-verified (not exercised e2e — drive couldn’t transmit the needed keys): scroll-compensation during backfill for scrolled-up readers, and ensureAllRows under message navigation

Demo

https://assets.kitlangton.dev/anomalyco/opencode/pr-39568/tab-switch-demo-e5c2cbf8eb06.mp4

Recorded from the PR branch: cycling <leader>1/2/3 between three sessions (150 / 60 / 250 tool-call transcripts with heavy markdown) — every switch paints instantly.

@kitlangton

Copy link
Copy Markdown
Contributor Author

Reworked to be front-end only per discussion with Dax: reverted the data-context changes (sync boolean return, session.prefetch, tab prefetch effect, plugin Data type). Now touches just routes/session/index.tsx and routes/session/rows.ts (+91/−39).

Tail-first mounting is unchanged, so warm switches stay flat regardless of transcript length. The post-sync re-reduce is back (it needed the data change to skip), but it runs after the switch paints so it is not visible. Cold first visits fetch inside the gesture as before; hiding that behind prefetch is deferred to a proper data-layer paging/prefetch API.

@kitlangton
kitlangton force-pushed the session-switch-speed branch from 2feb6e5 to 48d9514 Compare July 30, 2026 01:34
@kitlangton

Copy link
Copy Markdown
Contributor Author

Ran a simplification review pass (three parallel reviewers: reuse, quality, efficiency). Applied:

  • Restructured the backfill compensation check to remove a logically dead before === undefined disjunct (it existed only to narrow the type; now two honest early returns).
  • Corrected the streaming-pause comment: appends only reset the backfill timer until the first chunk pins hiddenRows; after that backfill drains on a fixed cadence, which reviewers agreed is the better behavior (no timer churn per streaming delta). The comment now says what the code does.
  • Deduplicated dialog.clear() in scrollToMessage.

Declined with reasons: promoting afterLayout to a shared util (it is the 4th double-rAF copy in the repo, but migrating dialog-select/diff-viewer belongs in a follow-up, not this PR); Map-keyed boundaryID and slice-cost reductions (bounded small at the 200-message cap, already noted in Scope). Checked @opentui/core ScrollBox for a public at-bottom accessor: only private _stickyScrollBottom, so the manual check stays.

Tests/typecheck unchanged (same single pre-existing flake).

@kitlangton
kitlangton merged commit cb37a71 into v2 Jul 30, 2026
6 of 7 checks passed
@kitlangton
kitlangton deleted the session-switch-speed branch July 30, 2026 02:23
jinhuang712 pushed a commit to jinhuang712/opencode that referenced this pull request Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant