mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site/src/pages/AgentsPage/components): collapse sequential read file events (#25075)
This commit is contained in:
+276
-29
@@ -236,6 +236,104 @@ const buildStoryArgs = (...messages: TypesGen.ChatMessage[]) => ({
|
||||
parsedMessages: buildMessages(messages),
|
||||
});
|
||||
|
||||
const buildParsedReadFileEntry = ({
|
||||
messageId,
|
||||
toolId,
|
||||
path,
|
||||
status,
|
||||
content = "",
|
||||
errorMessage,
|
||||
isError = status === "error",
|
||||
}: {
|
||||
messageId: number;
|
||||
toolId: string;
|
||||
path: string;
|
||||
status: "completed" | "error" | "running";
|
||||
content?: string;
|
||||
errorMessage?: string;
|
||||
isError?: boolean;
|
||||
}): ParsedMessageEntry => {
|
||||
const args = { path };
|
||||
const result =
|
||||
content || errorMessage
|
||||
? {
|
||||
...(content ? { content } : {}),
|
||||
...(errorMessage ? { error: errorMessage } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
message: {
|
||||
...baseMessage,
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
tool_call_id: toolId,
|
||||
tool_name: "read_file",
|
||||
args,
|
||||
},
|
||||
],
|
||||
},
|
||||
parsed: {
|
||||
markdown: "",
|
||||
reasoning: "",
|
||||
toolCalls: [{ id: toolId, name: "read_file", args }],
|
||||
toolResults: [],
|
||||
tools: [
|
||||
{
|
||||
id: toolId,
|
||||
name: "read_file",
|
||||
args,
|
||||
result,
|
||||
isError,
|
||||
status,
|
||||
},
|
||||
],
|
||||
blocks: [{ type: "tool", id: toolId }],
|
||||
sources: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const buildReadFileExchange = (
|
||||
callMessageId: number,
|
||||
toolId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): TypesGen.ChatMessage[] => {
|
||||
const args = { path };
|
||||
return [
|
||||
{
|
||||
...baseMessage,
|
||||
id: callMessageId,
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
tool_call_id: toolId,
|
||||
tool_name: "read_file",
|
||||
args,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...baseMessage,
|
||||
id: callMessageId + 1,
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
tool_call_id: toolId,
|
||||
tool_name: "read_file",
|
||||
result: { content },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const LONG_USER_MESSAGE = [
|
||||
"This is a deliberately long user message that should stay pinned to the",
|
||||
"right edge while the bubble stops short of filling the entire timeline",
|
||||
@@ -2207,6 +2305,163 @@ export const ThinkingBlockAlwaysCollapsed: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const SequentialReadFilesCollapsed: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedMessages: buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 1,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I'll inspect the relevant files." }],
|
||||
},
|
||||
...buildReadFileExchange(
|
||||
2,
|
||||
"read-1",
|
||||
"site/src/a.ts",
|
||||
"export const a = 1;",
|
||||
),
|
||||
...buildReadFileExchange(
|
||||
4,
|
||||
"read-2",
|
||||
"site/src/b.ts",
|
||||
"export const b = 2;",
|
||||
),
|
||||
...buildReadFileExchange(
|
||||
6,
|
||||
"read-3",
|
||||
"site/src/c.ts",
|
||||
"export const c = 3;",
|
||||
),
|
||||
]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const groupButton = canvas.getByRole("button", { name: /read 3 files/i });
|
||||
expect(groupButton).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: /read a\.ts/i }),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.click(groupButton);
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByRole("button", { name: /read a\.ts/i })).toBeVisible();
|
||||
expect(canvas.getByRole("button", { name: /read b\.ts/i })).toBeVisible();
|
||||
expect(canvas.getByRole("button", { name: /read c\.ts/i })).toBeVisible();
|
||||
});
|
||||
const firstFileButton = canvas.getByRole("button", { name: /read a\.ts/i });
|
||||
expect(firstFileButton).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await userEvent.click(firstFileButton);
|
||||
await waitFor(() => {
|
||||
expect(firstFileButton).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SequentialReadFilesEmptyAndErrorStates: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedMessages: [
|
||||
buildParsedReadFileEntry({
|
||||
messageId: 1,
|
||||
toolId: "read-empty-1",
|
||||
path: "site/src/empty-a.ts",
|
||||
status: "completed",
|
||||
}),
|
||||
buildParsedReadFileEntry({
|
||||
messageId: 2,
|
||||
toolId: "read-empty-2",
|
||||
path: "site/src/empty-b.ts",
|
||||
status: "completed",
|
||||
}),
|
||||
...buildMessages([
|
||||
{
|
||||
...baseMessage,
|
||||
id: 3,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Trying a different file set." }],
|
||||
},
|
||||
]),
|
||||
buildParsedReadFileEntry({
|
||||
messageId: 4,
|
||||
toolId: "read-error-1",
|
||||
path: "site/src/missing-a.ts",
|
||||
status: "error",
|
||||
errorMessage: "ENOENT: no such file or directory",
|
||||
}),
|
||||
buildParsedReadFileEntry({
|
||||
messageId: 5,
|
||||
toolId: "read-error-2",
|
||||
path: "site/src/missing-b.ts",
|
||||
status: "error",
|
||||
errorMessage: "permission denied",
|
||||
}),
|
||||
] satisfies ParsedMessageEntry[],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const buttons = canvas.getAllByRole("button", { name: /read 2 files/i });
|
||||
expect(buttons).toHaveLength(2);
|
||||
|
||||
await userEvent.click(buttons[0]);
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("Read empty-a.ts")).toBeVisible();
|
||||
expect(canvas.getByText("Read empty-b.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.click(buttons[1]);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("button", { name: /read missing-a\.ts/i }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
canvas.getByRole("button", { name: /read missing-b\.ts/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /read missing-a\.ts/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByText("ENOENT: no such file or directory"),
|
||||
).toBeVisible();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SequentialReadFilesRunningState: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
parsedMessages: [
|
||||
buildParsedReadFileEntry({
|
||||
messageId: 1,
|
||||
toolId: "read-running-1",
|
||||
path: "site/src/one.ts",
|
||||
status: "running",
|
||||
}),
|
||||
buildParsedReadFileEntry({
|
||||
messageId: 2,
|
||||
toolId: "read-running-2",
|
||||
path: "site/src/two.ts",
|
||||
status: "running",
|
||||
}),
|
||||
buildParsedReadFileEntry({
|
||||
messageId: 3,
|
||||
toolId: "read-running-3",
|
||||
path: "site/src/three.ts",
|
||||
status: "running",
|
||||
}),
|
||||
] satisfies ParsedMessageEntry[],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByRole("button", { name: /reading 3 files/i }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Collapsed thinking should visually align with adjacent tool calls. */
|
||||
export const ThinkingBlockWithToolCall: Story = {
|
||||
parameters: {
|
||||
@@ -2268,14 +2523,16 @@ export const ThinkingBlockWithToolCall: Story = {
|
||||
const toolButton = canvas.getByRole("button", {
|
||||
name: /read package\.json/i,
|
||||
});
|
||||
const thinkingContainer = thinkingButton.closest("[data-transcript-row]");
|
||||
const toolContainer = toolButton.closest("[data-transcript-row]");
|
||||
expect(thinkingContainer).toBeInstanceOf(HTMLElement);
|
||||
expect(toolContainer).toBeInstanceOf(HTMLElement);
|
||||
expect(toolContainer?.firstElementChild).not.toHaveAttribute("data-state");
|
||||
expect(thinkingContainer?.firstElementChild).not.toHaveAttribute(
|
||||
"data-state",
|
||||
);
|
||||
const thinkingContainer =
|
||||
thinkingButton.closest("[data-transcript-row]") ?? thinkingButton;
|
||||
const toolContainer =
|
||||
toolButton.closest("[data-transcript-row]") ?? toolButton;
|
||||
expect(
|
||||
toolContainer.firstElementChild ?? toolContainer,
|
||||
).not.toHaveAttribute("data-state");
|
||||
expect(
|
||||
thinkingContainer.firstElementChild ?? thinkingContainer,
|
||||
).not.toHaveAttribute("data-state");
|
||||
expect(
|
||||
canvas.queryByTestId("assistant-bottom-spacer"),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -2362,30 +2619,20 @@ export const ThinkingBlockWithShellTools: Story = {
|
||||
name: /expand process output/i,
|
||||
});
|
||||
|
||||
const thinkingRow = thinkingButton.closest(
|
||||
"[data-transcript-row]",
|
||||
)?.firstElementChild;
|
||||
const executeRow = executeButton.closest(
|
||||
"[data-transcript-row]",
|
||||
)?.firstElementChild;
|
||||
const processOutputRow = processOutputButton.closest(
|
||||
"[data-transcript-row]",
|
||||
)?.firstElementChild;
|
||||
const wrappers = [
|
||||
thinkingButton.closest("[data-transcript-row]") ?? thinkingButton,
|
||||
executeButton.closest("[data-transcript-row]") ?? executeButton,
|
||||
processOutputButton.closest("[data-transcript-row]") ??
|
||||
processOutputButton,
|
||||
];
|
||||
|
||||
expect(thinkingRow).toBeInstanceOf(HTMLElement);
|
||||
expect(executeRow).toBeInstanceOf(HTMLElement);
|
||||
expect(processOutputRow).toBeInstanceOf(HTMLElement);
|
||||
|
||||
const rowHeights = [thinkingRow, executeRow, processOutputRow].map((row) =>
|
||||
Math.round((row as HTMLElement).getBoundingClientRect().height),
|
||||
const rows = wrappers.map(
|
||||
(wrapper) => wrapper.firstElementChild ?? wrapper,
|
||||
);
|
||||
const rowHeights = rows.map((row) =>
|
||||
Math.round(row.getBoundingClientRect().height),
|
||||
);
|
||||
expect(new Set(rowHeights)).toHaveLength(1);
|
||||
|
||||
const wrappers = [
|
||||
thinkingButton.closest("[data-transcript-row]"),
|
||||
executeButton.closest("[data-transcript-row]"),
|
||||
processOutputButton.closest("[data-transcript-row]"),
|
||||
].map((row) => row as HTMLElement);
|
||||
const gaps = [
|
||||
Math.round(
|
||||
wrappers[1].getBoundingClientRect().top -
|
||||
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
Tool,
|
||||
} from "../ChatElements";
|
||||
import { WebSearchSources } from "../ChatElements/tools";
|
||||
import { ReadFilesTool } from "../ChatElements/tools/ReadFilesTool";
|
||||
import {
|
||||
getReadFileToolData,
|
||||
ReadFileTool,
|
||||
} from "../ChatElements/tools/ReadFileTool";
|
||||
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
|
||||
import { ToolCollapsible } from "../ChatElements/tools/ToolCollapsible";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
@@ -40,8 +45,12 @@ import {
|
||||
AttachmentBlock,
|
||||
type PreviewTextAttachment,
|
||||
} from "./AttachmentBlocks";
|
||||
import { groupSequentialReadFileBlocks } from "./blockUtils";
|
||||
import { FileProbeProvider } from "./FileProbeContext";
|
||||
import { deriveMessageDisplayState } from "./messageHelpers";
|
||||
import {
|
||||
deriveMessageDisplayState,
|
||||
groupSequentialReadFileMessages,
|
||||
} from "./messageHelpers";
|
||||
import { getEditableUserMessagePayload } from "./messageParsing";
|
||||
import { useSmoothStreamingText } from "./SmoothText";
|
||||
import { getThinkingDisclosureDisplay } from "./thinkingTitle";
|
||||
@@ -204,6 +213,38 @@ const SmoothedResponse = memo<{
|
||||
);
|
||||
});
|
||||
|
||||
const ReadFileTimelineBlock = memo<{
|
||||
tools: readonly MergedTool[];
|
||||
}>(({ tools }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [firstTool] = tools;
|
||||
if (!firstTool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tools.length === 1) {
|
||||
const readFile = getReadFileToolData(firstTool);
|
||||
return (
|
||||
<div data-tool-call="">
|
||||
<ReadFileTool
|
||||
{...readFile}
|
||||
status={firstTool.status}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReadFilesTool
|
||||
tools={tools}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// Shared block renderer used by both ChatMessageItem (historical
|
||||
// messages) and StreamingOutput (live stream). Encapsulates the
|
||||
// response / thinking / tool / file / sources switch so both
|
||||
@@ -257,16 +298,20 @@ export const BlockList: FC<{
|
||||
prefQuery.data?.code_diff_display_mode || "auto";
|
||||
|
||||
const toolByID = new Map(tools.map((tool) => [tool.id, tool]));
|
||||
const displayBlocks = groupSequentialReadFileBlocks(blocks, tools);
|
||||
|
||||
// Pre-compute which tool IDs have a corresponding block so
|
||||
// we can render "remaining" (block-less) tools afterwards.
|
||||
const blockToolIDs = new Set(
|
||||
blocks
|
||||
.filter(
|
||||
(b): b is Extract<RenderBlock, { type: "tool" }> =>
|
||||
b.type === "tool" && (toolByID.has(b.id) || isStreaming),
|
||||
)
|
||||
.map((b) => b.id),
|
||||
displayBlocks.flatMap((block) => {
|
||||
if (block.type === "tool") {
|
||||
return toolByID.has(block.id) || isStreaming ? [block.id] : [];
|
||||
}
|
||||
if (block.type === "tool-group") {
|
||||
return block.ids;
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
);
|
||||
|
||||
const remainingTools = tools.filter((tool) => !blockToolIDs.has(tool.id));
|
||||
@@ -274,12 +319,13 @@ export const BlockList: FC<{
|
||||
// A thinking block is actively streaming only when it is the
|
||||
// very last block in the list. Once newer content arrives
|
||||
// (response, tool call, etc.) the thinking phase is over.
|
||||
const lastBlockIsThinking =
|
||||
blocks.length > 0 && blocks[blocks.length - 1].type === "thinking";
|
||||
const lastDisplayBlockIsThinking =
|
||||
displayBlocks.length > 0 &&
|
||||
displayBlocks[displayBlocks.length - 1].type === "thinking";
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, index) => {
|
||||
{displayBlocks.map((block, index) => {
|
||||
switch (block.type) {
|
||||
case "response": {
|
||||
const responseEl = isStreaming ? (
|
||||
@@ -311,8 +357,8 @@ export const BlockList: FC<{
|
||||
text={block.text}
|
||||
isStreaming={
|
||||
isStreaming &&
|
||||
lastBlockIsThinking &&
|
||||
index === blocks.length - 1
|
||||
lastDisplayBlockIsThinking &&
|
||||
index === displayBlocks.length - 1
|
||||
}
|
||||
urlTransform={urlTransform}
|
||||
thinkingDisplayMode={thinkingDisplayMode}
|
||||
@@ -332,6 +378,21 @@ export const BlockList: FC<{
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "tool-group": {
|
||||
const groupTools = block.ids
|
||||
.map((id) => toolByID.get(id))
|
||||
.filter((tool) => tool !== undefined);
|
||||
const [firstGroupTool] = groupTools;
|
||||
if (!firstGroupTool) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ReadFileTimelineBlock
|
||||
key={firstGroupTool.id}
|
||||
tools={groupTools}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "tool": {
|
||||
const tool = toolByID.get(block.id);
|
||||
if (!tool) {
|
||||
@@ -354,6 +415,9 @@ export const BlockList: FC<{
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tool.name === "read_file") {
|
||||
return <ReadFileTimelineBlock key={tool.id} tools={[tool]} />;
|
||||
}
|
||||
return (
|
||||
<Tool
|
||||
key={tool.id}
|
||||
@@ -1012,25 +1076,16 @@ const StickyUserMessage = memo<{
|
||||
);
|
||||
|
||||
function computeLastInChainFlags(
|
||||
parsedMessages: readonly ParsedMessageEntry[],
|
||||
displayMessages: readonly ParsedMessageEntry[],
|
||||
): boolean[] {
|
||||
const flags = new Array<boolean>(parsedMessages.length).fill(false);
|
||||
let nextVisibleIsUser = true; // no next visible => treat as chain end
|
||||
for (let i = parsedMessages.length - 1; i >= 0; i--) {
|
||||
const entry = parsedMessages[i];
|
||||
const { shouldHide } = deriveMessageDisplayState({
|
||||
message: entry.message,
|
||||
parsed: entry.parsed,
|
||||
hideActions: false,
|
||||
hasActiveStream: false,
|
||||
isAwaitingFirstStreamChunk: false,
|
||||
});
|
||||
const flags = new Array<boolean>(displayMessages.length).fill(false);
|
||||
let nextVisibleIsUser = true;
|
||||
for (let i = displayMessages.length - 1; i >= 0; i--) {
|
||||
const entry = displayMessages[i];
|
||||
if (entry.message.role !== "user") {
|
||||
flags[i] = nextVisibleIsUser;
|
||||
}
|
||||
if (!shouldHide) {
|
||||
nextVisibleIsUser = entry.message.role === "user";
|
||||
}
|
||||
nextVisibleIsUser = entry.message.role === "user";
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
@@ -1086,7 +1141,8 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
});
|
||||
};
|
||||
|
||||
const lastInChainFlags = computeLastInChainFlags(parsedMessages);
|
||||
const displayMessages = groupSequentialReadFileMessages(parsedMessages);
|
||||
const lastInChainFlags = computeLastInChainFlags(displayMessages);
|
||||
|
||||
if (parsedMessages.length === 0) {
|
||||
return null;
|
||||
@@ -1178,7 +1234,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
data-testid="conversation-timeline"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{parsedMessages.map(({ message, parsed }, msgIdx) => {
|
||||
{displayMessages.map(({ message, parsed }, msgIdx) => {
|
||||
if (message.role === "user") {
|
||||
const { shouldHide } = deriveMessageDisplayState({
|
||||
message,
|
||||
|
||||
@@ -281,32 +281,28 @@ export const ThinkingDuringStreamingWithToolCalls: Story = {
|
||||
// Tool-only stream chunks can otherwise clear the activity indicator before text arrives.
|
||||
expect(canvas.getAllByText("Thinking").length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const toolCallWrappers = Array.from(
|
||||
canvasElement.querySelectorAll("[data-transcript-row]"),
|
||||
);
|
||||
expect(toolCallWrappers).toHaveLength(3);
|
||||
const executeButton = canvas.getByRole("button", {
|
||||
name: /collapse command/i,
|
||||
});
|
||||
const readFileLabel = canvas.getByText(/reading README\.md/i);
|
||||
const thinkingText = canvas.getAllByText("Thinking").at(-1);
|
||||
expect(thinkingText).toBeInstanceOf(HTMLElement);
|
||||
|
||||
const thinkingWrapper = toolCallWrappers.at(-1);
|
||||
const previousToolWrapper = toolCallWrappers.at(-2);
|
||||
expect(thinkingWrapper).toBeInstanceOf(HTMLElement);
|
||||
expect(previousToolWrapper).toBeInstanceOf(HTMLElement);
|
||||
expect(thinkingWrapper).toHaveTextContent("Thinking");
|
||||
const wrappers = [
|
||||
executeButton.closest("[data-transcript-row]") ?? executeButton,
|
||||
readFileLabel.closest("[data-tool-call]") ?? readFileLabel,
|
||||
(thinkingText as HTMLElement).closest("[data-transcript-row]") ??
|
||||
(thinkingText as HTMLElement),
|
||||
];
|
||||
expect(wrappers.at(-1)).toHaveTextContent("Thinking");
|
||||
|
||||
const gap = Math.round(
|
||||
(thinkingWrapper as HTMLElement).getBoundingClientRect().top -
|
||||
(previousToolWrapper as HTMLElement).getBoundingClientRect().bottom,
|
||||
wrappers[2].getBoundingClientRect().top -
|
||||
wrappers[1].getBoundingClientRect().bottom,
|
||||
);
|
||||
expect(gap).toBe(8);
|
||||
|
||||
// The placeholder inner row must match the committed collapsed
|
||||
// Thinking row height so the transition from streaming to settled
|
||||
// does not jump. ToolCollapsible enforces min-h-6 (24px).
|
||||
const placeholderRow = (thinkingWrapper as HTMLElement).firstElementChild;
|
||||
expect(placeholderRow).toBeInstanceOf(HTMLElement);
|
||||
expect(
|
||||
Math.round(
|
||||
(placeholderRow as HTMLElement).getBoundingClientRect().height,
|
||||
),
|
||||
).toBe(24);
|
||||
const placeholderRow = wrappers[2].firstElementChild ?? wrappers[2];
|
||||
expect(Math.round(placeholderRow.getBoundingClientRect().height)).toBe(24);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendTextBlock, asNonEmptyString } from "./blockUtils";
|
||||
import type { RenderBlock } from "./types";
|
||||
import {
|
||||
appendTextBlock,
|
||||
asNonEmptyString,
|
||||
groupSequentialReadFileBlocks,
|
||||
} from "./blockUtils";
|
||||
import type { MergedTool, RenderBlock } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// asNonEmptyString
|
||||
@@ -96,3 +100,70 @@ describe("appendTextBlock", () => {
|
||||
expect(result).not.toBe(blocks);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSequentialReadFileBlocks", () => {
|
||||
const tool = (id: string, name = "read_file"): MergedTool => ({
|
||||
id,
|
||||
name,
|
||||
isError: false,
|
||||
status: "completed",
|
||||
});
|
||||
const tools = [tool("read-1"), tool("read-2"), tool("execute-1", "execute")];
|
||||
|
||||
it("collapses consecutive read_file tool blocks", () => {
|
||||
const result = groupSequentialReadFileBlocks(
|
||||
[
|
||||
{ type: "tool", id: "read-1" },
|
||||
{ type: "tool", id: "read-2" },
|
||||
],
|
||||
tools,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "tool-group",
|
||||
ids: ["read-1", "read-2"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a single read_file tool block ungrouped", () => {
|
||||
const result = groupSequentialReadFileBlocks(
|
||||
[{ type: "tool", id: "read-1" }],
|
||||
tools,
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ type: "tool", id: "read-1" }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"response content",
|
||||
[
|
||||
{ type: "tool", id: "read-1" },
|
||||
{ type: "response", text: "middle" },
|
||||
{ type: "tool", id: "read-2" },
|
||||
],
|
||||
],
|
||||
[
|
||||
"another tool",
|
||||
[
|
||||
{ type: "tool", id: "read-1" },
|
||||
{ type: "tool", id: "execute-1" },
|
||||
{ type: "tool", id: "read-2" },
|
||||
],
|
||||
],
|
||||
[
|
||||
"an unresolved tool",
|
||||
[
|
||||
{ type: "tool", id: "read-1" },
|
||||
{ type: "tool", id: "missing" },
|
||||
{ type: "tool", id: "read-2" },
|
||||
],
|
||||
],
|
||||
] satisfies Array<
|
||||
[string, RenderBlock[]]
|
||||
>)("does not collapse read_file blocks across %s", (_, blocks) => {
|
||||
expect(groupSequentialReadFileBlocks(blocks, tools)).toEqual(blocks);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asString } from "../ChatElements/runtimeTypeUtils";
|
||||
import type { RenderBlock } from "./types";
|
||||
import type { MergedTool, RenderBlock } from "./types";
|
||||
|
||||
export const asNonEmptyString = (value: unknown): string | undefined => {
|
||||
const next = asString(value).trim();
|
||||
@@ -30,3 +30,50 @@ export const appendTextBlock = (
|
||||
nextBlocks.push({ type, text });
|
||||
return nextBlocks;
|
||||
};
|
||||
|
||||
type ToolGroupRenderBlock = {
|
||||
type: "tool-group";
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
type TimelineRenderBlock = RenderBlock | ToolGroupRenderBlock;
|
||||
|
||||
export const groupSequentialReadFileBlocks = (
|
||||
blocks: readonly RenderBlock[],
|
||||
tools: readonly MergedTool[],
|
||||
): TimelineRenderBlock[] => {
|
||||
const toolByID = new Map(tools.map((tool) => [tool.id, tool]));
|
||||
const grouped: TimelineRenderBlock[] = [];
|
||||
let currentReadFileIDs: string[] = [];
|
||||
|
||||
const flushReadFileIDs = () => {
|
||||
if (currentReadFileIDs.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (currentReadFileIDs.length === 1) {
|
||||
grouped.push({ type: "tool", id: currentReadFileIDs[0] });
|
||||
} else {
|
||||
grouped.push({
|
||||
type: "tool-group",
|
||||
ids: currentReadFileIDs,
|
||||
});
|
||||
}
|
||||
currentReadFileIDs = [];
|
||||
};
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === "tool") {
|
||||
const tool = toolByID.get(block.id);
|
||||
if (tool?.name === "read_file") {
|
||||
currentReadFileIDs.push(block.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
flushReadFileIDs();
|
||||
grouped.push(block);
|
||||
}
|
||||
|
||||
flushReadFileIDs();
|
||||
return grouped;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatMessage, ChatMessagePart } from "#/api/typesGenerated";
|
||||
import { deriveMessageDisplayState } from "./messageHelpers";
|
||||
import { parseMessagesWithMergedTools } from "./messageParsing";
|
||||
import type { ParsedMessageContent } from "./types";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import {
|
||||
deriveMessageDisplayState,
|
||||
groupSequentialReadFileMessages,
|
||||
} from "./messageHelpers";
|
||||
import { parseMessageContent } from "./messageParsing";
|
||||
import type {
|
||||
MergedTool,
|
||||
ParsedMessageContent,
|
||||
ParsedMessageEntry,
|
||||
} from "./types";
|
||||
|
||||
const buildMessage = (
|
||||
content: ChatMessagePart[],
|
||||
content: TypesGen.ChatMessagePart[],
|
||||
role: "user" | "assistant" = "user",
|
||||
): ChatMessage => ({
|
||||
): TypesGen.ChatMessage => ({
|
||||
id: 1,
|
||||
chat_id: "chat-1",
|
||||
created_at: "2026-05-11T00:00:00.000Z",
|
||||
@@ -15,21 +22,135 @@ const buildMessage = (
|
||||
content,
|
||||
});
|
||||
|
||||
const getParsedMessage = (message: ChatMessage) =>
|
||||
parseMessagesWithMergedTools([message])[0].parsed;
|
||||
|
||||
const getDisplayState = (
|
||||
message: ChatMessage,
|
||||
message: TypesGen.ChatMessage,
|
||||
overrides: Partial<Parameters<typeof deriveMessageDisplayState>[0]> = {},
|
||||
) =>
|
||||
deriveMessageDisplayState({
|
||||
message,
|
||||
parsed: getParsedMessage(message),
|
||||
parsed: parseMessageContent(message.content),
|
||||
hideActions: false,
|
||||
hasActiveStream: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const baseMessage = {
|
||||
chat_id: "chat",
|
||||
created_at: "2026-03-10T00:00:00.000Z",
|
||||
} as const;
|
||||
|
||||
const parsed = (
|
||||
overrides: Partial<ParsedMessageContent> = {},
|
||||
): ParsedMessageContent => ({
|
||||
markdown: "",
|
||||
reasoning: "",
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
tools: [],
|
||||
blocks: [],
|
||||
sources: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const entry = ({
|
||||
messageID,
|
||||
role = "assistant",
|
||||
content = [],
|
||||
parsedOverrides,
|
||||
}: {
|
||||
messageID: number;
|
||||
role?: TypesGen.ChatMessageRole;
|
||||
content?: TypesGen.ChatMessagePart[];
|
||||
parsedOverrides: Partial<ParsedMessageContent>;
|
||||
}): ParsedMessageEntry => ({
|
||||
message: { ...baseMessage, id: messageID, role, content },
|
||||
parsed: parsed(parsedOverrides),
|
||||
});
|
||||
|
||||
const readFileArgs = (id: string) => ({ path: `${id}.ts` });
|
||||
|
||||
const readFileTool = (id: string): MergedTool => ({
|
||||
id,
|
||||
name: "read_file",
|
||||
args: readFileArgs(id),
|
||||
result: { content: id },
|
||||
isError: false,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
const readFileToolResult = (id: string) => ({
|
||||
id,
|
||||
name: "read_file" as const,
|
||||
result: { content: id },
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const readFileMessage = (
|
||||
messageID: number,
|
||||
toolID: string,
|
||||
parsedOverrides: Partial<ParsedMessageContent> = {},
|
||||
): ParsedMessageEntry => {
|
||||
const args = readFileArgs(toolID);
|
||||
return entry({
|
||||
messageID,
|
||||
parsedOverrides: {
|
||||
toolCalls: [{ id: toolID, name: "read_file", args }],
|
||||
toolResults: [readFileToolResult(toolID)],
|
||||
tools: [readFileTool(toolID)],
|
||||
blocks: [{ type: "tool", id: toolID }],
|
||||
...parsedOverrides,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const hiddenToolResultMessage = (
|
||||
messageID: number,
|
||||
toolID: string,
|
||||
): ParsedMessageEntry =>
|
||||
entry({
|
||||
messageID,
|
||||
role: "tool",
|
||||
parsedOverrides: {
|
||||
toolResults: [readFileToolResult(toolID)],
|
||||
tools: [readFileTool(toolID)],
|
||||
blocks: [{ type: "tool", id: toolID }],
|
||||
},
|
||||
});
|
||||
|
||||
const textMessage = (
|
||||
messageID: number,
|
||||
text: string,
|
||||
role: TypesGen.ChatMessageRole = "assistant",
|
||||
): ParsedMessageEntry =>
|
||||
entry({
|
||||
messageID,
|
||||
role,
|
||||
content: [{ type: "text", text }],
|
||||
parsedOverrides: {
|
||||
markdown: text,
|
||||
blocks: [{ type: "response", text }],
|
||||
},
|
||||
});
|
||||
|
||||
const executeMessage = (messageID: number): ParsedMessageEntry => {
|
||||
const args = { command: "pnpm test" };
|
||||
const tool: MergedTool = {
|
||||
id: "execute-1",
|
||||
name: "execute",
|
||||
args,
|
||||
isError: false,
|
||||
status: "completed",
|
||||
};
|
||||
return entry({
|
||||
messageID,
|
||||
parsedOverrides: {
|
||||
toolCalls: [{ id: tool.id, name: tool.name, args }],
|
||||
tools: [tool],
|
||||
blocks: [{ type: "tool", id: tool.id }],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("deriveMessageDisplayState", () => {
|
||||
it("marks text-only user messages as copyable", () => {
|
||||
const message = buildMessage([{ type: "text", text: "Copy this" }]);
|
||||
@@ -67,7 +188,7 @@ describe("deriveMessageDisplayState", () => {
|
||||
expect(getDisplayState(message).hasCopyableContent).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the assistant spacer for thinking-only messages when no suppressing flags apply", () => {
|
||||
it("shows the assistant spacer for reasoning messages when no suppressing flags apply", () => {
|
||||
const message = buildMessage(
|
||||
[{ type: "reasoning", text: "I should think before answering." }],
|
||||
"assistant",
|
||||
@@ -76,40 +197,6 @@ describe("deriveMessageDisplayState", () => {
|
||||
expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the assistant spacer when thinking is followed by a tool call", () => {
|
||||
const message = buildMessage(
|
||||
[
|
||||
{ type: "reasoning", text: "I should think before acting." },
|
||||
{
|
||||
type: "tool-call",
|
||||
tool_call_id: "tool-1",
|
||||
tool_name: "execute",
|
||||
args: { command: "pnpm storybook --no-open" },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
);
|
||||
|
||||
expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the assistant spacer when thinking is followed by a hidden execute tool", () => {
|
||||
const message = buildMessage(
|
||||
[
|
||||
{ type: "reasoning", text: "I should think before acting." },
|
||||
{
|
||||
type: "tool-call",
|
||||
tool_call_id: "tool-1",
|
||||
tool_name: "execute",
|
||||
args: {},
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
);
|
||||
|
||||
expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses the assistant spacer while awaiting the first stream chunk", () => {
|
||||
const message = buildMessage(
|
||||
[{ type: "reasoning", text: "I should think before answering." }],
|
||||
@@ -187,13 +274,28 @@ describe("deriveMessageDisplayState", () => {
|
||||
"assistant",
|
||||
);
|
||||
|
||||
expect(getDisplayState(message).shouldHide).toBe(false);
|
||||
expect(
|
||||
getDisplayState(message, {
|
||||
parsed: parsed({
|
||||
tools: [
|
||||
{
|
||||
id: "tool-1",
|
||||
name: "execute",
|
||||
args: { command: "pnpm test" },
|
||||
isError: false,
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
blocks: [{ type: "tool", id: "tool-1" }],
|
||||
}),
|
||||
}).shouldHide,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides running wait_agent messages until the chat id is available", () => {
|
||||
const message = buildMessage([], "assistant");
|
||||
const parsed: ParsedMessageContent = {
|
||||
...getParsedMessage(message),
|
||||
const parsedContent: ParsedMessageContent = {
|
||||
...parseMessageContent(message.content),
|
||||
blocks: [{ type: "tool", id: "wait-1" }],
|
||||
tools: [
|
||||
{
|
||||
@@ -209,10 +311,99 @@ describe("deriveMessageDisplayState", () => {
|
||||
expect(
|
||||
deriveMessageDisplayState({
|
||||
message,
|
||||
parsed,
|
||||
parsed: parsedContent,
|
||||
hideActions: false,
|
||||
hasActiveStream: false,
|
||||
}).shouldHide,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSequentialReadFileMessages", () => {
|
||||
it("returns a single read_file-only message unchanged", () => {
|
||||
const readFile = readFileMessage(1, "read-1");
|
||||
|
||||
const result = groupSequentialReadFileMessages([readFile]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(readFile);
|
||||
});
|
||||
|
||||
it("collapses read_file-only assistant messages across hidden tool results", () => {
|
||||
const result = groupSequentialReadFileMessages([
|
||||
readFileMessage(1, "read-1"),
|
||||
hiddenToolResultMessage(2, "read-1"),
|
||||
readFileMessage(3, "read-2"),
|
||||
hiddenToolResultMessage(4, "read-2"),
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].message.id).toBe(1);
|
||||
expect(result[0].parsed.toolCalls).toEqual([
|
||||
{ id: "read-1", name: "read_file", args: { path: "read-1.ts" } },
|
||||
{ id: "read-2", name: "read_file", args: { path: "read-2.ts" } },
|
||||
]);
|
||||
expect(result[0].parsed.toolResults).toEqual([
|
||||
readFileToolResult("read-1"),
|
||||
readFileToolResult("read-2"),
|
||||
]);
|
||||
expect(result[0].parsed.blocks).toEqual([
|
||||
{ type: "tool", id: "read-1" },
|
||||
{ type: "tool", id: "read-2" },
|
||||
]);
|
||||
expect(result[0].parsed.tools.map((tool) => tool.id)).toEqual([
|
||||
"read-1",
|
||||
"read-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["assistant", textMessage(2, "middle")],
|
||||
["user", textMessage(2, "middle", "user")],
|
||||
] satisfies Array<
|
||||
[string, ParsedMessageEntry]
|
||||
>)("does not collapse read_file messages across visible %s content", (_, message) => {
|
||||
const result = groupSequentialReadFileMessages([
|
||||
readFileMessage(1, "read-1"),
|
||||
message,
|
||||
readFileMessage(3, "read-2"),
|
||||
]);
|
||||
|
||||
expect(result.map((entry) => entry.message.id)).toEqual([1, 2, 3]);
|
||||
expect(result[0].parsed.blocks).toEqual([{ type: "tool", id: "read-1" }]);
|
||||
expect(result[2].parsed.blocks).toEqual([{ type: "tool", id: "read-2" }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["markdown", { markdown: "Visible markdown" }],
|
||||
["reasoning", { reasoning: "Visible reasoning" }],
|
||||
[
|
||||
"sources",
|
||||
{
|
||||
sources: [
|
||||
{ url: "https://example.com/read-2", title: "Read 2 source" },
|
||||
],
|
||||
},
|
||||
],
|
||||
] satisfies Array<
|
||||
[string, Partial<ParsedMessageContent>]
|
||||
>)("does not collapse read_file messages with visible %s", (_, overrides) => {
|
||||
const result = groupSequentialReadFileMessages([
|
||||
readFileMessage(1, "read-1"),
|
||||
readFileMessage(2, "read-2", overrides),
|
||||
readFileMessage(3, "read-3"),
|
||||
]);
|
||||
|
||||
expect(result.map((entry) => entry.message.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("does not collapse read_file messages across another visible tool", () => {
|
||||
const result = groupSequentialReadFileMessages([
|
||||
readFileMessage(1, "read-1"),
|
||||
executeMessage(2),
|
||||
readFileMessage(3, "read-2"),
|
||||
]);
|
||||
|
||||
expect(result.map((entry) => entry.message.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { shouldRenderTool } from "../ChatElements/tools/toolVisibility";
|
||||
import type { ParsedMessageContent, RenderBlock } from "./types";
|
||||
import type {
|
||||
ParsedMessageContent,
|
||||
ParsedMessageEntry,
|
||||
RenderBlock,
|
||||
} from "./types";
|
||||
|
||||
export type UserInlineRenderBlock =
|
||||
| Extract<RenderBlock, { type: "response" }>
|
||||
@@ -119,3 +123,88 @@ export const deriveMessageDisplayState = ({
|
||||
needsAssistantBottomSpacer,
|
||||
};
|
||||
};
|
||||
|
||||
const isReadFileOnlyMessage = (entry: ParsedMessageEntry): boolean => {
|
||||
if (entry.message.role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
entry.parsed.blocks.length === 0 ||
|
||||
entry.parsed.markdown.trim() ||
|
||||
entry.parsed.reasoning.trim() ||
|
||||
entry.parsed.sources.length > 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const toolByID = new Map(entry.parsed.tools.map((tool) => [tool.id, tool]));
|
||||
return entry.parsed.blocks.every(
|
||||
(block) =>
|
||||
block.type === "tool" && toolByID.get(block.id)?.name === "read_file",
|
||||
);
|
||||
};
|
||||
|
||||
const mergeReadFileMessageGroup = (
|
||||
group: readonly ParsedMessageEntry[],
|
||||
): ParsedMessageEntry => {
|
||||
if (group.length === 1) {
|
||||
return group[0];
|
||||
}
|
||||
|
||||
const [first] = group;
|
||||
return {
|
||||
message: first.message,
|
||||
parsed: {
|
||||
markdown: "",
|
||||
reasoning: "",
|
||||
toolCalls: group.flatMap((entry) => entry.parsed.toolCalls),
|
||||
toolResults: group.flatMap((entry) => entry.parsed.toolResults),
|
||||
tools: group.flatMap((entry) => entry.parsed.tools),
|
||||
blocks: group.flatMap((entry) => entry.parsed.blocks),
|
||||
sources: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Real transcripts place hidden tool-result-only messages between
|
||||
// sequential read_file assistant messages. Those hidden entries stay
|
||||
// transparent so the visible timeline reflects one file-reading run instead
|
||||
// of one row per persisted message. Synthetic grouped entries deliberately
|
||||
// render from merged parsed fields because their raw message payload still
|
||||
// belongs to the first persisted message.
|
||||
export const groupSequentialReadFileMessages = (
|
||||
entries: readonly ParsedMessageEntry[],
|
||||
): ParsedMessageEntry[] => {
|
||||
const grouped: ParsedMessageEntry[] = [];
|
||||
let currentReadFileEntries: ParsedMessageEntry[] = [];
|
||||
|
||||
const flushReadFileEntries = () => {
|
||||
if (currentReadFileEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
grouped.push(mergeReadFileMessageGroup(currentReadFileEntries));
|
||||
currentReadFileEntries = [];
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
const displayState = deriveMessageDisplayState({
|
||||
message: entry.message,
|
||||
parsed: entry.parsed,
|
||||
hideActions: false,
|
||||
hasActiveStream: false,
|
||||
});
|
||||
if (displayState.shouldHide) {
|
||||
continue;
|
||||
}
|
||||
if (isReadFileOnlyMessage(entry)) {
|
||||
currentReadFileEntries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushReadFileEntries();
|
||||
grouped.push(entry);
|
||||
}
|
||||
|
||||
flushReadFileEntries();
|
||||
return grouped;
|
||||
};
|
||||
|
||||
@@ -8,13 +8,60 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { asRecord, asString } from "../runtimeTypeUtils";
|
||||
import { ToolCollapsible } from "./ToolCollapsible";
|
||||
import {
|
||||
DIFFS_FONT_STYLE,
|
||||
getFileViewerOptionsMinimal,
|
||||
parseArgs,
|
||||
type ToolStatus,
|
||||
} from "./utils";
|
||||
|
||||
const ReadFileContent: React.FC<{
|
||||
path: string;
|
||||
content: string;
|
||||
}> = ({ path, content }) => {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
|
||||
viewportClassName="max-h-64"
|
||||
scrollBarClassName="w-1.5"
|
||||
>
|
||||
<FileViewer
|
||||
file={{
|
||||
name: path,
|
||||
contents: content,
|
||||
}}
|
||||
options={getFileViewerOptionsMinimal(isDark)}
|
||||
style={DIFFS_FONT_STYLE}
|
||||
/>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export const getReadFileToolData = ({
|
||||
args,
|
||||
result,
|
||||
isError,
|
||||
}: {
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
isError: boolean;
|
||||
}) => {
|
||||
const parsedArgs = parseArgs(args);
|
||||
const path = parsedArgs ? asString(parsedArgs.path).trim() : "";
|
||||
const rec = asRecord(result);
|
||||
return {
|
||||
path: path || "file",
|
||||
content: rec ? asString(rec.content).trim() : "",
|
||||
isError,
|
||||
errorMessage: rec ? asString(rec.error || rec.message) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapsed-by-default rendering for `read_file` tool calls. Shows
|
||||
* "Read <filename>" with a chevron; expanding reveals the file viewer.
|
||||
@@ -25,10 +72,18 @@ export const ReadFileTool: React.FC<{
|
||||
status: ToolStatus;
|
||||
isError: boolean;
|
||||
errorMessage?: string;
|
||||
}> = ({ path, content, status, isError, errorMessage }) => {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
const hasContent = content.length > 0;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
}> = ({
|
||||
path,
|
||||
content,
|
||||
status,
|
||||
isError,
|
||||
errorMessage,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
}) => {
|
||||
const hasContent = content.length > 0 || isError;
|
||||
const isRunning = status === "running";
|
||||
const filename = path.split("/").pop() || path;
|
||||
const label = isRunning ? `Reading ${filename}…` : `Read ${filename}`;
|
||||
@@ -37,6 +92,8 @@ export const ReadFileTool: React.FC<{
|
||||
<ToolCollapsible
|
||||
className="w-full"
|
||||
hasContent={hasContent}
|
||||
expanded={expanded}
|
||||
onExpandedChange={onExpandedChange}
|
||||
header={
|
||||
<>
|
||||
<span className="text-[13px]">{label}</span>
|
||||
@@ -56,20 +113,12 @@ export const ReadFileTool: React.FC<{
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ScrollArea
|
||||
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
|
||||
viewportClassName="max-h-64"
|
||||
scrollBarClassName="w-1.5"
|
||||
>
|
||||
<FileViewer
|
||||
file={{
|
||||
name: path,
|
||||
contents: content,
|
||||
}}
|
||||
options={getFileViewerOptionsMinimal(isDark)}
|
||||
style={DIFFS_FONT_STYLE}
|
||||
/>
|
||||
</ScrollArea>
|
||||
{isError && (
|
||||
<div className="mt-1 text-xs text-content-destructive">
|
||||
{errorMessage || "Failed to read file"}
|
||||
</div>
|
||||
)}
|
||||
{content.length > 0 && <ReadFileContent path={path} content={content} />}
|
||||
</ToolCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import type { MergedTool } from "../../ChatConversation/types";
|
||||
import { getReadFileToolData, ReadFileTool } from "./ReadFileTool";
|
||||
import { ToolCollapsible } from "./ToolCollapsible";
|
||||
|
||||
type ReadFileItem = {
|
||||
id: string;
|
||||
path: string;
|
||||
content: string;
|
||||
status: MergedTool["status"];
|
||||
isError: boolean;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
const getReadFileItem = (tool: MergedTool): ReadFileItem => ({
|
||||
id: tool.id,
|
||||
status: tool.status,
|
||||
...getReadFileToolData(tool),
|
||||
});
|
||||
|
||||
export const ReadFilesTool: FC<{
|
||||
tools: readonly MergedTool[];
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
}> = ({ tools, expanded, onExpandedChange }) => {
|
||||
const [expandedFileIDs, setExpandedFileIDs] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const items = tools.map(getReadFileItem);
|
||||
const isRunning = tools.some((tool) => tool.status === "running");
|
||||
const isError = tools.some((tool) => tool.isError);
|
||||
const hasContent = items.length > 0;
|
||||
const label = isRunning
|
||||
? `Reading ${tools.length} files…`
|
||||
: `Read ${tools.length} files`;
|
||||
const errorMessage = items.find((item) => item.errorMessage)?.errorMessage;
|
||||
|
||||
return (
|
||||
<div data-tool-call="">
|
||||
<ToolCollapsible
|
||||
className="w-full"
|
||||
hasContent={hasContent}
|
||||
expanded={expanded}
|
||||
onExpandedChange={onExpandedChange}
|
||||
header={
|
||||
<>
|
||||
<span className="text-[13px]">{label}</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-current" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to read one or more files"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1 py-0.5 pl-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<ReadFileTool
|
||||
path={item.path}
|
||||
content={item.content}
|
||||
status={item.status}
|
||||
isError={item.isError}
|
||||
errorMessage={item.errorMessage}
|
||||
expanded={expandedFileIDs.has(item.id)}
|
||||
onExpandedChange={(nextExpanded) => {
|
||||
setExpandedFileIDs((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (nextExpanded) {
|
||||
next.add(item.id);
|
||||
} else {
|
||||
next.delete(item.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToolCollapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { ListTemplatesTool } from "./ListTemplatesTool";
|
||||
import { ProcessOutputTool } from "./ProcessOutputTool";
|
||||
import { ProposePlanTool } from "./ProposePlanTool";
|
||||
import { ReadFileTool } from "./ReadFileTool";
|
||||
import { getReadFileToolData, ReadFileTool } from "./ReadFileTool";
|
||||
import { ReadSkillTool } from "./ReadSkillTool";
|
||||
import { ReadTemplateTool } from "./ReadTemplateTool";
|
||||
import { StartWorkspaceTool } from "./StartWorkspaceTool";
|
||||
@@ -315,22 +315,12 @@ const ReadFileRenderer: FC<ToolRendererProps> = ({
|
||||
args,
|
||||
result,
|
||||
isError,
|
||||
}) => {
|
||||
const parsedArgs = parseArgs(args);
|
||||
const path = parsedArgs ? asString(parsedArgs.path).trim() : "";
|
||||
const rec = asRecord(result);
|
||||
const content = rec ? asString(rec.content).trim() : "";
|
||||
|
||||
return (
|
||||
<ReadFileTool
|
||||
path={path || "file"}
|
||||
content={content}
|
||||
status={status}
|
||||
isError={isError}
|
||||
errorMessage={rec ? asString(rec.error || rec.message) : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<ReadFileTool
|
||||
{...getReadFileToolData({ args, result, isError })}
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
|
||||
const ReadSkillRenderer: FC<ToolRendererProps> = ({
|
||||
status,
|
||||
|
||||
Reference in New Issue
Block a user