diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 036bbbd5..7f4cef3c 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -64,7 +64,7 @@ These stores coordinate persistent project/session metadata across multiple view `useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn. -`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`. On web, desktop, and mobile the OpenChamber server owns the queue (`packages/web/server/lib/message-queue/`): it delivers queued messages when the session goes idle whether or not any UI is open, and the store is a projection of it — `hydrate()` loads the server snapshot for the active runtime, `openchamber:message-queue.updated` broadcasts keep it current, and every mutation is optimistic locally then settled on the server's copy of that session (a failed round-trip re-reads the server instead of guessing). A failed take re-reads it too, since a 404 means the server already delivered the message the projection still shows. Broadcasts ride the WebSocket event transport only. A client on the SSE fallback, or one whose socket died, misses them, so the sync provider calls `resync()` when its stream reconnects or switches transport. `resync()` re-reads the snapshot once a hydration has established that the server owns this runtime's queue. Before that, hydration's one-time legacy upload would mistake the server's own copies for local messages. A per-key server revision rejects stale snapshots, and a hydration keeps any queue a newer broadcast produced even when its snapshot does not list that session. An empty session that arrives without a directory (servers before 1.22.2 dropped it once the queue emptied) clears every projection of that session id in the runtime, because a session id is unique across directories. Projection items carry attachment metadata only and no captured context; `popToInput()`/`takeForSend()` remove the message on the server and get the full payload back, which is why both are async. +`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`. On web, desktop, and mobile the OpenChamber server owns the queue (`packages/web/server/lib/message-queue/`): it delivers queued messages when the session goes idle whether or not any UI is open, and the store is a projection of it — `hydrate()` loads the server snapshot for the active runtime, `openchamber:message-queue.updated` broadcasts keep it current, and every mutation is optimistic locally then settled on the server's copy of that session (a failed round-trip re-reads the server instead of guessing). Stream reconnects and transport switches call `resync()` because the `/api/global/event` SSE fallback does not carry OpenChamber queue broadcasts. A per-key server revision rejects stale snapshots. An empty session that arrives without a directory (servers before 1.22.2 dropped it once the queue emptied) clears every projection of that session id in the runtime, because a session id is unique across directories. Projection items carry attachment metadata only and no captured context; `popToInput()`/`takeForSend()` remove the message on the server and get the full payload back, which is why both are async. A queued message is captured whole, so whoever delivers it sends exactly what the composer would have: `text` (the content with its agent mention stripped and `@file` mentions already resolved into `attachments`), `agentMention`, and `context` — every chip the composer had attached (inline comments, terminal selections, browser annotations, PR comments/checks, quotes, linked issue/PR/Linear references, pending synthetic parts) plus the skill instruction derived from the text. `QueuedContextPart` distinguishes attached items (restored to the chips when the message is edited) from derived instructions (re-derived on send, never restored) and from synthetic parts other surfaces handed the composer (restored as pending). Context is captured by `buildComposerContext` and delivered by `queuedContextToParts` (`components/chat/composer/submit/buildOutgoingMessage.ts`), the same functions the composer uses for its own send. Nothing is re-resolved at delivery: the server has no agent list, no confirmed mentions, and no draft store. Messages a previous build left in this browser are uploaded once on the first hydration of a runtime and then dropped from persistence for that runtime (`partialize` skips server-owned runtime keys). VS Code has no server and keeps the local queue with the foreground auto-send hook (`useQueuedMessageAutoSend`, enabled only there); `useMessageQueueHoldSync` tells the server to hold a session's queue while a UI-driven auto-review run is going. diff --git a/packages/ui/src/stores/messageQueueStore.server.test.ts b/packages/ui/src/stores/messageQueueStore.server.test.ts index 8a3b3f10..777d97ee 100644 --- a/packages/ui/src/stores/messageQueueStore.server.test.ts +++ b/packages/ui/src/stores/messageQueueStore.server.test.ts @@ -123,7 +123,7 @@ describe("server-owned message queue", () => { expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"]) }) - test("hydrate keeps a queue a newer broadcast added even when the snapshot predates it", async () => { + test("hydrate keeps a queue newer than its snapshot", async () => { applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued after the read started")])), "runtime-a") respond = () => json({ revision: 9, sessions: [] }) await useMessageQueueStore.getState().hydrate() @@ -131,24 +131,19 @@ describe("server-owned message queue", () => { expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"]) }) - test("resync re-reads the server once a hydration established ownership, and drops what it no longer lists", async () => { - // Before a hydration the store cannot tell the server's copies from an - // older build's local queue, so there is nothing to re-read yet. + test("resync waits for initial hydration", async () => { activeRuntimeKey = "runtime-never-hydrated" await useMessageQueueStore.getState().resync() expect(calls).toHaveLength(0) + }) - activeRuntimeKey = "runtime-a" + test("resync drops a queue the server no longer lists", async () => { respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "queued")], "q1")] }) await useMessageQueueStore.getState().hydrate() - expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1) - // The server delivered q1 while this client's stream was down. respond = () => json({ revision: 4, sessions: [] }) await useMessageQueueStore.getState().resync() - expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual(["GET /api/message-queue", "GET /api/message-queue"]) expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined) - expect(useMessageQueueStore.getState().sendingIds[key]).toBe(undefined) }) test("addToQueue shows the message at once and settles on the server's copy", async () => { @@ -305,10 +300,6 @@ describe("server-owned message queue", () => { : json({ revision: 12, sessions: [] })) await expect(useMessageQueueStore.getState().takeForSend(target, "q1")).rejects.toThrow() - expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([ - "POST /api/message-queue/sessions/session-1/items/q1/take", - "GET /api/message-queue", - ]) expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined) }) diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index 227a8d83..4a4de93e 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -375,11 +375,7 @@ interface MessageQueueActions { getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[]; /** Server-owned queue: load the authoritative queue for the active runtime. */ hydrate: () => Promise; - /** - * Server-owned queue: re-read the server after the event stream had a gap. - * A no-op until a hydration has established ownership, since only then can - * hydration tell the server's own copies from an older build's local queue. - */ + /** Server-owned queue: re-read after an event-stream gap. */ resync: () => Promise; /** Server-owned queue: apply one session's authoritative state (broadcast or response). */ applyServerSession: (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => void; @@ -629,7 +625,6 @@ export const useMessageQueueStore = create()( applyServerSession(result.session, result.revision, target.runtimeKey); return result.items.map(toQueuedMessage); } catch (error) { - // A 404 means the server already delivered or dropped the message. await refreshSession(target); throw error; } diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 6dd949e9..efb4a15c 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -642,12 +642,6 @@ function isRecentBoot() { return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS } -// Queue broadcasts ride the WebSocket event transport only. A stream gap or -// the SSE fallback misses them, so a reconnect re-reads the server's queue. -function resyncMessageQueue() { - void useMessageQueueStore.getState().resync().catch(() => undefined) -} - function getViewedSessionMaterializationTarget(directory: string) { if (!_activeDirectory || !_activeSession) return null if (directory !== _activeDirectory) return null @@ -2514,6 +2508,10 @@ export function SyncProvider(props: { // Event pipeline — created once per mount. No class, no start/stop. // Abort controller owned by the pipeline closure. Cleanup aborts + flushes. useEffect(() => { + const resyncAfterStreamGap = (reason: SessionMaterializationReason) => { + for (const dir of childStores.children.keys()) triggerDirectoryResync(dir, reason) + void useMessageQueueStore.getState().resync().catch(() => undefined) + } const pipeline = createEventPipeline({ sdk: props.sdk, transport: messageStreamTransport, @@ -2560,10 +2558,7 @@ export function SyncProvider(props: { if (isRecentBoot()) { return } - for (const dir of childStores.children.keys()) { - triggerDirectoryResync(dir, "stream-reconnect") - } - resyncMessageQueue() + resyncAfterStreamGap("stream-reconnect") }, onDisconnect: (reason) => { if (!pipelineHasConnectedRef.current) { @@ -2584,10 +2579,7 @@ export function SyncProvider(props: { hasEverConnected: true, connectionPhase: "connected", }) - for (const dir of childStores.children.keys()) { - triggerDirectoryResync(dir, "transport-switch") - } - resyncMessageQueue() + resyncAfterStreamGap("transport-switch") }, }) pipelineReconnectRef.current = pipeline.reconnect diff --git a/packages/web/server/lib/message-queue/DOCUMENTATION.md b/packages/web/server/lib/message-queue/DOCUMENTATION.md index 75bd342f..bb70b090 100644 --- a/packages/web/server/lib/message-queue/DOCUMENTATION.md +++ b/packages/web/server/lib/message-queue/DOCUMENTATION.md @@ -141,10 +141,8 @@ allowlists. Every mutation broadcasts `openchamber:message-queue.updated` with `{ revision, session }` to all connected clients (SSE and WS), so several devices on one server see one queue. "SSE" here is the notification stream -at `/api/notifications/stream`. The event pipeline's SSE fallback at -`/api/global/event` is a plain proxy of OpenCode's stream and carries no -`openchamber:*` events, so a UI on that transport re-reads the snapshot when -its stream reconnects instead. The session in that payload always names +at `/api/notifications/stream`; `/api/global/event` carries no OpenChamber +events. The session in that payload always names its `directory`, including the broadcast that removes the last item: the UI keys its projection by directory, and a broadcast without one left the delivered message on screen (a session's directory is remembered until the