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). --- 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 ++++++++++++++++ 18 files changed, 564 insertions(+), 36 deletions(-) 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 (limited to 'src/features') 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,11 +53,44 @@ 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; 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; + + 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 }; 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 @@ - - -
-
{field.label}
-
{field.value}
-
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 @@ + + +
+ + + {#each stats as stat, i (i)} + + + + + {/each} + +
{stat.label}{stat.value}
+
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 @@ + + +{#if data !== null} + +{/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 @@

{spec.title}

- {#each plan.fields as field (field)} - {#if field.kind === "toggle"} - - {:else if field.kind === "progress"} - - {:else if field.kind === "selector"} - - {:else if field.kind === "stat"} - - {:else if field.kind === "button"} -
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 @@ + + +
+ {#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