fix(site): fix AgentDetailView storybook tests hanging indefinitely (#23544)

This commit is contained in:
Danielle Maywood
2026-03-25 00:19:07 +00:00
committed by GitHub
parent bac45ad80f
commit 6a04e76b48
@@ -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> = {}): TypesGen.Chat => ({
...overrides,
});
const defaultEditing = {
const buildEditing = (
overrides: Partial<ComponentProps<typeof AgentDetailView>["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<string, TypesGen.WorkspaceAgentRepoChanges>;
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<ComponentProps<typeof AgentDetailView>>,
"editing"
> & {
editing?: Partial<ComponentProps<typeof AgentDetailView>["editing"]>;
};
const StoryAgentDetailView: FC<StoryProps> = ({ 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<typeof AgentDetailView>["mcpServers"],
selectedMCPServerIds: [] as ComponentProps<
typeof AgentDetailView
>["selectedMCPServerIds"],
onMCPSelectionChange: fn(),
onMCPAuthComplete: fn(),
...overrides,
editing: buildEditing(editing),
};
return <AgentDetailView {...props} />;
};
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
@@ -92,54 +173,6 @@ const meta: Meta<typeof AgentDetailView> = {
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<typeof AgentDetailView>;
// ---------------------------------------------------------------------------
/** Basic conversation view with a chat title, workspace, and no archive. */
export const Default: Story = {};
export const Default: Story = {
render: () => <StoryAgentDetailView />,
};
/** 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: () => (
<StoryAgentDetailView
isArchived
chatRecord={buildChat({ archived: true })}
isInputDisabled
/>
),
};
/** 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: () => (
<StoryAgentDetailView
parentChat={buildChat({ id: "parent-chat-1", title: "Root agent" })}
/>
),
};
/** Persisted error reason shown in the timeline area. */
export const WithError: Story = {
args: {
chatErrorReasons: {
[AGENT_ID]: { kind: "generic", message: "Model rate limited" },
},
},
render: () => (
<StoryAgentDetailView
chatErrorReasons={{
[AGENT_ID]: { kind: "generic", message: "Model rate limited" },
}}
/>
),
};
/** Input area appears disabled when `isInputDisabled` is true. */
export const InputDisabled: Story = {
args: {
isInputDisabled: true,
},
render: () => <StoryAgentDetailView isInputDisabled />,
};
/** Shows a sending/pending state for the input. */
export const SubmissionPending: Story = {
args: {
isSubmissionPending: true,
},
render: () => <StoryAgentDetailView isSubmissionPending />,
};
/** 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: () => (
<StoryAgentDetailView
showSidebarPanel
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
}
/>
),
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: () => <StoryAgentDetailView isSidebarCollapsed />,
};
/** 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: () => (
<StoryAgentDetailView
hasModelOptions={false}
modelOptions={[]}
inputStatusText="No models configured. Ask an admin."
isInputDisabled
/>
),
};
/** Top bar has workspace action buttons visible. */
export const WithWorkspaceActions: Story = {
args: {
canOpenEditors: true,
canOpenWorkspace: true,
sshCommand: "ssh coder.workspace",
},
render: () => (
<StoryAgentDetailView
canOpenEditors
canOpenWorkspace
sshCommand="ssh coder.workspace"
/>
),
};
// ---------------------------------------------------------------------------
@@ -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: () => (
<StoryAgentDetailView
store={buildStoreWithMessages(editingMessages)}
editing={{
editingMessageId: 3,
editorInitialValue: "Now tell me a joke",
}}
/>
),
};
/** 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: () => (
<StoryAgentDetailView
store={buildStoreWithMessages(editingMessages)}
editing={{
editingMessageId: 3,
editorInitialValue: "Now tell me a better joke",
}}
pendingEditMessageId={3}
isSubmissionPending
/>
),
};
// ---------------------------------------------------------------------------
@@ -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<typeof createChatStore>,
): 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: () => (
<StoryAgentDetailView
store={buildStoreWithMessages(buildLongConversation(40))}
/>
),
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: () => <StoryAgentDetailView store={preservedScrollStore} />,
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: () => <StoryAgentDetailView store={pinnedScrollStore} />,
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(