diff --git a/desktop/src/components/chat/CodeViewer.tsx b/desktop/src/components/chat/CodeViewer.tsx index 662aa6f4..fbeba957 100644 --- a/desktop/src/components/chat/CodeViewer.tsx +++ b/desktop/src/components/chat/CodeViewer.tsx @@ -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 ( -
+
{/* Header */}
-
- {languageLabel} +
+ {label ? ( + <> + {label} + + + ) : null} + + {languageLabel} + {lineCountLabel}
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index db0d3820..9cd52e34 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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') }) diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index 842650c5..084ebb30 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -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 ( -
+
{expanded && ( -
-
-
+
+
{toolCalls.map((toolCall) => ( -
-
-
-
-
- -
+ ))}
@@ -535,8 +529,8 @@ function AgentCallCard({ const canOpenRun = showOpenRun && !!sessionId && !!toolCall.toolUseId return ( -
-
+
+
smart_toy
@@ -616,14 +610,14 @@ function AgentCallCard({
{expanded && ( -
+
{errorText && ( -
+
{errorText}
)} {childToolCalls.length > 0 ? ( -
+
{childToolCalls.map((childToolCall) => ( ))}
) : outputSummary ? ( -
+
{t('agentStatus.noActivity')}
) : ( -
+
{status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')}
)} @@ -664,30 +659,40 @@ function ToolCallTree({ resultMap, childToolCallsByParent, compact = false, + chrome = 'card', }: { toolCall: ToolCall resultMap: Map childToolCallsByParent: Map compact?: boolean + chrome?: ToolCallChrome }) { const result = resultMap.get(toolCall.toolUseId) const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? [] + const isRow = chrome === 'row' return ( -
+
{childToolCalls.length > 0 && ( -
-
+
+
{childToolCalls.map((childToolCall) => ( ))}
diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 3865ca52..9fd7fb1f 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -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( + , + ) + + const row = container.querySelector('[data-tool-call-chrome="row"]') + const disclosure = row?.querySelector('[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', () => {