* 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>
28 KiB
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+materializeSessionSnapshotsprojectTurnRecordsrun 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 = 40desktop) 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 tovirtuavirtualization withbufferSize: 900andMESSAGE_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
ChatContainersubscribes tocurrentSessionIdfromuseSessionUIStore.useSessionMessageRecords(currentSessionId, directory)reads from the directory-scoped sync store viaReact.useSyncExternalStore. Snapshot cache:SESSION_MESSAGE_RECORDS_CACHE_MAX = 40desktop,4vscode/mobile.useChatTimelineController.turnWindowModelis rebuilt viabuildTurnWindowModel(messages)on first open of a session; cached per sessionId in module-levelturnModelCache(max 30 desktop, 4 constrained).MessageList.baseDisplayMessagesreverses + dedupes + normalizes each message viagetNormalizedMessageForDisplay(per-messageWeakMapcache).useTurnRecordsprojects turn records viaprojectTurnRecords(O(N) over messages + per-turn work for summary/activity/diffStats/changedFiles).previousProjectionRefstabilizes 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.useChatTimelineControllerruns theuseLayoutEffecton[sessionId]that resetsisLoadingOlder,pendingRevealWork,activeTurnId.
Render hot path (current cost map)
ChatContainer(990 lines) subscribes to the full directorypermissionandquestionrecords viauseDirectorySync((s) => s.permission ?? {})anduseDirectorySync((s) => s.question ?? {})(ChatContainer.tsx:436-443). Anypermission.askedorquestion.askedevent for any session in the same directory re-rendersChatContainerand re-runs thepermissionsMap/questionsMap/scopedSessionIds/sessionPermissions/sessionQuestions/sessionIsWorkingchain. Same foruseSessions(directory)— everysession.updatedevent for any session in the directory re-renders the container.MessageList.reviewTransferDirection(MessageList.tsx:1151) doesuseGlobalSessionsStore((state) => { const currentSession = state.activeSessions.find(...) })— runsfind+someover the entire global active list on every global store change.MessageListalready has auseStableEvent(getAnimationHandlers),useStableEvent(scrollToBottom), and a module-leveltimelineCachefor the virtua cache snapshot — those are the right pattern, just not applied uniformly.ChatMessage(1182 lines) isReact.memo-wrapped withareRenderRelevantMessagesEqualplusareRelevantTurnGroupingContextsEqual.MessageBody(2121 lines) is NOTReact.memo-wrapped but is only rendered throughMessageRow→ChatMessageand only re-runs whenareRenderRelevantMessagesEqualreturns 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.parseon 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.useLayoutEffectre-resolves pending scroll requests and updateshistorySignals— 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)
MessageListvirtualization withvirtuaat threshold 5, bufferSize 900 (PR #1651).MessageRow/ChatMessage/TurnBlockReact.memowith custom comparators.getNormalizedMessageForDisplayper-messageWeakMapcache.MarkdownRendererlazy-loaded vialazyWithChunkRecovery; Prism highlight and mermaid deferred (PR #1651).expandedToolsStateCacheandcollapsedToolsStateCachemodule-level LRU bounded at 4000 (ChatMessage.tsx:39-95).aggregateLiveSessions/aggregateLiveSessionStatusesbail viaareSessionListsEquivalentandareStatusMapsEquivalent; consumed throughuseLiveSyncSelector.useStickyProjectHeadersIntersectionObserver.- Targeted field cloning in
handleDirectoryEvent(sync/DOCUMENTATION.md). turnModelCacheforbuildTurnWindowModel(30 desktop, 4 vscode/mobile).timelineCachefor virtua snapshot (max 16 sessions).session-prefetch-cache15s TTL dedup ofsyncSessionrequests.syncSessionInflightByKeyfor 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:
-
Layer 0a — Generation guard in
useEnsureSessionMessages. Track a per-hook generation counter. When the effect fires for a newsessionID, increment the generation. Ignore the result (do not apply store mutations) if the generation is stale by the time the async fetch completes. -
Layer 0b — Generation guard in
syncSession/loadMessages. Add a module-levelMap<sessionKey, number>tracking the latest requested generation per session. Before every store write insidesyncSessionandloadMessages, check that the request is still current. This prevents fetches 1 and 2 from mutating state after the user has already selected 3. -
Layer 0c — Debounce
handleSessionSelect. InuseSessionActions.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 singlesetCurrentSession(3)call. -
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 byparentID). Reference-stabilized: a new Set is only returned when the actual membership changes, using the same "compare contents, reuse reference" pattern asareSessionListsEquivalent. -
useScopedBlockingRequests(sessionID: string | null, directory: string, kind: 'permission' | 'question')— combinesuseScopedSubtreeIdswith the existinguseSessionPermissions/useSessionQuestionspatterns 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-computedMap<sessionId, Session>that only changes when the target session or its parent changes. Replaces the O(N).find()atChatContainer.tsx:564.
ChatContainer then:
- reads
useScopedBlockingRequestsinstead of the fullallPermissions/allQuestions→permissionsMap→scopedSessionIds→sessionPermissions/sessionQuestionschain. - reads
useParentSessioninstead ofsessions.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:
// 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
-
reviewTransferDirectionpre-computed map. InMessageList.tsx:1151-1164, replaceuseGlobalSessionsStore((state) => state.activeSessions.find(...))with a selector that reads a pre-computedMap<sessionId, ReviewTransferDirection>maintained byuseGlobalSessionsStore(built once per active-sessions change, in the same reducer that updatesactiveSessions). Same selector shape from the component's perspective, but no.find()+.some()on every global store change. -
parentSessionlookup. Part of Layer 1 (seeuseParentSessionhook above). The pre-computedMap<sessionId, Session>is maintained insync-context.tsxand read via the hook, eliminating the O(N).find()inChatContainer.
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_LIMITinsessionTypes.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-84checksnextMessages.length !== previousMessages.length + 1) to handle batch prepends. This requires:- A new
updateTurnWindowModelBatchPrependthat verifies the new messages are all prepended (all existing message references match at the tail) and projects only the new messages into turn windows. - A corresponding
updateTurnProjectionIncrementalinprojectTurnRecords.tsthat merges the new turns into the existing projection.
Because
updateTurnWindowModelIncrementalis 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. - A new
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)
useChatAutoFollowand the scroll-restoration logic inuseChatTimelineController(theprePrependScrollRef/ height-delta compensation). PR #1282 closed because of scroll-restore regressions; these are working and tested.- The event-pipeline coalescing and
message.part.deltareducer 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.parsestep. Replacing it with a streaming parser requires owning the fetch call (the SDK doesn't expose theReadableStream), 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-cacheTTL 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 —useEnsureSessionMessagesguard)packages/ui/src/sync/use-sync.ts(Bugfix Layer 0b —syncSession/loadMessagesguard; 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 fromsetCurrentSession)packages/ui/src/sync/session-actions.ts(Layer 4a — newfetchMessagesForSession, 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 — syncDEFAULT_MESSAGE_LIMITwith 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
- Bugfix Layer 0 (race condition fixes) — highest priority, fixes the observed sidebar switching bug before any optimization work.
- Layer 6a (reduce constants) — simplest performance win, also reduces the race window.
- Layer 3 (projection cache) — isolated change, immediate return-visit win.
- Layer 5a (reviewTransfer map) — isolated, low risk.
- Layer 1 (narrow subscriptions) — more complex but well-bounded.
- Layer 4a (early fetch) — depends on Layer 1 for dedup safety.
- 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 forSessionSidebar.tsxand family, not the chat panel. - Existing internal benchmarks for
event-pipelineare unaffected (Layers 1, 3, 4a, 5, 6 do not touch event coalescing or ordering). bun run type-checkandbun run lintmust remain green.
Open Questions
- Layer 2 (drop
key={currentSessionId}fromChatViewport): explicitly out of scope per user. Revisit after the lower-risk layers ship. - 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. - 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.
- Layer 3 cache invalidation during streaming: the
lastMessagePartCountin 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.