diff --git a/bun.lock b/bun.lock index 52f0ff1d..2a05722e 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -100,7 +100,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.13.4", + "version": "1.13.8", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -136,7 +136,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.13.4", + "version": "1.13.8", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -173,7 +173,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", @@ -242,10 +242,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.13.4", + "version": "1.13.8", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -265,14 +265,14 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.13.4", + "version": "1.13.8", "bin": { "openchamber": "./bin/cli.js", }, "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", @@ -1007,7 +1007,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.12", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-N8kazWO0ZLCHWYFuZQt1UJM+bWxY6g1auSG6SvD1+K3+W+nw2qIhDAUGNCD0KVW3bY2LCwvfWvpG2ZbVGCHC0Q=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], @@ -3387,7 +3387,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -3739,8 +3739,6 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "serve-sim/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/package.json b/package.json index 6e9de3d3..7013f47c 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/package.json b/packages/ui/package.json index 509eb743..e44a52e3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,7 +46,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index da34f3a4..423ecf77 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -874,15 +874,20 @@ const childStoreHasSessionState = ( || Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionID) } -const childStoreHasMessagePartState = ( - childStores: ChildStoreManager, - directory: string, - messageID: string, -): boolean => { +const childStoreHasMessagePartState = ( + childStores: ChildStoreManager, + directory: string, + messageID: string, +): boolean => { const store = childStores.getChild(directory) if (!store) return false - return Object.prototype.hasOwnProperty.call(store.getState().part, messageID) -} + return Object.prototype.hasOwnProperty.call(store.getState().part, messageID) +} + +const getActiveDirectoryFallback = (childStores: ChildStoreManager): string | null => { + if (!_activeDirectory || !_activeSession) return null + return childStores.getChild(_activeDirectory) ? _activeDirectory : null +} const resolveDirectoryFromRoutingIndex = ( routingIndex: EventRoutingIndex, @@ -927,12 +932,21 @@ const resolveDirectoryFromRoutingIndex = ( } // Scan child stores for a store that has parts for this message - for (const [dir, store] of childStores.children) { - if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) { - return dir - } - } - } + for (const [dir, store] of childStores.children) { + if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) { + return dir + } + } + + // Some reconnect/idle gaps can deliver part events before the matching + // message.updated event and without a sessionID. If the user is actively + // viewing a session, route the orphaned part event there so the reducer can + // trigger HTTP materialization instead of dropping it as a global event. + const activeDirectory = getActiveDirectoryFallback(childStores) + if (activeDirectory) { + return activeDirectory + } + } // Single-store fallback: if there's only one directory, use it if ( @@ -946,8 +960,25 @@ const resolveDirectoryFromRoutingIndex = ( } } - return normalizedDirectory -} + return normalizedDirectory +} + +const resolveMaterializationSessionID = ( + materializationSessionID: string | undefined, + messageID: string | undefined, + resolvedDirectory: string, + routingIndex: EventRoutingIndex, +): string | undefined => { + if (materializationSessionID) return materializationSessionID + if (messageID) { + const indexedSessionID = routingIndex.messageSessionById.get(messageID) + if (indexedSessionID) return indexedSessionID + } + if (resolvedDirectory && resolvedDirectory === _activeDirectory && _activeSession) { + return _activeSession + } + return undefined +} const updateRoutingIndexFromEvent = ( routingIndex: EventRoutingIndex, @@ -1559,14 +1590,19 @@ function handleEvent( } - // Snapshot materialization is driven by typed reducer outcomes, not by - // inferring meaning from a generic false/no-change result. - if (materializationResult) { - const materializationSessionID = materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined - if (materializationSessionID) { - enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) - } - } + // Snapshot materialization is driven by typed reducer outcomes, not by + // inferring meaning from a generic false/no-change result. + if (materializationResult) { + const materializationSessionID = resolveMaterializationSessionID( + materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined, + materializationResult.messageID ?? getMessageIdFromPayload(payload) ?? undefined, + resolvedDirectory, + routingIndex, + ) + if (materializationSessionID) { + enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) + } + } updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) } diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 9b520338..20f556a5 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -244,7 +244,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index 3ac7bdb4..1fd0c48b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", diff --git a/packages/web/server/lib/event-stream/global-ws-bridge.js b/packages/web/server/lib/event-stream/global-ws-bridge.js index 9ab65c84..d26f7ca0 100644 --- a/packages/web/server/lib/event-stream/global-ws-bridge.js +++ b/packages/web/server/lib/event-stream/global-ws-bridge.js @@ -120,6 +120,17 @@ export function createGlobalMessageStreamWsBridge({ for (const socket of Array.from(clients)) { if (!readyClients.has(socket)) { markReady(socket, clientLastEventIds.get(socket) ?? ''); + continue; + } + + if (status.wasReady) { + const sent = sendMessageStreamWsFrame(socket, { + type: 'ready', + scope: 'global', + }); + if (!sent) { + removeClient(socket); + } } } return; diff --git a/packages/web/server/lib/event-stream/runtime.test.js b/packages/web/server/lib/event-stream/runtime.test.js index 19065a92..c8635112 100644 --- a/packages/web/server/lib/event-stream/runtime.test.js +++ b/packages/web/server/lib/event-stream/runtime.test.js @@ -435,7 +435,7 @@ describe('message stream websocket runtime', () => { return createSseResponse({ signal: options.signal, - holdOpen: false, + holdOpen: true, blocks: [ 'id: evt-2\ndata: {"type":"server.connected","properties":{}}\n\n', ], @@ -451,7 +451,7 @@ describe('message stream websocket runtime', () => { const readyFrames = socket.sent.filter((frame) => frame.type === 'ready'); const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected'); - expect(readyFrames).toHaveLength(1); + expect(readyFrames.length).toBeGreaterThanOrEqual(2); expect(eventFrames.length).toBeGreaterThanOrEqual(2); expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']); expect(triggerHealthCheckCalls).toBe(0);