mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(web-shell): show tool arguments and result in expanded cards Cards outside the built-in detail renderers expanded to the same 80-character result summary shown while collapsed, so the chevron promised details it never delivered. Render the call's arguments and result instead, pretty-printing a JSON object or array and falling back to plain text, with the trajectory inspector's 4000/40 000 caps and a Show more action. Fixes #15280 * fix(web-shell): address tool card details review findings * test(web-shell): cover tool card review findings * fix(web-shell): preserve tool card JSON payload fidelity * fix(web-shell): sanitize tool payloads and cancelled results * fix(web-shell): bound tool payloads by render mode * fix(web-shell): memoize tool card payload formatting * fix(web-shell): size tool card JSON by render cap and scope its rules * fix(web-templates): raise document export budget for tool cards * fix(web-shell): address round 3 tool card review suggestions * fix(web-shell): gate Show more on escaped payload length * fix(web-shell): stop rendering the cancelled input dump as a result A cancelled call that recorded no output carries the daemon's redacted input dump in rawOutput.text, written there as the same string as reason. cancelledOutputText accepted it as output, so the expanded card showed the call's own arguments twice, once as Result. Skip a text candidate that equals reason while keeping output/stdout. * test(web-shell): pin the arguments escape guard and the inline cap Both behaviours were unpinned: replacing the arguments-side sanitized-length comparison with the raw length, or lowering MAX_TOOL_PAYLOAD_LENGTH to 400, left the suites green. JSON.stringify escapes C0 bytes but not the bidi controls, so an object argument carrying U+202E is the reachable shape where escaping moves the payload past the cap. --------- Co-authored-by: Shaojin Wen <[email protected]>
2356 lines
75 KiB
TypeScript
2356 lines
75 KiB
TypeScript
import {
|
|
Fragment,
|
|
memo,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { formatSubscriptionQuotaDisplay } from '@qwen-code/qwen-code-core/utils/subscription-quota.js';
|
|
import type {
|
|
ACPToolCall,
|
|
PermissionRequest,
|
|
TodoItem,
|
|
} from '../../adapters/types';
|
|
import type { SessionContentGenerator } from './AssistantMessage';
|
|
import {
|
|
hasActiveAgents,
|
|
isBackgroundSubAgentToolCall,
|
|
isSubAgentToolCall,
|
|
} from '../../adapters/toolClassification';
|
|
// Circular import with SubAgentPanel (its SubToolLine renders ToolLine
|
|
// from this module). Safe only while both modules dereference each
|
|
// other's exports at render time — never in top-level code.
|
|
import { SubAgentPanel } from './tools/SubAgentPanel';
|
|
import { ParallelAgentsGroup } from './tools/ParallelAgentsGroup';
|
|
import { DiffView } from './tools/DiffView';
|
|
import { buildUnifiedDiff } from '../../utils/unifiedDiff';
|
|
import { ShellToolOutput } from './tools/ShellToolOutput';
|
|
import { ToolDetails } from './tools/ToolDetailsContext';
|
|
import {
|
|
extractTodosFromToolCall,
|
|
isTodoWriteToolName,
|
|
} from '../../utils/todos';
|
|
import { useSharedNow } from '../../hooks/useSharedNow';
|
|
import { useSubagentDetails } from '../../subagentDetailsContext';
|
|
import { useMonitorDetails } from '../../monitorDetailsContext';
|
|
import { useWorkflowDetails } from '../../workflowDetailsContext';
|
|
import { findWorkflowTaskForTool } from '../../utils/workflowTasks';
|
|
import { TodoEventSummary, TodoFullList } from './TodoView';
|
|
import { WorkflowExecutionView } from './WorkflowExecutionView';
|
|
import { Markdown } from './Markdown';
|
|
import { ThinkingDoneIcon, ThinkingTranslateButton } from './AssistantMessage';
|
|
import {
|
|
formatDurationMs,
|
|
formatElapsed,
|
|
localizeToolDisplayName,
|
|
StatusIcon,
|
|
truncateText,
|
|
} from './tools/toolDisplay';
|
|
import {
|
|
extractText,
|
|
hasRecordedOutput,
|
|
EXPANDED_TOOL_PAYLOAD_LENGTH,
|
|
formatJsonPayload,
|
|
formatTokenCount,
|
|
MAX_TOOL_PAYLOAD_LENGTH,
|
|
sanitizeControlChars,
|
|
stringifyValue,
|
|
getAgentCancellationReason,
|
|
getAgentCurrentToolHint,
|
|
getAgentDescription,
|
|
getSubagentDetailsUnavailableReason,
|
|
getAgentDisplayStatus,
|
|
getAgentType,
|
|
getTaskExecutionRecord,
|
|
getShellToolSemanticDescription,
|
|
getToolDescription,
|
|
getAdvisorDisplayText,
|
|
getToolSummaryDescription,
|
|
getToolResultSummary,
|
|
isAdvisorToolName,
|
|
isAskUserQuestionToolName,
|
|
isActiveToolStatus,
|
|
isSkillToolName,
|
|
isShellToolName,
|
|
localizeAgentTypeName,
|
|
toolContainsCallId,
|
|
isWorkflowToolName,
|
|
} from './toolFormatting';
|
|
import { useI18n } from '../../i18n';
|
|
import {
|
|
useTranscriptDocumentExpanded,
|
|
useTranscriptRenderMode,
|
|
type TranscriptRenderMode,
|
|
} from '../../transcriptRenderMode';
|
|
import { TodoTimelineContext } from '../../WebShellContexts';
|
|
import {
|
|
type ToolHeaderExtraRenderInfo,
|
|
type ToolHeaderKind,
|
|
useWebShellCustomization,
|
|
} from '../../customization';
|
|
import flashStyles from '../MessageLocateFlash.module.css';
|
|
import styles from './tools/ToolChrome.module.css';
|
|
import { getMcpAppDisplay, McpApp, GeneratedUiFallback } from './McpApp';
|
|
import { useMcpAppAdmission } from '../../mcpAppHostContext';
|
|
import type { TurnOutputOpenRequest } from '../artifacts/TurnOutputs';
|
|
import { ToolFilePreviewButton } from './ToolFilePreviewButton';
|
|
import { ManagedToolResultSummary } from '../managed/ManagedToolResultSummary';
|
|
|
|
interface ToolGroupProps {
|
|
tools: ACPToolCall[];
|
|
compactSummary?: boolean;
|
|
/**
|
|
* Thinking aggregated with the tools in this summary (compact mode), in
|
|
* the original order. Streaming entries drive the "Thinking…" summary;
|
|
* each entry renders as a click-to-expand row.
|
|
*/
|
|
thoughts?: Array<{
|
|
content: string;
|
|
isStreaming?: boolean;
|
|
beforeToolCallId?: string;
|
|
}>;
|
|
pendingApproval?: PermissionRequest | null;
|
|
workspaceCwd?: string;
|
|
onTurnOutputOpen?: (request: TurnOutputOpenRequest) => void;
|
|
onToolResultOpen?: (itemId: string) => void;
|
|
isLocateFlashing?: boolean;
|
|
/** Powers the translate action on completed thinking rows (zh-CN). */
|
|
generateContent?: SessionContentGenerator;
|
|
}
|
|
|
|
function openMonitorDetailsOnce(
|
|
requestRef: { current: object | null },
|
|
open: () => Promise<boolean>,
|
|
fallback: () => void,
|
|
): void {
|
|
if (requestRef.current) return;
|
|
const request = {};
|
|
requestRef.current = request;
|
|
void open()
|
|
.then(
|
|
(opened) => {
|
|
if (requestRef.current === request && !opened) fallback();
|
|
},
|
|
() => {
|
|
if (requestRef.current === request) fallback();
|
|
},
|
|
)
|
|
.finally(() => {
|
|
if (requestRef.current === request) requestRef.current = null;
|
|
});
|
|
}
|
|
|
|
// Tools whose expanded row renders a kind-specific detail view (shell output /
|
|
// diff / file content / Q&A). Must stay in sync with the renderers in
|
|
// ToolLine's lineDetail block below. Tools NOT in this set render the call's
|
|
// own arguments and recorded output in their expanded card; the collapsed row
|
|
// still shows the one-line summary from getToolResultSummary.
|
|
function hasDetailView(tool: ACPToolCall): boolean {
|
|
const name = tool.toolName.toLowerCase();
|
|
return (
|
|
isShellToolName(name) ||
|
|
name === 'write_file' ||
|
|
name === 'writefile' ||
|
|
name === 'edit' ||
|
|
name === 'write' ||
|
|
name === 'editfile' ||
|
|
name === 'read' ||
|
|
name === 'read_file' ||
|
|
name === 'readfile' ||
|
|
isSkillToolName(name) ||
|
|
isAdvisorToolName(name) ||
|
|
isAskUserQuestionToolName(tool.toolName) ||
|
|
isWorkflowToolName(name)
|
|
);
|
|
}
|
|
|
|
function isEditToolName(toolName: string): boolean {
|
|
// Like every other is*ToolName helper, normalize case here so callers can
|
|
// pass the raw wire name.
|
|
const name = toolName.toLowerCase();
|
|
return (
|
|
name === 'edit' ||
|
|
name === 'editfile' ||
|
|
name === 'write' ||
|
|
name === 'write_file' ||
|
|
name === 'writefile'
|
|
);
|
|
}
|
|
|
|
export function extractDiff(tool: ACPToolCall): string {
|
|
const rawFileDiff = getRawFileDiff(tool);
|
|
if (rawFileDiff) return rawFileDiff;
|
|
|
|
if (tool.content) {
|
|
const diffBlock = tool.content.find((b) => b.type === 'diff');
|
|
if (diffBlock && diffBlock.type === 'diff') {
|
|
return buildUnifiedDiff(diffBlock.oldText || '', diffBlock.newText || '');
|
|
}
|
|
}
|
|
|
|
if (tool.status === 'failed' || tool.wasCancelled) return '';
|
|
|
|
const previewPatch = tool.args?.patch;
|
|
if (typeof previewPatch === 'string' && previewPatch) return previewPatch;
|
|
// `newText`/`oldText` come from the safe tool preview projection; the full
|
|
// projection carries the edit tool's real parameter names instead.
|
|
const previewNewText = tool.args?.newText ?? tool.args?.new_string;
|
|
const previewOldText = tool.args?.oldText ?? tool.args?.old_string;
|
|
if (
|
|
typeof previewNewText === 'string' ||
|
|
typeof previewOldText === 'string'
|
|
) {
|
|
return buildUnifiedDiff(
|
|
typeof previewOldText === 'string' ? previewOldText : '',
|
|
typeof previewNewText === 'string' ? previewNewText : '',
|
|
);
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
export function getRawFileDiff(tool: ACPToolCall): string {
|
|
if (tool.rawOutput && typeof tool.rawOutput === 'object') {
|
|
const raw = tool.rawOutput as Record<string, unknown>;
|
|
if (isTruncatedSessionDiff(raw)) return '';
|
|
if (typeof raw.fileDiff === 'string') return raw.fileDiff;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function isTruncatedSessionDiff(raw: Record<string, unknown>): boolean {
|
|
return (
|
|
raw.truncatedForSession === true && 'fileName' in raw && 'newContent' in raw
|
|
);
|
|
}
|
|
|
|
// A description longer than this is likely ellipsised on a normal-width row, so
|
|
// the row becomes expandable to re-flow the full text into a wrapped block.
|
|
const DESCRIPTION_EXPAND_THRESHOLD = 60;
|
|
const MAX_MARKDOWN_READ_CHARS = 200_000;
|
|
const MAX_MARKDOWN_READ_LINES = 1000;
|
|
const MAX_DOCUMENT_JSON_PAYLOAD_LENGTH = 100_000;
|
|
const READ_LANGUAGE_ALIASES: Record<string, string> = {
|
|
cjs: 'javascript',
|
|
cts: 'typescript',
|
|
h: 'c',
|
|
hpp: 'cpp',
|
|
js: 'javascript',
|
|
jsx: 'jsx',
|
|
mjs: 'javascript',
|
|
mts: 'typescript',
|
|
py: 'python',
|
|
rb: 'ruby',
|
|
sh: 'bash',
|
|
ts: 'typescript',
|
|
tsx: 'tsx',
|
|
yml: 'yaml',
|
|
};
|
|
|
|
function ExpandedReadContent({ tool }: { tool: ACPToolCall }) {
|
|
const { t } = useI18n();
|
|
const content = useMemo(() => extractText(tool) || '', [tool]);
|
|
if (!content) {
|
|
return (
|
|
<div className={styles.expandedRead}>
|
|
<pre className={styles.expandedOutput}>
|
|
{t(
|
|
isActiveToolStatus(tool.status)
|
|
? 'shell.result.waiting'
|
|
: hasRecordedOutput(tool)
|
|
? 'shell.result.empty'
|
|
: 'shell.result.unavailable',
|
|
)}
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|
|
const language = languageForPath(getReadFilePath(tool));
|
|
const plainText =
|
|
language === 'text' ||
|
|
content.length > MAX_MARKDOWN_READ_CHARS ||
|
|
exceedsLineLimit(content, MAX_MARKDOWN_READ_LINES);
|
|
|
|
return (
|
|
<div className={styles.expandedRead}>
|
|
{plainText ? (
|
|
<pre className={styles.expandedOutput}>{content}</pre>
|
|
) : (
|
|
<Markdown content={fencedCodeBlock(language, content)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getReadFilePath(tool: ACPToolCall): string {
|
|
const filePath = tool.args?.file_path ?? tool.args?.path;
|
|
return typeof filePath === 'string' ? filePath : '';
|
|
}
|
|
|
|
function exceedsLineLimit(text: string, maxLines: number): boolean {
|
|
let lines = 1;
|
|
for (let i = 0; i < text.length; i++) {
|
|
if (text.charCodeAt(i) === 10 && ++lines > maxLines) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function languageForPath(filePath: string): string {
|
|
const ext = filePath.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase();
|
|
if (!ext || ext === filePath.toLowerCase()) return 'text';
|
|
if (ext === 'mermaid' || ext === 'mmd') return 'text';
|
|
const language = READ_LANGUAGE_ALIASES[ext] ?? ext;
|
|
return /^[\w+.#-]+$/.test(language) ? language : 'text';
|
|
}
|
|
|
|
export function fencedCodeBlock(language: string, code: string): string {
|
|
const longestFence =
|
|
code
|
|
.match(/~{3,}/g)
|
|
?.reduce((max, fence) => Math.max(max, fence.length), 0) ?? 0;
|
|
const fence = '~'.repeat(Math.max(3, longestFence + 1));
|
|
return `${fence}${language}\n${code}\n${fence}`;
|
|
}
|
|
|
|
function ExpandedEditContent({ tool }: { tool: ACPToolCall }) {
|
|
const diff = useMemo(() => extractDiff(tool), [tool]);
|
|
const text = useMemo(
|
|
() => (tool.content ? extractText(tool) || '' : ''),
|
|
[tool],
|
|
);
|
|
if (!diff && !text) return null;
|
|
return (
|
|
<div className={styles.expandedEdit}>
|
|
{diff ? (
|
|
<DiffView diff={diff} />
|
|
) : (
|
|
<pre className={styles.expandedOutput}>{text}</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToolExpandedCard({
|
|
title,
|
|
action,
|
|
detail,
|
|
status,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
action?: ReactNode;
|
|
detail?: string;
|
|
status?: ACPToolCall['status'];
|
|
children?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={styles.expandedCard}>
|
|
<div className={styles.expandedCardHeader}>
|
|
<span className={styles.expandedCardTitleRow}>
|
|
{status && <StatusIcon status={status} />}
|
|
<span className={styles.expandedCardTitle}>{title}</span>
|
|
{action}
|
|
</span>
|
|
{detail && <span className={styles.expandedCardDetail}>{detail}</span>}
|
|
</div>
|
|
{children && <div className={styles.expandedCardBody}>{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToolPayloadBlock({
|
|
payload,
|
|
limit,
|
|
}: {
|
|
payload: { text: string; json: boolean };
|
|
limit: number;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const renderMode = useTranscriptRenderMode();
|
|
// Escape before applying the cap: control-character escapes can expand a
|
|
// payload up to six characters per input character.
|
|
const escaped = sanitizeControlChars(truncateText(payload.text, limit));
|
|
const shown = escaped.length > limit ? truncateText(escaped, limit) : escaped;
|
|
const truncated = payload.text.length > limit || escaped.length > limit;
|
|
// Highlight a canonical JSON payload as long as it fits the Markdown budget.
|
|
// The former 1000-line read-file guard is deliberately not applied here: a
|
|
// tool result is one logical value, not a file preview, and can be short in
|
|
// characters yet span many lines once pretty-printed (e.g. a compact array of
|
|
// records). formatJsonPayload already caps the formatted text by render mode,
|
|
// and the read-file line budget lives on to guard ExpandedReadContent below.
|
|
const renderAsMarkdown =
|
|
payload.json && shown.length <= MAX_MARKDOWN_READ_CHARS;
|
|
return (
|
|
<>
|
|
{renderAsMarkdown ? (
|
|
<div className={styles.payloadJson}>
|
|
<Markdown content={fencedCodeBlock('json', shown)} />
|
|
</div>
|
|
) : (
|
|
<pre className={styles.payloadText}>
|
|
{payload.json ? shown : renderWithSessionLinks(shown, renderMode)}
|
|
</pre>
|
|
)}
|
|
{truncated && (
|
|
<p className={styles.payloadTruncated}>
|
|
{t('trajectory.inspector.truncated')}
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function toolResultText(tool: ACPToolCall, includeExtractText = true): string {
|
|
const texts: string[] = [];
|
|
for (const block of tool.content ?? []) {
|
|
if (block.type === 'content' && typeof block.content?.text === 'string') {
|
|
texts.push(block.content.text);
|
|
}
|
|
}
|
|
if (texts.length > 0) return texts.join('\n');
|
|
return includeExtractText ? (extractText(tool) ?? '') : '';
|
|
}
|
|
|
|
function cancelledOutputText(rawOutput: unknown): string {
|
|
if (typeof rawOutput === 'string') return rawOutput;
|
|
if (
|
|
rawOutput === null ||
|
|
typeof rawOutput !== 'object' ||
|
|
Array.isArray(rawOutput)
|
|
) {
|
|
return '';
|
|
}
|
|
const record = rawOutput as Record<string, unknown>;
|
|
for (const key of ['text', 'output', 'stdout'] as const) {
|
|
const value = record[key];
|
|
if (typeof value !== 'string') continue;
|
|
// A cancelled call that recorded no output carries the redacted input dump
|
|
// in `text`, written there as the same string as `reason`; it is not output.
|
|
if (key === 'text' && value === record['reason']) continue;
|
|
return value;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function GenericToolDetails({
|
|
tool,
|
|
pending,
|
|
cancelled,
|
|
}: {
|
|
tool: ACPToolCall;
|
|
pending: boolean;
|
|
cancelled: boolean;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const argumentsText = useMemo(
|
|
() =>
|
|
tool.args && Object.keys(tool.args).length > 0
|
|
? stringifyValue(tool.args)
|
|
: '',
|
|
[tool],
|
|
);
|
|
const resultText = useMemo(
|
|
() =>
|
|
toolResultText(tool, !tool.wasCancelled) ||
|
|
(tool.wasCancelled
|
|
? cancelledOutputText(tool.rawOutput)
|
|
: stringifyValue(tool.rawOutput)) ||
|
|
'',
|
|
[tool],
|
|
);
|
|
const renderMode = useTranscriptRenderMode();
|
|
const documentMode = renderMode === 'document';
|
|
const formatBudget = documentMode
|
|
? MAX_DOCUMENT_JSON_PAYLOAD_LENGTH
|
|
: EXPANDED_TOOL_PAYLOAD_LENGTH;
|
|
const [expanded, setExpanded] = useState(false);
|
|
const limit = documentMode
|
|
? Number.POSITIVE_INFINITY
|
|
: expanded
|
|
? EXPANDED_TOOL_PAYLOAD_LENGTH
|
|
: MAX_TOOL_PAYLOAD_LENGTH;
|
|
const argumentsLimit = documentMode ? EXPANDED_TOOL_PAYLOAD_LENGTH : limit;
|
|
// Pretty-printing grows a compact payload, and the blocks truncate what they
|
|
// render, so the "Show more" decision has to use the formatted lengths.
|
|
const argumentsPayload = useMemo(
|
|
() => formatJsonPayload(argumentsText, formatBudget, formatBudget),
|
|
[argumentsText, formatBudget],
|
|
);
|
|
const resultPayload = useMemo(
|
|
() => formatJsonPayload(resultText, formatBudget, formatBudget),
|
|
[resultText, formatBudget],
|
|
);
|
|
const argumentsNeedsExpansion = useMemo(
|
|
() =>
|
|
!documentMode &&
|
|
sanitizeControlChars(argumentsPayload.text).length >
|
|
MAX_TOOL_PAYLOAD_LENGTH,
|
|
[argumentsPayload.text, documentMode],
|
|
);
|
|
const resultNeedsExpansion = useMemo(
|
|
() =>
|
|
!documentMode &&
|
|
sanitizeControlChars(resultPayload.text).length > MAX_TOOL_PAYLOAD_LENGTH,
|
|
[resultPayload.text, documentMode],
|
|
);
|
|
const canExpand =
|
|
!expanded && (argumentsNeedsExpansion || resultNeedsExpansion);
|
|
return (
|
|
<div className={styles.payloadSections}>
|
|
{argumentsText && (
|
|
<section className={styles.payloadSection}>
|
|
<div className={styles.payloadLabel}>{t('turnCalls.arguments')}</div>
|
|
<ToolPayloadBlock payload={argumentsPayload} limit={argumentsLimit} />
|
|
</section>
|
|
)}
|
|
{(resultText || pending || cancelled) && (
|
|
<section className={styles.payloadSection}>
|
|
<div className={styles.payloadLabel}>{t('turnCalls.result')}</div>
|
|
{resultText ? (
|
|
<ToolPayloadBlock payload={resultPayload} limit={limit} />
|
|
) : cancelled ? (
|
|
<p className={styles.payloadEmpty}>{t('shell.result.cancelled')}</p>
|
|
) : (
|
|
<p className={styles.payloadEmpty}>{t('shell.result.waiting')}</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
{canExpand && (
|
|
<button
|
|
type="button"
|
|
className={styles.payloadMore}
|
|
onClick={() => setExpanded(true)}
|
|
>
|
|
{t('trajectory.inspector.expand')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Collapsed by default: the diff of this todo_write call (just-completed and
|
|
// just-started items), expanding to the full list on click. The per-snapshot
|
|
// diff comes from the timeline context, so this is isolated in its own
|
|
// component — only todo rows subscribe and re-render when the timeline changes,
|
|
// not every tool row.
|
|
function TodoToolBody({
|
|
tool,
|
|
todos,
|
|
expanded,
|
|
title,
|
|
}: {
|
|
tool: ACPToolCall;
|
|
todos: TodoItem[];
|
|
expanded: boolean;
|
|
title: string;
|
|
}) {
|
|
const timeline = useContext(TodoTimelineContext);
|
|
const events = timeline.get(tool.callId)?.events ?? [];
|
|
return expanded ? (
|
|
<ToolExpandedCard title={title} status={tool.status}>
|
|
<div className={styles.todoBody}>
|
|
<TodoFullList todos={todos} />
|
|
</div>
|
|
</ToolExpandedCard>
|
|
) : (
|
|
<div className={styles.todoBody}>
|
|
<TodoEventSummary todos={todos} events={events} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ToolLineProps {
|
|
tool: ACPToolCall;
|
|
approval?: PermissionRequest | null;
|
|
workspaceCwd?: string;
|
|
onTurnOutputOpen?: (request: TurnOutputOpenRequest) => void;
|
|
onToolResultOpen?: (itemId: string) => void;
|
|
summaryOnly?: boolean;
|
|
forceExpanded?: boolean;
|
|
detailsVisible?: boolean;
|
|
hideHeader?: boolean;
|
|
hideCollapsedOutput?: boolean;
|
|
}
|
|
|
|
function WorkflowToolDetail({
|
|
tool,
|
|
displayName,
|
|
detail,
|
|
result,
|
|
}: {
|
|
tool: ACPToolCall;
|
|
displayName: string;
|
|
detail: string;
|
|
result: string;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const workflowDetails = useWorkflowDetails();
|
|
const workflowTask = workflowDetails
|
|
? findWorkflowTaskForTool(workflowDetails.tasks, tool)
|
|
: undefined;
|
|
return workflowTask ? (
|
|
<WorkflowExecutionView task={workflowTask} />
|
|
) : (
|
|
<ToolExpandedCard title={displayName} detail={detail}>
|
|
<div className={styles.workflowFallback} role="status">
|
|
{tool.status === 'pending' || tool.status === 'in_progress'
|
|
? t('workflow.inline.loading')
|
|
: result || t('workflow.inline.unavailable')}
|
|
</div>
|
|
</ToolExpandedCard>
|
|
);
|
|
}
|
|
|
|
function getAgentDisplayInfo(
|
|
tool: ACPToolCall,
|
|
now?: number,
|
|
): {
|
|
agentType: string;
|
|
explicitAgentType: string;
|
|
description: string;
|
|
subToolCount: number;
|
|
elapsed: string;
|
|
tokens: string;
|
|
status: ACPToolCall['status'];
|
|
reason: string;
|
|
} {
|
|
const taskExec = getTaskExecutionRecord(tool.rawOutput);
|
|
const reason = getAgentCancellationReason(tool);
|
|
const status = getAgentDisplayStatus(tool);
|
|
const agentType = getAgentType(tool);
|
|
const explicitAgentType = getExplicitAgentType(tool);
|
|
const description = getAgentDescription(tool);
|
|
|
|
const subToolCount =
|
|
tool.subTools?.length ||
|
|
(taskExec?.['toolCalls'] as unknown[] | undefined)?.length ||
|
|
0;
|
|
|
|
const stats = taskExec?.['executionSummary'] as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
const elapsed =
|
|
stats && typeof stats['totalDurationMs'] === 'number'
|
|
? formatDurationMs(stats['totalDurationMs'])
|
|
: formatElapsed(
|
|
tool.startTime,
|
|
tool.endTime ??
|
|
(tool.status === 'in_progress' && now ? now : undefined),
|
|
);
|
|
|
|
const outputTokens =
|
|
taskExec &&
|
|
typeof taskExec['tokenCount'] === 'number' &&
|
|
taskExec['tokenCount'] > 0
|
|
? (taskExec['tokenCount'] as number)
|
|
: stats &&
|
|
typeof stats['outputTokens'] === 'number' &&
|
|
stats['outputTokens'] > 0
|
|
? (stats['outputTokens'] as number)
|
|
: 0;
|
|
const tokens = outputTokens > 0 ? formatTokenCount(outputTokens) : '';
|
|
|
|
return {
|
|
agentType,
|
|
explicitAgentType,
|
|
description,
|
|
subToolCount,
|
|
elapsed,
|
|
tokens,
|
|
status,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
function getExplicitAgentType(tool: ACPToolCall): string {
|
|
const taskExec = getTaskExecutionRecord(tool.rawOutput);
|
|
const name = taskExec?.['subagentName'];
|
|
if (typeof name === 'string' && name.trim()) return name.trim();
|
|
const subagentType = tool.args?.subagent_type;
|
|
if (typeof subagentType === 'string' && subagentType.trim()) {
|
|
return subagentType.trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export function shouldAutoExpand(tool: ACPToolCall): boolean {
|
|
// Only the verbose tool kinds below (shell/edit/write/ask) auto-expand, and
|
|
// only while pending/in-progress or after failing: a successful completion
|
|
// collapses them to a one-line summary so the transcript stays scannable
|
|
// (click to reopen), while a failure of those kinds stays expanded so its
|
|
// error output is visible without a click. Every other tool kind is collapsed
|
|
// by default regardless of status — its summary line already shows the
|
|
// outcome and it stays click-to-expand.
|
|
if (tool.status === 'completed') return false;
|
|
const name = tool.toolName.toLowerCase();
|
|
if (isAskUserQuestionToolName(tool.toolName)) return true;
|
|
if (isEditToolName(name)) return true;
|
|
if (isShellToolName(name)) return true;
|
|
return false;
|
|
}
|
|
|
|
function ExpandedAskUserQuestionOutput({ tool }: { tool: ACPToolCall }) {
|
|
const text = extractText(tool) || '';
|
|
return <pre className={styles.expandedOutput}>{text}</pre>;
|
|
}
|
|
|
|
function ExpandedSkillOutput({ tool }: { tool: ACPToolCall }) {
|
|
const content =
|
|
getFirstToolContentText(tool) ||
|
|
(typeof tool.args?.args === 'string' && tool.args.args.trim()
|
|
? tool.args.args.trim()
|
|
: (tool.title ?? ''));
|
|
|
|
return <pre className={styles.expandedOutput}>{content}</pre>;
|
|
}
|
|
|
|
function getFirstToolContentText(tool: ACPToolCall): string {
|
|
const block = tool.content?.[0];
|
|
if (block?.type !== 'content') return '';
|
|
return typeof block.content?.text === 'string' ? block.content.text : '';
|
|
}
|
|
|
|
export function getToolHeaderKind(tool: ACPToolCall): ToolHeaderKind {
|
|
const name = tool.toolName.toLowerCase();
|
|
if (isSubAgentToolCall(tool)) return 'agent';
|
|
if (isAskUserQuestionToolName(tool.toolName)) return 'ask';
|
|
if (isShellToolName(name)) return 'shell';
|
|
if (isWebFetchToolName(name)) return 'fetch';
|
|
if (isTodoWriteToolName(name)) return 'todo';
|
|
if (name === 'read' || name === 'read_file' || name === 'readfile')
|
|
return 'read';
|
|
if (name === 'edit' || name === 'editfile') return 'edit';
|
|
if (name === 'write' || name === 'write_file' || name === 'writefile')
|
|
return 'write';
|
|
return 'other';
|
|
}
|
|
|
|
function DefaultToolHeaderExtra({
|
|
description,
|
|
elapsed,
|
|
}: {
|
|
description: string;
|
|
elapsed: string;
|
|
}) {
|
|
return (
|
|
<>
|
|
{description && <span className={styles.lineArg}>{description}</span>}
|
|
{elapsed && <span className={styles.lineElapsed}>{elapsed}</span>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ToolHeaderExtra({ info }: { info: ToolHeaderExtraRenderInfo }) {
|
|
const { renderToolHeaderExtra } = useWebShellCustomization();
|
|
const customExtra = renderToolHeaderExtra?.(info);
|
|
if (customExtra) return <>{customExtra}</>;
|
|
return (
|
|
<DefaultToolHeaderExtra
|
|
description={info.description}
|
|
elapsed={
|
|
info.kind === 'agent' || isActiveToolStatus(info.tool.status)
|
|
? info.elapsed
|
|
: ''
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function isDescriptionExpandable(description: string): boolean {
|
|
return (
|
|
description.length > DESCRIPTION_EXPAND_THRESHOLD ||
|
|
description.includes('\n')
|
|
);
|
|
}
|
|
|
|
export function getActiveTool(tools: ACPToolCall[]): ACPToolCall {
|
|
return (
|
|
tools.find((tool) => isActiveToolStatus(tool.status)) ??
|
|
tools[tools.length - 1]
|
|
);
|
|
}
|
|
|
|
export function formatToolGroupSummary(
|
|
tools: ACPToolCall[],
|
|
t: ReturnType<typeof useI18n>['t'],
|
|
workspaceCwd?: string,
|
|
summarizeAgents = false,
|
|
): string {
|
|
if (hasActiveAgents(tools)) {
|
|
const foregroundActiveTools = tools.filter(
|
|
(tool) =>
|
|
isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool),
|
|
);
|
|
if (foregroundActiveTools.length === 0) {
|
|
return t('subagent.background');
|
|
}
|
|
if (
|
|
foregroundActiveTools.length === 1 &&
|
|
isAskUserQuestionToolName(foregroundActiveTools[0].toolName)
|
|
) {
|
|
return t('toolGroup.summary.provideInformation');
|
|
}
|
|
const activeSummaries = foregroundActiveTools.map((tool) =>
|
|
isAskUserQuestionToolName(tool.toolName)
|
|
? t('toolGroup.summary.provideInformation')
|
|
: formatSingleToolSummary(tool, t, workspaceCwd),
|
|
);
|
|
return t('toolGroup.running', {
|
|
name: activeSummaries.join(' · '),
|
|
count: foregroundActiveTools.length,
|
|
});
|
|
}
|
|
|
|
const summary = formatCompletedToolSummary(tools, t, summarizeAgents);
|
|
if (summary) return summary;
|
|
|
|
return t('toolGroup.summary', {
|
|
count: tools.length,
|
|
});
|
|
}
|
|
|
|
export function formatSingleToolSummary(
|
|
tool: ACPToolCall,
|
|
t: ReturnType<typeof useI18n>['t'],
|
|
workspaceCwd?: string,
|
|
): string {
|
|
if (isTodoWriteToolName(tool.toolName)) {
|
|
return t('toolGroup.summary.updatedTodos', { count: 1 });
|
|
}
|
|
if (isAskUserQuestionToolName(tool.toolName)) {
|
|
return isActiveToolStatus(tool.status)
|
|
? t('toolGroup.summary.provideInformation')
|
|
: t('toolGroup.summary.askedQuestions', {
|
|
count: getAskUserQuestionCount(tool),
|
|
});
|
|
}
|
|
|
|
const { displayName, description, hideDisplayName } =
|
|
getSingleToolSummaryInfo(tool, t, workspaceCwd);
|
|
return [hideDisplayName ? '' : displayName, description]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
}
|
|
|
|
function getSingleToolSummaryInfo(
|
|
tool: ACPToolCall,
|
|
t: ReturnType<typeof useI18n>['t'],
|
|
workspaceCwd?: string,
|
|
): ToolHeaderExtraRenderInfo & { hideDisplayName: boolean } {
|
|
const displayName = localizeToolDisplayName(tool.toolName, t);
|
|
const description = truncateText(
|
|
getToolSummaryDescription(tool, workspaceCwd),
|
|
120,
|
|
);
|
|
return {
|
|
kind: getToolHeaderKind(tool),
|
|
tool,
|
|
displayName,
|
|
description,
|
|
elapsed: '',
|
|
workspaceCwd,
|
|
hideDisplayName: !!getShellToolSemanticDescription(tool),
|
|
};
|
|
}
|
|
|
|
function SingleToolSummary({
|
|
tool,
|
|
workspaceCwd,
|
|
}: {
|
|
tool: ACPToolCall;
|
|
workspaceCwd?: string;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const isAskUserQuestion = isAskUserQuestionToolName(tool.toolName);
|
|
const isActive = isActiveToolStatus(tool.status);
|
|
const isBackgroundAgent = isBackgroundSubAgentToolCall(tool);
|
|
// A detached agent's card is deliberately timer-free (see
|
|
// docs/design/background-agent-status-and-details.md), so only a foreground
|
|
// agent subscribes to the shared 1s ticker.
|
|
const isLiveAgent =
|
|
isActive && !isBackgroundAgent && isSubAgentToolCall(tool);
|
|
const now = useSharedNow(isLiveAgent);
|
|
const runningPrefix =
|
|
!isAskUserQuestion && isActive
|
|
? isBackgroundAgent
|
|
? t('subagent.background')
|
|
: t('toolGroup.runningPrefix').trim()
|
|
: '';
|
|
|
|
if (isTodoWriteToolName(tool.toolName) || isAskUserQuestion) {
|
|
return (
|
|
<>
|
|
{runningPrefix && <span>{runningPrefix} </span>}
|
|
{formatSingleToolSummary(tool, t, workspaceCwd)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const baseInfo = getSingleToolSummaryInfo(tool, t, workspaceCwd);
|
|
const activity = isLiveAgent ? getAgentCurrentToolHint(tool, t) : '';
|
|
const info = isLiveAgent
|
|
? {
|
|
...baseInfo,
|
|
description: [baseInfo.description, activity && `(${activity})`]
|
|
.filter(Boolean)
|
|
.join(' '),
|
|
elapsed: getAgentDisplayInfo(tool, now).elapsed,
|
|
}
|
|
: baseInfo;
|
|
|
|
return (
|
|
<>
|
|
{runningPrefix && <span>{runningPrefix} </span>}
|
|
<span className={styles.chatSummaryInline}>
|
|
{!info.hideDisplayName && (
|
|
<span className={styles.lineName}>{info.displayName}</span>
|
|
)}
|
|
<ToolHeaderExtra info={info} />
|
|
</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function formatCompletedToolSummary(
|
|
tools: ACPToolCall[],
|
|
t: ReturnType<typeof useI18n>['t'],
|
|
summarizeAgents: boolean,
|
|
): string {
|
|
const agents = summarizeAgents ? tools.filter(isSubAgentToolCall).length : 0;
|
|
if (agents > 0) {
|
|
const otherTools = tools.length - agents;
|
|
return [
|
|
t('toolGroup.summary.ranAgents', { count: agents }),
|
|
otherTools ? t('toolGroup.summary', { count: otherTools }) : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
}
|
|
|
|
let edited = 0;
|
|
let commands = 0;
|
|
let read = 0;
|
|
let searched = 0;
|
|
let todos = 0;
|
|
let askedQuestions = 0;
|
|
let other = 0;
|
|
|
|
for (const tool of tools) {
|
|
const name = tool.toolName.toLowerCase();
|
|
if (isShellToolName(name)) {
|
|
commands++;
|
|
} else if (
|
|
name === 'edit' ||
|
|
name === 'editfile' ||
|
|
name === 'write' ||
|
|
name === 'write_file' ||
|
|
name === 'writefile'
|
|
) {
|
|
edited++;
|
|
} else if (name === 'read' || name === 'read_file' || name === 'readfile') {
|
|
read++;
|
|
} else if (
|
|
name === 'grep' ||
|
|
name === 'grep_search' ||
|
|
name === 'search' ||
|
|
name === 'glob' ||
|
|
name === 'web_search' ||
|
|
name === 'websearch'
|
|
) {
|
|
searched++;
|
|
} else if (isTodoWriteToolName(name)) {
|
|
todos++;
|
|
} else if (isAskUserQuestionToolName(name)) {
|
|
askedQuestions += getAskUserQuestionCount(tool);
|
|
} else {
|
|
other++;
|
|
}
|
|
}
|
|
|
|
const parts = [
|
|
edited ? t('toolGroup.summary.editedFiles', { count: edited }) : '',
|
|
commands ? t('toolGroup.summary.ranCommands', { count: commands }) : '',
|
|
read ? t('toolGroup.summary.readFiles', { count: read }) : '',
|
|
searched ? t('toolGroup.summary.searched', { count: searched }) : '',
|
|
todos ? t('toolGroup.summary.updatedTodos', { count: todos }) : '',
|
|
askedQuestions
|
|
? t('toolGroup.summary.askedQuestions', { count: askedQuestions })
|
|
: '',
|
|
other ? t('toolGroup.summary.otherTools', { count: other }) : '',
|
|
].filter(Boolean);
|
|
|
|
return parts.join(' ');
|
|
}
|
|
|
|
function getAskUserQuestionCount(tool: ACPToolCall): number {
|
|
const questions = tool.args?.questions;
|
|
return Array.isArray(questions) && questions.length > 0
|
|
? questions.length
|
|
: 1;
|
|
}
|
|
|
|
function PencilIcon() {
|
|
return (
|
|
<svg
|
|
className={styles.chatSummaryToolIcon}
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.7"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M12 20h9" />
|
|
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ToolGroupIcon() {
|
|
return (
|
|
<svg
|
|
className={styles.chatSummaryToolIcon}
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 14 14"
|
|
fill="none"
|
|
data-testid="chat-summary-tool-icon"
|
|
aria-hidden="true"
|
|
>
|
|
<rect
|
|
x="2"
|
|
y="2"
|
|
width="10"
|
|
height="10"
|
|
rx="2.4"
|
|
stroke="currentColor"
|
|
strokeWidth="1.2"
|
|
/>
|
|
<path
|
|
d="M4.6 5.2 6 6.6 4.6 8"
|
|
stroke="currentColor"
|
|
strokeWidth="1.05"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
<path
|
|
d="M7.3 8.1h2.1"
|
|
stroke="currentColor"
|
|
strokeWidth="1.05"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function WebFetchIcon() {
|
|
return (
|
|
<svg
|
|
className={styles.chatSummaryToolIcon}
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 14 14"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.25" />
|
|
<path
|
|
d="M1.8 7h10.4M7 1.5c1.3 1.5 2 3.3 2 5.5s-.7 4-2 5.5M7 1.5C5.7 3 5 4.8 5 7s.7 4 2 5.5"
|
|
stroke="currentColor"
|
|
strokeWidth="1.05"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FileIcon() {
|
|
return (
|
|
<svg
|
|
className={styles.chatSummaryToolIcon}
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 14 14"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<rect
|
|
x="2"
|
|
y="1.6"
|
|
width="10"
|
|
height="10.8"
|
|
rx="2.2"
|
|
stroke="currentColor"
|
|
strokeWidth="1.25"
|
|
/>
|
|
<path
|
|
d="M4.5 5.2h5M4.5 7h5M4.5 8.8h5"
|
|
stroke="currentColor"
|
|
strokeWidth="1.15"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function TodoIcon() {
|
|
return (
|
|
<svg
|
|
className={styles.chatSummaryToolIcon}
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 14 14"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M3 4.2 4.2 5.4 6.1 3.3M7.5 4.5h3.6M3 9.1l1.2 1.2 1.9-2.1M7.5 9.4h3.6"
|
|
stroke="currentColor"
|
|
strokeWidth="1.25"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function AskUserIcon() {
|
|
return (
|
|
<svg
|
|
className={styles.chatSummaryToolIcon}
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 14 14"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M3.4 3.2h7.2c.7 0 1.25.55 1.25 1.25v3.4c0 .7-.55 1.25-1.25 1.25H7.5L4.5 11V9.1H3.4c-.7 0-1.25-.55-1.25-1.25v-3.4c0-.7.55-1.25 1.25-1.25Z"
|
|
stroke="currentColor"
|
|
strokeWidth="1.2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function AgentIcon() {
|
|
return (
|
|
<svg
|
|
className={styles.chatSummaryToolIcon}
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 1024 1024"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M770.08 96.32c1.728.64 3.072 1.984 3.712 3.712l38.848 107.584c.64 1.728 1.984 3.104 3.712 3.712l107.584 38.848a6.144 6.144 0 0 1 0 11.584l-107.584 38.848a6.144 6.144 0 0 0-3.712 3.712l-38.848 107.584a6.144 6.144 0 0 1-11.584 0L723.36 304.32a6.144 6.144 0 0 0-3.712-3.712L612.064 261.76a6.144 6.144 0 0 1 0-11.584l107.584-38.848a6.144 6.144 0 0 0 3.712-3.712l38.848-107.584c1.184-3.2 4.704-4.8 7.872-3.68zM576 160H384q-119.296 0-203.648 84.352Q96 328.704 96 448v192q0 119.296 84.352 203.648Q264.704 928 384 928h256q119.296 0 203.648-84.352Q928 759.296 928 640V512h-64v128q0 92.8-65.6 158.4Q732.8 864 640 864H384q-92.8 0-158.4-65.6Q160 732.8 160 640V448q0-92.8 65.6-158.4Q291.2 224 384 224h192v-64zm96 248.224L568.224 512 672 615.776l45.248-45.28L658.752 512l58.496-58.496L672 408.224zM320 608V448h64v160h-64z"
|
|
stroke="currentColor"
|
|
strokeWidth="28"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function ToolSummaryIcon({ tool }: { tool: ACPToolCall }) {
|
|
const kind = getToolHeaderKind(tool);
|
|
if (kind === 'agent') return <AgentIcon />;
|
|
if (kind === 'ask') return <AskUserIcon />;
|
|
if (kind === 'edit' || kind === 'write') return <PencilIcon />;
|
|
if (kind === 'fetch') return <WebFetchIcon />;
|
|
if (kind === 'read') return <FileIcon />;
|
|
if (kind === 'todo') return <TodoIcon />;
|
|
return <ToolGroupIcon />;
|
|
}
|
|
|
|
export function isWebFetchToolName(toolName: string): boolean {
|
|
const name = toolName.toLowerCase();
|
|
return name === 'web_fetch' || name === 'webfetch' || name === 'fetch';
|
|
}
|
|
|
|
function areToolLinePropsEqual(
|
|
prev: ToolLineProps,
|
|
next: ToolLineProps,
|
|
): boolean {
|
|
if (prev.approval?.id !== next.approval?.id) return false;
|
|
if (prev.workspaceCwd !== next.workspaceCwd) return false;
|
|
if (prev.onTurnOutputOpen !== next.onTurnOutputOpen) return false;
|
|
if (prev.onToolResultOpen !== next.onToolResultOpen) return false;
|
|
if (prev.summaryOnly !== next.summaryOnly) return false;
|
|
if (prev.forceExpanded !== next.forceExpanded) return false;
|
|
if (prev.detailsVisible !== next.detailsVisible) return false;
|
|
if (prev.hideHeader !== next.hideHeader) return false;
|
|
if (prev.hideCollapsedOutput !== next.hideCollapsedOutput) return false;
|
|
const a = prev.tool;
|
|
const b = next.tool;
|
|
return (
|
|
a.callId === b.callId &&
|
|
a.generatedUiToolCallId === b.generatedUiToolCallId &&
|
|
a.toolName === b.toolName &&
|
|
a.status === b.status &&
|
|
a.subagentSessionReady === b.subagentSessionReady &&
|
|
a.startTime === b.startTime &&
|
|
a.endTime === b.endTime &&
|
|
a.subContent === b.subContent &&
|
|
a.rawOutput === b.rawOutput &&
|
|
a.toolResult === b.toolResult &&
|
|
a.toolDetailsRevision === b.toolDetailsRevision &&
|
|
a.wasCancelled === b.wasCancelled &&
|
|
a.args === b.args &&
|
|
a.content === b.content &&
|
|
a.locations === b.locations &&
|
|
a.title === b.title &&
|
|
areSubToolsEqual(a.subTools, b.subTools)
|
|
);
|
|
}
|
|
|
|
function areSubToolsEqual(
|
|
prev: ACPToolCall[] | undefined,
|
|
next: ACPToolCall[] | undefined,
|
|
): boolean {
|
|
if (prev === next) return true;
|
|
if (!prev || !next) return false;
|
|
if (prev.length !== next.length) return false;
|
|
for (let i = 0; i < prev.length; i++) {
|
|
const a = prev[i];
|
|
const b = next[i];
|
|
if (
|
|
a.callId !== b.callId ||
|
|
a.generatedUiToolCallId !== b.generatedUiToolCallId ||
|
|
a.toolName !== b.toolName ||
|
|
a.status !== b.status ||
|
|
a.subagentSessionReady !== b.subagentSessionReady ||
|
|
a.endTime !== b.endTime ||
|
|
a.rawOutput !== b.rawOutput ||
|
|
a.toolResult !== b.toolResult ||
|
|
a.toolDetailsRevision !== b.toolDetailsRevision ||
|
|
a.wasCancelled !== b.wasCancelled ||
|
|
a.args !== b.args ||
|
|
a.subContent !== b.subContent ||
|
|
a.title !== b.title
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Parse `[text](qwen-session://id)` links in plain-text tool output and
|
|
* replace them with clickable `<a>` elements that dispatch a DOM event.
|
|
* Keeps the rendering pipeline plain-text-compatible for all other tools. */
|
|
const SESSION_LINK_RE = /\[([^\]]+)\]\(qwen-session:\/\/([^)]+)\)/g;
|
|
|
|
function renderWithSessionLinks(
|
|
text: string,
|
|
renderMode: TranscriptRenderMode,
|
|
): ReactNode {
|
|
if (!text || !text.includes('qwen-session://')) return text;
|
|
const parts: ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
SESSION_LINK_RE.lastIndex = 0;
|
|
while ((match = SESSION_LINK_RE.exec(text)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
parts.push(text.slice(lastIndex, match.index));
|
|
}
|
|
const sessionId = match[2];
|
|
parts.push(
|
|
renderMode !== 'interactive' ? (
|
|
<span key={match.index} style={{ textDecoration: 'underline' }}>
|
|
{match[1]}
|
|
</span>
|
|
) : (
|
|
<a
|
|
key={match.index}
|
|
href="#"
|
|
role="button"
|
|
style={{ textDecoration: 'underline', cursor: 'pointer' }}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
window.dispatchEvent(
|
|
new CustomEvent('qwen:open-session', { detail: sessionId }),
|
|
);
|
|
}}
|
|
>
|
|
{match[1]}
|
|
</a>
|
|
),
|
|
);
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
|
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
|
}
|
|
|
|
export const ToolLine = memo(function ToolLine({
|
|
tool,
|
|
approval,
|
|
workspaceCwd,
|
|
onTurnOutputOpen,
|
|
onToolResultOpen,
|
|
summaryOnly = false,
|
|
forceExpanded = false,
|
|
detailsVisible = true,
|
|
hideHeader = false,
|
|
hideCollapsedOutput = false,
|
|
}: ToolLineProps) {
|
|
const { t } = useI18n();
|
|
const transcriptRenderMode = useTranscriptRenderMode();
|
|
const subagentDetails = useSubagentDetails();
|
|
const monitorDetails = useMonitorDetails();
|
|
const { hostOwnsEditDiffPreview, renderToolHeaderExtra } =
|
|
useWebShellCustomization();
|
|
const monitorDetailsAvailable = monitorDetails !== undefined;
|
|
const mcpApp = getMcpAppDisplay(tool.rawOutput);
|
|
const mcpAppAllowed = useMcpAppAdmission(mcpApp);
|
|
const isForcedExpanded = forceExpanded || Boolean(mcpApp);
|
|
const hasApproval = approval?.toolCallId === tool.callId;
|
|
const isHostOwnedEditApproval = Boolean(
|
|
hostOwnsEditDiffPreview &&
|
|
hasApproval &&
|
|
approval?.hasDiffPreview &&
|
|
isEditToolName(approval?.toolName ?? tool.toolName),
|
|
);
|
|
const locksPendingEditApproval = isHostOwnedEditApproval;
|
|
const [monitorDetailsUnavailable, setMonitorDetailsUnavailable] =
|
|
useState(false);
|
|
const [expanded, setExpanded] = useState(
|
|
() =>
|
|
isForcedExpanded ||
|
|
(!summaryOnly && shouldAutoExpand(tool) && !locksPendingEditApproval),
|
|
);
|
|
const monitorDetailsRequestRef = useRef<object | null>(null);
|
|
// Set once the user explicitly toggles this row, so auto-collapse-on-
|
|
// completion never silently overrides their choice.
|
|
const userToggledRef = useRef(false);
|
|
const isWorkflow = isWorkflowToolName(tool.toolName);
|
|
|
|
useEffect(
|
|
() => {
|
|
setExpanded(
|
|
isForcedExpanded ||
|
|
(!summaryOnly && shouldAutoExpand(tool) && !locksPendingEditApproval),
|
|
);
|
|
setMonitorDetailsUnavailable(false);
|
|
monitorDetailsRequestRef.current = null;
|
|
// A new tool identity resets the manual latch.
|
|
userToggledRef.current = false;
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[
|
|
isForcedExpanded,
|
|
locksPendingEditApproval,
|
|
monitorDetailsAvailable,
|
|
summaryOnly,
|
|
tool.callId,
|
|
tool.toolName,
|
|
],
|
|
);
|
|
const isAgent = isSubAgentToolCall(tool);
|
|
const hasSubToolApproval =
|
|
!hasApproval &&
|
|
approval?.toolCallId &&
|
|
isAgent &&
|
|
toolContainsCallId(tool, approval.toolCallId);
|
|
const waitingForApproval = Boolean(
|
|
isHostOwnedEditApproval ||
|
|
(hostOwnsEditDiffPreview &&
|
|
hasSubToolApproval &&
|
|
approval?.hasDiffPreview &&
|
|
isEditToolName(approval.toolName ?? '')),
|
|
);
|
|
const isRunningTool = isActiveToolStatus(tool.status);
|
|
const showsLiveElapsed =
|
|
isRunningTool &&
|
|
!isShellToolName(tool.toolName) &&
|
|
!isWebFetchToolName(tool.toolName) &&
|
|
!waitingForApproval;
|
|
const now = useSharedNow(showsLiveElapsed);
|
|
|
|
// Collapse a regular tool to its one-line summary once it completes
|
|
// successfully — unless the user explicitly toggled this row, in which case
|
|
// their choice wins. Agents are excluded (they keep whatever expand state the
|
|
// user chose, driven from their own panel) and failures stay open so the
|
|
// error output remains visible.
|
|
useEffect(() => {
|
|
if (
|
|
!isForcedExpanded &&
|
|
!isAgent &&
|
|
tool.status === 'completed' &&
|
|
!userToggledRef.current
|
|
) {
|
|
setExpanded(false);
|
|
}
|
|
}, [isForcedExpanded, isAgent, tool.status]);
|
|
|
|
if (isAgent) {
|
|
const info = getAgentDisplayInfo(tool, now);
|
|
const displayName = info.explicitAgentType
|
|
? `${t('agent.label')} (${localizeAgentTypeName(info.explicitAgentType, t)})`
|
|
: t('agent.label');
|
|
const isComplete = tool.status === 'completed' || tool.status === 'failed';
|
|
const isBackground = isBackgroundSubAgentToolCall(tool);
|
|
const progressLabel =
|
|
isBackground && !isComplete
|
|
? t('subagent.background')
|
|
: tool.status === 'pending'
|
|
? t('subagent.pending')
|
|
: t('subagent.running');
|
|
const runningMeta = [
|
|
progressLabel,
|
|
isBackground && !isComplete ? '' : waitingForApproval ? '' : info.elapsed,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
const completeMeta = [
|
|
info.subToolCount > 0
|
|
? t('subagent.toolsCount', { count: info.subToolCount })
|
|
: '',
|
|
info.elapsed,
|
|
info.tokens,
|
|
info.reason ? truncateText(info.reason, 80) : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
const showExpanded = forceExpanded || expanded || !!hasSubToolApproval;
|
|
// While the agent's own launch approval is pending there is nothing to
|
|
// show yet — keep the row compact and non-openable; the approval dialog
|
|
// is the single source of interaction.
|
|
const approvalPending = !!hasApproval;
|
|
const unavailableReason = getSubagentDetailsUnavailableReason(tool);
|
|
const panel = (
|
|
<SubAgentPanel
|
|
tool={tool}
|
|
approval={hostOwnsEditDiffPreview ? approval : undefined}
|
|
hideHeader
|
|
defaultExpanded
|
|
inline
|
|
/>
|
|
);
|
|
if (subagentDetails && !hideHeader) {
|
|
const rowContent = (
|
|
<>
|
|
<AgentIcon />
|
|
<StatusIcon status={isComplete ? info.status : tool.status} />
|
|
<span className={styles.lineName}>{displayName}</span>
|
|
<ToolHeaderExtra
|
|
info={{
|
|
kind: 'agent',
|
|
tool,
|
|
displayName,
|
|
description: info.description
|
|
? truncateText(info.description, 60)
|
|
: '',
|
|
elapsed: isComplete ? completeMeta : runningMeta,
|
|
workspaceCwd,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
return (
|
|
<div className={styles.line} data-transcript-tool-call-id={tool.callId}>
|
|
{approvalPending ? (
|
|
<div
|
|
className={`${styles.lineMain} ${styles.lineButton}`}
|
|
aria-disabled="true"
|
|
>
|
|
{rowContent}
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={`${styles.lineMain} ${styles.lineExpandable} ${styles.lineButton}`}
|
|
aria-disabled={!!unavailableReason || undefined}
|
|
title={unavailableReason ? t(unavailableReason) : undefined}
|
|
onClick={() => {
|
|
if (!unavailableReason) subagentDetails.onOpen(tool);
|
|
}}
|
|
>
|
|
{rowContent}
|
|
<span className={styles.lineChevronRight} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className={styles.line} data-transcript-tool-call-id={tool.callId}>
|
|
{!hideHeader && (
|
|
<div
|
|
className={`${styles.lineMain} ${
|
|
approvalPending ? '' : styles.lineExpandable
|
|
}`}
|
|
aria-disabled={!!unavailableReason || undefined}
|
|
title={unavailableReason ? t(unavailableReason) : undefined}
|
|
onClick={
|
|
approvalPending || unavailableReason
|
|
? undefined
|
|
: () => setExpanded(!expanded)
|
|
}
|
|
>
|
|
<AgentIcon />
|
|
<StatusIcon status={isComplete ? info.status : tool.status} />
|
|
<span className={styles.lineName}>{displayName}</span>
|
|
<ToolHeaderExtra
|
|
info={{
|
|
kind: 'agent',
|
|
tool,
|
|
displayName,
|
|
description: info.description
|
|
? truncateText(info.description, 60)
|
|
: '',
|
|
elapsed: isComplete ? completeMeta : runningMeta,
|
|
workspaceCwd,
|
|
}}
|
|
/>
|
|
{!approvalPending && (
|
|
<span
|
|
className={
|
|
expanded ? styles.lineChevronDown : styles.lineChevronRight
|
|
}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{showExpanded && (
|
|
<div className={styles.lineDetail}>
|
|
{hideHeader ? (
|
|
<div className={styles.expandedAgentCard}>{panel}</div>
|
|
) : (
|
|
panel
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fullDescription = getToolDescription(tool, workspaceCwd);
|
|
// Document exports carry a bounded, safe text preview. Keep the full answer
|
|
// in an expanded row instead of reducing it to the one-line header summary.
|
|
const result =
|
|
(tool.status === 'failed'
|
|
? formatSubscriptionQuotaDisplay(tool.rawOutput, t)
|
|
: undefined) ??
|
|
(transcriptRenderMode === 'document' && expanded
|
|
? (extractText(tool) ?? getToolResultSummary(tool))
|
|
: getToolResultSummary(tool));
|
|
const summaryShell = isShellToolName(tool.toolName);
|
|
const description = summaryShell
|
|
? getToolSummaryDescription(tool, workspaceCwd)
|
|
: fullDescription;
|
|
const displayName = localizeToolDisplayName(tool.toolName, t);
|
|
const elapsed =
|
|
waitingForApproval ||
|
|
isShellToolName(tool.toolName) ||
|
|
isWebFetchToolName(tool.toolName)
|
|
? ''
|
|
: formatElapsed(tool.startTime, isRunningTool ? now : tool.endTime);
|
|
|
|
const name = tool.toolName.toLowerCase();
|
|
const opensMonitorDetails =
|
|
name === 'monitor' &&
|
|
monitorDetailsAvailable &&
|
|
!monitorDetailsUnavailable &&
|
|
!hideHeader;
|
|
const isTodo = isTodoWriteToolName(name);
|
|
const todoItems = isTodo ? extractTodosFromToolCall(tool) : undefined;
|
|
const hasTodoList = !!todoItems && todoItems.length > 0;
|
|
const todoCompleted = todoItems
|
|
? todoItems.filter((td) => td.status === 'completed').length
|
|
: 0;
|
|
const isShell = isShellToolName(name);
|
|
const isSearch =
|
|
name === 'grep' ||
|
|
name === 'grep_search' ||
|
|
name === 'search' ||
|
|
name === 'glob';
|
|
const isRead = name === 'read' || name === 'read_file' || name === 'readfile';
|
|
const isAdvisor = isAdvisorToolName(name);
|
|
const filePreviewAction =
|
|
detailsVisible &&
|
|
(isRead ||
|
|
isEditToolName(name) ||
|
|
name === 'display_image' ||
|
|
name === 'zoom_image') ? (
|
|
<ToolFilePreviewButton
|
|
tool={tool}
|
|
workspaceCwd={workspaceCwd}
|
|
onOpen={onTurnOutputOpen}
|
|
/>
|
|
) : undefined;
|
|
// Every regular tool row expands on demand. Content controls only what the
|
|
// expanded card shows, never whether the user can open or close it —
|
|
// except while an opted-in host owns this pending Edit's diff preview.
|
|
// When a long description is expanded we move it out of the header into a
|
|
// wrapped block below, so the header drops its single-line copy.
|
|
const descExpandable = !isTodo && isDescriptionExpandable(description);
|
|
const expandable = !isForcedExpanded && !locksPendingEditApproval;
|
|
const interactive = opensMonitorDetails || expandable;
|
|
const toggleExpanded = () => {
|
|
userToggledRef.current = true;
|
|
setExpanded((value) => !value);
|
|
};
|
|
const fallbackToMonitorInline = () => {
|
|
setMonitorDetailsUnavailable(true);
|
|
if (!expandable) return;
|
|
toggleExpanded();
|
|
};
|
|
const tryOpenMonitorDetails = () => {
|
|
if (!monitorDetails) return;
|
|
openMonitorDetailsOnce(
|
|
monitorDetailsRequestRef,
|
|
() => monitorDetails.onOpen(tool),
|
|
fallbackToMonitorInline,
|
|
);
|
|
};
|
|
// Whether the expanded row renders a kind-specific detail view. When it does
|
|
// not, the expanded card renders the call's own arguments and recorded
|
|
// output; the collapsed row still shows the one-line result summary.
|
|
const detailView = hasDetailView(tool);
|
|
const showDescriptionInDetail = expanded && descExpandable;
|
|
const useMarkdownDetail = isRead || isAdvisor;
|
|
const hideDescriptionInHeader =
|
|
showDescriptionInDetail && !isShell && !isSearch && !isRead;
|
|
const expandedCardDetail = fullDescription;
|
|
// Contentless tools still get a titled card when the user opens them.
|
|
const showExpandedSummaryPanel =
|
|
expanded && !detailView && (!isTodo || (!hasTodoList && !result));
|
|
const showGenericDetails = !mcpApp && showExpandedSummaryPanel;
|
|
// A card outside the built-in detail renderers shows the call itself rather
|
|
// than repeating the header summary. The values are already redacted at the
|
|
// daemon boundary and carried by the ACP tool call; no extra read is needed.
|
|
const hasGenericArguments =
|
|
showGenericDetails &&
|
|
Boolean(tool.args && Object.keys(tool.args).length > 0);
|
|
const hasContentText =
|
|
tool.content?.some(
|
|
(block) =>
|
|
block.type === 'content' &&
|
|
typeof block.content?.text === 'string' &&
|
|
block.content.text.length > 0,
|
|
) ?? false;
|
|
const hasRawOutput =
|
|
tool.rawOutput !== undefined &&
|
|
tool.rawOutput !== null &&
|
|
(typeof tool.rawOutput !== 'string' || tool.rawOutput.length > 0);
|
|
const hasGenericResult =
|
|
showGenericDetails && (hasContentText || hasRawOutput);
|
|
const hasGenericPayload =
|
|
hasGenericArguments ||
|
|
hasGenericResult ||
|
|
(showGenericDetails &&
|
|
(isActiveToolStatus(tool.status) || tool.wasCancelled === true));
|
|
|
|
return (
|
|
<div className={styles.line} data-transcript-tool-call-id={tool.callId}>
|
|
{hideHeader && isRunningTool && elapsed && (
|
|
<div className={styles.lineMain}>
|
|
<ToolHeaderExtra
|
|
info={{
|
|
kind: getToolHeaderKind(tool),
|
|
tool,
|
|
displayName,
|
|
description: '',
|
|
elapsed,
|
|
workspaceCwd,
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{!hideHeader && (
|
|
<div
|
|
className={`${styles.lineMain} ${interactive ? styles.lineExpandable : ''}`}
|
|
title={
|
|
opensMonitorDetails
|
|
? undefined
|
|
: expandable
|
|
? expanded
|
|
? t('tool.collapseHint')
|
|
: t('tool.expand')
|
|
: undefined
|
|
}
|
|
aria-expanded={
|
|
opensMonitorDetails ? undefined : expandable ? expanded : undefined
|
|
}
|
|
role={interactive ? 'button' : undefined}
|
|
tabIndex={interactive ? 0 : undefined}
|
|
onClick={
|
|
interactive
|
|
? () => {
|
|
if (opensMonitorDetails) {
|
|
tryOpenMonitorDetails();
|
|
return;
|
|
}
|
|
toggleExpanded();
|
|
}
|
|
: undefined
|
|
}
|
|
onKeyDown={
|
|
interactive
|
|
? (event) => {
|
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
event.preventDefault();
|
|
if (opensMonitorDetails) {
|
|
tryOpenMonitorDetails();
|
|
return;
|
|
}
|
|
toggleExpanded();
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<ToolSummaryIcon tool={tool} />
|
|
<StatusIcon status={tool.status} />
|
|
<span className={styles.lineName}>{displayName}</span>
|
|
{isTodo && hasTodoList && (
|
|
<span className={styles.todoProgress}>
|
|
{todoCompleted}/{todoItems!.length}
|
|
</span>
|
|
)}
|
|
<ToolHeaderExtra
|
|
info={{
|
|
kind: getToolHeaderKind(tool),
|
|
tool,
|
|
displayName,
|
|
// A todo row carries its checklist in the body below; a redundant
|
|
// "Update Todos" description and the instant write duration would
|
|
// only clutter the header next to the progress count.
|
|
description: isTodo || hideDescriptionInHeader ? '' : description,
|
|
elapsed: isTodo ? '' : elapsed,
|
|
workspaceCwd,
|
|
}}
|
|
/>
|
|
{interactive && (
|
|
<span
|
|
className={
|
|
!opensMonitorDetails && expanded
|
|
? styles.lineChevronDown
|
|
: styles.lineChevronRight
|
|
}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{mcpApp &&
|
|
(!summaryOnly || expanded) &&
|
|
(mcpAppAllowed ? (
|
|
<McpApp
|
|
display={mcpApp}
|
|
toolId={tool.generatedUiToolCallId}
|
|
visible={detailsVisible}
|
|
/>
|
|
) : (
|
|
<GeneratedUiFallback display={mcpApp} />
|
|
))}
|
|
{!mcpApp && (!summaryOnly || expanded) && isTodo && hasTodoList && (
|
|
<TodoToolBody
|
|
tool={tool}
|
|
todos={todoItems!}
|
|
expanded={expanded}
|
|
title={displayName}
|
|
/>
|
|
)}
|
|
{/* Todo tool whose payload couldn't be parsed (e.g. malformed args):
|
|
fall back to the raw result summary so the row isn't blank. */}
|
|
{!mcpApp &&
|
|
(!summaryOnly || expanded) &&
|
|
isTodo &&
|
|
!hasTodoList &&
|
|
result && (
|
|
<div className={styles.lineOutput}>
|
|
{renderWithSessionLinks(result, transcriptRenderMode)}
|
|
</div>
|
|
)}
|
|
{!mcpApp && showExpandedSummaryPanel && (
|
|
<ToolExpandedCard
|
|
title={displayName}
|
|
detail={
|
|
hasGenericPayload &&
|
|
!showDescriptionInDetail &&
|
|
!hideHeader &&
|
|
!isTodo &&
|
|
!renderToolHeaderExtra
|
|
? undefined
|
|
: expandedCardDetail
|
|
}
|
|
status={tool.status}
|
|
action={filePreviewAction}
|
|
>
|
|
{hasGenericPayload ? (
|
|
<GenericToolDetails
|
|
tool={tool}
|
|
pending={isActiveToolStatus(tool.status)}
|
|
cancelled={tool.wasCancelled === true}
|
|
/>
|
|
) : null}
|
|
</ToolExpandedCard>
|
|
)}
|
|
{!mcpApp &&
|
|
!isTodo &&
|
|
!hideCollapsedOutput &&
|
|
result &&
|
|
!showExpandedSummaryPanel &&
|
|
(!expanded || !detailView) &&
|
|
(!summaryOnly || expanded) && (
|
|
<div
|
|
className={
|
|
expanded
|
|
? `${styles.lineOutput} ${styles.expandedLineOutput}`
|
|
: styles.lineOutput
|
|
}
|
|
>
|
|
{renderWithSessionLinks(result, transcriptRenderMode)}
|
|
</div>
|
|
)}
|
|
{!mcpApp &&
|
|
!isTodo &&
|
|
expanded &&
|
|
detailView &&
|
|
(!isWorkflow || detailsVisible) && (
|
|
<div
|
|
className={
|
|
isWorkflow
|
|
? `${styles.lineDetail} ${styles.workflowLineDetail}`
|
|
: useMarkdownDetail
|
|
? `${styles.lineDetail} ${styles.markdownLineDetail}`
|
|
: styles.lineDetail
|
|
}
|
|
>
|
|
<ToolDetails
|
|
tool={tool}
|
|
enabled={detailsVisible && (isShell || isRead)}
|
|
>
|
|
{(tool) =>
|
|
isWorkflow ? (
|
|
<WorkflowToolDetail
|
|
tool={tool}
|
|
displayName={displayName}
|
|
detail={expandedCardDetail}
|
|
result={result}
|
|
/>
|
|
) : isShell ? (
|
|
<ShellToolOutput tool={tool} />
|
|
) : isRead ? (
|
|
<ToolExpandedCard
|
|
title={displayName}
|
|
status={tool.status}
|
|
action={
|
|
detailsVisible ? (
|
|
<ToolFilePreviewButton
|
|
tool={tool}
|
|
workspaceCwd={workspaceCwd}
|
|
onOpen={onTurnOutputOpen}
|
|
/>
|
|
) : undefined
|
|
}
|
|
>
|
|
<ExpandedReadContent tool={tool} />
|
|
</ToolExpandedCard>
|
|
) : (
|
|
<ToolExpandedCard
|
|
title={displayName}
|
|
detail={expandedCardDetail}
|
|
status={tool.status}
|
|
action={filePreviewAction}
|
|
>
|
|
{(name === 'write_file' || name === 'writefile') && (
|
|
<ExpandedEditContent tool={tool} />
|
|
)}
|
|
{(name === 'edit' ||
|
|
name === 'write' ||
|
|
name === 'editfile') && (
|
|
<ExpandedEditContent tool={tool} />
|
|
)}
|
|
{isAskUserQuestionToolName(tool.toolName) && (
|
|
<ExpandedAskUserQuestionOutput tool={tool} />
|
|
)}
|
|
{isSkillToolName(name) && (
|
|
<ExpandedSkillOutput tool={tool} />
|
|
)}
|
|
{isAdvisor && (
|
|
<Markdown content={getAdvisorDisplayText(tool) ?? ''} />
|
|
)}
|
|
</ToolExpandedCard>
|
|
)
|
|
}
|
|
</ToolDetails>
|
|
</div>
|
|
)}
|
|
{tool.toolResult &&
|
|
(expanded ||
|
|
tool.toolResult.execution_status !== 'success' ||
|
|
tool.toolResult.capture_status === 'partial' ||
|
|
tool.toolResult.capture_status === 'unavailable' ||
|
|
tool.toolResult.delivery_status !== 'committed') && (
|
|
<ManagedToolResultSummary
|
|
result={tool.toolResult}
|
|
onOpen={onToolResultOpen}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}, areToolLinePropsEqual);
|
|
|
|
interface ThoughtLineHeaderProps {
|
|
isStreaming?: boolean;
|
|
expanded: boolean;
|
|
documentMode: boolean;
|
|
/**
|
|
* Thought content for the zh-CN translate button. Omitted while streaming —
|
|
* the button is hidden then — so streamed content growth does not defeat the
|
|
* header's memo boundary.
|
|
*/
|
|
translateContent?: string;
|
|
generateContent?: SessionContentGenerator;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
const ThoughtLineHeader = memo(function ThoughtLineHeader({
|
|
isStreaming,
|
|
expanded,
|
|
documentMode,
|
|
translateContent,
|
|
generateContent,
|
|
onToggle,
|
|
}: ThoughtLineHeaderProps) {
|
|
const { language, t } = useI18n();
|
|
return (
|
|
<div
|
|
className={`${styles.chatSummaryThoughtHeader}${
|
|
expanded ? ` ${styles.chatSummaryThoughtHeaderExpanded}` : ''
|
|
}`}
|
|
onClick={(event) => {
|
|
if (
|
|
!documentMode &&
|
|
event.currentTarget.contains(event.target as Node)
|
|
) {
|
|
onToggle();
|
|
}
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
disabled={documentMode}
|
|
tabIndex={documentMode ? -1 : undefined}
|
|
className={styles.chatSummaryThoughtSummary}
|
|
data-testid="compact-thinking-summary"
|
|
aria-expanded={documentMode ? undefined : expanded}
|
|
title={
|
|
documentMode
|
|
? undefined
|
|
: t(expanded ? 'thinking.collapse' : 'thinking.expand')
|
|
}
|
|
>
|
|
<span className={styles.chatSummaryThoughtIcon} aria-hidden="true">
|
|
<ThinkingDoneIcon />
|
|
</span>
|
|
<span
|
|
className={`${styles.chatSummaryThoughtLabel}${
|
|
isStreaming ? ` ${styles.chatSummaryThoughtLabelActive}` : ''
|
|
}`}
|
|
>
|
|
{t(isStreaming ? 'thinking.running' : 'thinking.done')}
|
|
</span>
|
|
</button>
|
|
{language === 'zh-CN' &&
|
|
translateContent !== undefined &&
|
|
generateContent && (
|
|
<ThinkingTranslateButton
|
|
content={translateContent}
|
|
generateContent={generateContent}
|
|
className={styles.chatSummaryThoughtTranslate}
|
|
/>
|
|
)}
|
|
<span
|
|
className={
|
|
expanded
|
|
? styles.chatSummaryThoughtChevronDown
|
|
: styles.chatSummaryThoughtChevronRight
|
|
}
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
const ThoughtLine = memo(function ThoughtLine({
|
|
content,
|
|
isStreaming,
|
|
generateContent,
|
|
}: {
|
|
content: string;
|
|
isStreaming?: boolean;
|
|
generateContent?: SessionContentGenerator;
|
|
}) {
|
|
const transcriptRenderMode = useTranscriptRenderMode();
|
|
const documentMode = transcriptRenderMode === 'document';
|
|
const documentExpanded = useTranscriptDocumentExpanded();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const showContent = documentMode ? documentExpanded : expanded;
|
|
const handleToggle = useCallback(() => {
|
|
if (!documentMode) setExpanded((value) => !value);
|
|
}, [documentMode]);
|
|
return (
|
|
<div className={styles.chatSummaryThought}>
|
|
<ThoughtLineHeader
|
|
isStreaming={isStreaming}
|
|
expanded={showContent}
|
|
documentMode={documentMode}
|
|
translateContent={documentMode || isStreaming ? undefined : content}
|
|
generateContent={generateContent}
|
|
onToggle={handleToggle}
|
|
/>
|
|
{showContent && (
|
|
<div className={styles.chatSummaryThoughtContent}>
|
|
<Markdown
|
|
content={content}
|
|
source="thinking"
|
|
isStreaming={isStreaming}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export const ToolGroup = memo(function ToolGroup({
|
|
tools,
|
|
compactSummary = false,
|
|
thoughts,
|
|
pendingApproval,
|
|
workspaceCwd,
|
|
onTurnOutputOpen,
|
|
onToolResultOpen,
|
|
isLocateFlashing = false,
|
|
generateContent,
|
|
}: ToolGroupProps) {
|
|
const { t } = useI18n();
|
|
const transcriptRenderMode = useTranscriptRenderMode();
|
|
const documentMode = transcriptRenderMode === 'document';
|
|
const documentExpanded = useTranscriptDocumentExpanded();
|
|
const subagentDetails = useSubagentDetails();
|
|
const monitorDetails = useMonitorDetails();
|
|
const monitorDetailsAvailable = monitorDetails !== undefined;
|
|
const [monitorDetailsUnavailable, setMonitorDetailsUnavailable] =
|
|
useState(false);
|
|
const [chatExpanded, setChatExpanded] = useState(false);
|
|
const showGroupContent = documentMode ? documentExpanded : chatExpanded;
|
|
const monitorDetailsRequestRef = useRef<object | null>(null);
|
|
const hasRunningTool = hasActiveAgents(tools);
|
|
const activeTool =
|
|
tools.find(
|
|
(tool) =>
|
|
isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool),
|
|
) ?? (tools.length > 0 ? getActiveTool(tools) : undefined);
|
|
// Single-tool identity stays available for the compact summary and the
|
|
// subagent/monitor drawer shortcut even when thoughts are folded in; only
|
|
// the force-expanded content dump is suppressed for thought groups.
|
|
const singleTool = tools.length === 1 ? tools[0] : undefined;
|
|
const compactToolLines = !!thoughts?.length;
|
|
const singleSubagent =
|
|
singleTool && isSubAgentToolCall(singleTool) ? singleTool : undefined;
|
|
const singleMonitor =
|
|
singleTool && singleTool.toolName.toLowerCase() === 'monitor'
|
|
? singleTool
|
|
: undefined;
|
|
const singleMcpApp = singleTool
|
|
? getMcpAppDisplay(singleTool.rawOutput)
|
|
: undefined;
|
|
const singleMcpAppResourceUri = singleMcpApp?.resourceUri;
|
|
const hasMcpApp = tools.some((tool) => getMcpAppDisplay(tool.rawOutput));
|
|
const hasWorkflow = tools.some((tool) => isWorkflowToolName(tool.toolName));
|
|
const hasForegroundActiveTool = tools.some(
|
|
(tool) =>
|
|
isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool),
|
|
);
|
|
const streamingThought = thoughts?.find((thought) => thought.isStreaming);
|
|
const animateSummary =
|
|
hasRunningTool && hasForegroundActiveTool
|
|
? true
|
|
: streamingThought !== undefined;
|
|
// A detached agent gets neither the running shimmer nor a ticking timer, so
|
|
// without a marker its card is indistinguishable from a finished one. The
|
|
// marker is CSS-only and only fills the gap where nothing else on the row
|
|
// already moves.
|
|
const marksDetachedAgent =
|
|
!animateSummary &&
|
|
tools.some(
|
|
(tool) =>
|
|
isActiveToolStatus(tool.status) && isBackgroundSubAgentToolCall(tool),
|
|
);
|
|
const opensSubagentDetails = Boolean(
|
|
!compactSummary && singleSubagent && subagentDetails,
|
|
);
|
|
const opensMonitorDetails = Boolean(
|
|
!compactSummary &&
|
|
singleMonitor &&
|
|
monitorDetailsAvailable &&
|
|
!monitorDetailsUnavailable,
|
|
);
|
|
const opensToolDetails = opensSubagentDetails || opensMonitorDetails;
|
|
const unavailableReason =
|
|
!compactSummary && singleSubagent
|
|
? getSubagentDetailsUnavailableReason(singleSubagent)
|
|
: undefined;
|
|
const summaryIconTool = hasRunningTool ? (activeTool ?? tools[0]) : tools[0];
|
|
const hasApprovalTool =
|
|
pendingApproval?.toolCallId &&
|
|
tools.some((t) => toolContainsCallId(t, pendingApproval.toolCallId!));
|
|
const parallelAgentsByFirstCallId = new Map<string, ACPToolCall[]>();
|
|
const groupedAgentCallIds = new Set<string>();
|
|
for (let i = 0; compactSummary && i < tools.length; i++) {
|
|
if (!isSubAgentToolCall(tools[i])) continue;
|
|
let end = i + 1;
|
|
while (end < tools.length && isSubAgentToolCall(tools[end])) end++;
|
|
if (end - i >= 2) {
|
|
const agents = tools.slice(i, end);
|
|
parallelAgentsByFirstCallId.set(agents[0].callId, agents);
|
|
for (const agent of agents.slice(1))
|
|
groupedAgentCallIds.add(agent.callId);
|
|
}
|
|
i = end - 1;
|
|
}
|
|
useEffect(() => {
|
|
setMonitorDetailsUnavailable(false);
|
|
setChatExpanded(false);
|
|
monitorDetailsRequestRef.current = null;
|
|
}, [monitorDetailsAvailable, singleMonitor?.callId]);
|
|
useEffect(() => {
|
|
if (singleMcpAppResourceUri || hasMcpApp) {
|
|
setChatExpanded(true);
|
|
}
|
|
}, [singleMcpAppResourceUri, hasMcpApp]);
|
|
|
|
const tryOpenMonitorDetails = () => {
|
|
if (!singleMonitor || !monitorDetails) return;
|
|
openMonitorDetailsOnce(
|
|
monitorDetailsRequestRef,
|
|
() => monitorDetails.onOpen(singleMonitor),
|
|
() => {
|
|
setMonitorDetailsUnavailable(true);
|
|
setChatExpanded((value) => !value);
|
|
},
|
|
);
|
|
};
|
|
|
|
if (!hasApprovalTool) {
|
|
return (
|
|
<div className={isLocateFlashing ? flashStyles.flash : undefined}>
|
|
<button
|
|
type="button"
|
|
disabled={documentMode}
|
|
aria-disabled={!!unavailableReason || undefined}
|
|
tabIndex={documentMode ? -1 : undefined}
|
|
className={styles.chatSummary}
|
|
onClick={() => {
|
|
if (documentMode || unavailableReason) return;
|
|
if (opensSubagentDetails && singleSubagent && subagentDetails) {
|
|
subagentDetails.onOpen(singleSubagent);
|
|
return;
|
|
}
|
|
if (opensMonitorDetails && singleMonitor && monitorDetails) {
|
|
tryOpenMonitorDetails();
|
|
return;
|
|
}
|
|
setChatExpanded((value) => !value);
|
|
}}
|
|
aria-expanded={
|
|
documentMode || opensToolDetails ? undefined : chatExpanded
|
|
}
|
|
title={
|
|
unavailableReason
|
|
? t(unavailableReason)
|
|
: documentMode || opensToolDetails
|
|
? undefined
|
|
: showGroupContent
|
|
? t('tool.collapseHint')
|
|
: t('tool.expand')
|
|
}
|
|
>
|
|
<span
|
|
className={
|
|
marksDetachedAgent
|
|
? `${styles.chatSummaryIcon} ${styles.chatSummaryIconDetached}`
|
|
: styles.chatSummaryIcon
|
|
}
|
|
aria-hidden="true"
|
|
>
|
|
{streamingThought ? (
|
|
<ThinkingDoneIcon />
|
|
) : summaryIconTool ? (
|
|
<ToolSummaryIcon tool={summaryIconTool} />
|
|
) : (
|
|
<ToolGroupIcon />
|
|
)}
|
|
</span>
|
|
<span
|
|
className={
|
|
animateSummary
|
|
? `${styles.chatSummaryText} ${styles.chatSummaryTextActive}`
|
|
: styles.chatSummaryText
|
|
}
|
|
>
|
|
{streamingThought ? (
|
|
t('thinking.running')
|
|
) : singleTool ? (
|
|
<SingleToolSummary
|
|
tool={singleTool}
|
|
workspaceCwd={workspaceCwd}
|
|
/>
|
|
) : (
|
|
formatToolGroupSummary(tools, t, workspaceCwd, compactSummary)
|
|
)}
|
|
</span>
|
|
<span
|
|
className={
|
|
showGroupContent
|
|
? styles.chatChevronDown
|
|
: styles.chatChevronRight
|
|
}
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
{(showGroupContent || hasMcpApp || hasWorkflow) && (
|
|
<div
|
|
aria-hidden={!showGroupContent}
|
|
// Set on the DOM node, not as a JSX prop: react-dom 18 — inside
|
|
// this package's supported peer range — has no `inert` property
|
|
// entry and drops a boolean on an unknown attribute, so the
|
|
// collapsed content (which stays mounted) would keep its
|
|
// focusable ToolLine rows while aria-hidden hid them from
|
|
// assistive tech. Same pattern as ParallelAgentsGroup.
|
|
ref={(element) => {
|
|
element?.toggleAttribute('inert', !showGroupContent);
|
|
}}
|
|
className={
|
|
showGroupContent
|
|
? styles.chatSummaryContentClip
|
|
: `${styles.chatSummaryContentClip} ${styles.chatSummaryContentCollapsed}`
|
|
}
|
|
style={
|
|
!showGroupContent && hasMcpApp && !hasWorkflow
|
|
? { display: 'none' }
|
|
: undefined
|
|
}
|
|
>
|
|
<div className={styles.chatSummaryContentInner}>
|
|
<div className={`${styles.group} ${styles.chatSummaryGroup}`}>
|
|
{tools.map((tool) => {
|
|
if (groupedAgentCallIds.has(tool.callId)) return null;
|
|
const parallelAgents = parallelAgentsByFirstCallId.get(
|
|
tool.callId,
|
|
);
|
|
return (
|
|
<Fragment key={tool.callId}>
|
|
{thoughts
|
|
?.filter(
|
|
(thought) => thought.beforeToolCallId === tool.callId,
|
|
)
|
|
.map((thought, index) => (
|
|
<ThoughtLine
|
|
key={`thought-${tool.callId}-${index}`}
|
|
content={thought.content}
|
|
isStreaming={thought.isStreaming}
|
|
generateContent={generateContent}
|
|
/>
|
|
))}
|
|
{parallelAgents ? (
|
|
<>
|
|
<div
|
|
className={styles.chatSummaryParallelAgents}
|
|
data-testid="compact-parallel-agents"
|
|
>
|
|
<ParallelAgentsGroup
|
|
agents={parallelAgents}
|
|
pendingApproval={pendingApproval}
|
|
/>
|
|
</div>
|
|
{parallelAgents
|
|
.slice(1)
|
|
.flatMap((agent) =>
|
|
thoughts
|
|
?.filter(
|
|
(thought) =>
|
|
thought.beforeToolCallId === agent.callId,
|
|
)
|
|
.map((thought, index) => (
|
|
<ThoughtLine
|
|
key={`thought-${agent.callId}-${index}`}
|
|
content={thought.content}
|
|
isStreaming={thought.isStreaming}
|
|
generateContent={generateContent}
|
|
/>
|
|
)),
|
|
)}
|
|
</>
|
|
) : (
|
|
<ToolLine
|
|
tool={tool}
|
|
approval={pendingApproval}
|
|
workspaceCwd={workspaceCwd}
|
|
onTurnOutputOpen={onTurnOutputOpen}
|
|
onToolResultOpen={onToolResultOpen}
|
|
summaryOnly={!singleTool || compactToolLines}
|
|
forceExpanded={
|
|
documentMode || (!!singleTool && !compactToolLines)
|
|
}
|
|
detailsVisible={showGroupContent}
|
|
hideHeader={!!singleTool && !compactToolLines}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
{thoughts
|
|
?.filter((thought) => thought.beforeToolCallId === undefined)
|
|
.map((thought, index) => (
|
|
<ThoughtLine
|
|
key={`thought-trailing-${index}`}
|
|
content={thought.content}
|
|
isStreaming={thought.isStreaming}
|
|
generateContent={generateContent}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`${styles.group}${
|
|
isLocateFlashing ? ` ${flashStyles.flash}` : ''
|
|
}`}
|
|
>
|
|
{tools.map((tool) => (
|
|
<ToolLine
|
|
key={tool.callId}
|
|
tool={tool}
|
|
approval={pendingApproval}
|
|
workspaceCwd={workspaceCwd}
|
|
onTurnOutputOpen={onTurnOutputOpen}
|
|
onToolResultOpen={onToolResultOpen}
|
|
forceExpanded={documentMode}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
});
|