diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx index 2490853c9f..965568b58f 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx @@ -4,6 +4,7 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; import type * as TypesGen from "api/typesGenerated"; import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated"; +import type { ComponentProps, FC } from "react"; import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import type { ModelSelectorOption } from "#/components/ai-elements"; @@ -44,10 +45,12 @@ const buildChat = (overrides: Partial = {}): TypesGen.Chat => ({ ...overrides, }); -const defaultEditing = { +const buildEditing = ( + overrides: Partial["editing"]> = {}, +) => ({ chatInputRef: { current: null }, editorInitialValue: "", - editingMessageId: null, + editingMessageId: null as number | null, editingFileBlocks: [] as readonly ChatMessagePart[], handleEditUserMessage: fn(), handleCancelHistoryEdit: fn(), @@ -56,15 +59,15 @@ const defaultEditing = { handleCancelQueueEdit: fn(), handleSendFromInput: fn(), handleContentChange: fn(), -}; + ...overrides, +}); -const defaultGitWatcher: { - repositories: ReadonlyMap; - refresh: () => void; -} = { +const buildGitWatcher = (): ComponentProps< + typeof AgentDetailView +>["gitWatcher"] => ({ repositories: new Map(), refresh: fn(), -}; +}); const agentsRouting = [ { path: "/agents/:agentId", useStoryElement: true }, @@ -74,6 +77,84 @@ const agentsRouting = [ ...{ path: string; useStoryElement: boolean }[], ]; +// --------------------------------------------------------------------------- +// Wrapper component. +// +// Storybook's composeStory deep-merges meta.args into every story. +// When meta.args contains many fn() mocks, Maps, and closure-bound +// stores the merge hangs the browser. This wrapper builds fresh +// default props on each render, accepting only the overrides each +// story cares about. +// --------------------------------------------------------------------------- +type StoryProps = Omit< + Partial>, + "editing" +> & { + editing?: Partial["editing"]>; +}; + +const StoryAgentDetailView: FC = ({ editing, ...overrides }) => { + const props = { + agentId: AGENT_ID, + chatTitle: "Help me refactor", + chatErrorReasons: {} as ComponentProps< + typeof AgentDetailView + >["chatErrorReasons"], + parentChat: undefined as TypesGen.Chat | undefined, + chatRecord: buildChat(), + isArchived: false, + hasWorkspace: true, + store: createChatStore(), + pendingEditMessageId: null as number | null, + effectiveSelectedModel: "openai:gpt-4o", + setSelectedModel: fn(), + modelOptions: defaultModelOptions, + modelSelectorPlaceholder: "Select a model", + hasModelOptions: true, + inputStatusText: null as string | null, + modelCatalogStatusMessage: null as string | null, + compressionThreshold: undefined as number | undefined, + isInputDisabled: false, + isSubmissionPending: false, + isInterruptPending: false, + isSidebarCollapsed: false, + onToggleSidebarCollapsed: fn(), + showSidebarPanel: false, + onSetShowSidebarPanel: fn(), + prNumber: undefined as number | undefined, + diffStatusData: undefined as ComponentProps< + typeof AgentDetailView + >["diffStatusData"], + gitWatcher: buildGitWatcher(), + canOpenEditors: false, + canOpenWorkspace: false, + sshCommand: undefined as string | undefined, + handleOpenInEditor: fn(), + handleViewWorkspace: fn(), + handleOpenTerminal: fn(), + handleCommit: fn(), + handleInterrupt: fn(), + handleDeleteQueuedMessage: fn(), + handlePromoteQueuedMessage: fn(), + handleArchiveAgentAction: fn(), + handleUnarchiveAgentAction: fn(), + handleArchiveAndDeleteWorkspaceAction: fn(), + scrollContainerRef: { current: null }, + hasMoreMessages: false, + isFetchingMoreMessages: false, + onFetchMoreMessages: fn(), + mcpServers: [] as ComponentProps["mcpServers"], + selectedMCPServerIds: [] as ComponentProps< + typeof AgentDetailView + >["selectedMCPServerIds"], + onMCPSelectionChange: fn(), + onMCPAuthComplete: fn(), + ...overrides, + editing: buildEditing(editing), + }; + return ; +}; + // --------------------------------------------------------------------------- // Meta // --------------------------------------------------------------------------- @@ -92,54 +173,6 @@ const meta: Meta = { routing: agentsRouting, }), }, - args: { - agentId: AGENT_ID, - chatTitle: "Help me refactor", - parentChat: undefined, - chatErrorReasons: {}, - chatRecord: buildChat(), - isArchived: false, - hasWorkspace: true, - store: createChatStore(), - editing: defaultEditing, - pendingEditMessageId: null, - effectiveSelectedModel: "openai:gpt-4o", - setSelectedModel: fn(), - modelOptions: defaultModelOptions, - modelSelectorPlaceholder: "Select a model", - hasModelOptions: true, - inputStatusText: null, - modelCatalogStatusMessage: null, - compressionThreshold: undefined, - isInputDisabled: false, - isSubmissionPending: false, - isInterruptPending: false, - isSidebarCollapsed: false, - onToggleSidebarCollapsed: fn(), - showSidebarPanel: false, - onSetShowSidebarPanel: fn(), - prNumber: undefined, - diffStatusData: undefined, - gitWatcher: defaultGitWatcher, - canOpenEditors: false, - canOpenWorkspace: false, - sshCommand: undefined, - handleOpenInEditor: fn(), - handleViewWorkspace: fn(), - handleOpenTerminal: fn(), - handleCommit: fn(), - handleInterrupt: fn(), - handleDeleteQueuedMessage: fn(), - handlePromoteQueuedMessage: fn(), - handleArchiveAgentAction: fn(), - handleUnarchiveAgentAction: fn(), - handleArchiveAndDeleteWorkspaceAction: fn(), - scrollContainerRef: { current: null }, - mcpServers: [], - selectedMCPServerIds: [], - onMCPSelectionChange: fn(), - onMCPAuthComplete: fn(), - }, }; export default meta; @@ -150,66 +183,71 @@ type Story = StoryObj; // --------------------------------------------------------------------------- /** Basic conversation view with a chat title, workspace, and no archive. */ -export const Default: Story = {}; +export const Default: Story = { + render: () => , +}; /** Archived agent displays the read-only banner below the top bar. */ export const Archived: Story = { - args: { - isArchived: true, - chatRecord: buildChat({ archived: true }), - isInputDisabled: true, - }, + render: () => ( + + ), }; /** Shows the parent chat link in the top bar when a parent exists. */ export const WithParentChat: Story = { - args: { - parentChat: buildChat({ - id: "parent-chat-1", - title: "Root agent", - }), - }, + render: () => ( + + ), }; /** Persisted error reason shown in the timeline area. */ export const WithError: Story = { - args: { - chatErrorReasons: { - [AGENT_ID]: { kind: "generic", message: "Model rate limited" }, - }, - }, + render: () => ( + + ), }; /** Input area appears disabled when `isInputDisabled` is true. */ export const InputDisabled: Story = { - args: { - isInputDisabled: true, - }, + render: () => , }; /** Shows a sending/pending state for the input. */ export const SubmissionPending: Story = { - args: { - isSubmissionPending: true, - }, + render: () => , }; /** Right sidebar panel is open with diff status data. */ export const WithSidebarPanel: Story = { - args: { - showSidebarPanel: true, - prNumber: 123, - diffStatusData: { - chat_id: AGENT_ID, - url: "https://github.com/coder/coder/pull/123", - pull_request_title: "fix: resolve race condition in workspace builds", - pull_request_draft: false, - changes_requested: false, - additions: 42, - deletions: 7, - changed_files: 5, - } satisfies ChatDiffStatus, - }, + render: () => ( + + ), beforeEach: () => { spyOn(API.experimental, "getChatDiffContents").mockResolvedValue({ chat_id: AGENT_ID, @@ -229,28 +267,30 @@ index abc1234..def5678 100644 /** Left sidebar is collapsed. */ export const SidebarCollapsed: Story = { - args: { - isSidebarCollapsed: true, - }, + render: () => , }; /** No model options available — shows a disabled status message. */ export const NoModelOptions: Story = { - args: { - hasModelOptions: false, - modelOptions: [], - inputStatusText: "No models configured. Ask an admin.", - isInputDisabled: true, - }, + render: () => ( + + ), }; /** Top bar has workspace action buttons visible. */ export const WithWorkspaceActions: Story = { - args: { - canOpenEditors: true, - canOpenWorkspace: true, - sshCommand: "ssh coder.workspace", - }, + render: () => ( + + ), }; // --------------------------------------------------------------------------- @@ -382,29 +422,31 @@ const editingMessages = [ * border on the edited message, faded subsequent messages, and the editing * banner + outline on the chat input. */ export const EditingMessage: Story = { - args: { - store: buildStoreWithMessages(editingMessages), - editing: { - ...defaultEditing, - editingMessageId: 3, - editorInitialValue: "Now tell me a joke", - }, - }, + render: () => ( + + ), }; /** The saving state while an edit is in progress — shows the pending * indicator on the message being saved. */ export const EditingSaving: Story = { - args: { - store: buildStoreWithMessages(editingMessages), - editing: { - ...defaultEditing, - editingMessageId: 3, - editorInitialValue: "Now tell me a better joke", - }, - pendingEditMessageId: 3, - isSubmissionPending: true, - }, + render: () => ( + + ), }; // --------------------------------------------------------------------------- @@ -484,13 +526,30 @@ const scrollAwayFromBottom = (scrollContainer: HTMLElement) => { scrollContainer.dispatchEvent(new Event("scroll")); }; +/** Helper that extracts the current messages array from a store. */ +const getStoreMessages = ( + store: ReturnType, +): TypesGen.ChatMessage[] => { + const snapshot = store.getSnapshot(); + const messages: TypesGen.ChatMessage[] = []; + for (const id of snapshot.orderedMessageIDs) { + const message = snapshot.messagesByID.get(id); + if (message) { + messages.push(message); + } + } + return messages; +}; + /** Scroll-to-bottom button appears after scrolling up in a long * conversation, and clicking it returns to the bottom. */ export const ScrollToBottomButton: Story = { - args: { - store: buildStoreWithMessages(buildLongConversation(40)), - }, decorators: scrollStoryDecorators, + render: () => ( + + ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -532,13 +591,17 @@ export const ScrollToBottomButton: Story = { }, }; +// Each scroll story that mutates the store in its play function +// creates the store at module scope so the play closure can reach +// it. Stories in a file execute sequentially, so there is no +// cross-contamination. +const preservedScrollStore = buildStoreWithMessages(buildLongConversation(30)); + /** When scrolled away from bottom, new content preserves scroll position. */ export const ScrollPositionPreservedOnNewContent: Story = { - args: { - store: buildStoreWithMessages(buildLongConversation(30)), - }, decorators: scrollStoryDecorators, - play: async ({ canvasElement, args }) => { + render: () => , + play: async ({ canvasElement }) => { const canvas = within(canvasElement); const scrollContainer = canvas.getByTestId("scroll-container"); @@ -561,29 +624,21 @@ export const ScrollPositionPreservedOnNewContent: Story = { const scrollTopBefore = scrollContainer.scrollTop; expect(Math.abs(scrollTopBefore)).toBeGreaterThan(50); - const store = args?.store; - if (!store) { - throw new Error("Expected store in args"); - } - - const snapshot = store.getSnapshot(); - const existing: TypesGen.ChatMessage[] = []; - for (const id of snapshot.orderedMessageIDs) { - const message = snapshot.messagesByID.get(id); - if (message) { - existing.push(message); - } - } - - const newMessages = [ - buildMessage(31, "user", "Follow-up question about the implementation."), - buildMessage( - 32, - "assistant", - "Here is a detailed response about the implementation details you asked about.", - ), - ]; - store.replaceMessages(existing.concat(newMessages)); + const existing = getStoreMessages(preservedScrollStore); + preservedScrollStore.replaceMessages( + existing.concat([ + buildMessage( + 31, + "user", + "Follow-up question about the implementation.", + ), + buildMessage( + 32, + "assistant", + "Here is a detailed response about the implementation details you asked about.", + ), + ]), + ); // Wait for ResizeObserver + RAF compensation to settle. // We should remain significantly away from the bottom. @@ -600,13 +655,13 @@ export const ScrollPositionPreservedOnNewContent: Story = { }, }; +const pinnedScrollStore = buildStoreWithMessages(buildLongConversation(30)); + /** When at bottom, new content keeps the user pinned to bottom. */ export const ScrollPinnedToBottomOnNewContent: Story = { - args: { - store: buildStoreWithMessages(buildLongConversation(30)), - }, decorators: scrollStoryDecorators, - play: async ({ canvasElement, args }) => { + render: () => , + play: async ({ canvasElement }) => { const canvas = within(canvasElement); const scrollContainer = canvas.getByTestId("scroll-container"); @@ -618,31 +673,19 @@ export const ScrollPinnedToBottomOnNewContent: Story = { canvas.queryByRole("button", { name: "Scroll to bottom" }), ).toBeNull(); - const store = args?.store; - if (!store) { - throw new Error("Expected store in args"); - } - - const snapshot = store.getSnapshot(); - const existing: TypesGen.ChatMessage[] = []; - for (const id of snapshot.orderedMessageIDs) { - const message = snapshot.messagesByID.get(id); - if (message) { - existing.push(message); - } - } - - const newMessages = [ - buildMessage(31, "user", "Another question."), - buildMessage(32, "assistant", "Here is the answer with full details."), - buildMessage(33, "user", "Thanks, one more thing."), - buildMessage( - 34, - "assistant", - "Sure, here is the additional information you requested.", - ), - ]; - store.replaceMessages(existing.concat(newMessages)); + const existing = getStoreMessages(pinnedScrollStore); + pinnedScrollStore.replaceMessages( + existing.concat([ + buildMessage(31, "user", "Another question."), + buildMessage(32, "assistant", "Here is the answer with full details."), + buildMessage(33, "user", "Thanks, one more thing."), + buildMessage( + 34, + "assistant", + "Sure, here is the additional information you requested.", + ), + ]), + ); // Wait for the double-RAF pin to complete. await waitFor(