diff options
| author | Adam Malczewski <[email protected]> | 2026-09-05 04:42:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-05 04:42:20 +0900 |
| commit | 5e5f02c67d0c165dd93f8277a3815d51a540d996 (patch) | |
| tree | 2b98579d25f3f16204feaf25ddac2746e4bbb934 /src/app | |
| parent | 2f282f1a5c784dff97af7c936ccb43ca2b63f03f (diff) | |
| download | dispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.tar.gz dispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.zip | |
feat(layout): plan 03 — frontend plugin ABI + handshake + load lifecycle
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/plugin-host.svelte.ts | 134 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 118 |
2 files changed, 240 insertions, 12 deletions
diff --git a/src/app/plugin-host.svelte.ts b/src/app/plugin-host.svelte.ts new file mode 100644 index 0000000..9e53bc1 --- /dev/null +++ b/src/app/plugin-host.svelte.ts @@ -0,0 +1,134 @@ +/** + * The frontend-plugin HOST wiring (plan 03 §3.2) — the composition-root glue + * between the ABI driver (`core/plugin-abi`), the view registry, the + * surfaces ports, and the plugins HTTP edge. + * + * The sync triggers (boot, `extensions-changed`, `workspace.set` → the app + * store calls `sync()` on exactly those events) each fetch the plugin list + + * the ACTIVE workspace's enablement rows, derive the desired candidate set + * (pure), and run one driver pass. The driver's effects are injected HERE at + * the edge: + * - `importModule` — the dynamic `import()` (the only module-system touch); + * - `buildHostApi` — the per-plugin `FrontendHostAPI` with FORCED pluginId + * attribution into the layout registry, app-store-backed surfaces ports, + * and a plugin-prefixed logger; + * - `unregisterViewsByPlugin` — the registry's unload sweep. + * + * After every pass the per-plugin FE states are reported to the plugins + * store (the manager UI renders "incompatible frontend plugin" and + * activation failures). + */ +import { + createFrontendDriver, + FRONTEND_ABI_VERSION, + type FrontendDriver, + type FrontendHostAPI, + type FrontendPluginState, + frontendCandidates, + usablePluginIds, +} from "../core/plugin-abi"; +import { + registerPluginView, + unregisterViewsByPlugin, + type ViewComponent, +} from "../features/layout"; +import type { PluginsHttp } from "../features/plugins"; + +/** The surfaces ports the ABI hands plugin views — backed by the app store. */ +export interface FrontendSurfacesPorts { + readonly subscribe: (surfaceId: string, conversationId?: string) => void; + readonly unsubscribe: (surfaceId: string, conversationId?: string) => void; + readonly invoke: ( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ) => void; + readonly getSpec: (surfaceId: string) => unknown; +} + +/** The shape of the plugins store the FE states are reported to. */ +export interface FrontendStateReporter { + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void; +} + +export interface PluginHostOptions { + readonly http: PluginsHttp; + /** The backend HTTP base (import-URL origin). */ + readonly httpBase: string; + readonly reporter: FrontendStateReporter; + /** The ACTIVE workspace provider (read per sync — workspace.set trigger). */ + readonly activeWorkspaceId: () => string; + readonly surfaces: FrontendSurfacesPorts; +} + +export interface PluginHost { + /** + * Run one sync pass (all three triggers share it — the active workspace + * is read per call, which is what makes `workspace.set` a distinct + * trigger). Never throws: a failed list fetch keeps the current set. + */ + sync(): Promise<void>; +} + +export function createPluginHost(opts: PluginHostOptions): PluginHost { + const driver: FrontendDriver = createFrontendDriver({ + importModule: (url) => import(/* @vite-ignore */ url), + buildHostApi: (pluginId) => buildHostApi(pluginId), + unregisterViewsByPlugin, + logger: console, + }); + + function buildHostApi(pluginId: string): FrontendHostAPI { + return { + abiVersion: FRONTEND_ABI_VERSION, + registerView: (reg) => + registerPluginView( + { + id: reg.id, + label: reg.label, + // ONE controlled cast at the ABI seam — the registry is a + // heterogeneous component bag (register-views.ts precedent); + // the plugin's own build already vetted its component. + component: reg.component as ViewComponent, + ...(reg.panelEligible !== undefined ? { panelEligible: reg.panelEligible } : {}), + }, + pluginId, + ), + surfaces: { + subscribe: (surfaceId, conversationId) => + opts.surfaces.subscribe(surfaceId, conversationId), + unsubscribe: (surfaceId, conversationId) => + opts.surfaces.unsubscribe(surfaceId, conversationId), + invoke: (surfaceId, actionId, payload, conversationId) => + opts.surfaces.invoke(surfaceId, actionId, payload, conversationId), + getSpec: (surfaceId) => opts.surfaces.getSpec(surfaceId) as never, + }, + logger: { + info: (msg) => console.info(`[frontend-plugin:${pluginId}] ${msg}`), + warn: (msg) => console.warn(`[frontend-plugin:${pluginId}] ${msg}`), + }, + }; + } + + return { + async sync(): Promise<void> { + // Both reads are fail-soft (null on network failure) — a down backend + // keeps the CURRENT imported set untouched (no unload on a flaky + // fetch, and the manager UI's last-known states persist). + const workspaceId = opts.activeWorkspaceId(); + const [list, workspace] = await Promise.all([ + opts.http.list(), + opts.http.workspacePlugins(workspaceId), + ]); + if (list === null) return; + const candidates = frontendCandidates(list, { + workspaceId, + usableIds: usablePluginIds(workspace?.plugins ?? []), + httpBase: opts.httpBase, + }); + await driver.sync(candidates); + opts.reporter.setFrontendStates(driver.states()); + }, + }; +} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index bfec235..8a80160 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -103,6 +103,7 @@ import { type VisionSettings, type VisionSettingsPatch, } from "../features/vision"; +import { createPluginHost } from "./plugin-host.svelte"; import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; import { randomId } from "./uuid"; @@ -250,6 +251,15 @@ export interface AppStore { renameTab(conversationId: string, title: string): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; /** + * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the + * focused conversation when absent) — the frontend-plugin ABI's + * `surfaces.subscribe` path (plan 03). The app auto-subscribes every + * catalog entry; this is the re-assertion port (same wire semantics). + */ + subscribeSurface(surfaceId: string, conversationId?: string): void; + /** Drop one surface's subscription (the ABI's `surfaces.unsubscribe`). */ + unsubscribeSurface(surfaceId: string, conversationId?: string): void; + /** * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). * Returns null when no conversation is focused (a draft has nothing to warm). */ @@ -703,7 +713,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // The app store wires the server's `extensions-changed` broadcast into it // (§11 multi-client coherence) and refreshes the workspace-scoped model // picker + guardrail on the same event. - const pluginsStore = createPluginsStore(createPluginsHttp(httpBase, fetchImpl)); + const pluginsHttp = createPluginsHttp(httpBase, fetchImpl); + const pluginsStore = createPluginsStore(pluginsHttp); // ── Layout (plan 02) ───────────────────────────────────────────────────────── // The ACTIVE workspace's tile tree, owned by the backend's layout provider @@ -1180,6 +1191,57 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let socket: ReturnType<typeof createSurfaceSocket> | null = null; /** + * The surface OP implementations — closure functions shared by the app + * store's public methods AND the frontend-plugin ABI's surfaces ports (the + * plugin host binds exactly these; there is no second protocol state). + */ + function invokeSurfaceOp( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ): void { + const result = protocolInvoke( + protocol, + surfaceId, + actionId, + payload, + conversationId ?? focusedConversationId(), + ); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + + function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void { + const result = protocolSubscribe( + protocol, + surfaceId, + conversationId ?? focusedConversationId(), + ); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + + function unsubscribeSurfaceOp(surfaceId: string, conversationId?: string): void { + // protocolUnsubscribe keys on the surface alone (it drops the pair it + // was subscribed under); the optional id documents the intent. + void conversationId; + const result = protocolUnsubscribe(protocol, surfaceId); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + + function surfaceSpecOp(surfaceId: string): SurfaceSpec | null { + return getSurfaceSpec(protocol, surfaceId); + } + + /** * Open a conversation tab — used by the `conversation.open` WS broadcast * (CLI `--open` flag) and by `conversation.statusChanged` when a new active * conversation is discovered. If the conversation is already open, this is a @@ -1335,6 +1397,28 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } + // ── Frontend plugins (plan 03) ───────────────────────────────────────────── + // The ABI driver's composition-root wiring: one host instance per app + // store. `sync()` runs on boot, on `extensions-changed`, and on every + // workspace switch (the three load-lifecycle triggers — the active + // workspace is read per call, which makes workspace.set meaningful). + // The surfaces ports delegate to the SAME protocol/socket machinery the + // app store's own surface methods use (closure functions below, shared + // with the returned object — no second protocol state). + const pluginHost = createPluginHost({ + http: pluginsHttp, + httpBase, + reporter: pluginsStore, + activeWorkspaceId: () => activeWorkspaceId, + surfaces: { + subscribe: (surfaceId, conversationId) => subscribeSurfaceOp(surfaceId, conversationId), + unsubscribe: (surfaceId, conversationId) => unsubscribeSurfaceOp(surfaceId, conversationId), + invoke: (surfaceId, actionId, payload, conversationId) => + invokeSurfaceOp(surfaceId, actionId, payload, conversationId), + getSpec: (surfaceId) => surfaceSpecOp(surfaceId), + }, + }); + const socketOpts: SurfaceSocketOptions = { url: wsUrl, onMessage: handleServerMessage, @@ -1428,6 +1512,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // surface catalog is NOT refetched here — the server re-pushes it // (workspace-filtered) on the same broadcast. pluginsStore.onExtensionsChanged(); + // Frontend plugins (plan 03): the load lifecycle's extensions-changed + // trigger — newly-loaded/enabled frontend plugins import + activate; + // unloaded/disabled ones are swept (their tiles fall back). + void pluginHost.sync(); // A layout plugin enabled/disabled mid-session starts/stops serving — // the probe picks the new provider state up without a reload (§2.6). void layoutStore.probe(activeWorkspaceId); @@ -1464,6 +1552,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // served by the backend's layout plugin when one is active. `boot: true` // arms the one-way §2.7 localStorage migration on the first success. void layoutStore.probe(activeWorkspaceId, { boot: true }); + // Frontend plugins (plan 03): the load lifecycle's boot trigger — every + // loaded ∧ enabled ∧ frontend-declaring ∧ handshake-passing plugin is + // imported + activated (after the first list fetch lands). + void pluginHost.sync(); // One-time legacy model-name migration on boot (independent of the fetch; // preserved from the pre-refreshModels boot chain). if (migrateLegacyModelName(activeModel) !== activeModel) { @@ -1587,6 +1679,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // default for the new workspace and probe its provider (§2.6 — every // workspace switch is a probe, which is also the missed-push catch-up). layoutStore.onWorkspaceChanged(workspaceId); + // Frontend plugins (plan 03): the load lifecycle's workspace.set + // trigger — the enablement set is WORKSPACE-SCOPED, so a switch may + // load and unload frontend plugins. + void pluginHost.sync(); }, get activeChat(): ChatStore { return activeChat; @@ -1793,17 +1889,15 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { - const result = protocolInvoke( - protocol, - surfaceId, - actionId, - payload, - focusedConversationId(), - ); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } + invokeSurfaceOp(surfaceId, actionId, payload); + }, + + subscribeSurface(surfaceId: string, conversationId?: string): void { + subscribeSurfaceOp(surfaceId, conversationId); + }, + + unsubscribeSurface(surfaceId: string, conversationId?: string): void { + unsubscribeSurfaceOp(surfaceId, conversationId); }, async warmNow(): Promise<WarmResult | null> { |
