-
Notifications
You must be signed in to change notification settings - Fork 1.5k
fix(site/src/pages/AgentsPage/components): make timeline blocks carry their tool #27593
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1519a0e
6abc44e
7fa49b3
3d72f6f
bb3a363
7e52684
2913f98
61530b4
7435d70
964d5cc
b15495d
d4ea4e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
|
|
@@ -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 = | ||
|
|
@@ -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: { | ||
|
|
@@ -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, | ||
|
|
@@ -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: { | ||
|
|
@@ -2492,6 +2522,126 @@ export const SequentialReadFilesCollapsed: Story = { | |
| }, | ||
| }; | ||
|
|
||
| const readFileTool = (id: string, path: string): MergedTool => ({ | ||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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:
|
||
|
|
||
| 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, | ||
|
|
@@ -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: { | ||
|
|
@@ -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(); | ||
|
|
||
There was a problem hiding this comment.
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_fileMergedToolfactory joins the three already in this directory. (Robin)The others:
blockUtils.test.ts:105tool(id, name = "read_file"),streamingActivity.test.ts:51tool(id, status), andbuildParsedReadFileEntryatConversationTimeline.stories.tsx:240, which constructs this same object inline 28 lines lower. Four spellings of one three-field literal.storyFixtures.tsis already the shared fixture module for this directory andbuildStreamRenderStateproves the pattern. Pre-existing for three of the four; this PR adds the fourth.