feat: roll up state architecture providers

This commit is contained in:
Benjamin Shafii
2026-01-20 15:01:35 -08:00
14 changed files with 516 additions and 3 deletions

View File

@@ -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"
}
}

11
pnpm-lock.yaml generated
View File

@@ -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)

View File

@@ -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<typeof createOpencodeClient>;
event: ReturnType<typeof createGlobalEmitter<{ [key: string]: Event }>>;
};
const GlobalSDKContext = createContext<GlobalSDKContextValue | undefined>(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<Queued | undefined> = [];
const coalesced = new Map<string, number>();
let timer: ReturnType<typeof setTimeout> | 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<unknown>) {
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<void>((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 <GlobalSDKContext.Provider value={value}>{props.children}</GlobalSDKContext.Provider>;
}
export function useGlobalSDK() {
const context = useContext(GlobalSDKContext);
if (!context) {
throw new Error("Global SDK context is missing");
}
return context;
}

View File

@@ -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<string, string>;
message: Record<string, Message[]>;
part: Record<string, Part[]>;
todo: Record<string, TodoItem[]>;
};
type WorkspaceStore = [Store<WorkspaceState>, SetStoreFunction<WorkspaceState>];
type GlobalSyncContextValue = {
data: Store<{ ready: boolean; error?: string }>;
child: (directory: string) => WorkspaceStore;
};
const GlobalSyncContext = createContext<GlobalSyncContextValue | undefined>(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<string, WorkspaceStore>();
const child = (directory: string): WorkspaceStore => {
const key = directory || "global";
const existing = children.get(key);
if (existing) return existing;
const store = createStore<WorkspaceState>(createWorkspaceState());
children.set(key, store);
return store;
};
const value: GlobalSyncContextValue = {
data: globalStore,
child,
};
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>;
}
export function useGlobalSync() {
const context = useContext(GlobalSyncContext);
if (!context) {
throw new Error("Global sync context is missing");
}
return context;
}

69
src/app/context/local.tsx Normal file
View File

@@ -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<LocalUIState>;
setUi: SetStoreFunction<LocalUIState>;
prefs: Store<LocalPreferences>;
setPrefs: SetStoreFunction<LocalPreferences>;
ready: () => boolean;
};
const LocalContext = createContext<LocalContextValue | undefined>(undefined);
export function LocalProvider(props: ParentProps) {
const [ui, setUi, , uiReady] = persisted(
Persist.global("local.ui", ["openwork.ui"]),
createStore<LocalUIState>({
view: "onboarding",
tab: "home",
demoMode: false,
demoSequence: "cold-open",
}),
);
const [prefs, setPrefs, , prefsReady] = persisted(
Persist.global("local.preferences", ["openwork.preferences"]),
createStore<LocalPreferences>({
showThinking: false,
modelVariant: null,
defaultModel: null,
}),
);
const ready = () => uiReady() && prefsReady();
const value: LocalContextValue = {
ui,
setUi,
prefs,
setPrefs,
ready,
};
return <LocalContext.Provider value={value}>{props.children}</LocalContext.Provider>;
}
export function useLocal() {
const context = useContext(LocalContext);
if (!context) {
throw new Error("Local context is missing");
}
return context;
}

180
src/app/context/server.tsx Normal file
View File

@@ -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<ServerContextValue | undefined>(undefined);
export function ServerProvider(props: ParentProps & { defaultUrl: string }) {
const [list, setList] = createSignal<string[]>([]);
const [active, setActiveRaw] = createSignal("");
const [healthy, setHealthy] = createSignal<boolean | undefined>(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 <ServerContext.Provider value={value}>{props.children}</ServerContext.Provider>;
}
export function useServer() {
const context = useContext(ServerContext);
if (!context) {
throw new Error("Server context is missing");
}
return context;
}

33
src/app/context/sync.tsx Normal file
View File

@@ -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<WorkspaceState>;
set: SetStoreFunction<WorkspaceState>;
};
const SyncContext = createContext<SyncContextValue | undefined>(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 <SyncContext.Provider value={value}>{props.children}</SyncContext.Provider>;
}
export function useSync() {
const context = useContext(SyncContext);
if (!context) {
throw new Error("Sync context is missing");
}
return context;
}

21
src/app/entry.tsx Normal file
View File

@@ -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 (
<ServerProvider defaultUrl={defaultUrl}>
<GlobalSDKProvider>
<GlobalSyncProvider>
<LocalProvider>
<App />
</LocalProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerProvider>
);
}

1
src/app/state/demo.ts Normal file
View File

@@ -0,0 +1 @@
export { createDemoState } from "../demo-state";

View File

@@ -0,0 +1 @@
export { createExtensionsStore } from "../context/extensions";

View File

@@ -0,0 +1 @@
export { createSessionStore } from "../context/session";

1
src/app/state/system.ts Normal file
View File

@@ -0,0 +1 @@
export { createSystemState } from "../system-state";

View File

@@ -0,0 +1 @@
export { createTemplateState } from "../template-state";

View File

@@ -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(
() => (
<PlatformProvider value={platform}>
<App />
<AppEntry />
</PlatformProvider>
),
root,