# FE handoff — todo task list surface

Courier this to `../dispatch-web` (cross-repo contract change; `lsp references` does
not span repos — ORCHESTRATOR §7). All changes are ADDITIVE — nothing existing breaks.

## What shipped (backend)

A per-conversation **task list** the AI model maintains via a `todo_write` tool. The
list is exposed to the frontend as a per-conversation **surface** (read-only). The
model creates/updates the list during a turn; the surface updates live so the FE can
render the current state.

- **`todo_write` tool** — the model passes the FULL list each call (replaces the
  existing list). Returns the list as JSON. The tool description guides the model on
  when to use it (3+ step tasks, planning, etc.).
- **State** — in-memory, per-conversation. No persistence (the list lives for the
  process lifetime of the conversation).
- **No new wire types, no version bumps.** The todo surface uses the existing
  `custom` surface field kind (`ui-contract` unchanged). The `TodoItem` type is
  defined by the `todo` extension and carried in the surface payload — it is NOT
  in `@dispatch/wire` or `@dispatch/transport-contract`.

## The surface

The `todo` extension contributes a per-conversation surface:

- **Surface id:** `"todo"`
- **Scope:** `"conversation"` (subscribe with the `conversationId`)
- **Region:** `"side"`
- **Title:** `"Tasks"`
- **One `custom` field**, `rendererId: "todo"`, `payload: TodoPayload`

```ts
interface TodoPayload {
  todos: readonly TodoItem[];
}

interface TodoItem {
  content: string;
  status: "pending" | "in_progress" | "completed" | "cancelled";
}
```

- **Read-only** — no `invoke` actions. The model mutates the list via the
  `todo_write` tool; the FE only renders.
- **Updates** on every `todo_write` call (subscriber-notify → full new spec with the
  updated `todos` array).
- **Empty list** — an idle conversation (no todo list created yet, or the model
  cleared it with an empty array) renders `todos: []`. Hide the panel when empty.

## What the FE needs to do

1. **Subscribe** to the `todo` surface per conversation (same pattern as
   `message-queue` and `cache-warming` — `scope: "conversation"`, pass
   `conversationId` on subscribe).

2. **Custom renderer** for `rendererId: "todo"` — render the `payload.todos` array
   as a task list. Suggested UI:
   - Each item shows `content` with a status indicator:
     - `pending` — empty circle / checkbox
     - `in_progress` — spinner / filled circle (highlight)
     - `completed` — checkmark (strikethrough or dim the content)
     - `cancelled` — X / dash (dim/strikethrough)
   - Order is significant — items are in the order the model provided them (array
     index = identity).
   - Only one item should be `in_progress` at a time (the tool description enforces
     this via guidance, not validation — but the model should comply).

3. **Live updates** — the surface pushes a new spec on every `todo_write` call. No
   polling needed. Just re-render from the new `payload.todos`.

4. **Empty state** — when `todos` is `[]`, hide the panel (the model hasn't created
   a list yet, or cleared it).

## No other integration points

- No new WS ops (no `chat.queue` equivalent — the model is the only writer).
- No new HTTP endpoints (the list is tool-driven, not API-driven).
- No new `AgentEvent` types (the list is not on the chat stream).
- No version bumps in `@dispatch/wire` or `@dispatch/transport-contract`.

## Notes

- **In-memory only** — the todo list does NOT persist across server restarts. If
  the server restarts, the list is cleared. The model recreates it on the next
  `todo_write` call. This mirrors the message-queue behavior.
- **Per-conversation** — each conversation has its own list. Switching conversations
  means subscribing to a different `conversationId` and rendering that conversation's
  list.
- **Model-driven** — the FE has no control over the list (read-only surface). The
  model creates, updates, and clears items. The FE just displays the current state.
