summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:13:28 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:13:31 +0900
commit2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch)
tree94e1923180ae38d571d34b578afecb0a18913c24 /src/features/surface-host/logic
parent80f99665034a0e510300793205c162fc7a46769f (diff)
parent08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff)
downloaddispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz
dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip
Merge branch 'dev' into feature/heartbeat
# Conflicts: # src/app/App.svelte # src/app/store.svelte.ts # src/app/store.test.ts # src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/surface-host/logic')
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts72
-rw-r--r--src/features/surface-host/logic/message-queue.ts38
-rw-r--r--src/features/surface-host/logic/plan.test.ts468
-rw-r--r--src/features/surface-host/logic/plan.ts210
-rw-r--r--src/features/surface-host/logic/table.test.ts78
-rw-r--r--src/features/surface-host/logic/table.ts66
-rw-r--r--src/features/surface-host/logic/todo.test.ts110
-rw-r--r--src/features/surface-host/logic/todo.ts32
-rw-r--r--src/features/surface-host/logic/types.ts78
9 files changed, 576 insertions, 576 deletions
diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts
index ce078d9..8d55eb7 100644
--- a/src/features/surface-host/logic/message-queue.test.ts
+++ b/src/features/surface-host/logic/message-queue.test.ts
@@ -3,46 +3,46 @@ 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,
+ 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 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("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("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();
- });
+ 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
index a8e1567..79707a5 100644
--- a/src/features/surface-host/logic/message-queue.ts
+++ b/src/features/surface-host/logic/message-queue.ts
@@ -14,31 +14,31 @@ import type { QueuedMessage } from "@dispatch/wire";
* payload shape.
*/
export interface MessageQueueData {
- readonly messages: readonly QueuedMessage[];
+ 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)
- );
+ 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 };
+ 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. */
diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts
index be296a7..9c8a34d 100644
--- a/src/features/surface-host/logic/plan.test.ts
+++ b/src/features/surface-host/logic/plan.test.ts
@@ -4,247 +4,247 @@ 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 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"]);
- });
+ 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([]);
- });
+ 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 89088c3..c8f82b9 100644
--- a/src/features/surface-host/logic/plan.ts
+++ b/src/features/surface-host/logic/plan.ts
@@ -1,20 +1,20 @@
import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
import type {
- FieldView,
- NumberFieldView,
- RenderGroup,
- StatFieldView,
- SurfaceRenderPlan,
+ FieldView,
+ NumberFieldView,
+ RenderGroup,
+ StatFieldView,
+ SurfaceRenderPlan,
} from "./types";
const KNOWN_KINDS = new Set([
- "toggle",
- "progress",
- "selector",
- "stat",
- "number",
- "button",
- "custom",
+ "toggle",
+ "progress",
+ "selector",
+ "stat",
+ "number",
+ "button",
+ "custom",
]);
/**
@@ -25,73 +25,73 @@ const KNOWN_KINDS = new Set([
* 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 "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 };
+ 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 };
}
/**
@@ -100,24 +100,24 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
* 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;
+ 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;
}
/**
@@ -126,13 +126,13 @@ export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] {
* 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
index e55b3f7..6fb558a 100644
--- a/src/features/surface-host/logic/table.test.ts
+++ b/src/features/surface-host/logic/table.test.ts
@@ -2,46 +2,46 @@ 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("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("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("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();
- });
+ 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
index 027553c..5d2b831 100644
--- a/src/features/surface-host/logic/table.ts
+++ b/src/features/surface-host/logic/table.ts
@@ -9,46 +9,46 @@
*/
export interface TableData {
- readonly columns: readonly string[];
- readonly rows: readonly (readonly string[])[];
+ readonly columns: readonly string[];
+ readonly rows: readonly (readonly string[])[];
}
function isStringArray(v: unknown): v is unknown[] {
- return Array.isArray(v);
+ 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;
+ 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 };
+ 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
index 225ecde..66ff036 100644
--- a/src/features/surface-host/logic/todo.test.ts
+++ b/src/features/surface-host/logic/todo.test.ts
@@ -2,66 +2,66 @@ import { describe, expect, it } from "vitest";
import { parseTodoPayload, type TodoItem } from "./todo";
const item = (content: string, status: TodoItem["status"] = "pending"): TodoItem => ({
- content,
- status,
+ 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 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("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("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("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();
- });
+ 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
index e442e78..8b8a5ef 100644
--- a/src/features/surface-host/logic/todo.ts
+++ b/src/features/surface-host/logic/todo.ts
@@ -16,33 +16,33 @@
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
export interface TodoItem {
- readonly content: string;
- readonly status: TodoStatus;
+ readonly content: string;
+ readonly status: TodoStatus;
}
export interface TodoData {
- readonly todos: readonly TodoItem[];
+ 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);
+ 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 };
+ 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. */
diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts
index 23f8757..11c222f 100644
--- a/src/features/surface-host/logic/types.ts
+++ b/src/features/surface-host/logic/types.ts
@@ -2,33 +2,33 @@ 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;
}
/**
@@ -37,21 +37,21 @@ export interface StatFieldView {
* 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;
+ 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;
}
/**
@@ -60,24 +60,24 @@ export interface ButtonFieldView {
* 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;
+ 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
- | NumberFieldView
- | ButtonFieldView
- | CustomFieldView;
+ | 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[];
}
/**
@@ -86,5 +86,5 @@ export interface SurfaceRenderPlan {
* 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> };
+ | { readonly type: "stats"; readonly stats: readonly StatFieldView[] }
+ | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> };