summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/App.svelte73
-rw-r--r--src/app/App.test.ts175
-rw-r--r--src/app/store.svelte.ts74
-rw-r--r--src/app/store.test.ts72
-rw-r--r--src/components/Table.svelte42
-rw-r--r--src/components/Table.test.ts35
-rw-r--r--src/features/chat/index.ts6
-rw-r--r--src/features/conversation-cache/index.ts6
-rw-r--r--src/features/surface-host/index.ts6
-rw-r--r--src/features/surface-host/logic/plan.test.ts64
-rw-r--r--src/features/surface-host/logic/plan.ts43
-rw-r--r--src/features/surface-host/logic/table.test.ts47
-rw-r--r--src/features/surface-host/logic/table.ts54
-rw-r--r--src/features/surface-host/logic/types.ts23
-rw-r--r--src/features/surface-host/ui/Stat.svelte10
-rw-r--r--src/features/surface-host/ui/StatTable.svelte21
-rw-r--r--src/features/surface-host/ui/SurfaceTable.svelte14
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte36
-rw-r--r--src/features/tabs/index.ts6
-rw-r--r--src/features/views/index.ts15
-rw-r--r--src/features/views/logic/panels.test.ts55
-rw-r--r--src/features/views/logic/panels.ts49
-rw-r--r--src/features/views/ui/ViewSidebar.svelte87
-rw-r--r--src/features/views/ui/ViewSidebar.test.ts58
24 files changed, 844 insertions, 227 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 4ee071d..ff6b1ca 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,22 +1,39 @@
<script lang="ts">
import type { InvokeMessage } from "@dispatch/ui-contract";
- import { ChatView, Composer, ModelSelector } from "../features/chat";
- import { TabBar } from "../features/tabs";
- import { SurfaceView } from "../features/surface-host";
+ import Table from "../components/Table.svelte";
+ import { ChatView, Composer, manifest as chatManifest, ModelSelector } from "../features/chat";
+ import { manifest as conversationCacheManifest } from "../features/conversation-cache";
+ import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host";
+ import { manifest as tabsManifest, TabBar } from "../features/tabs";
+ import { manifest as viewsManifest, ViewSidebar } from "../features/views";
import type { AppStore } from "./store.svelte";
let { store }: { store: AppStore } = $props();
+ // The view kinds offered in the sidebar's dropdown. Generic data — the
+ // `viewContent` snippet below maps each kind id to its renderer.
+ const viewKinds = [{ id: "extensions", label: "Extensions" }] as const;
+
+ // Frontend module list for the "Loaded Modules" view, AGGREGATED from each
+ // feature's public `manifest` export so it can't drift from what's actually
+ // composed. (The backend's "Loaded Extensions" surface is a SEPARATE,
+ // backend-owned list.) FE features are internal units of this single repo, so
+ // there is no per-module version — they all share dispatch-web's version.
+ const MODULE_COLUMNS = ["Module", "Description"] as const;
+ const loadedModules: readonly (readonly [string, string])[] = [
+ chatManifest,
+ tabsManifest,
+ surfaceHostManifest,
+ viewsManifest,
+ conversationCacheManifest,
+ ].map((m) => [m.name, m.description] as const);
+
// Right sidebar: open by default on wide screens (pushes the chat aside),
// closed by default on narrow screens (overlays the chat). Initial state is
// derived from the viewport width once; the hamburger toggles it thereafter.
const WIDE_BREAKPOINT = 1024; // Tailwind `lg`
let sidebarOpen = $state(typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true);
- function handleSelect(surfaceId: string) {
- store.select(surfaceId);
- }
-
function handleInvoke(msg: InvokeMessage) {
store.invoke(msg.surfaceId, msg.actionId, msg.payload);
}
@@ -106,31 +123,6 @@
</div>
<Composer onSend={handleSend} />
-
- {#if store.catalog.length > 0}
- <section class="border-t border-base-300 p-4">
- <h2 class="mb-2 text-sm font-semibold">Surfaces</h2>
- <div class="flex flex-wrap gap-2">
- {#each store.catalog as entry (entry.id)}
- <button
- class="btn btn-sm"
- class:btn-active={entry.id === store.selectedId}
- aria-current={entry.id === store.selectedId ? "true" : undefined}
- onclick={() => handleSelect(entry.id)}
- >
- {entry.title}
- <span class="text-xs opacity-60">({entry.region})</span>
- </button>
- {/each}
- </div>
- </section>
- {/if}
-
- {#if store.selectedSpec}
- <section class="border-t border-base-300 p-4">
- <SurfaceView spec={store.selectedSpec} onInvoke={handleInvoke} />
- </section>
- {/if}
</div>
<!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so
@@ -145,7 +137,7 @@
class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out"
style="transform: translateX({sidebarOpen ? '0' : '100%'})"
>
- <h2 class="text-sm font-semibold opacity-60">Sidebar</h2>
+ <ViewSidebar kinds={viewKinds} content={viewContent} />
</div>
</aside>
@@ -164,3 +156,18 @@
></div>
{/if}
</main>
+
+{#snippet viewContent(kind: string)}
+ {#if kind === "extensions"}
+ <section>
+ <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3>
+ <Table columns={MODULE_COLUMNS} rows={loadedModules} />
+ </section>
+ <section class="mt-4 flex flex-col gap-3">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
+ {#each store.surfaces as spec (spec.id)}
+ <SurfaceView {spec} onInvoke={handleInvoke} />
+ {/each}
+ </section>
+ {/if}
+{/snippet}
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 8110d41..121bd20 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -1,6 +1,6 @@
import type { WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { render, screen, within } from "@testing-library/svelte";
+import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
@@ -119,7 +119,7 @@ describe("App component interaction tests", () => {
store.dispose();
});
- it("renders catalog buttons when surfaces are available", () => {
+ it("auto-subscribes to every catalog entry on render (no buttons to click)", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
@@ -128,6 +128,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
+ ws.sent.length = 0;
ws.feedSurfaceMessage({
type: "catalog",
catalog: [
@@ -138,17 +139,16 @@ describe("App component interaction tests", () => {
render(App, { props: { store } });
- const surfacesSection = screen.getByRole("heading", { name: "Surfaces" }).closest("section");
- if (surfacesSection === null) throw new Error("Surfaces section not found");
- const buttons = within(surfacesSection).getAllByRole("button");
- expect(buttons).toHaveLength(2);
- expect(buttons[0]).toHaveTextContent("Surface One");
- expect(buttons[1]).toHaveTextContent("Surface Two");
+ const subscribed = sentMessages(ws)
+ .filter((m: { type: string }) => m.type === "subscribe")
+ .map((m: { surfaceId: string }) => m.surfaceId);
+ expect(subscribed).toContain("s1");
+ expect(subscribed).toContain("s2");
store.dispose();
});
- it("clicking a catalog entry subscribes and renders its surface", async () => {
+ it("renders every surface expanded once their specs arrive", async () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
@@ -159,22 +159,15 @@ describe("App component interaction tests", () => {
ws.feedSurfaceMessage({
type: "catalog",
- catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
});
render(App, { props: { store } });
- const user = userEvent.setup();
- const button = screen.getByRole("button", { name: /Surface One/ });
- ws.sent.length = 0;
- await user.click(button);
-
- const msgs = sentMessages(ws);
- const subscribe = msgs.find(
- (m: { type: string; surfaceId: string }) => m.type === "subscribe" && m.surfaceId === "s1",
- );
- expect(subscribe).toBeTruthy();
-
+ // No interaction: specs arrive and both surfaces render expanded.
ws.feedSurfaceMessage({
type: "surface",
spec: {
@@ -184,83 +177,19 @@ describe("App component interaction tests", () => {
fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
},
});
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] },
+ });
expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Surface Two" })).toBeInTheDocument();
expect(await screen.findByText("Tokens")).toBeInTheDocument();
expect(await screen.findByText("1,234")).toBeInTheDocument();
store.dispose();
});
- it("clicking a different entry unsubscribes the previous then subscribes the new", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- render(App, { props: { store } });
-
- const user = userEvent.setup();
- await user.click(screen.getByRole("button", { name: /Surface One/ }));
- ws.sent.length = 0;
-
- await user.click(screen.getByRole("button", { name: /Surface Two/ }));
-
- const msgs = sentMessages(ws) as Array<{ type: string; surfaceId: string }>;
- const unsubIdx = msgs.findIndex((m) => m.type === "unsubscribe" && m.surfaceId === "s1");
- const subIdx = msgs.findIndex((m) => m.type === "subscribe" && m.surfaceId === "s2");
- expect(unsubIdx).toBeGreaterThanOrEqual(0);
- expect(subIdx).toBeGreaterThanOrEqual(0);
- expect(unsubIdx).toBeLessThan(subIdx);
-
- store.dispose();
- });
-
- it("selected catalog button reflects aria-current", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- render(App, { props: { store } });
-
- const user = userEvent.setup();
- const btn1 = screen.getByRole("button", { name: /Surface One/ });
- const btn2 = screen.getByRole("button", { name: /Surface Two/ });
-
- await user.click(btn1);
- expect(btn1).toHaveAttribute("aria-current", "true");
- expect(btn2).not.toHaveAttribute("aria-current");
-
- await user.click(btn2);
- expect(btn2).toHaveAttribute("aria-current", "true");
- expect(btn1).not.toHaveAttribute("aria-current");
-
- store.dispose();
- });
-
it("an error message renders the alert banner", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -300,8 +229,7 @@ describe("App component interaction tests", () => {
render(App, { props: { store } });
const user = userEvent.setup();
- await user.click(screen.getByRole("button", { name: /Surface One/ }));
-
+ // Surface is auto-subscribed; its spec arrives and renders expanded.
ws.feedSurfaceMessage({
type: "surface",
spec: {
@@ -403,4 +331,67 @@ describe("App component interaction tests", () => {
store.dispose();
});
+
+ it("renders a custom 'table' field of a surface as a table", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ });
+
+ render(App, { props: { store } });
+
+ // Auto-subscribed; the custom-table spec arrives and renders expanded.
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: {
+ id: "s1",
+ region: "sidebar",
+ title: "Surface One",
+ fields: [
+ {
+ kind: "custom",
+ rendererId: "table",
+ payload: {
+ columns: ["Name", "Scope"],
+ rows: [["cache-warm", "backend"]],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument();
+ expect(await screen.findByText("cache-warm")).toBeInTheDocument();
+ expect(await screen.findByText("backend")).toBeInTheDocument();
+
+ store.dispose();
+ });
+
+ it("the Extensions view lists frontend modules aggregated from feature manifests", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store } });
+
+ // Extensions is the default view, so the modules table renders immediately.
+ expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument();
+ for (const name of ["chat", "tabs", "surface-host", "views", "conversation-cache"]) {
+ expect(screen.getByRole("cell", { name })).toBeInTheDocument();
+ }
+
+ store.dispose();
+ });
});
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index fe3c55c..efbe065 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -5,7 +5,7 @@ import type {
ConversationMetricsResponse,
ModelsResponse,
} from "@dispatch/transport-contract";
-import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
import { createIdbChunkStore } from "../adapters/idb";
import { createLocalStore } from "../adapters/local-storage";
import type { WebSocketLike } from "../adapters/ws";
@@ -37,15 +37,14 @@ export interface AppStore {
readonly models: readonly string[];
readonly activeModel: string;
readonly catalog: ProtocolState["catalog"];
- readonly selectedId: string | null;
- readonly selectedSpec: SurfaceSpec | null;
+ /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */
+ readonly surfaces: readonly SurfaceSpec[];
readonly lastError: ProtocolState["lastError"];
send(text: string): void;
selectModel(model: string): void;
newDraft(): void;
selectTab(conversationId: string): void;
closeTab(conversationId: string): void;
- select(surfaceId: string): void;
invoke(surfaceId: string, actionId: string, payload?: unknown): void;
dispose(): void;
}
@@ -85,7 +84,6 @@ function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSy
export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
let protocol = $state<ProtocolState>(protocolInitialState());
- let selectedId = $state<string | null>(null);
let models = $state<readonly string[]>([]);
let activeModel = $state(DEFAULT_MODEL);
@@ -183,6 +181,32 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
function handleServerMessage(msg: SurfaceServerMessage): void {
protocol = applyServerMessage(protocol, msg);
+ // Surfaces are auto-expanded: whenever the catalog changes, subscribe to
+ // every entry (and drop subscriptions for entries that vanished).
+ if (msg.type === "catalog") {
+ syncSubscriptions();
+ }
+ }
+
+ /** Subscribe to every catalog entry not yet subscribed; unsubscribe stragglers. */
+ function syncSubscriptions(): void {
+ for (const entry of protocol.catalog) {
+ const result = protocolSubscribe(protocol, entry.id);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ }
+ const catalogIds = new Set(protocol.catalog.map((e) => e.id));
+ for (const id of [...protocol.subscriptions.keys()]) {
+ if (!catalogIds.has(id)) {
+ const result = protocolUnsubscribe(protocol, id);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ }
+ }
}
let socket: ReturnType<typeof createSurfaceSocket> | null = null;
@@ -192,12 +216,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
onMessage: handleServerMessage,
onChat: handleChatMessage,
onReopen() {
- if (selectedId !== null) {
- const result = protocolSubscribe(protocol, selectedId);
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
+ // The server forgot our subscriptions on reconnect; re-send for all
+ // catalog entries (protocolSubscribe would no-op since they're still in
+ // our local map, so emit the wire messages directly).
+ for (const entry of protocol.catalog) {
+ const msg: SubscribeMessage = { type: "subscribe", surfaceId: entry.id };
+ socket?.send(msg);
}
},
};
@@ -265,12 +289,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
get catalog() {
return protocol.catalog;
},
- get selectedId() {
- return selectedId;
- },
- get selectedSpec() {
- if (selectedId === null) return null;
- return protocol.subscriptions.get(selectedId) ?? null;
+ get surfaces(): readonly SurfaceSpec[] {
+ const out: SurfaceSpec[] = [];
+ for (const entry of protocol.catalog) {
+ const spec = protocol.subscriptions.get(entry.id);
+ if (spec) out.push(spec);
+ }
+ return out;
},
get lastError() {
return protocol.lastError;
@@ -341,21 +366,6 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
refreshActiveChat();
},
- select(surfaceId: string): void {
- if (selectedId !== null && selectedId !== surfaceId) {
- const unsub = protocolUnsubscribe(protocol, selectedId);
- protocol = unsub.state;
- for (const msg of unsub.outgoing) {
- socket?.send(msg);
- }
- }
- selectedId = surfaceId;
- const sub = protocolSubscribe(protocol, surfaceId);
- protocol = sub.state;
- for (const msg of sub.outgoing) {
- socket?.send(msg);
- }
- },
invoke(surfaceId: string, actionId: string, payload?: unknown): void {
const result = protocolInvoke(protocol, surfaceId, actionId, payload);
protocol = result.state;
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 86a21d6..19530e2 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -105,7 +105,7 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string
}
describe("createAppStore", () => {
- it("starts with empty catalog and no selection", () => {
+ it("starts with empty catalog and no surfaces", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
@@ -116,8 +116,7 @@ describe("createAppStore", () => {
ws.resolveOpen();
expect(store.catalog).toEqual([]);
- expect(store.selectedId).toBeNull();
- expect(store.selectedSpec).toBeNull();
+ expect(store.surfaces).toEqual([]);
expect(store.lastError).toBeNull();
store.dispose();
@@ -148,7 +147,7 @@ describe("createAppStore", () => {
store.dispose();
});
- it("select sends subscribe and sets selectedId", () => {
+ it("auto-subscribes to every catalog entry when the catalog arrives", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
@@ -158,25 +157,26 @@ describe("createAppStore", () => {
});
ws.resolveOpen();
+ ws.sent.length = 0;
ws.feedSurfaceMessage({
type: "catalog",
- catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
});
- ws.sent.length = 0;
- store.select("s1");
-
- expect(store.selectedId).toBe("s1");
- const subscribeMsg = ws.sent.find((s) => {
- const parsed = JSON.parse(s);
- return parsed.type === "subscribe" && parsed.surfaceId === "s1";
- });
- expect(subscribeMsg).toBeTruthy();
+ const subscribed = ws.sent
+ .map((s) => JSON.parse(s))
+ .filter((p) => p.type === "subscribe")
+ .map((p) => p.surfaceId);
+ expect(subscribed).toContain("s1");
+ expect(subscribed).toContain("s2");
store.dispose();
});
- it("selecting a different surface unsubscribes from previous", () => {
+ it("unsubscribes from entries that vanish from a new catalog", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
@@ -195,25 +195,22 @@ describe("createAppStore", () => {
});
ws.sent.length = 0;
- store.select("s1");
- store.select("s2");
-
- const unsubscribeMsg = ws.sent.find((s) => {
- const parsed = JSON.parse(s);
- return parsed.type === "unsubscribe" && parsed.surfaceId === "s1";
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
});
- expect(unsubscribeMsg).toBeTruthy();
- const subscribeMsg = ws.sent.find((s) => {
- const parsed = JSON.parse(s);
- return parsed.type === "subscribe" && parsed.surfaceId === "s2";
- });
- expect(subscribeMsg).toBeTruthy();
+ const unsubscribed = ws.sent
+ .map((s) => JSON.parse(s))
+ .filter((p) => p.type === "unsubscribe")
+ .map((p) => p.surfaceId);
+ expect(unsubscribed).toContain("s2");
+ expect(unsubscribed).not.toContain("s1");
store.dispose();
});
- it("surface message updates selectedSpec", () => {
+ it("exposes received surface specs via `surfaces`, in catalog order", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
@@ -225,11 +222,13 @@ describe("createAppStore", () => {
ws.feedSurfaceMessage({
type: "catalog",
- catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
});
- store.select("s1");
-
+ // Only s1's spec has arrived: surfaces reflects what's actually received.
ws.feedSurfaceMessage({
type: "surface",
spec: {
@@ -239,10 +238,15 @@ describe("createAppStore", () => {
fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
},
});
+ expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]);
- expect(store.selectedSpec).not.toBeNull();
- expect(store.selectedSpec?.id).toBe("s1");
- expect(store.selectedSpec?.fields).toHaveLength(1);
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] },
+ });
+ // Catalog order preserved (s1 before s2).
+ expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]);
+ expect(store.surfaces[0]?.fields).toHaveLength(1);
store.dispose();
});
diff --git a/src/components/Table.svelte b/src/components/Table.svelte
new file mode 100644
index 0000000..7c56e69
--- /dev/null
+++ b/src/components/Table.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ // Generic, purely presentational table. Props in → markup out; zero logic,
+ // zero data-fetching. Shared by the surface custom-field "table" renderer and
+ // the frontend "Loaded Modules" view, so neither feature depends on the other.
+ let {
+ columns,
+ rows,
+ empty = "No data",
+ }: {
+ readonly columns: readonly string[];
+ readonly rows: readonly (readonly string[])[];
+ /** Text shown when there are no rows. */
+ readonly empty?: string;
+ } = $props();
+</script>
+
+<div class="overflow-x-auto">
+ <table class="table table-sm">
+ <thead>
+ <tr>
+ {#each columns as col, i (i)}
+ <th>{col}</th>
+ {/each}
+ </tr>
+ </thead>
+ <tbody>
+ {#if rows.length === 0}
+ <tr>
+ <td colspan={Math.max(columns.length, 1)} class="opacity-60">{empty}</td>
+ </tr>
+ {:else}
+ {#each rows as row, r (r)}
+ <tr>
+ {#each row as cell, c (c)}
+ <td>{cell}</td>
+ {/each}
+ </tr>
+ {/each}
+ {/if}
+ </tbody>
+ </table>
+</div>
diff --git a/src/components/Table.test.ts b/src/components/Table.test.ts
new file mode 100644
index 0000000..9fbecd3
--- /dev/null
+++ b/src/components/Table.test.ts
@@ -0,0 +1,35 @@
+import { render, screen, within } from "@testing-library/svelte";
+import { describe, expect, it } from "vitest";
+import Table from "./Table.svelte";
+
+describe("Table", () => {
+ it("renders a header cell per column", () => {
+ render(Table, { props: { columns: ["Name", "Version"], rows: [] } });
+ const headers = screen.getAllByRole("columnheader");
+ expect(headers.map((h) => h.textContent)).toEqual(["Name", "Version"]);
+ });
+
+ it("renders one row per data row with aligned cells", () => {
+ render(Table, {
+ props: {
+ columns: ["Name", "Version"],
+ rows: [
+ ["alpha", "1.0"],
+ ["beta", "2.3"],
+ ],
+ },
+ });
+ const body = screen.getAllByRole("rowgroup")[1];
+ if (body === undefined) throw new Error("expected a tbody rowgroup");
+ const rows = within(body).getAllByRole("row");
+ expect(rows).toHaveLength(2);
+ expect(within(rows[0] as HTMLElement).getByText("alpha")).toBeInTheDocument();
+ expect(within(rows[0] as HTMLElement).getByText("1.0")).toBeInTheDocument();
+ expect(within(rows[1] as HTMLElement).getByText("beta")).toBeInTheDocument();
+ });
+
+ it("shows the empty message when there are no rows", () => {
+ render(Table, { props: { columns: ["A"], rows: [], empty: "Nothing loaded" } });
+ expect(screen.getByText("Nothing loaded")).toBeInTheDocument();
+ });
+});
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index ae3e1f8..18ed693 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -7,3 +7,9 @@ export { createChatStore } from "./store.svelte";
export { default as ChatView } from "./ui/ChatView.svelte";
export { default as Composer } from "./ui/Composer.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "chat",
+ description: "Conversation turns, composer, model selector, and metrics",
+} as const;
diff --git a/src/features/conversation-cache/index.ts b/src/features/conversation-cache/index.ts
index ba3f69a..32e32d9 100644
--- a/src/features/conversation-cache/index.ts
+++ b/src/features/conversation-cache/index.ts
@@ -6,3 +6,9 @@ export type {
ConversationChunkStore,
ReconcileResult,
} from "./types";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "conversation-cache",
+ description: "IndexedDB-backed chunk cache with reconciliation",
+} as const;
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts
index afa3127..8f289f1 100644
--- a/src/features/surface-host/index.ts
+++ b/src/features/surface-host/index.ts
@@ -1,3 +1,9 @@
export { buildInvoke, planSurface } from "./logic/plan";
export type { FieldView, SurfaceRenderPlan } from "./logic/types";
export { default as SurfaceView } from "./ui/SurfaceView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "surface-host",
+ description: "Generic renderer for backend-declared surfaces",
+} as const;
diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts
index 50d6f11..a5727b4 100644
--- a/src/features/surface-host/logic/plan.test.ts
+++ b/src/features/surface-host/logic/plan.test.ts
@@ -1,6 +1,7 @@
import type { SurfaceField, SurfaceSpec } from "@dispatch/ui-contract";
import { describe, expect, it } from "vitest";
-import { buildInvoke, planSurface } from "./plan";
+import { buildInvoke, groupRenderFields, planSurface } from "./plan";
+import type { FieldView } from "./types";
const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({
id: "test-surface",
@@ -74,7 +75,7 @@ describe("planSurface", () => {
{ kind: "button", label: "D", action: { actionId: "d" } },
),
);
- expect(plan.fields.map((f) => f.label)).toEqual(["A", "B", "C", "D"]);
+ expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]);
});
it("drops unknown field kinds gracefully", () => {
@@ -86,10 +87,11 @@ describe("planSurface", () => {
} as SurfaceField),
);
expect(plan.fields).toHaveLength(1);
- expect(plan.fields[0]?.label).toBe("Known");
+ const first = plan.fields[0];
+ expect(first && "label" in first ? first.label : null).toBe("Known");
});
- it("drops custom fields (no renderer registered)", () => {
+ it("carries custom fields through verbatim, preserving order", () => {
const plan = planSurface(
makeSpec(
{ kind: "stat", label: "Before", value: "1" },
@@ -97,8 +99,12 @@ describe("planSurface", () => {
{ kind: "stat", label: "After", value: "2" },
),
);
- expect(plan.fields).toHaveLength(2);
- expect(plan.fields.map((f) => f.label)).toEqual(["Before", "After"]);
+ expect(plan.fields).toHaveLength(3);
+ expect(plan.fields[1]).toEqual({
+ kind: "custom",
+ rendererId: "chart",
+ payload: { data: [1, 2, 3] },
+ });
});
it("returns empty fields for an empty spec", () => {
@@ -106,14 +112,56 @@ describe("planSurface", () => {
expect(plan.fields).toEqual([]);
});
- it("drops all fields when all are custom", () => {
+ it("keeps every custom field (render-time decides whether to show each)", () => {
const plan = planSurface(
makeSpec(
{ kind: "custom", rendererId: "x", payload: null },
{ kind: "custom", rendererId: "y", payload: 42 },
),
);
- expect(plan.fields).toEqual([]);
+ expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]);
+ });
+});
+
+describe("groupRenderFields", () => {
+ const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value });
+ const toggle = (label: string): FieldView => ({
+ kind: "toggle",
+ label,
+ value: false,
+ action: { actionId: label },
+ });
+
+ it("coalesces consecutive stats into a single stats group", () => {
+ const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]);
+ expect(groups).toHaveLength(1);
+ expect(groups[0]).toEqual({
+ type: "stats",
+ stats: [
+ { kind: "stat", label: "a", value: "1" },
+ { kind: "stat", label: "b", value: "2" },
+ { kind: "stat", label: "c", value: "3" },
+ ],
+ });
+ });
+
+ it("keeps non-stat fields as standalone groups and preserves order", () => {
+ const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]);
+ expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
+ const first = groups[0];
+ const last = groups[2];
+ if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping");
+ expect(first.stats.map((s) => s.label)).toEqual(["a"]);
+ expect(last.stats.map((s) => s.label)).toEqual(["b"]);
+ });
+
+ it("starts a new stats run after an interrupting field", () => {
+ const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]);
+ expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
+ });
+
+ it("returns no groups for an empty field list", () => {
+ expect(groupRenderFields([])).toEqual([]);
});
});
diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts
index 5b4530b..769f9f9 100644
--- a/src/features/surface-host/logic/plan.ts
+++ b/src/features/surface-host/logic/plan.ts
@@ -1,12 +1,14 @@
import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
-import type { FieldView, SurfaceRenderPlan } from "./types";
+import type { FieldView, RenderGroup, StatFieldView, SurfaceRenderPlan } from "./types";
-const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button"]);
+const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button", "custom"]);
/**
* Validate and normalise a SurfaceSpec into a renderable plan.
- * Keeps known field kinds in order; drops unknown kinds and `custom` fields
- * (no renderer registry yet — graceful skip, never throw).
+ * Keeps known field kinds in order (including `custom`, carried through verbatim
+ * for the renderer to dispatch on `rendererId`); drops unknown kinds — graceful
+ * skip, never throw. Whether a `custom` field actually renders is a RENDER-time
+ * decision (unknown `rendererId` → skipped there), not a planning one.
*/
export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
const fields: FieldView[] = [];
@@ -51,12 +53,45 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
action: field.action,
});
break;
+ case "custom":
+ fields.push({
+ kind: "custom",
+ rendererId: field.rendererId,
+ payload: field.payload,
+ });
+ break;
}
}
return { fields };
}
/**
+ * Coalesce a field list into render groups: maximal runs of consecutive `stat`
+ * fields become one `stats` group (rendered as a single aligned table), every
+ * other field stays a standalone `field` group. Order is preserved. Pure.
+ */
+export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] {
+ const groups: RenderGroup[] = [];
+ let run: StatFieldView[] = [];
+ const flush = (): void => {
+ if (run.length > 0) {
+ groups.push({ type: "stats", stats: run });
+ run = [];
+ }
+ };
+ for (const field of fields) {
+ if (field.kind === "stat") {
+ run.push(field);
+ } else {
+ flush();
+ groups.push({ type: "field", field });
+ }
+ }
+ flush();
+ return groups;
+}
+
+/**
* Construct a typed `invoke` client message for an actionable field.
* For toggle the payload is the new boolean; for selector the chosen value;
* for button the payload is omitted.
diff --git a/src/features/surface-host/logic/table.test.ts b/src/features/surface-host/logic/table.test.ts
new file mode 100644
index 0000000..e55b3f7
--- /dev/null
+++ b/src/features/surface-host/logic/table.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { parseTablePayload } from "./table";
+
+describe("parseTablePayload", () => {
+ it("parses a well-formed table payload", () => {
+ const data = parseTablePayload({
+ columns: ["Name", "Version"],
+ rows: [
+ ["alpha", "1.0"],
+ ["beta", "2.3"],
+ ],
+ });
+ expect(data).toEqual({
+ columns: ["Name", "Version"],
+ rows: [
+ ["alpha", "1.0"],
+ ["beta", "2.3"],
+ ],
+ });
+ });
+
+ it("coerces numeric and boolean cells to strings", () => {
+ const data = parseTablePayload({
+ columns: ["k", "n", "b"],
+ rows: [["x", 42, true]],
+ });
+ expect(data?.rows[0]).toEqual(["x", "42", "true"]);
+ });
+
+ it("accepts an empty rows array", () => {
+ expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] });
+ });
+
+ it.each([
+ ["null", null],
+ ["a number", 7],
+ ["a string", "nope"],
+ ["missing columns", { rows: [] }],
+ ["missing rows", { columns: ["A"] }],
+ ["non-string column", { columns: [1], rows: [] }],
+ ["row that is not an array", { columns: ["A"], rows: ["x"] }],
+ ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }],
+ ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }],
+ ])("returns null for invalid payload: %s", (_label, payload) => {
+ expect(parseTablePayload(payload)).toBeNull();
+ });
+});
diff --git a/src/features/surface-host/logic/table.ts b/src/features/surface-host/logic/table.ts
new file mode 100644
index 0000000..027553c
--- /dev/null
+++ b/src/features/surface-host/logic/table.ts
@@ -0,0 +1,54 @@
+/**
+ * Pure parser for the `rendererId: "table"` custom-field payload.
+ *
+ * This is the FRONTEND-side renderer contract for tabular custom fields: a
+ * backend that wants a table emits a `custom` field with `rendererId: "table"`
+ * and a payload of `{ columns: string[]; rows: (string|number)[][] }`. Cells are
+ * coerced to strings. Anything that does not match the shape returns `null`, so
+ * the renderer gracefully skips it (never throws on hostile/partial data).
+ */
+
+export interface TableData {
+ readonly columns: readonly string[];
+ readonly rows: readonly (readonly string[])[];
+}
+
+function isStringArray(v: unknown): v is unknown[] {
+ return Array.isArray(v);
+}
+
+function coerceCell(v: unknown): string | null {
+ if (typeof v === "string") return v;
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
+ if (typeof v === "boolean") return String(v);
+ return null;
+}
+
+export function parseTablePayload(payload: unknown): TableData | null {
+ if (typeof payload !== "object" || payload === null) return null;
+ const obj = payload as Record<string, unknown>;
+
+ const rawColumns = obj.columns;
+ const rawRows = obj.rows;
+ if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null;
+
+ const columns: string[] = [];
+ for (const col of rawColumns) {
+ if (typeof col !== "string") return null;
+ columns.push(col);
+ }
+
+ const rows: string[][] = [];
+ for (const row of rawRows) {
+ if (!Array.isArray(row)) return null;
+ const cells: string[] = [];
+ for (const cell of row) {
+ const c = coerceCell(cell);
+ if (c === null) return null;
+ cells.push(c);
+ }
+ rows.push(cells);
+ }
+
+ return { columns, rows };
+}
diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts
index f24438a..d1888a2 100644
--- a/src/features/surface-host/logic/types.ts
+++ b/src/features/surface-host/logic/types.ts
@@ -38,15 +38,36 @@ export interface ButtonFieldView {
readonly action: ActionRef;
}
+/**
+ * Normalised view-model for a custom (escape-hatch) field. The plan carries it
+ * through verbatim; the renderer dispatches on `rendererId` (a renderer KIND,
+ * never a surface id) and gracefully skips ids it has no renderer for.
+ */
+export interface CustomFieldView {
+ readonly kind: "custom";
+ readonly rendererId: string;
+ readonly payload: unknown;
+}
+
/** A normalised field view-model — one entry per renderable field kind. */
export type FieldView =
| ToggleFieldView
| ProgressFieldView
| SelectorFieldView
| StatFieldView
- | ButtonFieldView;
+ | ButtonFieldView
+ | CustomFieldView;
/** The output of `planSurface`: the ordered list of renderable fields. */
export interface SurfaceRenderPlan {
readonly fields: readonly FieldView[];
}
+
+/**
+ * A render group: a maximal run of consecutive `stat` fields (rendered together
+ * as one aligned label/value table), or a single non-stat field. Grouping is a
+ * GENERIC presentation rule keyed on field kind — it never inspects a surface id.
+ */
+export type RenderGroup =
+ | { readonly type: "stats"; readonly stats: readonly StatFieldView[] }
+ | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> };
diff --git a/src/features/surface-host/ui/Stat.svelte b/src/features/surface-host/ui/Stat.svelte
deleted file mode 100644
index e184dab..0000000
--- a/src/features/surface-host/ui/Stat.svelte
+++ /dev/null
@@ -1,10 +0,0 @@
-<script lang="ts">
- import type { StatFieldView } from "../logic/types";
-
- let { field }: { field: StatFieldView } = $props();
-</script>
-
-<dl>
- <dt>{field.label}</dt>
- <dd>{field.value}</dd>
-</dl>
diff --git a/src/features/surface-host/ui/StatTable.svelte b/src/features/surface-host/ui/StatTable.svelte
new file mode 100644
index 0000000..415423f
--- /dev/null
+++ b/src/features/surface-host/ui/StatTable.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import type { StatFieldView } from "../logic/types";
+
+ // Renders a run of stat fields as one aligned label/value table. Headerless:
+ // the column semantics aren't known generically, but the two-column layout
+ // gives the tidy, aligned readout the stats deserve (e.g. extension → version).
+ let { stats }: { readonly stats: readonly StatFieldView[] } = $props();
+</script>
+
+<div class="overflow-x-auto">
+ <table class="table table-sm">
+ <tbody>
+ {#each stats as stat, i (i)}
+ <tr>
+ <th class="font-medium">{stat.label}</th>
+ <td class="text-right tabular-nums">{stat.value}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+</div>
diff --git a/src/features/surface-host/ui/SurfaceTable.svelte b/src/features/surface-host/ui/SurfaceTable.svelte
new file mode 100644
index 0000000..764cc36
--- /dev/null
+++ b/src/features/surface-host/ui/SurfaceTable.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+ import Table from "../../../components/Table.svelte";
+ import { parseTablePayload } from "../logic/table";
+
+ let { payload }: { readonly payload: unknown } = $props();
+
+ // Parse defensively; an unparseable payload yields null → render nothing
+ // (graceful skip, per the custom-field contract).
+ const data = $derived(parseTablePayload(payload));
+</script>
+
+{#if data !== null}
+ <Table columns={data.columns} rows={data.rows} />
+{/if}
diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte
index 4207913..5210e8c 100644
--- a/src/features/surface-host/ui/SurfaceView.svelte
+++ b/src/features/surface-host/ui/SurfaceView.svelte
@@ -1,10 +1,11 @@
<script lang="ts">
import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
- import { planSurface } from "../logic/plan";
+ import { groupRenderFields, planSurface } from "../logic/plan";
import Button from "./Button.svelte";
import Progress from "./Progress.svelte";
import Selector from "./Selector.svelte";
- import Stat from "./Stat.svelte";
+ import StatTable from "./StatTable.svelte";
+ import SurfaceTable from "./SurfaceTable.svelte";
import Toggle from "./Toggle.svelte";
let {
@@ -13,21 +14,30 @@
}: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props();
const plan = $derived(planSurface(spec));
+ // Consecutive stats render together as one aligned table; everything else is
+ // a standalone widget. Grouping keys on field KIND only — never the surface id.
+ const groups = $derived(groupRenderFields(plan.fields));
</script>
<article>
<h2>{spec.title}</h2>
- {#each plan.fields as field (field)}
- {#if field.kind === "toggle"}
- <Toggle {field} surfaceId={spec.id} {onInvoke} />
- {:else if field.kind === "progress"}
- <Progress {field} />
- {:else if field.kind === "selector"}
- <Selector {field} surfaceId={spec.id} {onInvoke} />
- {:else if field.kind === "stat"}
- <Stat {field} />
- {:else if field.kind === "button"}
- <Button {field} surfaceId={spec.id} {onInvoke} />
+ {#each groups as group, i (i)}
+ {#if group.type === "stats"}
+ <StatTable stats={group.stats} />
+ {:else if group.field.kind === "toggle"}
+ <Toggle field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "progress"}
+ <Progress field={group.field} />
+ {:else if group.field.kind === "selector"}
+ <Selector field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "button"}
+ <Button field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "custom"}
+ <!-- Dispatch on rendererId (a renderer KIND, never a surface id);
+ unknown ids gracefully render nothing. -->
+ {#if group.field.rendererId === "table"}
+ <SurfaceTable payload={group.field.payload} />
+ {/if}
{/if}
{/each}
</article>
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 50de62a..699c845 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -15,3 +15,9 @@ export {
export type { TabsStorage, TabsStore } from "./tabs-store.svelte";
export { createTabsStore } from "./tabs-store.svelte";
export { default as TabBar } from "./ui/TabBar.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "tabs",
+ description: "Conversation tabs with title derivation and persistence",
+} as const;
diff --git a/src/features/views/index.ts b/src/features/views/index.ts
new file mode 100644
index 0000000..c4e7f25
--- /dev/null
+++ b/src/features/views/index.ts
@@ -0,0 +1,15 @@
+export {
+ addPanel,
+ initialPanels,
+ type PanelsState,
+ removePanel,
+ selectKind,
+ type ViewPanel,
+} from "./logic/panels";
+export { default as ViewSidebar } from "./ui/ViewSidebar.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "views",
+ description: "Sidebar view panels (dropdown picker + add / remove)",
+} as const;
diff --git a/src/features/views/logic/panels.test.ts b/src/features/views/logic/panels.test.ts
new file mode 100644
index 0000000..edd7d9e
--- /dev/null
+++ b/src/features/views/logic/panels.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { addPanel, initialPanels, removePanel, selectKind } from "./panels";
+
+describe("view panels reducer", () => {
+ it("seeds one empty panel by default", () => {
+ const s = initialPanels();
+ expect(s.panels).toHaveLength(1);
+ expect(s.panels[0]?.kind).toBeNull();
+ });
+
+ it("seeds a panel per provided kind, in order, with unique ids", () => {
+ const s = initialPanels(["surfaces", null]);
+ expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]);
+ expect(new Set(s.panels.map((p) => p.id)).size).toBe(2);
+ });
+
+ it("addPanel appends an empty panel with a fresh id", () => {
+ const seed = initialPanels(["surfaces"]);
+ const s = addPanel(seed);
+ expect(s.panels).toHaveLength(2);
+ expect(s.panels[1]?.kind).toBeNull();
+ expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id);
+ });
+
+ it("addPanel can seed a kind", () => {
+ const s = addPanel(initialPanels([null]), "surfaces");
+ expect(s.panels[1]?.kind).toBe("surfaces");
+ });
+
+ it("removePanel drops the matching id only", () => {
+ const seed = initialPanels(["surfaces", null]);
+ const firstId = seed.panels[0]?.id ?? -1;
+ const s = removePanel(seed, firstId);
+ expect(s.panels).toHaveLength(1);
+ expect(s.panels[0]?.kind).toBeNull();
+ });
+
+ it("selectKind updates only the targeted panel", () => {
+ const seed = initialPanels([null, null]);
+ const targetId = seed.panels[1]?.id ?? -1;
+ const s = selectKind(seed, targetId, "surfaces");
+ expect(s.panels[0]?.kind).toBeNull();
+ expect(s.panels[1]?.kind).toBe("surfaces");
+ });
+
+ it("is pure — never mutates the input state", () => {
+ const seed = initialPanels(["surfaces"]);
+ const snapshot = JSON.stringify(seed);
+ const id = seed.panels[0]?.id ?? -1;
+ addPanel(seed);
+ removePanel(seed, id);
+ selectKind(seed, id, null);
+ expect(JSON.stringify(seed)).toBe(snapshot);
+ });
+});
diff --git a/src/features/views/logic/panels.ts b/src/features/views/logic/panels.ts
new file mode 100644
index 0000000..38c28fb
--- /dev/null
+++ b/src/features/views/logic/panels.ts
@@ -0,0 +1,49 @@
+/**
+ * Pure reducer for the view sidebar's panel stack.
+ *
+ * A "view" is the RESERVED Dispatch sidebar affordance (see GLOSSARY): the user
+ * stacks panels, each showing one view KIND chosen from a dropdown, and adds
+ * more with a `+` button. This module is the pure model — zero DOM, zero Svelte.
+ * The component (`ViewSidebar.svelte`) is a thin runes wrapper over it.
+ *
+ * `id` is a per-session stable key for `{#each}` only; it is never persisted.
+ */
+
+export interface ViewPanel {
+ readonly id: number;
+ /** Selected view-kind id, or `null` while the panel still reads "Select a view". */
+ readonly kind: string | null;
+}
+
+export interface PanelsState {
+ readonly panels: readonly ViewPanel[];
+ readonly nextId: number;
+}
+
+/**
+ * Seed state. Each entry becomes one panel in order; pass `["surfaces"]` to open
+ * a single preset panel, or `[null]` for one empty "Select a view" panel.
+ */
+export function initialPanels(kinds: readonly (string | null)[] = [null]): PanelsState {
+ let nextId = 0;
+ const panels = kinds.map((kind) => ({ id: nextId++, kind }));
+ return { panels, nextId };
+}
+
+export function addPanel(state: PanelsState, kind: string | null = null): PanelsState {
+ return {
+ panels: [...state.panels, { id: state.nextId, kind }],
+ nextId: state.nextId + 1,
+ };
+}
+
+export function removePanel(state: PanelsState, id: number): PanelsState {
+ return { ...state, panels: state.panels.filter((p) => p.id !== id) };
+}
+
+export function selectKind(state: PanelsState, id: number, kind: string | null): PanelsState {
+ return {
+ ...state,
+ panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)),
+ };
+}
diff --git a/src/features/views/ui/ViewSidebar.svelte b/src/features/views/ui/ViewSidebar.svelte
new file mode 100644
index 0000000..c4b466f
--- /dev/null
+++ b/src/features/views/ui/ViewSidebar.svelte
@@ -0,0 +1,87 @@
+<script lang="ts">
+ import { type Snippet, untrack } from "svelte";
+ import {
+ addPanel,
+ initialPanels,
+ type PanelsState,
+ removePanel,
+ selectKind,
+ } from "../logic/panels";
+
+ interface ViewKind {
+ readonly id: string;
+ readonly label: string;
+ }
+
+ let {
+ kinds,
+ content,
+ initial,
+ }: {
+ /** The view kinds offered in every panel's dropdown. */
+ kinds: readonly ViewKind[];
+ /** Renders a panel body for the given (non-null) view-kind id. */
+ content: Snippet<[string]>;
+ /** Optional seed of panel kinds; defaults to one panel of the first kind. */
+ initial?: readonly (string | null)[];
+ } = $props();
+
+ // Local UI composition state, owned by this unit and folded through the pure
+ // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from
+ // the props (untrack makes that one-time read explicit, not reactive).
+ let state = $state<PanelsState>(
+ untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])),
+ );
+</script>
+
+<div class="flex min-h-0 flex-col gap-2">
+ {#each state.panels as panel, idx (panel.id)}
+ <div class="flex flex-col rounded-lg bg-base-200 p-3">
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.kind ?? ""}
+ onchange={(e) => {
+ const v = e.currentTarget.value;
+ state = selectKind(state, panel.id, v === "" ? null : v);
+ }}
+ >
+ <option value="" disabled>Select a view</option>
+ {#each kinds as kind (kind.id)}
+ <option value={kind.id}>{kind.label}</option>
+ {/each}
+ </select>
+ {#if idx > 0}
+ <button
+ type="button"
+ class="btn btn-square btn-ghost btn-sm shrink-0"
+ aria-label="Remove view"
+ onclick={() => {
+ state = removePanel(state, panel.id);
+ }}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
+
+ {#if panel.kind !== null}
+ <div class="mt-2">
+ {@render content(panel.kind)}
+ </div>
+ {/if}
+ </div>
+ {/each}
+
+ <button
+ type="button"
+ class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300"
+ aria-label="Add view"
+ onclick={() => {
+ state = addPanel(state);
+ }}
+ >
+ +
+ </button>
+</div>
diff --git a/src/features/views/ui/ViewSidebar.test.ts b/src/features/views/ui/ViewSidebar.test.ts
new file mode 100644
index 0000000..8a0049c
--- /dev/null
+++ b/src/features/views/ui/ViewSidebar.test.ts
@@ -0,0 +1,58 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { createRawSnippet } from "svelte";
+import { describe, expect, it } from "vitest";
+import ViewSidebar from "./ViewSidebar.svelte";
+
+const kinds = [
+ { id: "surfaces", label: "Surfaces" },
+ { id: "tasks", label: "Tasks" },
+];
+
+// A raw snippet that echoes the kind it was rendered for, so tests can assert
+// which view-kind content each panel shows.
+const content = createRawSnippet<[string]>((kind) => ({
+ render: () => `<div data-testid="view-content">kind:${kind()}</div>`,
+}));
+
+describe("ViewSidebar", () => {
+ it("opens one panel seeded with the first kind and renders its content", () => {
+ render(ViewSidebar, { props: { kinds, content } });
+ expect(screen.getAllByRole("combobox")).toHaveLength(1);
+ expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces");
+ });
+
+ it("the first panel has no remove button", () => {
+ render(ViewSidebar, { props: { kinds, content } });
+ expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
+ });
+
+ it("the add button appends a new empty panel", async () => {
+ const user = userEvent.setup();
+ render(ViewSidebar, { props: { kinds, content } });
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ expect(screen.getAllByRole("combobox")).toHaveLength(2);
+ // the new panel is empty → only the first panel renders content
+ expect(screen.getAllByTestId("view-content")).toHaveLength(1);
+ });
+
+ it("non-first panels can be removed", async () => {
+ const user = userEvent.setup();
+ render(ViewSidebar, { props: { kinds, content } });
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
+ expect(removeButtons).toHaveLength(1);
+ const target = removeButtons[0];
+ if (target === undefined) throw new Error("expected a remove button");
+ await user.click(target);
+ expect(screen.getAllByRole("combobox")).toHaveLength(1);
+ });
+
+ it("selecting a kind renders that kind's content", async () => {
+ const user = userEvent.setup();
+ render(ViewSidebar, { props: { kinds, content, initial: [null] } });
+ expect(screen.queryByTestId("view-content")).toBeNull();
+ await user.selectOptions(screen.getByRole("combobox"), "tasks");
+ expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks");
+ });
+});