diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 16:29:01 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 16:29:01 +0900 |
| commit | 871957b930203c019e631c4606cfdf8266d222fa (patch) | |
| tree | 50c522018c3ce4127ffa76f4b3b6c7843e90db43 | |
| parent | 7b345f132763fa6405ae858b74e46229629c19d9 (diff) | |
| download | dispatch-web-871957b930203c019e631c4606cfdf8266d222fa.tar.gz dispatch-web-871957b930203c019e631c4606cfdf8266d222fa.zip | |
feat(views,surface-host): Extensions sidebar view — auto-expanded surfaces + tables
views (new feature):
- pure panel-stack reducer + thin generic ViewSidebar (dropdown picker + add/remove),
switches on view KIND, never a surface id
Extensions view (composition root):
- folds frontend modules + backend surfaces into one "Extensions" view
- frontend module list AGGREGATED from each feature's public `manifest` export
(can't drift); no per-module version (FE features are internal to dispatch-web)
- surfaces are AUTO-SUBSCRIBED on catalog + rendered expanded (no catalog buttons)
surface-host:
- consecutive `stat` fields coalesce into one aligned label/value table (StatTable)
- generic custom-field renderer: dispatch on rendererId === "table" → SurfaceTable
(pure parseTablePayload), so a backend `custom`/table field renders generically
- shared presentational components/Table.svelte (used by both, neither feature
depends on the other)
store:
- auto-subscribe every catalog entry, unsubscribe vanished ones, re-subscribe all
on reconnect; expose all received specs via `surfaces` (drops single-selection)
backend-handoff: CR-1 — emit Loaded Extensions as a custom/table field; notes
what's already covered FE-side (renderer shipped, stat-table fallback works).
25 files changed, 882 insertions, 229 deletions
diff --git a/backend-handoff.md b/backend-handoff.md index df6a618..2ddebe1 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,7 +5,7 @@ > **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (ORCHESTRATOR §5) — every cross-repo ask flows through here. -_Last updated: 2026-06-10 — metrics display slice FE-complete + live-verified (probe 17/17). No open backend asks._ +_Last updated: 2026-06-10 — "Extensions" view shipped FE-side. ONE open ask: CR-1 (Loaded Extensions as a real multi-column table). The surface is already readable today; CR-1 is the enhancement that finishes the user's "nice table" request._ --- @@ -28,7 +28,43 @@ Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-con ## 2. Open asks FOR THE BACKEND -- _(none open)_ +### CR-1 — emit the **Loaded Extensions** surface as a true table + +The user wants the Loaded Extensions surface rendered as a nice multi-column +table (e.g. `Name | Version | Trust | Scope`), listing **all** loaded extensions. + +**Already covered — do NOT redo these (no contract change needed):** +- The `custom` field kind + `rendererId` + graceful-skip already exist in + `[email protected]`. CR-1 uses that escape hatch — no `@dispatch/ui-contract` bump. +- The FE renderer is **done and shipped**: `SurfaceView` → `SurfaceTable` → + shared `Table`, dispatched on `rendererId === "table"`. It renders the moment + the surface emits the field below. +- The FE already groups consecutive `stat` fields into an aligned 2-column + (label → value) table, so the current surface (one `stat` per extension: + name → version) is **already readable as a table today**. CR-1 is the upgrade + to real columns, not a fix for something broken. +- The "frontend modules" half of the Extensions view is **100% FE-owned** + (aggregated from each FE feature's `manifest`) — backend has nothing to provide there. + +**What I NEED from the backend to finish it:** replace the N per-extension +`stat` fields with a SINGLE `custom` field: +```ts +{ + kind: "custom", + rendererId: "table", + payload: { + columns: string[], // header labels + rows: (string | number | boolean)[][], // each row aligns cell-for-cell to columns + }, +} +``` +- Cells are coerced to strings; a malformed payload renders nothing (safe skip). +- `rows` should enumerate **every** loaded extension (all trust tiers / kinds), + so "show all" is satisfied from this one surface. + +**Optional (data quality, not a blocker):** extension manifest `version`s all +read `0.0.0` (unversioned). If real versions should appear in the table column, +bump each extension's manifest `version` — otherwise the column is all `0.0.0`. ## 3. Likely NEXT backend asks (heads-up, not yet requested) 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"); + }); +}); |
