Web todo display — snapshot side-effect channel + two render surfaces
Web todo 展示——快照副作用通道 + 两个渲染面
`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 —
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) and clears on turn/start (turn-scoped plan lifetime). rebuildDerivedFromWindow sweeps the window from an empty plan and restores the tail-page seed only when the window never determined the plan (no todo/write and no turn/start); otherwise the in-window write/turn/start fold wins. Every installWindow caller is a tail request (doOpen, its gap re-pull, repairGap; loadOlder prepends without reseeding), which the host answers with the projection or omits it when no plan stands — 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, using ctx.slots.inject with no ConversationController edge, order: 0 above the queue rows), hidden while empty, collapsible to a header of title + ·-joined per-status counts (localized, 1 completed · 2 in progress · 1 pending, zero-count segments omitted; no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (--dsw-specific-tip, 14px radius, width: calc(100% - 88px) / max-width: 776px centered; InputBar top pad 6px is the gap to the composer card). It reads the host-computed todos projection via the standard-kit useProjection 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 tool.call.toolview slot through ctx.slots.inject, the same declaration-lifetime posture as the bash sample but a product registration. The summary derives from call args (N/M done · first active item, with a +<n> count of the other active ones in ToolRow's non-shrinking summarySuffix slot); 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
nodesas 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 71) plus packages/client/ui-conversation/tests/todo-panel.client.spec.tsx pin the full chain (row summary and state, dock panel content, collapse round-trip). todos is a required ConversationSnapshot field, so scripted fakes in specs must carry it. 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 standing plan (latest todo/write with no later turn/start), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later turn/start, and resets to empty when a tail response carries no projection.
中文
问题
todo_write 把 todo/write 的整份列表快照追加进会话日志;TUI 渲染一块常驻的 plan 面板(自动化专用的 ACP(Agent Client Protocol)桥接刻意不做 todo 呈现)。Web 客户端把这个事件整个丢弃了:host mux 流本已转发每一个会话事件,但 todo/write 不是 surface 类型(它从不 fold 进 ConversationSnapshot.nodes),也没有任何副作用分支累积它——浏览器既无消费点,也无展示面。
决策
把 todo/write 当作会话副作用消费,而非 surface 节点,并在两个面上渲染它,这两个面正对应 TUI 已经绘制的那套划分。
副作用通道,与窗口回放收敛
applyEventSideEffects 新增一个 todo/write 分支(整份列表,后写覆盖先写),并在 turn/start 清空(按轮次界定的计划生命周期)。rebuildDerivedFromWindow 从空计划扫过窗口,仅当窗口从未判定计划(无 todo/write 且无 turn/start)时恢复尾页种子;否则以窗口内写入/turn/start 折叠为准。installWindow 的每个调用方都是尾页请求(doOpen、其补洞重拉、repairGap;loadOlder 只往前拼接、不再播种),而 host 对尾页请求要么带上投影、要么在没有当前有效的计划时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:若 host 在持久化实时写入前崩溃,log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。ConversationSnapshot.todos 是读取面。这遵循事件自身的约定(「仅存在于日志中的 UI 状态;绝不纳入派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。
TodoPanel:持久化列表作为一条常驻横条
面板经 conversation.input.dock slot 挂载(普通注册者插件 todoDockEntry 使用 ctx.slots.inject,不依赖 ConversationController,order: 0 排在队列条上方),空列表时隐藏,可折叠为标题加以 · 连接的各状态计数的表头(本地化,形如 1 已完成 · 2 进行中 · 1 待处理,计数为零的段落省略;折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(--dsw-specific-tip、14px 圆角、width: calc(100% - 88px)/max-width: 776px 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 useProjection hook 读取 host 计算的 todos 投影——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。
TodoRow:经 keyed toolview slot 的逐调用行
专用的 todo_write 对话行是一个普通注册者插件(todoToolview,由 apply 挂载),经 ctx.slots.inject 注册进 keyed 的 tool.call.toolview slot,遵循与 bash 样例相同的声明生命周期,但属产品级注册。摘要由调用 args 推导(N/M done · first active item,其余活跃项的 +<n> 计数放在 ToolRow 的不收缩 summarySuffix 位里);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 ToolEventView——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。
考虑过的替代方案
- 把 todo 写入作为 surface 条目折叠进
nodes——回放的窗口会渲染每一份已被取代的列表;该事件被刻意设计成非 surface 类型。 - 面板硬编码进
ConversationRoot——input-dock slot 出现之前的原始落点;dock 是本架构给「composer 上方常开横条」安排的位置,硬编码绕开了 slot 注册表的 disposal 与定序。 - 面板放进 details 列——details slot 单占用且由选中驱动,生命周期不同于一条常开横条。
- host 计算的视图(一个 todo
ToolEventView)——呈现属于客户端;协议已在事件载荷里携带整份快照。
后果
回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 packages/client/ui-conversation/tests/todo-panel.client.spec.tsx 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。todos 是 ConversationSnapshot 的必填字段,所以 spec 里脚本化的 fake 必须带上它。自动化专用的 ACP 桥接刻意不做 todo 呈现;Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 todos——全量 log 上当前有效的计划(其后没有更晚 turn/start 的最近一次 todo/write),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 turn/start 会清空,而尾页响应不带投影时复位为空。