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
@@ -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;
}
@@ -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>
@@ -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',
@@ -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}