diff options
| author | Adam Malczewski <[email protected]> | 2026-09-05 01:20:28 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-05 01:20:28 +0900 |
| commit | 985116fdc8961e656cd9d3027debcd1a6237d6c2 (patch) | |
| tree | 43aed23977ea354d23d8c54f27d45bf5e0635dfc /src/app | |
| parent | a104c094b588560f955ff52dc7f949cf3c355f94 (diff) | |
| download | dispatch-web-985116fdc8961e656cd9d3027debcd1a6237d6c2.tar.gz dispatch-web-985116fdc8961e656cd9d3027debcd1a6237d6c2.zip | |
feat(layout): plan 02 — provider consumption + layout WS + localStorage migration
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 39 | ||||
| -rw-r--r-- | src/app/App.test.ts | 57 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 45 |
3 files changed, 105 insertions, 36 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index b7eb783..9eaba9c 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -10,18 +10,10 @@ <script lang="ts"> import type { LayoutTree } from "@dispatch/layout-contract"; import { untrack } from "svelte"; - import { createLocalStore } from "../adapters/local-storage"; - import { - createCollapseStore, - firstCollapsibleNodeId, - firstPanelsNode, - listPanelViews, - TileRoot, - } from "../features/layout"; + import { createCollapseStore, firstCollapsibleNodeId, TileRoot } from "../features/layout"; import { RunModal, type HeartbeatRunView } from "../features/heartbeat"; import { SystemPromptBuilder } from "../features/system-prompt"; import type { AppStore } from "./store.svelte"; - import { seedLayout } from "../features/layout"; import type { AppViewPorts, ChatViewportBridge } from "./view-ports"; import ErrorModal from "./ErrorModal.svelte"; @@ -33,27 +25,18 @@ // closed). Holds a snapshot run view; the modal re-mounts per run (keyed). let heartbeatRun = $state<HeartbeatRunView | null>(null); - // --- The layout tree: seeded from DEFAULT_LAYOUT (the compatibility tree - // that reproduces today's layout) with the sidebar selection overlaid from - // localStorage — today's exact boot behavior (unknown/removed kinds dropped; - // nothing valid stored → the tabs+model defaults). - const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", { - storage: untrack(() => store.storage), - }); - let tree = $state<LayoutTree>( - seedLayout(sidebarStore.load(), listPanelViews().map((v) => v.id)), - ); + // --- The layout tree (plan 02): the ACTIVE workspace's tile tree, served + // by the backend's layout provider plugin when one is active (probed on + // boot, on workspace switch, and on extensions-changed; `layout` WS pushes + // re-render live) and the built-in DEFAULT_LAYOUT otherwise (no-provider + // mode: edits are ephemeral, logged once, no error UI). The store owns the + // whole lifecycle; the shell just renders `tree` and forwards edits. + // `dispatch.sidebar.views` is RETIRED (D-L4): its only reader is the store's + // one-way §2.7 migration. + const tree = $derived(store.layout.tree); - // Tile edits flow up from the panels; the OWNER decides persistence. The - // panels selection is written back to the SAME key in the SAME format - // (`dispatch.sidebar.views` — a JSON array of view ids) so the storage stays - // byte-compatible with today (plan 02 migrates it). function handleTreeChange(next: LayoutTree): void { - tree = next; - const panels = firstPanelsNode(next); - if (panels !== null) { - sidebarStore.save(panels.node.panels.map((p) => p.view)); - } + store.layout.onTreeChange(next); } // --- Sidebar collapse: renderer chrome state (per-node-id open/closed), diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 69b181d..57e966f 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -1,4 +1,7 @@ import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract"; +import type { LayoutTree } from "@dispatch/layout-contract"; +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { overlaySidePanels } from "../features/layout"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; @@ -62,6 +65,12 @@ function fakeFetchImpl(): typeof fetch { status: 200, }); } + if (url.endsWith("/layout")) { + // No layout provider in these tests: the plan-02 no-provider signal. + return new Response(JSON.stringify({ error: "No layout provider is active" }), { + status: 404, + }); + } if (url.includes("/conversations?status=")) { return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); } @@ -82,6 +91,35 @@ function fakeFetchImpl(): typeof fetch { }; } +/** + * A fetch whose layout provider is ACTIVE and serves the given sidebar + * panels (plan 02): `GET /workspaces/:id/layout` returns DEFAULT_LAYOUT with + * the views overlaid; `PUT` echoes the tree (200). Tests that need specific + * sidebar views visible drive the layout through THIS edge — the pre-seeded + * `dispatch.sidebar.views` localStorage is only read by the one-way §2.7 + * migration now. + */ +function fakeFetchWithLayout(views: readonly string[]): typeof fetch { + const inner = fakeFetchImpl(); + return 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("/layout")) { + const method = init?.method ?? "GET"; + if (method === "PUT" && typeof init?.body === "string") { + const body = JSON.parse(init.body) as { tree: LayoutTree }; + return new Response(JSON.stringify({ tree: body.tree }), { status: 200 }); + } + if (method === "GET") { + const tree = overlaySidePanels(DEFAULT_LAYOUT, views); + return new Response(JSON.stringify({ tree }), { status: 200 }); + } + const tree = overlaySidePanels(DEFAULT_LAYOUT, views); + return new Response(JSON.stringify({ tree }), { status: 200 }); + } + return inner(input, init); + }; +} + function createFakeStorage(): Storage { const map = new Map<string, string>(); return { @@ -236,7 +274,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -300,7 +338,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -420,7 +458,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -459,19 +497,22 @@ describe("App component interaction tests", () => { store.dispose(); }); - it("the Extensions view lists frontend modules aggregated from feature manifests", () => { + it("the Extensions view lists frontend modules aggregated from feature manifests", async () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); render(App, { props: { store, onNavigate: vi.fn() } }); - // Extensions view is pre-populated in the fake storage, so the modules table renders immediately. - expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); + // The Extensions view arrives with the provider's tree (an async GET — + // plan 02), so await the modules table rendering. + expect( + await screen.findByRole("columnheader", { name: "Module" }), + ).toBeInTheDocument(); for (const name of [ "chat", "tabs", @@ -593,7 +634,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["vision"]), localStorage: createFakeStorageWithViews(["vision"]), }); ws.resolveOpen(); diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index bca9579..bfec235 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -14,6 +14,7 @@ import type { ConversationStatusChangedMessage, CwdResponse, ExtensionsChangedMessage, + LayoutChangedMessage, LspStatusResponse, McpStatusResponse, ModelMetadata, @@ -82,6 +83,8 @@ import type { HeartbeatStopResult, } from "../features/heartbeat"; import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat"; +import type { LayoutStore } from "../features/layout"; +import { createLayoutHttp, createLayoutStore, listPanelViews } from "../features/layout"; import type { NamedAgent, NamedAgentDeleteResult, @@ -555,6 +558,14 @@ export interface AppStore { */ readonly plugins: PluginsStore; /** + * The ACTIVE workspace's tile tree (plan 02): served by the backend's + * layout provider when one is active, the local default otherwise. The + * shell reads `tree` for the renderer and feeds edits back through + * `onTreeChange` (debounced PUT + rollback); the store owns the probes, + * the WS-push filter, and the no-provider degrade. + */ + readonly layout: LayoutStore; + /** * 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). @@ -694,6 +705,20 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // picker + guardrail on the same event. const pluginsStore = createPluginsStore(createPluginsHttp(httpBase, fetchImpl)); + // ── Layout (plan 02) ───────────────────────────────────────────────────────── + // The ACTIVE workspace's tile tree, owned by the backend's layout provider + // plugin when one is active (its well-known routes + the `layout` WS push) + // and rendered from the local default otherwise (no-provider mode: edits + // ephemeral, logged once, no error UI). Probes run on boot, on every + // workspace switch, and on `extensions-changed` (§2.6); the one-way + // localStorage migration (§2.7) arms on the boot probe. + const layoutStore = createLayoutStore({ + http: createLayoutHttp(httpBase, fetchImpl), + workspaceId: activeWorkspaceId, + storage: localStorageOpt, + knownViews: listPanelViews().map((v) => v.id), + }); + /** * Refetch the model catalog for the ACTIVE workspace * (`GET /models?workspaceId=`, plan 06 §6.5 — workspace-aware; a backend @@ -1403,9 +1428,18 @@ 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(); + // 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); void refreshModels(); void refreshProviderStatus(); }, + onLayout(msg: LayoutChangedMessage) { + // §2.5 multi-client coherence: a layout write on ANY client (or a + // plugin's op) re-renders here — the store filters non-active + // workspaces (the server fans out to everyone). + layoutStore.onServerTree(msg.workspaceId, msg.tree); + }, }; if (opts?.socketFactory !== undefined) { socketOpts.socketFactory = opts.socketFactory; @@ -1426,6 +1460,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshModels(); // Fetch the active workspace's provider status (§6.5 first-boot guardrail). void refreshProviderStatus(); + // Probe the ACTIVE layout provider (plan 02 §2.6 boot trigger): the tree is + // 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 }); // One-time legacy model-name migration on boot (independent of the fetch; // preserved from the pre-refreshModels boot chain). if (migrateLegacyModelName(activeModel) !== activeModel) { @@ -1545,6 +1583,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // (plan 06 §6.5): refetch both for the new workspace. void refreshModels(); void refreshProviderStatus(); + // The tile tree is WORKSPACE-SCOPED too (plan 02): reset to the local + // 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); }, get activeChat(): ChatStore { return activeChat; @@ -2516,6 +2558,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get plugins(): PluginsStore { return pluginsStore; }, + get layout(): LayoutStore { + return layoutStore; + }, get enabledProviderCount(): number | null { return enabledProviderCount; }, |
