Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md
T
Chinesezjc d2bb2a809b fix(web-todo): dock-owned selection, keyboard-openable row, documented tail todos field
Three ds-review-bot round-8 findings on the todo display surfaces.

TodoPanel took the whole `useSession` hook and cast the snapshot to reach
`todos`, which put slot plumbing and an unchecked cast inside the presentation
component. The panel now takes `todos: readonly TodoItem[]`; TodoDock does the
selecting, matching the QueueDock posture the dock slot already establishes.

The todo row carried `onClick` with no keyboard route, so its details panel was
mouse-only. It now takes ToolRow's route verbatim: `role="button"`,
`tabIndex={0}`, and an Enter/Space handler that claims the event. The row stays
a `<div>` because a `<button>` flattens its inline spans.

`session.history`'s tail-only `todos` field was documented at the TS signature
but not in the apiproxy README pair, and the Agent Note claimed the feature
added no new wire vocabulary. Both README sides now state the tail/omission
semantics (an omitted field on a tail response is the empty plan, not unchanged
state), and the note records the one added field instead of denying it.

Tests: TodoPanel specs render the plain list; new TodoDock specs cover selection,
live follow, rollback-to-empty, and the registration shape; a new row spec pins
Enter/Space activation and non-activating keys.
2026-07-27 12:08:46 +08:00

5.1 KiB

Agent Note: Web todo display — snapshot side-effect channel + two render surfaces

Status: implemented

English | 中文

Problem

todo_write appends todo/write whole-list snapshots to the session log; the TUI renders a persistent plan panel (the automation-only ACP bridge deliberately omits todo presentation). The web client dropped the event entirely: the host mux stream already forwards every session event, but todo/write is not a surface type (it never folds into ConversationSnapshot.nodes), and no side-effect branch accumulated it — the browser had no consumption point and no display surface.

Decision

Consume todo/write as a Session side effect, not a surface node, and render it on two surfaces matching the split the TUI already draws.

Side-effect channel, converging with window replay

applyEventSideEffects gains a todo/write case (whole list, last write wins). Unlike partial/openCalls, rebuildDerivedFromWindow deliberately does NOT reset it: the value is session-level — taken from the tail history page's full-log projection — and an arbitrary window may not contain the latest write, so an older-page prepend keeps it and only an in-window or live write overwrites it. Every installWindow caller is a tail request (doOpen, its gap re-pull, repairGap; loadOlder prepends without it), which the host answers with the projection or omits it only when the full log holds no todo/write — so an absent field is the authoritative empty list and is assigned as such. That distinction matters on rollback: a live write whose host crashed before persisting leaves the log empty, and preserving the prior value instead would strand the rolled-back plan on screen indefinitely. ConversationSnapshot.todos is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing.

TodoPanel: the durable list as a persistent strip

The panel mounts through the conversation.input.dock slot (a plain registrant plugin, todoDockEntry, the QueueDock posture: inject: ['slots', 'conversation'] as the load-order seam, order: -1 above the queue rows), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads snapshot.todos via the standard-kit useSession hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper.

TodoRow: the per-call row through the keyed toolview slot

The dedicated todo_write chat row is a plain registrant plugin (todoToolview, mounted from apply) that registers into the keyed conversation.chat.toolview slot via ctx.slots.register — the same seam and load-order posture as the bash sample (inject: ['slots', 'conversation']), but a product registration. The summary derives from call args (N/M done · active item); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No ToolEventView is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card.

Alternatives considered

  • Fold todo writes into nodes as surface entries — replayed windows would render every superseded list; the event is deliberately not a surface type.
  • Hardcoding the panel inside ConversationRoot — the original landing spot before the input-dock slot existed; the dock is the architecture's home for always-on strips above the composer, and a hardcode bypasses the slot registry's disposal and ordering.
  • Details column for the panel — the details slot is single-occupant and selection-driven, a different lifetime than an always-on strip.
  • Host-computed view (a todo ToolEventView) — presentation belongs to the client; the wire already carries the whole snapshot in the event payload.

Consequences

Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (apps/web/tests/todo-display.snapshot.ts) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. todos is a required ConversationSnapshot field, so scripted fakes in specs must carry it. The TUI panel is untouched (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries todos — the full-log latest todo/write, computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan even when the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, and resets to empty when a tail response carries no projection.