Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
formatTextAttachmentPreview,
} from "../../utils/fetchTextAttachment";
import { ImageThumbnail } from "../AgentChatInput";
import { ownValue } from "../ChatElements/runtimeTypeUtils";
import { useFileProbes } from "./FileProbeContext";
import type { RenderBlock } from "./types";

Expand Down Expand Up @@ -70,7 +71,7 @@ const sanitizeAttachmentExtension = (value: string): string => {
const getAttachmentExtension = (
block: Pick<FileAttachmentBlock, "media_type" | "name">,
): string => {
const mapped = ATTACHMENT_FALLBACK_EXTENSIONS[block.media_type];
const mapped = ownValue(ATTACHMENT_FALLBACK_EXTENSIONS, block.media_type);
if (mapped) {
return mapped;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, useState } from "react";
import {
expect,
fireEvent,
Expand All @@ -12,9 +13,9 @@ import {
import type * as TypesGen from "#/api/typesGenerated";
import { getChatFileURL } from "../../utils/chatAttachments";
import { encodeInlineTextAttachment } from "../../utils/fetchTextAttachment";
import { ConversationTimeline } from "./ConversationTimeline";
import { BlockList, ConversationTimeline } from "./ConversationTimeline";
import { parseMessagesWithMergedTools } from "./messageParsing";
import type { ParsedMessageEntry } from "./types";
import type { MergedTool, ParsedMessageEntry, RenderBlock } from "./types";

// 1×1 solid coral (#FF6B6B) PNG encoded as base64.
const TEST_PNG_B64 =
Expand Down Expand Up @@ -1680,6 +1681,33 @@ export const NoCopyButtonAfterTrailingToolCall: Story = {
},
};

const askQuestionExchange: TypesGen.ChatMessage[] = [
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "tool-call",
tool_call_id: "ask-tool-1",
tool_name: "ask_user_question",
},
],
},
{
...baseMessage,
id: 3,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "ask-tool-1",
result: { output: JSON.stringify(askUserQuestionPayload) },
},
],
},
];

/** Persisted ask-user-question answers survive reloads. */
export const AskUserQuestionSubmittedAnswer: Story = {
args: {
Expand All @@ -1692,32 +1720,7 @@ export const AskUserQuestionSubmittedAnswer: Story = {
role: "user",
content: [{ type: "text", text: "Help me pick a rollout plan." }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "tool-call",
tool_call_id: "ask-tool-1",
tool_name: "ask_user_question",
},
],
},
{
...baseMessage,
id: 3,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "ask-tool-1",
result: {
output: JSON.stringify(askUserQuestionPayload),
},
},
],
},
...askQuestionExchange,
{
...baseMessage,
id: 4,
Expand Down Expand Up @@ -1753,6 +1756,33 @@ export const AskUserQuestionSubmittedAnswer: Story = {
},
};

/**
* A hidden metadata-only user message does not settle the question, so the
* answer form stays interactive.
*/
export const AskUserQuestionMetadataOnlyReply: Story = {
args: {
...defaultArgs,
isChatCompleted: true,
onSendAskUserQuestionResponse: fn(),
parsedMessages: buildMessages([
...askQuestionExchange,
{
...baseMessage,
id: 4,
role: "user",
content: [
{ type: "context-file", context_file_path: "/home/coder/AGENTS.md" },
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Next" })).toBeVisible();
},
};

/** No copy button when assistant message has no markdown content. */
export const AssistantMessageNoCopyWhenToolOnly: Story = {
args: {
Expand Down Expand Up @@ -2492,6 +2522,126 @@ export const SequentialReadFilesCollapsed: Story = {
},
};

const readFileTool = (id: string, path: string): MergedTool => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-170] A fourth read_file MergedTool factory joins the three already in this directory. (Robin)

The others: blockUtils.test.ts:105 tool(id, name = "read_file"), streamingActivity.test.ts:51 tool(id, status), and buildParsedReadFileEntry at ConversationTimeline.stories.tsx:240, which constructs this same object inline 28 lines lower. Four spellings of one three-field literal.

storyFixtures.ts is already the shared fixture module for this directory and buildStreamRenderState proves the pattern. Pre-existing for three of the four; this PR adds the fourth.

🤖

id,
name: "read_file",
args: { path },
result: { content: `// ${path}` },
isError: false,
status: "completed",
});

// One block of every kind that carries a source index, behind a read-file run
// that collapses two rows into one and shifts every later timeline position.
const SOURCE_INDEX_BLOCKS: readonly RenderBlock[] = [
{ type: "tool", id: "read-1" },
{ type: "tool", id: "read-2" },
{ type: "thinking", text: "Let me think about this step by step." },
{ type: "response", text: "The collapse must not remount this response." },
{
type: "file-reference",
file_name: "notes.ts",
start_line: 12,
end_line: 12,
content: "const notes = [];",
},
// No file_id, so the key falls through to the source index.
{ type: "file", media_type: "image/png", data: TEST_PNG_B64 },
{
type: "sources",
sources: [{ url: "https://example.com", title: "Example" }],
},
];

const ReadFileRunCollapseHarness: FC<{ isStreaming?: boolean }> = ({
isStreaming,
}) => {
const [secondReadResolved, setSecondReadResolved] = useState(false);
return (
<div className="flex flex-col gap-2">
<button type="button" onClick={() => setSecondReadResolved(true)}>
Resolve second read
</button>
<div data-testid="timeline-rows">
<BlockList
blocks={SOURCE_INDEX_BLOCKS}
tools={
secondReadResolved
? [readFileTool("read-1", "a.ts"), readFileTool("read-2", "b.ts")]
: [readFileTool("read-1", "a.ts")]
}
keyPrefix="read-run"
isStreaming={isStreaming}
/>
</div>
</div>
);
};

// The rows below the collapsing read-file run: thinking, response, file
// reference, attachment, and sources.
const SURVIVING_ROW_COUNT = 5;

const expectRowsSurviveCollapse: Story["play"] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const thinkingButton = canvas.getByRole("button", { name: "Thinking" });
await userEvent.click(thinkingButton);
expect(thinkingButton).toHaveAttribute("aria-expanded", "true");
// Rows are captured by position, so a response still revealing its streamed
// text cannot decide the outcome.
const rowsBelowRun = [...canvas.getByTestId("timeline-rows").children].slice(
-SURVIVING_ROW_COUNT,
);
expect(rowsBelowRun).toHaveLength(SURVIVING_ROW_COUNT);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-154] The row window is guarded by an assertion that restates slice, so appending one block silently disarms the sourceIndex key checks. (Komugi)

slice(-5) on any array of five or more returns five, so expect(rowsBelowRun).toHaveLength(SURVIVING_ROW_COUNT) cannot fail. Komugi measured the real counts, 6 children in the non-streaming story and 7 in the streaming one, then ran the experiment twice: regress the thinking render key to index and both stories go red; do that and append one response block to SOURCE_INDEX_BLOCKS and both go green, because the window slid up and the read-files group, keyed block.tools[0].id, slid in as guaranteed-passing filler.

So the mutation coverage the body claims for seven keys is one fixture edit away from silent. His fix is one line per story: assert the absolute child count before slicing, or capture the five rows by a stable handle.

He also names the invariant the position capture rests on, stated nowhere: Response renders its wrapper div unconditionally at visibleText === "".

🤖


await userEvent.click(
canvas.getByRole("button", { name: "Resolve second read" }),
);
await waitFor(() => {
expect(canvas.getByRole("button", { name: /read 2 files/i })).toBeVisible();
});

// A key that moved with the timeline would unmount its row, which detaches
// the node and loses the expanded thinking body with it.
for (const row of rowsBelowRun) {
expect(row).toBeInTheDocument();
}
};

/**
* Resolving the second read collapses two rows into one, shortening the
* timeline. Every row below keeps its state because its key comes from its
* position in `blocks`, which did not move.
*/
export const ReadFileRunCollapseKeepsRowsMounted: Story = {
render: () => <ReadFileRunCollapseHarness />,
play: expectRowsSurviveCollapse,
};

/** The same guarantee on the streaming arm, which keys its own response row. */
export const ReadFileRunCollapseKeepsRowsMountedWhileStreaming: Story = {
render: () => <ReadFileRunCollapseHarness isStreaming />,
play: expectRowsSurviveCollapse,
};

/** A lone read and a file-list group are different disclosures. */
export const ReadFileRunGrowthDoesNotCarryExpansion: Story = {
render: () => <ReadFileRunCollapseHarness />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const singleRead = canvas.getByRole("button", { name: /read a\.ts/i });
await userEvent.click(singleRead);
expect(singleRead).toHaveAttribute("aria-expanded", "true");

await userEvent.click(
canvas.getByRole("button", { name: "Resolve second read" }),
);

const group = await canvas.findByRole("button", { name: /read 2 files/i });
expect(group).toHaveAttribute("aria-expanded", "false");
},
};

export const SequentialReadFilesEmptyAndErrorStates: Story = {
args: {
...defaultArgs,
Expand Down Expand Up @@ -2596,6 +2746,54 @@ export const SequentialReadFilesRunningState: Story = {
},
};

/** Solo failures say "Failed to read file", groups "…read one or more files". */
export const ReadFileErrorStates: Story = {
args: {
...defaultArgs,
parsedMessages: [
buildParsedReadFileEntry({
messageId: 1,
toolId: "read-solo-error",
path: "site/src/missing-solo.ts",
status: "error",
}),
...buildMessages([
{
...baseMessage,
id: 2,
role: "assistant",
content: [{ type: "text", text: "Trying the pair instead." }],
},
]),
buildParsedReadFileEntry({
messageId: 3,
toolId: "read-pair-error-1",
path: "site/src/missing-pair-a.ts",
status: "error",
}),
buildParsedReadFileEntry({
messageId: 4,
toolId: "read-pair-error-2",
path: "site/src/missing-pair-b.ts",
status: "error",
}),
] satisfies ParsedMessageEntry[],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /read missing-solo\.ts/i }),
);
await waitFor(() => {
expect(canvas.getByText("Failed to read file")).toBeVisible();
});

expect(
canvas.getByLabelText("Failed to read one or more files"),
).toBeInTheDocument();
},
};

/** Collapsed thinking should visually align with adjacent tool calls. */
export const ThinkingBlockWithToolCall: Story = {
parameters: {
Expand Down Expand Up @@ -2657,16 +2855,12 @@ export const ThinkingBlockWithToolCall: Story = {
const toolButton = canvas.getByRole("button", {
name: /read package\.json/i,
});
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");
const thinkingRow = thinkingButton.closest("[data-transcript-row]");
const toolRow = toolButton.closest("[data-transcript-row]");
expect(thinkingRow).not.toBeNull();
expect(toolRow).not.toBeNull();
expect(toolRow?.firstElementChild).not.toHaveAttribute("data-state");
expect(thinkingRow?.firstElementChild).not.toHaveAttribute("data-state");
expect(
canvas.queryByTestId("assistant-bottom-spacer"),
).not.toBeInTheDocument();
Expand Down
Loading
Loading