From 21cd24117726c67ac0a1a74259dac93fac581376 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 16:04:45 +0800 Subject: [PATCH] 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. --- .../src/client/contract/toolview-status.ts | 45 +++++++++++++++++++ .../client/toolviews/file-mutation-row.tsx | 36 ++------------- .../src/client/toolviews/search-row.tsx | 43 +++--------------- 3 files changed, 53 insertions(+), 71 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/contract/toolview-status.ts diff --git a/packages/client/ui-conversation/src/client/contract/toolview-status.ts b/packages/client/ui-conversation/src/client/contract/toolview-status.ts new file mode 100644 index 0000000000..043fe06522 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/toolview-status.ts @@ -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 +} diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx index 323a73e77c..c7dba58c5b 100644 --- a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -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 (
diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx index 8c0181ba78..5ea72e4a25 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -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 (