summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host/logic/todo.ts
blob: e442e78672d10324ec33d29415338353fda567ff (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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";