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.
* 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: reconnect SSE immediately on OS wake-from-sleep
When the desktop app resumes from OS sleep, TCP connections are dead
but timers were paused during sleep so the heartbeat watchdog doesn't
fire until ~30s after wake.
Add Electron powerMonitor.resume → renderer notification → event-pipeline
immediate abort, cutting reconnection delay from ~30s to ~0ms.
Changes:
- electron/main.mjs: import powerMonitor, emit openchamber:system-resume
to all renderer windows on OS resume
- ui/sync/event-pipeline.ts: listen for openchamber:system-resume, set
attemptAbortReason and abort the active SSE/WS attempt to trigger
immediate reconnection with retryDelayMs=0 and lastEventId preservation
* fix: reconnect SSE immediately on OS wake-from-sleep
When the desktop app resumes from OS sleep, TCP connections are dead
but timers were paused during sleep so the heartbeat watchdog doesn't
fire until ~30s after wake.
Add Electron powerMonitor.resume → renderer notification → event-pipeline
immediate abort, cutting reconnection delay from ~30s to ~0ms.
Changes:
- electron/main.mjs: import powerMonitor, emit openchamber:system-resume
to all renderer windows on OS resume
- ui/sync/event-pipeline.ts: listen for openchamber:system-resume via
globalThis.window, set attemptAbortReason and abort the active SSE/WS
attempt to trigger immediate reconnection with retryDelayMs=0 and
lastEventId preservation
- Test: event-pipeline-resume.test.js verifies abort → reconnect flow
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: hide archived section and empty folders when no sessions remain
- Only push archived group in useSessionGrouping when there are archived
sessions, preventing an empty archived section from rendering
- Hide empty folders in archived bucket via shouldKeepFolder check in
SessionGroupSection (folders with no sessions and no content in
children are filtered out)
- Always filter folders through shouldKeepFolder, not just during search
* perf: memoize archived folder filtering
* fix(sync): deduplicate overlapping delta after coalesced part.updated
When message.part.updated coalesces in the event pipeline and a
message.part.delta for the same part arrives in the same flush window,
the reducer appends the delta verbatim to the already-complete field
value, producing duplicated text in tool output and assistant messages.
Add targeted overlap reconciliation: only when a part.updated replaces
an existing part with overlapping string content, mark the next delta
for that field as dedupe-eligible. Normal streaming deltas remain
untouched (pure append).
Covers four cases:
- Full overlap: delta already present -> no-op
- Partial overlap: only non-overlapping suffix appended
- No overlap: unchanged append behavior
- Legitimate repeated output (ha + ha -> haha): preserved
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
* fix: hide archived section and empty folders when no sessions remain
- Only push archived group in useSessionGrouping when there are archived
sessions, preventing an empty archived section from rendering
- Hide empty folders in archived bucket via shouldKeepFolder check in
SessionGroupSection (folders with no sessions and no content in
children are filtered out)
- Always filter folders through shouldKeepFolder, not just during search
* perf: memoize archived folder filtering
* perf(sync): per-directory event queues to eliminate cross-session HoL blocking
The SSE event pipeline previously used a single global queue and a single
flush timer shared across all directories. Under concurrent multi-session
workloads, a busy directory's delta storm would block other directories'
status and state events from reaching the UI until the next flush tick,
producing the "multi-session latency" symptom users report.
Split the queue into one DirectoryQueue per directory, each with its own
coalesce map, stale-delta set, and flush timer. Directories flush
independently so a busy directory can no longer starve a quiet one. Coalesce
keys are now scoped to a single directory's queue, so the directory prefix
is removed from the key strings.
Cross-directory behavior only; same-directory multi-session behavior is
unchanged (React 18 auto-batching still collapses a single directory's
flush into one render).
* perf(sync): coalesce consecutive message.part.delta events per flush window
Within a 16ms flush window, consecutive delta events for the same
(messageID, partID, field) tuple are string-concatenated into a single
accumulated delta rather than being queued individually.
This directly addresses same-project multi-session workloads — most
notably parent sessions with subagent tasks (child sessions share the
same directory queue). Both parties stream deltas concurrently, which
previously multiplied raw event count proportionally to the number of
active sessions. Coalescing can reduce queue depth by 10-100x during
active streaming.
Safety: verified against event-reducer.ts — the delta handler is a pure
string append (existingValue + props.delta) with no per-event side
effects (no time.updated, no notifications, no diff calculations). The
merged result is semantically identical to applying each delta separately.
The staleDeltas skip mechanism is unaffected: accumulated delta payloads
retain their type and identifiers, so message.part.updated supersession
still works correctly.
* test(sync): cover per-directory queues and delta coalescing
Extend event-pipeline.test.js with behavioural coverage for both
optimizations landed in 98d013a and 258acf0:
P1 (per-directory queues)
- Delivers events from two directories without loss
- Keeps distinct sessionIDs in the same directory as independent coalesce
slots (session.status is not overwritten across sessions)
- Collapses repeated session.status for the same session down to latest
Option C (delta coalescing)
- Accumulates consecutive deltas for the same (messageID, partID, field)
into a single dispatched event with concatenated content
- Does not merge deltas across different fields on the same part
- Does not merge deltas across different parts on the same message
- Does not merge deltas across different directories (per-dir queues)
- Skips accumulated deltas when message.part.updated is coalesced onto
an earlier update, proving staleDeltas still works with C
- Leaves non-delta coalescing (session.status replace semantics) intact
All 13 tests pass under bun:test.
Also adds event-pipeline.bench.js, a runnable synthetic benchmark that
reports delta reduction and byte integrity across 8 workload scenarios
from "single session, 500 tokens" up to "10 projects × 5 sessions ×
1000 tokens". Run with:
bun packages/ui/src/sync/__tests__/event-pipeline.bench.js
Current numbers on this machine: 99.5% - 99.9% delta event reduction
with full byte-level integrity (concatenated delta bytes always equal
the input total).
* fix(sync): remove staleDeltas — it silently drops delta events
---------
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