One session-header task list — jobs joined with activities in the UI projection only
会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并
The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activ
English
Problem
The activity observation seam shipped its Web surface as a second session-header button beside the background-job list. A background bash run registers in both planes — ctx.jobs for control and ctx.activities for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy.
Decision
The service planes stay orthogonal; the join happens once, per row, in the UI projection.
ctx.jobsandctx.activitiesremain separate services. Jobs are the control plane: model-visible ids, a consumingreadOutputcursor the model drains,stopping,kill, and thereportedcompletion-notice contract. Activities are the transient observation plane: non-consuming absolute offsets, invisible to the model, gone on restart. Merging the services would force one registry to carry both a consuming and a non-consuming cursor and would blur the "model-visible ⟺ logged" line for a plane designed to stay off the session log.dsh-client-ui-activityrenders the single merged list;dsh-client-ui-jobsis deleted (pre-release, no compatibility shim), and the merged entry takes the job list's slot order. EachjobsBySessionrow is joined with the activity carrying itscorrelation.jobId: the job supplies identity, lifecycle (stoppingexists only there), duration, and the model-visibledetail; the activity supplies the expandable output panel. An activity without a job (a workflow run) keeps its own row; an activity whose job is not in the projection is not dropped. Without the activity registry, job rows still render — they just offer no panel, so the merged control degrades to exactly the old job list.- User-facing vocabulary is "task" (任务) —
N tasks running/N 个任务进行中— because every row is running work to the user, while "background job" excludes workflow runs and "activity" names the internal plane, not the user concept. The dictionary namespace and package names stayactivity: internal names follow the naming ledger, not display copy. - Hierarchy: live rows first (label over a kind-badge-and-status second line, ticking duration), then settled rows compacted to one de-emphasized line; each non-empty section carries its own heading (Running / Finished). Rows without an observable activity render as static rows with no expansion affordance.
Deliberately not joined: subagent delegation rows stay bare job rows (the subagent panel is their surface), and foreground commands stay in their tool cards — neither gains an activity producer.
Alternatives considered
- Merge the services — rejected for the cursor-semantics and model-visibility reasons above.
- Keep the job list and bolt output onto it — leaves workflow runs (activities without jobs) homeless, forcing a second list to survive anyway.
Refinements from browser acceptance
Driving the merged list in the real app produced four presentation decisions, all scoped to the expanded panel and the header band:
- The header band order is preset → tasks → subagent catalog. The root-session descendant count moved out of the breadcrumb (
header.lineage) into the actions band (order 30) and dropped its/separator — a root session has no hierarchy to breadcrumb; child sessions keep theirroot / childswitcher where the/is real.SubagentCatalogActionowns the moved seat; the lineage renderer now renders nothing on roots. - The panel's copy control copies the command (
TerminalBlock.copyText), not the output: a long command ellipsizes in the row, so the control is its only complete source, while output is selectable text below. SupplyingcopyTextalso keeps the control rendered before any output exists. - The panel scrolls instead of folding: commands and output lines wrap in full (
--dsl-terminal-command-whitespace/--dsl-terminal-line-whitespace: pre-wrap, so the output region never scrolls sideways) and the output region caps at a fixed height (--dsl-terminal-output-max-height) withmaxLines: Infinitydisabling the head/tail fold. - The panel draws no run-state dot (
TerminalBlock.runStateDot: false) — the row above it already carries the same state — and reclaims the dot gutter via--dsl-terminal-gutter. The panel's left spine was dropped with it.
All four land as opt-ins on TerminalBlock (props or --dsl-terminal-* variables), so tool cards keep their shipped fold/ellipsis/dot behavior unchanged.
Testing
Component suites cover the join (job lifecycle wins, activity supplies the panel, bare-job rows, standalone activities), sections, durations, and the panel options; pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent runs them. The keyless web e2e pair (background-job-list, live-activity-stream) replays the merged list end to end against ARIA goldens; agent-preset-selection pins the header band order.
Consequences
- The two web e2e scenarios (
background-job-list,live-activity-stream) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end. - A human kill control on live rows is still blocked on the jobs
reportedcontract question recorded in the job-display note. - The
joblocale namespace is gone with its package;activityowns the merged copy including the duration vocabulary.
中文
问题
activity 观察面的 Web 呈现落地为后台任务列表旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——ctx.jobs 负责控制、ctx.activities 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。
决策
服务面保持正交;合并只在 UI 投影层逐行发生一次。
ctx.jobs与ctx.activities仍是两个服务。 jobs 是控制面:模型可见的 id、模型消耗式的readOutput游标、stopping、kill与reported完成通知契约。activity 是瞬态观察面:非消耗式绝对偏移、模型不可见、重启即无。合并服务会迫使一个注册表同时携带消耗式与非消耗式两种游标,并模糊这个刻意不进 session log 的面与"模型可见 ⟺ 已记录"不变量的边界。dsh-client-ui-activity渲染唯一的合并列表;dsh-client-ui-jobs删除(预发布阶段,不留兼容层),合并入口占用原 job 列表的 slot 顺位。每个jobsBySession行与携带其correlation.jobId的 activity 逐行 join:job 提供身份、生命周期(stopping只存在于此)、时长与模型可见detail;activity 提供可展开的输出面板。没有 job 的 activity(workflow 运行)保留自己的行;job 不在投影里的 activity 也不会被丢弃。没有 activity 注册表时 job 行照常渲染——只是没有面板,合并控件退化为与旧 job 列表完全等价。- 用户可见词汇统一为"任务"(task)——
N 个任务进行中/N tasks running——因为对用户而言每一行都是在跑的工作,而"后台任务"排除了 workflow 运行、"活动"命名的是内部面而非用户概念。词典命名空间与包名保持activity:内部名遵循命名台账,不跟随展示文案。 - 层次:进行中的行在前(命令为主行、kind 徽章加状态为副行、时长每秒跳动),已结束的行折为降权单行;每个非空分组带自己的标题(进行中 / 已结束)。没有可观察 activity 的行渲染为无展开交互的静态行。
刻意不合并的:subagent 委托行保持裸 job 行(subagent 面板是它的呈现面),前台命令留在工具卡片里——两者都不接活动生产者。
曾考虑的替代方案
- 合并服务层——因上述游标语义与模型可见性理由否决。
- 保留 job 列表、把输出挂上去——workflow 运行(无 job 的 activity)无处安放,第二个列表终究得活下来。
浏览器验收带来的细化
在真实应用里驱动合并列表得出四个呈现决策,全部限定在展开面板与头部条带:
- 头部条带顺序为 preset → 任务 → subagent 目录。 根会话的后代数量从面包屑(
header.lineage)移入操作区(order 30)并去掉/分隔符——根会话没有可面包屑的层级;子会话保留根 / 子切换器,那里的/是真实层级。移位后的席位由SubagentCatalogAction拥有;谱系 renderer 在根会话上不再渲染任何东西。 - 面板的复制控件复制命令(
TerminalBlock.copyText)而非输出:长命令在行内被省略号截断,控件是它唯一的完整来源,而输出本身就是下方可选中的文本。提供copyText还让控件在任何输出出现之前就保持渲染。 - 面板以滚动取代折叠:命令与输出行完整换行(
--dsl-terminal-command-whitespace/--dsl-terminal-line-whitespace: pre-wrap,输出区因此永不横向滚动),输出区以固定高度封顶(--dsl-terminal-output-max-height),maxLines: Infinity禁用头尾折叠。 - 面板不画运行状态点(
TerminalBlock.runStateDot: false)——上方的行已携带同一状态——并通过--dsl-terminal-gutter收回状态点落区。面板左侧竖线随之移除。
四项全部以 TerminalBlock 的可选项落地(prop 或 --dsl-terminal-* 变量),工具卡片保持既有的折叠/省略/状态点行为不变。
测试
组件套件覆盖 join(job 生命周期优先、activity 提供面板、裸 job 行、独立 activity)、分段、时长与面板选项;pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent 运行它们。无密钥 web e2e 对(background-job-list、live-activity-stream)按 ARIA golden 端到端回放合并列表;agent-preset-selection 钉住头部条带顺序。
后果
- 两个 web e2e 场景(
background-job-list、live-activity-stream)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。 - live 行上的人工 kill 控件仍被 job-display note 记录的 jobs
reported契约问题阻塞。 job词典命名空间随包一起消失;activity拥有合并后的全部文案,包括时长词汇。