summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 04:42:20 +0900
committerAdam Malczewski <[email protected]>2026-09-05 04:42:20 +0900
commit5e5f02c67d0c165dd93f8277a3815d51a540d996 (patch)
tree2b98579d25f3f16204feaf25ddac2746e4bbb934 /src/app
parent2f282f1a5c784dff97af7c936ccb43ca2b63f03f (diff)
downloaddispatch-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.ts134
-rw-r--r--src/app/store.svelte.ts118
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> {