diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index 9e68e81..0764ec6 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -294,19 +294,6 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro return footerByMessageId; }, [visibleMessages, turnRuns]); - // Find the last reasoning part across all assistant messages so we can - // auto-collapse earlier reasoning blocks when a new one starts. - const lastReasoningPartId = useMemo(() => { - for (let i = visibleMessages.length - 1; i >= 0; i--) { - const entry = visibleMessages[i]; - if (!entry || entry.info.role !== "assistant") continue; - for (let j = entry.parts.length - 1; j >= 0; j--) { - const part = entry.parts[j]; - if (part?.type === "reasoning") return part.id; - } - } - return undefined; - }, [visibleMessages]); const firstUserMessageIndex = useMemo( () => visibleMessages.findIndex((message) => message.info.role === "user"), [visibleMessages], @@ -342,7 +329,6 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro forkFromMessage(entry.info.id) @@ -365,7 +351,6 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro firstUserMessageIndex, visibleMessages, turnFooterByMessageId, - lastReasoningPartId, forkFromMessage, revertToMessage, expandedUserMessages, diff --git a/src/components/message-list/MessageBubble.tsx b/src/components/message-list/MessageBubble.tsx index ca67716..b531f35 100644 --- a/src/components/message-list/MessageBubble.tsx +++ b/src/components/message-list/MessageBubble.tsx @@ -19,7 +19,6 @@ import type { TurnFooter } from "./types"; export const MessageBubble = memo(function MessageBubble({ entry, turnFooter, - lastReasoningPartId, onFork, onRevert, expandedUserMessages, @@ -29,7 +28,6 @@ export const MessageBubble = memo(function MessageBubble({ }: { entry: TranscriptMessageEntry; turnFooter?: TurnFooter; - lastReasoningPartId?: string; onFork?: () => void; onRevert?: () => void; expandedUserMessages?: ReadonlySet; @@ -140,7 +138,6 @@ export const MessageBubble = memo(function MessageBubble({ key={part.id} part={part} isUser={isUser} - lastReasoningPartId={lastReasoningPartId} expandedToolCalls={expandedToolCalls} onToggleToolCall={onToggleToolCall} activeImagePath={activeImagePath} diff --git a/src/components/message-list/PartView.tsx b/src/components/message-list/PartView.tsx index 469af84..6adbd54 100644 --- a/src/components/message-list/PartView.tsx +++ b/src/components/message-list/PartView.tsx @@ -9,7 +9,6 @@ import { TextPartView } from "./TextPartView"; export const PartView = memo(function PartView({ part, isUser, - lastReasoningPartId, expandedToolCalls, onToggleToolCall, activeImagePath, @@ -19,7 +18,6 @@ export const PartView = memo(function PartView({ }: { part: TranscriptPart; isUser?: boolean; - lastReasoningPartId?: string; expandedToolCalls?: ReadonlySet; onToggleToolCall?: (partId: string, expanded: boolean) => void; activeImagePath?: string | null; @@ -42,7 +40,7 @@ export const PartView = memo(function PartView({ case "file": return ; case "reasoning": - return ; + return ; case "tool": return ( (null); const hasText = !!part.text?.trim(); - // Start false so first visible render counts as "became visible". - // Needed when backend batches snapshots and component first mounts only - // after reasoning text already exists. - const prevHasTextRef = useRef(false); - - useEffect(() => { - const becameVisible = hasText && !prevHasTextRef.current; - if (isThinking || (becameVisible && isLastReasoning && isBusy)) { - setExpanded(true); - } else if (!isLastReasoning || !isBusy) { - setExpanded(false); - } - prevHasTextRef.current = hasText; - }, [hasText, isThinking, isLastReasoning, isBusy]); // biome-ignore lint/correctness/useExhaustiveDependencies: part.text triggers scroll on new streamed content useEffect(() => { diff --git a/src/components/message-list/tools/ToolCallOutputView.tsx b/src/components/message-list/tools/ToolCallOutputView.tsx index a61c3d8..ad6f5b6 100644 --- a/src/components/message-list/tools/ToolCallOutputView.tsx +++ b/src/components/message-list/tools/ToolCallOutputView.tsx @@ -1,8 +1,19 @@ import { CheckCircle2, Circle, Wrench, XCircle } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { MarkdownRenderer } from "@/components/MarkdownRenderer"; import { TerminalOutput } from "@/components/message-list/TerminalOutput"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { todoStatusConfig } from "@/lib/todos"; -import { cn, looksLikeTerminalOutput } from "@/lib/utils"; +import { cn, copyTextToClipboard, looksLikeTerminalOutput } from "@/lib/utils"; import { ApplyPatchFilesView } from "./ApplyPatchFilesView"; import type { ToolOutputBlock } from "./toolCallModel"; @@ -26,7 +37,16 @@ function ToolImages({ block }: { block: Extract {blocks.map((block, index) => { @@ -109,6 +129,39 @@ export function ToolCallOutputView({ blocks }: { blocks: ToolOutputBlock[] }) { ); } })} + {rawOutput && ( +
+ + + + + {t("toolOutput.rawTitle")} + {t("toolOutput.rawDescription")} + +
+                {rawOutput}
+              
+ + + +
+
+
+ )} ); } diff --git a/src/components/message-list/tools/ToolCallPartView.tsx b/src/components/message-list/tools/ToolCallPartView.tsx index f56b688..3386279 100644 --- a/src/components/message-list/tools/ToolCallPartView.tsx +++ b/src/components/message-list/tools/ToolCallPartView.tsx @@ -45,17 +45,6 @@ export function ToolCallPartView({ const expanded = expandedToolCalls?.has(part.id) ?? false; const setExpanded = (nextExpanded: boolean) => onToggleToolCall?.(part.id, nextExpanded); const outputRef = useRef(null); - const shouldAutoExpand = - tool.status === "running" && - tool.expandable && - (tool.kind === "bash" || - (tool.kind === "task" && - tool.output.some((block) => block.type === "task" && block.taskInfo.childSessionId))); - - useEffect(() => { - if (shouldAutoExpand && !expanded) setExpanded(true); - }, [expanded, shouldAutoExpand]); - useEffect(() => { if (!expanded || tool.status !== "running" || (tool.kind !== "bash" && tool.kind !== "task")) { return; @@ -120,7 +109,7 @@ export function ToolCallPartView({ )} {tool.expandable && expanded && (
- +
)} diff --git a/src/components/message-list/tools/toolCallModel.test.ts b/src/components/message-list/tools/toolCallModel.test.ts index afbafc4..2643184 100644 --- a/src/components/message-list/tools/toolCallModel.test.ts +++ b/src/components/message-list/tools/toolCallModel.test.ts @@ -66,4 +66,110 @@ describe("getToolCallViewModel", () => { expect(vm.kind).toBe("unknown"); expect(vm.label).toBe("Ask User"); }); + + test("keeps todo raw output separate when formatted todos are available", () => { + const vm = getToolCallViewModel( + toolPart({ + tool: "todowrite", + state: { + status: "completed", + input: { todos: [{ content: "Buy milk", status: "pending", priority: "medium" }] }, + output: '[{"content":"Buy milk","status":"pending","priority":"medium"}]', + }, + }), + ); + + expect(vm.output).toEqual([ + { type: "todos", todos: [{ content: "Buy milk", status: "pending", priority: "medium" }] }, + { + type: "text", + text: '[{"content":"Buy milk","status":"pending","priority":"medium"}]', + format: "plain", + }, + ]); + expect(vm.rawOutput).toBe('[{"content":"Buy milk","status":"pending","priority":"medium"}]'); + }); + + test("prefers completed bash output over streaming metadata for raw output", () => { + const vm = getToolCallViewModel( + toolPart({ + tool: "bash", + state: { + status: "completed", + output: "final output", + metadata: { output: "partial output" }, + }, + }), + ); + + expect(vm.output).toEqual([{ type: "text", text: "final output", format: "terminal" }]); + expect(vm.rawOutput).toBe(null); + }); + + test("uses bash metadata while output is still streaming", () => { + const vm = getToolCallViewModel( + toolPart({ + tool: "bash", + state: { status: "running", metadata: { output: "streaming output" } }, + }), + ); + + expect(vm.output).toEqual([{ type: "text", text: "streaming output", format: "terminal" }]); + expect(vm.rawOutput).toBe(null); + }); + + test("prefers latest bash metadata over stale output while running", () => { + const vm = getToolCallViewModel( + toolPart({ + tool: "bash", + state: { + status: "running", + output: "stale output", + metadata: { output: "latest output" }, + }, + }), + ); + + expect(vm.output).toEqual([{ type: "text", text: "latest output", format: "terminal" }]); + expect(vm.rawOutput).toBe(null); + }); + + test("uses error text for failed tools", () => { + const vm = getToolCallViewModel( + toolPart({ tool: "bash", state: { status: "error", error: "command failed" } }), + ); + + expect(vm.output).toEqual([{ type: "text", text: "command failed", format: "terminal" }]); + expect(vm.rawOutput).toBe(null); + }); + + test("prefers bash error text over partial output when both are present", () => { + const vm = getToolCallViewModel( + toolPart({ + tool: "bash", + state: { status: "error", output: "partial stdout", error: "command failed" }, + }), + ); + + expect(vm.output).toEqual([{ type: "text", text: "command failed", format: "terminal" }]); + expect(vm.rawOutput).toBe(null); + }); + + test("keeps raw output null for formatted output with meaningless text", () => { + const vm = getToolCallViewModel( + toolPart({ + tool: "todowrite", + state: { + status: "completed", + input: { todos: [{ content: "Buy milk", status: "pending", priority: "medium" }] }, + output: "\n>\n>\n", + }, + }), + ); + + expect(vm.output).toEqual([ + { type: "todos", todos: [{ content: "Buy milk", status: "pending", priority: "medium" }] }, + ]); + expect(vm.rawOutput).toBe(null); + }); }); diff --git a/src/components/message-list/tools/toolCallModel.ts b/src/components/message-list/tools/toolCallModel.ts index bae36f2..71e8542 100644 --- a/src/components/message-list/tools/toolCallModel.ts +++ b/src/components/message-list/tools/toolCallModel.ts @@ -44,6 +44,7 @@ export interface ToolCallViewModel { diffSummary: { added: number; removed: number } | null; durationLabel: string | null; output: ToolOutputBlock[]; + rawOutput: string | null; expandable: boolean; } @@ -210,35 +211,38 @@ export function getToolCallViewModel( const kind = normalizeKind(part.tool); const text = rawOutput(state); const error = errorOutput(state); - const bashText = + const metadataText = metadataOutput(state); + const bashDisplayText = kind === "bash" - ? running - ? (metadataOutput(state) ?? text) - : (text ?? metadataOutput(state) ?? error) + ? status === "error" + ? (error ?? text) + : running + ? (metadataText ?? text) + : (text ?? metadataText) : null; + const rawCandidate = + kind === "bash" ? bashDisplayText : status === "error" ? (error ?? text) : text; const editFiles = kind === "edit" ? extractEditFiles(state) : []; const taskInfo = kind === "task" ? extractTaskInfo(state) : null; const todos = kind === "todo" ? extractTodos(state) : null; const images = extractImageAttachments(state, serverUrl); const output: ToolOutputBlock[] = []; + const rawContent = meaningfulText(rawCandidate); if (editFiles.length > 0) output.push({ type: "diff", files: editFiles }); else if (taskInfo) output.push({ type: "task", taskInfo }); - else { - const content = meaningfulText( - kind === "bash" ? bashText : status === "error" ? (error ?? text) : text, - ); - if (content) - output.push({ - type: "text", - text: content, - format: kind === "bash" || looksLikeTerminalOutput(content) ? "terminal" : "plain", - }); - } - if (todos?.length) output.push({ type: "todos", todos }); if (images.length) output.push({ type: "images", images }); + const hasFormattedOutput = output.length > 0; + if (rawContent) { + output.push({ + type: "text", + text: rawContent, + format: kind === "bash" || looksLikeTerminalOutput(rawContent) ? "terminal" : "plain", + }); + } + const grepText = meaningfulText(text); const match = kind === "grep" ? grepText?.match(/^Found (\d+) match/) : null; const matchCount = match ? Number.parseInt(match[1] ?? "", 10) : null; @@ -253,6 +257,7 @@ export function getToolCallViewModel( diffSummary: summarizeApplyPatchFiles(editFiles), durationLabel: kind === "task" ? getTaskDurationLabel(state) : null, output, + rawOutput: hasFormattedOutput && rawContent ? rawCandidate : null, expandable: status !== "error" && output.length > 0, }; } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index cc46e1e..b987e94 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -214,6 +214,12 @@ "showMore": "Mehr anzeigen", "showLess": "Weniger anzeigen" }, + "toolOutput": { + "showRaw": "Rohdaten anzeigen", + "rawTitle": "Rohe Werkzeugausgabe", + "rawDescription": "Ursprüngliche Textausgabe des Werkzeugs.", + "copyRaw": "Rohdaten kopieren" + }, "workspace": { "addTitle": "Workspace hinzufügen", "editTitle": "Workspace bearbeiten", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 28cacf7..162b67d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -211,6 +211,12 @@ "showMore": "Show more", "showLess": "Show less" }, + "toolOutput": { + "showRaw": "Show raw", + "rawTitle": "Raw tool output", + "rawDescription": "Original textual output returned by the tool.", + "copyRaw": "Copy raw" + }, "workspace": { "addTitle": "Add workspace", "editTitle": "Edit workspace", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5330c58..e8dc78e 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -214,6 +214,12 @@ "showMore": "Mostrar más", "showLess": "Mostrar menos" }, + "toolOutput": { + "showRaw": "Mostrar sin procesar", + "rawTitle": "Salida sin procesar de la herramienta", + "rawDescription": "Salida textual original devuelta por la herramienta.", + "copyRaw": "Copiar sin procesar" + }, "workspace": { "addTitle": "Añadir espacio de trabajo", "editTitle": "Editar espacio de trabajo",