|
|
@@ -3,26 +3,33 @@
|
|
|
// separator dot + FILL-truncated summary, drawn through the shared
|
|
|
// DisclosureRow chrome with the whole row as the expand toggle (click /
|
|
|
// Enter / Space, icon→chevron hover preview). The collapsed row is always
|
|
|
-// one line; every row with body, output, terminal, or search material is
|
|
|
-// expandable; the summary stays inline while open, except Think, whose body
|
|
|
-// opens with the same first line and would repeat it.
|
|
|
+// one line; every row with body, output, or a card material (terminal, diff,
|
|
|
+// read, search, web) is expandable; the summary stays inline while open,
|
|
|
+// except Think, whose body opens with the same first line and would repeat it.
|
|
|
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
|
|
|
-// text input/output, the run_code program through CodeBlock, a terminal
|
|
|
-// card's command output through TerminalBlock, or a search card's grouped
|
|
|
-// matches / path list through SearchBlock (capped at CHAT_SEARCH_MAX_LINES) —
|
|
|
-// lives in a max-height scroll container so a long payload scrolls internally
|
|
|
-// instead of taking over the message flow; Think's prose is the exception and
|
|
|
-// flows uncapped like message text. Expand state is component-local view state.
|
|
|
-// File-tool summaries are path links that open through the host (stopPropagation
|
|
|
-// keeps the two gestures independent); an error row's collapsed summary is the
|
|
|
-// failure's first line in the error color.
|
|
|
+// text input/output, the run_code program through CodeBlock, or a card
|
|
|
+// primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a
|
|
|
+// call that declared that render intent — lives in a max-height scroll
|
|
|
+// container so a long payload scrolls internally instead of taking over the
|
|
|
+// message flow; Think's prose is the exception and flows uncapped like message
|
|
|
+// text. Every card kind starts collapsed, so a run of tool calls stays
|
|
|
+// scannable; the details panel is the single-call full-height reading surface.
|
|
|
+// Expand state is component-local view state. File-tool summaries are path
|
|
|
+// links that open through the host (stopPropagation keeps the two gestures
|
|
|
+// 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, SearchBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
+import {
|
|
|
+ CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
|
|
|
+} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
+import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
-import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
|
|
|
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
|
|
|
+import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts'
|
|
|
+import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
|
|
|
+import { CHAT_WEB_MAX_SOURCES } from '../contract/web-card-model.ts'
|
|
|
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
|
|
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
|
|
import { DisclosureRow } from './DisclosureRow.tsx'
|
|
|
@@ -47,24 +54,34 @@ export interface ToolRowProps {
|
|
|
/**
|
|
|
* Terminal-card material for a call whose render intent is a terminal card
|
|
|
* (derived by `terminalCardModel`); it replaces the text sections when
|
|
|
- * present. A row with no body, no output, and no terminal material is not
|
|
|
- * expandable.
|
|
|
+ * present. A call carries at most one card kind, so the card props below are
|
|
|
+ * mutually exclusive.
|
|
|
*/
|
|
|
terminal?: TerminalCardModel | null | undefined
|
|
|
+ /**
|
|
|
+ * Diff-card material for a call whose render intent is a diff card (derived by
|
|
|
+ * `diffCardModel`); it replaces the text body when present, the same way
|
|
|
+ * `terminal` does.
|
|
|
+ */
|
|
|
+ diff?: DiffCardModel | null | undefined
|
|
|
+ /**
|
|
|
+ * Read-card material for a call whose render intent is a read card (derived by
|
|
|
+ * `readCardModel`); it replaces the text body with the file's line-numbered,
|
|
|
+ * syntax-highlighted window when present.
|
|
|
+ */
|
|
|
+ read?: ReadCardModel | null | undefined
|
|
|
/**
|
|
|
* Search-card material for a call whose render intent is a search card
|
|
|
- * (derived by `searchCardModel`); it replaces the text body when present.
|
|
|
- * Null or absent leaves the text body. A call carries at most one card kind,
|
|
|
- * so `terminal`, `search`, and `diff` are never both present on the same row.
|
|
|
+ * (derived by `searchCardModel`); it replaces the text body with grouped
|
|
|
+ * matches or a path list when present.
|
|
|
*/
|
|
|
search?: SearchCardModel | null | undefined
|
|
|
/**
|
|
|
- * Diff-card material for a call whose render intent is a diff card (derived by
|
|
|
- * `diffCardModel`); it replaces the text body when present, the same way
|
|
|
- * `terminal` does. A call carries at most one card intent, so the cards are
|
|
|
- * never both set.
|
|
|
+ * Web-card material for a call whose render intent is a web card (derived by
|
|
|
+ * `webCardModel`); it replaces the text body with the retrieval's citation
|
|
|
+ * list or fetched-source card when present.
|
|
|
*/
|
|
|
- diff?: DiffCardModel | null | undefined
|
|
|
+ web?: WebBlockProps | null | undefined
|
|
|
state: ToolRowState
|
|
|
/**
|
|
|
* Filesystem path from tool args; when set with onOpenFile, the summary
|
|
|
@@ -101,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,
|
|
|
@@ -112,8 +142,10 @@ export function ToolRow({
|
|
|
output,
|
|
|
errorSummary,
|
|
|
terminal,
|
|
|
- search,
|
|
|
diff,
|
|
|
+ read,
|
|
|
+ search,
|
|
|
+ web,
|
|
|
state,
|
|
|
filePath,
|
|
|
onOpenFile,
|
|
|
@@ -121,13 +153,20 @@ export function ToolRow({
|
|
|
}: ToolRowProps) {
|
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
const terminalBody = terminal ?? null
|
|
|
- const searchBody = search ?? null
|
|
|
const diffBody = diff ?? null
|
|
|
+ const readBody = read ?? null
|
|
|
+ const searchBody = search ?? null
|
|
|
+ const webBody = web ?? null
|
|
|
const outputText = output ?? null
|
|
|
- // A search or diff card replaces the text body; a call carries at most one
|
|
|
- // card kind, so terminal, search, and diff are never both present on a row.
|
|
|
- const expandable = body !== null || outputText !== null || terminalBody !== null || searchBody !== null || diffBody !== null
|
|
|
+ // A card replaces the text body; a call carries at most one card kind, so the
|
|
|
+ // card props are mutually exclusive. Any of them, or a text body/output,
|
|
|
+ // makes the row expandable.
|
|
|
+ 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
|
|
|
@@ -141,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.
|
|
|
@@ -153,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}
|
|
|
@@ -175,6 +222,7 @@ export function ToolRow({
|
|
|
type="button"
|
|
|
className={css.fileLink}
|
|
|
onClick={openFile}
|
|
|
+ onKeyDown={fileLinkKeyDown}
|
|
|
>
|
|
|
{summaryText}
|
|
|
</button>
|
|
|
@@ -198,51 +246,55 @@ export function ToolRow({
|
|
|
className={css.terminalBody}
|
|
|
/>
|
|
|
)
|
|
|
- : searchBody !== null
|
|
|
- ? (
|
|
|
- <>
|
|
|
- <SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
|
|
|
- {/* A capped search's recovery locator lives only in the result
|
|
|
- text; show it below the card so the dropped rows survive. */}
|
|
|
- {searchBody.recovery !== undefined && (
|
|
|
- <div className={css.searchRecovery}>{searchBody.recovery}</div>
|
|
|
- )}
|
|
|
- </>
|
|
|
- )
|
|
|
- : diffBody !== null
|
|
|
- ? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
|
|
|
- : isThink
|
|
|
- ? <div className={css.thinkBody}>{body}</div>
|
|
|
- : (
|
|
|
+ : diffBody !== null
|
|
|
+ ? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
|
|
|
+ : readBody !== null
|
|
|
+ ? <ReadBlock {...readBody} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
|
|
|
+ : searchBody !== null
|
|
|
+ ? (
|
|
|
<>
|
|
|
- {variant === 'code' && body !== null && (
|
|
|
- <div className={css.bodyScroll}>
|
|
|
- <CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
|
|
- </div>
|
|
|
+ <SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
|
|
|
+ {/* A capped search's recovery locator lives only in the result
|
|
|
+ text; show it below the card so the dropped rows survive. */}
|
|
|
+ {searchBody.recovery !== undefined && (
|
|
|
+ <div className={css.searchRecovery}>{searchBody.recovery}</div>
|
|
|
)}
|
|
|
- {(cardBody !== null || outputText !== null) && (
|
|
|
- <div className={css.ioCard}>
|
|
|
- {cardBody !== null && (
|
|
|
- <div className={css.ioSection}>
|
|
|
- <span className={css.ioLabel}>IN</span>
|
|
|
- <span className={css.ioText}>{cardBody}</span>
|
|
|
+ </>
|
|
|
+ )
|
|
|
+ : webBody !== null
|
|
|
+ ? <WebBlock {...webBody} maxSources={CHAT_WEB_MAX_SOURCES} className={css.webBody} />
|
|
|
+ : isThink
|
|
|
+ ? <div className={css.thinkBody}>{body}</div>
|
|
|
+ : (
|
|
|
+ <>
|
|
|
+ {variant === 'code' && body !== null && (
|
|
|
+ <div className={css.bodyScroll}>
|
|
|
+ <CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
|
|
</div>
|
|
|
)}
|
|
|
- {cardBody !== null && outputText !== null && (
|
|
|
- <span className={css.ioDivider} aria-hidden />
|
|
|
- )}
|
|
|
- {outputText !== null && (
|
|
|
- <div className={css.ioSection}>
|
|
|
- <span className={css.ioLabel}>OUT</span>
|
|
|
- <span className={css.ioText} data-error={state === 'error' || undefined}>
|
|
|
- {outputText}
|
|
|
- </span>
|
|
|
+ {(cardBody !== null || outputText !== null) && (
|
|
|
+ <div className={css.ioCard}>
|
|
|
+ {cardBody !== null && (
|
|
|
+ <div className={css.ioSection}>
|
|
|
+ <span className={css.ioLabel}>IN</span>
|
|
|
+ <span className={css.ioText}>{cardBody}</span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {cardBody !== null && outputText !== null && (
|
|
|
+ <span className={css.ioDivider} aria-hidden />
|
|
|
+ )}
|
|
|
+ {outputText !== null && (
|
|
|
+ <div className={css.ioSection}>
|
|
|
+ <span className={css.ioLabel}>OUT</span>
|
|
|
+ <span className={css.ioText} data-error={state === 'error' || undefined}>
|
|
|
+ {outputText}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
)}
|
|
|
- </div>
|
|
|
+ </>
|
|
|
)}
|
|
|
- </>
|
|
|
- )}
|
|
|
{inspect !== undefined && (
|
|
|
<button
|
|
|
type="button"
|