diff --git a/package.json b/package.json index d1f5a0ffa..2243aefa0 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", @@ -51,4 +52,4 @@ ] }, "packageManager": "pnpm@10.27.0" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40946480a..4ce0181d7 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,10 @@ 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 +1288,10 @@ 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; +} 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; +} 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; +} 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; +} 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; +} 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/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"; 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,