summaryrefslogtreecommitdiffhomepage
path: root/src/features/plugins/ui
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/features/plugins/ui
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/features/plugins/ui')
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.svelte5
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.test.ts55
2 files changed, 59 insertions, 1 deletions
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");