)
-}
+})
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. */
+
+ )
+ : 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 && (
+
{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. */
-
- )
- : 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 && (
-