summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 01:20:28 +0900
committerAdam Malczewski <[email protected]>2026-09-05 01:20:28 +0900
commit985116fdc8961e656cd9d3027debcd1a6237d6c2 (patch)
tree43aed23977ea354d23d8c54f27d45bf5e0635dfc /src/app
parenta104c094b588560f955ff52dc7f949cf3c355f94 (diff)
downloaddispatch-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.svelte39
-rw-r--r--src/app/App.test.ts57
-rw-r--r--src/app/store.svelte.ts45
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;
},