fix(web-search-card): surface result text when an errored search has no card

grep/glob return no presentResult on an error result, so an errored search had
no card and the keyed SearchRow showed only a red dot — the model-facing error
text (bad pattern, missing path, a nested run_code dispatch with no card) was
nowhere on screen. Add an error-text arm mirroring the file-mutation and read
rows. Added tests for the text arm and its name/code fallback. The unknown-kind
fallback in search-card-model is already guarded (returns null → generic path).
This commit is contained in:
Chinesezjc
2026-07-30 20:49:23 +08:00
parent b832f46eff
commit 6608ede1a0
3 changed files with 55 additions and 0 deletions

View File

@@ -93,3 +93,14 @@
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* The result text for an errored search, indented to the card's own column and
in the error tone, standing in for the search card the failure path does not
produce. */
.failure {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-state-error-primary);
}

View File

@@ -40,6 +40,27 @@ function stateStatus(state: ToolRowState): string | null {
}
}
/**
* A settled result's text, flattened from its content blocks, for the arm that
* shows a failure the search card cannot: grep/glob have no `presentResult` on
* an error result, so an errored search has no card, and the keyed row is not a
* details-panel target. Without this the failure — a bad pattern, a missing
* path, a nested run_code dispatch that returned no card — would read as a bare
* red dot with the model-facing error text nowhere on screen.
* @param block - the frozen call slice.
* @returns the result text, or null for a running call or an empty result.
*/
function errorText(block: ToolRowProps['block']): string | null {
if (!('kind' in block)) return null
const parts: string[] = []
for (const item of block.content) {
if (item.type === 'text') parts.push(item.text)
}
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
const text = parts.join('\n')
return text === '' ? null : text
}
/**
* Search row: icon + Search · {summary} in the shared ToolRow chrome, with the
* completed search's card resident below it. The summary row is not a
@@ -51,6 +72,9 @@ export function SearchRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const search = searchCardModel(block)
const status = stateStatus(model.state)
// An errored search has no card (grep/glob return no presentResult on error);
// surface its result text so the failure is more than a red dot.
const failure = search === null && model.state === 'error' ? errorText(block) : null
return (
<div className={css.card}>
<div className={css.root} data-variant="search" data-tool={toolName} data-state={model.state}>
@@ -65,6 +89,7 @@ export function SearchRow({ toolName, block }: ToolRowProps) {
{search !== null && (
<SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />
)}
{failure !== null && <div className={css.failure}>{failure}</div>}
</div>
)
}

View File

@@ -184,6 +184,25 @@ describe('SearchRow keyed card', () => {
expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error')
})
it('surfaces the result text when an errored search has no card', () => {
// grep/glob return no presentResult on error → no card; the row shows the
// model-facing error text instead of a bare red dot.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null,
content: [{ type: 'text', text: 'grep: invalid regular expression' }],
}), 'grep')} />)
expect(searchKindOf(view.container)).toBeNull()
expect(view.getByText('grep: invalid regular expression')).toBeTruthy()
})
it('falls back to the error name/code when an errored result has no text block', () => {
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null, content: [],
error: { name: 'ToolError', code: 'timeout' },
}), 'grep')} />)
expect(view.getByText('ToolError: timeout')).toBeTruthy()
})
it('shows the result view\'s replacement title instead of the args summary', () => {
const view = render(<SearchRow {...rowProps(settledGrep({
resultView: resultMatches({ title: '3 matches in 2 files' }),