diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/chat/index.ts | 6 | ||||
| -rw-r--r-- | src/features/conversation-cache/index.ts | 6 | ||||
| -rw-r--r-- | src/features/surface-host/index.ts | 6 | ||||
| -rw-r--r-- | src/features/surface-host/logic/plan.test.ts | 64 | ||||
| -rw-r--r-- | src/features/surface-host/logic/plan.ts | 43 | ||||
| -rw-r--r-- | src/features/surface-host/logic/table.test.ts | 47 | ||||
| -rw-r--r-- | src/features/surface-host/logic/table.ts | 54 | ||||
| -rw-r--r-- | src/features/surface-host/logic/types.ts | 23 | ||||
| -rw-r--r-- | src/features/surface-host/ui/Stat.svelte | 10 | ||||
| -rw-r--r-- | src/features/surface-host/ui/StatTable.svelte | 21 | ||||
| -rw-r--r-- | src/features/surface-host/ui/SurfaceTable.svelte | 14 | ||||
| -rw-r--r-- | src/features/surface-host/ui/SurfaceView.svelte | 36 | ||||
| -rw-r--r-- | src/features/tabs/index.ts | 6 | ||||
| -rw-r--r-- | src/features/views/index.ts | 15 | ||||
| -rw-r--r-- | src/features/views/logic/panels.test.ts | 55 | ||||
| -rw-r--r-- | src/features/views/logic/panels.ts | 49 | ||||
| -rw-r--r-- | src/features/views/ui/ViewSidebar.svelte | 87 | ||||
| -rw-r--r-- | src/features/views/ui/ViewSidebar.test.ts | 58 |
18 files changed, 564 insertions, 36 deletions
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"); + }); +}); |
