From 871957b930203c019e631c4606cfdf8266d222fa Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 10 Jun 2026 16:29:01 +0900 Subject: feat(views,surface-host): Extensions sidebar view — auto-expanded surfaces + tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend-handoff.md | 40 +++++- src/app/App.svelte | 73 +++++----- src/app/App.test.ts | 175 +++++++++++------------ src/app/store.svelte.ts | 74 +++++----- src/app/store.test.ts | 72 +++++----- src/components/Table.svelte | 42 ++++++ src/components/Table.test.ts | 35 +++++ src/features/chat/index.ts | 6 + src/features/conversation-cache/index.ts | 6 + src/features/surface-host/index.ts | 6 + src/features/surface-host/logic/plan.test.ts | 64 +++++++-- src/features/surface-host/logic/plan.ts | 43 +++++- src/features/surface-host/logic/table.test.ts | 47 ++++++ src/features/surface-host/logic/table.ts | 54 +++++++ src/features/surface-host/logic/types.ts | 23 ++- src/features/surface-host/ui/Stat.svelte | 10 -- src/features/surface-host/ui/StatTable.svelte | 21 +++ src/features/surface-host/ui/SurfaceTable.svelte | 14 ++ src/features/surface-host/ui/SurfaceView.svelte | 36 +++-- src/features/tabs/index.ts | 6 + src/features/views/index.ts | 15 ++ src/features/views/logic/panels.test.ts | 55 +++++++ src/features/views/logic/panels.ts | 49 +++++++ src/features/views/ui/ViewSidebar.svelte | 87 +++++++++++ src/features/views/ui/ViewSidebar.test.ts | 58 ++++++++ 25 files changed, 882 insertions(+), 229 deletions(-) create mode 100644 src/components/Table.svelte create mode 100644 src/components/Table.test.ts create mode 100644 src/features/surface-host/logic/table.test.ts create mode 100644 src/features/surface-host/logic/table.ts delete mode 100644 src/features/surface-host/ui/Stat.svelte create mode 100644 src/features/surface-host/ui/StatTable.svelte create mode 100644 src/features/surface-host/ui/SurfaceTable.svelte create mode 100644 src/features/views/index.ts create mode 100644 src/features/views/logic/panels.test.ts create mode 100644 src/features/views/logic/panels.ts create mode 100644 src/features/views/ui/ViewSidebar.svelte create mode 100644 src/features/views/ui/ViewSidebar.test.ts 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 + `ui-contract@0.1.0`. 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 @@ + +
+ {#each state.panels as panel, idx (panel.id)} +
+
+ + {#if idx > 0} + + {/if} +
+ + {#if panel.kind !== null} +
+ {@render content(panel.kind)} +
+ {/if} +
+ {/each} + + +
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: () => `
kind:${kind()}
`, +})); + +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"); + }); +}); -- cgit v1.2.3