mirror of
https://github.com/coder/coder.git
synced 2026-09-23 14:03:57 +08:00
fix(site/src/pages/AgentsPage): show error state with retry when chat fetch fails (#27887)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user