fix(activity): acceptance refinements — header band order and panel presentation

Interactive acceptance produced five presentation decisions:

- Header band order is preset → tasks → subagent catalog. The root-session
  descendant count moves out of the breadcrumb (header.lineage) into the
  actions band (order 30) as SubagentCatalogAction and drops its '/'
  separator — a root session has no hierarchy to breadcrumb. Child
  sessions keep their root/child switcher unchanged.
- 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, and it stays rendered before any output.
- Commands and output lines wrap in full (--dsl-terminal-command-whitespace
  / --dsl-terminal-line-whitespace: pre-wrap), so the output region never
  scrolls sideways; the region caps at a fixed height and scrolls
  (--dsl-terminal-output-max-height) with maxLines: Infinity disabling the
  head/tail fold.
- The panel draws no run-state dot (TerminalBlock.runStateDot: false) —
  the row above carries the same state — and reclaims the dot gutter via
  --dsl-terminal-gutter, which must target the card element itself because
  the variable is declared on it. The panel's left spine is gone with it.

All TerminalBlock changes are opt-ins (props or --dsl-terminal-* variables);
tool cards keep their shipped fold/ellipsis/dot behavior.
This commit is contained in:
Yichen Jiang committed 2026-08-25 14:23:12 +08:00
1 parent 61aadb3abf
commit e200eadd73
25 files changed
+261 -110

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-unified-task-list.md
2026-08-25-unified-task-list.md: 9ce3e6d2b066a94ee738880f69b0ddf718a10b02
2026-08-25-unified-task-list.zh.md: ab224fd082fb8d6408449716e68e6df6c648a281
2026-08-25-unified-task-list.md: b098ea82e4762f9528ecd71bbd2b8095c540b900
2026-08-25-unified-task-list.zh.md: 4e5a2ef5d616f73741670c1c7ad787df19069fdd
@@ -19,11 +19,26 @@ The service planes stay orthogonal; the join happens once, per row, in the UI pr
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 rejected
## 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 their `root / child` switcher where the `/` is real. `SubagentCatalogAction` owns 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. Supplying `copyText` also 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`) with `maxLines: Infinity` disabling 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.
@@ -19,11 +19,26 @@
刻意不合并的: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 端到端生效的证据。
+3 -2
View File
@@ -280,8 +280,9 @@ describe('web e2e: agent-preset selection', () => {
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('Minimal mode')
expect(snapshot).toContain('button "1 subagent"')
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode'))
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "Session log"'))
// The preset label leads the header band; delegation navigation follows it.
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"'))
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "Session log"'))
// Static chrome, not a control: the header can only report a composition
// the host would refuse to change.
expect(snapshot).not.toContain('button "Minimal mode"')
@@ -1,11 +1,10 @@
- navigation "Session hierarchy":
- button "Seeded turn" [disabled]
- text: /
- button "1 subagent":
- text: 1 subagent
- img
- img
- text: Minimal mode
- button "1 subagent":
- text: 1 subagent
- img
- button "Session log":
- text: Session log
- img
@@ -3,6 +3,6 @@
- button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]:
- text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}"
- img
- text: done $ printf 'streamed-%s\n' marker-line; sleep 45
- text: $ printf 'streamed-%s\n' marker-line; sleep 45
- button "Copy"
- text: streamed-marker-line
@@ -3,6 +3,6 @@
- button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]:
- text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running
- img
- text: running $ printf 'streamed-%s\n' marker-line; sleep 45
- text: $ printf 'streamed-%s\n' marker-line; sleep 45
- button "Copy"
- text: streamed-marker-line
@@ -189,11 +189,22 @@
transform: rotate(180deg);
}
/* Output hangs under its row behind a spine that ties the two together. */
.panel {
margin: 0 8px 8px 14px;
padding-left: 8px;
border-left: 2px solid var(--dsw-alias-border-l2);
margin: 0 8px 8px;
/* Long commands and output lines wrap in full instead of ellipsizing or
scrolling sideways, and the output region scrolls within a fixed height
instead of folding its middle lines. */
--dsl-terminal-command-whitespace: pre-wrap;
--dsl-terminal-line-whitespace: pre-wrap;
--dsl-terminal-output-max-height: 288px;
}
/* The row carries the state dot, so the panel omits its own and reclaims the
dot gutter down to an ordinary content inset. The override targets the card
element itself because the gutter variable is declared on it — a value
inherited from this container would lose to that declaration. */
.panel :global([data-terminal]) {
--dsl-terminal-gutter: 12px;
}
.notice {
@@ -62,8 +62,6 @@ const NO_JOBS: readonly SessionJob[] = []
/** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */
const VIEWPORT_MARGIN = 12
/** Height cap for one live output panel inside the popover. */
const PANEL_MAX_LINES = 16
function isLive(row: TaskRow): boolean {
return row.status === 'running' || row.status === 'stopping'
@@ -136,7 +134,11 @@ function terminalLabels(t: TranslateNS<typeof NS>): TerminalBlockLabels {
noOutput: t('terminal.noOutput'),
collapseAria: t('terminal.collapseAria'),
collapse: t('terminal.collapse'),
// The panel never caps lines (it scrolls), so the fold controls that
// would invoke these stay unrendered.
/* v8 ignore next */
expandAria: hidden => t('terminal.expandAria', { n: hidden }),
/* v8 ignore next */
expand: hidden => t('terminal.expand', { n: hidden }),
}
}
@@ -288,7 +290,12 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: {
command={row.label}
output={view.text}
running={live}
maxLines={PANEL_MAX_LINES}
copyText={row.label}
// The row above the panel already carries the state dot.
runStateDot={false}
// The panel scrolls its output (a stylesheet height cap) instead
// of collapsing the middle.
maxLines={Number.POSITIVE_INFINITY}
labels={labels}
/>
</div>
@@ -192,7 +192,9 @@ describe('ActivityListAction rows and observation', () => {
expect(items[1]?.textContent).toContain('exit code: 3')
})
it('marks killed rows and expands an overflowing panel through the collapse controls', () => {
it('marks killed rows, renders every output line unfolded, and copies the command', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n')
const view: ObservedActivity = {
activityId: 'bash-1' as ActivityId,
@@ -208,9 +210,15 @@ describe('ActivityListAction rows and observation', () => {
openList()
expect(screen.getByText(zh['status.killed'])).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
const expand = screen.getByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') })
fireEvent.click(expand)
expect(screen.getByRole('button', { name: zh['terminal.collapseAria'] })).toBeDefined()
// The panel scrolls instead of folding: every line renders, no fold control.
expect(screen.getByText('line 1')).toBeDefined()
expect(screen.getByText('line 24')).toBeDefined()
expect(screen.queryByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') })).toBeNull()
// The panel draws no state dot or label of its own — the row carries it.
expect(screen.queryByText(zh['terminal.done'])).toBeNull()
// The copy control carries the command, not the output.
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['terminal.copy'] })) })
expect(writeText).toHaveBeenCalledWith('pnpm run build')
})
it('orders live rows first by start and settled rows newest-first with tie-breaks', () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 4df368b270511776896c74a55b0c1d2e684035d0
README.zh.md: 77830a1f162e963709ea4d4d25c8393e7dd97d66
README.md: 48550de318cdd637d97c23009d95215a793c9b62
README.zh.md: 83fdaa927c04dd130b410ac885c0d110939f48b3
+1 -1
View File
@@ -18,7 +18,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
## Terminal output
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; a running block with a supplied `output` renders that live text under the running state (an undefined output keeps the historical prompt-only frame); it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop — or the `copyText` override, which also keeps the control rendered before any output exists. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; a running block with a supplied `output` renders that live text under the running state (an undefined output keeps the historical prompt-only frame); it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. `runStateDot: false` omits the dot and its hidden label for hosts whose surrounding row already carries the same state; they reclaim the dot gutter via `--dsl-terminal-gutter`. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes; rebinding `--dsl-terminal-command-whitespace` to `pre-wrap` wraps long commands in full instead. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping (`--dsl-terminal-line-whitespace: pre-wrap` opts a host into wrapped lines with no sideways scroll), and collapses to a head slice plus a tail slice past `maxLines` (default 16; Infinity disables the fold for hosts that cap height through `--dsl-terminal-output-max-height` instead) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
## Read rendering
+1 -1
View File
@@ -18,7 +18,7 @@
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;运行中且提供了 `output` 的块会在 running 状态下渲染这段实时文本(未提供 output 则保持历史上的仅提示行画面);由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件——或写入 `copyText` 覆盖值,提供它还会让控件在任何输出出现之前就保持渲染。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;运行中且提供了 `output` 的块会在 running 状态下渲染这段实时文本(未提供 output 则保持历史上的仅提示行画面);由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。`runStateDot: false` 为外围行已携带同一状态的宿主省去状态点及其隐藏标签;此类宿主通过 `--dsl-terminal-gutter` 收回状态点落区。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断;把 `--dsl-terminal-command-whitespace` 重绑为 `pre-wrap` 则让长命令完整换行。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行(`--dsl-terminal-line-whitespace: pre-wrap` 让宿主选择换行且不再横向滚动);超过 `maxLines`(默认 16;传 Infinity 可禁用折叠,供改用 `--dsl-terminal-output-max-height` 限高滚动的宿主)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。
## Read 渲染
@@ -110,15 +110,18 @@
color: var(--dsw-alias-label-tertiary);
}
/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and
`nowrap` collapses the repeated spaces, tabs, and alignment of an indented
continuation. Both hold the single row and the ellipsis. */
/* Default `pre`, not `nowrap`: the prompt row renders the command verbatim,
and `nowrap` would collapse the repeated spaces, tabs, and alignment of an
indented continuation. Hosts opt into wrapping long commands by rebinding
the variable to `pre-wrap`; the ellipsis then simply never engages, and
overflow-wrap is inert while white-space stays `pre`. */
.command {
min-width: 0;
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
white-space: var(--dsl-terminal-command-whitespace, pre);
overflow-wrap: anywhere;
}
/* Capped to the prompt's line height (Pill's own 24px height would exceed a
@@ -176,10 +179,14 @@
margin: 6px;
}
/* No wrapping, no word-break: alignment is the payload of terminal output. */
/* Default `pre`: alignment is the payload of terminal output. Hosts opt
into wrapping long lines by rebinding the variable to `pre-wrap`, which
also keeps the output region from ever scrolling sideways; overflow-wrap
is inert while white-space stays `pre`. */
.line {
min-height: var(--dsl-terminal-line-height);
white-space: pre;
white-space: var(--dsl-terminal-line-whitespace, pre);
overflow-wrap: anywhere;
}
.expand {
@@ -61,6 +61,18 @@ export interface TerminalBlockProps {
running?: boolean | undefined
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
maxLines?: number | undefined
/**
* Copy-control payload override; the control copies the raw output when
* absent. Supplying it also keeps the control rendered before any output
* exists — a command is copyable before it prints.
*/
copyText?: string | undefined
/**
* Draw the run-state dot and its assistive label in the card gutter
* (default true). Hosts whose surrounding row already carries the same
* state omit both and reclaim the gutter via `--dsl-terminal-gutter`.
*/
runStateDot?: boolean | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Localized display copy supplied by the owning render site. */
@@ -155,6 +167,8 @@ export function TerminalBlock({
signal,
running = false,
maxLines = DEFAULT_TERMINAL_MAX_LINES,
copyText,
runStateDot = true,
className,
labels,
}: TerminalBlockProps) {
@@ -166,7 +180,7 @@ export function TerminalBlock({
// newline (`line\n\x1b[0m`) leaves the string not ending in one while still
// producing a last line with nothing visible in it. A genuinely blank final
// line — the double newline — survives, since it has a real empty line before
// the terminator. The copy control still copies `text` untouched.
// the terminator. The copy control's payload is unaffected.
const lines = useMemo(() => {
const parsed = parseAnsiLines(text)
const last = parsed[parsed.length - 1]
@@ -175,9 +189,9 @@ export function TerminalBlock({
return terminated ? parsed.slice(0, -1) : parsed
}, [text])
const [expanded, setExpanded] = useState(false)
// The raw output, never the rendered tree: the prompt line and the status pill
// are chrome the user did not run.
const { copied, onCopy } = useCopyFeedback(text)
// The raw output (or the caller's override), never the rendered tree: the
// prompt line and the status pill are chrome the user did not run.
const { copied, onCopy } = useCopyFeedback(copyText ?? text)
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
@@ -202,14 +216,14 @@ export function TerminalBlock({
<div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
<div className={css.header}>
<div className={css.prompt}>
<span className={css.runStateLabel}>{state.label}</span>
{runStateDot && <span className={css.runStateLabel}>{state.label}</span>}
{commandLines.map((line, index) => (
<div key={index} className={css.promptLine}>
{/* One dot for the card, on the first row: the exit status the
view carries is the whole call's, and bash reports no
per-command status, so a dot per row would assert a
per-line outcome nothing here knows. */}
{index === 0 && <StateDot state={state.state} className={css.runState} />}
{index === 0 && runStateDot && <StateDot state={state.state} className={css.runState} />}
{/* The cwd labels the CALL, so only its first row carries it. The
view knows one working directory — where the call started —
and a later line may well run somewhere else (a `cd` in the
@@ -224,7 +238,7 @@ export function TerminalBlock({
))}
</div>
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
{(!running || output !== undefined) && !empty && (
{(copyText !== undefined || ((!running || output !== undefined) && !empty)) && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? copy.copied : copy.copy}
</button>
@@ -98,6 +98,22 @@ describe('TerminalBlock states', () => {
expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('')
})
it('copyText overrides the copy payload and keeps the control before any output', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const view = render(<TerminalBlock command="npm run build --verbose" running copyText="npm run build --verbose" />)
// No output yet, but the command is already copyable.
const button = view.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(button) })
expect(writeText).toHaveBeenCalledWith('npm run build --verbose')
})
it('omits the run-state dot and its assistive label when the host carries the state', () => {
const view = render(<TerminalBlock command="sleep 5" running output="partial" runStateDot={false} />)
expect(view.container.querySelector('[class*="runState"]')).toBeNull()
expect(view.queryByText('运行中')).toBeNull()
})
it('running with supplied output streams the live text and keeps it copyable', () => {
const view = render(<TerminalBlock command="sleep 5" running output="partial" />)
expect(view.getByText('partial')).toBeTruthy()
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
README.md: d4a804a94b0f2cb295fd29c09c7f32385e423495
README.zh.md: 3cec2109cbb5f85617cf69155c5582ee43496f09
README.md: 67240628bf91e28e3b1a226f364f2d549b28695c
README.zh.md: 1c45ce74e4c04edef22a6a59b6a041c6728cbe9a
+2 -2
View File
@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Web subagent feature owner: contributes current-title lineage navigation to `conversation.session.header.lineage`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.inputTriggers`.
Web subagent feature owner: contributes current-title lineage navigation to `conversation.session.header.lineage`, the root-session descendant-count entry to `conversation.session.header.actions` (ordered after the task list), reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.inputTriggers`.
The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. On an ordinary session it preserves the current title and, when descendants exist, appends `/` plus the descendant-count dropdown before the header action row. Every subagent breadcrumb uses the count trigger's 12px type scale and combines its title with a fixed double chevron; the current breadcrumb keeps primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, ArrowDown remains the keyboard entry path, and clicking an ancestor cancels a pending menu and navigates upward without opening it. Each catalog enables sibling switching and bolds its selected row; a catalog label overrides the session-summary title, and every switcher whose catalog has not loaded requests it automatically. Only the current subagent appends its own descendant-count dropdown. Long breadcrumb titles truncate while the chevron remains visible. The count trigger covers the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionRuntime.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. On an ordinary session the lineage slot stays empty; when descendants exist, the descendant-count dropdown renders in the header actions band after the task list, with no breadcrumb separator. Every subagent breadcrumb uses the count trigger's 12px type scale and combines its title with a fixed double chevron; the current breadcrumb keeps primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, ArrowDown remains the keyboard entry path, and clicking an ancestor cancels a pending menu and navigates upward without opening it. Each catalog enables sibling switching and bolds its selected row; a catalog label overrides the session-summary title, and every switcher whose catalog has not loaded requests it automatically. Only the current subagent appends its own descendant-count dropdown. Long breadcrumb titles truncate while the chevron remains visible. The count trigger covers the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionRuntime.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable and the child is not running, with copy explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer, whose input and Send action are disabled but whose independent Stop stays usable, and the takeover returns once it stops. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes prompts through `subagent.prompt`: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, while an independent Stop routes through `subagent.interrupt`. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) and the [current-turn interrupt Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md).
+2 -2
View File
@@ -2,9 +2,9 @@
[English](README.md) | 中文
Web subagent 功能 owner:向 `conversation.session.header.lineage` 贡献当前 title 谱系导航,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.inputTriggers` 的既有 `@` 引用 source。
Web subagent 功能 owner:向 `conversation.session.header.lineage` 贡献当前 title 谱系导航,向 `conversation.session.header.actions` 贡献根会话的后代数量入口(排在任务列表之后),向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.inputTriggers` 的既有 `@` 引用 source。
页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。普通会话保留当前 title,并在存在后代时,于页头操作行之前追加 `/` 和后代数量下拉菜单。每一级 subagent 面包屑都使用数量触发器的 12px 字号,并将 title 与固定显示的双向箭头组合成一个控件;当前面包屑保留主标签颜色和 500 字重,祖先面包屑则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录,ArrowDown 保留为键盘入口;点击祖先面包屑会取消待打开的菜单并仅向上导航。每个目录都可切换 sibling,并会加粗选中行;目录 label 优先于会话摘要 title,尚未加载目录的每个切换器都会自动请求该目录。只有当前 subagent 会追加自己的后代数量下拉菜单。过长的面包屑 title 会截断,而双向箭头始终保留。数量触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionRuntime.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。普通会话的谱系槽保持为空;存在后代时,后代数量下拉菜单渲染在页头操作区里、任务列表之后,不带任何面包屑分隔符。每一级 subagent 面包屑都使用数量触发器的 12px 字号,并将 title 与固定显示的双向箭头组合成一个控件;当前面包屑保留主标签颜色和 500 字重,祖先面包屑则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录,ArrowDown 保留为键盘入口;点击祖先面包屑会取消待打开的菜单并仅向上导航。每个目录都可切换 sibling,并会加粗选中行;目录 label 优先于会话摘要 title,尚未加载目录的每个切换器都会自动请求该目录。只有当前 subagent 会追加自己的后代数量下拉菜单。过长的面包屑 title 会截断,而双向箭头始终保留。数量触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionRuntime.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器,并以文案说明恢复路径;此类 child 仍在运行期间,selector 会让位给普通编辑器——其输入区与 Send 操作被禁用,但独立的 Stop 保持可用,停止后只读替代恢复。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话通过 `subagent.prompt` 路由提示词:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagent.interrupt` 路由。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md) 与[当前轮次中断 Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md) 规定。
@@ -55,12 +55,6 @@
flex: none;
}
.separator {
color: var(--dsw-alias-label-caption);
font-size: 14px;
line-height: 20px;
}
.activitySlot {
display: inline-flex;
flex: none;
@@ -439,8 +439,6 @@ function CatalogRows({
interface CatalogDropdownSharedProps extends SubagentCatalogInjected {
/** Session whose direct catalog roots the tree. */
rootSessionId: SessionId
/** Whether an ordinary title needs a breadcrumb separator before its count. */
separator?: boolean
useSessions: SubagentHeaderLineageProps['useSessions']
t: TranslateNS<typeof NS>
}
@@ -482,7 +480,7 @@ function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties {
/** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */
function CatalogDropdown({
rootSessionId, currentSessionId, displayTitle, openTitle, variant, separator = false,
rootSessionId, currentSessionId, displayTitle, openTitle, variant,
useSessions, openChild, refresh, setCatalogOpen, t,
}: CatalogDropdownProps) {
const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined
@@ -731,7 +729,6 @@ function CatalogDropdown({
onMouseEnter={scheduleHoverOpen}
onMouseLeave={scheduleHoverClose}
>
{separator && <span className={css.separator}>/</span>}
<button
ref={triggerRef}
type="button"
@@ -807,10 +804,41 @@ function CatalogDropdown({
)
}
/** Full props for the root-session catalog entry in the header actions band. */
export type SubagentCatalogActionProps =
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale<typeof NS>
/**
* Session-header catalog action for root sessions: the descendant count and
* its dropdown, ordered after the task list. Child sessions render nothing
* here — their breadcrumb switcher in the lineage slot owns the same
* navigation.
* @param props - Session standard props plus the catalog actions and translator.
* @returns The count dropdown, or null on a child session.
*/
export function SubagentCatalogAction({
sessionId, useSessions, openChild, refresh, setCatalogOpen, t,
}: SubagentCatalogActionProps) {
const isChild = useSessions(state => state.byId[sessionId]?.origin === 'subagent')
if (isChild) return null
return (
<CatalogDropdown
key={sessionId}
rootSessionId={sessionId}
variant="count"
useSessions={useSessions}
openChild={openChild}
refresh={refresh}
setCatalogOpen={setCatalogOpen}
t={t}
/>
)
}
/**
* Render one breadcrumb title together with its subagent navigation.
* @param props - Breadcrumb title, session standard props, and catalog actions.
* @returns An ordinary-title descendant count, or a title-and-chevron sibling switcher.
* @returns A title-and-chevron sibling switcher, or nothing on a root session.
*/
export function SubagentHeaderLineage({
lineageSessionId, displayTitle, openTitle,
@@ -821,17 +849,9 @@ export function SubagentHeaderLineage({
return summary?.origin === 'subagent' ? summary.parentId : undefined
})
const shared = { useSessions, openChild, refresh, setCatalogOpen, t }
if (parentId === undefined) {
return (
<CatalogDropdown
key={lineageSessionId}
rootSessionId={lineageSessionId}
variant="count"
separator
{...shared}
/>
)
}
// Root sessions carry no breadcrumb; their descendant count lives in the
// header actions band (SubagentCatalogAction), after the task list.
if (parentId === undefined) return null
return (
<>
<CatalogDropdown
@@ -3,7 +3,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentHeaderLineage.tsx'
import { SubagentCatalogAction, SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentHeaderLineage.tsx'
import {
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
} from './SubagentReadOnlyComposer.tsx'
@@ -20,7 +20,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
export type {
SubagentCatalogInjected, SubagentHeaderLineageProps,
SubagentCatalogActionProps, SubagentCatalogInjected, SubagentHeaderLineageProps,
} from './SubagentHeaderLineage.tsx'
export type {
SubagentReadOnlyComposerProps, SubagentReadOnlyMatch,
@@ -67,6 +67,18 @@ export function apply(ctx: ClientContext): void {
inject: catalogActions,
}, SubagentHeaderLineage),
)
ctx.slots.inject(
'conversation.session.header.actions',
() => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'subagent-catalog',
// After the task list: the preset label leads, running work follows,
// and delegation navigation closes the band.
order: 30,
locale: NS,
inject: catalogActions,
}, SubagentCatalogAction),
)
ctx.slots.inject(
'conversation.composer',
() => ctx.slots.register({
@@ -60,6 +60,7 @@ async function provideSlotFaces(ctx: Context): Promise<void> {
name: 'root',
children: {
'conversation.session.header.lineage': { kind: 'single', scope: 'session' },
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
},
} as never, () => null)
@@ -113,6 +114,15 @@ describe('apply', () => {
{ method: 'setSubagentCatalogOpen', args: [sid('parent'), true] },
])
// The root-session catalog seat registers in the actions band with the
// same business face, ordered after the task list.
const actionEntry = ctx.slots.entries('conversation.session.header.actions')
.find(entry => entry.options.id === 'subagent-catalog')!
expect(actionEntry.options.order).toBe(30)
const actionFace = (actionEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent'))
actionFace.refresh(sid('parent'))
expect(face.actionCalls.at(-1)).toEqual({ method: 'refreshSubagents', args: [sid('parent')] })
const composerEntry = ctx.slots.entries('conversation.composer')
.find(entry => entry.component === SubagentReadOnlyComposer)!
const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null
@@ -7,7 +7,8 @@ import type {
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
SubagentHeaderLineage, type SubagentHeaderLineageProps,
SubagentCatalogAction, SubagentHeaderLineage,
type SubagentCatalogActionProps, type SubagentHeaderLineageProps,
} from '../src/client/SubagentHeaderLineage.tsx'
import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
import { zh } from '../src/client/locales.ts'
@@ -98,6 +99,24 @@ function hoverCatalog(trigger: HTMLElement): void {
act(() => { vi.advanceTimersByTime(150) })
}
/**
* Both header seats as the real header composes them: the breadcrumb switcher
* (child sessions, lineage slot) and the actions-band catalog entry (root
* sessions) — exactly one renders per form. The actions seat sees the same
* session the lineage renders.
*/
function HeaderCatalog(props: SubagentHeaderLineageProps) {
const actionProps = {
...props, sessionId: (props as unknown as { lineageSessionId: SessionId }).lineageSessionId,
} as unknown as SubagentCatalogActionProps
return (
<>
<SubagentHeaderLineage {...props} />
<SubagentCatalogAction {...actionProps} />
</>
)
}
describe('SubagentHeaderLineage', () => {
it('aggregates live descendant activity onto the closed trigger', () => {
const summaries: Record<SessionId, SessionSummary> = {
@@ -118,12 +137,12 @@ describe('SubagentHeaderLineage', () => {
origin: 'subagent',
},
}
const view = render(<SubagentHeaderLineage {...props(catalog(), {}, summaries)} />)
const view = render(<HeaderCatalog {...props(catalog(), {}, summaries)} />)
const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
view.rerender(<SubagentHeaderLineage {...props(catalog(), {}, {
view.rerender(<HeaderCatalog {...props(catalog(), {}, {
...summaries,
[GRANDCHILD]: { ...summaries[GRANDCHILD]!, running: false },
})} />)
@@ -134,7 +153,7 @@ describe('SubagentHeaderLineage', () => {
it('does not aggregate subagents reached through an ordinary fork', () => {
const fork = 'fork' as SessionId
const forkChild = 'fork-child' as SessionId
render(<SubagentHeaderLineage {...props(catalog(), {}, {
render(<HeaderCatalog {...props(catalog(), {}, {
[CHILD]: { ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' },
['child-2' as SessionId]: {
...summary('child-2' as SessionId, 1), parentId: PARENT, origin: 'subagent',
@@ -149,7 +168,7 @@ describe('SubagentHeaderLineage', () => {
it('renders healthy counts, stable rows, diagnostics, and catalog-addressed navigation', () => {
const input = props(catalog())
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
hoverCatalog(trigger)
@@ -182,7 +201,7 @@ describe('SubagentHeaderLineage', () => {
},
})
const translate = vi.fn(base.t)
render(<SubagentHeaderLineage {...base} t={translate} />)
render(<HeaderCatalog {...base} t={translate} />)
expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 })
expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 })
@@ -195,7 +214,7 @@ describe('SubagentHeaderLineage', () => {
activity: 'running', hasChildren: false,
}],
}))
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理/ }))
expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1)
@@ -203,7 +222,7 @@ describe('SubagentHeaderLineage', () => {
it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
const input = props(catalog())
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
@@ -232,7 +251,7 @@ describe('SubagentHeaderLineage', () => {
const advance = async (duration: number): Promise<void> => {
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
}
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
const view = render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const triggerRect = vi.spyOn(trigger, 'getBoundingClientRect')
.mockReturnValue({ bottom: 40, left: 50 } as DOMRect)
@@ -269,7 +288,7 @@ describe('SubagentHeaderLineage', () => {
})
it('repositions an open catalog after viewport resize and document scroll', () => {
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
const view = render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const bounds = vi.spyOn(trigger, 'getBoundingClientRect')
bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect)
@@ -292,12 +311,12 @@ describe('SubagentHeaderLineage', () => {
it('cancels a pending hover when the trigger becomes hidden', async () => {
vi.useFakeTimers()
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
const view = render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
fireEvent.mouseEnter(trigger.parentElement!)
await vi.advanceTimersByTimeAsync(149)
view.rerender(<SubagentHeaderLineage {...props(catalog({ entries: [] }))} />)
view.rerender(<HeaderCatalog {...props(catalog({ entries: [] }))} />)
expect(screen.queryByRole('button')).toBeNull()
await vi.advanceTimersByTimeAsync(1)
@@ -323,7 +342,7 @@ describe('SubagentHeaderLineage', () => {
},
],
}))
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
fireEvent.keyDown(trigger, { key: 'Tab' })
expect(screen.queryByRole('tree')).toBeNull()
@@ -411,7 +430,7 @@ describe('SubagentHeaderLineage', () => {
}]
})) as Record<SessionId, SessionSummary>
const input = props(catalog({ entries }), {}, summaries)
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
expect(within(trigger).getByText('9 个子代理')).toBeTruthy()
hoverCatalog(trigger)
@@ -453,7 +472,7 @@ describe('SubagentHeaderLineage', () => {
[CHILD]: childCatalog,
[GRANDCHILD]: grandchildCatalog,
})
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
@@ -481,7 +500,7 @@ describe('SubagentHeaderLineage', () => {
},
}
const deferred = props(catalog(), {}, summaries)
const view = render(<SubagentHeaderLineage {...deferred} />)
const view = render(<HeaderCatalog {...deferred} />)
hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
@@ -495,7 +514,7 @@ describe('SubagentHeaderLineage', () => {
const loading = props(catalog(), {
[CHILD]: catalog({ entries: [], state: 'loading' }),
}, summaries)
view.rerender(<SubagentHeaderLineage {...loading} />)
view.rerender(<HeaderCatalog {...loading} />)
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
const ready = props(catalog(), {
@@ -512,7 +531,7 @@ describe('SubagentHeaderLineage', () => {
],
}),
}, summaries)
view.rerender(<SubagentHeaderLineage {...ready} />)
view.rerender(<HeaderCatalog {...ready} />)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull()
expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /critic/ })).toBeTruthy()
@@ -528,7 +547,7 @@ describe('SubagentHeaderLineage', () => {
}],
}),
})
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
@@ -552,7 +571,7 @@ describe('SubagentHeaderLineage', () => {
],
}),
})
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子代理' }))
@@ -564,12 +583,12 @@ describe('SubagentHeaderLineage', () => {
})
it('hides an arrived empty catalog and exposes retry for a failed one', () => {
const absent = render(<SubagentHeaderLineage {...props(undefined)} />)
const absent = render(<HeaderCatalog {...props(undefined)} />)
expect(screen.queryByRole('button')).toBeNull()
absent.unmount()
const empty = props(catalog({ entries: [] }))
const view = render(<SubagentHeaderLineage {...empty} />)
const view = render(<HeaderCatalog {...empty} />)
expect(screen.queryByRole('button')).toBeNull()
view.unmount()
@@ -578,7 +597,7 @@ describe('SubagentHeaderLineage', () => {
state: 'error',
error: { code: 'internal', message: 'index down', details: {} },
}))
render(<SubagentHeaderLineage {...failed} />)
render(<HeaderCatalog {...failed} />)
hoverCatalog(screen.getByRole('button', { name: /0 个子代理/ }))
expect(screen.getByText('index down')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /重试/ }))
@@ -596,7 +615,7 @@ describe('SubagentHeaderLineage', () => {
},
}
const absent = props(undefined, {}, summaries)
const view = render(<SubagentHeaderLineage {...absent} />)
const view = render(<HeaderCatalog {...absent} />)
const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
expect(within(trigger).getByText('2 个子代理')).toBeTruthy()
@@ -606,7 +625,7 @@ describe('SubagentHeaderLineage', () => {
expect(absent.openChild).not.toHaveBeenCalled()
const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
view.rerender(<SubagentHeaderLineage {...staleEmpty} />)
view.rerender(<HeaderCatalog {...staleEmpty} />)
expect(screen.getByRole('button', { name: '1 个子代理,正在运行' })).toBeTruthy()
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
expect(staleEmpty.openChild).not.toHaveBeenCalled()
@@ -616,12 +635,12 @@ describe('SubagentHeaderLineage', () => {
// Selecting any session schedules a catalog refresh; a loading snapshot
// with no other evidence of children must not flash the action in.
const loading = props(catalog({ entries: [], state: 'loading' }))
const view = render(<SubagentHeaderLineage {...loading} />)
const view = render(<HeaderCatalog {...loading} />)
expect(screen.queryByRole('button')).toBeNull()
view.unmount()
const failed = props(catalog({ entries: [], state: 'error', error: null }))
render(<SubagentHeaderLineage {...failed} />)
render(<HeaderCatalog {...failed} />)
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
hoverCatalog(trigger)
expect(screen.getByText('无法加载子代理')).toBeTruthy()
@@ -633,7 +652,7 @@ describe('SubagentHeaderLineage', () => {
it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => {
const input = props(catalog())
const view = render(<SubagentHeaderLineage {...input} />)
const view = render(<HeaderCatalog {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
hoverCatalog(trigger)
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
@@ -652,25 +671,27 @@ describe('SubagentHeaderLineage', () => {
}],
}),
})
const view = render(<SubagentHeaderLineage {...populated} />)
const view = render(<HeaderCatalog {...populated} />)
hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
const empty = props(catalog({ entries: [] }))
view.rerender(<SubagentHeaderLineage {...empty} />)
view.rerender(<HeaderCatalog {...empty} />)
expect(screen.queryByRole('button')).toBeNull()
expect(empty.setCatalogOpen).toHaveBeenCalledWith(PARENT, false)
expect(empty.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
})
it('places a separator before a visible ordinary-session descendant count', () => {
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
it('shows the root-session descendant count without a breadcrumb separator', () => {
// The count moved from the breadcrumb into the header actions band, so no
// separator precedes it in either form.
const view = render(<HeaderCatalog {...props(catalog())} />)
expect(screen.getByText('/')).toBeTruthy()
expect(screen.queryByText('/')).toBeNull()
expect(screen.getByRole('button', { name: /2 个子代理/ })).toBeTruthy()
view.rerender(<SubagentHeaderLineage {...props(catalog({ entries: [] }))} />)
expect(screen.queryByText('/')).toBeNull()
view.rerender(<HeaderCatalog {...props(catalog({ entries: [] }))} />)
expect(screen.queryByRole('button', { name: /子代理/ })).toBeNull()
})
it('combines the current subagent title and chevron into the parent-catalog trigger', () => {
@@ -688,7 +709,7 @@ describe('SubagentHeaderLineage', () => {
lineageSessionId: CHILD,
displayTitle: 'worker',
}
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
const switcher = screen.getByRole('button', { name: '切换子代理:worker' })
expect(switcher.className).toContain('switcherTrigger')
@@ -724,7 +745,7 @@ describe('SubagentHeaderLineage', () => {
lineageSessionId: CHILD,
displayTitle: 'summary title',
}
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
expect(screen.getByRole('button', { name: `切换子代理:${CHILD}` })).toBeTruthy()
})
@@ -748,7 +769,7 @@ describe('SubagentHeaderLineage', () => {
displayTitle: '正在扫描项目文件',
openTitle: vi.fn(),
}
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
const switcher = screen.getByRole('button', { name: '切换子代理:worker' })
expect(switcher.className).toContain('ancestorSwitcherTrigger')
@@ -782,7 +803,7 @@ describe('SubagentHeaderLineage', () => {
displayTitle: '正在扫描项目文件',
...(openTitle === undefined ? {} : { openTitle }),
}
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
expect(input.refresh).toHaveBeenCalledWith(PARENT)
})
@@ -806,7 +827,7 @@ describe('SubagentHeaderLineage', () => {
lineageSessionId: GRANDCHILD,
displayTitle: 'indexer',
}
render(<SubagentHeaderLineage {...input} />)
render(<HeaderCatalog {...input} />)
hoverCatalog(screen.getByRole('button', { name: '切换子代理:indexer' }))
@@ -1143,6 +1143,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [
'client-ui-activity ActivityListAction id \'activity-list\'',
'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'',
'client-ui-subagent SubagentCatalogAction id \'subagent-catalog\'',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',