diff --git a/package.json b/package.json index d1f5a0ff..d3a4d7be 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@opencode-ai/sdk": "^1.1.19", "@radix-ui/colors": "^3.0.0", + "@solid-primitives/event-bus": "^1.1.2", "@solid-primitives/storage": "^4.3.3", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "~2.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40946480..aa37880c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@radix-ui/colors': specifier: ^3.0.0 version: 3.0.0 + '@solid-primitives/event-bus': + specifier: ^1.1.2 + version: 1.1.2(solid-js@1.9.10) '@solid-primitives/storage': specifier: ^4.3.3 version: 4.3.3(solid-js@1.9.10) @@ -447,6 +450,11 @@ packages: cpu: [x64] os: [win32] + '@solid-primitives/event-bus@1.1.2': + resolution: {integrity: sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/storage@4.3.3': resolution: {integrity: sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw==} peerDependencies: @@ -1281,6 +1289,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@solid-primitives/event-bus@1.1.2(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + '@solid-primitives/storage@4.3.3(solid-js@1.9.10)': dependencies: '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) diff --git a/src/app/context/global-sdk.tsx b/src/app/context/global-sdk.tsx new file mode 100644 index 00000000..a9f27a48 --- /dev/null +++ b/src/app/context/global-sdk.tsx @@ -0,0 +1,131 @@ +import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"; +import { createGlobalEmitter } from "@solid-primitives/event-bus"; +import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"; + +import { usePlatform } from "./platform"; +import { useServer } from "./server"; + +type GlobalSDKContextValue = { + url: string; + client: ReturnType; + event: ReturnType>; +}; + +const GlobalSDKContext = createContext(undefined); + +export function GlobalSDKProvider(props: ParentProps) { + const server = useServer(); + const platform = usePlatform(); + const abort = new AbortController(); + + const eventClient = createOpencodeClient({ + baseUrl: server.url, + signal: abort.signal, + fetch: platform.fetch, + }); + + const emitter = createGlobalEmitter<{ [key: string]: Event }>(); + + type Queued = { directory: string; payload: Event }; + + let queue: Array = []; + const coalesced = new Map(); + let timer: ReturnType | undefined; + let last = 0; + + const keyForEvent = (directory: string, payload: Event) => { + if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`; + if (payload.type === "lsp.updated") return `lsp.updated:${directory}`; + if (payload.type === "todo.updated") return `todo.updated:${directory}:${payload.properties.sessionID}`; + if (payload.type === "mcp.tools.changed") return `mcp.tools.changed:${directory}:${payload.properties.server}`; + if (payload.type === "message.part.updated") { + const part = payload.properties.part; + return `message.part.updated:${directory}:${part.messageID}:${part.id}`; + } + }; + + const flush = () => { + if (timer) clearTimeout(timer); + timer = undefined; + + const events = queue; + queue = []; + coalesced.clear(); + if (events.length === 0) return; + + last = Date.now(); + batch(() => { + for (const entry of events) { + if (!entry) continue; + emitter.emit(entry.directory, entry.payload); + } + }); + }; + + const schedule = () => { + if (timer) return; + const elapsed = Date.now() - last; + timer = setTimeout(flush, Math.max(0, 16 - elapsed)); + }; + + const stop = () => { + flush(); + }; + + void (async () => { + const subscription = await eventClient.event.subscribe(undefined, { signal: abort.signal }); + let yielded = Date.now(); + + for await (const event of subscription.stream as AsyncIterable) { + const record = event as Event & { directory?: string; payload?: Event }; + const payload = record.payload ?? record; + if (!payload?.type) continue; + + const directory = typeof record.directory === "string" ? record.directory : "global"; + const key = keyForEvent(directory, payload); + if (key) { + const index = coalesced.get(key); + if (index !== undefined) { + queue[index] = undefined; + } + coalesced.set(key, queue.length); + } + + queue.push({ directory, payload }); + schedule(); + + if (Date.now() - yielded < 8) continue; + yielded = Date.now(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + })() + .finally(stop) + .catch(() => undefined); + + onCleanup(() => { + abort.abort(); + stop(); + }); + + const client = createOpencodeClient({ + baseUrl: server.url, + fetch: platform.fetch, + throwOnError: true, + }); + + const value: GlobalSDKContextValue = { + url: server.url, + client, + event: emitter, + }; + + return {props.children}; +} + +export function useGlobalSDK() { + const context = useContext(GlobalSDKContext); + if (!context) { + throw new Error("Global SDK context is missing"); + } + return context; +}