541 lines
28 KiB
Markdown
541 lines
28 KiB
Markdown
# 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 (50–500 messages per session,
|
|||
|
|
tool-heavy workloads where each tool call returns 10–200 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 200–500 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: ~50–200 ms in V8.
|
|||
|
|
- `materializeSessionSnapshots`: O(N) sort/filter, ~10–20 ms for 150.
|
|||
|
|
- `projectTurnRecords`: O(N) with per-turn work (summary/activity/diffStats/
|
|||
|
|
changedFiles/indexes), ~30–100 ms for 150.
|
|||
|
|
- `useChatTimelineController.useLayoutEffect` re-resolves pending scroll
|
|||
|
|
requests and updates `historySignals` — fast but runs synchronously.
|
|||
|
|
|
|||
|
|
Total main-thread block on first paint: **200–500 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 30–50 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 (~30–80 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, ~30–80 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: 100–200 ms → 30–70 ms.
|
|||
|
|
- projection cost: 30–100 ms → 10–30 ms.
|
|||
|
|
- Combined main-thread block: 200–500 ms → 50–150 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 ~10–30 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 ~50–150 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 ~200–400 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 200–500 ms → 50–150 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 ~50–150 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
|
|||
|
|
~10–30 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.
|