mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
refactor(desktop): flatten nested tool activity
This commit is contained in:
@@ -3,12 +3,16 @@ import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
||||
import { CopyButton } from '@/components/ui/CopyButton'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export type CodeViewerChrome = 'card' | 'embedded'
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
language?: string
|
||||
maxLines?: number
|
||||
showLineNumbers?: boolean
|
||||
wrapLongLines?: boolean
|
||||
chrome?: CodeViewerChrome
|
||||
label?: string
|
||||
}
|
||||
|
||||
const warmPrismTheme: PrismTheme = {
|
||||
@@ -282,9 +286,18 @@ function CodeArea({
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = false, wrapLongLines = false }: Props) {
|
||||
export function CodeViewer({
|
||||
code,
|
||||
language,
|
||||
maxLines = 20,
|
||||
showLineNumbers = false,
|
||||
wrapLongLines = false,
|
||||
chrome = 'card',
|
||||
label,
|
||||
}: Props) {
|
||||
const t = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isEmbedded = chrome === 'embedded'
|
||||
|
||||
const allLines = code.split('\n')
|
||||
const isTruncated = !expanded && allLines.length > maxLines
|
||||
@@ -296,16 +309,35 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = fa
|
||||
const showExpandToggle = allLines.length > maxLines
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]">
|
||||
<div
|
||||
data-code-viewer-chrome={chrome}
|
||||
className={
|
||||
isEmbedded
|
||||
? 'overflow-hidden bg-[var(--color-code-bg)]'
|
||||
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]'
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold uppercase tracking-[0.14em]">{languageLabel}</span>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{label ? (
|
||||
<>
|
||||
<span className="font-semibold uppercase tracking-[0.14em]">{label}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className={`${label ? 'font-medium' : 'font-semibold'} uppercase tracking-[0.14em]`}>
|
||||
{languageLabel}
|
||||
</span>
|
||||
<span>{lineCountLabel}</span>
|
||||
</div>
|
||||
<CopyButton
|
||||
text={code}
|
||||
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
|
||||
className={
|
||||
isEmbedded
|
||||
? 'rounded-[var(--radius-sm)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]'
|
||||
: 'rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1545,6 +1545,15 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(screen.queryByText(/Read .*example\.ts.*done/i)).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/i }))
|
||||
expect(screen.getByText(/Read .*example\.ts.*done/i)).toBeTruthy()
|
||||
|
||||
const agentRow = container.querySelector('[data-agent-call-layout="row"]')
|
||||
expect(agentRow).toBeTruthy()
|
||||
expect(agentRow?.className).not.toMatch(/\b(?:border|rounded)-/)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand agent' }))
|
||||
const nestedToolRow = container.querySelector('[data-tool-call-chrome="row"]')
|
||||
expect(nestedToolRow).toBeTruthy()
|
||||
expect(nestedToolRow?.textContent).toContain('Read')
|
||||
expect(container.textContent).toContain('Agent')
|
||||
})
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
/**
|
||||
* `card` is the standalone bordered block. `row` strips the border, the ink icon
|
||||
* square and the bold name so the call reads as one line inside an expanded
|
||||
* activity group — the fix for "one card per tool call" (#1177). Its own output
|
||||
* still gets a card, but only once the row is expanded.
|
||||
* activity group. Expanded row details stay inline on a nested guide instead of
|
||||
* rebuilding the card stack that the activity-group treatment removed (#1177).
|
||||
*/
|
||||
export type ToolCallChrome = 'card' | 'row'
|
||||
|
||||
@@ -156,8 +156,14 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
)
|
||||
const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : ''
|
||||
|
||||
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
|
||||
const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t])
|
||||
const preview = useMemo(
|
||||
() => renderPreview(toolName, obj, result, t, isRow),
|
||||
[isRow, obj, result, toolName, t],
|
||||
)
|
||||
const details = useMemo(
|
||||
() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined, isRow),
|
||||
[isPending, isRow, obj, partialInput, toolName, t],
|
||||
)
|
||||
const hasResultDetails = Boolean(result && extractTextContent(result.content))
|
||||
const hasEditPreview = toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string'
|
||||
const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string'
|
||||
@@ -211,13 +217,16 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={
|
||||
isRow
|
||||
? ''
|
||||
: `overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
|
||||
compact ? 'mb-0' : 'mb-2'
|
||||
}`
|
||||
}>
|
||||
<div
|
||||
data-tool-call-chrome={chrome}
|
||||
className={
|
||||
isRow
|
||||
? ''
|
||||
: `overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
|
||||
compact ? 'mb-0' : 'mb-2'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-chat-disclosure="true"
|
||||
@@ -315,13 +324,14 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
|
||||
{expandable && expanded && (
|
||||
<div
|
||||
data-tool-call-details={isRow ? 'inline' : 'panel'}
|
||||
data-tool-output-error={result?.isError ? 'true' : undefined}
|
||||
className={
|
||||
isRow
|
||||
? `mb-2 mt-1 space-y-2.5 rounded-[var(--radius-lg)] border px-3 py-2.5 ${
|
||||
? `mb-2 ml-2 mt-1 space-y-2.5 border-l py-1 pl-3 ${
|
||||
result?.isError
|
||||
? 'border-[var(--color-error-soft-hover)] bg-[var(--color-error-soft)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]'
|
||||
? 'border-[var(--color-error-soft-hover)]'
|
||||
: 'border-[var(--color-border)]'
|
||||
}`
|
||||
: `space-y-2.5 border-t px-4 py-3.5 ${
|
||||
result?.isError
|
||||
@@ -464,6 +474,7 @@ function renderPreview(
|
||||
obj: Record<string, unknown>,
|
||||
result?: { content: unknown; isError: boolean } | null,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
embedded = false,
|
||||
) {
|
||||
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
|
||||
// Must match the terminal-card condition below exactly. When they diverged, a
|
||||
@@ -473,7 +484,7 @@ function renderPreview(
|
||||
const shellCommand = isShellTool(toolName) && typeof obj.command === 'string' ? obj.command : null
|
||||
const echoesInTerminal = shellCommand !== null
|
||||
const resultText = getVisibleResultText(toolName, result, echoesInTerminal)
|
||||
const resultOutput = result && resultText ? renderResultOutput(result, resultText, t) : null
|
||||
const resultOutput = result && resultText ? renderResultOutput(result, resultText, t, embedded) : null
|
||||
|
||||
if (toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string') {
|
||||
return (
|
||||
@@ -745,17 +756,49 @@ function renderResultOutput(
|
||||
result: { content: unknown; isError: boolean },
|
||||
text: string,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
embedded = false,
|
||||
) {
|
||||
const label = result.isError
|
||||
? t?.('tool.errorOutput') ?? 'Error Output'
|
||||
: t?.('tool.toolOutput') ?? 'Tool Output'
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
<InlineImageGallery text={text} />
|
||||
{result.isError ? (
|
||||
<div data-tool-detail-surface="embedded" className="overflow-hidden bg-[var(--color-error-soft)]">
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-error-soft-hover)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-error)]">
|
||||
<span>{label}</span>
|
||||
<CopyButton
|
||||
text={text}
|
||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-error)] transition-colors hover:bg-[var(--color-error-soft-hover)]"
|
||||
/>
|
||||
</div>
|
||||
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words px-3 py-2 font-mono text-[12px] leading-[1.45] text-[var(--color-error)]">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<CodeViewer code={text} language="plaintext" maxLines={18} chrome="embedded" label={label} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineImageGallery text={text} />
|
||||
<div className={`overflow-hidden rounded-[var(--radius-lg)] border ${
|
||||
result.isError
|
||||
? 'border-[var(--color-error)] bg-[var(--color-error-container)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
}`}>
|
||||
<div
|
||||
data-tool-detail-surface="card"
|
||||
className={`overflow-hidden rounded-[var(--radius-lg)] border ${
|
||||
result.isError
|
||||
? 'border-[var(--color-error)] bg-[var(--color-error-container)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
|
||||
<span>{result.isError ? t?.('tool.errorOutput') ?? 'Error Output' : t?.('tool.toolOutput') ?? 'Tool Output'}</span>
|
||||
<span>{label}</span>
|
||||
<CopyButton
|
||||
text={text}
|
||||
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
@@ -778,15 +821,16 @@ function renderDetails(
|
||||
obj: Record<string, unknown>,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
partialInput?: string,
|
||||
embedded = false,
|
||||
) {
|
||||
if (partialInput) {
|
||||
if (toolName === 'Write') {
|
||||
const writerContent = extractPartialJsonStringField(partialInput, 'content')
|
||||
if (writerContent !== null) {
|
||||
return renderWriterPreview(writerContent, t)
|
||||
return renderWriterPreview(writerContent, t, embedded)
|
||||
}
|
||||
}
|
||||
return renderPartialInput(partialInput, t)
|
||||
return renderPartialInput(partialInput, t, embedded)
|
||||
}
|
||||
|
||||
if (toolName === 'Edit' || toolName === 'Write') {
|
||||
@@ -804,10 +848,15 @@ function renderDetails(
|
||||
}
|
||||
|
||||
const text = JSON.stringify(displayed, null, 2)
|
||||
const label = t?.('tool.toolInput') ?? 'Tool Input'
|
||||
if (embedded) {
|
||||
return <CodeViewer code={text} language="json" maxLines={18} chrome="embedded" label={label} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div data-tool-detail-surface="card" className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
|
||||
<span>{t?.('tool.toolInput') ?? 'Tool Input'}</span>
|
||||
<span>{label}</span>
|
||||
<CopyButton
|
||||
text={text}
|
||||
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
@@ -977,6 +1026,7 @@ function formatCount(count: number): string {
|
||||
function renderWriterPreview(
|
||||
content: string,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
embedded = false,
|
||||
) {
|
||||
const contentStats = countContentStats(content)
|
||||
const lines = content.length === 0 ? [] : content.split('\n')
|
||||
@@ -1000,7 +1050,14 @@ function renderWriterPreview(
|
||||
}, t)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div
|
||||
data-tool-detail-surface={embedded ? 'embedded' : 'card'}
|
||||
className={
|
||||
embedded
|
||||
? 'overflow-hidden bg-[var(--color-surface)]'
|
||||
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
|
||||
<span>{t?.('tool.writerPreview') ?? 'Writer'}</span>
|
||||
<span className="font-mono normal-case tracking-normal tabular-nums">
|
||||
@@ -1017,13 +1074,28 @@ function renderWriterPreview(
|
||||
function renderPartialInput(
|
||||
partialInput: string,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
embedded = false,
|
||||
) {
|
||||
const formattedInput = formatPartialJsonInput(partialInput)
|
||||
const label = t?.('tool.partialInput') ?? 'Partial input'
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<CodeViewer
|
||||
code={formattedInput}
|
||||
language="json"
|
||||
maxLines={8}
|
||||
wrapLongLines
|
||||
chrome="embedded"
|
||||
label={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div data-tool-detail-surface="card" className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div className="border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
|
||||
{t?.('tool.partialInput') ?? 'Partial input'}
|
||||
{label}
|
||||
</div>
|
||||
<CodeViewer code={formattedInput} language="json" maxLines={8} wrapLongLines />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { BookMarked, ChevronDown, ChevronRight, CircleCheck, Settings } from 'lucide-react'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
import { ToolCallBlock, type ToolCallChrome } from './ToolCallBlock'
|
||||
import { ActivityGroup } from './ActivityGroup'
|
||||
import { ThinkingBlock } from './ThinkingBlock'
|
||||
import {
|
||||
@@ -451,25 +451,19 @@ function AgentToolGroup({
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="relative border-t border-[var(--color-border)] py-3 pl-5 pr-3.5">
|
||||
<div className="absolute bottom-6 left-[11px] top-4 w-px rounded-full bg-[var(--color-border)]" />
|
||||
<div className="space-y-2">
|
||||
<div className="px-3.5 pb-2.5 pt-0.5">
|
||||
<div className="ml-1.5 flex flex-col border-l border-[var(--color-border)] pl-4">
|
||||
{toolCalls.map((toolCall) => (
|
||||
<div key={toolCall.id} className="relative pl-7">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-[11px] top-1/2 h-px w-4 -translate-y-1/2 bg-[var(--color-border)]" />
|
||||
<div className="absolute left-[8px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_0_0_2px_var(--color-surface)]" />
|
||||
</div>
|
||||
<AgentCallCard
|
||||
sessionId={sessionId}
|
||||
toolCall={toolCall}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
|
||||
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
|
||||
showOpenRun={showOpenRun}
|
||||
/>
|
||||
</div>
|
||||
<AgentCallCard
|
||||
key={toolCall.id}
|
||||
sessionId={sessionId}
|
||||
toolCall={toolCall}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
|
||||
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
|
||||
showOpenRun={showOpenRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,8 +529,8 @@ function AgentCallCard({
|
||||
const canOpenRun = showOpenRun && !!sessionId && !!toolCall.toolUseId
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
|
||||
<div data-agent-call-layout="row">
|
||||
<div className="-mx-2 flex w-[calc(100%+1rem)] items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-outline)]">smart_toy</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -616,14 +610,14 @@ function AgentCallCard({
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--color-border)] px-3 py-3">
|
||||
<div className="mb-2 ml-2 mt-1 border-l border-[var(--color-border)] py-1 pl-3">
|
||||
{errorText && (
|
||||
<div className="mb-3 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-[11px] text-[var(--color-on-error-container)]">
|
||||
<div className="mb-2 bg-[var(--color-error-soft)] px-3 py-2 text-[11px] text-[var(--color-error)]">
|
||||
{errorText}
|
||||
</div>
|
||||
)}
|
||||
{childToolCalls.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
{childToolCalls.map((childToolCall) => (
|
||||
<ToolCallTree
|
||||
key={childToolCall.id}
|
||||
@@ -631,15 +625,16 @@ function AgentCallCard({
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
compact
|
||||
chrome="row"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : outputSummary ? (
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('agentStatus.noActivity')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')}
|
||||
</div>
|
||||
)}
|
||||
@@ -664,30 +659,40 @@ function ToolCallTree({
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
compact = false,
|
||||
chrome = 'card',
|
||||
}: {
|
||||
toolCall: ToolCall
|
||||
resultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
compact?: boolean
|
||||
chrome?: ToolCallChrome
|
||||
}) {
|
||||
const result = resultMap.get(toolCall.toolUseId)
|
||||
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
|
||||
const isRow = chrome === 'row'
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-1' : ''}>
|
||||
<div className={!isRow && compact ? 'space-y-1' : ''}>
|
||||
<ToolCallBlock
|
||||
toolName={toolCall.toolName}
|
||||
input={toolCall.input}
|
||||
result={result ? { content: result.content, isError: result.isError } : null}
|
||||
compact={compact}
|
||||
chrome={chrome}
|
||||
isPending={toolCall.isPending}
|
||||
status={toolCall.status}
|
||||
partialInput={toolCall.partialInput}
|
||||
durationMs={toolCallDurationMs(toolCall, result)}
|
||||
/>
|
||||
{childToolCalls.length > 0 && (
|
||||
<div className={compact ? 'ml-4 border-l border-[var(--color-border)] pl-3' : 'mb-2 ml-16 border-l border-[var(--color-border)] pl-3'}>
|
||||
<div className="space-y-1">
|
||||
<div className={
|
||||
isRow
|
||||
? 'ml-2 border-l border-[var(--color-border)] pl-3'
|
||||
: compact
|
||||
? 'ml-4 border-l border-[var(--color-border)] pl-3'
|
||||
: 'mb-2 ml-16 border-l border-[var(--color-border)] pl-3'
|
||||
}>
|
||||
<div className={isRow ? 'space-y-0.5' : 'space-y-1'}>
|
||||
{childToolCalls.map((childToolCall) => (
|
||||
<ToolCallTree
|
||||
key={childToolCall.id}
|
||||
@@ -695,6 +700,7 @@ function ToolCallTree({
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
compact
|
||||
chrome={chrome}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -349,6 +349,35 @@ describe('chat blocks', () => {
|
||||
expect(container.textContent).not.toContain('const answer = 42')
|
||||
})
|
||||
|
||||
it('keeps expanded row input and output inline instead of nesting detail cards', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
chrome="row"
|
||||
toolName="TaskCreate"
|
||||
input={{ subject: 'Inspect activity UI', description: 'Find the nested card treatment' }}
|
||||
result={{ content: 'Task #1 created successfully', isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const row = container.querySelector('[data-tool-call-chrome="row"]')
|
||||
const disclosure = row?.querySelector<HTMLButtonElement>('[data-chat-disclosure="true"]')
|
||||
expect(disclosure).toBeTruthy()
|
||||
fireEvent.click(disclosure!)
|
||||
|
||||
const details = container.querySelector('[data-tool-call-details="inline"]')
|
||||
expect(details).toBeTruthy()
|
||||
expect(details?.className).not.toContain('rounded-')
|
||||
|
||||
const viewers = [...container.querySelectorAll('[data-code-viewer-chrome]')]
|
||||
expect(viewers).toHaveLength(2)
|
||||
expect(viewers.every((viewer) => viewer.getAttribute('data-code-viewer-chrome') === 'embedded')).toBe(true)
|
||||
// Each section owns one compact header and one copy action. The previous
|
||||
// nested Tool Output -> PLAINTEXT and Tool Input -> JSON cards doubled both.
|
||||
expect(screen.getAllByRole('button', { name: 'Copy' })).toHaveLength(2)
|
||||
expect((details?.textContent?.match(/Tool Output/g) ?? [])).toHaveLength(1)
|
||||
expect((details?.textContent?.match(/Tool Input/g) ?? [])).toHaveLength(1)
|
||||
})
|
||||
|
||||
// #1149: bash stdout used to be dropped entirely — the card showed the command
|
||||
// three times and the result zero times. Output is now echoed into the terminal.
|
||||
it('echoes bash stdout into the terminal card when expanded', () => {
|
||||
|
||||
Reference in New Issue
Block a user