From 1d892de4558297c535eb8318adc31545d0eeefd3 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:08:52 -0800 Subject: [PATCH 01/15] feat: add platform context and persistence helper --- package.json | 1 + pnpm-lock.yaml | 29 ++++ src/app/context/platform.tsx | 46 +++++++ src/app/index.css | 4 +- src/app/pages/mcp.tsx | 10 -- src/app/utils/persist.ts | 253 +++++++++++++++++++++++++++++++++++ src/index.tsx | 74 +++++++++- 7 files changed, 403 insertions(+), 14 deletions(-) create mode 100644 src/app/context/platform.tsx create mode 100644 src/app/utils/persist.ts diff --git a/package.json b/package.json index 5dff43c86..d1f5a0ffa 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/storage": "^4.3.3", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "~2.6.0", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfe37ad00..40946480a 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/storage': + specifier: ^4.3.3 + version: 4.3.3(solid-js@1.9.10) '@tauri-apps/api': specifier: ^2.0.0 version: 2.9.1 @@ -444,6 +447,23 @@ packages: cpu: [x64] os: [win32] + '@solid-primitives/storage@4.3.3': + resolution: {integrity: sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw==} + peerDependencies: + '@tauri-apps/plugin-store': '*' + solid-js: ^1.6.12 + solid-start: '*' + peerDependenciesMeta: + '@tauri-apps/plugin-store': + optional: true + solid-start: + optional: true + + '@solid-primitives/utils@6.3.2': + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} + peerDependencies: + solid-js: ^1.6.12 + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -1261,6 +1281,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@solid-primitives/storage@4.3.3(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/src/app/context/platform.tsx b/src/app/context/platform.tsx new file mode 100644 index 000000000..78df67a2b --- /dev/null +++ b/src/app/context/platform.tsx @@ -0,0 +1,46 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; + +export type SyncStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +}; + +export type AsyncStorage = { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; +}; + +export type Platform = { + platform: "web" | "desktop"; + os?: "macos" | "windows" | "linux"; + version?: string; + openLink(url: string): void; + restart(): Promise; + notify(title: string, description?: string, href?: string): Promise; + storage?: (name?: string) => SyncStorage | AsyncStorage; + checkUpdate?: () => Promise<{ updateAvailable: boolean; version?: string }>; + update?: () => Promise; + fetch?: typeof fetch; + getDefaultServerUrl?: () => Promise; + setDefaultServerUrl?: (url: string | null) => Promise; +}; + +const PlatformContext = createContext(undefined); + +export function PlatformProvider(props: ParentProps & { value: Platform }) { + return ( + + {props.children} + + ); +} + +export function usePlatform() { + const context = useContext(PlatformContext); + if (!context) { + throw new Error("Platform context is missing"); + } + return context; +} diff --git a/src/app/index.css b/src/app/index.css index 03140c047..5cee44b46 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -1,7 +1,7 @@ @import "tailwindcss"; -@config "../tailwind.config.ts"; +@config "../../tailwind.config.ts"; -@import "./styles/colors.css"; +@import "../styles/colors.css"; :root { color-scheme: light; diff --git a/src/app/pages/mcp.tsx b/src/app/pages/mcp.tsx index 84e6185dd..12dc0d171 100644 --- a/src/app/pages/mcp.tsx +++ b/src/app/pages/mcp.tsx @@ -24,16 +24,6 @@ export type McpViewProps = { connectMcp: (entry: McpDirectoryInfo) => void; showMcpReloadBanner: boolean; reloadMcpEngine: () => void; - advancedName: string; - setAdvancedName: (value: string) => void; - advancedUrl: string; - setAdvancedUrl: (value: string) => void; - advancedOAuth: boolean; - setAdvancedOAuth: (value: boolean) => void; - advancedEnabled: boolean; - setAdvancedEnabled: (value: boolean) => void; - addAdvancedMcp: () => void; - testAdvancedMcp: () => void; }; const statusBadge = (status: "connected" | "needs_auth" | "needs_client_registration" | "failed" | "disabled" | "disconnected") => { diff --git a/src/app/utils/persist.ts b/src/app/utils/persist.ts new file mode 100644 index 000000000..9747465c2 --- /dev/null +++ b/src/app/utils/persist.ts @@ -0,0 +1,253 @@ +import { makePersisted } from "@solid-primitives/storage"; +import { createResource, type Accessor } from "solid-js"; +import type { SetStoreFunction, Store } from "solid-js/store"; + +import { usePlatform, type AsyncStorage, type SyncStorage } from "../context/platform"; + +type InitType = Promise | string | null; +type PersistedWithReady = [Store, SetStoreFunction, InitType, Accessor]; + +type PersistTarget = { + storage?: string; + key: string; + legacy?: string[]; + migrate?: (value: unknown) => unknown; +}; + +const LEGACY_STORAGE = "default.dat"; +const GLOBAL_STORAGE = "openwork.global.dat"; + +function snapshot(value: unknown) { + return JSON.parse(JSON.stringify(value)) as unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function merge(defaults: unknown, value: unknown): unknown { + if (value === undefined) return defaults; + if (value === null) return value; + + if (Array.isArray(defaults)) { + if (Array.isArray(value)) return value; + return defaults; + } + + if (isRecord(defaults)) { + if (!isRecord(value)) return defaults; + + const result: Record = { ...defaults }; + for (const key of Object.keys(value)) { + if (key in defaults) { + result[key] = merge((defaults as Record)[key], (value as Record)[key]); + } else { + result[key] = (value as Record)[key]; + } + } + return result; + } + + return value; +} + +function parse(value: string) { + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +} + +function checksum(input: string) { + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash << 5) - hash + input.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(36); +} + +function workspaceStorage(dir: string) { + const head = dir.slice(0, 12) || "workspace"; + const sum = checksum(dir); + return `openwork.workspace.${head}.${sum}.dat`; +} + +function localStorageWithPrefix(prefix: string): SyncStorage { + const base = `${prefix}:`; + return { + getItem: (key) => localStorage.getItem(base + key), + setItem: (key, value) => localStorage.setItem(base + key, value), + removeItem: (key) => localStorage.removeItem(base + key), + }; +} + +export const Persist = { + global(key: string, legacy?: string[]): PersistTarget { + return { storage: GLOBAL_STORAGE, key, legacy }; + }, + workspace(dir: string, key: string, legacy?: string[]): PersistTarget { + return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }; + }, + session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget { + return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }; + }, + scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget { + if (session) return Persist.session(dir, session, key, legacy); + return Persist.workspace(dir, key, legacy); + }, +}; + +export function removePersisted(target: { storage?: string; key: string }) { + const platform = usePlatform(); + const isDesktop = platform.platform === "desktop" && !!platform.storage; + + if (isDesktop) { + return platform.storage?.(target.storage)?.removeItem(target.key); + } + + if (!target.storage) { + localStorage.removeItem(target.key); + return; + } + + localStorageWithPrefix(target.storage).removeItem(target.key); +} + +export function persisted( + target: string | PersistTarget, + store: [Store, SetStoreFunction], +): PersistedWithReady { + const platform = usePlatform(); + const config: PersistTarget = typeof target === "string" ? { key: target } : target; + + const defaults = snapshot(store[0]); + const legacy = config.legacy ?? []; + + const isDesktop = platform.platform === "desktop" && !!platform.storage; + + const currentStorage = (() => { + if (isDesktop) return platform.storage?.(config.storage); + if (!config.storage) return localStorage; + return localStorageWithPrefix(config.storage); + })(); + + const legacyStorage = (() => { + if (!isDesktop) return localStorage; + if (!config.storage) return platform.storage?.(); + return platform.storage?.(LEGACY_STORAGE); + })(); + + const storage = (() => { + if (!isDesktop) { + const current = currentStorage as SyncStorage; + const legacyStore = legacyStorage as SyncStorage; + + const api: SyncStorage = { + getItem: (key) => { + const raw = current.getItem(key); + if (raw !== null) { + const parsed = parse(raw); + if (parsed === undefined) return raw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (raw !== next) current.setItem(key, next); + return next; + } + + for (const legacyKey of legacy) { + const legacyRaw = legacyStore.getItem(legacyKey); + if (legacyRaw === null) continue; + + current.setItem(key, legacyRaw); + legacyStore.removeItem(legacyKey); + + const parsed = parse(legacyRaw); + if (parsed === undefined) return legacyRaw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (legacyRaw !== next) current.setItem(key, next); + return next; + } + + return null; + }, + setItem: (key, value) => { + current.setItem(key, value); + }, + removeItem: (key) => { + current.removeItem(key); + }, + }; + + return api; + } + + const current = currentStorage as AsyncStorage; + const legacyStore = legacyStorage as AsyncStorage | undefined; + + const api: AsyncStorage = { + getItem: async (key) => { + const raw = await current.getItem(key); + if (raw !== null) { + const parsed = parse(raw); + if (parsed === undefined) return raw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (raw !== next) await current.setItem(key, next); + return next; + } + + if (!legacyStore) return null; + + for (const legacyKey of legacy) { + const legacyRaw = await legacyStore.getItem(legacyKey); + if (legacyRaw === null) continue; + + await current.setItem(key, legacyRaw); + await legacyStore.removeItem(legacyKey); + + const parsed = parse(legacyRaw); + if (parsed === undefined) return legacyRaw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (legacyRaw !== next) await current.setItem(key, next); + return next; + } + + return null; + }, + setItem: async (key, value) => { + await current.setItem(key, value); + }, + removeItem: async (key) => { + await current.removeItem(key); + }, + }; + + return api; + })(); + + const [state, setState, init] = makePersisted(store, { name: config.key, storage }); + + const isAsync = init instanceof Promise; + const [ready] = createResource( + () => init, + async (initValue) => { + if (initValue instanceof Promise) await initValue; + return true; + }, + { initialValue: !isAsync }, + ); + + return [state, setState, init, () => ready() === true]; +} diff --git a/src/index.tsx b/src/index.tsx index c1451e74a..01cd9bc6f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,8 +2,10 @@ import { render } from "solid-js/web"; import { bootstrapTheme } from "./app/theme"; -import "./styles.css"; +import "./app/index.css"; import App from "./app/app"; +import { PlatformProvider, type Platform } from "./app/context/platform"; +import { isTauriRuntime } from "./app/utils"; bootstrapTheme(); @@ -13,4 +15,72 @@ if (!root) { throw new Error("Root element not found"); } -render(() => , root); +const platform: Platform = { + platform: isTauriRuntime() ? "desktop" : "web", + openLink(url: string) { + if (isTauriRuntime()) { + void import("@tauri-apps/plugin-opener") + .then(({ openUrl }) => openUrl(url)) + .catch(() => undefined); + return; + } + + window.open(url, "_blank"); + }, + restart: async () => { + if (isTauriRuntime()) { + const { relaunch } = await import("@tauri-apps/plugin-process"); + await relaunch(); + return; + } + + window.location.reload(); + }, + notify: async (title, description, href) => { + if (!("Notification" in window)) return; + + const permission = + Notification.permission === "default" + ? await Notification.requestPermission().catch(() => "denied") + : Notification.permission; + + if (permission !== "granted") return; + + const inView = document.visibilityState === "visible" && document.hasFocus(); + if (inView) return; + + await Promise.resolve() + .then(() => { + const notification = new Notification(title, { + body: description ?? "", + }); + notification.onclick = () => { + window.focus(); + if (href) { + window.history.pushState(null, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); + } + notification.close(); + }; + }) + .catch(() => undefined); + }, + storage: (name) => { + const prefix = name ? `${name}:` : ""; + return { + getItem: (key) => window.localStorage.getItem(prefix + key), + setItem: (key, value) => window.localStorage.setItem(prefix + key, value), + removeItem: (key) => window.localStorage.removeItem(prefix + key), + }; + }, + fetch, +}; + +render( + () => ( + + + + ), + root, +); From 1a89bfcafecd5b55070c764a0246f9f421e2822d Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:15:02 -0800 Subject: [PATCH 02/15] feat: add server context for base urls --- src/app/context/server.tsx | 180 +++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/app/context/server.tsx diff --git a/src/app/context/server.tsx b/src/app/context/server.tsx new file mode 100644 index 000000000..09d34eab9 --- /dev/null +++ b/src/app/context/server.tsx @@ -0,0 +1,180 @@ +import { createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js"; +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; + +export function normalizeServerUrl(input: string) { + const trimmed = input.trim(); + if (!trimmed) return; + const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`; + return withProtocol.replace(/\/+$/, ""); +} + +export function serverDisplayName(url: string) { + if (!url) return ""; + return url.replace(/^https?:\/\//, "").replace(/\/+$/, ""); +} + +type ServerContextValue = { + url: string; + name: string; + list: string[]; + healthy: () => boolean | undefined; + setActive: (url: string) => void; + add: (url: string) => void; + remove: (url: string) => void; +}; + +const ServerContext = createContext(undefined); + +export function ServerProvider(props: ParentProps & { defaultUrl: string }) { + const [list, setList] = createSignal([]); + const [active, setActiveRaw] = createSignal(""); + const [healthy, setHealthy] = createSignal(undefined); + const [ready, setReady] = createSignal(false); + + const readStoredList = () => { + try { + const raw = window.localStorage.getItem("openwork.server.list"); + const parsed = raw ? (JSON.parse(raw) as unknown) : []; + return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []; + } catch { + return []; + } + }; + + const readStoredActive = () => { + try { + const stored = window.localStorage.getItem("openwork.server.active"); + return typeof stored === "string" ? stored : ""; + } catch { + return ""; + } + }; + + createEffect(() => { + if (typeof window === "undefined") return; + if (ready()) return; + + const storedList = readStoredList(); + const fallback = normalizeServerUrl(props.defaultUrl) ?? ""; + const storedActive = normalizeServerUrl(readStoredActive()); + + const initialList = storedList.length ? storedList : fallback ? [fallback] : []; + const initialActive = storedActive || initialList[0] || fallback || ""; + + setList(initialList); + setActiveRaw(initialActive); + setReady(true); + }); + + createEffect(() => { + if (!ready()) return; + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem("openwork.server.list", JSON.stringify(list())); + window.localStorage.setItem("openwork.server.active", active()); + } catch { + // ignore + } + }); + + const activeUrl = createMemo(() => active()); + + const checkHealth = async (url: string) => { + if (!url) return false; + const client = createOpencodeClient({ + baseUrl: url, + signal: AbortSignal.timeout(3000), + }); + return client.global + .health() + .then((result) => result.data?.healthy === true) + .catch(() => false); + }; + + createEffect(() => { + const url = activeUrl(); + if (!url) return; + + setHealthy(undefined); + + let activeRun = true; + let busy = false; + + const run = () => { + if (busy) return; + busy = true; + void checkHealth(url) + .then((next) => { + if (!activeRun) return; + setHealthy(next); + }) + .finally(() => { + busy = false; + }); + }; + + run(); + const interval = window.setInterval(run, 10_000); + + onCleanup(() => { + activeRun = false; + window.clearInterval(interval); + }); + }); + + const setActive = (input: string) => { + const next = normalizeServerUrl(input); + if (!next) return; + setActiveRaw(next); + }; + + const add = (input: string) => { + const next = normalizeServerUrl(input); + if (!next) return; + + setList((current) => { + if (current.includes(next)) return current; + return [...current, next]; + }); + setActiveRaw(next); + }; + + const remove = (input: string) => { + const next = normalizeServerUrl(input); + if (!next) return; + + setList((current) => current.filter((item) => item !== next)); + setActiveRaw((current) => { + if (current !== next) return current; + const remaining = list().filter((item) => item !== next); + return remaining[0] ?? ""; + }); + }; + + const value: ServerContextValue = { + get url() { + return activeUrl(); + }, + get name() { + return serverDisplayName(activeUrl()); + }, + get list() { + return list(); + }, + healthy, + setActive, + add, + remove, + }; + + return {props.children}; +} + +export function useServer() { + const context = useContext(ServerContext); + if (!context) { + throw new Error("Server context is missing"); + } + return context; +} From cafc3efb4eedbd8899833b6334d272cff9d67e00 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:21:42 -0800 Subject: [PATCH 03/15] feat: add global sdk event pipeline --- package.json | 1 + pnpm-lock.yaml | 13 ++++ src/app/context/global-sdk.tsx | 131 +++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 src/app/context/global-sdk.tsx diff --git a/package.json b/package.json index d1f5a0ffa..d3a4d7be2 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 40946480a..aa37880cf 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 000000000..a9f27a486 --- /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; +} From ce04ae32bdcff661e85454a1ff4027014c7d878b Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:24:24 -0800 Subject: [PATCH 04/15] feat: add global sync store scaffolding --- src/app/context/global-sync.tsx | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/app/context/global-sync.tsx diff --git a/src/app/context/global-sync.tsx b/src/app/context/global-sync.tsx new file mode 100644 index 000000000..9b9fe9e25 --- /dev/null +++ b/src/app/context/global-sync.tsx @@ -0,0 +1,62 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; +import { createStore, type SetStoreFunction, type Store } from "solid-js/store"; + +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"; + +import type { TodoItem } from "../types"; + +export type WorkspaceState = { + status: "idle" | "loading" | "partial" | "ready"; + session: Session[]; + session_status: Record; + message: Record; + part: Record; + todo: Record; +}; + +type WorkspaceStore = [Store, SetStoreFunction]; + +type GlobalSyncContextValue = { + data: Store<{ ready: boolean; error?: string }>; + child: (directory: string) => WorkspaceStore; +}; + +const GlobalSyncContext = createContext(undefined); + +const createWorkspaceState = (): WorkspaceState => ({ + status: "idle", + session: [], + session_status: {}, + message: {}, + part: {}, + todo: {}, +}); + +export function GlobalSyncProvider(props: ParentProps) { + const [globalStore] = createStore({ ready: true, error: undefined as string | undefined }); + const children = new Map(); + + const child = (directory: string): WorkspaceStore => { + const key = directory || "global"; + const existing = children.get(key); + if (existing) return existing; + const store = createStore(createWorkspaceState()); + children.set(key, store); + return store; + }; + + const value: GlobalSyncContextValue = { + data: globalStore, + child, + }; + + return {props.children}; +} + +export function useGlobalSync() { + const context = useContext(GlobalSyncContext); + if (!context) { + throw new Error("Global sync context is missing"); + } + return context; +} From b787fec70973a5e45a451763b13369519fe30c59 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:27:25 -0800 Subject: [PATCH 05/15] feat: add sync provider scaffold --- src/app/context/sync.tsx | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/app/context/sync.tsx diff --git a/src/app/context/sync.tsx b/src/app/context/sync.tsx new file mode 100644 index 000000000..c3dedabb7 --- /dev/null +++ b/src/app/context/sync.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; +import type { SetStoreFunction, Store } from "solid-js/store"; + +import { useGlobalSync, type WorkspaceState } from "./global-sync"; + +type SyncContextValue = { + directory: string; + data: Store; + set: SetStoreFunction; +}; + +const SyncContext = createContext(undefined); + +export function SyncProvider(props: ParentProps & { directory: string }) { + const globalSync = useGlobalSync(); + const [store, setStore] = globalSync.child(props.directory); + + const value: SyncContextValue = { + directory: props.directory, + data: store, + set: setStore, + }; + + return {props.children}; +} + +export function useSync() { + const context = useContext(SyncContext); + if (!context) { + throw new Error("Sync context is missing"); + } + return context; +} From 96f8df72bcc0cb693dd33217fad27ed17c327b9b Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:30:32 -0800 Subject: [PATCH 06/15] feat: add local state provider scaffold --- src/app/context/local.tsx | 69 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/app/context/local.tsx diff --git a/src/app/context/local.tsx b/src/app/context/local.tsx new file mode 100644 index 000000000..afa647a9e --- /dev/null +++ b/src/app/context/local.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; +import { createStore, type SetStoreFunction, type Store } from "solid-js/store"; + +import type { DashboardTab, DemoSequence, ModelRef, View } from "../types"; +import { Persist, persisted } from "../utils/persist"; + +type LocalUIState = { + view: View; + tab: DashboardTab; + demoMode: boolean; + demoSequence: DemoSequence; +}; + +type LocalPreferences = { + showThinking: boolean; + modelVariant: string | null; + defaultModel: ModelRef | null; +}; + +type LocalContextValue = { + ui: Store; + setUi: SetStoreFunction; + prefs: Store; + setPrefs: SetStoreFunction; + ready: () => boolean; +}; + +const LocalContext = createContext(undefined); + +export function LocalProvider(props: ParentProps) { + const [ui, setUi, , uiReady] = persisted( + Persist.global("local.ui", ["openwork.ui"]), + createStore({ + view: "onboarding", + tab: "home", + demoMode: false, + demoSequence: "cold-open", + }), + ); + + const [prefs, setPrefs, , prefsReady] = persisted( + Persist.global("local.preferences", ["openwork.preferences"]), + createStore({ + showThinking: false, + modelVariant: null, + defaultModel: null, + }), + ); + + const ready = () => uiReady() && prefsReady(); + + const value: LocalContextValue = { + ui, + setUi, + prefs, + setPrefs, + ready, + }; + + return {props.children}; +} + +export function useLocal() { + const context = useContext(LocalContext); + if (!context) { + throw new Error("Local context is missing"); + } + return context; +} From c8f2886ac8cf0335c2b1a95c78a4616d73a497f5 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:34:37 -0800 Subject: [PATCH 07/15] feat: add state module entrypoints --- src/app/state/demo.ts | 1 + src/app/state/extensions.ts | 1 + src/app/state/sessions.ts | 1 + src/app/state/system.ts | 1 + src/app/state/templates.ts | 1 + 5 files changed, 5 insertions(+) create mode 100644 src/app/state/demo.ts create mode 100644 src/app/state/extensions.ts create mode 100644 src/app/state/sessions.ts create mode 100644 src/app/state/system.ts create mode 100644 src/app/state/templates.ts diff --git a/src/app/state/demo.ts b/src/app/state/demo.ts new file mode 100644 index 000000000..995c3156c --- /dev/null +++ b/src/app/state/demo.ts @@ -0,0 +1 @@ +export { createDemoState } from "../demo-state"; diff --git a/src/app/state/extensions.ts b/src/app/state/extensions.ts new file mode 100644 index 000000000..a5a12c433 --- /dev/null +++ b/src/app/state/extensions.ts @@ -0,0 +1 @@ +export { createExtensionsStore } from "../context/extensions"; diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts new file mode 100644 index 000000000..ec318e592 --- /dev/null +++ b/src/app/state/sessions.ts @@ -0,0 +1 @@ +export { createSessionStore } from "../context/session"; diff --git a/src/app/state/system.ts b/src/app/state/system.ts new file mode 100644 index 000000000..437a6d4ee --- /dev/null +++ b/src/app/state/system.ts @@ -0,0 +1 @@ +export { createSystemState } from "../system-state"; diff --git a/src/app/state/templates.ts b/src/app/state/templates.ts new file mode 100644 index 000000000..63bceadac --- /dev/null +++ b/src/app/state/templates.ts @@ -0,0 +1 @@ +export { createTemplateState } from "../template-state"; From 1dbe539356435f8a5a7fa4aa52bc3ba6139fa962 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:08:52 -0800 Subject: [PATCH 08/15] feat: add platform context and persistence helper --- package.json | 1 + pnpm-lock.yaml | 29 ++++ src/app/context/platform.tsx | 46 +++++++ src/app/index.css | 4 +- src/app/pages/mcp.tsx | 10 -- src/app/utils/persist.ts | 253 +++++++++++++++++++++++++++++++++++ src/index.tsx | 74 +++++++++- 7 files changed, 403 insertions(+), 14 deletions(-) create mode 100644 src/app/context/platform.tsx create mode 100644 src/app/utils/persist.ts diff --git a/package.json b/package.json index 5dff43c86..d1f5a0ffa 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/storage": "^4.3.3", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "~2.6.0", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfe37ad00..40946480a 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/storage': + specifier: ^4.3.3 + version: 4.3.3(solid-js@1.9.10) '@tauri-apps/api': specifier: ^2.0.0 version: 2.9.1 @@ -444,6 +447,23 @@ packages: cpu: [x64] os: [win32] + '@solid-primitives/storage@4.3.3': + resolution: {integrity: sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw==} + peerDependencies: + '@tauri-apps/plugin-store': '*' + solid-js: ^1.6.12 + solid-start: '*' + peerDependenciesMeta: + '@tauri-apps/plugin-store': + optional: true + solid-start: + optional: true + + '@solid-primitives/utils@6.3.2': + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} + peerDependencies: + solid-js: ^1.6.12 + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -1261,6 +1281,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@solid-primitives/storage@4.3.3(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/src/app/context/platform.tsx b/src/app/context/platform.tsx new file mode 100644 index 000000000..78df67a2b --- /dev/null +++ b/src/app/context/platform.tsx @@ -0,0 +1,46 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; + +export type SyncStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +}; + +export type AsyncStorage = { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; +}; + +export type Platform = { + platform: "web" | "desktop"; + os?: "macos" | "windows" | "linux"; + version?: string; + openLink(url: string): void; + restart(): Promise; + notify(title: string, description?: string, href?: string): Promise; + storage?: (name?: string) => SyncStorage | AsyncStorage; + checkUpdate?: () => Promise<{ updateAvailable: boolean; version?: string }>; + update?: () => Promise; + fetch?: typeof fetch; + getDefaultServerUrl?: () => Promise; + setDefaultServerUrl?: (url: string | null) => Promise; +}; + +const PlatformContext = createContext(undefined); + +export function PlatformProvider(props: ParentProps & { value: Platform }) { + return ( + + {props.children} + + ); +} + +export function usePlatform() { + const context = useContext(PlatformContext); + if (!context) { + throw new Error("Platform context is missing"); + } + return context; +} diff --git a/src/app/index.css b/src/app/index.css index 03140c047..5cee44b46 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -1,7 +1,7 @@ @import "tailwindcss"; -@config "../tailwind.config.ts"; +@config "../../tailwind.config.ts"; -@import "./styles/colors.css"; +@import "../styles/colors.css"; :root { color-scheme: light; diff --git a/src/app/pages/mcp.tsx b/src/app/pages/mcp.tsx index 84e6185dd..12dc0d171 100644 --- a/src/app/pages/mcp.tsx +++ b/src/app/pages/mcp.tsx @@ -24,16 +24,6 @@ export type McpViewProps = { connectMcp: (entry: McpDirectoryInfo) => void; showMcpReloadBanner: boolean; reloadMcpEngine: () => void; - advancedName: string; - setAdvancedName: (value: string) => void; - advancedUrl: string; - setAdvancedUrl: (value: string) => void; - advancedOAuth: boolean; - setAdvancedOAuth: (value: boolean) => void; - advancedEnabled: boolean; - setAdvancedEnabled: (value: boolean) => void; - addAdvancedMcp: () => void; - testAdvancedMcp: () => void; }; const statusBadge = (status: "connected" | "needs_auth" | "needs_client_registration" | "failed" | "disabled" | "disconnected") => { diff --git a/src/app/utils/persist.ts b/src/app/utils/persist.ts new file mode 100644 index 000000000..9747465c2 --- /dev/null +++ b/src/app/utils/persist.ts @@ -0,0 +1,253 @@ +import { makePersisted } from "@solid-primitives/storage"; +import { createResource, type Accessor } from "solid-js"; +import type { SetStoreFunction, Store } from "solid-js/store"; + +import { usePlatform, type AsyncStorage, type SyncStorage } from "../context/platform"; + +type InitType = Promise | string | null; +type PersistedWithReady = [Store, SetStoreFunction, InitType, Accessor]; + +type PersistTarget = { + storage?: string; + key: string; + legacy?: string[]; + migrate?: (value: unknown) => unknown; +}; + +const LEGACY_STORAGE = "default.dat"; +const GLOBAL_STORAGE = "openwork.global.dat"; + +function snapshot(value: unknown) { + return JSON.parse(JSON.stringify(value)) as unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function merge(defaults: unknown, value: unknown): unknown { + if (value === undefined) return defaults; + if (value === null) return value; + + if (Array.isArray(defaults)) { + if (Array.isArray(value)) return value; + return defaults; + } + + if (isRecord(defaults)) { + if (!isRecord(value)) return defaults; + + const result: Record = { ...defaults }; + for (const key of Object.keys(value)) { + if (key in defaults) { + result[key] = merge((defaults as Record)[key], (value as Record)[key]); + } else { + result[key] = (value as Record)[key]; + } + } + return result; + } + + return value; +} + +function parse(value: string) { + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +} + +function checksum(input: string) { + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash << 5) - hash + input.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(36); +} + +function workspaceStorage(dir: string) { + const head = dir.slice(0, 12) || "workspace"; + const sum = checksum(dir); + return `openwork.workspace.${head}.${sum}.dat`; +} + +function localStorageWithPrefix(prefix: string): SyncStorage { + const base = `${prefix}:`; + return { + getItem: (key) => localStorage.getItem(base + key), + setItem: (key, value) => localStorage.setItem(base + key, value), + removeItem: (key) => localStorage.removeItem(base + key), + }; +} + +export const Persist = { + global(key: string, legacy?: string[]): PersistTarget { + return { storage: GLOBAL_STORAGE, key, legacy }; + }, + workspace(dir: string, key: string, legacy?: string[]): PersistTarget { + return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }; + }, + session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget { + return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }; + }, + scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget { + if (session) return Persist.session(dir, session, key, legacy); + return Persist.workspace(dir, key, legacy); + }, +}; + +export function removePersisted(target: { storage?: string; key: string }) { + const platform = usePlatform(); + const isDesktop = platform.platform === "desktop" && !!platform.storage; + + if (isDesktop) { + return platform.storage?.(target.storage)?.removeItem(target.key); + } + + if (!target.storage) { + localStorage.removeItem(target.key); + return; + } + + localStorageWithPrefix(target.storage).removeItem(target.key); +} + +export function persisted( + target: string | PersistTarget, + store: [Store, SetStoreFunction], +): PersistedWithReady { + const platform = usePlatform(); + const config: PersistTarget = typeof target === "string" ? { key: target } : target; + + const defaults = snapshot(store[0]); + const legacy = config.legacy ?? []; + + const isDesktop = platform.platform === "desktop" && !!platform.storage; + + const currentStorage = (() => { + if (isDesktop) return platform.storage?.(config.storage); + if (!config.storage) return localStorage; + return localStorageWithPrefix(config.storage); + })(); + + const legacyStorage = (() => { + if (!isDesktop) return localStorage; + if (!config.storage) return platform.storage?.(); + return platform.storage?.(LEGACY_STORAGE); + })(); + + const storage = (() => { + if (!isDesktop) { + const current = currentStorage as SyncStorage; + const legacyStore = legacyStorage as SyncStorage; + + const api: SyncStorage = { + getItem: (key) => { + const raw = current.getItem(key); + if (raw !== null) { + const parsed = parse(raw); + if (parsed === undefined) return raw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (raw !== next) current.setItem(key, next); + return next; + } + + for (const legacyKey of legacy) { + const legacyRaw = legacyStore.getItem(legacyKey); + if (legacyRaw === null) continue; + + current.setItem(key, legacyRaw); + legacyStore.removeItem(legacyKey); + + const parsed = parse(legacyRaw); + if (parsed === undefined) return legacyRaw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (legacyRaw !== next) current.setItem(key, next); + return next; + } + + return null; + }, + setItem: (key, value) => { + current.setItem(key, value); + }, + removeItem: (key) => { + current.removeItem(key); + }, + }; + + return api; + } + + const current = currentStorage as AsyncStorage; + const legacyStore = legacyStorage as AsyncStorage | undefined; + + const api: AsyncStorage = { + getItem: async (key) => { + const raw = await current.getItem(key); + if (raw !== null) { + const parsed = parse(raw); + if (parsed === undefined) return raw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (raw !== next) await current.setItem(key, next); + return next; + } + + if (!legacyStore) return null; + + for (const legacyKey of legacy) { + const legacyRaw = await legacyStore.getItem(legacyKey); + if (legacyRaw === null) continue; + + await current.setItem(key, legacyRaw); + await legacyStore.removeItem(legacyKey); + + const parsed = parse(legacyRaw); + if (parsed === undefined) return legacyRaw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (legacyRaw !== next) await current.setItem(key, next); + return next; + } + + return null; + }, + setItem: async (key, value) => { + await current.setItem(key, value); + }, + removeItem: async (key) => { + await current.removeItem(key); + }, + }; + + return api; + })(); + + const [state, setState, init] = makePersisted(store, { name: config.key, storage }); + + const isAsync = init instanceof Promise; + const [ready] = createResource( + () => init, + async (initValue) => { + if (initValue instanceof Promise) await initValue; + return true; + }, + { initialValue: !isAsync }, + ); + + return [state, setState, init, () => ready() === true]; +} diff --git a/src/index.tsx b/src/index.tsx index c1451e74a..01cd9bc6f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,8 +2,10 @@ import { render } from "solid-js/web"; import { bootstrapTheme } from "./app/theme"; -import "./styles.css"; +import "./app/index.css"; import App from "./app/app"; +import { PlatformProvider, type Platform } from "./app/context/platform"; +import { isTauriRuntime } from "./app/utils"; bootstrapTheme(); @@ -13,4 +15,72 @@ if (!root) { throw new Error("Root element not found"); } -render(() => , root); +const platform: Platform = { + platform: isTauriRuntime() ? "desktop" : "web", + openLink(url: string) { + if (isTauriRuntime()) { + void import("@tauri-apps/plugin-opener") + .then(({ openUrl }) => openUrl(url)) + .catch(() => undefined); + return; + } + + window.open(url, "_blank"); + }, + restart: async () => { + if (isTauriRuntime()) { + const { relaunch } = await import("@tauri-apps/plugin-process"); + await relaunch(); + return; + } + + window.location.reload(); + }, + notify: async (title, description, href) => { + if (!("Notification" in window)) return; + + const permission = + Notification.permission === "default" + ? await Notification.requestPermission().catch(() => "denied") + : Notification.permission; + + if (permission !== "granted") return; + + const inView = document.visibilityState === "visible" && document.hasFocus(); + if (inView) return; + + await Promise.resolve() + .then(() => { + const notification = new Notification(title, { + body: description ?? "", + }); + notification.onclick = () => { + window.focus(); + if (href) { + window.history.pushState(null, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); + } + notification.close(); + }; + }) + .catch(() => undefined); + }, + storage: (name) => { + const prefix = name ? `${name}:` : ""; + return { + getItem: (key) => window.localStorage.getItem(prefix + key), + setItem: (key, value) => window.localStorage.setItem(prefix + key, value), + removeItem: (key) => window.localStorage.removeItem(prefix + key), + }; + }, + fetch, +}; + +render( + () => ( + + + + ), + root, +); From 8077426041118c3763a8946bbcf234cd35a76430 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:15:02 -0800 Subject: [PATCH 09/15] feat: add server context for base urls --- src/app/context/server.tsx | 180 +++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/app/context/server.tsx diff --git a/src/app/context/server.tsx b/src/app/context/server.tsx new file mode 100644 index 000000000..09d34eab9 --- /dev/null +++ b/src/app/context/server.tsx @@ -0,0 +1,180 @@ +import { createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js"; +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; + +export function normalizeServerUrl(input: string) { + const trimmed = input.trim(); + if (!trimmed) return; + const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`; + return withProtocol.replace(/\/+$/, ""); +} + +export function serverDisplayName(url: string) { + if (!url) return ""; + return url.replace(/^https?:\/\//, "").replace(/\/+$/, ""); +} + +type ServerContextValue = { + url: string; + name: string; + list: string[]; + healthy: () => boolean | undefined; + setActive: (url: string) => void; + add: (url: string) => void; + remove: (url: string) => void; +}; + +const ServerContext = createContext(undefined); + +export function ServerProvider(props: ParentProps & { defaultUrl: string }) { + const [list, setList] = createSignal([]); + const [active, setActiveRaw] = createSignal(""); + const [healthy, setHealthy] = createSignal(undefined); + const [ready, setReady] = createSignal(false); + + const readStoredList = () => { + try { + const raw = window.localStorage.getItem("openwork.server.list"); + const parsed = raw ? (JSON.parse(raw) as unknown) : []; + return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []; + } catch { + return []; + } + }; + + const readStoredActive = () => { + try { + const stored = window.localStorage.getItem("openwork.server.active"); + return typeof stored === "string" ? stored : ""; + } catch { + return ""; + } + }; + + createEffect(() => { + if (typeof window === "undefined") return; + if (ready()) return; + + const storedList = readStoredList(); + const fallback = normalizeServerUrl(props.defaultUrl) ?? ""; + const storedActive = normalizeServerUrl(readStoredActive()); + + const initialList = storedList.length ? storedList : fallback ? [fallback] : []; + const initialActive = storedActive || initialList[0] || fallback || ""; + + setList(initialList); + setActiveRaw(initialActive); + setReady(true); + }); + + createEffect(() => { + if (!ready()) return; + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem("openwork.server.list", JSON.stringify(list())); + window.localStorage.setItem("openwork.server.active", active()); + } catch { + // ignore + } + }); + + const activeUrl = createMemo(() => active()); + + const checkHealth = async (url: string) => { + if (!url) return false; + const client = createOpencodeClient({ + baseUrl: url, + signal: AbortSignal.timeout(3000), + }); + return client.global + .health() + .then((result) => result.data?.healthy === true) + .catch(() => false); + }; + + createEffect(() => { + const url = activeUrl(); + if (!url) return; + + setHealthy(undefined); + + let activeRun = true; + let busy = false; + + const run = () => { + if (busy) return; + busy = true; + void checkHealth(url) + .then((next) => { + if (!activeRun) return; + setHealthy(next); + }) + .finally(() => { + busy = false; + }); + }; + + run(); + const interval = window.setInterval(run, 10_000); + + onCleanup(() => { + activeRun = false; + window.clearInterval(interval); + }); + }); + + const setActive = (input: string) => { + const next = normalizeServerUrl(input); + if (!next) return; + setActiveRaw(next); + }; + + const add = (input: string) => { + const next = normalizeServerUrl(input); + if (!next) return; + + setList((current) => { + if (current.includes(next)) return current; + return [...current, next]; + }); + setActiveRaw(next); + }; + + const remove = (input: string) => { + const next = normalizeServerUrl(input); + if (!next) return; + + setList((current) => current.filter((item) => item !== next)); + setActiveRaw((current) => { + if (current !== next) return current; + const remaining = list().filter((item) => item !== next); + return remaining[0] ?? ""; + }); + }; + + const value: ServerContextValue = { + get url() { + return activeUrl(); + }, + get name() { + return serverDisplayName(activeUrl()); + }, + get list() { + return list(); + }, + healthy, + setActive, + add, + remove, + }; + + return {props.children}; +} + +export function useServer() { + const context = useContext(ServerContext); + if (!context) { + throw new Error("Server context is missing"); + } + return context; +} From 0fa4a415ae3a6b504053a694403f4a7d0a8eac53 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:21:42 -0800 Subject: [PATCH 10/15] feat: add global sdk event pipeline --- package.json | 1 + pnpm-lock.yaml | 13 ++++ src/app/context/global-sdk.tsx | 131 +++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 src/app/context/global-sdk.tsx diff --git a/package.json b/package.json index d1f5a0ffa..d3a4d7be2 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 40946480a..aa37880cf 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 000000000..a9f27a486 --- /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; +} From 75a758a7950a654435b97aef27e01a2358486262 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:24:24 -0800 Subject: [PATCH 11/15] feat: add global sync store scaffolding --- src/app/context/global-sync.tsx | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/app/context/global-sync.tsx diff --git a/src/app/context/global-sync.tsx b/src/app/context/global-sync.tsx new file mode 100644 index 000000000..9b9fe9e25 --- /dev/null +++ b/src/app/context/global-sync.tsx @@ -0,0 +1,62 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; +import { createStore, type SetStoreFunction, type Store } from "solid-js/store"; + +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"; + +import type { TodoItem } from "../types"; + +export type WorkspaceState = { + status: "idle" | "loading" | "partial" | "ready"; + session: Session[]; + session_status: Record; + message: Record; + part: Record; + todo: Record; +}; + +type WorkspaceStore = [Store, SetStoreFunction]; + +type GlobalSyncContextValue = { + data: Store<{ ready: boolean; error?: string }>; + child: (directory: string) => WorkspaceStore; +}; + +const GlobalSyncContext = createContext(undefined); + +const createWorkspaceState = (): WorkspaceState => ({ + status: "idle", + session: [], + session_status: {}, + message: {}, + part: {}, + todo: {}, +}); + +export function GlobalSyncProvider(props: ParentProps) { + const [globalStore] = createStore({ ready: true, error: undefined as string | undefined }); + const children = new Map(); + + const child = (directory: string): WorkspaceStore => { + const key = directory || "global"; + const existing = children.get(key); + if (existing) return existing; + const store = createStore(createWorkspaceState()); + children.set(key, store); + return store; + }; + + const value: GlobalSyncContextValue = { + data: globalStore, + child, + }; + + return {props.children}; +} + +export function useGlobalSync() { + const context = useContext(GlobalSyncContext); + if (!context) { + throw new Error("Global sync context is missing"); + } + return context; +} From 626c43f0499fb56a06dd51ad98517ba7821e0b9b Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:27:25 -0800 Subject: [PATCH 12/15] feat: add sync provider scaffold --- src/app/context/sync.tsx | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/app/context/sync.tsx diff --git a/src/app/context/sync.tsx b/src/app/context/sync.tsx new file mode 100644 index 000000000..c3dedabb7 --- /dev/null +++ b/src/app/context/sync.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; +import type { SetStoreFunction, Store } from "solid-js/store"; + +import { useGlobalSync, type WorkspaceState } from "./global-sync"; + +type SyncContextValue = { + directory: string; + data: Store; + set: SetStoreFunction; +}; + +const SyncContext = createContext(undefined); + +export function SyncProvider(props: ParentProps & { directory: string }) { + const globalSync = useGlobalSync(); + const [store, setStore] = globalSync.child(props.directory); + + const value: SyncContextValue = { + directory: props.directory, + data: store, + set: setStore, + }; + + return {props.children}; +} + +export function useSync() { + const context = useContext(SyncContext); + if (!context) { + throw new Error("Sync context is missing"); + } + return context; +} From 44696da43db78738f81fcd2c052ec3a7d685b17d Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:30:32 -0800 Subject: [PATCH 13/15] feat: add local state provider scaffold --- src/app/context/local.tsx | 69 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/app/context/local.tsx diff --git a/src/app/context/local.tsx b/src/app/context/local.tsx new file mode 100644 index 000000000..afa647a9e --- /dev/null +++ b/src/app/context/local.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; +import { createStore, type SetStoreFunction, type Store } from "solid-js/store"; + +import type { DashboardTab, DemoSequence, ModelRef, View } from "../types"; +import { Persist, persisted } from "../utils/persist"; + +type LocalUIState = { + view: View; + tab: DashboardTab; + demoMode: boolean; + demoSequence: DemoSequence; +}; + +type LocalPreferences = { + showThinking: boolean; + modelVariant: string | null; + defaultModel: ModelRef | null; +}; + +type LocalContextValue = { + ui: Store; + setUi: SetStoreFunction; + prefs: Store; + setPrefs: SetStoreFunction; + ready: () => boolean; +}; + +const LocalContext = createContext(undefined); + +export function LocalProvider(props: ParentProps) { + const [ui, setUi, , uiReady] = persisted( + Persist.global("local.ui", ["openwork.ui"]), + createStore({ + view: "onboarding", + tab: "home", + demoMode: false, + demoSequence: "cold-open", + }), + ); + + const [prefs, setPrefs, , prefsReady] = persisted( + Persist.global("local.preferences", ["openwork.preferences"]), + createStore({ + showThinking: false, + modelVariant: null, + defaultModel: null, + }), + ); + + const ready = () => uiReady() && prefsReady(); + + const value: LocalContextValue = { + ui, + setUi, + prefs, + setPrefs, + ready, + }; + + return {props.children}; +} + +export function useLocal() { + const context = useContext(LocalContext); + if (!context) { + throw new Error("Local context is missing"); + } + return context; +} From ba4a40e21930e14de116c652a3f5d6c44c98a0b0 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:34:37 -0800 Subject: [PATCH 14/15] feat: add state module entrypoints --- src/app/state/demo.ts | 1 + src/app/state/extensions.ts | 1 + src/app/state/sessions.ts | 1 + src/app/state/system.ts | 1 + src/app/state/templates.ts | 1 + 5 files changed, 5 insertions(+) create mode 100644 src/app/state/demo.ts create mode 100644 src/app/state/extensions.ts create mode 100644 src/app/state/sessions.ts create mode 100644 src/app/state/system.ts create mode 100644 src/app/state/templates.ts diff --git a/src/app/state/demo.ts b/src/app/state/demo.ts new file mode 100644 index 000000000..995c3156c --- /dev/null +++ b/src/app/state/demo.ts @@ -0,0 +1 @@ +export { createDemoState } from "../demo-state"; diff --git a/src/app/state/extensions.ts b/src/app/state/extensions.ts new file mode 100644 index 000000000..a5a12c433 --- /dev/null +++ b/src/app/state/extensions.ts @@ -0,0 +1 @@ +export { createExtensionsStore } from "../context/extensions"; diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts new file mode 100644 index 000000000..ec318e592 --- /dev/null +++ b/src/app/state/sessions.ts @@ -0,0 +1 @@ +export { createSessionStore } from "../context/session"; diff --git a/src/app/state/system.ts b/src/app/state/system.ts new file mode 100644 index 000000000..437a6d4ee --- /dev/null +++ b/src/app/state/system.ts @@ -0,0 +1 @@ +export { createSystemState } from "../system-state"; diff --git a/src/app/state/templates.ts b/src/app/state/templates.ts new file mode 100644 index 000000000..63bceadac --- /dev/null +++ b/src/app/state/templates.ts @@ -0,0 +1 @@ +export { createTemplateState } from "../template-state"; From 21946e40cf86e1b4a9b2839155a7e6dff67bdcac Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 20 Jan 2026 14:40:58 -0800 Subject: [PATCH 15/15] feat: wire app providers entry --- src/app/entry.tsx | 21 +++++++++++++++++++++ src/index.tsx | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/app/entry.tsx diff --git a/src/app/entry.tsx b/src/app/entry.tsx new file mode 100644 index 000000000..9caccc6ba --- /dev/null +++ b/src/app/entry.tsx @@ -0,0 +1,21 @@ +import App from "./app"; +import { GlobalSDKProvider } from "./context/global-sdk"; +import { GlobalSyncProvider } from "./context/global-sync"; +import { LocalProvider } from "./context/local"; +import { ServerProvider } from "./context/server"; + +export default function AppEntry() { + const defaultUrl = "http://127.0.0.1:4096"; + + return ( + + + + + + + + + + ); +} diff --git a/src/index.tsx b/src/index.tsx index 01cd9bc6f..d56271169 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,7 @@ import { render } from "solid-js/web"; import { bootstrapTheme } from "./app/theme"; import "./app/index.css"; -import App from "./app/app"; +import AppEntry from "./app/entry"; import { PlatformProvider, type Platform } from "./app/context/platform"; import { isTauriRuntime } from "./app/utils"; @@ -79,7 +79,7 @@ const platform: Platform = { render( () => ( - + ), root,