perf(ui-chat): memoize tool and command rows

This commit is contained in:
imccyu committed 2026-09-20 22:45:29 +08:00
1 parent b6f99fad55
commit bcd14d776c
3 files changed
+198 -167

No files matched your search

@@ -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 = <IconApiOutlineRegular size={14} />
/** 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(() => (
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-error={state === 'error' || undefined}>
<TextShimmer active={running}>{summary}</TextShimmer>
</span>
</>
), [running, state, summary])
const content = useMemo(() => open
? <pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
: undefined, [body, open, state])
return (
<div className={css.root} data-variant="others" data-state={state}>
{state === 'running' && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
@@ -41,25 +60,18 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={<IconApiOutlineRegular size={14} />}
icon={COMMAND_ICON}
title={title}
running={running}
open={open}
expandable={body !== null}
expandOnRowClick
keepContentWhenOpen
onToggle={() => { setExpanded(value => !value) }}
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-error={state === 'error' || undefined}>
<TextShimmer active={running}>{summary}</TextShimmer>
</span>
</>
)}
onToggle={toggle}
collapsedContent={collapsedContent}
>
<pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
{content}
</DisclosureRow>
</div>
)
}
})
@@ -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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
const fileLinkKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
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). */
<>
<span className={css.sep} aria-hidden />
{openFile !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={openFile}
onKeyDown={fileLinkKeyDown}
>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</button>
) : (
<span
className={clsx(
css.summary,
state === 'error' && css.errorSummary,
state === 'stopped' && css.stoppedSummary,
)}
>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</span>
)}
{suffix !== null && (
<TextShimmer className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)} active={running}>{suffix}</TextShimmer>
)}
</>
), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText])
const expandedContent = useMemo(() => open ? (
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
{askQuestionBody !== null
? <AskQuestionCard card={askQuestionBody} />
: terminalBody !== null
? (
<TerminalBlock
{...terminalBody.card}
maxLines={Infinity}
labels={terminalLabels}
className={css.terminalBody}
/>
)
: diffBody !== null
? <DiffBlock {...diffBody.card} labels={diffLabels} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
: readBody !== null
? <ReadBlock {...readBody} labels={readLabels} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
: 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. */
<div className={css.imageBody}>
<div className={css.imageLabel}>{imageBody.label}</div>
{renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', {
images: imageBody.images,
loadImage,
align: 'start',
})}
<div className={css.imageMeta}>{imageBody.text}</div>
</div>
)
: searchBody !== null
? (
<>
<SearchBlock
{...searchBody.card}
labels={searchLabels}
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>
)}
</>
)
: webBody !== null
? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
: detailsBody !== null
? <ToolDetails model={detailsBody} hasInspect={inspect !== undefined} t={t} onOpenFile={onOpenFile} />
: (
<>
{variant === 'code' && bodyText !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>{t('row.input')}</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}>{t('row.output')}</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"
className={css.inspectButton}
onClick={inspect}
>
<IconInspectOutlineRegular />
{t('row.inspect')}
</button>
)}
</div>
) : 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 (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
@@ -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). */
<>
<span className={css.sep} aria-hidden />
{openFile !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={openFile}
onKeyDown={fileLinkKeyDown}
>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</button>
) : (
<span
className={clsx(
css.summary,
state === 'error' && css.errorSummary,
state === 'stopped' && css.stoppedSummary,
)}
>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</span>
)}
{suffix !== null && (
<TextShimmer className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)} active={running}>{suffix}</TextShimmer>
)}
</>
)}
collapsedContent={collapsedContent}
>
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
{askQuestionBody !== null
? <AskQuestionCard card={askQuestionBody} />
: terminalBody !== null
? (
<TerminalBlock
{...terminalBody.card}
maxLines={Infinity}
labels={terminalLabels}
className={css.terminalBody}
/>
)
: diffBody !== null
? <DiffBlock {...diffBody.card} labels={diffLabels} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
: readBody !== null
? <ReadBlock {...readBody} labels={readLabels} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
: 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. */
<div className={css.imageBody}>
<div className={css.imageLabel}>{imageBody.label}</div>
{renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', {
images: imageBody.images,
loadImage,
align: 'start',
})}
<div className={css.imageMeta}>{imageBody.text}</div>
</div>
)
: searchBody !== null
? (
<>
<SearchBlock
{...searchBody.card}
labels={searchLabels}
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>
)}
</>
)
: webBody !== null
? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
: detailsBody !== null
? <ToolDetails model={detailsBody} hasInspect={inspect !== undefined} t={t} onOpenFile={onOpenFile} />
: (
<>
{variant === 'code' && bodyText !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>{t('row.input')}</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}>{t('row.output')}</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"
className={css.inspectButton}
onClick={inspect}
>
<IconInspectOutlineRegular />
{t('row.inspect')}
</button>
)}
</div>
{expandedContent}
</DisclosureRow>
</div>
)
}
})
@@ -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 = <IconApiOutlineRegular size={14} />
/** 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<HTMLDivElement>) => {
}, [])
const toggleFromKeyboard = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
const businessIcon = <IconApiOutlineRegular size={14} />
}, [expandable, toggleExpand])
const leading = open
? <IconChevronUpOutlineRegular className={css.chevron} />
: expandable
? (
<>
<span className={css.iconIdle}>{businessIcon}</span>
<span className={css.iconIdle}>{BASH_ICON}</span>
<IconChevronDownOutlineRegular className={clsx(css.chevron, css.chevronHover)} />
</>
)
: businessIcon
: BASH_ICON
return (
<div className={css.card}>
<div
@@ -116,7 +122,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
<TerminalBlock
{...terminal.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
labels={labels}
className={css.terminal}
/>
)
@@ -151,7 +157,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
)}
</div>
)
}
})
/** Registers the standalone Bash conversation-row sample. */
export const bashToolviewSample = {