summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-03 18:05:29 +0900
committerAdam Malczewski <[email protected]>2026-09-03 18:05:29 +0900
commit3a4922a05930b0dc22740bf5621910803e8f275e (patch)
treed5f831c3d3838cfff346e378f5370a9c58bc2ed3 /src/app
parent33c7245f18346553f660434bc3ac589cff3e80c2 (diff)
downloaddispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.tar.gz
dispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.zip
feat(plugin-system): plan 07 frontend plugin manager ui
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte1
-rw-r--r--src/app/App.test.ts4
-rw-r--r--src/app/store.svelte.ts159
-rw-r--r--src/app/store.test.ts10
4 files changed, 145 insertions, 29 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 9436648..f2d7e9c 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -780,6 +780,7 @@
namedAgents={store.namedAgents}
namedAgentSnapshot={store.namedAgentSnapshot}
onSelectNamedAgent={handleSelectNamedAgent}
+ zeroProviders={store.enabledProviderCount === 0}
/>
<!-- Keyed on the workspace conversation (active tab OR draft) so the inputs
re-mount per conversation — incl. switching between drafts — and can't
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 06e562f..69b181d 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -57,7 +57,7 @@ function fakeSocket(): FakeSocket {
function fakeFetchImpl(): typeof fetch {
return async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
@@ -491,7 +491,7 @@ describe("App component interaction tests", () => {
// network failure / unreachable backend on a new device).
const failingFetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 52136ee..bca9579 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -13,6 +13,7 @@ import type {
ConversationOpenMessage,
ConversationStatusChangedMessage,
CwdResponse,
+ ExtensionsChangedMessage,
LspStatusResponse,
McpStatusResponse,
ModelMetadata,
@@ -34,6 +35,7 @@ import type {
TestComputerResponse,
WarmRequest,
WarmResponse,
+ WorkspaceSetMessage,
} from "@dispatch/transport-contract";
import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire";
@@ -89,6 +91,8 @@ import type {
NamedAgentsResult,
} from "../features/named-agents";
import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents";
+import type { PluginsStore } from "../features/plugins";
+import { createPluginsHttp, createPluginsStore } from "../features/plugins";
import type { Tab, TabsState } from "../features/tabs";
import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs";
import {
@@ -543,6 +547,19 @@ export interface AppStore {
readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null;
/** Refetch the Named Agents (`GET /named-agents`). */
refreshNamedAgents(): Promise<NamedAgentsResult>;
+ // ── Plugins (plan 07) ──────────────────────────────────────────────────────
+ /**
+ * The plugin manager + per-workspace enablement store (one instance per
+ * app store). The workspaces page's manager/modal read + mutate through
+ * it; the app store feeds its `onExtensionsChanged` hook.
+ */
+ readonly plugins: PluginsStore;
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`). `null` = unknown — the
+ * first-boot guardrail renders only on a confident `0` (§7).
+ */
+ readonly enabledProviderCount: number | null;
/**
* Create a Named Agent (`POST /named-agents`). On success the store's
* `namedAgents` list is updated (append/replace) so every consumer — the
@@ -628,6 +645,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
let namedAgentSnapshot = $state<NamedAgentSelectionSnapshot | null>(null);
let fatalError = $state<string | null>(null);
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`, plan 06 §6.5). `null` =
+ * unknown (not fetched yet / endpoint missing) — the first-boot guardrail
+ * shows only on a confident `0` (§7).
+ */
+ let enabledProviderCount = $state<number | null>(null);
+
/** Replace-or-append a Named Agent into the reactive list (immutably). */
function upsertNamedAgent(agent: NamedAgent): void {
namedAgents = upsertNamedAgentList(namedAgents, agent);
@@ -661,6 +686,68 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB;
const localStorageOpt = opts?.localStorage ?? globalThis.localStorage;
+ // ── Plugins (plan 07) ──────────────────────────────────────────────────────
+ // The plugin-manager + per-workspace enablement state lives in its own
+ // feature store (one instance per app store, passed explicitly to the UI).
+ // 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));
+
+ /**
+ * Refetch the model catalog for the ACTIVE workspace
+ * (`GET /models?workspaceId=`, plan 06 §6.5 — workspace-aware; a backend
+ * without the param returns the global catalog, which stays correct).
+ * Re-run on boot, on every workspace switch, and on `extensions-changed`
+ * (a provider toggled in another client updates the picker here too).
+ */
+ async function refreshModels(): Promise<void> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/models?workspaceId=${encodeURIComponent(activeWorkspaceId)}`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as ModelsResponse;
+ // Defensive: a malformed/SPA-fallback response must never poison the
+ // picker with a non-array (the old boot chain had the same exposure).
+ if (!Array.isArray(data.models)) return;
+ models = data.models;
+ modelInfo = data.modelInfo ?? {};
+ } catch (err) {
+ reportError("Failed to load model list", err);
+ }
+ }
+
+ /**
+ * Refetch the active workspace's provider status (the §6.5 first-boot
+ * guardrail signal). Guarded against a slow response losing a race with a
+ * workspace switch (only the CURRENT workspace's result lands). Non-JSON
+ * 200 (SPA fallback — endpoint not shipped) leaves it null (no prompt).
+ */
+ async function refreshProviderStatus(): Promise<void> {
+ const workspaceId = activeWorkspaceId;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`,
+ );
+ if (!res.ok) {
+ if (workspaceId === activeWorkspaceId) enabledProviderCount = null;
+ return;
+ }
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) return;
+ const data = (await res.json()) as { enabledProviderCount?: number };
+ if (workspaceId === activeWorkspaceId) {
+ enabledProviderCount =
+ typeof data.enabledProviderCount === "number" ? data.enabledProviderCount : null;
+ }
+ } catch {
+ // Non-fatal: the guardrail just stays hidden (null) when the endpoint
+ // is unreachable.
+ if (workspaceId === activeWorkspaceId) enabledProviderCount = null;
+ }
+ }
+
const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", {
storage: localStorageOpt,
});
@@ -1290,6 +1377,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
: { type: "subscribe", surfaceId, conversationId: sub.conversationId };
socket?.send(msg);
}
+ // A reconnect is a NEW connection — the server forgot this client's
+ // workspace too. Re-declare it so the re-pushed catalog is filtered
+ // (plan 06 §6.4).
+ socket?.send(workspaceSetMsg(activeWorkspaceId));
// Re-attach to every open conversation's turn stream. A turn that kept
// running while we were disconnected resumes streaming (server replays it
// from `turn-start`); one that sealed while we were gone is committed from
@@ -1305,34 +1396,41 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
watchStore.resync();
}
},
+ onExtensionsChanged(_msg: ExtensionsChangedMessage) {
+ // §11 multi-client coherence: another client (or this one) mutated the
+ // plugin set. Refetch the plugin list (+ any open workspace modal view),
+ // the workspace-scoped model catalog, and the provider guardrail. The
+ // surface catalog is NOT refetched here — the server re-pushes it
+ // (workspace-filtered) on the same broadcast.
+ pluginsStore.onExtensionsChanged();
+ void refreshModels();
+ void refreshProviderStatus();
+ },
};
if (opts?.socketFactory !== undefined) {
socketOpts.socketFactory = opts.socketFactory;
}
socket = createSurfaceSocket(socketOpts);
- // Fetch model catalog
- void fetchImpl(`${httpBase}/models`)
- .then((res) => {
- if (!res.ok) return;
- return res.json() as Promise<ModelsResponse>;
- })
- .then((data) => {
- if (data === undefined) return;
- models = data.models;
- modelInfo = data.modelInfo ?? {};
- // Do NOT silently switch the active model when the current selection is
- // missing from the catalog (e.g. a saved numbered OpenCode credential was
- // removed). The model selector surfaces it as unavailable; the user picks
- // explicitly. activeModel is still normalized through the legacy-migration
- // helper so an old `opencode/...` value is rewritten once.
- if (migrateLegacyModelName(activeModel) !== activeModel) {
- activeModel = migrateLegacyModelName(activeModel);
- }
- })
- .catch((err) => {
- reportError("Failed to load model list", err);
- });
+ // Tell the backend THIS connection's active workspace (plan 06 §6.4). The
+ // socket queues the send until the connection opens; on receipt the server
+ // re-pushes the workspace-filtered surface catalog. Also refreshed on
+ // reconnect (onReopen — a new connection is anonymous again).
+ const workspaceSetMsg = (workspaceId: string): WorkspaceSetMessage => ({
+ type: "workspace.set",
+ workspaceId,
+ });
+ socket.send(workspaceSetMsg(activeWorkspaceId));
+
+ // Fetch the model catalog (workspace-scoped — plan 06 §6.5).
+ void refreshModels();
+ // Fetch the active workspace's provider status (§6.5 first-boot guardrail).
+ void refreshProviderStatus();
+ // One-time legacy model-name migration on boot (independent of the fetch;
+ // preserved from the pre-refreshModels boot chain).
+ if (migrateLegacyModelName(activeModel) !== activeModel) {
+ activeModel = migrateLegacyModelName(activeModel);
+ }
// Fetch the discovered-computer catalog (global, like models). Empty until
// the `ssh` extension lands — a safe no-op until then (the selector shows
@@ -1423,6 +1521,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
},
setActiveWorkspace(workspaceId: string): void {
activeWorkspaceId = workspaceId;
+ // Tell the backend the switch (plan 06 §6.4): workspace switching is
+ // instant + client-side, so the server must be told explicitly — it
+ // re-pushes the workspace-filtered surface catalog on receipt. The send
+ // is idempotent + queued until the socket opens.
+ socket?.send(workspaceSetMsg(workspaceId));
// Reset to a fresh draft scoped to the new workspace so a new chat is
// stamped with the right `workspaceId` on `chat.send`.
const nextDraftId = randomId();
@@ -1438,6 +1541,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void refreshReasoningEffort();
void refreshThinking();
void refreshCompactPercent();
+ // The model picker + provider guardrail are WORKSPACE-SCOPED now
+ // (plan 06 §6.5): refetch both for the new workspace.
+ void refreshModels();
+ void refreshProviderStatus();
},
get activeChat(): ChatStore {
return activeChat;
@@ -2405,6 +2512,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
return namedAgentSnapshot;
},
+ // ── Plugins (plan 07) ────────────────────────────────────────────────────
+ get plugins(): PluginsStore {
+ return pluginsStore;
+ },
+ get enabledProviderCount(): number | null {
+ return enabledProviderCount;
+ },
+
async refreshNamedAgents(): Promise<NamedAgentsResult> {
try {
const res = await fetchImpl(`${httpBase}/named-agents`);
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index f8b878a..d798406 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -128,7 +128,7 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
"body" in input
? await input.clone().text()
: null;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models }), { status: 200 });
}
if (url.endsWith("/named-agents") && method === "GET") {
@@ -575,7 +575,7 @@ describe("createAppStore", () => {
const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
@@ -806,7 +806,7 @@ describe("createAppStore", () => {
init?: RequestInit,
): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
@@ -1439,7 +1439,7 @@ describe("createAppStore", () => {
const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
@@ -2299,7 +2299,7 @@ describe("createAppStore — vision settings (global)", () => {
fetchImpl: async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(
JSON.stringify({
models: ["kimi/k2", "umans/glm-5.2"],