The stale-event check excluded heartbeats from lastActiveEventAt, so a
quiet-but-connected session (only receiving heartbeats) tripped the 20s
stale timer and triggered a full resync every ~15s. This re-fetched
listPendingQuestions, listPendingPermissions, session.get, and
session.messages despite the event stream being healthy.
Track all stream activity (including heartbeats) in a global
lastStreamActivityAt ref. The stale check now only fires when no events
at all arrive for 20s, meaning the stream is genuinely dead.
Resyncs still fire correctly on genuine reconnects, transport switches,
and status-poll escalation when a real discrepancy is detected.
Fixes#1656
Reworks the chat and session-sidebar render paths to cut render cascades, memory
churn, and UI jank on large sessions and big session trees. Behavior is preserved;
the changes are about *when* and *how much* the UI re-renders.
## Chat streaming
- Freeze the streaming message's parts in the bulk turn projection during streaming,
and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
no longer re-runs the whole-session projection or re-renders unrelated rows.
session with referential reuse of unchanged turns.
- Memoize message rows with field-aware comparators instead of reference equality.
- Replace the manual child-session polling in the task tool with the live SSE
stream + a one-shot load, removing a fetch/settle state machine.
## History loading & scroll
- Load an initial page fast, then prepend one older page in the background so the
scroll container has headroom and "load older on scroll-up" fires before the user
hits the absolute top.
- Compensate scroll synchronously (in a layout effect, before paint) for prepends —
including background prepends that don't originate from a user scroll — so the
viewport stays stable instead of judder-correcting on the next frame.
## Markdown rendering
- Render markdown synchronously *styled* on first paint (paragraphs, lists, code
cards, tables, inline code) instead of raw escaped text; the async pass then only
upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
- Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
chunk, avoiding a late stylesheet injection on first render.
## Sidebar
- Hoist per-row recursive tree walks out of row comparators into per-group
precomputed sets/keys; batch live-session lookups into a single map; add a
group-level memo boundary.
- Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.
## Sync layer
- Add a staleness guard so a slow message fetch can't repopulate a session the user
navigated away from.
- Throw on fetch failure for authoritative loaders so a transient blip can't read as
an empty server response.
## Cleanup
- Remove dead code (unused hooks, params, duplicated inline types) surfaced while
reworking the above.
## Known issue
- A rare, purely cosmetic first-paint width flash can still appear on large sessions;
it has no behavioral or data impact and is tracked for a follow-up runtime trace.
Desktop notifications no longer duplicate when native delivery succeeds
Reasoning chain-of-thought is excluded from notification body text
Untyped message parts are ignored in notification text extraction
* perf(startup): hydrate providers/agents from cache (stale-while-revalidate)
Persist last-known provider/agent snapshots instead of stripping them, so the
model/agent pickers paint instantly on cold start. Freshness is preserved by the
background refresh in initializeApp() and activateDirectory() (which overwrite on
success) and by the existing provider/agent config-change subscriptions, so the
prior stale-provider regression stays fixed without blanking the UI during fetch.
* perf(startup): cache directory session list for instant sidebar
Persist a capped slice of each directory's session list and seed the child store
from it on creation, so the sidebar paints chats immediately on cold start.
Bootstrap phase-3 loadSessions overwrites with the fresh list; its empty-list
race guard preserves the seeded sessions during OpenCode warmup.
* perf(startup): hold API requests through OpenCode warmup instead of 503
The readiness gate returned 503 the instant OpenCode wasn't ready, pushing the
client into an exponential-backoff retry loop (500ms -> 1s -> ...) that wasted
seconds of cold-start time and could fail bootstrap outright. Now hold the
request and poll readiness up to a bounded window so the first call succeeds as
soon as OpenCode is up (typically sub-second); still 503 fast past the window so
a genuinely-down server doesn't hang. Adds coverage for both paths.
* perf(startup): surface cached providers/agents in pickers (optimistic readiness)
The model/agent pickers gated purely on isInitialized, so they showed
"Loading…" for the entire init round-trip even when provider/agent data was
already hydrated from cache — making the persisted-cache work invisible. Treat
the pickers as ready as soon as cached providers are present (stale-while-
revalidate), so they paint last-known models/agents instantly and refresh in the
background. First-ever launch (no cache) still shows Loading until init.
* perf(startup): don't abort directory bootstrap on transient phase-1 failure
A failed initial path.get OR session.status aborted the whole directory
bootstrap, stranding it in loading and skipping phase 2/3 (session load).
session.status is live data the event pipeline keeps current, and path.get is
tolerable once a project is resolved from global state. Now only a total
failure (or path.get failing with no resolved project) aborts, so the sidebar
and chat keep advancing and loading sessions through warmup hiccups.
* perf(startup): don't bootstrap directories from archived sidebar rows
Each sidebar session row called useDirectoryStore(dir), which defaulted to
bootstrap:true and triggered a full directory bootstrap. Archived sessions point
at dozens of (often deleted) worktrees, so on startup this fired a session-list
fetch + 6x2s empty-retry storm per dead directory (the logs the user saw). The
store ref there is only read on-demand via getState() in export handlers, never
subscribed, so archived rows don't need it bootstrapped. Add a { bootstrap }
option to useDirectoryStore and skip bootstrap for archived rows; active rows
still bootstrap so live cross-directory session/status keeps aggregating.
* perf(startup): stop empty-session bootstrap retry storm on web/desktop
The post-bootstrap retry re-ran the full directory bootstrap 6x2s whenever the
session list came back empty, on the theory that empty meant OpenCode wasn't
ready. But loadSessions already retries transient failures twice over
(listGlobalSessionPages throws on 5xx and retries internally), so on web/desktop
an empty result is authoritative — the directory genuinely has no sessions (e.g.
deleted worktrees referenced only by archived sessions). That produced the
dozens of '[bootstrap] sessions empty ... 6 attempts; giving up' log storms.
Gate the retry to VS Code, where the bridge can return an empty 200 during
warmup that the inner retries can't catch.
* perf(startup): scope provider/agent config to project (worktrees inherit)
Providers/agents/defaults are project-level, but were keyed per directory, so a
worktree fetched and cached its own snapshot — duplicating the parent project's
load (the trace showed initializeApp loading the worktree and activateDirectory
loading the project concurrently, ~8s of redundant background work).
- resolveConfigDirectory() maps a worktree to its owning project; loadProviders
/loadAgents/activateDirectory now key by it, so a worktree reuses one shared
project snapshot. activateDirectory resolves up-front so activeDirectoryKey and
the snapshot key always match (picker stays consistent); the OpenCode working
directory is unaffected.
- Add a 30s runtime freshness guard so the stale-while-revalidate background
refresh skips re-fetching config that was just loaded (initializeApp then
activateDirectory for the same project), and to avoid churn on rapid project
switches. Config-change invalidation clears the snapshot, which bypasses the
guard, so freshness never masks a needed refresh.
* fix(sidebar): default archived sessions to hidden to avoid startup flash
useSessionDisplayStore defaulted showArchivedSessions to true, so on startup
archived sessions rendered by default and then vanished once the persisted
preference rehydrated to hidden — a visible flash. Default to hidden so the
pre-hydration state is the quiet one; users who opted into showing archived keep
their persisted true (default change doesn't override persisted state).
* perf(startup): persist worktree->project mapping to kill cold double-load
The worktree->project map (availableWorktreesByProject) is populated by async git
discovery, so it isn't ready when initializeApp runs — a worktree's first config
load couldn't resolve to its project and duplicated the project's provider/agent
load, saturating OpenCode during cold start (the source of the slow first
createSession/send the user observed). Cache resolved worktree->project mappings
to localStorage so resolveConfigDirectory resolves synchronously at init on
subsequent launches; the project is loaded once and activateDirectory hits the
freshness guard. worktree->project is immutable so a cached entry is safe; live
resolution still populates/corrects the cache.
* perf(startup): persist worktree map for instant sidebar + first-launch keying
Worktree discovery is async (git), so availableWorktreesByProject was empty at
startup: the sidebar worktree list appeared late, and useConfigStore couldn't
resolve a worktree to its project on the first launch (causing the cold
worktree+project double-load). Persist the discovered worktree map to
localStorage and seed it synchronously on store init (stale-while-revalidate:
discovery refreshes in the background via the existing setState, which now
write-through persists). The sidebar paints worktrees instantly and
resolveConfigDirectory resolves the project from the very first launch.
* perf(startup): coalesce concurrent duplicate OpenCode reads in runtimeFetch
On cold start the sync bootstrap and the config store independently fire the same
idempotent reads (providers, config, path, agents, project) concurrently with no
shared dedup, saturating the single OpenCode process and delaying work queued
behind it (e.g. createSession). Coalesce genuinely-concurrent identical GETs to
those read endpoints at the transport layer so OpenCode does the work once; each
caller receives an independent response clone. Tightly scoped: GET only,
allowlisted read paths, never event streams, never a signal-bearing request (so
one caller's abort can't cancel the shared fetch). Entries clear on settle, so it
only shares overlapping in-flight requests — never a stale response.
* perf(startup): cache git branches so the draft branch selector paints instantly
The branch selector above the composer was the slowest-loading element: it's
gated behind a cold 'git branch' fetch (useGitStore, not persisted). Cache the
per-directory branch list to localStorage and seed the store on init (with
isGitRepo:true so the selector's gate passes), and write the cache on every
successful fetchBranches. The ChatInput draft-branch effect now refreshes on
staleness (>30s) rather than mere absence, so seeded branches show immediately
and still refresh in the background without a spinner — no stale-forever
regression. Only the branch list is cached; status/log/diff are untouched.
The global event-stream WebSocket opened before a valid oc_url_token was
minted, so the upgrade failed auth ("no valid credentials available") in
packaged builds with a UI password. The resulting reconnect storm churned
the sync store and made session status flicker busy<->idle. Await the URL
auth token before connecting (a WS upgrade can't send a bearer header like
SSE does) and drop a rejected token on pre-ready close so the next attempt
re-mints a fresh one.
Also harden /session/status reconciliation: the watchdog poll is now
monotonic (only confirms/raises active status, never blindly lowers a
busy/retry session to idle on a transient or misscoped snapshot). Idle is
applied only by the authoritative reconnect/escalation resync, which trusts
the live server snapshot as the source of truth. Add a Help -> Toggle
Developer Tools menu item so production builds can open the console.
The tray showed the busy indicator for at most one session at a time.
Several gaps in how per-session status was sourced stacked up to that:
- Status was derived by iterating each sync child store's session list,
so a busy session missing from the list (created moments earlier from
another window, the tray, or the API while session.created raced or
the list got trimmed) was invisible even though the store's
session_status map already held its busy entry.
- The upstream /session/status endpoint is directory-scoped — querying
it without a directory only covers the server's own cwd, so there was
no authoritative cross-project snapshot to fall back on.
- Status events for directories without a child store were dropped by
the sync dispatcher, so sessions in unopened projects always rendered
idle.
Fix, layer by layer:
- Add a cross-project session-status store (sync/global-session-status)
fed two ways: the sync dispatcher now records status-bearing events
(session.status / session.idle / session.error) for ALL directories,
and the tray polls /session/status per visible-session directory to
seed initial state and reconcile missed events. Snapshots clear stale
entries both by directory key and by session id, so canonicalized
(realpath) directory mismatches can't strand a busy entry.
- Read live status straight from each child store's session_status map
instead of via its session list, and never let one store's idle entry
clobber another store's busy/retry for the same session.
- Resolve a session as active when either source (child stores or the
cross-project map) reports busy/retry, instead of letting the synced
store's idle shadow the fallback.
Verified end-to-end in the dev shell: two sessions running concurrently
in different projects — including a brand-new session in the open
project root, the exact case that failed — now both show busy in the
tray, and both return to idle when they finish.
Add an always-visible macOS status bar (tray) item that surfaces OpenChamber's
live state and acts as a quick launcher, plus a series of related desktop UX
fixes around mini-chat, window routing, notifications and shortcuts.
Tray (new):
- Monochrome template cube glyph that adapts to the menu bar light/dark.
- Icon-driven activity indicator: a smooth, eased, infinite "breathing" fill
while sessions are busy; a static filled cube when finished sessions are left
unread; a plain outline when idle. Text counters next to the icon only for
actionable states (pending approvals, errors).
- Menu lists active sessions (status glyph, branch, unread count) with overflow
rolled into a submenu; pending permission/question approvals with inline
Allow once / Allow always / Deny; quick actions (New Session, New Mini Chat,
Show OpenChamber, Quit). Header shows the active instance name
("Local OpenChamber" or the remote host label) for multi-window clarity.
- Session list sourced from the global (cross-project) sessions store, sorted by
last-updated, independent of which directories are currently open; live
status/unread/branch merged in from directory sync stores where available.
Sub-session (multi-run) activity rolls up to the parent row.
- Event-driven updates (global store + directory stores + notifications +
registry) with a short debounce; polling kept only as a slow safety net.
Tray/window routing:
- Opening a session from the tray targets the surface the user was last on: if a
mini-chat is active it switches that existing window to the session in place
(no new window); otherwise the main window (revealed without a reload).
- app.activate (dock click) restores the last-focused/minimized window instead
of spawning a new main window; only creates one when nothing is left.
- "Open in main window" and tray session-open now create the main window when
none exists, queuing the session as a pending deep-link so it opens once the
fresh renderer is ready.
Mini chat:
- New Mini Chat is now a customizable shortcut, exposed in Settings > Shortcuts,
in the File menu (hint only, renderer owns the binding), and in the tray.
- Themed splash backdrop on window open to remove the white flash / flicker;
dismissed once content is ready, leaving the content's single cube logo.
- Mini-chat can switch sessions in place via openchamber:open-session.
Notifications:
- The active/selected session only counts as "seen" when the window is focused,
so turns completing while the app is backgrounded raise an unread marker;
refocusing the window clears it.
* feat: add draw.io diagram editor integration
Embed draw.io editor via react-drawio (MIT, zero deps) for inline
editing of .drawio files. Changes auto-save to disk. Includes
inline editor in FilesView with Visual/Source toggle, dark mode
support, template picker for new files, and chat file attachment
integration.
* fix: debounce diagram autosave to prevent reload loop
* fix: ignore watcher-triggered xml prop changes to prevent reload loop
* fix: remove auto-save-to-disk, add manual save button for diagrams
Autosave writes triggered file watcher cascade that reloaded the
draw.io iframe and reset zoom. Replaced with explicit Save button
in the toolbar (floppy disk icon). Editor XML is stable on mount
and ignores watcher-triggered prop changes.
* fix: remove auto-save write from DiagramView, add save button
* fix: hide draw.io save/exit buttons in editor
* fix: also hide save-and-exit button
* fix: brighten save button styling, add saved confirmation
* fix: remove autoSaveStatus toggle on diagram save to prevent toolbar collapse
* fix: add local save confirmation state for diagram button
* fix: remount drawio iframe on theme change, persisting XML across mounts
* fix: clear persisted xml on mount to prevent leaking between files
* fix: initialize dark mode synchronously, preserve edits across theme remount
* fix: auto-focus drawio iframe on mount/theme-change for keyboard shortcuts
* fix: add diagram i18n keys to Traditional Chinese locale
* fix: restore upstream HMR host and LAN address support
* fix: load sub-agent sessions on bootstrap for sidebar visibility
Two-phase session load: first fetch root sessions (for accurate
sessionTotal), then fetch all sessions and include child sessions
(sub-agent delegations). This ensures sub-agent sessions appear
in the sidebar immediately instead of relying on the async global
session store.
* remove opencode-drawio from PR branch
* fix: atomic file writes to prevent concurrent read/write truncation
Three-layer defense against the O_TRUNC race:
1. Write side (server): replace direct writeFile with write-to-temp-
then-rename. fs.rename is atomic on POSIX.
2. Read side (server): retry up to 3 times with 50ms backoff when
readFile returns empty but stat reported non-zero size.
3. FilesView client: refuse to save empty draftContent when the
original fileContent was non-empty.
* fix(dev): clean up orphaned OpenCode processes on Ctrl+C
* fix: allow empty file saves, log warning instead of blocking
Replaces the hard block on saving empty content with a console.warn.
The atomic write + read retry on the server side handle the O_TRUNC
race properly. The previous guard caused a UX regression by silently
preventing users from clearing a file and saving.
* fix: remove time window from sub-agent fallback for live tasks
While a task tool is active, the fallback now matches any session
with the correct parentID regardless of creation time. This allows
late-appearing child sessions to be found when the OpenCode server
is slow or the SSE event pipeline is delayed. The time window is
still applied once the task tool has completed, as a final sanity
check.
* fix: three diagram editor bugs from Greptile review
1. stableXmlRef now resets when xml prop changes — switching
between .drawio files renders the correct content.
2. Focus effect only runs on mount, not on isDark changes —
theme toggle no longer steals keyboard focus 600ms later.
3. saveDiagram updates xml state after writing — dirty-check
guard works correctly for subsequent saves.
* fix: route session.created SSE events to correct directory
Three-layer fix for sub-agent sessions not appearing in sidebar and
inline chat:
1. protocol.js: parseSseEventEnvelope now extracts directory from
properties.info.directory (where session.created/updated events
carry it) in addition to properties.directory. WS frames relayed
to the browser now carry the real directory instead of 'global',
so child sessions routed to the correct directory store.
2. event-pipeline.ts: same fallback in resolveEventDirectory for
defense-in-depth when SSE events bypass the WS relay.
3. resolveFallbackTaskSessionId.ts: time window lower bound now
allows 2s grace before taskStartTime to accommodate server timing
jitter (child session creation timestamps consistently precede the
tool's recorded start by ~6-9ms), fixing the 'Open subtask'
button not rendering in OpenChamber's inline chat.
* fix: sub-agent sidebar visibility, file zeroing guard, inline badge fallback
- Sync watchdog: periodic child session discovery poll (every 15s) detects
sessions created by other OpenCode instances, triggers parent materialization
- protocol.js: parseSseEventEnvelope extracts directory from
properties.info.directory for session.created/updated events
- event-pipeline.ts: same fallback in resolveEventDirectory for defense-in-depth
- resolveFallbackTaskSessionId: don't require taskStartTime (cross-OpenCode);
pick most recent child when multiple idle candidates exist
- readTaskSessionIdFromOutput: parse <task id="ses_xxx"> format from output
- FilesView: reinstate empty-draft guard (block save when draftContent='' but
fileContent had content) to prevent file zeroing on tab switch
* Fix diagram autosave reload loop
* Highlight drawio files as XML
* Use diff-compatible highlighting for drawio files
* Restore drawio file icon mapping
* Stabilize drawio source preview toggle
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
Keeps optimistic prompt state in the session directory
Routes live assistant part updates using upstream event payloads
Adds regressions for startup session switch races
Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.
Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.
Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.
Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.
Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.
Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
Treat the mobile web surface as a constrained runtime so sync loads smaller message pages, keeps fewer warm session caches, and evicts heavy inactive sessions instead of retaining them across switches.
Limit mobile message-record and turn-model caches to reduce memory pressure on phones while preserving bounded initial page expansion for large final turns.
Split the mobile session status bar so the collapsed state avoids subscribing to the full session/status list; the expensive grouping work now only mounts for the expanded list.
Verified with bun run type-check and bun run lint.
Reconnects and resyncs active sessions when live updates stall
Normalizes synthetic session status events
Uses authoritative status snapshots to clear stale busy states
Improve chat session switching and history pagination, with most of the aggressive limits scoped to the VS Code webview where the freezes were observed.
Session history loading and pagination:
- Reduce the VS Code message page size to 30 records so switching sessions does not immediately hydrate large histories into the webview.
- Keep manual Load older messages in VS Code fixed at 30 records per request instead of growing the request size over time.
- Add a bounded VS Code initial-tail expansion path from 30 to 50, 80, and 120 records only when the initial page has no user-message turn boundary, preventing large final turns from rendering as an empty chat.
- Lower the normal web message page size from 200 to 150 for a mild shared optimization without adopting the aggressive VS Code limits.
- Make session pagination metadata reactive per session so ChatContainer receives cursor updates from materialization and reconnect paths without requiring a switch away and back.
- Write pagination metadata before publishing newly materialized messages so the first render sees the correct has-more state.
- Store cursor information from direct materialization and reconnect message fetches in the shared session prefetch metadata cache.
VS Code cache and memory pressure reductions:
- Use a shared per-directory session recency map so cache eviction is based on app-level recency instead of whichever useSync instance happened to run.
- Limit VS Code warm session cache retention to 4 sessions and evict heavy inactive message caches after switching away from a large session.
- Disable sidebar session prefetch in VS Code because warming extra sessions was increasing webview memory and GC pressure during navigation.
- Remove dropdown background message prefetch so opening the switcher does not start additional session materialization work.
- Drop cached session-message-record snapshots when evicting session data so stale derived records do not remain after the raw session cache is cleared.
- Add bounded LRU caching for session message record snapshots, with much smaller VS Code limits and a VS Code cap that avoids caching snapshots above 30 messages.
- Bound the turn-window model cache in VS Code and avoid caching turn models for sessions above the VS Code message-page size.
Chat render-path reductions:
- Reuse ChatContainer's already-materialized message records in plan detection instead of adding a second active-session message subscription.
- Add a no-op guard when marking session plan availability so repeated detections do not create new Map references and fan out renders.
- Add no-op guards for session switcher and dropdown open state updates to avoid unnecessary store updates and renders.
- Convert several session-specific hooks to useSyncExternalStore with empty-session no-subscribe behavior so empty IDs do not subscribe to broad store updates.
- Remount the chat viewport when the current session changes, isolating per-session viewport and list state.
- Change the virtualized message-list fallback to render only a tail window when the virtualizer has not produced rows yet, instead of rendering an entire large history.
VS Code layout and header improvements:
- Remove the broad useSessions subscription from the VS Code layout header path and subscribe only to the active session title and initial-session existence.
- Unmount the compact VS Code session sidebar when the user is in chat view instead of keeping the hidden session list mounted and subscribed.
- Compute the latest assistant model and latest context-token usage in a single reverse scan of current-session messages instead of scanning the same list twice.
- Remove switcher git-status warmup work so the switcher reads already-loaded branch labels without starting extra background git status requests.
Markdown and file-reference safeguards:
- Skip expensive syntax highlighting for very large code blocks, with a 200-line cap in VS Code and a softer 1200-line cap in web.
- Add an LRU cap to file-reference stat lookups so the cache cannot grow without bound across many rendered messages.
- Limit the number of file references annotated per render to 40 in VS Code and 200 in web to prevent large assistant outputs from spawning too many stat checks.
- Clear file-link annotations when file-reference mode is disabled so stale attributes and handlers do not remain on previously annotated nodes.
Assistant-message action and preview reductions:
- Skip preview URL scanning on VS Code, mobile, and mini-chat surfaces so assistant text and tool output are not scanned where the preview action is unavailable.
- Skip Save-as-Plan project lookup on VS Code, mini-chat, and mobile surfaces.
- Hide Save-as-Plan and Start MultiRun assistant-message actions on VS Code, mini-chat, and mobile surfaces.
- Resolve the current session directory on demand for assistant actions instead of subscribing each assistant message to the full session list.
Tool and task rendering optimizations:
- Prefer finalized task metadata summaries without fetching child-session messages when the summary is already present.
- Avoid polling or final-fetching task child sessions once a final metadata summary is available.
- Use VS Code-specific task child fetch limits of 30 records for initial, active, and idle fetches.
- Parse diff stats by scanning patch text line-by-line instead of splitting large patches into arrays.
- Count write-tool lines by scanning content instead of allocating a split array for large files.
- Avoid trimming large patch strings just to test whether they contain content.
- Memoize diff and write statistics so unchanged tool parts do not recalculate them on every render.
VS Code bridge improvements:
- Return JSON and text proxy responses through the VS Code bridge as bodyText instead of base64 so the webview avoids synchronous base64 decoding for common API responses.
- Keep binary responses on the base64 path while making bodyBase64 optional in the bridge contract.
- Strip content-length, content-encoding, and transfer-encoding headers from proxied responses because the bridge reconstructs the Response body.
Validation:
- bun run type-check
- bun run lint
- bun run vscode:build
Move VS Code/Cursor desktop notifications onto the webview Notification API instead of the extension-host watcher path, which could not reliably produce native OS notifications.
Route OpenCode runtime events from the shared sync pipeline into the VS Code webview so completion, error, question, and permission notifications use the same live event stream as the UI.
Respect the OpenChamber notification settings in VS Code, including template rendering, completion cooldowns, permission auto-accept suppression, and the notify-while-focused mode.
Use VS Code's window focus signal from the extension host instead of document.hasFocus() inside the webview, so hidden-only notifications are suppressed while Cursor or VS Code is focused across platforms.
* 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>
Add dedicated Electron Mini Chat windows for focused chat sessions without the full desktop shell. Mini Chat can open existing sessions or draft sessions, supports pinning above other windows, transfers sessions or drafts back to the main window, and deduplicates existing-session windows.
Expose Mini Chat entry points from the main header, session sidebar, command palette, and `mod+alt+n`. Add a dedicated Vite entry and React runtime so the compact surface can stay isolated from full-app chrome while still sharing chat, sync, theme, locale, model, agent, and worktree behavior.
Keep Mini Chat behavior scoped to the compact surface:
- limit assistant/user message actions to the appropriate Mini Chat set
- hide workspace changed-files UI in Mini Chat
- keep draft worktree selection and streaming directory state in sync
- mark sessions viewed while they are open in Mini Chat
- support Mini Chat-specific keyboard shortcuts for input focus, model selection, thinking variant cycling, favorite model cycling, and opening new Mini Chat drafts
Harden Electron integration by gating Mini Chat controls on desktop IPC availability, restricting pin/unpin IPC to Mini Chat windows, and only closing Mini Chat after the main window handoff succeeds.
Canonicalize session message/part materialization across load, prefetch, reconnect, and recovery paths so OpenChamber restores session snapshots through one consistent merge flow.
Preserve live assistant streaming text when stale or delayed snapshots arrive, while still replacing optimistic user parts with confirmed server snapshots to avoid duplicated user messages.
Narrow recovery triggers to explicit incomplete snapshot signals instead of broad session-event fallbacks, reducing unnecessary session refetches during active streaming.
Keep turn windowing aligned with parented assistant replies and add regression coverage for materialization gaps, stale snapshot protection, optimistic user replacement, reconnect recovery, and turn grouping.
Limits reconnect and repair message fetches to recent messages
Reduces repeated bandwidth for large tool outputs
Keeps normal session loading unchanged
* fix(sync): preserve pending questions across session switch and directory eviction
Closes#918, completes the gap left by #909.
The 'agent question disappears after switching session / coming back
later' bug had two root causes that #909 only partially addressed:
1. Directory-eviction TTL (20 min) silently dropped child stores that
held pending questions/permissions. The discard wasn't gated on
in-flight blocking-request state, so any 'question.asked' event
that arrived during the eviction-then-rehydrate window was routed
to a non-existent store and silently lost.
2. PR #909 re-fetches listPendingQuestions/Permissions only on SSE
reconnect. Switching sessions within the same socket — including
navigating back to a directory whose child store was rebuilt after
eviction — left the UI relying on store state that may have missed
events that fired while a different session was active.
Three edits, in src/sync:
- eviction.ts / types.ts / child-store.ts: add hasPendingBlockingRequests
to EvictPlan + DisposeCheck and never evict a directory whose store
carries a non-empty state.question or state.permission record.
- sync-context.tsx: extract resyncBlockingRequestsForDirectory from the
reconnect path and call it on currentSessionId changes (debounced
250ms), reusing PR #909's signature-based merge so concurrent SSE
updates aren't clobbered.
- __tests__/eviction.test.ts, __tests__/session-switch-resync.test.ts:
new unit coverage for the eviction guard and resync semantics
(deduped fetch per switch, in-flight SSE preservation, stale entry
cleanup, unknown-session filtering).
* fix(sync): refresh store before blocking request resync
---------
Co-authored-by: Alexander Busse <alex@ableph.net>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
* fix(sync): resync the active session after reconnect transitions
Include the viewed session in reconnect recovery and trigger a targeted resync on transport switches so the active chat catches up after missed live events.
Constraint: Keep the fix in the sync layer instead of adding ChatContainer-only recovery
Rejected: Widen reconnect heuristics for every cached session | broader recovery scope than needed
Confidence: high
Scope-risk: narrow
* Avoid no-op reconnect resync store writes
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
Recover stalled event streams without dropping the session
Wait briefly for reconnection before showing connection lost errors
Persist Electron server logs for easier disconnect debugging
* fix(pipeline): distinguish transport switch from real disconnect
WS_FALLBACK errors (e.g. ready timeout → SSE fallback) are transport
switches, not disconnections. No events are lost because lastEventId is
preserved across the switch.
Previously, every WS timeout triggered onDisconnect → onReconnect with a
full resyncDirectoryAfterReconnect, which:
- Missed idle parent sessions in candidate selection (root cause 1)
- Could overwrite in-flight SSE state with stale fetch data (root cause 4)
- Caused isConnected to flash false→true
Now: WS_FALLBACK fires onTransportSwitch (sets isConnected only).
Real disconnections (heartbeat timeout, network error) still fire the
full onDisconnect → onReconnect → resync cycle.
* fix(sync): relationship-aware reconnect with merge-not-replace
Two changes to resyncDirectoryAfterReconnect:
1. Candidate selection now also includes parent sessions of any child
sessions in the directory. Previously, if a child completed during
the disconnect gap (busy→idle), neither child nor parent was selected
because both appeared idle. The parent's task tool part would remain
permanently stale.
2. Parent resync merges parts instead of replacing. Previously, the
resync deleted parts for messages not in the fetch snapshot, which
could erase parts delivered by SSE events that arrived between the
fetch and the setState. Now only parts for messages in the snapshot
are overwritten; everything else is preserved.
* fix(sync): demand-load child session messages on access
Bootstrap only loads session metadata — messages are populated
exclusively by SSE events. When a user navigates to an old session
that spawned subagents, child session messages were never in the store.
Add useEnsureSessionMessages hook that detects this gap (session exists
in state.session but state.message[sessionID] is absent) and triggers a
background API fetch to load messages and parts.
ToolPart already calls useSessionMessageRecords(taskSessionId) which
returns empty when not loaded. Now it also calls useEnsureSessionMessages
to populate the store on first access.
* fix(sync): unmount-safe parent resync when child session goes idle
When a child session transitions to idle (completes), the sync layer
now schedules a targeted parts repair for the parent session's task
tool part. Previously this only happened when the ToolPart component
was mounted and had observed the child being active (taskChildSeenActive).
This covers:
- User navigated away while child was running
- App restarted with active subagent sessions
- SSE reconnect where child completed during disconnect
Uses the existing repairSessionParts mechanism with its 5s cooldown
to avoid redundant fetches.
* fix: type-check fixes for sync-layer parent resync
Fix TypeScript errors in Fix 5 implementation:
- Convert currentSessionId from null to undefined for resolveFallbackTaskSessionId
- Add default empty string for dir parameter in getScopedSdkClient
- Use explicit sessionID parameter for scopedClient.session.messages
All type-checks now pass.
* fix(sync): address PR review feedback on deduplication
- Use enqueuePartsRepair for session.idle parent resync instead of
direct repairSessionParts call. enqueuePartsRepair already has a 5s
cooldown to prevent redundant parallel API calls when multiple child
sessions go idle concurrently.
- Move useEnsureSessionMessages loading guard from component-scoped
React.useRef to a module-level Set keyed by directory:sessionID.
Prevents parallel fetches when multiple ToolPart instances mount
for the same child session.
* fix(sync): add missing semicolon on useEnsureSessionMessages call
Address Greptile P2 review comment on ToolPart.tsx:1931.
- event-pipeline: onDisconnect now carries a reason tag (ws_closed:code=N, ws_error_frame, ws_closed_before_ready, sse_error)
- useConfigStore: store lastDisconnectReason, clear on reconnect
- send guards: embed reason in Connection lost toast so we can tell which path tripped
- lifecycle: isOpenCodeProcessHealthy hits /global/health (healthy flag) with 5s timeout instead of /session with 2s, avoids false restarts under stream load
* feat: add FileChangeSummary component for multi-file diff preview in ToolPart
Provides an aggregated diff card for apply_patch and multi-edit tools,
showing per-file stats with click-to-expand diff view.
* feat: add PendingChangesBar above chat input with collapse/expand and file opening
- Collapsed/expanded toggle with aggregate +N -N stats (green/red)
- Relative path display, chat-column alignment with ChatInput
- Click file to open in diff viewer (web/desktop) or editor (VS Code)
- Support edit/multiedit/apply_patch/write tool metadata extraction
- Add PendingChangesBar to main chat view (ChatContainer)
* feat: dual-mode ChangedFilesBar — Git diff state vs latest AI turn
Git mode: reads git status from useGitStore (status.files + diffStats),
auto-clears on commit/restore. Shows 'N files changed in workspace'.
Non-Git mode: latest assistant turn only (no accumulation), clears on new
user message or manual dismiss. Shows 'AI updated N files in the last reply'.
Both modes: dismiss button with signature-based tracking.
Add pendingChangesBarDismissed state to session-ui-store, cleared on sendMessage.
* fix: address code review issues in ChangedFilesBar
- Gate non-git mode on streaming state to prevent flicker during AI turns
- Return null when isGitRepo is unknown (loading state)
- Add group/row class for reject button visibility in FileChangeSummary
- Use per-session Map for dismiss tracking to prevent cross-session leaks
- Include additions/deletions in dismiss signature for re-edit detection
- Extract shared parsePatchStats/parseCount to fileChangeHelpers
* chore: revert local .opencode/package-lock.json changes from PR
* fix: per-part fallback guard and git-only reject button
- Fix extractChangedFiles to use per-part files.length snapshot instead
of global guard, preventing file entries from being skipped when
earlier parts already contributed files
- Hide reject button in FileChangeSummary when not in a git repo,
preventing silent revert failures
* refactor: remove FileChangeSummary — dead code redundant with ToolPart
FileChangeSummary duplicated diff rendering that ToolPart already
provides (PatchDiff, per-file stats, DiffViewToggle). The only unique
feature was a git revert button, which conflicts with the design
principle of not having accept/reject on file change previews.
Moved parsePatchStats/parseCount back into PendingChangesBar (sole
consumer) and deleted the shared helper module.
* chore: remove stray Tester.txt
* fix: deduplicate Fallback 4 'Diff' placeholder via seen set
* chore: update non-Git mode copy to neutral 'changed in the last reply'
* fix(chat): unify changes row with tasks
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
Two fixes for intermittent "sessions missing at app launch":
- loadSessions: if session.list comes back empty but the store already
has sessions populated via WS events, don't clobber. OpenCode can
answer HTTP with [] while the WS snapshot is still arriving.
- loadSessions: wrap SDK errors preserving HTTP status so retry()'s
transient detection (5xx) actually fires. String(errorObject) was
erasing the status and retry gave up after one attempt.
- retry: broaden transient 5xx window from just 502/503 to any 5xx.
OpenCode can return 500/504 while warming up (session store reading
from disk), and those deserve the same retry treatment.
- Log bootstrap empty-session retries for future diagnosis.
When the computer sleeps and wakes, the SSE/WS event stream drops
silently. Messages appeared sent (optimistic insert) but never reached
the OpenCode server, and the user had no indication the system was
disconnected.
Three fixes:
1. Connection state tracking: add onDisconnect callback to the event
pipeline. Stream failures set isConnected=false in useConfigStore;
successful reconnect sets isConnected=true.
2. Send guard: optimisticSend, respondToPermission, and
respondToQuestion now check isConnected before making API calls,
throwing a clear error that surfaces as a toast to the user.
The /compact command also checks connection with error feedback.
3. Faster server recovery: add triggerHealthCheck() to the server
lifecycle and wire it into the WS event stream runtime. When the
upstream OpenCode connection fails, the server immediately checks
health and restarts if needed, instead of waiting up to 15s for
the periodic health check.
Move session export into the sidebar menu
Add desktop save-and-reveal flow for exported markdown
Show empty-state UI instead of a loading skeleton for empty sessions
* fix: show question content in ToolPart instead of 'Awaiting response...' after refresh
Previously, when the question tool was pending/running or completed
without parseable output, the ToolPart fell through to a generic
'Awaiting response...' message. After a page refresh or app restart,
this made questions appear empty even though the tool state still
contained the question input data.
Now the ToolPart reads question text, headers, and options from the
tool state's input field, ensuring question content persists across
refreshes regardless of QuestionCard store availability.
Fixes#879
* fix: restore QuestionCard after refresh and pause working status during active questions
Two fixes for question tool UX:
1. ChatContainer: sessionIsWorking now returns false when there are
active questions (same as it already did for permissions). This
prevents the status row from showing 'Asking question...' and
instead shows the QuestionCard.
2. sync-context: resyncDirectoryAfterReconnect now re-fetches
pending questions via listPendingQuestions(). Previously only
sessions and messages were re-fetched on SSE reconnect, so
questions asked during disconnection were lost, causing
QuestionCard to disappear after page refresh.
Refs #879
* fix: hide assistant working status while questions are pending
The assistant status hook only special-cased pending permissions, so
question tools still surfaced 'Asking question...' after refresh even
when the UI was already waiting on a QuestionCard response.
Treat pending questions like other blocking requests by clearing the
working indicator until the user answers.
Refs #879
* fix: merge question/permission stores instead of full replace on bootstrap and reconnect
The root cause of QuestionCard disappearing after refresh was a race
condition between SSE events and HTTP bootstrap. Bootstrap and reconnect
both did full replacement of state.question, wiping SSE-delivered data
that arrived between the HTTP call initiation and response arrival.
Changes:
- bootstrap.ts: question and permission stores now use merge semantics.
Only sessions present in the API response are overwritten. Sessions
absent from the response are left untouched (they may hold SSE data).
- sync-context.tsx: reconnect question resync uses the same merge pattern.
No longer clears question entries for sessions not in the API response.
- bootstrap.ts: sdk.question.list() now passes directory parameter to
scope the query correctly.
This ensures SSE-delivered question data survives the bootstrap window,
while still allowing the API response to be authoritative for sessions
it covers.
Refs #879
* fix: prune stale pending requests after reconnect
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
The pipeline's stale-delta mechanism incorrectly marked all
message.part.delta events as stale when a message.part.updated
coalesced, regardless of queue position. This caused valid streaming
deltas to be silently dropped, resulting in blank or incomplete
assistant messages.
Additionally, when part events were dropped by the reducer (missing
parts array or partID not found), there was no recovery path — the
state stayed permanently out of sync until the next SSE reconnect
or manual refresh.
Also discovered: message.updated that successfully writes an assistant
message but has empty parts would render a blank bubble, with no
repair triggered since repair only ran on reducer return false.
Changes:
- Remove staleDeltas Set and deltaKey from event-pipeline.ts
- Coalesce still replaces same-key events, but deltas are never skipped
- Add enqueuePartsRepair + repairSessionParts to sync-context.tsx
(5s cooldown, deduped, async SDK re-fetch)
- Trigger repair on reducer return false for part events
- Trigger repair on message.updated return true with empty parts
- Add sync debug.ts with gated diagnostic logging
- Add pipeline coalescing tests
Roll back aggressive chat reuse paths that froze message parts, tool state, and completed assistant bodies when sessions were inactive or backgrounded.
- Fix OpenChamber proxying to the correct OpenCode host for remote VPS setups
- Show a clear empty-state error when OpenCode is not reachable
- Harden session event routing for early or mismatched directory events
- Keep assistant auto-scroll smooth during active responses without periodic snaps
- Restore live `/compact` rendering and streamed compaction summaries without refresh
- Preserve global event handling while routing session and message updates efficiently
- Show `/compact` command and streamed compaction summary live without refresh
- Handle versioned sync event types so message and part updates are processed
- Route session/message events via indexed lookup to avoid hot-path store scans
- Keep historical chat messages stable during streaming
- Isolate active stream state to the current message path
- Reduce chat container churn from sync message updates
Stop historical chat view from waking on live tail deltas by suspending detached part updates, narrowing scroll manager dependencies, and hardening sync-context HMR behavior during dev.
Split static history from live tail, stabilize chat data identities, and reduce scroll-shell churn so old viewport content stops waking on stream deltas. Also defer watcher startup and health polling so idle runtime work better matches actual usage.