refactor(ui-conversation): extract shared toolview row-status helpers

SearchRow (this PR) and FileMutationRow (landed on master) independently carry
byte-identical rowStateStatus + rowResultText helpers, which the duplication gate
flags once both are present. Extract both into contract/toolview-status.ts and
consume them from both rows, deleting the clone rather than nudging it under the
threshold.
This commit is contained in:
Chinesezjc
2026-07-31 16:04:45 +08:00
parent a7139b651c
commit 21cd241177
3 changed files with 53 additions and 71 deletions

View File

@@ -0,0 +1,45 @@
// Shared toolview-row helpers for the keyed rows whose card is resident below a
// summary (SearchRow, FileMutationRow): the visually hidden run-state label and
// the flattened settled-result text for the fallback arm a card cannot render.
// Both are pure functions of a frozen call slice — no chat-domain imports — so a
// row stays a thin ToolRowProps consumer.
import type { ToolRowProps } from './slots.ts'
import type { ToolRowState } from './tool-call-model.ts'
/**
* Visually hidden run-state label for a row's leading `StateDot` (which is
* `aria-hidden`), so assistive technology still announces the state. Returns
* null for the settled-ok state, which needs no spoken label.
* @param state - the row's run state.
* @returns the label, or null when none is needed.
*/
export function rowStateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* A settled result's text, flattened from its content blocks, for the fallback
* arm a keyed row shows when its card cannot render the result — an errored call
* (the tool emits no result view on error) or a settled call with no card view
* (a nested `run_code` sub-dispatch, a legacy generic result). The keyed row owns
* the render slot, so without this the model-facing text would have nowhere to
* go. Falls back to the error name/code when the result carries no text block.
* @param block - the frozen call slice.
* @returns the result text, or null for a running call or an empty result.
*/
export function rowResultText(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
}

View File

@@ -18,6 +18,7 @@ import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts'
import css from './file-mutation-row.module.css'
function leadingFor(state: ToolRowState) {
@@ -29,37 +30,6 @@ function leadingFor(state: ToolRowState) {
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* A settled result's text, flattened from its content blocks, for the arm that
* shows a failure the diff card cannot: write/edit return `undefined` from
* `presentResult` on `result.isError`, so an errored mutation has no diff card,
* and the keyed row is not a details-panel target. Without this the failure —
* an `old_string` that did not match, a permission denial — 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
}
/**
* File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome,
* with the applied diff resident below it. The summary is a path link (a file
@@ -70,11 +40,11 @@ function errorText(block: ToolRowProps['block']): string | null {
export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) {
const model = toolRowModel(toolName, block, cwd)
const diff = diffCardModel(block)
const status = stateStatus(model.state)
const status = rowStateStatus(model.state)
const filePath = model.filePath
// An errored mutation has no diff card (presentResult returns undefined on
// isError); surface its result text so the failure is more than a red dot.
const failure = diff === null && model.state === 'error' ? errorText(block) : null
const failure = diff === null && model.state === 'error' ? rowResultText(block) : null
return (
<div className={css.card}>
<div className={css.root} data-variant={model.variant} data-state={model.state}>

View File

@@ -17,6 +17,7 @@ import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-cli
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts'
import css from './search-row.module.css'
/** Leading-slot glyph substitution: the search icon yields to the terminal
@@ -30,40 +31,6 @@ function leadingFor(state: ToolRowState) {
}
}
/** Visually hidden status — StateDot is aria-hidden; assistive technology needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* A settled result's text, flattened from its content blocks, for the arm that
* shows a result the search card cannot. Two cases reach it: an errored search
* (grep/glob emit no `presentResult` on an error result, so an errored search
* has no card), and a settled call whose result view is not a search card at all
* — a nested `run_code` sub-dispatch (the backend computes no presentationMeta
* for it, so `resultView` is null) or a legacy generic result. In both the keyed
* SearchRow owns the render slot, so without this arm the model-facing text would
* have nowhere to go: an errored search would read as a bare red dot, and a
* successful cardless result would show only its summary with its content lost.
* @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, and — when the result was capped —
@@ -75,15 +42,15 @@ function errorText(block: ToolRowProps['block']): string | null {
export function SearchRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const search = searchCardModel(block)
const status = stateStatus(model.state)
const status = rowStateStatus(model.state)
// A settled call with no search card — an errored search (grep/glob emit no
// result view on error), a successful nested run_code sub-dispatch, or a
// legacy generic result — has its model-facing text nowhere else to go, since
// the keyed SearchRow owns this render slot. Surface it as the fallback body.
// A running call ('kind' absent) has no result to flatten; errorText returns
// null for it, so the arm stays closed until settle.
// A running call ('kind' absent) has no result to flatten; rowResultText
// returns null for it, so the arm stays closed until settle.
const settled = 'kind' in block
const fallback = search === null && settled ? errorText(block) : null
const fallback = search === null && settled ? rowResultText(block) : null
return (
<div className={css.card}>
<div className={css.root} data-variant="search" data-tool={toolName} data-state={model.state}>