Files
openchamber/CHAT_PERF_PLAN.md
T
9199798a14 perf(right-sidebar): gate live effects, memoize lookups, always-mount tabs (#1674)
* perf(right-sidebar): gate live effects, memoize lookups, always-mount tabs

Performance fixes for the right sidebar (git/files/context tabs).

== Correctness / leak fixes (P0)

* RightSidebar: drop dead useEffect that re-nulled refs the resize
  handler already nulled; collapse the redundant width/minWidth/maxWidth
  triple into width + the existing --oc-right-sidebar-width variable.
* useUIStore: clamp setRightSidebarWidth to [MIN, MAX]; simplify
  setRightSidebarOpen (22 lines -> 12).
* RightSidebarTabs: useRightSidebarGitSync now takes the right tab and
  main tab and only polls when the right git tab is the visible consumer
  and the browser is online + visible. Replaces a global poll that
  fired for the lifetime of the open sidebar.
* GitView: commit-files fetch refactored to cancelled + Promise.all
  (was a per-hash loop that could setState after unmount); getRemoteUrl
  and refreshRemotes gated on cancelled/mountedRef; new module-scoped
  mountedRef guards setIsSettingIdentity from firing after unmount.
* GitView + useGitmojiList: extract gitmoji fetch/cache into a hook
  with module-level inflight promise + subscribers Set; stale-while-
  revalidate from localStorage; ensureLoaded() for call-site-initiated
  hydration; cancelled flag on setIsLoading to avoid the React
  setState-after-unmount race.
* ProjectNotesTodoPanel: 400 ms notes debounce now cancels on blur
  (was double-saving); persistProjectData chained per project through
  a module-level Map<projectId, Promise> so a fast todo toggle racing
  the debounced save no longer hits the server in parallel; resize
  auto-adjust guards against same-value pings.

== Render fanout (P1)

* RightSidebarTabs: all three tab content components are now always
  mounted with the hidden attribute. State and cache survive tab
  switches. When activeMainTab === 'git' (or 'context') the matching
  right tab is filtered out of the tab strip and a redirect effect
  snaps any persisted-but-now-hidden right tab to 'files'. onSelect is
  now a type-guarded handler instead of `as RightTab`.
* GitView: 13 separate useGitStore action selectors collapsed into one
  useShallow block (one re-evaluation per store change instead of 13).
* GitView: new isGitViewActive flag (true when this instance is the
  visible consumer) gates the 7 live effects — load identities, fetch
  remote URL, refresh remotes, ensureAll, sessionEvents.onGitRefreshHint,
  worktree bootstrap poll, default-identity auto-apply. Hidden
  GitView instances no longer run these.
* GitView: gitViewSnapshots module-level Map is now backed by an
  LRU wrapper (cap 20) so per-directory draft snapshots cannot leak
  across hundreds of project switches. Removed the dead `unique.set`
  dedup in changeEntries — GitStatus.files is already unique by path.
* SidebarFilesTree: statusByPath Map<path, FileStatus> and
  badgeByDir Map<dirPath, { modified, added }> are precomputed once
  per gitStatus change. Tree render is O(1) per node instead of O(N)
  per node via the previous per-row find/scan. badgeByDir walks each
  file's path segments and increments counters for every ancestor
  dir, so total cost is O(N + total_dirs_in_files) per gitStatus
  change.
* SidebarFilesTree: FileRow wrapped in React.memo with a custom
  comparator. Context-menu open state moved INTO FileRow as local
  state — opening a menu in one row no longer re-renders siblings.
* SidebarFilesTree: loadDirectory accepts an isCancelled predicate;
  the batch-load effect for expandedPaths passes a stable predicate
  so per-dir fetches stop touching state once the effect tears down.
* SidebarFilesTree: module-level fileTreeCacheByRoot Map (LRU,
  cap 8 roots) hydrates childrenByDir / loadErrorsByDir /
  loadedDirsRef on mount or root change. Mirror effects write state
  back to the cache. Survives close-and-reopen of the right sidebar;
  populated entries are dropped on unmount only when they had no
  data.

== Result

Net diff: 6 files modified, 1 new (useGitmojiList.ts), 682 insertions,
283 deletions. Existing test suite baseline preserved (537 pass / 58
fail / 1 error) — no new regressions. The 58 pre-existing failures are
in unrelated chat/streaming tests and were verified via git stash on
the same branch.

Architecture assumptions, verified by manual review:
- P1.1's redirect effect snaps rightSidebarTab to 'files' whenever
  activeMainTab === 'git', so the right and main GitView instances
  are mutually exclusive — isGitViewActive cannot be true for both.
- The 7 gated effects plus the useRightSidebar GitSync poll cover all
  cases where git state should advance: visible consumer fetches; the
  poll keeps the store warm when only the right git tab is visible.
- The aborted loadDirectory predicate is sufficient because
  inFlightDirsRef and loadedDirsRef dedup at the call site before
  any network IO is initiated.

* fix(sidebar): always clean up inFlightDirsRef regardless of cancellation

* refactor(sidebar): deduplicate RIGHT_SIDEBAR_MIN/MAX_WIDTH constants, export from useUIStore

* docs: split right sidebar perf plan into standalone file, clean up merged master status from chat plan

* fix(git): gate GitView effects by instance visibility

---------

Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-06-16 13:52:05 +03:00

541 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Chat Session Performance Plan
## Goal
Reduce render cost and history-load latency when switching between chat sessions
in `packages/ui/src/components/chat/`, especially on Windows desktop with
projects that have a long-running chat history (50500 messages per session,
tool-heavy workloads where each tool call returns 10200 KB of output).
The plan is **strictly behavior-preserving**: every change keeps the visible
behavior 1:1 with the current build. Differences are only in the cost of doing
the work, not in the UX, layout, limits, persistence, or order of operations.
The two scenarios this targets:
- Switching between sessions in the sidebar (especially in/out of a session
with a long history) — the main window currently blocks the main thread
for 200500 ms on first paint while `JSON.parse` + `materializeSessionSnapshots`
+ `projectTurnRecords` run synchronously over the full initial page.
- Returning to a session you visited a few minutes ago — the in-memory LRU
(`SESSION_MESSAGE_RECORDS_CACHE_MAX = 40` desktop) helps, but the second
visit to a session outside that window still pays the full network + parse
cost.
A secondary goal is to avoid hammering the OpenCode server with large
single-shot fetches; we want smaller, more frequent, deduped requests.
## Upstream context
Before implementing, I scanned open/closed PRs for overlap and lessons:
- **#1651 — "perf: migrate chat rendering to virtua"** (`b920fd6f`, MERGED).
Already moved chat history to `virtua` virtualization with `bufferSize: 900`
and `MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5`, plus deferred Prism highlight,
mermaid/file annotation, table listeners, stable plugin lists. This plan
builds on top of that.
- **#1650 — "perf: instant startup via cache hydration + decoupled readiness"**
(`c62f0d1c`, MERGED). Instant startup + decoupled readiness — orthogonal.
- **#1503 — "fix(chat): do not force scroll in thinking block during streaming"**
(OPEN). Scroll behaviour during streaming — not in our hot path.
- **#1584 — "feat: improve long user message collapse, scrolling, and navigation"**
(DRAFT). Touches long user messages; no direct conflict with this plan.
- **#1621 — "feat: comprehensive scrolling and navigation improvements"**
(DRAFT). Broad scroll/navigation PR; no direct conflict.
- **#1282 — "Optimize large-session sidebar trees, switching UX, and chat
scroll restore stability"** (CLOSED by maintainer). Introduced session-
switching instability, selection/chat desync, optimistic transitions.
Lesson: keep this plan focused on render-cost reduction and stable memoization;
do NOT add optimistic transitions, backend search endpoints, scroll-
restoration loops, or pagination on top of an unstable base.
- **#1448 — "feat: implement unified multi-server sidebar"** (DRAFT). Rewrites
large parts of `SessionSidebar`. Independent of this plan.
There is currently NO open PR targeting the same render-cost or history-load
problems for the chat panel. We can proceed without coordination.
## Current Architecture Notes
### Data flow on session switch
1. `ChatContainer` subscribes to `currentSessionId` from `useSessionUIStore`.
2. `useSessionMessageRecords(currentSessionId, directory)` reads from the
directory-scoped sync store via `React.useSyncExternalStore`. Snapshot
cache: `SESSION_MESSAGE_RECORDS_CACHE_MAX = 40` desktop, `4` vscode/mobile.
3. `useChatTimelineController.turnWindowModel` is rebuilt via
`buildTurnWindowModel(messages)` on first open of a session; cached per
sessionId in module-level `turnModelCache` (max 30 desktop, 4 constrained).
4. `MessageList.baseDisplayMessages` reverses + dedupes + normalizes each
message via `getNormalizedMessageForDisplay` (per-message `WeakMap` cache).
5. `useTurnRecords` projects turn records via `projectTurnRecords` (O(N) over
messages + per-turn work for summary/activity/diffStats/changedFiles).
`previousProjectionRef` stabilizes across re-renders, but there is no
module-level projection cache — returning to a session after the component
unmounts pays the full projection cost again.
6. `useChatTimelineController` runs the `useLayoutEffect` on `[sessionId]`
that resets `isLoadingOlder`, `pendingRevealWork`, `activeTurnId`.
### Render hot path (current cost map)
- `ChatContainer` (990 lines) subscribes to the full directory `permission`
and `question` records via `useDirectorySync((s) => s.permission ?? {})` and
`useDirectorySync((s) => s.question ?? {})` (`ChatContainer.tsx:436-443`).
Any `permission.asked` or `question.asked` event for any session in the
same directory re-renders `ChatContainer` and re-runs the `permissionsMap` /
`questionsMap` / `scopedSessionIds` / `sessionPermissions` / `sessionQuestions`
/ `sessionIsWorking` chain. Same for `useSessions(directory)` — every
`session.updated` event for any session in the directory re-renders the
container.
- `MessageList.reviewTransferDirection` (`MessageList.tsx:1151`) does
`useGlobalSessionsStore((state) => { const currentSession = state.activeSessions.find(...) })`
— runs `find` + `some` over the entire global active list on every global
store change.
- `MessageList` already has a `useStableEvent(getAnimationHandlers)`,
`useStableEvent(scrollToBottom)`, and a module-level `timelineCache` for
the virtua cache snapshot — those are the right pattern, just not applied
uniformly.
- `ChatMessage` (1182 lines) is `React.memo`-wrapped with
`areRenderRelevantMessagesEqual` plus `areRelevantTurnGroupingContextsEqual`.
`MessageBody` (2121 lines) is NOT `React.memo`-wrapped but is only
rendered through `MessageRow``ChatMessage` and only re-runs when
`areRenderRelevantMessagesEqual` returns false.
### The 1.5 MB problem
The OpenCode server (separate repo, `anomalyco/opencode`) responds to
`GET /api/session/{sessionID}/message` with
`Array<{ info: Message, parts: Part[] }>` (the v1 wire shape preserved in
the v2 SDK). On a typical tool-heavy session of 150 messages, this array
serializes to ~1.5 MB of JSON. The bulk is `ToolStateCompleted.output: string`
(confirmed in `node_modules/.bun/@opencode-ai+sdk@1.17.7/.../types.gen.d.ts:357-373`).
The OpenChamber web server (`packages/web/server/index.js:1115`) installs
Express `compression()` middleware BEFORE the OpenCode proxy
(`packages/web/server/lib/opencode/proxy.js`), so the response IS compressed
on the way out (5x reduction on typical JSON, so ~300 KB on the wire). The
proxy explicitly sets `accept-encoding: identity` (`proxy.js:629`) which is
a no-op against the upstream OpenCode server (it has no compression
middleware — `server.ts` uses raw `@effect/platform-node` NodeHttpServer).
So the **wire cost is ~300 KB after gzip**, not 1.5 MB. The real cost is the
**client-side main-thread blocking**:
- `JSON.parse` on 1.5 MB of structured data: ~50200 ms in V8.
- `materializeSessionSnapshots`: O(N) sort/filter, ~1020 ms for 150.
- `projectTurnRecords`: O(N) with per-turn work (summary/activity/diffStats/
changedFiles/indexes), ~30100 ms for 150.
- `useChatTimelineController.useLayoutEffect` re-resolves pending scroll
requests and updates `historySignals` — fast but runs synchronously.
Total main-thread block on first paint: **200500 ms**. The user-perceived
"long load" is mostly this.
### Limit constants (current)
| Constant | Value | Location | Purpose |
| ------------------------------------- | --------------------------------- | --------------------- | --------------------------------------- |
| `INITIAL_MESSAGE_PAGE_SIZE` | 150 (desktop), 30 (vscode/mobile) | `use-sync.ts:25-27` | First page on session switch |
| `HISTORY_MESSAGE_PAGE_SIZE` | 200 | `use-sync.ts:28` | Scroll-up pagination |
| `MESSAGE_REFETCH_LIMIT` | 200 | `session-actions.ts:27` | Refetch after revert/abort |
| `RECONNECT_MESSAGE_LIMIT` | 30 | `sync-context.tsx:193` | Reconnect bootstrap |
| `SESSION_MATERIALIZATION_MESSAGE_LIMIT` | 30 | `sync-context.tsx:194` | Materialize on `message.updated` recovery |
| `DEFAULT_MESSAGE_LIMIT` | 200 | `sessionTypes.ts:78` | Declarative ceiling; not enforced |
For 99% of sessions, users read the latest 3050 messages and rarely scroll
beyond. The 150-initial / 200-history combination fetches ~3x more than the
visible window on first paint.
### Already optimized (do not touch)
- `MessageList` virtualization with `virtua` at threshold 5, bufferSize 900
(PR #1651).
- `MessageRow` / `ChatMessage` / `TurnBlock` `React.memo` with custom
comparators.
- `getNormalizedMessageForDisplay` per-message `WeakMap` cache.
- `MarkdownRenderer` lazy-loaded via `lazyWithChunkRecovery`; Prism highlight
and mermaid deferred (PR #1651).
- `expandedToolsStateCache` and `collapsedToolsStateCache` module-level LRU
bounded at 4000 (`ChatMessage.tsx:39-95`).
- `aggregateLiveSessions` / `aggregateLiveSessionStatuses` bail via
`areSessionListsEquivalent` and `areStatusMapsEquivalent`; consumed through
`useLiveSyncSelector`.
- `useStickyProjectHeaders` IntersectionObserver.
- Targeted field cloning in `handleDirectoryEvent` (sync/DOCUMENTATION.md).
- `turnModelCache` for `buildTurnWindowModel` (30 desktop, 4 vscode/mobile).
- `timelineCache` for virtua snapshot (max 16 sessions).
- `session-prefetch-cache` 15s TTL dedup of `syncSession` requests.
- `syncSessionInflightByKey` for in-flight dedup.
## Proposed Architecture
### Bugfix Layer 0 — Fix rapid project-switch race in sidebar
**Observed bug:** When connected from desktop to an OpenChamber server, with
multiple trees in the sidebar and large sessions (~200k context), rapidly
switching between recent sessions in different projects causes the focus to
follow the **fetch completion order** instead of the user's final click. The
sequence is: user clicks 1, then 2, then 3; UI briefly shows 3; when fetch 1
finishes it jumps to 1; when fetch 2 finishes it jumps to 2; when fetch 3
finishes it finally lands on 3.
**Root cause:** `useProjectSessionSelection` (`useProjectSessionSelection.ts`)
has auto-selection logic that runs when `activeProjectId` changes. Combined
with uncancelled async fetches and cross-project store updates, stale fetch
completions update the sidebar session lists, which re-triggers selection
fallbacks and overwrites the user's explicit choice.
**Fix — four parts:**
1. **Layer 0a — Generation guard in `useEnsureSessionMessages`.**
Track a per-hook generation counter. When the effect fires for a new
`sessionID`, increment the generation. Ignore the result (do not apply
store mutations) if the generation is stale by the time the async fetch
completes.
2. **Layer 0b — Generation guard in `syncSession` / `loadMessages`.**
Add a module-level `Map<sessionKey, number>` tracking the latest requested
generation per session. Before every store write inside `syncSession` and
`loadMessages`, check that the request is still current. This prevents
fetches 1 and 2 from mutating state after the user has already selected 3.
3. **Layer 0c — Debounce `handleSessionSelect`.**
In `useSessionActions.ts`, wrap the actual navigation in a short timeout
(~80ms). The first click executes immediately; rapid subsequent clicks
cancel the pending navigation and reschedule for the latest target. This
coalesces 1→2→3 into a single `setCurrentSession(3)` call.
4. **Layer 0d — Explicit-selection guard in `useProjectSessionSelection`.**
Track the timestamp of the last explicit user selection. Suppress the
auto-select fallback in the layout effect for ~500ms after an explicit
click, so fallback logic never overwrites an active user choice.
**Effect:** Rapid project switching becomes deterministic: the UI stays on the
last clicked session and only loads its content. Stale fetches are silently
ignored instead of fighting for focus.
**Files:** `sync-context.tsx`, `use-sync.ts`, `useSessionActions.ts`,
`useProjectSessionSelection.ts`.
---
### Layer 1 — Narrow subscriptions in ChatContainer
**Problem:** `ChatContainer.tsx:436-443` subscribes to the **full directory**
`permission` and `question` records. Any permission/question event for **any**
session in the directory causes a ChatContainer re-render, even if the event
is for an unrelated session. Same for `useSessions(directory)` at line 427
— every `session.updated` event re-renders the container.
There are already session-scoped hooks (`useSessionPermissions(sessionID)`,
`useSessionQuestions(sessionID)` at `sync-context.tsx:2151,2165`), but they
don't aggregate across descendant subagent sessions — which
`collectVisibleSessionIdsForBlockingRequests` (`ChatContainer.tsx:458-464`)
handles for the `sessionPermissions` / `sessionQuestions` / `sessionIsWorking`
chain.
**Fix:** Replace the broad directory reads with scoped subtree selectors. The
subtree is the current session and all its descendant subagent sessions.
Add to `sync-context.tsx`:
- `useScopedSubtreeIds(sessionID: string | null, directory?: string): Set<string>`
— returns the set of session IDs in the current subtree (current session +
all descendants by `parentID`). Reference-stabilized: a new Set is only
returned when the actual membership changes, using the same "compare
contents, reuse reference" pattern as `areSessionListsEquivalent`.
- `useScopedBlockingRequests(sessionID: string | null, directory: string, kind: 'permission' | 'question')`
— combines `useScopedSubtreeIds` with the existing `useSessionPermissions` /
`useSessionQuestions` patterns to return a flat array of blocking requests
scoped to the subtree only.
- `useParentSession(sessionID: string | null): Session | null` — returns
the parent Session via a pre-computed `Map<sessionId, Session>` that only
changes when the target session or its parent changes. Replaces the O(N)
`.find()` at `ChatContainer.tsx:564`.
`ChatContainer` then:
- reads `useScopedBlockingRequests` instead of the full `allPermissions`/
`allQuestions``permissionsMap``scopedSessionIds``sessionPermissions`
/ `sessionQuestions` chain.
- reads `useParentSession` instead of `sessions.find(...)`.
- stops subscribing to `useSessions(directory)` entirely (the subtree selectors
provide all needed session data scoped to the active subtree).
**Effect:** Third-party sessions in the same directory stop re-rendering
ChatContainer. Streaming events for unrelated sessions no longer invalidate
the chat's blocking-requests chain.
**Files:** `ChatContainer.tsx`, `sync-context.tsx`.
---
### Layer 3 — Module-level projection cache
**Problem:** `useTurnRecords` keeps `previousProjectionRef` for in-component
stabilization, but a session that unmounts and remounts loses that ref. Full
re-projection cost is paid every time you return to a session.
**Fix:** Add a module-level LRU keyed by `sessionKey`. The cache key must
capture the exact identity of the message list to avoid stale projections:
```ts
// Includes: sessionKey, message count, last message id,
// AND the count of parts on the last message (detects streaming deltas
// that add parts to an existing message without changing id or count).
type CacheKey = `${sessionKey}|${length}|${lastMessageId ?? ''}|${lastMessagePartCount ?? 0}`
const projectionCache = new Map<CacheKey, { projection: TurnProjectionResult }>()
```
Including `lastMessagePartCount` is critical: during streaming, new parts are
appended to the same last message — the message ID and total message count
stay the same, but the projection must be recomputed.
Cap at the same limits as `turnModelCache` (30 desktop, 4 vscode/mobile)
to bound memory.
`useTurnRecords` consults the cache before running `projectTurnRecords`; on
hit, returns the cached projection and refreshes LRU order; on miss, runs the
full projection and writes the result.
**Effect:** Returning to a session within the LRU window is zero projection
work. Combined with `MessageList.timelineCache` (which already caches the
virtua snapshot), the first paint after a remount is essentially "rebuild
only what's strictly session-specific (resolvers, refs)".
**Files:** `useTurnRecords.ts` (small extension) + new helper
`lib/turns/turnProjectionCache.ts`.
---
### Layer 4a — Start message fetch synchronously in setCurrentSession
**Problem:** `session-ui-store.setCurrentSession` currently only calls
`setState` (Zustand). The actual message fetch is started by
`ChatContainer.useEffect` at `ChatContainer.tsx:787-792`, which runs on the
render AFTER the state update — adding one React commit cycle (~3080 ms)
of latency before the network request starts.
**Fix:** Start the fetch directly from `setCurrentSession` on the same tick
as the state update, using the existing imperative access layer.
The approach uses `session-actions.ts`, which already has module-level refs
(`_sdk`, `_childStores`, `_getDirectory``session-actions.ts:33-35`) set by
SyncProvider at mount time. Add a `fetchMessagesForSession(sessionID: string)`
function there that duplicates the core path from `useSync().syncSession`:
checks the child store for existing messages, fetches from SDK if needed,
calls `materializeSessionSnapshots`, and writes to the directory store.
`setCurrentSession` then calls `fetchMessagesForSession(targetId)` immediately
after `set({ currentSessionId: id, ... })`. The `syncSessionInflightByKey`
in `use-sync.ts` doesn't cover this new path, so the function also uses the
existing `_ensureMessagesLoading` Set in `sync-context.tsx` (line 2668) for
dedup.
The existing `ChatContainer.useEffect` at line 787 stays as a safety net for
sessions restored from URL or other non-sidebar entry points.
**Effect:** Fetch starts on the same tick as the state update, ~3080 ms
earlier than the current "wait for React commit → useEffect → fetch" path.
**Files:** `session-ui-store.ts` (one new call in `setCurrentSession`),
`session-actions.ts` (new `fetchMessagesForSession` function).
---
### Layer 5 — Small wins
- **`reviewTransferDirection` pre-computed map.** In
`MessageList.tsx:1151-1164`, replace
`useGlobalSessionsStore((state) => state.activeSessions.find(...))` with
a selector that reads a pre-computed `Map<sessionId, ReviewTransferDirection>`
maintained by `useGlobalSessionsStore` (built once per active-sessions
change, in the same reducer that updates `activeSessions`). Same selector
shape from the component's perspective, but no `.find()` + `.some()` on
every global store change.
- **`parentSession` lookup.** Part of Layer 1 (see `useParentSession` hook
above). The pre-computed `Map<sessionId, Session>` is maintained in
`sync-context.tsx` and read via the hook, eliminating the O(N) `.find()`
in `ChatContainer`.
**Files:** `MessageList.tsx`, `useGlobalSessionsStore.ts`.
---
### Layer 6a — Reduce initial payload
Lower the page sizes to match typical user reading patterns:
- `INITIAL_MESSAGE_PAGE_SIZE`: 150 → 50 (desktop), 30 stays (vscode/mobile).
- `HISTORY_MESSAGE_PAGE_SIZE`: 200 → 100.
- `MESSAGE_REFETCH_LIMIT`: 200 → 100.
- `DEFAULT_MESSAGE_LIMIT` in `sessionTypes.ts`: stays 200 (it's a ceiling,
not a fetch target).
**How the "load more" indicator still works with a smaller initial page:**
`historyMeta` in ChatContainer is built from `sync.hasMore(currentSessionId)`,
which reads `meta.current` (set immediately after the first `loadMessages`
call, before the component renders). If the server returns a cursor,
`sync.hasMore()` returns `true``historyMeta.complete = false`
`hasMoreAboveTurns = true` → scroll-up indicator appears correctly.
The `messages.length >= defaultLimit` fallback is never reached in practice
because `historyMeta` is populated before the first render of the chat.
Effect on the 1.5 MB problem:
- Initial fetch payload: 1.5 MB → ~500 KB parsed (50 messages).
- JSON.parse cost: 100200 ms → 3070 ms.
- projection cost: 30100 ms → 1030 ms.
- Combined main-thread block: 200500 ms → 50150 ms.
User cost: one extra round trip when scrolling past the 50th message. The
scroll-triggered `loadMore` is already in place and is the right UX for "I
want to see older messages" — the previous behavior of "fetch 150 and ignore
100" was wasted work for the common case.
**Files:** `use-sync.ts`, `session-actions.ts`.
---
### Layer 6b — Progressive mount
`use-sync.syncSession` starts the initial 50-message page synchronously.
After the first page resolves, if the cursor indicates more messages, a
second fetch of `HISTORY_MESSAGE_PAGE_SIZE` (100 messages) is dispatched
via `loadMessages(sessionID, { before: cursor, mode: "prepend" })` — the
prepend mode already exists in `loadMessages` (`use-sync.ts:373`). This
second page is non-blocking; the user sees the first 50 messages immediately.
On the client side, `useTurnRecords` and `useChatTimelineController` must
re-run projection when the prepended messages arrive. Two options:
- **Simple:** Let the full re-projection run on the second page arrival
(50→150 messages). With Layer 6a, projection of 150 messages is ~1030 ms —
fast enough that a one-time re-project on the second page is acceptable.
- **Optimal:** Extend `updateTurnWindowModelIncremental` (currently handles
only +1 message appends — `windowTurns.ts:76-84` checks
`nextMessages.length !== previousMessages.length + 1`) to handle batch
prepends. This requires:
1. A new `updateTurnWindowModelBatchPrepend` that verifies the new messages
are all prepended (all existing message references match at the tail)
and projects only the new messages into turn windows.
2. A corresponding `updateTurnProjectionIncremental` in `projectTurnRecords.ts`
that merges the new turns into the existing projection.
Because `updateTurnWindowModelIncremental` is designed for single-message
streaming deltas (not batch prepends), this is non-trivial additional work.
Given Layer 6a alone reduces the main-thread block to ~50150 ms, start
with the simple approach and measure before committing to the incremental
path.
**Net effect:** The first 50 messages mount within the same time as today
(possibly faster, because parsing 500 KB < parsing 1.5 MB). The next 100
arrive ~200400 ms later. From the user's perspective, the chat becomes
interactive immediately.
**Files:** `use-sync.ts`, `ChatContainer.tsx` (small — no change to `loadMore` UX).
---
## What I do not propose to touch (and why)
- `useChatAutoFollow` and the scroll-restoration logic in
`useChatTimelineController` (the `prePrependScrollRef` / height-delta
compensation). PR #1282 closed because of scroll-restore regressions;
these are working and tested.
- The event-pipeline coalescing and `message.part.delta` reducer path.
Already correct (sync/DOCUMENTATION.md).
- The Markdown renderer and Prism highlight deferral. PR #1651 already took
the low-hanging fruit.
- The `key={currentSessionId}` on `<ChatViewport>`. Forces a full remount,
which is expensive, but a previous PR attempt (Layer 2 in the sidebar
plan) regressed scroll/follow. Keeping the remount for now.
- The OpenCode server response shape. It's in `anomalyco/opencode`, which
AGENTS.md forbids us from touching.
- The SDK's internal `JSON.parse` step. Replacing it with a streaming
parser requires owning the fetch call (the SDK doesn't expose the
`ReadableStream`), and the response is a single JSON array, not NDJSON —
so streaming buys us the same "first N visible, rest in background"
pattern that Layer 6b already implements.
- Tool output lazy-load. Would need a server-side endpoint like
`GET /api/session/{sid}/part/{partID}` that the OpenCode server does
not currently provide.
## Expected effect
- **Initial session switch on a 150-message tool-heavy session:**
main-thread block 200500 ms → 50150 ms. ~3x faster.
- **Wire payload on the same session:** 1.5 MB parsed → 500 KB parsed.
After gzip, ~300 KB → ~100 KB.
- **Returning to a session within the LRU window:** zero projection work
(Layer 3), zero scroll-cache rebuild (existing `timelineCache`).
- **Returning to a session outside the LRU window:** same as initial, but
the 15s `session-prefetch-cache` TTL catches sessions visited twice in
rapid succession.
- **Server load:** the OpenCode server processes 1/3 the volume per
session-switch, and the warm second page in Layer 6b is deduped
against the inflight request. Strictly less load than today.
## Files Touched by the Plan
- `CHAT_PERF_PLAN.md` (new, this file)
- `packages/ui/src/sync/sync-context.tsx` (Bugfix Layer 0a — `useEnsureSessionMessages` guard)
- `packages/ui/src/sync/use-sync.ts` (Bugfix Layer 0b — `syncSession`/`loadMessages` guard; Layer 6a — constants)
- `packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts` (Bugfix Layer 0c — debounce)
- `packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts` (Bugfix Layer 0d — explicit-selection guard)
- `packages/ui/src/components/chat/ChatContainer.tsx` (Layers 1, 6b)
- `packages/ui/src/components/chat/MessageList.tsx` (Layer 5a)
- `packages/ui/src/stores/useGlobalSessionsStore.ts` (Layer 5a — pre-computed map)
- `packages/ui/src/sync/sync-context.tsx` (Layer 1 — new hooks: `useScopedSubtreeIds`, `useScopedBlockingRequests`, `useParentSession`)
- `packages/ui/src/sync/session-ui-store.ts` (Layer 4a — call fetch from `setCurrentSession`)
- `packages/ui/src/sync/session-actions.ts` (Layer 4a — new `fetchMessagesForSession`, Layer 6a — constants)
- `packages/ui/src/sync/use-sync.ts` (Layer 6a — constants, Layer 6b — progressive fetch)
- `packages/ui/src/stores/types/sessionTypes.ts` (Layer 6a — sync `DEFAULT_MESSAGE_LIMIT` with new constants)
- `packages/ui/src/components/chat/hooks/useTurnRecords.ts` (Layer 3 — projection cache)
- `packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts` (new, Layer 3)
## Implementation order
1. **Bugfix Layer 0** (race condition fixes) — highest priority, fixes the
observed sidebar switching bug before any optimization work.
2. **Layer 6a** (reduce constants) — simplest performance win, also reduces
the race window.
3. **Layer 3** (projection cache) — isolated change, immediate return-visit win.
4. **Layer 5a** (reviewTransfer map) — isolated, low risk.
5. **Layer 1** (narrow subscriptions) — more complex but well-bounded.
6. **Layer 4a** (early fetch) — depends on Layer 1 for dedup safety.
7. **Layer 6b** (progressive mount) — highest complexity, implement after
measuring real-world gains from 6a.
## Conflict risks
- None of the open PRs touch the files above in a conflicting way. PR #1584
(draft, long user message collapse) modifies `UserTextPart.tsx`, not the
files we touch. PR #1621 (draft, comprehensive scrolling) modifies scroll
and navigation components, not the chat data flow. PR #1448 (draft,
unified multi-server sidebar) is for `SessionSidebar.tsx` and family, not
the chat panel.
- Existing internal benchmarks for `event-pipeline` are unaffected
(Layers 1, 3, 4a, 5, 6 do not touch event coalescing or ordering).
- `bun run type-check` and `bun run lint` must remain green.
## Open Questions
1. **Layer 2 (drop `key={currentSessionId}` from `ChatViewport`):** explicitly
out of scope per user. Revisit after the lower-risk layers ship.
2. **Layer 7 (Web Worker for `JSON.parse` + materialize):** out of scope.
The Layer 6a/6b combo should bring main-thread block to ~50150 ms,
which is acceptable. Revisit if real-user feedback still shows jank.
3. **Layer 6b incremental projection for batch prepends:** start with the
simple full-reproject-on-second-page approach (50→150 projection is
~1030 ms post-Layer-6a). Only build the incremental path if benchmarks
show the full reproject is still noticeable.
4. **Layer 3 cache invalidation during streaming:** the `lastMessagePartCount`
in the cache key catches part deltas on the last message. If the streaming
model appends parts to a message that is NOT the last (e.g., tool output
on a completed turn while a new turn is streaming), the cache would miss —
which is correct (projection must re-run). Confirm this edge case during
smoke testing.