feat(web): state the run state on the terminal card's prompt line

The terminal card showed no run state: a running command and a settled
command that produced no output rendered the same prompt line, so whether
a command was still running had to be inferred from the absence of output.

Lead the prompt line with a StateDot in three of its states — the spinning
ring while running, red for the same exit status that renders the status
pill, green for a clean settle. That is the same indicator a tool row's
leading icon carries, so a row and its own card cannot disagree about one
command; the row/card agreement is pinned in the ui-conversation spec.
StateDot is aria-hidden, so a visually hidden text label rides beside it,
which is what the refreshed aria goldens now record.

The e2e adds what jsdom cannot compute: the dot's color resolves to the
green success token through the real theme stylesheet, and the dot precedes
the prompt label in document order.
This commit is contained in:
Chinesezjc
2026-07-28 16:41:04 +08:00
parent 540cb59585
commit f4c243c75f
18 changed files with 184 additions and 27 deletions

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-conversation/README.md
README.md: 1c6912b05e259fa1f4a7096c3a2b82f9f67f5527
README.zh.md: 157bfafd3a1157420acbb73861cd40d759228743
README.md: 39d185014c658f490f0a3672ea3d7c99f30d8df2
README.zh.md: 6b63631287bf420af0985a743f068f27b9c97873

View File

@@ -10,7 +10,7 @@ The view ring IS a slot: the conversation registration declares the `'conversati
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. The keyed `BashRow` carries the card resident below its summary row and outside that row's click target, so copying or expanding the output does not open the details panel; the render-site fallback row keeps it behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. The keyed `BashRow` carries the card resident below its summary row and outside that row's click target, so copying or expanding the output does not open the details panel; the render-site fallback row keeps it behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).

View File

@@ -10,7 +10,7 @@
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。键控的 `BashRow` 把卡片常驻在摘要行下方、且位于该行点击目标之外,因此复制或展开输出不会打开详情面板;渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。键控的 `BashRow` 把卡片常驻在摘要行下方、且位于该行点击目标之外,因此复制或展开输出不会打开详情面板;渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。

View File

@@ -28,6 +28,11 @@ afterEach(cleanup)
*/
const RAW = { normalizer: (text: string) => text }
/** The rendered card's run-state dot state, so a render site cannot silently drop it. */
function runStateOf(container: HTMLElement): string | null {
return container.querySelector('[data-terminal] [data-state]')?.getAttribute('data-state') ?? null
}
const SID = 's1' as SessionId
const ARGS = '{"command":"ls -la","description":"List files"}'
@@ -137,6 +142,9 @@ describe('chat row terminal body', () => {
fireEvent.click(view.container.querySelector('button')!)
expect(view.getByText('ls -la')).toBeTruthy()
expect(view.queryByText('复制')).toBeNull()
// The card states its own run state: a running command reads as running
// even though it has no output yet to distinguish it from an empty settle.
expect(runStateOf(view.container)).toBe('ongoing')
})
it('a non-terminal call keeps the args-JSON text body', () => {
@@ -183,6 +191,19 @@ describe('BashRow terminal card', () => {
expect(openDetails).toHaveBeenCalledTimes(1)
})
// The row's leading StateDot and the card's run-state dot describe the same
// command, so a running row whose card claimed 'done' would be a contradiction
// the reader sees on one line.
it('agrees with the summary row about the run state', () => {
const runningView = render(<BashRow {...rowProps(running())} />)
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
expect(runStateOf(runningView.container)).toBe('ongoing')
cleanup()
const settledView = render(<BashRow {...rowProps(settled())} />)
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
expect(runStateOf(settledView.container)).toBe('done')
})
it('a non-terminal bash call (background start) renders the summary row alone', () => {
const view = render(<BashRow {...rowProps(settled({
callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
@@ -246,6 +267,7 @@ describe('DetailsPanel Output section', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.getByText('ls -la')).toBeTruthy()
expect(view.queryByText('运行中…')).toBeNull()
expect(runStateOf(view.container)).toBe('ongoing')
})
it('a running non-terminal call keeps the 运行中… placeholder', () => {