Files
openchamber/AGENTS.md
T
vhqtvnandvhqtvn ff35f40b43 fix: resilient reconnect — preserve state on fetch fail, pause when offline (#1308)
* fix: preserve state when reconnect-time fetches fail

Several client API methods swallowed fetch/SDK errors and returned an
empty value (`[]`, `{}`), which was indistinguishable from a successful
"server says nothing here" response. Reconnect resync paths trusted that
empty result as authoritative and deleted local state — so after a
network blip (sleep/wake, wifi reconnect, tunnel switch), the UI could
show:

- sessions stuck on the "running" indicator (status never cleared)
- pending permission prompts disappearing from the UI
- pending question prompts disappearing from the UI

and only a page reload would recover. A related case: `listAgents`
silently returning `[]` defeated the 3-attempt retry loop in
`useAgentsStore` because the loop never saw an error.

The systematic fix:

- `getSessionStatusForDirectory` now returns `null` on fetch failure
  (vs the previous `{}`); the reconnect resync treats only a non-null
  response as authoritative — candidates missing from the response are
  written as `{type: "idle"}`, candidates after a failure are left
  untouched.
- `listPendingPermissions`, `listPendingQuestions`, and `listAgents`
  now throw on SDK/network failure. The pre-existing outer try/catch
  blocks in `resyncBlockingRequestsForDirectory` and the retry loop in
  `useAgentsStore` were already in the right shape — they just never
  fired because no exception was thrown. A small `formatSdkError`
  helper renders the SDK `{data, error}` shape into the thrown message.
- `permissionStore.setSessionAutoAccept` catches the new throw and
  falls back to whatever sync-store snapshots provide; the next SSE
  event or reconnect resync will catch up anything missed.

AGENTS.md gets a new "Distinguish fetch failure from empty success"
subsection documenting the principle (throw vs `T | null` patterns,
when to pick which, the retry-loop trap) so this doesn't regress.

Adds 3 regression tests covering the resync paths: existing
questions/permissions are preserved when the corresponding `list*`
method throws, and a permission-fetch failure does not block the
question block from running (verifies per-block try/catch isolation).

* fix: pause reconnect loop when offline or hidden

The SSE/WebSocket reconnect loop retried indefinitely with no awareness
of whether the browser was online or whether the tab was even visible.
Three issues compounded:

- No `online`/`offline` event handling. With a foreground tab on a dead
  network, we'd hit the server every ~5s forever, and on network
  recovery we'd wait up to ~5s for the next probe instead of reacting
  to the `online` event.
- No visibility awareness. A backgrounded PWA on a flaky link kept
  probing at the same rate as a foreground tab. The browser does
  throttle hidden-tab timers, but the intent wasn't expressed in code.
- The "exponential backoff" math
  `min(5000, max(retryDelayMs, 250) * (failures <= 1 ? 1 : 2))`
  re-initialized `retryDelayMs` to 250 every iteration, so the cap of
  5s was never reached — we waited 500ms forever after the second
  failure. Not actually exponential.

Now:

- `online` event aborts the current attempt (if disconnected) and
  cuts inter-attempt waits short. `offline` event aborts so the loop
  enters the slow-probe path immediately.
- `computeRetryDelay` returns the long cap (60s) when `navigator.onLine`
  is false or the tab is hidden; the short cap (5s) when foreground +
  online. The `online` event is the expected recovery path; the 60s cap
  is a fallback for browsers that miss the event.
- Real exponential growth: `BASE * 2^min(failures-1, 8)`, clamped.
- New `waitForRetry` helper interrupts on `online`,
  visibility-becomes-visible, and abort signal — so visibility/network
  recovery doesn't wait out the rest of the current sleep.

AGENTS.md gets a "Reconnect-loop pacing" subsection alongside the
fetch-failure rule, since they're the same family of resilience
concerns.

One regression test: simulates offline + failed first attempt + `online`
event after the failure; verifies the next attempt fires within seconds
instead of waiting the full 60s offline cap.

* fix: long-cap backoff for permanent 4xx server errors

Before this commit the reconnect loop didn't distinguish HTTP error
types. A stuck-path client (wrong URL after server upgrade) or an
expired-auth client (stale token) would hit the server at the normal
5-second cap forever — ~12 reqs/min, indefinitely, with no path to
recovery besides the user reloading.

Now the catch block extracts an HTTP status (looking on `error.status`
and `error.response.status` — the SDK exposes both depending on the
code path) and overrides the backoff:

- 4xx other than 408/429 → use the long cap (60s) immediately.
  Blind retries won't fix wrong path / bad auth / forbidden, so don't
  pound the server. waitForRetry's `online` / visibility-visible
  interrupters still apply — when an operator fixes the server-side
  config and the client comes back to foreground, recovery is prompt.
- 408 (Request Timeout) and 429 (Too Many Requests) → normal
  exponential path. Those are retryable in spirit.
- 5xx / network / unknown → normal exponential path. Unchanged.

AGENTS.md gets a new bullet under "Reconnect-loop pacing" covering
this — the rule fits naturally alongside the existing `navigator.onLine`
and visibility signals.

Two regression tests:
- A 404-throwing SDK doesn't fire a second attempt within 250ms (proves
  we left the exponential path). After `online` interrupts the wait,
  subsequent attempts fire promptly — proves the override doesn't break
  recovery once the underlying problem is fixed.
- A 429-throwing SDK recovers within 2s — proves 429 still hits the
  fast exponential path and isn't caught by the permanent-error branch.

---------

Co-authored-by: vhqtvn <8930337+vhqtvn@users.noreply.github.com>
2026-05-18 17:47:20 +03:00

27 KiB

OpenChamber - AI Agent Reference (verified)

Core purpose

OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an OpenCode server (local auto-start or remote URL). UI uses HTTP + SSE via @opencode-ai/sdk.

Runtime architecture (IMPORTANT)

  • Desktop (Electron) boots the web server in the same Node process as the Electron main, then loads the web UI from http://127.0.0.1:<port>. No sidecar subprocess.
  • Desktop (Tauri, legacy) still spawns openchamber-server as a bun-compiled sidecar binary. Kept only for auto-update compatibility with existing Tauri installs.
  • All backend logic lives in packages/web/server/* (and packages/vscode/* for the VS Code runtime). The native shell is not a feature backend.
  • The shell is used only for stable native integrations: menu, dialog (open folder), notifications, updater, deep-links, quit confirmation.

Desktop shell: Electron is the target, Tauri is legacy

  • New desktop work goes into packages/electron/. This is the forward path.
  • packages/desktop/ (Tauri) is kept running in parallel only to preserve auto-update for existing installs until the cutover. Do not add features to it; do not port bug fixes back unless they actually affect currently-released Tauri users.
  • Desktop-side changes (IPC handlers, native integrations, window/quit/notification behavior) land in packages/electron/main.mjs + packages/electron/preload.mjs. The __TAURI__ shim exposed by the preload keeps the shared UI working against both shells, so renderer-side code should not branch on shell type.
  • Electron imports the server via @openchamber/web/server/index.js (workspace dep) and calls startWebUiServer({...}). The returned handle has getPort() / stop(). Notifications flow via an onDesktopNotification callback injected at startup — no stdout-parsing IPC.
  • Build/release: both shells ship in the same GitHub release today (.github/workflows/release.yml). The one-shot Tauri → Electron auto-update migration is documented in docs/TAURI_TO_ELECTRON_CUTOVER.md; run that when the user decides to flip.
  • After the cutover ships and stabilises, packages/desktop/ is deleted; this note collapses back to "Desktop is Electron".

Tech stack (source of truth: package.json, resolved: bun.lock)

  • Runtime/tooling: Bun (package.json packageManager), Node >=20 (package.json engines)
  • UI: React, TypeScript, Vite, Tailwind v4
  • State: Zustand (packages/ui/src/stores/)
  • UI primitives: Base UI (@base-ui/react, primary source for dropdown/select/dialog/menu/tooltip/etc. — wrappers live in packages/ui/src/components/ui/), Radix UI (package.json deps, legacy usages being migrated), HeroUI (package.json deps), Remixicon (package.json deps)
  • Server: Express (packages/web/server/index.js)
  • Desktop (forward): Electron 41 (packages/electron/)
  • Desktop (legacy, maintenance-only): Tauri v2 (packages/desktop/src-tauri/)
  • VS Code: extension + webview (packages/vscode/)

Monorepo layout

Workspaces are packages/* (see package.json).

  • Shared UI: packages/ui
  • Web app + server + CLI: packages/web
  • Desktop shell (Electron — forward): packages/electron
  • Desktop shell (Tauri — legacy, maintenance-only): packages/desktop
  • VS Code extension: packages/vscode

Documentation map

Before changing any mapped module, read its module documentation first.

web

Web runtime and server implementation for OpenChamber.

lib

Server-side integration modules used by API routes and runtime services.

quota

Quota provider registry, dispatch, and provider integrations for usage endpoints.

  • Module docs: packages/web/server/lib/quota/DOCUMENTATION.md
git

Git repository operations for the web server runtime.

  • Module docs: packages/web/server/lib/git/DOCUMENTATION.md
github

GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing.

  • Module docs: packages/web/server/lib/github/DOCUMENTATION.md
opencode

OpenCode server integration utilities including config management, provider authentication, and UI authentication.

  • Module docs: packages/web/server/lib/opencode/DOCUMENTATION.md
notifications

Notification message preparation utilities for system notifications, including text truncation and optional summarization.

  • Module docs: packages/web/server/lib/notifications/DOCUMENTATION.md
terminal

WebSocket protocol utilities for terminal input handling including message normalization, control frame parsing, and rate limiting.

  • Module docs: packages/web/server/lib/terminal/DOCUMENTATION.md
tts

Server-side text-to-speech services and summarization helpers for /api/tts/* endpoints.

  • Module docs: packages/web/server/lib/tts/DOCUMENTATION.md
skills-catalog

Skills catalog management including discovery, installation, and configuration of agent skill packages.

  • Module docs: packages/web/server/lib/skills-catalog/DOCUMENTATION.md

Build / dev commands (verified)

All scripts are in package.json.

  • Validate: bun run type-check, bun run lint
  • Build all: bun run build
  • Desktop build (Electron — primary): bun run electron:build
  • Desktop dev (Electron): bun run electron:dev
  • Desktop build (Tauri — legacy): bun run desktop:build
  • VS Code build: bun run vscode:build
  • Release smoke build: bun run release:test (shell script: scripts/test-release-build.sh)

Runtime entry points

  • Web bootstrap: packages/web/src/main.tsx
  • Web server: packages/web/server/index.js
  • Web CLI: packages/web/bin/cli.js (package bin: packages/web/package.json)
  • Desktop (Electron — primary): packages/electron/main.mjs (boots the web server in-process via startWebUiServer, loads web UI over loopback; preload at packages/electron/preload.mjs exposes the __TAURI__ IPC shim so shared UI code is shell-agnostic)
  • Desktop (Tauri — legacy): packages/desktop/src-tauri/src/main.rs
  • VS Code extension host: packages/vscode/src/extension.ts
  • VS Code webview bootstrap: packages/vscode/webview/main.tsx

OpenCode integration

  • UI client wrapper: packages/ui/src/lib/opencode/client.ts (imports @opencode-ai/sdk/v2)
  • SSE hookup: packages/ui/src/hooks/useEventStream.ts
  • Web server embeds/starts OpenCode server: packages/web/server/index.js (createOpencodeServer)
  • Web runtime filesystem endpoints: search packages/web/server/index.js for /api/fs/
  • External server support: Set OPENCODE_HOST (full base URL, e.g. http://hostname:4096) or OPENCODE_PORT, plus OPENCODE_SKIP_START=true, to connect to existing OpenCode instance

Key UI patterns (reference files)

  • Settings shell: packages/ui/src/components/views/SettingsView.tsx
  • Settings shared primitives: packages/ui/src/components/sections/shared/
  • Settings sections: packages/ui/src/components/sections/ (incl skills/)
  • Chat UI: packages/ui/src/components/chat/ and packages/ui/src/components/chat/message/
  • Theme + typography: packages/ui/src/lib/theme/, packages/ui/src/lib/typography.ts
  • Terminal UI: packages/ui/src/components/terminal/ (uses ghostty-web)

External / system integrations (active)

  • Git: packages/ui/src/lib/gitApi.ts, packages/web/server/index.js (simple-git)
  • Terminal PTY: packages/web/server/index.js (bun-pty/node-pty)
  • Skills catalog: packages/web/server/lib/skills-catalog/, UI: packages/ui/src/components/sections/skills/

Agent constraints

  • Do not modify ../opencode (separate repo).
  • Do not run git/GitHub commands unless explicitly asked.
  • Keep baseline green (run bun run type-check, bun run lint before finalizing changes).

Agent code of conduct

  • Prefer the smallest correct change.
  • Preserve working behavior before improving structure.
  • Do not add cleverness where a direct implementation is enough.
  • Do not infer critical state from weak signals when a stronger source exists.
  • Do not encode policy only in UI; enforce it in core logic.
  • Do not hide data loss, partial failure, or fallback behavior. Make it explicit in code.
  • Finish work end-to-end: implementation, verification, and cleanup.

Development rules

  • Keep diffs tight; avoid drive-by refactors.
  • Follow local precedent; inspect nearby code before introducing new patterns.
  • Backend changes: keep web, desktop, and VS Code behavior consistent when they share contracts.
  • TypeScript: avoid any, blind casts, and shape guessing.
  • React: prefer function components + hooks; use classes only when required.
  • Control flow: prefer early returns and explicit branching over nested ternaries.
  • Styling: Tailwind v4, typography via packages/ui/src/lib/typography.ts, theme vars via packages/ui/src/lib/theme/.
  • Shared UI patterns: reuse shared primitives before introducing feature-local markup patterns.
  • Toasts: use the wrapper from @/components/ui; do not import sonner directly in feature code.
  • No new deps unless asked.
  • Never add secrets or log sensitive data.

Architecture patterns

Thin entrypoints, focused modules

  • Keep orchestration entrypoints thin: index.js, bridge files, bootstrap files, provider roots.
  • Move route, domain, and runtime logic into focused modules with clear ownership.
  • Prefer dependency injection over hidden module coupling.
  • Add or update module documentation when ownership changes.

Strong source of truth

  • Prefer deterministic state over heuristics.
  • Use live server/session state for live activity. Do not let historical anomalies masquerade as current execution.
  • If a fallback is necessary, scope it narrowly to the active entity and treat it as temporary.
  • Restore derived UI state from authoritative records. Example: restore model or agent from the latest user message, not assistant-side guesses.

Live state vs historical state

  • Derive live UI behavior from live state channels, not persisted history.
  • Use historical records to restore context, not to infer that work is still in progress.
  • If live state is delayed, use the narrowest possible transient fallback and clear it as soon as authoritative state arrives.

Cross-runtime parity

  • If web defines a route or payload contract that shared UI depends on, keep VS Code and desktop parity where applicable.
  • Shared behavior differences must be intentional and visible in code.
  • Do not ship a web-only assumption into shared UI.

Partial-failure-safe flows

  • Cross-directory and multi-entity operations must tolerate partial failure.
  • Prefer per-item results, rollback paths, or resumable cleanup over all-or-nothing assumptions.
  • Never leave optimistic state or local caches stranded after failure.

Distinguish fetch failure from empty success

Client API methods that feed authoritative state (bootstrap, reconnect resync, retry loops) must signal fetch failure distinctly from a successful-but-empty server response. A method that swallows errors and returns []/{}/null lets the caller delete or overwrite legitimate state on a transient network blip, indistinguishable from "the server says nothing here."

  • Decide which methods are authoritative. A method is authoritative if any caller uses its result to delete, clear, or replace persisted/sync state. UI-display-only methods (autocomplete, dropdowns, settings pages) can keep silent-empty fallback because the user's next action refreshes them.
  • For authoritative methods, pick one of two patterns — both already exist in the codebase, do not invent a third:
    • Throw on failure (e.g. listPendingPermissions, listPendingQuestions, listAgents, the unwrap() helper in packages/ui/src/sync/bootstrap.ts). Use this when the caller has an outer try/catch per logical block — the throw skips the block and preserves prior state.
    • Return T | null on failure, where null strictly means "fetch failed" (e.g. getSessionStatusForDirectory, the .catch(() => null) + early-return-on-null pattern at the per-session reconnect loop in sync-context.tsx). Use this when the caller has follow-up work that should still run when one fetch fails.
  • Never swallow inside the method while returning the same type as success. The SDK's {data, error} shape already does this silently — wrap with if (result.error) throw … so the failure can't be lost.
  • Verify the caller actually preserves state on failure. Adding the throw is only half the fix; the consumer must not run the "delete missing" / "overwrite" branch unless it knows the fetch succeeded. The relevant outer try/catch is often already there but dormant.
  • Retry loops require a failure signal. A for (let attempt = 0; attempt < 3; …) retry around a method that swallows to [] will run exactly once — the loop never sees an error.

This rule is the API-layer counterpart of "Use live server/session state for live activity. Do not let historical anomalies masquerade as current execution." A fetch failure is the same kind of anomaly — don't let it masquerade as authoritative server state.

Reconnect-loop pacing

The SSE/WebSocket reconnect loop in packages/ui/src/sync/event-pipeline.ts retries indefinitely. To avoid burning battery and server load on dead/idle connections, the loop's pacing must respect three signals:

  • navigator.onLine: when the browser reports offline, use the long backoff cap (~60s) instead of the short one (~5s). The expected recovery path is the online event, not the next probe.
  • document.visibilityState: when hidden, use the long cap too. A backgrounded PWA shouldn't hammer the network at 1/5s; the browser may also throttle our timers, but state the intent in code rather than relying on it.
  • HTTP status of the last failure: permanent 4xx errors (401, 403, 404, …) don't recover from blind retry. Jump straight to the long cap instead of running the normal exponential path; otherwise a stale-path or expired-token client would put ~12 reqs/min on the server log forever. 408 (Request Timeout) and 429 (Too Many Requests) are retryable in spirit — let them go through normal backoff.
  • Consecutive failures: real exponential growth (base * 2^failures, clamped), not constant 500ms. A hard-down server should see geometrically fewer probes per minute over time.

The inter-attempt wait must be interruptible by online, visibility-becomes-visible, and the pipeline's abort signal — otherwise recovery is delayed by however long the current sleep had left to run.

CLI Parity and Safety Policy (MANDATORY)

Principle: policy-first, UX-second

All safety and correctness rules MUST be enforced in core command logic, independent of output mode.

Interactive/pretty UX (@clack/prompts) is a presentation layer only. It must never be the only place where validation or restriction is enforced.

Required parity across modes

The same functional outcome and safety gates MUST hold for all execution modes:

  • Interactive TTY (full Clack UX)
  • Non-interactive shells (piped/stdin-less automation)
  • --quiet
  • --json
  • Fully pre-specified flags (no prompts)

In all modes, invalid operations MUST fail with non-zero exit code and deterministic error semantics.

Non-negotiable rule

Do not rely on prompts to enforce policy.

  • Prompts MAY help users choose valid inputs.
  • Core validators MUST run even when prompts are unavailable or skipped.
  • --quiet suppresses non-essential output only; it does not weaken validation.
  • --json changes output shape only; it does not weaken validation.

Detailed Clack UX patterns (primitives, prompt gating, and implementation checklist) are defined in the clack-cli-patterns skill and should not be duplicated here.

Clack CLI Skill (MANDATORY for terminal CLI work)

When working on terminal CLI commands, prompts, or output formatting, agents MUST study the Clack CLI skill first.

Before starting terminal CLI work:

skill({ name: "clack-cli-patterns" })

Scope: terminal CLI only (for example packages/web/bin/*). Do not apply this requirement to VS Code or web UI work.

Theme System (MANDATORY for UI work)

When working on any UI components, styling, or visual changes, agents MUST study the theme system skill first.

Before starting any UI work:

skill({ name: "theme-system" })

This skill contains all color tokens, semantic logic, decision tree, and usage patterns. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes.

Performance rules (MANDATORY)

These rules exist because violating them has caused measurable regressions (render cascades, memory bloat, UI jank). They apply to all UI and sync layer work.

Shared-store render discipline

  • Treat common stores as render fanout boundaries. An unnecessary reference change in shared state can re-render large parts of the app.
  • Do not put high-frequency state in broadly consumed stores. Fast-changing state should live in narrow stores with narrow subscribers.
  • Update only the fields that changed. Preserve references for untouched state branches.
  • Prefer leaf selectors over container selectors. Subscribe to the smallest stable value that satisfies the component.
  • Isolate hot consumers. If a value changes often and only a few components need it, move it to a narrower store or consume it in a memoized child.
  • Do not subscribe shell/layout components to broad live collections. If a shell only needs one field, entity, or derived flag, subscribe to that instead of the whole collection.
  • Treat provider roots as global hot paths. A top-level provider must not subscribe to high-frequency data unless the feature is actually enabled and the subscription is essential.

Zustand referential equality

Zustand skips re-renders when a selector returns the same reference (Object.is). Every new object/array reference triggers a re-render in every subscriber.

  • Never spread all state fields in an update. Only create new references for fields that actually changed. A message.part.delta event should not clone session, permission, etc.
  • Select leaf values, not containers. useStore((s) => s.permission[sessionID]) is correct. useStore((s) => s.permission) subscribes to every permission change across all sessions.
  • Preserve references when merging. If prepending older messages, keep existing message object references. Only add truly new items. Return the original array if nothing was added.
  • For derived collections, preserve item identity when presentation-relevant fields are unchanged. Reuse previous item references for unchanged rows/items and move high-frequency live fields to narrow per-item selectors.

Store splitting

A single store with N properties means every subscriber re-evaluates on every state change. Split stores by change frequency and subscriber set.

  • Group state by how often it changes. Streaming state (updated 60/sec) must not live with user preferences (updated on click).
  • Group state by who reads it. If only 2 components need a value, it belongs in a store that only those 2 subscribe to.
  • Cross-store reads use .getState(). Actions in one store that need another store call useOtherStore.getState() — imperative, no subscription.
  • Never add unrelated state to an existing store just because it's convenient. Create a new store.

Event pipeline and SSE

  • Gate expensive operations on the hot path. During streaming, message.part.delta and message.part.updated fire ~60/sec. Any findIndex, filter, or iteration added to these handlers multiplies across every event. Gate behind a cheap boolean check first (e.g., check next[0] before scanning the array).
  • Skip no-op updates. If an incoming event doesn't change the state (same role, same finish, same timestamps), return false from the reducer to avoid creating new references.
  • Coalesce by key. Same-entity events (e.g., repeated session.status for the same session) should replace earlier ones in the queue, not accumulate.
  • Preserve event ordering semantics. Reducers and queues must not let stale deltas or out-of-order events corrupt the latest state.
  • Do not widen live-activity fallbacks. A fallback for delayed status should inspect only the current trailing entity, not arbitrary historical records.

Polling payload fidelity

  • Do not let lightweight polling erase rich fields. If light mode omits fields (e.g., diffStats), preserve previous rich data until a heavy follow-up fetch lands.
  • Use two-phase polling. Run cheap change detection first; only run heavy status fetches for directories that actually changed.

Optimistic updates

  • Use the shadow Map pattern. Insert optimistic data into the store for instant UI, AND register it in a separate tracking Map. Cleanup happens deterministically via mergeOptimisticPage on the next data fetch — not via heuristics in the event reducer.
  • Pass client-generated IDs to the server. Use the same ID format as the server (hex-encoded timestamps). Pass messageID to promptAsync so the server echoes back the same ID. This prevents duplicates and enables in-place replacement.
  • Rollback on error. Remove the optimistic entry from both the store and the shadow Map.
  • Stabilize bridge callbacks. When wiring hook callbacks into module-level refs, use stable ref wrappers so effects do not loop on changing function identities.

Session/input consistency

  • Capture send config at queue time. Queue items must include provider/model/agent/variant snapshot; do not re-resolve from mutable live state at send time.
  • Keep server-selected attachments sendable. Preserve server-backed file selections in queue/submit flows and convert them to proper file:// URLs before sending.
  • Do not let text input state repaint unrelated chrome. Typing should not force unrelated controls, menus, indicators, or toolbars to re-render on every keystroke.
  • Extract slow-changing chrome from hot input paths. If controls do not depend on the current text value, move them behind memoized boundaries with stable callbacks.

Bootstrap resilience

  • Treat startup 502/503 as transient. Retry bootstrap/session-list flows with bounded retries/intervals, especially in VS Code where API readiness can lag bridge startup.
  • Use polling recovery when failures are swallowed. If an async loader resolves without throwing on failure, recover with interval retries gated by loaded-state checks.

Scroll and DOM

  • Never use await waitForFrames() for scroll preservation. Frames of visible scroll jump are unacceptable. Use useLayoutEffect to adjust scroll synchronously after React commits DOM — before the browser paints.
  • Capture scroll state before the state change, restore in layout effect. The pattern: save scrollHeight/scrollTop into a ref before triggering the update, consume it in useLayoutEffect on the rendered output.
  • Do not let viewport resizes masquerade as content growth. Viewport-height changes must not trigger the same scroll compensation logic used for actual content growth.
  • Disable or narrow native/browser scroll anchoring when custom scroll logic exists. Browser anchoring and app-managed pinning/follow logic will fight and produce jiggle.
  • Autosize textareas without transient collapse on growth. Avoid height='auto' shrink/expand cycles on every character when the content only grew; this creates visible layout bounce.

List ordering and view consistency

  • Do not sort structural lists directly from high-churn live fields. If live updates are frequent, sorting directly from them causes reorder thrash and wide rerender cascades.
  • If live recency is required, freeze order during high-frequency updates and apply a one-shot reorder only at an intentional lifecycle edge. Choose the lifecycle edge explicitly instead of letting every intermediate update reshuffle the UI.
  • Use one ordering source for all views of the same data. Different views of the same entities must derive from the same ranked list or rank map; do not let each surface re-derive ordering independently.
  • Do not mix global snapshots and local live snapshots without an explicit reconciliation policy. If multiple data sources feed one view, define which fields win and how they merge.

Component isolation

  • Extract high-frequency hook consumers into separate components. If a hook re-evaluates 60/sec (e.g., streaming status), wrap its consumer in a React.memo child component so the parent doesn't re-render.
  • Use custom React.memo comparators for message rows. Compare render-relevant fields (role, finish, parts count, part IDs) — not object references.

Caching and memory

  • Cap in-memory caches with both count and byte limits. Entry count alone doesn't prevent memory bloat from large files. Use dual-constraint LRU (e.g., 40 entries OR 20MB).
  • Set store session limits to match loaded data. If bootstrap loads N sessions, set limit >= N. Otherwise the next SSE event triggers trimming that silently removes sessions.
  • Invalidate caches on mutations. File content cache must clear entries on write, delete, rename. Prefetch cache must clear on session eviction.
  • Use TTLs to prevent redundant fetches. If a session was fetched <15s ago, skip re-fetching — SSE events keep it current.

Directory context

  • Never cache directory strings in closures. Directory can change at any time (worktree switch). Read it dynamically from opencodeClient.getDirectory() at call time.
  • Pass directory hints when the source of truth isn't available yet. Newly created sessions aren't in the sync store until SSE delivers them. Pass the known directory as a parameter instead of relying on lookup.

Regression-prevention checklist

  • When adding fallback logic, ask: can stale persisted data keep this path active forever?
  • When deriving UI state, ask: is this live state, historical state, or inferred state?
  • When adding store fields, ask: who reads this, how often does it change, and should it live elsewhere?
  • When touching polling or bootstrap, ask: can a lighter payload erase richer existing data?
  • When handling optimistic updates, ask: where is rollback, reconciliation, and duplicate prevention?
  • When changing shared routes or state contracts, ask: what breaks in web, desktop, and VS Code?
  • When fixing a bug with a heuristic, prefer narrowing the heuristic over widening it.

Validation expectations

  • Run bun run type-check and bun run lint before finalizing.
  • For hot-path changes, verify behavior under streaming or repeated events, not just static render.
  • For sync or startup changes, verify fresh load, retry/failure, and restart behavior.
  • For session changes, verify create, stream, abort, permission, archive/delete, and revisit flows when relevant.

Recent changes

  • Releases + high-level changes: CHANGELOG.md
  • Recent commits: git log --oneline (latest tags: v1.4.6, v1.4.5)