mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat: roll up state architecture providers
This commit is contained in:
@@ -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
11
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
131
src/app/context/global-sdk.tsx
Normal file
131
src/app/context/global-sdk.tsx
Normal 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;
|
||||
}
|
||||
62
src/app/context/global-sync.tsx
Normal file
62
src/app/context/global-sync.tsx
Normal 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
69
src/app/context/local.tsx
Normal 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
180
src/app/context/server.tsx
Normal 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
33
src/app/context/sync.tsx
Normal 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
21
src/app/entry.tsx
Normal 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
1
src/app/state/demo.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createDemoState } from "../demo-state";
|
||||
1
src/app/state/extensions.ts
Normal file
1
src/app/state/extensions.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createExtensionsStore } from "../context/extensions";
|
||||
1
src/app/state/sessions.ts
Normal file
1
src/app/state/sessions.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createSessionStore } from "../context/session";
|
||||
1
src/app/state/system.ts
Normal file
1
src/app/state/system.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createSystemState } from "../system-state";
|
||||
1
src/app/state/templates.ts
Normal file
1
src/app/state/templates.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createTemplateState } from "../template-state";
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user