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 | |
| 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')
| -rw-r--r-- | src/adapters/ws/index.ts | 8 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 35 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 26 | ||||
| -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 | ||||
| -rw-r--r-- | src/core/wire/conformance.ts | 2 | ||||
| -rw-r--r-- | src/features/layout/adapter/http.test.ts | 136 | ||||
| -rw-r--r-- | src/features/layout/adapter/http.ts | 117 | ||||
| -rw-r--r-- | src/features/layout/index.ts | 3 | ||||
| -rw-r--r-- | src/features/layout/logic/migration.test.ts | 72 | ||||
| -rw-r--r-- | src/features/layout/logic/migration.ts | 76 | ||||
| -rw-r--r-- | src/features/layout/store.svelte.ts | 270 | ||||
| -rw-r--r-- | src/features/layout/store.test.ts | 420 |
14 files changed, 1270 insertions, 36 deletions
diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index 3a6a97d..3713c8c 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -5,6 +5,7 @@ import type { ConversationOpenMessage, ConversationStatusChangedMessage, ExtensionsChangedMessage, + LayoutChangedMessage, WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -34,6 +35,11 @@ export interface SurfaceSocketOptions { * workspace-filtered surface catalog itself). */ onExtensionsChanged?: (msg: ExtensionsChangedMessage) => void; + /** + * Broadcast after every successful layout write (plan 02 §2.5): the FULL + * tree for one workspace. The consumer filters by its active workspace. + */ + onLayout?: (msg: LayoutChangedMessage) => void; onReopen?: () => void; socketFactory?: (url: string) => WebSocketLike; } @@ -84,6 +90,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa opts.onConversationCompacted?.(msg as ConversationCompactedMessage); } else if (msg.type === "extensions-changed") { opts.onExtensionsChanged?.(msg as ExtensionsChangedMessage); + } else if (msg.type === "layout") { + opts.onLayout?.(msg as LayoutChangedMessage); } else { opts.onMessage(msg as SurfaceServerMessage); } diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index c4ec7e3..a7d30a7 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -463,3 +463,38 @@ describe("parseServerMessage — extensions-changed (plan 05)", () => { expect(parseServerMessage(JSON.stringify({ type: "extensions-changed" }))).toBeNull(); }); }); + +describe("parseServerMessage — layout (plan 02)", () => { + const TREE = { version: 1, root: { kind: "leaf", view: "chat" } }; + + it("parses the broadcast (workspaceId + full tree)", () => { + const result = parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: TREE }), + ); + expect(result).toEqual({ type: "layout", workspaceId: "ws-a", tree: TREE }); + }); + + it("rejects a missing/invalid workspaceId", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout", tree: TREE }))).toBeNull(); + expect(parseServerMessage(JSON.stringify({ type: "layout", workspaceId: 7, tree: TREE }))).toBeNull(); + }); + + it("rejects a missing/invalid tree (wrong version, missing root, non-object)", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a" }))).toBeNull(); + expect( + parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 2, root: {} } }), + ), + ).toBeNull(); + expect( + parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 1 } }), + ), + ).toBeNull(); + expect(parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: 7 }))).toBeNull(); + }); + + it("an unknown type still parses to null (the layout addition doesn't loosen the guard)", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout.unknown" }))).toBeNull(); + }); +}); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index 696655b..6cbc559 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -5,6 +5,7 @@ import type { ConversationOpenMessage, ConversationStatusChangedMessage, ExtensionsChangedMessage, + LayoutChangedMessage, WsClientMessage, WsServerMessage, } from "@dispatch/transport-contract"; @@ -26,6 +27,7 @@ const VALID_SERVER_TYPES = new Set([ "conversation.statusChanged", "conversation.compacted", "extensions-changed", + "layout", ]); /** Serialize a client message to a JSON string for the wire. */ @@ -180,6 +182,30 @@ export function parseServerMessage(data: string): WsServerMessage | null { }; return msg; } + case "layout": { + // Layout-provider broadcast (plan 02 §2.5): the FULL tree for ONE + // workspace (decision 7 — no patch protocol). Light shape checks here + // (the tree's structural validation is the backend's write-time job); + // the store filters by its active workspace. + if (typeof parsed.workspaceId !== "string") return null; + const tree = parsed.tree; + if ( + tree === null || + typeof tree !== "object" || + Array.isArray(tree) || + (tree as { version?: unknown }).version !== 1 || + typeof (tree as { root?: unknown }).root !== "object" || + (tree as { root?: unknown }).root === null + ) { + return null; + } + const msg: LayoutChangedMessage = { + type: "layout", + workspaceId: parsed.workspaceId, + tree: tree as LayoutChangedMessage["tree"], + }; + return msg; + } default: return null; } 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; }, diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts index 5dbaae4..99afc6d 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -93,6 +93,8 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { return "conversation.compacted"; case "extensions-changed": return "extensions-changed"; + case "layout": + return "layout"; default: return msg satisfies never; } diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts new file mode 100644 index 0000000..5b50715 --- /dev/null +++ b/src/features/layout/adapter/http.test.ts @@ -0,0 +1,136 @@ +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { createLayoutHttp } from "./http"; + +/** + * Layout HTTP adapter tests — the OUTERMOST edge (fetch) is the only fake: + * a scripted `fetchImpl` returning canned Responses. No internal mocks. + */ + +const LAYOUT_URL = "http://backend.test/workspaces/ws-a/layout"; + +function fetchReturning( + status: number, + body: unknown, + capture?: { url?: string; init?: RequestInit | undefined }, +): typeof fetch { + return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { + if (capture !== undefined) { + capture.url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + capture.init = init; + } + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +describe("createLayoutHttp — getTree", () => { + it("GETs the well-known route and unwraps { tree }", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.url).toBe(LAYOUT_URL); + expect(capture.init?.method).toBeUndefined(); // GET is the default + }); + + it("maps a 404 to the no-provider signal (notFound: true)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(404, { error: "No layout provider is active" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ + ok: false, + notFound: true, + error: "No layout provider is active", + }); + }); + + it("maps a 500 to a non-notFound failure carrying the body's error", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(500, { error: "Layout storage failure" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: false, notFound: false, error: "Layout storage failure" }); + }); + + it("a network failure degrades to notFound (the no-provider mode)", async () => { + const http = createLayoutHttp("http://backend.test", async () => { + throw new TypeError("Failed to fetch"); + }); + const result = await http.getTree("ws-a"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.notFound).toBe(true); + expect(result.error).toContain("Failed to fetch"); + } + }); + + it("a malformed 200 (SPA-fallback JSON without a tree) degrades to notFound", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { chunks: [], latestSeq: 0 }), + ); + const result = await http.getTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — putTree", () => { + it("PUTs { tree } as JSON and unwraps the echo", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("PUT"); + expect(capture.init?.body).toBe(JSON.stringify({ tree: DEFAULT_LAYOUT })); + }); + + it("maps a 400 { error } to a validation failure (not notFound)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(400, { error: "root: split has no children" }), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ + ok: false, + notFound: false, + error: "root: split has no children", + }); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — deleteTree", () => { + it("DELETEs and unwraps the fresh seed", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.deleteTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("DELETE"); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.deleteTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts new file mode 100644 index 0000000..2f91e43 --- /dev/null +++ b/src/features/layout/adapter/http.ts @@ -0,0 +1,117 @@ +import type { LayoutTree } from "@dispatch/layout-contract"; + +/** + * Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the + * ACTIVE layout provider's well-known routes: + * - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace) + * - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }` + * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; plan 04 adds the UI) + * + * Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). The 404 is a SIGNAL, not an error: it means + * no layout provider is active, and the store renders its local default with + * ephemeral edits (never an error UI). A network failure degrades the same way. + */ + +/** Outcome of a layout read/write. `notFound` distinguishes the no-provider 404. */ +export type LayoutHttpResult = + | { readonly ok: true; readonly tree: LayoutTree } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface LayoutHttp { + getTree(workspaceId: string): Promise<LayoutHttpResult>; + putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>; + deleteTree(workspaceId: string): Promise<LayoutHttpResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a 404 — never poisons the store. */ +function malformed(): LayoutHttpResult { + return { ok: false, notFound: true, error: "Malformed layout response" }; +} + +export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): LayoutHttp { + return { + async getTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async putTree(workspaceId, tree): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tree }), + }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async deleteTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { method: "DELETE" }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + }; +} diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts index a2e93a6..f5696ac 100644 --- a/src/features/layout/index.ts +++ b/src/features/layout/index.ts @@ -7,6 +7,7 @@ * OUTSIDE the tree — the tree owns the content region. */ +export { createLayoutHttp, type LayoutHttp, type LayoutHttpResult } from "./adapter/http"; export { bootDefaultOpen, type CollapseState, @@ -17,6 +18,7 @@ export { SIDEBAR_OPEN_KEY, WIDE_BREAKPOINT, } from "./logic/collapse"; +export { migrationTree, sidePanelViews, storedPanelViews } from "./logic/migration"; export { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./logic/panels-edit"; export { getView, @@ -31,6 +33,7 @@ export { } from "./logic/registry"; export { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./logic/seed"; export { firstCollapsibleNodeId, firstPanelsNode } from "./logic/tree-query"; +export { createLayoutStore, type LayoutProviderState, type LayoutStore } from "./store.svelte"; export { default as TileRoot } from "./ui/TileRoot.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/layout/logic/migration.test.ts b/src/features/layout/logic/migration.test.ts new file mode 100644 index 0000000..0b2b402 --- /dev/null +++ b/src/features/layout/logic/migration.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { + migrationTree, + sidePanelViews, + storedPanelViews, +} from "./migration"; +import { overlaySidePanels } from "./seed"; + +const KNOWN = new Set(["tabs", "model", "extensions", "vision", "tasks"]); + +describe("storedPanelViews", () => { + it("parses a stored array of view ids, filtered to known views, order kept", () => { + expect(storedPanelViews(["vision", "junk", "tasks"], KNOWN)).toEqual(["vision", "tasks"]); + }); + + it("returns null for absent/junk/non-array values", () => { + expect(storedPanelViews(null, KNOWN)).toBeNull(); + expect(storedPanelViews(undefined, KNOWN)).toBeNull(); + expect(storedPanelViews("extensions", KNOWN)).toBeNull(); + expect(storedPanelViews({ 0: "tabs" }, KNOWN)).toBeNull(); + }); + + it("returns null when nothing stored is a known view", () => { + expect(storedPanelViews(["deleted-view", "another-gone"], KNOWN)).toBeNull(); + expect(storedPanelViews([], KNOWN)).toBeNull(); + }); +}); + +describe("sidePanelViews", () => { + it("reads the default tree's side panels", () => { + expect(sidePanelViews(DEFAULT_LAYOUT)).toEqual(["tabs", "model"]); + }); + + it("returns null for a tree without a 'side' panels node", () => { + expect(sidePanelViews({ version: 1, root: { kind: "leaf", view: "chat" } })).toBeNull(); + }); +}); + +describe("migrationTree (§2.7 — one-way, best-effort)", () => { + it("imports the stored selection when the fetched tree is the untouched default seed", () => { + const stored = JSON.stringify(["extensions", "tasks"]); + const migrated = migrationTree(JSON.parse(stored), KNOWN, DEFAULT_LAYOUT); + expect(migrated).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])); + // The rest of the tree is untouched — only the side panels changed. + expect(migrated?.root).toEqual( + overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])?.root, + ); + }); + + it("returns null when the key is absent / junk / has no known views (nothing to import)", () => { + expect(migrationTree(null, KNOWN, DEFAULT_LAYOUT)).toBeNull(); + expect(migrationTree("junk", KNOWN, DEFAULT_LAYOUT)).toBeNull(); + expect(migrationTree(["gone-view"], KNOWN, DEFAULT_LAYOUT)).toBeNull(); + }); + + it("returns null when the provider's tree is NOT the default seed (custom layout wins)", () => { + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + expect(migrationTree(["extensions"], KNOWN, custom)).toBeNull(); + }); + + it("returns null when the fetched tree has no side panels node at all", () => { + expect( + migrationTree(["extensions"], KNOWN, { version: 1, root: { kind: "leaf", view: "chat" } }), + ).toBeNull(); + }); + + it("a stored selection identical to the default still migrates (a harmless idempotent PUT)", () => { + const migrated = migrationTree(["tabs", "model"], KNOWN, DEFAULT_LAYOUT); + expect(migrated).toEqual(DEFAULT_LAYOUT); + }); +}); diff --git a/src/features/layout/logic/migration.ts b/src/features/layout/logic/migration.ts new file mode 100644 index 0000000..e0ed6fd --- /dev/null +++ b/src/features/layout/logic/migration.ts @@ -0,0 +1,76 @@ +/** + * localStorage → provider migration (plan 02 §2.7, D-L4; one-way, best-effort). + * + * `dispatch.sidebar.views` is retired as a source of truth: on boot WITH a + * provider present, if the key exists AND the fetched tree's `side` panels + * equal DEFAULT_LAYOUT's (the workspace still has the untouched default + * seed), the stored panel selection is imported once via a PUT. When the + * provider's tree differs from the default (e.g. a calendar provider), the + * workspace already has a custom layout — importing the old sidebar override + * would be wrong. Regardless of outcome the key is never read again (the + * bytes stay, but nothing consumes them). + * + * Pure decision logic: the store owns the effects (the localStorage read + + * the PUT); this module decides IF and WHAT. + */ + +import { + DEFAULT_LAYOUT, + findById, + type LayoutTree, + type PanelsNode, +} from "@dispatch/layout-contract"; +import { overlaySidePanels } from "./seed"; + +/** + * The stored selection's usable view ids: an array of strings filtered to the + * known (registered) views, in stored order. null when nothing usable is + * stored (absent key, junk, or no known ids) — nothing to import. + */ +export function storedPanelViews( + raw: unknown, + knownViews: ReadonlySet<string>, +): readonly string[] | null { + if (!Array.isArray(raw)) return null; + const known = raw.filter((v): v is string => typeof v === "string" && knownViews.has(v)); + return known.length > 0 ? known : null; +} + +/** + * The view ids of a tree's `"side"` panels node (the default tree's swappable + * sidebar stack), or null when the tree has no such node. + */ +export function sidePanelViews(tree: LayoutTree): readonly string[] | null { + const found = findById(tree, "side"); + if (found === null || found.node.kind !== "panels") return null; + return (found.node as PanelsNode).panels.map((p) => p.view); +} + +function samePanels(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +/** + * The one-way migration decision (§2.7): given the RAW stored value, the + * known view ids, and the tree fetched from the provider, return the tree to + * PUT once — or null when there is nothing to migrate: + * - the stored key holds no usable selection, or + * - the provider's tree is NOT the untouched default seed (its side panels + * differ from DEFAULT_LAYOUT's — the workspace already has a custom layout). + */ +export function migrationTree( + storedRaw: unknown, + knownViews: ReadonlySet<string>, + fetched: LayoutTree, +): LayoutTree | null { + const stored = storedPanelViews(storedRaw, knownViews); + if (stored === null) return null; + const fetchedPanels = sidePanelViews(fetched); + const defaultPanels = sidePanelViews(DEFAULT_LAYOUT); + if (fetchedPanels === null || defaultPanels === null) return null; + if (!samePanels(fetchedPanels, defaultPanels)) return null; + // Overlay the stored selection onto the fetched tree (identical to the + // default here by the guard above — the seed overlay keeps the rest of the + // tree shared, not copied). + return overlaySidePanels(fetched, stored); +} diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts new file mode 100644 index 0000000..3d7de3f --- /dev/null +++ b/src/features/layout/store.svelte.ts @@ -0,0 +1,270 @@ +import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +import type { LayoutHttp, LayoutHttpResult } from "./adapter/http"; +import { migrationTree } from "./logic/migration"; + +/** + * Layout store (plan 02 §2.6) — a thin reactive wrapper over the injected + * `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). + * - **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. + * - **WS push** — `onServerTree(workspaceId, tree)` applies a `layout` + * broadcast, ignoring non-active workspaces (the server fans out to all + * clients; the client filters). + * - **User edits persist through the provider** — `onTreeChange` applies the + * edit locally immediately, then PUTs the WHOLE tree (decision 7 — no + * patch protocol) lightly debounced; on a failed PUT the tree rolls back + * to the last server-known tree with a quiet console warning. + * - **Collapse state stays local** (decision 3) — `dispatch.sidebar.open` + * is the collapse store's concern, untouched here. + * - **One-way localStorage migration** (§2.7) — on the FIRST successful + * provider read of the boot session, if `dispatch.sidebar.views` exists + * AND the fetched tree is the untouched default seed, the stored panel + * selection is imported with one PUT; the key is then never read again. + * + * No ambient state: one instance per composition root, passed explicitly; + * no module-level mutables. + */ + +/** Whether an active provider is serving the active workspace. */ +export type LayoutProviderState = "unknown" | "present" | "absent"; + +export interface LayoutStore { + /** The rendered tree for the ACTIVE workspace (starts at the local default). */ + readonly tree: LayoutTree; + /** The active provider's state (absent = the no-provider degrade path). */ + readonly providerState: LayoutProviderState; + /** The workspace the tree currently belongs to. */ + readonly activeWorkspaceId: string; + /** + * Probe the provider for `workspaceId`'s tree. `boot: true` (the composition + * root's boot call) also arms the one-way localStorage migration (§2.7), + * which consumes itself on the first successful provider read regardless of + * outcome. A probe is the missed-push catch-up (§2.5). + */ + probe(workspaceId: string, opts?: { readonly boot?: boolean }): Promise<void>; + /** Workspace switch: reset to the local default for the new workspace + probe. */ + onWorkspaceChanged(workspaceId: string): void; + /** Server `layout` broadcast (§2.5). Non-active workspaces are ignored. */ + onServerTree(workspaceId: string, tree: LayoutTree): void; + /** A user edit (the panels' dropdowns / TileRoot ops). Persists via PUT. */ + onTreeChange(next: LayoutTree): void; + /** Multi-client coherence (§11): re-probe on `extensions-changed`. */ + onExtensionsChanged(): void; +} + +export interface LayoutStoreOptions { + readonly http: LayoutHttp; + /** The boot workspace (its URL slug); "default" if absent. */ + readonly workspaceId?: string; + /** + * Injected localStorage — read ONLY by the one-way §2.7 migration (the + * write path is retired; collapse state has its own store). Undefined → + * migration never applies (tests / storageless contexts). + */ + readonly storage?: Storage | undefined; + /** The known (registered) view ids, for the migration's unknown-id filter. */ + readonly knownViews?: ReadonlySet<string> | readonly string[]; + /** + * The localStorage key the §2.7 migration reads (default + * `dispatch.sidebar.views`; tests override). + */ + readonly sidebarViewsKey?: string; + /** Edit-PUT debounce window in ms (default 300; tests shrink it). */ + readonly debounceMs?: number; + /** The quiet-failure hook (default `console.warn` — never an error UI). */ + readonly warn?: (message: string) => void; +} + +const DEFAULT_SIDEBAR_VIEWS_KEY = "dispatch.sidebar.views"; +const DEFAULT_DEBOUNCE_MS = 300; + +function toKnownSet(known: LayoutStoreOptions["knownViews"]): ReadonlySet<string> { + if (known === undefined) return new Set(); + return known instanceof Set ? known : new Set(known); +} + +export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore { + const http = opts.http; + const storage = opts.storage; + const sidebarViewsKey = opts.sidebarViewsKey ?? DEFAULT_SIDEBAR_VIEWS_KEY; + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const knownViews = toKnownSet(opts.knownViews); + const warn = opts.warn ?? ((message: string) => console.warn(message)); + + let tree = $state<LayoutTree>(DEFAULT_LAYOUT); + let providerState = $state<LayoutProviderState>("unknown"); + let activeWorkspaceId = $state<string>(opts.workspaceId ?? "default"); + + /** The last tree known to come from the server (GET / WS push / PUT echo) — the rollback target. */ + let lastServerTree: LayoutTree | null = null; + /** The pending edit-PUT timer (the "lightly debounced" write). */ + let putTimer: ReturnType<typeof setTimeout> | null = null; + /** Logged-once guard for the no-provider log (reset when a provider appears). */ + let loggedAbsent = false; + /** + * The §2.7 migration is armed by the BOOT probe and consumed by the first + * successful provider read — regardless of outcome (read-once semantics). + */ + let migrationArmed = false; + + function noteAbsent(reason: string): void { + providerState = "absent"; + if (!loggedAbsent) { + loggedAbsent = true; + warn( + `layout: no layout provider active (${reason}) — rendering the built-in default; edits are ephemeral`, + ); + } + } + + function notePresent(): void { + providerState = "present"; + loggedAbsent = false; + } + + function applyServerTree(next: LayoutTree): void { + lastServerTree = next; + tree = next; + } + + /** The §2.7 one-way migration: at most one read + one PUT per session. */ + async function maybeMigrate(): Promise<void> { + if (!migrationArmed || storage === undefined) return; + migrationArmed = false; // consumed regardless of outcome (stop reading the key) + let raw: string | null = null; + try { + raw = storage.getItem(sidebarViewsKey); + } catch { + return; // storage unavailable — best-effort, done + } + if (raw === null) return; // nothing stored — nothing to import + let parsed: unknown = null; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return; // junk — best-effort, done + } + const migrated = migrationTree(parsed, knownViews, tree); + if (migrated === null) return; // custom layout already / nothing usable + const result = await http.putTree(activeWorkspaceId, migrated); + if (result.ok) { + // The server's canonical copy (also echoed back via the WS broadcast). + applyServerTree(result.tree); + } else if (!result.notFound) { + warn(`layout: localStorage migration PUT failed — ${result.error}`); + } + } + + async function runProbe(workspaceId: string, boot: boolean): Promise<void> { + if (boot) migrationArmed = true; + const result: LayoutHttpResult = await http.getTree(workspaceId); + // A switch may have happened while the fetch was in flight — only the + // CURRENT workspace's result lands (the refreshProviderStatus guard). + if (workspaceId !== activeWorkspaceId) return; + if (result.ok) { + notePresent(); + applyServerTree(result.tree); + await maybeMigrate(); + } else if (result.notFound) { + noteAbsent(result.error); + } else { + // A real failure (5xx, malformed) degrades like absent — quietly. + noteAbsent(result.error); + } + } + + function clearPutTimer(): void { + if (putTimer !== null) { + clearTimeout(putTimer); + putTimer = null; + } + } + + async function flushPut(workspaceId: string, pending: LayoutTree): Promise<void> { + putTimer = null; + // Read the CURRENT tree at fire time: a newer edit supersedes the + // debounced snapshot (one in-flight write per window, last wins). + const target = tree === pending ? pending : tree; + const result = await http.putTree(workspaceId, target); + if (result.ok) { + notePresent(); + // The server's validated echo is the new rollback target. + lastServerTree = result.tree; + return; + } + if (result.notFound) { + // The provider vanished: keep the local edit (ephemeral), no rollback — + // there is no server tree to roll back TO. + noteAbsent(result.error); + return; + } + // A failed write on a PRESENT provider: roll back to the last server + // tree, quiet console warning (never an error UI). + warn( + `layout: persisting the tree failed (${result.error}) — rolled back to the last server tree`, + ); + if (lastServerTree !== null) tree = lastServerTree; + } + + return { + get tree() { + return tree; + }, + get providerState() { + return providerState; + }, + get activeWorkspaceId() { + return activeWorkspaceId; + }, + + async probe(workspaceId, probeOpts) { + await runProbe(workspaceId, probeOpts?.boot === true); + }, + + onWorkspaceChanged(workspaceId) { + if (workspaceId === activeWorkspaceId) return; + activeWorkspaceId = workspaceId; + // The tree belongs to the OLD workspace now: reset to the local default + // while the probe resolves (never flash the previous workspace's tree), + // and drop any queued edit — it was for the old workspace. + clearPutTimer(); + lastServerTree = null; + tree = DEFAULT_LAYOUT; + providerState = "unknown"; + void runProbe(workspaceId, false); + }, + + onServerTree(workspaceId, next) { + if (workspaceId !== activeWorkspaceId) return; // clients filter (§2.5) + notePresent(); + clearPutTimer(); // the server's copy supersedes any queued edit + applyServerTree(next); + }, + + onTreeChange(next) { + tree = next; // the edit applies locally IMMEDIATELY (the UI never waits) + if (providerState === "absent") return; // ephemeral — no write, no warning + clearPutTimer(); + putTimer = setTimeout(() => { + void flushPut(activeWorkspaceId, next).catch(() => { + // Network-grade failure: roll back to the last server tree, quietly. + warn("layout: persisting the tree failed — rolled back to the last server tree"); + if (lastServerTree !== null) tree = lastServerTree; + }); + }, debounceMs); + }, + + onExtensionsChanged() { + void runProbe(activeWorkspaceId, false).catch(() => { + // runProbe never throws for HTTP failures; this guard is for the + // migration path. Staying silent keeps the degrade quiet. + }); + }, + }; +} diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts new file mode 100644 index 0000000..7e7d6bc --- /dev/null +++ b/src/features/layout/store.test.ts @@ -0,0 +1,420 @@ +import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { overlaySidePanels } from "./logic/seed"; +import type { LayoutHttp, LayoutHttpResult } from "./adapter/http"; +import { createLayoutStore } from "./store.svelte"; + +/** + * Layout store tests (plan 02 verification gate) — mock HTTP EDGES ONLY (the + * injected `LayoutHttp` is the store's outermost boundary; nothing internal + * is mocked — asymmetric testing per AGENTS.md). Fake timers drive the edit + * PUT debounce deterministically. + */ + +/** A recording fake of the LayoutHttp edge, scripted per test. */ +interface HttpFake extends LayoutHttp { + getCalls: string[]; + putCalls: Array<{ workspaceId: string; tree: LayoutTree }>; + getResult: (workspaceId: string) => LayoutHttpResult; + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult; +} + +function fakeHttp( + getResult: (workspaceId: string) => LayoutHttpResult = () => ({ + ok: false, + notFound: true, + error: "No layout provider is active", + }), + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult = (_ws, tree) => ({ + ok: true, + tree, + }), +): HttpFake { + const getCalls: string[] = []; + const putCalls: Array<{ workspaceId: string; tree: LayoutTree }> = []; + const fake: HttpFake = { + getCalls, + putCalls, + getResult, + putResult, + // Delegate to the CURRENT properties so a test can re-script outcomes + // mid-test (http.putResult = …). + async getTree(workspaceId) { + getCalls.push(workspaceId); + return fake.getResult(workspaceId); + }, + async putTree(workspaceId, tree) { + putCalls.push({ workspaceId, tree }); + return fake.putResult(workspaceId, tree); + }, + async deleteTree() { + return { ok: false, notFound: true, error: "unused" }; + }, + }; + return fake; +} + +function storageWith(views: string[] | null): Storage { + const map = new Map<string, string>(); + if (views !== null) map.set("test.sidebar.views", JSON.stringify(views)); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem: (k) => map.get(k) ?? null, + key: () => null, + removeItem: (k) => map.delete(k), + setItem: (k, v) => map.set(k, v), + }; +} + +const KNOWN = ["tabs", "model", "extensions", "vision"]; + +function makeStore(http: HttpFake, extra: Record<string, unknown> = {}) { + return createLayoutStore({ + http, + workspaceId: "ws-a", + knownViews: KNOWN, + sidebarViewsKey: "test.sidebar.views", + debounceMs: 100, + warn: (message) => warnings.push(message), + ...extra, + }); +} + +let warnings: string[] = []; + +beforeEach(() => { + vi.useFakeTimers(); + warnings = []; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("layout store — no-provider fallback (§2.6)", () => { + it("a 404 probe keeps the default tree, marks the provider absent, and logs ONCE", async () => { + const http = fakeHttp(); + const store = makeStore(http); + + await store.probe("ws-a", { boot: true }); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("no layout provider"); + + // A re-probe (extensions-changed while still absent) does not re-warn. + await store.probe("ws-a"); + expect(warnings).toHaveLength(1); + }); + + it("a network failure degrades like a 404 (quiet, no error UI)", async () => { + const http = fakeHttp(() => ({ ok: false, notFound: true, error: "fetch blew up" })); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + }); + + it("edits still apply in no-provider mode but are EPHEMERAL (no PUT, no rollback, no warning)", async () => { + const http = fakeHttp(); + const store = makeStore(http); + await store.probe("ws-a"); // absent now + expect(store.providerState).toBe("absent"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + expect(store.tree).toEqual(edited); + + vi.advanceTimersByTime(500); + expect(http.putCalls).toEqual([]); // ephemeral: no write attempted + // Quiet = the single absence log (from the probe), nothing more. + expect(warnings).toHaveLength(1); + }); + + it("an edit while the probe is still in flight is PUT optimistically; a 404 flips absent and KEEPS the edit", async () => { + const http = fakeHttp(); + // The PUT is a 404 (the probe will confirm: no provider). + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const store = makeStore(http); + + void store.probe("ws-a"); // in flight (microtask pending) + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); // lands BEFORE the probe resolves + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); // the optimistic write WAS attempted + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(edited); // no rollback — there is no server tree + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — provider present", () => { + it("a 200 probe replaces the tree and marks the provider present", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + expect(warnings).toEqual([]); + }); +}); + +describe("layout store — WS push (§2.5)", () => { + it("a layout push for the ACTIVE workspace replaces the tree", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const pushed = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-a", pushed); + expect(store.tree).toEqual(pushed); + }); + + it("a push for a NON-active workspace is ignored", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const other = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-b", other); + expect(store.tree).toEqual(served); + }); + + it("after a workspace switch the OLD workspace's push is ignored (clients filter)", async () => { + const servedA = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + let served = servedA; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const servedB = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + served = servedB; + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(0); // the probe's promise + expect(store.tree).toEqual(servedB); + + // A late push for the OLD workspace must not land. + const stale = overlaySidePanels(DEFAULT_LAYOUT, ["tasks"]); + store.onServerTree("ws-a", stale); + expect(store.tree).toEqual(servedB); + }); +}); + +describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => { + it("an edit PUTs the WHOLE tree, debounced; a 200 echo becomes the rollback target", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + // Not yet — debounced. + expect(http.putCalls).toEqual([]); + await vi.advanceTimersByTimeAsync(150); + expect(http.putCalls).toEqual([{ workspaceId: "ws-a", tree: edited }]); + expect(warnings).toEqual([]); + }); + + it("rapid edits coalesce into ONE debounced PUT of the latest tree", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(50); + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); + expect(http.putCalls[0]?.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + }); + + it("a 400 rejection rolls back to the last server tree with a quiet warning", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + + const bad = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(bad); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(served); // rolled back + expect(store.providerState).toBe("present"); // the provider is still there + expect(warnings.some((w) => w.includes("rolled back"))).toBe(true); + }); + + it("a 5xx failure rolls back too (present provider, transient error)", async () => { + const served = DEFAULT_LAYOUT; + let failPut = false; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => + failPut + ? { ok: false, notFound: false, error: "storage down" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + failPut = true; + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(200); + expect(store.tree).toEqual(served); + }); + + it("a PUT 404 does NOT roll back — the provider vanished mid-session; the edit stays ephemeral", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + // The provider unloads before the edit's PUT fires. + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(edited); // the edit STAYS (in-memory) + expect(store.providerState).toBe("absent"); + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — probe triggers (§2.6)", () => { + it("extensions-changed re-probes the active workspace", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent ? { ok: false, notFound: true, error: "No layout provider is active" } : { 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 on the server + store.onExtensionsChanged(); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a workspace switch resets to the default, probes the NEW workspace, and drops queued edits", async () => { + const http = fakeHttp((ws) => + ws === "ws-b" ? { ok: true, tree: overlaySidePanels(DEFAULT_LAYOUT, ["vision"]) } : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + // Queue an edit for ws-a, then switch before the debounce fires. + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(300); + + expect(store.activeWorkspaceId).toBe("ws-b"); + expect(http.getCalls).toEqual(["ws-a", "ws-b"]); + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + // The queued edit never PUTs — it belonged to the old workspace. + expect(http.putCalls).toEqual([]); + }); + + it("switching to the SAME workspace is a no-op (no re-probe, no reset)", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + store.onWorkspaceChanged("ws-a"); + expect(http.getCalls).toEqual(["ws-a"]); + }); +}); + +describe("layout store — one-way localStorage migration (§2.7)", () => { + it("boot probe + equal panels + stored key → exactly ONE migration PUT, then the key is dead", async () => { + const storage = storageWith(["extensions", "vision"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]) }, + ]); + // The migrated tree is now the rendered tree. + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"])); + + // The key is never read again: a later probe PUTs nothing. + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("no stored key → no migration PUT (but the read-once flag is still consumed)", async () => { + const storage = storageWith(null); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + }); + + it("a custom provider tree (panels differ) is NOT migrated over", async () => { + const storage = storageWith(["extensions"]); + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + const http = fakeHttp(() => ({ ok: true, tree: custom })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + expect(store.tree).toEqual(custom); + }); + + it("migration is boot-only: a non-boot probe never PUTs the stored selection", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a"); + expect(http.putCalls).toEqual([]); + }); + + it("a failed migration PUT consumes the flag (best-effort — never retried, never fatal)", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp( + () => ({ ok: true, tree: DEFAULT_LAYOUT }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toHaveLength(1); + expect(warnings.some((w) => w.includes("migration"))).toBe(true); + // The rendered tree is untouched (the PUT failed). + expect(store.tree).toEqual(DEFAULT_LAYOUT); + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("migration arms even when the boot probe 404s, and applies on the first LATER success", async () => { + const storage = storageWith(["extensions"]); + let absent = true; + const http = fakeHttp(() => + absent ? { ok: false, notFound: true, error: "No layout provider is active" } : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + + absent = false; + await store.probe("ws-a"); // extensions-changed probe — first success + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]) }, + ]); + }); +}); |
