fix(site/src/pages/AgentsPage): show error state with retry when chat fetch fails (#27887)

This commit is contained in:
Danielle Maywood
2026-08-05 14:50:17 +01:00
committed by GitHub
parent c62079c053
commit 5ed73f06ad
3 changed files with 214 additions and 1 deletions
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { useRef } from "react";
import { hashKey } from "react-query";
import { Outlet, useNavigate } from "react-router";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import {
@@ -22,6 +23,7 @@ import {
import { workspaceByIdKey } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import {
MockChat,
MockChatMessage,
MockChatQueuedMessage,
} from "#/testHelpers/chatEntities";
@@ -32,6 +34,7 @@ import {
MockOrganizationMember2,
MockUserOwner,
MockWorkspace,
mockApiError,
} from "#/testHelpers/entities";
import {
withAuthProvider,
@@ -3059,6 +3062,124 @@ export const SendResponseAfterChatSwitch: Story = {
},
};
const mockErrorChat: TypesGen.Chat = {
...MockChat,
id: CHAT_ID,
...baseChatFields,
title: "Failing chat",
};
const mockServerError = {
...mockApiError({ message: "Internal server error." }),
status: 500,
};
const withoutQuery = (
queries: ReturnType<typeof buildQueries>,
queryKey: readonly unknown[],
) => queries.filter(({ key }) => hashKey(key) !== hashKey(queryKey));
export const DetailQueryError: Story = {
parameters: {
queries: withoutQuery(
buildQueries(mockErrorChat, {
messages: [],
queued_messages: [],
has_more: false,
}),
chatKey(CHAT_ID),
),
},
beforeEach: () => {
spyOn(API.experimental, "getChat").mockRejectedValue(mockServerError);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Failed to load chat")).toBeVisible();
expect(canvas.queryByText("Chat not found")).not.toBeInTheDocument();
expect(
canvas.getByRole("button", { name: "Try again" }),
).toBeInTheDocument();
},
};
export const InitialMessagesError: Story = {
parameters: {
queries: withoutQuery(
buildQueries(mockErrorChat, {
messages: [],
queued_messages: [],
has_more: false,
}),
chatMessagesKey(CHAT_ID),
),
},
beforeEach: () => {
spyOn(API.experimental, "getChatMessages").mockRejectedValue(
mockServerError,
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Failed to load chat")).toBeVisible();
expect(canvas.queryByText("Chat not found")).not.toBeInTheDocument();
},
};
export const ErrorRetryRecovers: Story = {
parameters: {
queries: withoutQuery(
buildQueries(mockErrorChat, {
messages: [],
queued_messages: [],
has_more: false,
}),
chatKey(CHAT_ID),
),
},
beforeEach: ({ parameters }) => {
const getChatSpy = spyOn(API.experimental, "getChat")
.mockRejectedValueOnce(mockServerError)
.mockResolvedValue(mockErrorChat);
parameters.getChatCallsForChat = () =>
getChatSpy.mock.calls.filter(([chatId]) => chatId === CHAT_ID).length;
},
play: async ({ canvasElement, parameters }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Failed to load chat")).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Try again" }));
await waitFor(() => {
expect(canvas.queryByText("Failed to load chat")).not.toBeInTheDocument();
});
expect(canvas.queryByText("Chat not found")).not.toBeInTheDocument();
expect(parameters.getChatCallsForChat()).toBeGreaterThanOrEqual(2);
},
};
export const ChatNotFound: Story = {
parameters: {
queries: withoutQuery(
buildQueries(mockErrorChat, {
messages: [],
queued_messages: [],
has_more: false,
}),
chatKey(CHAT_ID),
),
},
beforeEach: () => {
spyOn(API.experimental, "getChat").mockRejectedValue({
...mockApiError({ message: "Chat not found." }),
status: 404,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Chat not found")).toBeVisible();
expect(canvas.queryByText("Failed to load chat")).not.toBeInTheDocument();
},
};
export const SendRejectedByHookDispatchFailure: Story = {
parameters: {
queries: buildQueries(
+33 -1
View File
@@ -22,7 +22,7 @@ import {
type CreateChatMessageRequestWithClearablePlanMode,
watchWorkspace,
} from "#/api/api";
import { getErrorMessage, isApiError } from "#/api/errors";
import { getErrorMessage, getErrorStatus, isApiError } from "#/api/errors";
import { checkAuthorization } from "#/api/queries/authCheck";
import { buildOptimisticEditedMessage } from "#/api/queries/chatMessageEdits";
import {
@@ -68,6 +68,7 @@ import { isMobileViewport } from "#/utils/mobile";
import { pageTitle } from "#/utils/page";
import { rewriteLocalhostURL } from "#/utils/portForward";
import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket";
import { AgentChatPageErrorView } from "./AgentChatPageErrorView";
import {
AgentChatPageLoadingView,
AgentChatPageNotFoundView,
@@ -1881,6 +1882,37 @@ const AgentChatPage: FC = () => {
);
}
if (chatQuery.isLoadingError || chatMessagesQuery.isLoadingError) {
if (getErrorStatus(chatQuery.error) === 404) {
return (
<AgentChatPageNotFoundView
titleElement={titleElement}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
);
}
return (
<AgentChatPageErrorView
titleElement={titleElement}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
error={
chatQuery.isLoadingError ? chatQuery.error : chatMessagesQuery.error
}
onRetry={() => {
if (chatQuery.isLoadingError) {
void chatQuery.refetch();
}
if (chatMessagesQuery.isLoadingError) {
void chatMessagesQuery.refetch();
}
}}
/>
);
}
if (!chatQuery.data || !chatMessagesQuery.data?.pages?.length || !agentId) {
return (
<AgentChatPageNotFoundView
@@ -0,0 +1,60 @@
import { RotateCcwIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { Button } from "#/components/Button/Button";
import { ChatTopBar } from "./components/ChatTopBar";
interface AgentChatPageErrorViewProps {
titleElement: ReactNode;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
error: unknown;
onRetry: () => void;
}
export const AgentChatPageErrorView: FC<AgentChatPageErrorViewProps> = ({
titleElement,
isSidebarCollapsed,
onToggleSidebarCollapsed,
error,
onRetry,
}) => {
const detail = getErrorDetail(error);
return (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<ChatTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex flex-1 items-center justify-center px-6 text-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-base text-content-primary">
Failed to load chat
</h3>
<p className="m-0 mt-1 max-w-md text-sm text-content-secondary">
{getErrorMessage(error, "The chat could not be loaded.")}
</p>
{detail && (
<p className="m-0 mt-1 max-w-md text-sm text-content-secondary">
{detail}
</p>
)}
<Button size="sm" onClick={onRetry} className="mt-4">
<RotateCcwIcon />
Try again
</Button>
</div>
</div>
</div>
);
};