refactor(web-cards): route read/search/web/diff rows through collapsed ToolRow

The read, search, web, and file-mutation toolviews each hand-drew their own
summary-row chrome and kept their card (ReadBlock/SearchBlock/WebBlock/
DiffBlock) resident below the summary. Fold all four into the shared ToolRow:
ToolRow now takes read/search/web/diff card material alongside terminal, and
renders it in its collapsed-by-default expanded body, so every tool row shares
one expand interaction and starts collapsed. The GenericToolCard fallback and
the DetailsPanel Output section keep rendering the same cards (the panel at full
height); the per-row .module.css files are deleted as ToolRow owns the chrome.
This commit is contained in:
Chinesezjc
2026-07-31 16:13:10 +08:00
parent 6005fc93ca
commit 01d450f819
9 changed files with 163 additions and 798 deletions

View File

@@ -1,17 +0,0 @@
/* GenericToolCard resident cards: a read-declaring or web-declaring tool
without its own keyed row (e.g. web_fetch) grows a resident card under its
summary row. A column around the ToolRow keeps the row's own 24px height, so
the read card renders identically to the keyed ReadRow and the web card to
the web_search/web_fetch WebRow. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.read,
.web {
margin: 4px 0 4px 22px;
}

View File

@@ -1,130 +0,0 @@
/* File-mutation toolview: same geometry/tokens as ToolRow (figma
{Edit,Write} · path), plus the diff card the row stacks under its summary
line. Mirrors bash-sample.module.css, whose terminal card this replaces with
a diff card. */
/* Summary line over the diff card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.diff {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-file-mutation-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-file-mutation-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* The result text for an errored mutation, indented to the card's own column
(the diff card's inset) and in the error tone, since it stands in for the diff
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

@@ -1,104 +1,54 @@
// File-mutation toolview registrant: third-party posture over the keyed // File-mutation toolview registrant: the keyed toolview hole for the `edit`
// toolview hole (ctx.slots.register + ToolRowProps only — never imports the // and `write` tools. The row composes the shared ToolRow (chrome, running
// chat domain), registered under both `edit` and `write`. Product chrome // sweep, whole-row expand) and feeds it the applied diff as ToolRow's `diff`
// matches ToolRow (figma: {Edit,Write} · {path}). // card material, so the change renders through DiffBlock in the collapsed-by-
// // default expanded body — the same unified interaction every other card row
// A write/edit call declares the diff render intent, so this row renders the // has. The summary stays a path link (the file-tool interaction) that opens
// applied change through DiffBlock resident below its summary line — the same // through the host; an errored mutation (write/edit return no diff on
// posture BashRow gives a terminal card. The row has no expand control and is // `result.isError`) keeps the model-facing error text on ToolRow's Output
// not a details-panel target (tool rows stopped being one), so the diff body // section, its first line in the collapsed summary.
// is resident rather than expand-gated, and the card's own copy and expand
// controls are the row's only interactions. CHAT_DIFF_MAX_LINES caps the body
// against the message flow; the details panel keeps the block's full default.
// The summary stays a path link (the file-tool interaction) that opens through
// the host.
import type { Context } from 'cordis' import type { Context } from 'cordis'
import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import { IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts' import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts' import { diffCardModel } from '../contract/diff-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import { toolRowModel } from '../contract/tool-call-model.ts'
import css from './file-mutation-row.module.css' import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
function leadingFor(state: ToolRowState) { /** Full row props: the toolview runtime share plus the standard locale seat. */
switch (state) { type FileMutationRowProps = ToolRowProps & PropsLocale<'conversation'>
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return <IconEditOutline16 size={14} />
}
}
/** 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, * 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 * with the applied diff as the row's collapsed-by-default card body. The
* tool's interaction); the host's `openFile` resolves it against the session * summary is a path link (a file tool's interaction); the host's `openFile`
* cwd, so this passes the tool's own path verbatim. The card's copy and expand * resolves it against the session cwd, so this passes the tool's own path
* controls are the row's only other actions. * verbatim. An errored mutation has no diff card, so ToolRow surfaces the
* model-facing error text through its Output section and its first line in the
* collapsed summary instead.
*/ */
export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) { export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: FileMutationRowProps) {
const model = toolRowModel(toolName, block, cwd) const model = toolRowModel(toolName, block, cwd)
const diff = diffCardModel(block) const diff = diffCardModel(block)
const status = stateStatus(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
return ( return (
<div className={css.card}> <ToolRow
<div className={css.root} data-variant={model.variant} data-state={model.state}> t={t}
<span className={css.leading}>{leadingFor(model.state)}</span> variant={model.variant}
{status !== null && <span className={css.visuallyHidden}>{status}</span>} toolName={toolName}
<span className={css.title}>{model.title}</span> icon={<IconEditOutline16 size={14} />}
<span className={css.sep} aria-hidden /> title={model.title}
{filePath !== undefined ? ( summary={model.summary}
<button body={null}
type="button" output={model.output}
className={css.fileLink} errorSummary={model.errorSummary}
onClick={() => { openFile(filePath) }} diff={diff}
> state={model.state}
{model.summary} filePath={model.filePath}
</button> onOpenFile={openFile}
) : ( inspect={inspect}
<span className={css.summary}>{model.summary}</span> />
)}
</div>
{diff !== null && (
<DiffBlock {...diff.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diff} />
)}
{failure !== null && <div className={css.failure}>{failure}</div>}
</div>
) )
} }
@@ -117,7 +67,7 @@ export const fileMutationToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/ */
apply(ctx: Context): void { apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow) ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow) ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
}, },
} }

View File

@@ -1,119 +0,0 @@
/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus
the read card the row stacks under its summary line. */
/* Summary line over the read card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.read {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row
gives the same executing feedback a running command row does. The leading
read icon stays static (a read has no per-step state to animate); the sweep
is the row-level running signal. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-read-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-read-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* File path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,88 +1,49 @@
// Read toolview registrant: the keyed toolview hole for the read tool // Read toolview registrant: the keyed toolview hole for the read tool. The row
// (ctx.slots.register + ToolRowProps only — never imports the chat domain). // composes the shared ToolRow (chrome, running sweep, whole-row expand) and
// Product chrome matches ToolRow (figma: Read · {path}); the summary is the // feeds it the file's line-numbered, syntax-highlighted content as ToolRow's
// file path as an openable link, exactly as the generic read row draws it. // `read` card material, so it renders through ReadBlock in the collapsed-by-
// // default expanded body — the same unified interaction every other card row
// A read RESULT declares the read render intent, so this row renders the file's // has. The summary path is an openable host link. A running read (no result
// own line-numbered, syntax-highlighted content through ReadBlock resident // yet) and a non-read result render the summary row alone: the read intent is
// below its summary line — the same posture BashRow gives a terminal card. The
// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the
// block's own default of 16) with the block's internal expander keeping a long
// read from taking over the message flow. A running read (no result yet) and a
// non-read result both render the summary row alone. The read intent is
// result-side only, so there is no running-state read card to draw. // result-side only, so there is no running-state read card to draw.
import type { Context } from 'cordis' import type { Context } from 'cordis'
import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts' import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts' import { readCardModel } from '../contract/read-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import { toolRowModel } from '../contract/tool-call-model.ts'
import css from './read-row.module.css' import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** Leading-slot state substitution: the tool icon yields to the state dot /** Full row props: the toolview runtime share plus the standard locale seat. */
* (error = red, interrupted = amber). Running keeps the icon. */ type ReadRowProps = ToolRowProps & PropsLocale<'conversation'>
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconBrowseOutline16 size={14} />
}
}
/** 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
}
}
/** /**
* Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's * Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
* read card resident below it. The summary path is an openable host link when * read card as the row's collapsed-by-default card body. The summary path is an
* the row names a single file; the card's copy and expand controls plus that * openable host link when the row names a single file.
* link are the row's only interactions (tool rows are not details-panel
* targets).
*/ */
export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) { export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) {
// Session workspace root: the read view's path relativizes against it (a
// workspace-rooted absolute path shows its short form), which the pure
// presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const model = toolRowModel(toolName, block, cwd) const model = toolRowModel(toolName, block, cwd)
const read = readCardModel(block, cwd) const read = readCardModel(block, cwd)
const status = stateStatus(model.state)
const filePath = model.filePath
return ( return (
<div className={css.card}> <ToolRow
{/* jscpd:ignore-start — the summary-line chrome (leading, status, title, t={t}
sep, path-link/summary) is the shared ToolRow row shape every keyed variant={model.variant}
toolview draws; extracting it into one component is a separate change toolName={toolName}
tracked for all rows at once, not this read-card PR. */} icon={<IconBrowseOutline16 size={14} />}
<div className={css.root} data-variant="read" data-state={model.state}> title={model.title}
<span className={css.leading}>{leadingFor(model.state)}</span> summary={model.summary}
{status !== null && <span className={css.visuallyHidden}>{status}</span>} body={null}
<span className={css.title}>{model.title}</span> output={model.output}
<span className={css.sep} aria-hidden /> errorSummary={model.errorSummary}
{filePath !== undefined ? ( read={read}
<button state={model.state}
type="button" filePath={model.filePath}
className={css.fileLink} onOpenFile={openFile}
onClick={() => { openFile(filePath) }} inspect={inspect}
> />
{model.summary}
</button>
) : (
<span className={css.summary}>{model.summary}</span>
)}
</div>
{/* jscpd:ignore-end */}
{read !== null && (
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
)}
</div>
) )
} }
@@ -100,6 +61,6 @@ export const readToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/ */
apply(ctx: Context): void { apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow) ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow)
}, },
} }

View File

@@ -1,117 +0,0 @@
/* Search toolview: same geometry/tokens as ToolRow and BashRow (figma
Search · summary), plus the search card the row stacks resident under its
summary line. */
/* Summary line over the search card; the summary row keeps its own 24px
height, so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.search {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow / BashRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-search-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-search-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
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);
}
/* The recovery footer for a capped search: the model-facing result text (its
`Full … stored at …` locator) shown below the card in the muted tone, since
the card holds only the retained rows. Same column indent as the card body. */
.recovery {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -1,109 +1,59 @@
// Search toolview registrant: the keyed toolview hole (ctx.slots.register + // Search toolview registrant: the keyed toolview hole for the `grep` and `glob`
// ToolRowProps only — never imports the chat domain). One SearchRow component // tools. One SearchRow component registered under both, since both declare the
// registered under both `grep` and `glob`, since both tools declare the same // same `card: 'search'` render intent and render as one visual object; the
// `card: 'search'` render intent and render as one visual object; the row reads // derived model's `kind` decides the card shape (grouped matches or a path
// the `kind` discriminant off the derived model to draw grouped matches or a // list). The row composes the shared ToolRow (chrome, running sweep, whole-row
// path list. Product chrome matches ToolRow / BashRow (Search · {summary}). // expand) and feeds it the completed search as ToolRow's `search` card
// // material, so it renders through SearchBlock in the collapsed-by-default
// A search call declares its render intent result-time only, so this row's // expanded body — with a capped search's recovery footer below the card. A
// search card is resident below the summary rather than expand-gated: the row // search declares its render intent result-time only, so a running row is the
// itself has no expand control, and the card's own copy, per-file collapse, and // summary line alone; a settled call with no search card (an errored search, a
// head/tail expand are the row's only interactions. CHAT_SEARCH_MAX_LINES is // nested run_code sub-dispatch, a legacy generic result) surfaces its
// passed as `maxLines` — the chat flow's tighter cap over the block's own // model-facing text through ToolRow's Output section instead.
// default of 16 — so a large result stays bounded in the message flow.
import type { Context } from 'cordis' import type { Context } from 'cordis'
import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts' import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts' import { searchCardModel } from '../contract/search-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import { toolRowModel } from '../contract/tool-call-model.ts'
import css from './search-row.module.css' import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** Leading-slot glyph substitution: the search icon yields to the terminal /** Full row props: the toolview runtime share plus the standard locale seat. */
* state semantic (error = red, interrupted = amber). Running keeps the icon — type SearchRowProps = ToolRowProps & PropsLocale<'conversation'>
* the row sweep carries the in-flight signal. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconSearchOutline16 size={14} />
}
}
/** 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 * 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 — * completed search's card as the row's collapsed-by-default card body (a capped
* the recovery footer below the card. The summary row is not a details-panel * search's recovery footer rides below it, inside ToolRow). Registered under
* control, so the card's copy, per-file collapse, and expand controls are the * both `grep` and `glob`; the derived model's `kind` decides the card shape. A
* row's only interactions. Registered under both `grep` and `glob`; the derived * settled call with no search card surfaces its model-facing text through
* model's `kind` decides the card shape. * ToolRow's Output section, since the keyed SearchRow owns this render slot.
*/ */
export function SearchRow({ toolName, block }: ToolRowProps) { export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
const model = toolRowModel(toolName, block) const model = toolRowModel(toolName, block)
const search = searchCardModel(block) const search = searchCardModel(block)
const status = stateStatus(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.
const settled = 'kind' in block
const fallback = search === null && settled ? errorText(block) : null
return ( return (
<div className={css.card}> <ToolRow
<div className={css.root} data-variant="search" data-tool={toolName} data-state={model.state}> t={t}
<span className={css.leading}>{leadingFor(model.state)}</span> variant={model.variant}
{status !== null && <span className={css.visuallyHidden}>{status}</span>} toolName={toolName}
<span className={css.title}>{model.title}</span> icon={<IconSearchOutline16 size={14} />}
<span className={css.sep} aria-hidden /> title={model.title}
{/* The result view's replacement title outranks the args-derived // The result view's replacement title outranks the args-derived summary,
summary, matching the terminal card's description precedence. */} // matching the terminal card's description precedence.
<span className={css.summary}>{search?.title ?? model.summary}</span> summary={search?.title ?? model.summary}
</div> body={null}
{search !== null && ( // A settled call with no search card (errored search, nested run_code
<SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} /> // sub-dispatch, legacy generic result) has its text nowhere else to go;
)} // ToolRow's Output section carries it, and errorSummary its first line.
{/* A capped search drops rows from the card; its recovery locator (the output={search === null ? model.output : null}
`Full … stored at …` footer) lives only in the result text, so show it errorSummary={model.errorSummary}
below the card so the one path to the dropped rows survives. */} search={search}
{search?.recovery !== undefined && <div className={css.recovery}>{search.recovery}</div>} state={model.state}
{fallback !== null && <div className={css.failure}>{fallback}</div>} inspect={inspect}
</div> />
) )
} }
@@ -123,7 +73,7 @@ export const searchToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/ */
apply(ctx: Context): void { apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow) ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob' }, SearchRow) ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
}, },
} }

View File

@@ -1,95 +0,0 @@
/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus
the web card the row stacks under its summary line, mirroring the bash row's
resident terminal card. */
/* Summary line over the web card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.web {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-web-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-web-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,24 +1,25 @@
// Web toolview registrant: third-party posture over the keyed toolview hole // Web toolview registrant: the keyed toolview hole for the `web_search` and
// (ctx.slots.register + ToolRowProps only — never imports the chat domain). // `web_fetch` tools. Registered under BOTH, since both declare the one `web`
// Registered under BOTH web_search and web_fetch, since both declare the one // render intent and render through the one WebBlock family; the row
// `web` render intent and render through the one WebBlock family; the row // discriminates on the toolName only to pick its icon and title. The row
// discriminates on the toolName only to pick its icon and title. // composes the shared ToolRow (chrome, running sweep, whole-row expand) and
// // feeds it the completed retrieval as ToolRow's `web` card material, so it
// A web tool declares the `web` render intent at result time, so this row // renders through WebBlock in the collapsed-by-default expanded body — the same
// renders the completed retrieval through WebBlock resident below its summary, // unified interaction every other card row has. Until the call settles there is
// the same posture BashRow uses for the terminal card: no expand control on the // no web card (the tools keep a generic pending view), so a running row is the
// row itself, not a details-panel target, and the block's own expander keeps a // summary line alone.
// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is
// passed as maxSources — the chat flow's tighter cap over the block's default
// of 16). Until the call settles there is no web card (the tools keep a generic
// pending view), so a running row is the summary line alone.
import type { Context } from 'cordis' import type { Context } from 'cordis'
import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts' import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' import { webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import { toolRowModel } from '../contract/tool-call-model.ts'
import css from './web-row.module.css' import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** Full row props: the toolview runtime share plus the standard locale seat. */
type WebRowProps = ToolRowProps & PropsLocale<'conversation'>
/** web_fetch reads one URL; web_search queries. Titles are figma literals. */ /** web_fetch reads one URL; web_search queries. Titles are figma literals. */
const WEB_TITLES: Record<string, string> = { const WEB_TITLES: Record<string, string> = {
@@ -26,49 +27,30 @@ const WEB_TITLES: Record<string, string> = {
web_fetch: 'Fetch', web_fetch: 'Fetch',
} }
/** Leading icon per tool, yielding to the state semantic while failed/stopped. */
function leadingFor(toolName: string, state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
}
}
/** 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
}
}
/** /**
* Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with * Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
* the completed retrieval's web card resident below it. The summary row is not * the completed retrieval's web card as the row's collapsed-by-default card
* a details-panel control (tool rows stopped being one), so the card's own * body. The row discriminates on `toolName` only to pick its icon and title.
* links and expander are the row's only interactions.
*/ */
export function WebRow({ toolName, block }: ToolRowProps) { export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
const model = toolRowModel(toolName, block) const model = toolRowModel(toolName, block)
const web = webCardModel(block) const web = webCardModel(block)
const status = stateStatus(model.state) const icon = toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
return ( return (
<div className={css.card}> <ToolRow
<div className={css.root} data-variant="web" data-tool={toolName} data-state={model.state}> t={t}
<span className={css.leading}>{leadingFor(toolName, model.state)}</span> variant={model.variant}
{status !== null && <span className={css.visuallyHidden}>{status}</span>} toolName={toolName}
<span className={css.title}>{WEB_TITLES[toolName] ?? model.title}</span> icon={icon}
<span className={css.sep} aria-hidden /> title={WEB_TITLES[toolName] ?? model.title}
<span className={css.summary}>{model.summary}</span> summary={model.summary}
</div> body={null}
{web !== null && ( output={model.output}
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} /> errorSummary={model.errorSummary}
)} web={web}
</div> state={model.state}
inspect={inspect}
/>
) )
} }
@@ -86,7 +68,7 @@ export const webToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/ */
apply(ctx: Context): void { apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow) ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow) ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
}, },
} }