diff options
Diffstat (limited to 'src/features/surface-host')
21 files changed, 1010 insertions, 331 deletions
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts index afa3127..1e21e3d 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/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts new file mode 100644 index 0000000..8d55eb7 --- /dev/null +++ b/src/features/surface-host/logic/message-queue.test.ts @@ -0,0 +1,48 @@ +import type { QueuedMessage } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { parseMessageQueuePayload } from "./message-queue"; + +const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({ + id, + text, + queuedAt, +}); + +describe("parseMessageQueuePayload", () => { + it("parses a well-formed payload with messages", () => { + const data = parseMessageQueuePayload({ + messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], + }); + expect(data).toEqual({ + messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], + }); + }); + + it("parses an empty-messages payload (queue is empty)", () => { + expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] }); + }); + + it("preserves message order", () => { + const data = parseMessageQueuePayload({ + messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")], + }); + expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]); + }); + + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing messages key", { foo: [] }], + ["messages not an array", { messages: "x" }], + ["entry not an object", { messages: ["x"] }], + ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }], + ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }], + ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }], + ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }], + ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }], + ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseMessageQueuePayload(payload)).toBeNull(); + }); +}); diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts new file mode 100644 index 0000000..79707a5 --- /dev/null +++ b/src/features/surface-host/logic/message-queue.ts @@ -0,0 +1,45 @@ +import type { QueuedMessage } from "@dispatch/wire"; + +/** + * Pure parser for the `rendererId: "message-queue"` custom-field payload. + * + * The message-queue extension's per-conversation surface emits ONE `custom` + * field with `rendererId: "message-queue"` and `payload: QueuePayload` + * (`{ messages: QueuedMessage[] }` — the current queue snapshot). This parser + * validates the untyped `payload: unknown` at the network seam so a + * hostile/partial payload can never crash the renderer (graceful skip → null). + * + * Empty `messages` is a valid, parseable state (the queue is empty — nothing to + * render); the caller hides the panel. Null is returned only for a malformed + * payload shape. + */ +export interface MessageQueueData { + readonly messages: readonly QueuedMessage[]; +} + +function isQueuedMessage(v: unknown): v is QueuedMessage { + if (typeof v !== "object" || v === null) return false; + const o = v as Record<string, unknown>; + return ( + typeof o.id === "string" && + typeof o.text === "string" && + typeof o.queuedAt === "number" && + Number.isFinite(o.queuedAt) + ); +} + +export function parseMessageQueuePayload(payload: unknown): MessageQueueData | null { + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record<string, unknown>; + const raw = obj.messages; + if (!Array.isArray(raw)) return null; + const messages: QueuedMessage[] = []; + for (const entry of raw) { + if (!isQueuedMessage(entry)) return null; + messages.push(entry); + } + return { messages }; +} + +/** The `rendererId` the message-queue extension's `custom` surface field uses. */ +export const MESSAGE_QUEUE_RENDERER_ID = "message-queue"; diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts index 50d6f11..9c8a34d 100644 --- a/src/features/surface-host/logic/plan.test.ts +++ b/src/features/surface-host/logic/plan.test.ts @@ -1,161 +1,250 @@ 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", - region: "test", - title: "Test Surface", - fields, + id: "test-surface", + region: "test", + title: "Test Surface", + fields, }); describe("planSurface", () => { - it("maps a toggle field to a ToggleFieldView", () => { - const plan = planSurface( - makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }), - ); - expect(plan.fields).toEqual([ - { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }, - ]); - }); - - it("maps a progress field to a ProgressFieldView", () => { - const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 })); - expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]); - }); - - it("maps a selector field to a SelectorFieldView", () => { - const plan = planSurface( - makeSpec({ - kind: "selector", - label: "Model", - value: "gpt-4", - options: [ - { value: "gpt-4", label: "GPT-4" }, - { value: "gpt-3.5", label: "GPT-3.5" }, - ], - action: { actionId: "set-model" }, - }), - ); - expect(plan.fields).toEqual([ - { - kind: "selector", - label: "Model", - value: "gpt-4", - options: [ - { value: "gpt-4", label: "GPT-4" }, - { value: "gpt-3.5", label: "GPT-3.5" }, - ], - action: { actionId: "set-model" }, - }, - ]); - }); - - it("maps a stat field to a StatFieldView", () => { - const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" })); - expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]); - }); - - it("maps a button field to a ButtonFieldView", () => { - const plan = planSurface( - makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }), - ); - expect(plan.fields).toEqual([ - { kind: "button", label: "Retry", action: { actionId: "retry" } }, - ]); - }); - - it("preserves field order", () => { - const plan = planSurface( - makeSpec( - { kind: "stat", label: "A", value: "1" }, - { kind: "toggle", label: "B", value: false, action: { actionId: "b" } }, - { kind: "progress", label: "C", value: 0.5 }, - { kind: "button", label: "D", action: { actionId: "d" } }, - ), - ); - expect(plan.fields.map((f) => f.label)).toEqual(["A", "B", "C", "D"]); - }); - - it("drops unknown field kinds gracefully", () => { - const plan = planSurface( - makeSpec({ kind: "stat", label: "Known", value: "ok" }, { - kind: "future-kind" as "stat", - label: "Unknown", - value: "?", - } as SurfaceField), - ); - expect(plan.fields).toHaveLength(1); - expect(plan.fields[0]?.label).toBe("Known"); - }); - - it("drops custom fields (no renderer registered)", () => { - const plan = planSurface( - makeSpec( - { kind: "stat", label: "Before", value: "1" }, - { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } }, - { kind: "stat", label: "After", value: "2" }, - ), - ); - expect(plan.fields).toHaveLength(2); - expect(plan.fields.map((f) => f.label)).toEqual(["Before", "After"]); - }); - - it("returns empty fields for an empty spec", () => { - const plan = planSurface(makeSpec()); - expect(plan.fields).toEqual([]); - }); - - it("drops all fields when all are custom", () => { - const plan = planSurface( - makeSpec( - { kind: "custom", rendererId: "x", payload: null }, - { kind: "custom", rendererId: "y", payload: 42 }, - ), - ); - expect(plan.fields).toEqual([]); - }); + it("maps a toggle field to a ToggleFieldView", () => { + const plan = planSurface( + makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }), + ); + expect(plan.fields).toEqual([ + { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }, + ]); + }); + + it("maps a progress field to a ProgressFieldView", () => { + const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 })); + expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]); + }); + + it("maps a selector field to a SelectorFieldView", () => { + const plan = planSurface( + makeSpec({ + kind: "selector", + label: "Model", + value: "gpt-4", + options: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-3.5", label: "GPT-3.5" }, + ], + action: { actionId: "set-model" }, + }), + ); + expect(plan.fields).toEqual([ + { + kind: "selector", + label: "Model", + value: "gpt-4", + options: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-3.5", label: "GPT-3.5" }, + ], + action: { actionId: "set-model" }, + }, + ]); + }); + + it("maps a stat field to a StatFieldView", () => { + const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" })); + expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]); + }); + + it("maps a number field to a NumberFieldView, carrying optional hints", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }), + ); + expect(plan.fields).toEqual([ + { + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + ]); + }); + + it("omits absent number hints (no max key when undefined)", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + action: { actionId: "set" }, + }), + ); + const field = plan.fields[0]; + expect(field).not.toHaveProperty("max"); + expect(field).not.toHaveProperty("step"); + expect(field).not.toHaveProperty("unit"); + }); + + it("maps a button field to a ButtonFieldView", () => { + const plan = planSurface( + makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }), + ); + expect(plan.fields).toEqual([ + { kind: "button", label: "Retry", action: { actionId: "retry" } }, + ]); + }); + + it("preserves field order", () => { + const plan = planSurface( + makeSpec( + { kind: "stat", label: "A", value: "1" }, + { kind: "toggle", label: "B", value: false, action: { actionId: "b" } }, + { kind: "progress", label: "C", value: 0.5 }, + { kind: "button", label: "D", action: { actionId: "d" } }, + ), + ); + expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]); + }); + + it("drops unknown field kinds gracefully", () => { + const plan = planSurface( + makeSpec({ kind: "stat", label: "Known", value: "ok" }, { + kind: "future-kind" as "stat", + label: "Unknown", + value: "?", + } as SurfaceField), + ); + expect(plan.fields).toHaveLength(1); + const first = plan.fields[0]; + expect(first && "label" in first ? first.label : null).toBe("Known"); + }); + + it("carries custom fields through verbatim, preserving order", () => { + const plan = planSurface( + makeSpec( + { kind: "stat", label: "Before", value: "1" }, + { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } }, + { kind: "stat", label: "After", value: "2" }, + ), + ); + 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", () => { + const plan = planSurface(makeSpec()); + expect(plan.fields).toEqual([]); + }); + + 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.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([]); + }); }); describe("buildInvoke", () => { - it("builds an invoke message for a toggle field", () => { - const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } }; - const msg = buildInvoke("s1", field, true); - expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true }); - }); - - it("builds an invoke message for a selector field", () => { - const field = { - kind: "selector" as const, - label: "S", - value: "a", - options: [], - action: { actionId: "sel" }, - }; - const msg = buildInvoke("s1", field, "b"); - expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" }); - }); - - it("builds an invoke message without payload for a button field", () => { - const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; - const msg = buildInvoke("s1", field); - expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" }); - }); - - it("omits payload key when value is undefined", () => { - const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; - const msg = buildInvoke("s1", field, undefined); - expect(msg).not.toHaveProperty("payload"); - }); - - it("uses the field's actionId, not a surface-level id", () => { - const field = { - kind: "toggle" as const, - label: "X", - value: true, - action: { actionId: "custom-action-123" }, - }; - const msg = buildInvoke("surf", field, false); - expect(msg.actionId).toBe("custom-action-123"); - }); + it("builds an invoke message for a toggle field", () => { + const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } }; + const msg = buildInvoke("s1", field, true); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true }); + }); + + it("builds an invoke message for a selector field", () => { + const field = { + kind: "selector" as const, + label: "S", + value: "a", + options: [], + action: { actionId: "sel" }, + }; + const msg = buildInvoke("s1", field, "b"); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" }); + }); + + it("builds an invoke message without payload for a button field", () => { + const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; + const msg = buildInvoke("s1", field); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" }); + }); + + it("omits payload key when value is undefined", () => { + const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; + const msg = buildInvoke("s1", field, undefined); + expect(msg).not.toHaveProperty("payload"); + }); + + it("uses the field's actionId, not a surface-level id", () => { + const field = { + kind: "toggle" as const, + label: "X", + value: true, + action: { actionId: "custom-action-123" }, + }; + const msg = buildInvoke("surf", field, false); + expect(msg.actionId).toBe("custom-action-123"); + }); }); diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts index 5b4530b..c8f82b9 100644 --- a/src/features/surface-host/logic/plan.ts +++ b/src/features/surface-host/logic/plan.ts @@ -1,59 +1,123 @@ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; -import type { FieldView, SurfaceRenderPlan } from "./types"; +import type { + FieldView, + NumberFieldView, + RenderGroup, + StatFieldView, + SurfaceRenderPlan, +} from "./types"; -const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button"]); +const KNOWN_KINDS = new Set([ + "toggle", + "progress", + "selector", + "stat", + "number", + "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[] = []; - for (const field of spec.fields) { - if (!KNOWN_KINDS.has(field.kind)) continue; - switch (field.kind) { - case "toggle": - fields.push({ - kind: "toggle", - label: field.label, - value: field.value, - action: field.action, - }); - break; - case "progress": - fields.push({ - kind: "progress", - label: field.label, - value: field.value, - }); - break; - case "selector": - fields.push({ - kind: "selector", - label: field.label, - value: field.value, - options: field.options, - action: field.action, - }); - break; - case "stat": - fields.push({ - kind: "stat", - label: field.label, - value: field.value, - }); - break; - case "button": - fields.push({ - kind: "button", - label: field.label, - action: field.action, - }); - break; - } - } - return { fields }; + const fields: FieldView[] = []; + for (const field of spec.fields) { + if (!KNOWN_KINDS.has(field.kind)) continue; + switch (field.kind) { + case "toggle": + fields.push({ + kind: "toggle", + label: field.label, + value: field.value, + action: field.action, + }); + break; + case "progress": + fields.push({ + kind: "progress", + label: field.label, + value: field.value, + }); + break; + case "selector": + fields.push({ + kind: "selector", + label: field.label, + value: field.value, + options: field.options, + action: field.action, + }); + break; + case "stat": + fields.push({ + kind: "stat", + label: field.label, + value: field.value, + }); + break; + case "number": { + // Carry optional hints only when present (exactOptionalPropertyTypes). + const view: NumberFieldView = { + kind: "number", + label: field.label, + value: field.value, + action: field.action, + ...(field.min !== undefined ? { min: field.min } : {}), + ...(field.max !== undefined ? { max: field.max } : {}), + ...(field.step !== undefined ? { step: field.step } : {}), + ...(field.unit !== undefined ? { unit: field.unit } : {}), + }; + fields.push(view); + break; + } + case "button": + fields.push({ + kind: "button", + label: field.label, + 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; } /** @@ -62,13 +126,13 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { * for button the payload is omitted. */ export function buildInvoke( - surfaceId: string, - field: Extract<FieldView, { action: unknown }>, - value?: unknown, + surfaceId: string, + field: Extract<FieldView, { action: unknown }>, + value?: unknown, ): InvokeMessage { - const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId }; - if (value !== undefined) { - return { ...base, payload: value }; - } - return base; + const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId }; + if (value !== undefined) { + return { ...base, payload: value }; + } + return base; } 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..6fb558a --- /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..5d2b831 --- /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/todo.test.ts b/src/features/surface-host/logic/todo.test.ts new file mode 100644 index 0000000..66ff036 --- /dev/null +++ b/src/features/surface-host/logic/todo.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { parseTodoPayload, type TodoItem } from "./todo"; + +const item = (content: string, status: TodoItem["status"] = "pending"): TodoItem => ({ + content, + status, +}); + +describe("parseTodoPayload", () => { + it("parses a well-formed payload with items", () => { + const data = parseTodoPayload({ + todos: [ + item("Write tests", "in_progress"), + item("Ship it", "pending"), + item("Read docs", "completed"), + ], + }); + expect(data).toEqual({ + todos: [ + item("Write tests", "in_progress"), + item("Ship it", "pending"), + item("Read docs", "completed"), + ], + }); + }); + + it("parses an empty-todos payload", () => { + expect(parseTodoPayload({ todos: [] })).toEqual({ todos: [] }); + }); + + it("preserves item order", () => { + const data = parseTodoPayload({ todos: [item("a"), item("b"), item("c")] }); + expect(data?.todos.map((t) => t.content)).toEqual(["a", "b", "c"]); + }); + + it("accepts all four status values", () => { + const data = parseTodoPayload({ + todos: [ + item("p", "pending"), + item("i", "in_progress"), + item("c", "completed"), + item("x", "cancelled"), + ], + }); + expect(data?.todos.map((t) => t.status)).toEqual([ + "pending", + "in_progress", + "completed", + "cancelled", + ]); + }); + + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing todos key", { foo: [] }], + ["todos not an array", { todos: "x" }], + ["entry not an object", { todos: ["x"] }], + ["entry missing content", { todos: [{ status: "pending" }] }], + ["entry with non-string content", { todos: [{ content: 1, status: "pending" }] }], + ["entry missing status", { todos: [{ content: "x" }] }], + ["entry with invalid status", { todos: [item("x", "done" as never)] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseTodoPayload(payload)).toBeNull(); + }); +}); diff --git a/src/features/surface-host/logic/todo.ts b/src/features/surface-host/logic/todo.ts new file mode 100644 index 0000000..8b8a5ef --- /dev/null +++ b/src/features/surface-host/logic/todo.ts @@ -0,0 +1,49 @@ +/** + * Pure parser for the `rendererId: "todo"` custom-field payload. + * + * The `todo` backend extension maintains a per-conversation task list (written + * by the model via a `todo_write` tool) and exposes it as a read-only + * conversation-scoped surface with one `custom` field + * (`rendererId: "todo"`, `payload: TodoPayload`). This parser validates the + * untyped `payload: unknown` at the network seam so a hostile/partial payload + * can never crash the renderer (graceful skip → null). + * + * The `TodoItem` type is NOT in `@dispatch/wire` — it is defined by the `todo` + * extension and carried in the surface payload, so we define it here (the FE's + * rendering contract for the shape). Empty `todos` is a valid, parseable state + * (the model hasn't created a list / cleared it); the caller hides the panel. + */ +export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; + +export interface TodoItem { + readonly content: string; + readonly status: TodoStatus; +} + +export interface TodoData { + readonly todos: readonly TodoItem[]; +} + +const STATUSES = new Set<string>(["pending", "in_progress", "completed", "cancelled"]); + +function isTodoItem(v: unknown): v is TodoItem { + if (typeof v !== "object" || v === null) return false; + const o = v as Record<string, unknown>; + return typeof o.content === "string" && typeof o.status === "string" && STATUSES.has(o.status); +} + +export function parseTodoPayload(payload: unknown): TodoData | null { + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record<string, unknown>; + const raw = obj.todos; + if (!Array.isArray(raw)) return null; + const todos: TodoItem[] = []; + for (const entry of raw) { + if (!isTodoItem(entry)) return null; + todos.push(entry); + } + return { todos }; +} + +/** The `rendererId` the `todo` extension's `custom` surface field uses. */ +export const TODO_RENDERER_ID = "todo"; diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts index f24438a..11c222f 100644 --- a/src/features/surface-host/logic/types.ts +++ b/src/features/surface-host/logic/types.ts @@ -2,51 +2,89 @@ import type { ActionRef, SurfaceOption } from "@dispatch/ui-contract"; /** Normalised view-model for a toggle field. */ export interface ToggleFieldView { - readonly kind: "toggle"; - readonly label: string; - readonly value: boolean; - readonly action: ActionRef; + readonly kind: "toggle"; + readonly label: string; + readonly value: boolean; + readonly action: ActionRef; } /** Normalised view-model for a progress field. */ export interface ProgressFieldView { - readonly kind: "progress"; - readonly label: string; - readonly value: number; + readonly kind: "progress"; + readonly label: string; + readonly value: number; } /** Normalised view-model for a selector field. */ export interface SelectorFieldView { - readonly kind: "selector"; - readonly label: string; - readonly value: string; - readonly options: readonly SurfaceOption[]; - readonly action: ActionRef; + readonly kind: "selector"; + readonly label: string; + readonly value: string; + readonly options: readonly SurfaceOption[]; + readonly action: ActionRef; } /** Normalised view-model for a stat field. */ export interface StatFieldView { - readonly kind: "stat"; - readonly label: string; - readonly value: string; + readonly kind: "stat"; + readonly label: string; + readonly value: string; +} + +/** + * Normalised view-model for a number field — the free-value counterpart to + * selector. `min`/`max`/`step`/`unit` are optional semantic hints (absent when + * the spec omits them). The renderer posts the new number as the action payload. + */ +export interface NumberFieldView { + readonly kind: "number"; + readonly label: string; + readonly value: number; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly unit?: string; + readonly action: ActionRef; } /** Normalised view-model for a button field. */ export interface ButtonFieldView { - readonly kind: "button"; - readonly label: string; - readonly action: ActionRef; + readonly kind: "button"; + readonly label: string; + 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; + | ToggleFieldView + | ProgressFieldView + | SelectorFieldView + | StatFieldView + | NumberFieldView + | ButtonFieldView + | CustomFieldView; /** The output of `planSurface`: the ordered list of renderable fields. */ export interface SurfaceRenderPlan { - readonly fields: readonly FieldView[]; + 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/Button.svelte b/src/features/surface-host/ui/Button.svelte index 62d7acf..ee9097c 100644 --- a/src/features/surface-host/ui/Button.svelte +++ b/src/features/surface-host/ui/Button.svelte @@ -1,21 +1,21 @@ <script lang="ts"> - import type { InvokeMessage } from "@dispatch/ui-contract"; - import type { ButtonFieldView } from "../logic/types"; + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { ButtonFieldView } from "../logic/types"; - let { - field, - surfaceId, - onInvoke, - }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = - $props(); + let { + field, + surfaceId, + onInvoke, + }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); - function handleClick() { - onInvoke({ - type: "invoke", - surfaceId, - actionId: field.action.actionId, - }); - } + function handleClick() { + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + }); + } </script> <button onclick={handleClick}>{field.label}</button> diff --git a/src/features/surface-host/ui/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte new file mode 100644 index 0000000..554fa02 --- /dev/null +++ b/src/features/surface-host/ui/MessageQueueList.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import { parseMessageQueuePayload } from "../logic/message-queue"; + + 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(parseMessageQueuePayload(payload)); +</script> + +{#if data !== null && data.messages.length > 0} + <ul class="flex flex-col gap-1 text-sm"> + {#each data.messages as msg (msg.id)} + <li class="rounded-box bg-base-200 px-3 py-2"> + <p class="whitespace-pre-wrap">{msg.text}</p> + <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}> + {new Date(msg.queuedAt).toLocaleTimeString()} + </time> + </li> + {/each} + </ul> +{/if} diff --git a/src/features/surface-host/ui/Number.svelte b/src/features/surface-host/ui/Number.svelte new file mode 100644 index 0000000..5a67087 --- /dev/null +++ b/src/features/surface-host/ui/Number.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { NumberFieldView } from "../logic/types"; + + let { + field, + surfaceId, + onInvoke, + }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); + + // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric + // input (the backend also floors/validates); send the new number as payload. + function commit(event: Event) { + const target = event.target as HTMLInputElement; + const next = target.valueAsNumber; + if (Number.isNaN(next)) return; + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: next, + }); + } +</script> + +<label class="flex items-center justify-between gap-2 text-sm"> + <span>{field.label}</span> + <span class="flex items-center gap-1"> + <input + type="number" + class="input input-bordered input-sm w-24" + value={field.value} + min={field.min} + max={field.max} + step={field.step} + onchange={commit} + /> + {#if field.unit} + <span class="opacity-60">{field.unit}</span> + {/if} + </span> +</label> diff --git a/src/features/surface-host/ui/Progress.svelte b/src/features/surface-host/ui/Progress.svelte index cba9e0f..e291c79 100644 --- a/src/features/surface-host/ui/Progress.svelte +++ b/src/features/surface-host/ui/Progress.svelte @@ -1,13 +1,13 @@ <script lang="ts"> - import type { ProgressFieldView } from "../logic/types"; + import type { ProgressFieldView } from "../logic/types"; - let { field }: { field: ProgressFieldView } = $props(); + let { field }: { field: ProgressFieldView } = $props(); - const percent = $derived(Math.round(field.value * 100)); + const percent = $derived(Math.round(field.value * 100)); </script> <div> - <span>{field.label}</span> - <progress max="100" value={percent}>{percent}%</progress> - <span>{percent}%</span> + <span>{field.label}</span> + <progress max="100" value={percent}>{percent}%</progress> + <span>{percent}%</span> </div> diff --git a/src/features/surface-host/ui/Selector.svelte b/src/features/surface-host/ui/Selector.svelte index 2da104f..4cb3536 100644 --- a/src/features/surface-host/ui/Selector.svelte +++ b/src/features/surface-host/ui/Selector.svelte @@ -1,32 +1,32 @@ <script lang="ts"> - import type { InvokeMessage } from "@dispatch/ui-contract"; - import type { SelectorFieldView } from "../logic/types"; + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { SelectorFieldView } from "../logic/types"; - let { - field, - surfaceId, - onInvoke, - }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = - $props(); + let { + field, + surfaceId, + onInvoke, + }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); - function handleChange(event: Event) { - const target = event.target as HTMLSelectElement; - onInvoke({ - type: "invoke", - surfaceId, - actionId: field.action.actionId, - payload: target.value, - }); - } + function handleChange(event: Event) { + const target = event.target as HTMLSelectElement; + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: target.value, + }); + } </script> <label> - {field.label} - <select onchange={handleChange}> - {#each field.options as option (option.value)} - <option value={option.value} selected={option.value === field.value}> - {option.label} - </option> - {/each} - </select> + {field.label} + <select onchange={handleChange}> + {#each field.options as option (option.value)} + <option value={option.value} selected={option.value === field.value}> + {option.label} + </option> + {/each} + </select> </label> 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..c559352 --- /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..e47c122 --- /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..aed8d03 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -1,33 +1,49 @@ <script lang="ts"> - import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; - import { 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 Toggle from "./Toggle.svelte"; + import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; + import { groupRenderFields, planSurface } from "../logic/plan"; + import Button from "./Button.svelte"; + import MessageQueueList from "./MessageQueueList.svelte"; + import Number from "./Number.svelte"; + import Progress from "./Progress.svelte"; + import Selector from "./Selector.svelte"; + import StatTable from "./StatTable.svelte"; + import SurfaceTable from "./SurfaceTable.svelte"; + import TodoList from "./TodoList.svelte"; + import Toggle from "./Toggle.svelte"; - let { - spec, - onInvoke, - }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props(); + let { spec, onInvoke }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props(); - const plan = $derived(planSurface(spec)); + 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} /> - {/if} - {/each} + <h2>{spec.title}</h2> + {#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 === "number"} + <Number 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} /> + {:else if group.field.rendererId === "message-queue"} + <MessageQueueList payload={group.field.payload} /> + {:else if group.field.rendererId === "todo"} + <TodoList payload={group.field.payload} /> + {/if} + {/if} + {/each} </article> diff --git a/src/features/surface-host/ui/TodoList.svelte b/src/features/surface-host/ui/TodoList.svelte new file mode 100644 index 0000000..cffefde --- /dev/null +++ b/src/features/surface-host/ui/TodoList.svelte @@ -0,0 +1,66 @@ +<script lang="ts"> + import { parseTodoPayload } from "../logic/todo"; + + let { payload }: { readonly payload: unknown } = $props(); + + const data = $derived(parseTodoPayload(payload)); +</script> + +<!-- Fixed at 30% of the viewport height so the region is consistent whether the + list is empty or overflowing — it always reserves the space and scrolls + internally (mirrors the tabs view). --> +<ul class="flex h-[30vh] flex-col gap-1 overflow-y-auto pr-1"> + {#if data !== null && data.todos.length > 0} + {#each data.todos as todo, i (i)} + <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2 text-sm"> + <!-- Status indicator --> + <span class="mt-0.5 shrink-0"> + {#if todo.status === "in_progress"} + <span class="block h-4 w-4 rounded-full bg-primary"></span> + {:else if todo.status === "completed"} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="3" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-success" + > + <polyline points="20 6 9 17 4 12"></polyline> + </svg> + {:else if todo.status === "cancelled"} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="3" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-base-content/40" + > + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + {:else} + <!-- pending: empty circle --> + <span class="block h-4 w-4 rounded-full border-2 border-base-content/30"></span> + {/if} + </span> + + <!-- Content --> + <span + class:flex-1={true} + class:line-through={todo.status === "completed" || todo.status === "cancelled"} + class:opacity-50={todo.status === "completed" || todo.status === "cancelled"} + > + {todo.content} + </span> + </li> + {/each} + {:else} + <li class="text-xs opacity-60">No tasks yet.</li> + {/if} +</ul> diff --git a/src/features/surface-host/ui/Toggle.svelte b/src/features/surface-host/ui/Toggle.svelte index aec8f4e..0326851 100644 --- a/src/features/surface-host/ui/Toggle.svelte +++ b/src/features/surface-host/ui/Toggle.svelte @@ -1,25 +1,25 @@ <script lang="ts"> - import type { InvokeMessage } from "@dispatch/ui-contract"; - import type { ToggleFieldView } from "../logic/types"; + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { ToggleFieldView } from "../logic/types"; - let { - field, - surfaceId, - onInvoke, - }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = - $props(); + let { + field, + surfaceId, + onInvoke, + }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); - function handleChange() { - onInvoke({ - type: "invoke", - surfaceId, - actionId: field.action.actionId, - payload: !field.value, - }); - } + function handleChange() { + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: !field.value, + }); + } </script> <label> - <input type="checkbox" checked={field.value} onchange={handleChange} /> - {field.label} + <input type="checkbox" checked={field.value} onchange={handleChange} /> + {field.label} </label> |
