diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index 0374637e17..0b0749881b 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' import { DisclosureRow, IconApiOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives' import a11yCss from './accessibility.module.css' @@ -6,6 +6,8 @@ import css from './GenericCommandCard.module.css' type CommandRowState = 'running' | 'ok' | 'error' +const COMMAND_ICON = + /** Node state → row state semantic (running while unsettled; outcome kind after). */ function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): CommandRowState { if (outcome === null) return 'running' @@ -19,7 +21,12 @@ export interface GenericCommandCardProps extends CommandRowOwnerProps { runningSummary?: string | undefined } -export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) { +/** + * Render a command summary and its lazily mounted multiline output. + * @param props - command, locale, and optional running label. + * @returns the command disclosure. + */ +export const GenericCommandCard = memo(function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) { const [expanded, setExpanded] = useState(false) const text = node.outcome?.text const summary = node.outcome === null @@ -32,6 +39,18 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa const running = state === 'running' const body = text !== undefined && text.includes('\n') ? text : null const open = expanded && body !== null + const toggle = useCallback(() => { setExpanded(value => !value) }, []) + const collapsedContent = useMemo(() => ( + <> + + + {summary} + + + ), [running, state, summary]) + const content = useMemo(() => open + ?
{body}
+ : undefined, [body, open, state]) return (
{state === 'running' && {t('row.running')}} @@ -41,25 +60,18 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa leadingClassName={css.leading} titleClassName={css.title} chevronClassName={css.chevron} - icon={} + icon={COMMAND_ICON} title={title} running={running} open={open} expandable={body !== null} expandOnRowClick keepContentWhenOpen - onToggle={() => { setExpanded(value => !value) }} - collapsedContent={( - <> - - - {summary} - - - )} + onToggle={toggle} + collapsedContent={collapsedContent} > -
{body}
+ {content}
) -} +}) diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 874beb9252..62b91189fc 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { memo, useCallback, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock, @@ -101,7 +101,12 @@ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): strin } } -export function ToolRow({ +/** + * Render one localized tool summary and lazily mounted result card. + * @param props - tool state, summary, output, and navigation callbacks. + * @returns the tool disclosure. + */ +export const ToolRow = memo(function ToolRow({ t, variant, toolName, @@ -134,9 +139,9 @@ export function ToolRow({ const readLabels = useMemo(() => readBlockLabels(t), [t]) const searchLabels = useMemo(() => searchBlockLabels(t), [t]) const webLabels = useMemo(() => webBlockLabels(t), [t]) - const terminalBody = terminal === undefined || terminal === null + const terminalBody = useMemo(() => terminal === undefined || terminal === null ? null - : localizeTerminalCardModel(terminal, t) + : localizeTerminalCardModel(terminal, t), [terminal, t]) const diffBody = diff ?? null const readBody = read ?? null const imageBody = image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined @@ -173,26 +178,159 @@ export function ToolRow({ }, [diffBody]) const settledWithCue = state === 'error' || state === 'stopped' const suffix = settledWithCue ? null : summarySuffix ?? diffStat - const toggleExpand = () => { + const toggleExpand = useCallback(() => { setExpanded(v => !v) - } - const openFile = filePath !== undefined && onOpenFile !== undefined && !settledWithCue + }, []) + const openFile = useMemo(() => filePath !== undefined && onOpenFile !== undefined && !settledWithCue ? (event: MouseEvent) => { event.stopPropagation() if (filePathLine === undefined) onOpenFile(filePath) else onOpenFile(filePath, { line: filePathLine }) } - : undefined + : undefined, [filePath, filePathLine, onOpenFile, settledWithCue]) // 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) => { + const fileLinkKeyDown = useCallback((event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() - } + }, []) // The code variant's program renders through CodeBlock (shiki), so only its // output joins the IN/OUT card; every other variant's input does too. const cardBody = variant === 'code' ? null : bodyText + const collapsedContent = useMemo(() => summaryText !== '' && ( + /* An empty summary drops the separator with it (a row that is only + its title shows no trailing dot). */ + <> + + {openFile !== undefined ? ( + + ) : ( + + {summaryText} + + )} + {suffix !== null && ( + {suffix} + )} + + ), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText]) + const expandedContent = useMemo(() => open ? ( +
+ {askQuestionBody !== null + ? + : terminalBody !== null + ? ( + + ) + : diffBody !== null + ? + : readBody !== null + ? + : imageBody !== null + ? ( + /* Label, gallery, then the result's OWN envelope text. The text + comes from the image card model (which reads the result's text + block), never from the row's flattened output: an image read's + content is [text envelope, image block] and flattening + JSON.stringifies the image block, printing the raw attachment + object under the picture. It is not redundant either — the + attachment slot can render nothing, and then this line is the + only evidence an image was returned. */ +
+
{imageBody.label}
+ {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { + images: imageBody.images, + loadImage, + align: 'start', + })} +
{imageBody.text}
+
+ ) + : searchBody !== null + ? ( + <> + + {/* 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 && ( +
{searchBody.recovery}
+ )} + + ) + : webBody !== null + ? + : detailsBody !== null + ? + : ( + <> + {variant === 'code' && bodyText !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ {t('row.input')} + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ {t('row.output')} + + {outputText} + +
+ )} +
+ )} + + )} + {inspect !== undefined && ( + + )} +
+ ) : undefined, [ + open, detailsBody, askQuestionBody, terminalBody, terminalLabels, diffBody, diffLabels, readBody, readLabels, + imageBody, renderSlot, loadImage, searchBody, searchLabels, webBody, webLabels, inspect, t, onOpenFile, + variant, bodyText, cardBody, outputText, state, + ]) return (
{status !== null && {status}} @@ -209,135 +347,10 @@ export function ToolRow({ expandOnRowClick keepContentWhenOpen onToggle={toggleExpand} - collapsedContent={summaryText !== '' && ( - /* An empty summary drops the separator with it (a row that is only - its title shows no trailing dot). */ - <> - - {openFile !== undefined ? ( - - ) : ( - - {summaryText} - - )} - {suffix !== null && ( - {suffix} - )} - - )} + collapsedContent={collapsedContent} > -
- {askQuestionBody !== null - ? - : terminalBody !== null - ? ( - - ) - : diffBody !== null - ? - : readBody !== null - ? - : imageBody !== null - ? ( - /* Label, gallery, then the result's OWN envelope text. The text - comes from the image card model (which reads the result's text - block), never from the row's flattened output: an image read's - content is [text envelope, image block] and flattening - JSON.stringifies the image block, printing the raw attachment - object under the picture. It is not redundant either — the - attachment slot can render nothing, and then this line is the - only evidence an image was returned. */ -
-
{imageBody.label}
- {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { - images: imageBody.images, - loadImage, - align: 'start', - })} -
{imageBody.text}
-
- ) - : searchBody !== null - ? ( - <> - - {/* 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 && ( -
{searchBody.recovery}
- )} - - ) - : webBody !== null - ? - : detailsBody !== null - ? - : ( - <> - {variant === 'code' && bodyText !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- {t('row.input')} - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- {t('row.output')} - - {outputText} - -
- )} -
- )} - - )} - {inspect !== undefined && ( - - )} -
+ {expandedContent}
) -} +}) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 37b1d355d0..ebbe18e532 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type KeyboardEvent } from 'react' +import { memo, useCallback, useMemo, useState, type KeyboardEvent } from 'react' import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { @@ -21,6 +21,8 @@ import css from './bash-sample.module.css' type BashRowProps = ToolCallViewProps & PropsLocale<'conversation'> +const BASH_ICON = + /** Visually hidden status for the color-only running sweep and error tone. */ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { switch (state) { @@ -31,14 +33,19 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { } } -/** Renders expandable Bash output with an accessible lifecycle label. */ -export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { - const model = toolRowModel(toolName, block) +/** + * Render expandable Bash output with an accessible lifecycle label. + * @param props - tool call, Session sources, locale, and inspection callback. + * @returns the Bash output row. + */ +export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { + const model = useMemo(() => toolRowModel(toolName, block), [toolName, block]) // An omitted shell workdir is the session workspace; relative values resolve // against it before reaching the terminal primitive. const cwd = useSessions(list => list.byId[sessionId]?.cwd) - const terminalModel = terminalCardModel(block, cwd) - const terminal = terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t) + const terminalModel = useMemo(() => terminalCardModel(block, cwd), [block, cwd]) + const terminal = useMemo(() => terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t), [terminalModel, t]) + const labels = useMemo(() => terminalBlockLabels(t), [t]) // A failing exit status is the terminal card's own error signal (the call // itself settles isError:false), surfaced through the row's error summary. const state = model.state === 'ok' && terminalModel !== null && terminalFailed(terminalModel) @@ -64,25 +71,24 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: ? model.errorSummary ?? normalSummary : state === 'stopped' ? t('bash.stopped') : null const running = state === 'running' - const toggleExpand = () => { + const toggleExpand = useCallback(() => { setExpanded(v => !v) - } - const toggleFromKeyboard = (event: KeyboardEvent) => { + }, []) + const toggleFromKeyboard = useCallback((event: KeyboardEvent) => { if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return event.preventDefault() toggleExpand() - } - const businessIcon = + }, [expandable, toggleExpand]) const leading = open ? : expandable ? ( <> - {businessIcon} + {BASH_ICON} ) - : businessIcon + : BASH_ICON return (
) @@ -151,7 +157,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: )}
) -} +}) /** Registers the standalone Bash conversation-row sample. */ export const bashToolviewSample = {