summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-06 15:43:37 +0900
committerAdam Malczewski <[email protected]>2026-09-06 15:43:37 +0900
commitb117dba1cd1fa80e1b0c18bae487dc632f4005ae (patch)
tree5c76f3133ce07595d28fc904133bf0b060b13daf /src
parentd39cce17765c809cef4555c9640d12ed171cf4af (diff)
downloaddispatch-web-b117dba1cd1fa80e1b0c18bae487dc632f4005ae.tar.gz
dispatch-web-b117dba1cd1fa80e1b0c18bae487dc632f4005ae.zip
feat(layout): re-probe the layout when the workspace's plugin states change
The backend now activates layout providers PER WORKSPACE (the workspace's per-workspace enablement state picks the ONE active layout plugin), so toggling a layout plugin in the workspace plugins modal changes which tree serves the workspace. The modal's toggle now refreshes the layout so the newly-enabled provider's tree (or the no-provider degrade) lands without a reload: - WorkspaceLayoutPort gains refresh(workspaceId) — the modal calls it after every state write (an attempted write re-probes either way: the probe is the idempotent catch-up; a refused write changed nothing, so it re-syncs the same tree). - App.svelte wires the port to the layout store's new onWorkspacePluginsChanged(workspaceId) — a guarded re-probe that only fires for the ACTIVE workspace (another workspace's toggle is picked up by its own probe on switch, exactly like the other §2.6 triggers). The per-workspace tree resolution itself is backend-side; the marker renderer, the probe triggers, and the WS push filtering are unchanged. Verification: typecheck (svelte-check 0 errors) / test (1528) / check / build all green.
Diffstat (limited to 'src')
-rw-r--r--src/App.svelte5
-rw-r--r--src/features/layout/adapter/abi-port.test.ts1
-rw-r--r--src/features/layout/store.svelte.ts27
-rw-r--r--src/features/layout/store.test.ts38
-rw-r--r--src/features/plugins/logic/types.ts9
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.svelte5
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.test.ts55
7 files changed, 136 insertions, 4 deletions
diff --git a/src/App.svelte b/src/App.svelte
index d19a392..bc9367c 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -77,6 +77,11 @@
return store.layout.providerState === "present";
},
reset: (workspaceId) => store.layout.resetToDefault(workspaceId),
+ // The plugins modal's per-workspace toggles can change WHICH layout
+ // plugin serves the workspace (the per-workspace activation rule) — the
+ // store re-probes only the active workspace (non-active toggles are
+ // picked up by their own probe on switch).
+ refresh: (workspaceId) => store.layout.onWorkspacePluginsChanged(workspaceId),
};
</script>
diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts
index d11bf9e..24f9c94 100644
--- a/src/features/layout/adapter/abi-port.test.ts
+++ b/src/features/layout/adapter/abi-port.test.ts
@@ -28,6 +28,7 @@ function fakeStore(
onTreeChange: vi.fn(),
resetToDefault: vi.fn(async () => null),
onExtensionsChanged: vi.fn(),
+ onWorkspacePluginsChanged: vi.fn(),
...overrides,
} as LayoutStore;
}
diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts
index 5fdf100..29e2fa1 100644
--- a/src/features/layout/store.svelte.ts
+++ b/src/features/layout/store.svelte.ts
@@ -7,9 +7,11 @@ import { migrationTree } from "./logic/migration";
* `LayoutHttp` edge (the plugins-store pattern). Owns the ACTIVE workspace's
* tree and the provider lifecycle:
*
- * - **Provider probe triggers** — boot, every workspace switch, and every
- * `extensions-changed` (a provider enabled/disabled mid-session starts/
- * stops serving; the probe picks it up without a reload).
+ * - **Provider probe triggers** — boot, every workspace switch, every
+ * `extensions-changed`, and every change to the ACTIVE workspace's
+ * per-workspace plugin state (a layout plugin enabled/disabled in the
+ * workspace plugins modal starts/stops serving; the probe picks it up
+ * without a reload).
* - **No-provider mode** — a 404 (or network failure) keeps the tree on its
* local default; edits still work but are EPHEMERAL (in-memory only —
* D-L4 retired the localStorage write path); logged once, no error UI.
@@ -66,6 +68,15 @@ export interface LayoutStore {
resetToDefault(workspaceId: string): Promise<string | null>;
/** Multi-client coherence (§11): re-probe on `extensions-changed`. */
onExtensionsChanged(): void;
+ /**
+ * The active workspace's PER-WORKSPACE PLUGIN STATE changed (the workspace
+ * plugins modal toggled a layout plugin's enablement — the per-workspace
+ * activation rule): re-probe so the newly-enabled provider's tree (or the
+ * no-provider degrade) lands without a reload. A NON-active workspace's
+ * toggle is ignored — its tree is fetched on its next probe (workspace
+ * switch); there is nothing to re-render here.
+ */
+ onWorkspacePluginsChanged(workspaceId: string): void;
}
export interface LayoutStoreOptions {
@@ -291,5 +302,15 @@ export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore {
// migration path. Staying silent keeps the degrade quiet.
});
},
+
+ onWorkspacePluginsChanged(workspaceId) {
+ // The per-workspace activation rule: only the ACTIVE workspace's tree
+ // can change what is rendered here — another workspace's toggle is
+ // picked up by its own probe (boot/workspace-switch/§2.6 triggers).
+ if (workspaceId !== activeWorkspaceId) return;
+ void runProbe(activeWorkspaceId, false).catch(() => {
+ // Same quiet-degrade guard as onExtensionsChanged.
+ });
+ },
};
}
diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts
index 73850ed..331af95 100644
--- a/src/features/layout/store.test.ts
+++ b/src/features/layout/store.test.ts
@@ -315,6 +315,44 @@ describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => {
});
describe("layout store — probe triggers (§2.6)", () => {
+ it("a per-workspace plugin-state change on the ACTIVE workspace re-probes (the plugins-modal toggle)", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a layout plugin got enabled in THIS workspace
+ store.onWorkspacePluginsChanged("ws-a");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a per-workspace plugin-state change on ANOTHER workspace is ignored (its probe fetches on switch)", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a DIFFERENT workspace's toggle — nothing re-renders here
+ store.onWorkspacePluginsChanged("ws-other");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(http.getCalls).toEqual(["ws-a"]); // no second probe
+ expect(store.providerState).toBe("absent");
+ });
+
it("extensions-changed re-probes the active workspace", async () => {
let absent = true;
const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts
index ec80c9b..e64b553 100644
--- a/src/features/plugins/logic/types.ts
+++ b/src/features/plugins/logic/types.ts
@@ -70,6 +70,15 @@ export interface WorkspaceLayoutPort {
* success or to the failure reason to display.
*/
readonly reset: (workspaceId: string) => Promise<string | null>;
+ /**
+ * The workspace's PER-WORKSPACE PLUGIN STATE changed (the modal just
+ * toggled a plugin's enablement): the layout plugin that serves THIS
+ * workspace may have changed (the per-workspace activation rule) — the
+ * composition root re-probes the layout so the newly-enabled provider's
+ * tree (or the no-provider degrade) lands without a reload. Only the
+ * ACTIVE workspace's toggle re-renders; others are ignored by the store.
+ */
+ readonly refresh: (workspaceId: string) => void;
}
/**
diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte
index d1bb27a..0ba7cd6 100644
--- a/src/features/plugins/ui/WorkspacePluginModal.svelte
+++ b/src/features/plugins/ui/WorkspacePluginModal.svelte
@@ -38,6 +38,11 @@
async function toggle(pluginId: string, checked: boolean): Promise<void> {
await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled");
+ // The per-workspace activation rule (layout): toggling a plugin may have
+ // changed which layout plugin serves THIS workspace — re-probe so the
+ // newly-enabled provider's tree (or the no-provider degrade) renders
+ // without a reload. The store ignores non-active workspaces.
+ layout?.refresh(workspaceId);
}
// ── The reset affordance (plan 04 §4.4) ─────────────────────────────────
diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts
index 6bc7179..4c75ab3 100644
--- a/src/features/plugins/ui/WorkspacePluginModal.test.ts
+++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts
@@ -51,8 +51,9 @@ async function renderModal(http: PluginsHttp = fakeHttp()) {
function fakeLayoutPort(
providerActive = true,
reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null),
+ refresh: WorkspaceLayoutPort["refresh"] = vi.fn(),
): WorkspaceLayoutPort {
- return { providerActive, reset };
+ return { providerActive, reset, refresh };
}
describe("WorkspacePluginModal", () => {
@@ -117,6 +118,58 @@ describe("WorkspacePluginModal", () => {
expect(fake.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "disabled");
});
+ it("a successful toggle calls the layout port's refresh (the per-workspace activation may change the served tree)", async () => {
+ const user = userEvent.setup();
+ const refresh = vi.fn();
+ const store = createPluginsStore(fakeHttp());
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ layout: fakeLayoutPort(
+ true,
+ vi.fn(async () => null),
+ refresh,
+ ),
+ onClose: vi.fn(),
+ },
+ });
+ await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i }));
+ expect(refresh).toHaveBeenCalledWith("ws");
+ });
+
+ it("a REFUSED toggle (state write fails) still calls refresh — the probe is the idempotent catch-up", async () => {
+ const user = userEvent.setup();
+ const refresh = vi.fn();
+ const fake = fakeHttp({
+ setWorkspaceState: async () => ({
+ ok: false,
+ error: "Plugin is not loaded — load it globally first",
+ }),
+ });
+ const store = createPluginsStore(fake);
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ layout: fakeLayoutPort(
+ true,
+ vi.fn(async () => null),
+ refresh,
+ ),
+ onClose: vi.fn(),
+ },
+ });
+ await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i }));
+ // The attempted state change re-probes either way (a refused write changed
+ // nothing, so the probe re-syncs the same tree — harmless and consistent).
+ expect(refresh).toHaveBeenCalledWith("ws");
+ });
+
it("shows the template hint for the default workspace", async () => {
const store = createPluginsStore(fakeHttp());
await store.openWorkspace("default");