feat(web): list background tasks in the session header

The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.

Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.

Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
This commit is contained in:
Yichen Jiang
2026-08-08 23:29:41 +08:00
parent 22609ea425
commit eab0aeb9db
93 changed files with 2130 additions and 68 deletions
+2
View File
@@ -24,6 +24,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像