fix(web-cards): restore AT status text, guard file-link keys, add read docs

Address review of the ToolRow card refactor:
- ToolRow renders a visually-hidden run-state label (row.running/failed/stopped
  locale keys) so a running/failed/interrupted row is not a colour-only signal;
  the StateDot and the sweep are both aria-hidden. Restores the stopped-state
  text assertion in diff-card.spec at the ToolRow layer.
- The file-link button stops Enter/Space from bubbling to DisclosureRow's
  whole-row keydown, which would preventDefault the key and toggle expand
  instead of opening the file (the keyboard analogue of the click stopPropagation).
- search-row passes output={model.output} unconditionally, matching the other
  three card rows (ToolRow renders the card over the output when present).
- Assert locale: 'conversation' in the search/web/diff registration tests.
- Add the read render-intent paragraph to the ui-conversation README and a
  ReadBlock atom entry + Read section to ui-primitives (both languages).
- Update the grep fixture sample lines to the post-refactor SearchRow shape.
Refresh the code-mode-round, steering, and search-card goldens for the added
status text and updated sample lines.
This commit is contained in:
Chinesezjc
2026-07-31 17:23:14 +08:00
parent bcec49606b
commit 4276a7386f
17 changed files with 97 additions and 29 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: 8003e8901d580b4aae64b5920427e789e2233acc
README.zh.md: ff734b719ccb569f303aada43a3f83fde70f5d84
README.md: 37a5c216d0e70def7c4884b710b2802e60a4c32a
README.zh.md: f364714a0dd2ed15d7696e3eb408d347cafc0900

View File

@@ -18,6 +18,8 @@ A tool call declaring the `terminal` render intent renders its command output in
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
A `read` call declaring the `read` render intent renders the returned file window inline, at both conversation render sites, through ui-primitives' `ReadBlock` — the line-numbered, syntax-highlighted content the tool projects. `contract/read-card-model.ts` is the single derivation from the snapshot's `resultView`; the read card is result-side only (a call carries no file content until `execute` returns), so a running read shows its summary alone and it yields null — the generic path — for a non-read result view or a `card` tag this client version does not know. The keyed `ReadRow` composes the shared `ToolRow`, feeding the card as ToolRow's `read` body, so it is the row's collapsed-by-default expanded card; the summary stays a path link that opens the file through the host. The render-site fallback and the details panel are read-aware too. Rows cap at `CHAT_READ_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md)).
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.

View File

@@ -16,6 +16,8 @@
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
声明 `read` 渲染意图的 `read` 调用,会在两个对话渲染点上都通过 ui-primitives 的 `ReadBlock` 内联渲染返回的文件窗口——工具投影出的带行号、语法高亮的内容。`contract/read-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;read 卡片是仅结果侧的(调用在 `execute` 返回前不携带文件内容),所以运行中的 read 只显示摘要,且对非 read 的 result view 或本客户端版本不认识的 `card` 标签返回 null,落回通用路径。键控的 `ReadRow` 组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `read` body 传入,因此它是该行默认折叠的展开卡片;摘要仍是一个经 host 打开文件的路径链接。渲染点兜底行与详情面板同样感知 read。行的上限是 `CHAT_READ_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md))。
声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。

View File

@@ -289,3 +289,15 @@
--dsl-terminal-output-max-height: 224px;
border: 1px solid var(--dsw-alias-border-l1);
}
/* Visually hidden run-state label for assistive technology: the StateDot and
the running sweep are aria-hidden / colour-only, so the text carries the
running/failed/interrupted state to a screen reader. */
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -19,7 +19,7 @@
// independent); an error row's collapsed summary is the failure's first line in
// the error color.
import { useState, type MouseEvent, type ReactNode } from 'react'
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -118,6 +118,19 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
}
/** Visually hidden run-state label: the StateDot and the CSS sweep are both
* aria-hidden / colour-only, so assistive technology needs this text to know a
* row is running, failed, or interrupted. null in the ok state (the icon and
* summary already describe a settled row). */
function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null {
switch (state) {
case 'running': return t('row.running')
case 'error': return t('row.failed')
case 'stopped': return t('row.stopped')
default: return null
}
}
export function ToolRow({
t,
variant,
@@ -151,6 +164,9 @@ export function ToolRow({
const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody
const expandable = body !== null || outputText !== null || card !== null
const open = expanded && expandable
// The run-state label AT needs: the StateDot and the running sweep are both
// aria-hidden / colour-only, so a stopped or running row is otherwise silent.
const status = stateStatus(state, t)
// An error row's collapsed summary IS the failure: the first error line in
// the error color outranks both the args summary and a terminal description.
const failureLine = state === 'error' ? errorSummary ?? null : null
@@ -164,6 +180,13 @@ export function ToolRow({
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
}
// Keep Enter/Space on the focused path link from bubbling to the row's
// keydown handler, which would preventDefault() the key and toggle expand
// instead of activating the link — the keyboard analogue of openFile's
// stopPropagation. The native button still fires its own onClick from the key.
const fileLinkKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
}
// Think reasoning is prose, not an input payload: expanded, it renders as
// plain indented text (no IN/OUT card) and the inline summary — the body's
// own first line — yields to avoid repeating itself.
@@ -176,6 +199,7 @@ export function ToolRow({
// of losing it with the icon.
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<DisclosureRow
rowClassName={css.row}
leadingClassName={css.leading}
@@ -198,6 +222,7 @@ export function ToolRow({
type="button"
className={css.fileLink}
onClick={openFile}
onKeyDown={fileLinkKeyDown}
>
{summaryText}
</button>

View File

@@ -77,6 +77,9 @@ export const zh = {
'bash.running': '运行中',
'bash.failed': '失败',
'bash.stopped': '已停止',
'row.running': '运行中',
'row.failed': '失败',
'row.stopped': '已停止',
'queue.count': '{n} 条排队消息',
'queue.edit': '编辑排队消息',
'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
@@ -171,6 +174,9 @@ export const en = {
'bash.running': 'Running',
'bash.failed': 'Failed',
'bash.stopped': 'Stopped',
'row.running': 'Running',
'row.failed': 'Failed',
'row.stopped': 'Stopped',
'queue.count': '{n} queued messages',
'queue.edit': 'Edit queued message',
'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',

View File

@@ -48,7 +48,10 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
// A settled call with no search card (errored search, nested run_code
// sub-dispatch, legacy generic result) has its text nowhere else to go;
// ToolRow's Output section carries it, and errorSummary its first line.
output={search === null ? model.output : null}
// When a card is present ToolRow renders it instead of the output, so
// passing model.output unconditionally is safe and keeps the four card
// rows symmetric.
output={model.output}
errorSummary={model.errorSummary}
search={search}
state={model.state}

View File

@@ -253,6 +253,9 @@ describe('FileMutationRow diff card', () => {
error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
// The amber StateDot is aria-hidden, so ToolRow carries the state to AT as
// visually-hidden text; without it a stopped row is a colour-only signal.
expect(view.getByText('已停止')).toBeTruthy()
})
it('renders a plain summary span when the call carries no file path', () => {
@@ -267,12 +270,12 @@ describe('FileMutationRow diff card', () => {
describe('fileMutationToolview registration', () => {
it('registers one component under both edit and write, and each disposes', () => {
const registered: { key: string; disposed: boolean }[] = []
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
const disposers: (() => void)[] = []
const ctx = {
slots: {
register: ({ key }: { name: string; key: string }) => {
const entry = { key, disposed: false }
register: ({ key, locale }: { name: string; key: string; locale?: string }) => {
const entry = { key, locale, disposed: false }
registered.push(entry)
const dispose = () => { entry.disposed = true }
disposers.push(dispose)
@@ -282,6 +285,8 @@ describe('fileMutationToolview registration', () => {
}
fileMutationToolview.apply(ctx as never)
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// The registrant's inject seam is the load-order contract the row relies on.
expect(fileMutationToolview.inject).toEqual(['slots', 'conversation'])
// Disposal removes each contribution (packages/AGENTS.md registry contract).

View File

@@ -346,16 +346,18 @@ describe('SearchRow keyed card', () => {
})
it('registers the one row component under both grep and glob keys', () => {
const registered: { key: unknown; component: unknown }[] = []
const registered: { key: unknown; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
register: (options: { name: string; key: string }, component: unknown) => {
registered.push({ key: options.key, component })
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
},
},
} as never
searchToolview.apply(ctx)
expect(registered.map(r => r.key)).toEqual(['grep', 'glob'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// One component, two keys.
expect(registered[0]!.component).toBe(SearchRow)
expect(registered[1]!.component).toBe(SearchRow)

View File

@@ -267,17 +267,19 @@ describe('DetailsPanel web Output section', () => {
describe('web toolview registration', () => {
it('registers one WebRow under both web_search and web_fetch', () => {
const registered: { key: string; component: unknown }[] = []
const registered: { key: string; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
register: (options: { name: string; key: string }, component: unknown) => {
registered.push({ key: options.key, component })
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
return () => {}
},
},
} as unknown as import('cordis').Context
webToolview.apply(ctx)
expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// One component under both keys, not two thin rows.
expect(registered[0]?.component).toBe(WebRow)
expect(registered[1]?.component).toBe(WebRow)