Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions src/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -342,7 +329,6 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro
<MessageBubble
entry={entry}
turnFooter={turnFooterByMessageId.get(entry.info.id)}
lastReasoningPartId={lastReasoningPartId}
onFork={
capabilities?.fork && entry.info.role === "user" && !isFirstUserMsg
? () => forkFromMessage(entry.info.id)
Expand All @@ -365,7 +351,6 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro
firstUserMessageIndex,
visibleMessages,
turnFooterByMessageId,
lastReasoningPartId,
forkFromMessage,
revertToMessage,
expandedUserMessages,
Expand Down
3 changes: 0 additions & 3 deletions src/components/message-list/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import type { TurnFooter } from "./types";
export const MessageBubble = memo(function MessageBubble({
entry,
turnFooter,
lastReasoningPartId,
onFork,
onRevert,
expandedUserMessages,
Expand All @@ -29,7 +28,6 @@ export const MessageBubble = memo(function MessageBubble({
}: {
entry: TranscriptMessageEntry;
turnFooter?: TurnFooter;
lastReasoningPartId?: string;
onFork?: () => void;
onRevert?: () => void;
expandedUserMessages?: ReadonlySet<string>;
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 1 addition & 3 deletions src/components/message-list/PartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { TextPartView } from "./TextPartView";
export const PartView = memo(function PartView({
part,
isUser,
lastReasoningPartId,
expandedToolCalls,
onToggleToolCall,
activeImagePath,
Expand All @@ -19,7 +18,6 @@ export const PartView = memo(function PartView({
}: {
part: TranscriptPart;
isUser?: boolean;
lastReasoningPartId?: string;
expandedToolCalls?: ReadonlySet<string>;
onToggleToolCall?: (partId: string, expanded: boolean) => void;
activeImagePath?: string | null;
Expand All @@ -42,7 +40,7 @@ export const PartView = memo(function PartView({
case "file":
return <FilePartView part={part} />;
case "reasoning":
return <ReasoningPartView part={part} isLastReasoning={part.id === lastReasoningPartId} />;
return <ReasoningPartView part={part} />;
case "tool":
return (
<ToolCallPartView
Expand Down
26 changes: 2 additions & 24 deletions src/components/message-list/ReasoningPartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { ChevronRight } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MarkdownRenderer } from "@/components/MarkdownRenderer";
import { useSessionState } from "@/hooks/use-agent-state";
import { cn } from "@/lib/utils";
import type { ReasoningTranscriptPart } from "@/protocol/session-transcript";
import { formatDuration, hideZeroDurationLabel } from "./duration";
Expand All @@ -11,33 +10,12 @@ const TIMELINE_ROW_BASE = "flex min-w-0 items-center gap-1.5";
const TIMELINE_BUTTON_RESET =
"m-0 appearance-none border-0 bg-transparent p-0 text-left text-inherit";

export function ReasoningPartView({
part,
isLastReasoning,
}: {
part: ReasoningTranscriptPart;
isLastReasoning?: boolean;
}) {
export function ReasoningPartView({ part }: { part: ReasoningTranscriptPart }) {
const isThinking = !part.time.end;
const { t } = useTranslation();
const { isBusy } = useSessionState();
const [expanded, setExpanded] = useState(isThinking);
const [expanded, setExpanded] = useState(false);
const contentRef = useRef<HTMLDivElement>(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(() => {
Expand Down
57 changes: 55 additions & 2 deletions src/components/message-list/tools/ToolCallOutputView.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -26,7 +37,16 @@ function ToolImages({ block }: { block: Extract<ToolOutputBlock, { type: "images
);
}

export function ToolCallOutputView({ blocks }: { blocks: ToolOutputBlock[] }) {
export function ToolCallOutputView({
blocks,
rawOutput,
}: {
blocks: ToolOutputBlock[];
rawOutput?: string | null;
}) {
const { t } = useTranslation();
const [rawOpen, setRawOpen] = useState(false);

return (
<div className="pl-5 pt-1 space-y-1">
{blocks.map((block, index) => {
Expand Down Expand Up @@ -109,6 +129,39 @@ export function ToolCallOutputView({ blocks }: { blocks: ToolOutputBlock[] }) {
);
}
})}
{rawOutput && (
<div className="pt-1">
<Button
type="button"
variant="ghost"
size="xs"
className="h-5 px-1.5 text-[11px] text-muted-foreground"
onClick={() => setRawOpen(true)}
>
{t("toolOutput.showRaw")}
</Button>
<Dialog open={rawOpen} onOpenChange={setRawOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("toolOutput.rawTitle")}</DialogTitle>
<DialogDescription>{t("toolOutput.rawDescription")}</DialogDescription>
</DialogHeader>
<pre className="max-h-[60vh] overflow-auto rounded-lg border border-border/60 bg-background/70 p-3 text-xs text-muted-foreground whitespace-pre-wrap break-words">
{rawOutput}
</pre>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => void copyTextToClipboard(rawOutput)}
>
{t("toolOutput.copyRaw")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</div>
);
}
13 changes: 1 addition & 12 deletions src/components/message-list/tools/ToolCallPartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement | null>(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;
Expand Down Expand Up @@ -120,7 +109,7 @@ export function ToolCallPartView({
)}
{tool.expandable && expanded && (
<div ref={outputRef} className="max-h-96 overflow-auto">
<ToolCallOutputView blocks={tool.output} />
<ToolCallOutputView blocks={tool.output} rawOutput={tool.rawOutput} />
</div>
)}
</div>
Expand Down
106 changes: 106 additions & 0 deletions src/components/message-list/tools/toolCallModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading