mirror of
https://github.com/coder/coder.git
synced 2026-09-24 06:47:27 +08:00
refactor(site): align AgentsPage layout naming with codebase conventions (#27378)
This commit is contained in:
@@ -1804,7 +1804,7 @@ describe("sidebar title race condition", () => {
|
||||
});
|
||||
|
||||
// Simulate the title_change WebSocket event arriving while the
|
||||
// refetch is in flight. This mirrors what AgentsPage does.
|
||||
// refetch is in flight. This mirrors what AgentsPageLayout does.
|
||||
updateInfiniteChatsCache(queryClient, (chats) =>
|
||||
chats.map((c) =>
|
||||
c.id === chatId ? { ...c, title: "generated title" } : c,
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface PortsData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by both the workspace port-forward button and the AgentsPage right-panel
|
||||
* Used by both the workspace port-forward button and the agents page right-panel
|
||||
* ports menu so they stay on the same query keys and refresh cadence.
|
||||
*/
|
||||
export const usePortsData = (
|
||||
|
||||
@@ -40,10 +40,10 @@ import {
|
||||
withWebSocket,
|
||||
} from "#/testHelpers/storybook";
|
||||
import AgentChatPage, { RIGHT_PANEL_OPEN_KEY } from "./AgentChatPage";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
import type { AgentsPageOutletContext } from "./AgentsPageLayout";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout wrapper – provides outlet context for the child route.
|
||||
// Layout wrapper: provides outlet context for the child route.
|
||||
// ---------------------------------------------------------------------------
|
||||
const AgentChatPageLayout: FC = () => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -71,7 +71,7 @@ const AgentChatPageLayout: FC = () => {
|
||||
onExpandSidebar: () => {},
|
||||
onChatReady: () => {},
|
||||
scrollContainerRef,
|
||||
} satisfies AgentsOutletContext
|
||||
} satisfies AgentsPageOutletContext
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -2177,7 +2177,7 @@ export const SidebarWithSingleRepo: Story = {
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Streaming reasoning part via WebSocket — renders inline text.
|
||||
* Streaming reasoning part via WebSocket, renders inline text.
|
||||
*/
|
||||
export const StreamedReasoning: Story = {
|
||||
parameters: {
|
||||
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
AgentChatPageNotFoundView,
|
||||
AgentChatPageView,
|
||||
} from "./AgentChatPageView";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
import type { AgentsPageOutletContext } from "./AgentsPageLayout";
|
||||
import type { ChatMessageInputRef } from "./components/AgentChatInput";
|
||||
import { normalizeChatErrorPayload } from "./components/ChatConversation/chatError";
|
||||
import {
|
||||
@@ -570,7 +570,7 @@ export function useConversationEditingState(deps: {
|
||||
serializedEditorStateRef.current = serializedEditorState;
|
||||
|
||||
// Don't overwrite the persisted draft while editing a
|
||||
// history or queued message — the original draft (possibly
|
||||
// history or queued message, the original draft (possibly
|
||||
// containing file-reference chips) is saved in React state
|
||||
// and should survive a cancel.
|
||||
if (editingMessageId !== null || editingQueuedMessageID !== null) {
|
||||
@@ -583,7 +583,7 @@ export function useConversationEditingState(deps: {
|
||||
try {
|
||||
localStorage.setItem(draftStorageKey, serializedEditorState);
|
||||
} catch {
|
||||
// QuotaExceededError — silently discard the draft.
|
||||
// QuotaExceededError, silently discard the draft.
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(draftStorageKey);
|
||||
@@ -735,7 +735,7 @@ const AgentChatPage: FC = () => {
|
||||
onToggleSidebarCollapsed,
|
||||
onChatReady,
|
||||
scrollContainerRef,
|
||||
} = useOutletContext<AgentsOutletContext>();
|
||||
} = useOutletContext<AgentsPageOutletContext>();
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions, user: currentUser } = useAuthenticated();
|
||||
const { organizations, experiments } = useDashboard();
|
||||
@@ -1838,7 +1838,7 @@ const AgentChatPage: FC = () => {
|
||||
|
||||
// Keyed wrapper so that navigating between agents (changing the
|
||||
// :agentId param) fully remounts the component, resetting all
|
||||
// internal state — drafts, editing, queries — cleanly.
|
||||
// internal state (drafts, editing, queries) cleanly.
|
||||
const KeyedAgentChatPage: FC = () => {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
return <AgentChatPage key={agentId} />;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type ConcreteThemeName,
|
||||
isConcreteThemeName,
|
||||
} from "#/theme";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
import type { AgentsPageOutletContext } from "./AgentsPageLayout";
|
||||
import {
|
||||
bootstrapChatEmbedSession,
|
||||
EmbedContext,
|
||||
@@ -221,7 +221,7 @@ const AgentEmbedPage: FC = () => {
|
||||
window.parent.postMessage({ type: "coder:chat-ready" }, "*");
|
||||
};
|
||||
|
||||
const outletContext: AgentsOutletContext = {
|
||||
const outletContext: AgentsPageOutletContext = {
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { Outlet, useLocation } from "react-router";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
import { AgentPageHeader } from "./components/AgentPageHeader";
|
||||
|
||||
const AgentSettingsPage: FC = () => {
|
||||
const AgentSettingsLayout: FC = () => {
|
||||
const location = useLocation();
|
||||
const match = location.pathname.match(/\/agents\/settings\/(.+)/);
|
||||
const section = match?.[1];
|
||||
@@ -23,4 +23,4 @@ const AgentSettingsPage: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentSettingsPage;
|
||||
export default AgentSettingsLayout;
|
||||
+127
-184
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import dayjs from "dayjs";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Navigate, useOutletContext } from "react-router";
|
||||
import {
|
||||
expect,
|
||||
@@ -26,15 +26,17 @@ import {
|
||||
import {
|
||||
withAuthProvider,
|
||||
withDashboardProvider,
|
||||
withWebSocket,
|
||||
} from "#/testHelpers/storybook";
|
||||
import { CoderAgentsPageView } from "../AISettingsPage/CoderAgentsPage/CoderAgentsPageView";
|
||||
import AgentAnalyticsPage from "./AgentAnalyticsPage";
|
||||
import AgentCreatePage from "./AgentCreatePage";
|
||||
import AgentSettingsCompactionPage from "./AgentSettingsCompactionPage";
|
||||
import AgentSettingsGeneralPage from "./AgentSettingsGeneralPage";
|
||||
import AgentSettingsPage from "./AgentSettingsPage";
|
||||
import { type AgentsOutletContext, AgentsPageView } from "./AgentsPageView";
|
||||
import type { ModelSelectorOption } from "./components/ChatElements";
|
||||
import AgentSettingsLayout from "./AgentSettingsLayout";
|
||||
import AgentsPageLayout, {
|
||||
type AgentsPageOutletContext,
|
||||
} from "./AgentsPageLayout";
|
||||
import {
|
||||
AGENTS_MAIN_PANEL_MIN_WIDTH,
|
||||
clampLeftSidebarWidth,
|
||||
@@ -45,27 +47,9 @@ import {
|
||||
LEFT_SIDEBAR_STORAGE_KEY,
|
||||
} from "./components/ChatsSidebar/sidebarWidth";
|
||||
import { ChatTopBar } from "./components/ChatTopBar";
|
||||
import type { AgentSidebarFilters } from "./utils/agentSidebarFilters";
|
||||
|
||||
const defaultModelConfigID = "model-config-1";
|
||||
|
||||
const defaultSidebarFilters: AgentSidebarFilters = {
|
||||
archiveStatus: "active",
|
||||
groupBy: "date",
|
||||
prStatuses: [],
|
||||
chatStatuses: ["unread", "read"],
|
||||
sources: ["created_by_me"],
|
||||
};
|
||||
|
||||
const defaultModelOptions: ModelSelectorOption[] = [
|
||||
{
|
||||
id: defaultModelConfigID,
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
displayName: "GPT-4o",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultModelConfigs: TypesGen.ChatModelConfig[] = [
|
||||
{
|
||||
id: defaultModelConfigID,
|
||||
@@ -217,7 +201,7 @@ const agentsRouting = {
|
||||
children: [
|
||||
{
|
||||
path: "settings",
|
||||
element: <AgentSettingsPage />,
|
||||
element: <AgentSettingsLayout />,
|
||||
children: [
|
||||
{ index: true, element: <AgentSettingsGeneralPage /> },
|
||||
{ path: "general", element: <AgentSettingsGeneralPage /> },
|
||||
@@ -285,7 +269,7 @@ const setInnerWidthForStory = (width: number) => {
|
||||
|
||||
const AgentTopBarRouteElement = () => {
|
||||
const { isSidebarCollapsed, onToggleSidebarCollapsed } =
|
||||
useOutletContext<AgentsOutletContext>();
|
||||
useOutletContext<AgentsPageOutletContext>();
|
||||
return (
|
||||
<ChatTopBar
|
||||
chatTitle="Collapsed sidebar agent"
|
||||
@@ -342,49 +326,15 @@ const agentsWithChatTopBarRouting = {
|
||||
),
|
||||
};
|
||||
|
||||
const defaultArgs: ComponentProps<typeof AgentsPageView> = {
|
||||
agentId: undefined,
|
||||
chatList: [],
|
||||
currentUserId: MockUserOwner.id,
|
||||
catalogModelOptions: defaultModelOptions,
|
||||
modelConfigs: defaultModelConfigs,
|
||||
handleNewAgent: fn(),
|
||||
isSearchDialogOpen: false,
|
||||
onSearchDialogOpenChange: fn(),
|
||||
isCreating: false,
|
||||
isArchiving: false,
|
||||
archivingChatId: undefined,
|
||||
isChatsLoading: false,
|
||||
chatsLoadError: null,
|
||||
onRetryChatsLoad: fn(),
|
||||
onCollapseSidebar: fn(),
|
||||
isSidebarCollapsed: false,
|
||||
onExpandSidebar: fn(),
|
||||
chatErrorReasons: {},
|
||||
setChatErrorReason: fn(),
|
||||
clearChatErrorReason: fn(),
|
||||
requestArchiveAgent: fn(),
|
||||
requestUnarchiveAgent: fn(),
|
||||
requestArchiveAndDeleteWorkspace: fn(),
|
||||
requestPinAgent: fn(),
|
||||
requestUnpinAgent: fn(),
|
||||
onProposeTitle: fn(async () => "Proposed title"),
|
||||
onRenameTitle: fn(async () => {}),
|
||||
onToggleSidebarCollapsed: fn(),
|
||||
isAgentsAdmin: false,
|
||||
sidebarFilters: defaultSidebarFilters,
|
||||
onSidebarFiltersChange: fn(),
|
||||
hasNextPage: false,
|
||||
onLoadMore: fn(),
|
||||
isFetchingNextPage: false,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof AgentsPageView> = {
|
||||
title: "pages/AgentsPage/AgentsPageView",
|
||||
component: AgentsPageView,
|
||||
decorators: [withAuthProvider, withDashboardProvider],
|
||||
const meta: Meta<typeof AgentsPageLayout> = {
|
||||
title: "pages/AgentsPage/AgentsPageLayout",
|
||||
component: AgentsPageLayout,
|
||||
decorators: [withAuthProvider, withDashboardProvider, withWebSocket],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
// The layout opens a chat-watch WebSocket on mount. An empty
|
||||
// event list gives an inert socket that never emits.
|
||||
webSocket: [],
|
||||
user: MockUserOwner,
|
||||
permissions: MockPermissions,
|
||||
reactRouter: reactRouterParameters({
|
||||
@@ -392,9 +342,50 @@ const meta: Meta<typeof AgentsPageView> = {
|
||||
routing: [agentsRouting, aiSettingsRouting],
|
||||
}),
|
||||
},
|
||||
args: defaultArgs,
|
||||
args: {},
|
||||
beforeEach: () => {
|
||||
localStorage.removeItem(LEFT_SIDEBAR_STORAGE_KEY);
|
||||
// Mocks for the queries AgentsPageLayout runs for the sidebar.
|
||||
spyOn(API.experimental, "getChats").mockResolvedValue([]);
|
||||
spyOn(
|
||||
API.experimental,
|
||||
"getUserChatPersonalModelOverrides",
|
||||
).mockResolvedValue({
|
||||
enabled: false,
|
||||
root: {
|
||||
context: "root",
|
||||
mode: "deployment_default",
|
||||
model_config_id: "",
|
||||
is_set: false,
|
||||
is_malformed: false,
|
||||
},
|
||||
general: {
|
||||
context: "general",
|
||||
mode: "deployment_default",
|
||||
model_config_id: "",
|
||||
is_set: false,
|
||||
is_malformed: false,
|
||||
},
|
||||
explore: {
|
||||
context: "explore",
|
||||
mode: "deployment_default",
|
||||
model_config_id: "",
|
||||
is_set: false,
|
||||
is_malformed: false,
|
||||
},
|
||||
deployment_defaults: {
|
||||
general: {
|
||||
context: "general",
|
||||
model_config_id: "",
|
||||
is_malformed: false,
|
||||
},
|
||||
explore: {
|
||||
context: "explore",
|
||||
model_config_id: "",
|
||||
is_malformed: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
spyOn(API, "getWorkspaces").mockResolvedValue({
|
||||
workspaces: [],
|
||||
count: 0,
|
||||
@@ -528,13 +519,17 @@ const meta: Meta<typeof AgentsPageView> = {
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentsPageView>;
|
||||
type Story = StoryObj<typeof AgentsPageLayout>;
|
||||
|
||||
const mockChats = (chats: Chat[]) => {
|
||||
spyOn(API.experimental, "getChats").mockResolvedValue(chats);
|
||||
};
|
||||
|
||||
export const EmptyState: Story = {};
|
||||
|
||||
export const WithChatList: Story = {
|
||||
args: {
|
||||
chatList: [
|
||||
beforeEach: () => {
|
||||
mockChats([
|
||||
buildChat({
|
||||
id: "chat-1",
|
||||
title: "Refactor authentication module",
|
||||
@@ -576,19 +571,19 @@ export const WithChatList: Story = {
|
||||
status: "interrupting",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
export const ResizableSidebar: Story = {
|
||||
args: {
|
||||
chatList: [
|
||||
beforeEach: () => {
|
||||
mockChats([
|
||||
buildChat({
|
||||
id: "chat-resize",
|
||||
title: "Resizable sidebar agent",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "ipad" },
|
||||
@@ -645,14 +640,14 @@ export const ResizableSidebar: Story = {
|
||||
const persistedLeftSidebarWidth = 380;
|
||||
|
||||
export const PersistedResizableSidebarWidth: Story = {
|
||||
args: {
|
||||
chatList: [
|
||||
beforeEach: () => {
|
||||
mockChats([
|
||||
buildChat({
|
||||
id: "chat-resize-persisted",
|
||||
title: "Persisted sidebar width agent",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
@@ -686,17 +681,14 @@ export const PersistedResizableSidebarWidth: Story = {
|
||||
const narrowAgentsLayoutWidth = 720;
|
||||
|
||||
export const WideSidebarPreservesChatPaneWidth: Story = {
|
||||
args: {
|
||||
agentId: "chat-wide-sidebar",
|
||||
chatList: [
|
||||
beforeEach: () => {
|
||||
mockChats([
|
||||
buildChat({
|
||||
id: "chat-wide-sidebar",
|
||||
title: "Wide sidebar agent",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
},
|
||||
beforeEach: () => {
|
||||
]);
|
||||
localStorage.setItem(LEFT_SIDEBAR_STORAGE_KEY, "660");
|
||||
return setInnerWidthForStory(narrowAgentsLayoutWidth);
|
||||
},
|
||||
@@ -754,14 +746,14 @@ export const WideSidebarPreservesChatPaneWidth: Story = {
|
||||
};
|
||||
|
||||
export const ResizableSidebarKeyboard: Story = {
|
||||
args: {
|
||||
chatList: [
|
||||
beforeEach: () => {
|
||||
mockChats([
|
||||
buildChat({
|
||||
id: "chat-resize-keyboard",
|
||||
title: "Keyboard resizable sidebar agent",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "ipad" },
|
||||
@@ -824,42 +816,42 @@ export const ResizableSidebarKeyboard: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// The layout never resolves the chats query, so the sidebar stays
|
||||
// in its loading state.
|
||||
export const LoadingChats: Story = {
|
||||
args: {
|
||||
isChatsLoading: true,
|
||||
chatList: [],
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChats").mockReturnValue(new Promise(() => {}));
|
||||
},
|
||||
};
|
||||
|
||||
export const ChatsLoadError: Story = {
|
||||
args: {
|
||||
chatsLoadError: new Error("Failed to fetch chats"),
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChats").mockRejectedValue(
|
||||
new Error("Failed to fetch chats"),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// The collapsed state is internal to the layout. Drive it through
|
||||
// the UI, then assert the collapse took effect.
|
||||
export const SidebarCollapsed: Story = {
|
||||
args: {
|
||||
isSidebarCollapsed: true,
|
||||
chatList: [
|
||||
beforeEach: () => {
|
||||
mockChats([
|
||||
buildChat({
|
||||
id: "chat-1",
|
||||
title: "Collapsed sidebar agent",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
chatErrorReasons: {},
|
||||
setChatErrorReason: fn(),
|
||||
clearChatErrorReason: fn(),
|
||||
requestArchiveAgent: fn(),
|
||||
requestUnarchiveAgent: fn(),
|
||||
requestArchiveAndDeleteWorkspace: fn(),
|
||||
onToggleSidebarCollapsed: fn(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithToolbarEndContent: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: true,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: "Collapse sidebar" }),
|
||||
);
|
||||
await expect(
|
||||
await canvas.findByRole("button", { name: "Expand sidebar" }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -902,15 +894,15 @@ export const EmptyStateZoom200Desktop: Story = {
|
||||
};
|
||||
|
||||
export const CollapsedSidebarZoom200Desktop: Story = {
|
||||
args: {
|
||||
isSidebarCollapsed: true,
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "desktopZoom200" },
|
||||
chromatic: { viewports: [720] },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: "Collapse sidebar" }),
|
||||
);
|
||||
const expandButton = await canvas.findByRole("button", {
|
||||
name: "Expand sidebar",
|
||||
});
|
||||
@@ -920,16 +912,14 @@ export const CollapsedSidebarZoom200Desktop: Story = {
|
||||
};
|
||||
|
||||
export const CollapsedSidebarZoom200DesktopWithAgent: Story = {
|
||||
args: {
|
||||
agentId: "chat-1",
|
||||
isSidebarCollapsed: true,
|
||||
chatList: [
|
||||
beforeEach: () => {
|
||||
mockChats([
|
||||
buildChat({
|
||||
id: "chat-1",
|
||||
title: "Collapsed sidebar agent",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "desktopZoom200" },
|
||||
@@ -941,6 +931,9 @@ export const CollapsedSidebarZoom200DesktopWithAgent: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: "Collapse sidebar" }),
|
||||
);
|
||||
const expandButton = await canvas.findByRole("button", {
|
||||
name: "Expand sidebar",
|
||||
});
|
||||
@@ -949,43 +942,11 @@ export const CollapsedSidebarZoom200DesktopWithAgent: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const CreatingAgent: Story = {
|
||||
args: {
|
||||
isCreating: true,
|
||||
chatList: [
|
||||
buildChat({
|
||||
id: "chat-1",
|
||||
title: "Existing agent",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const ArchivingAgent: Story = {
|
||||
args: {
|
||||
isArchiving: true,
|
||||
archivingChatId: "chat-1",
|
||||
chatList: [
|
||||
buildChat({
|
||||
id: "chat-1",
|
||||
title: "Agent being archived",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
buildChat({
|
||||
id: "chat-2",
|
||||
title: "Another agent",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Standalone story for the delete-confirmation dialog with
|
||||
* agents-specific copy (title, verb, info). The dialog now lives in
|
||||
* AgentsPage (the container) rather than AgentsPageView, so we
|
||||
* render it directly here to preserve interaction-test coverage.
|
||||
* AgentsPageLayout (the container), so we render it directly here to
|
||||
* preserve interaction-test coverage.
|
||||
*/
|
||||
export const DeleteConfirmationDialog: Story = {
|
||||
render: function Render() {
|
||||
@@ -1037,9 +998,8 @@ export const DeleteConfirmationDialog: Story = {
|
||||
};
|
||||
|
||||
export const WithAgentSelected: Story = {
|
||||
args: {
|
||||
agentId: "chat-1",
|
||||
chatList: [
|
||||
beforeEach: () => {
|
||||
mockChats([
|
||||
buildChat({
|
||||
id: "chat-1",
|
||||
title: "Selected agent",
|
||||
@@ -1051,7 +1011,7 @@ export const WithAgentSelected: Story = {
|
||||
title: "Another agent",
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
@@ -1064,13 +1024,20 @@ export const WithAgentSelected: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Error reasons surface via each chat's last_error, which the
|
||||
// layout turns into sidebar error badges.
|
||||
export const WithErrorReasons: Story = {
|
||||
args: {
|
||||
chatList: [
|
||||
beforeEach: () => {
|
||||
mockChats([
|
||||
buildChat({
|
||||
id: "chat-1",
|
||||
title: "Rate limited agent",
|
||||
status: "error",
|
||||
last_error: {
|
||||
kind: "generic",
|
||||
message: "Model rate limited",
|
||||
retryable: false,
|
||||
},
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
buildChat({
|
||||
@@ -1083,19 +1050,14 @@ export const WithErrorReasons: Story = {
|
||||
id: "chat-3",
|
||||
title: "Another errored agent",
|
||||
status: "error",
|
||||
last_error: {
|
||||
kind: "generic",
|
||||
message: "Context window exceeded",
|
||||
retryable: false,
|
||||
},
|
||||
updated_at: todayTimestamp,
|
||||
}),
|
||||
],
|
||||
chatErrorReasons: {
|
||||
"chat-1": { kind: "generic", message: "Model rate limited" },
|
||||
"chat-3": { kind: "generic", message: "Context window exceeded" },
|
||||
},
|
||||
setChatErrorReason: fn(),
|
||||
clearChatErrorReason: fn(),
|
||||
requestArchiveAgent: fn(),
|
||||
requestUnarchiveAgent: fn(),
|
||||
requestArchiveAndDeleteWorkspace: fn(),
|
||||
onToggleSidebarCollapsed: fn(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1105,9 +1067,6 @@ const openSettingsView = async (canvasElement: HTMLElement) => {
|
||||
};
|
||||
|
||||
export const OpensAnalyticsForAdmins: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: true,
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/agents/analytics" },
|
||||
@@ -1126,9 +1085,6 @@ export const OpensAnalyticsForAdmins: Story = {
|
||||
};
|
||||
|
||||
export const OpensAnalyticsForNonAdmins: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: false,
|
||||
},
|
||||
parameters: {
|
||||
permissions: MockNoPermissions,
|
||||
reactRouter: reactRouterParameters({
|
||||
@@ -1148,9 +1104,6 @@ export const OpensAnalyticsForNonAdmins: Story = {
|
||||
};
|
||||
|
||||
export const OpensSettingsForAdmins: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettingsView(canvasElement);
|
||||
|
||||
@@ -1163,9 +1116,6 @@ export const OpensSettingsForAdmins: Story = {
|
||||
};
|
||||
|
||||
export const OpensSettingsForNonAdmins: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: false,
|
||||
},
|
||||
parameters: {
|
||||
permissions: MockNoPermissions,
|
||||
},
|
||||
@@ -1185,9 +1135,6 @@ export const OpensSettingsForNonAdmins: Story = {
|
||||
};
|
||||
|
||||
export const OpensAISettingsFromManageAgentsOnMobile: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: true,
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile1" },
|
||||
reactRouter: reactRouterParameters({
|
||||
@@ -1213,11 +1160,7 @@ export const OpensAISettingsFromManageAgentsOnMobile: Story = {
|
||||
};
|
||||
|
||||
export const SettingsViewCoderAgentsLink: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// Open settings
|
||||
await openSettingsView(canvasElement);
|
||||
|
||||
await waitFor(() => {
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { shouldInvalidateFilteredChatList } from "./AgentsPage";
|
||||
import { shouldInvalidateFilteredChatList } from "./AgentsPageLayout";
|
||||
import {
|
||||
emptyInputStorageKey,
|
||||
useEmptyStateDraft,
|
||||
+148
-45
@@ -1,4 +1,4 @@
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { type FC, type RefObject, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useQueryClient,
|
||||
} from "react-query";
|
||||
import {
|
||||
Outlet,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
@@ -53,9 +54,16 @@ import {
|
||||
getDefaultOrganizationName,
|
||||
useDashboard,
|
||||
} from "#/modules/dashboard/useDashboard";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket";
|
||||
import { AgentsPageView } from "./AgentsPageView";
|
||||
import { emptyInputStorageKey } from "./components/AgentCreateForm";
|
||||
import {
|
||||
ChatsSidebar,
|
||||
isSettingsView,
|
||||
sidebarViewFromPath,
|
||||
} from "./components/ChatsSidebar/ChatsSidebar";
|
||||
import { ResizableChatsSidebarFrame } from "./components/ChatsSidebar/ResizableChatsSidebarFrame";
|
||||
import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings";
|
||||
import { useAgentsPWA } from "./hooks/useAgentsPWA";
|
||||
import { getAgentSidebarFilters } from "./utils/agentSidebarFilters";
|
||||
@@ -79,7 +87,31 @@ import {
|
||||
chatDetailErrorsEqual,
|
||||
} from "./utils/usageLimitMessage";
|
||||
|
||||
export type { AgentsOutletContext } from "./AgentsPageView";
|
||||
export interface AgentsPageOutletContext {
|
||||
chatErrorReasons: Record<string, ChatDetailError>;
|
||||
setChatErrorReason: (chatId: string, reason: ChatDetailError) => void;
|
||||
clearChatErrorReason: (chatId: string) => void;
|
||||
requestArchiveAgent: (chatId: string) => void;
|
||||
requestUnarchiveAgent: (chatId: string) => void;
|
||||
requestArchiveAndDeleteWorkspace: (
|
||||
chatId: string,
|
||||
workspaceId: string,
|
||||
) => void;
|
||||
requestPinAgent: (chatId: string) => void;
|
||||
requestUnpinAgent: (chatId: string) => void;
|
||||
requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void;
|
||||
isArchiving: boolean;
|
||||
archivingChatId: string | undefined;
|
||||
onRenameTitle?: (chatId: string, title: string) => Promise<void>;
|
||||
/** Opens the shared rename dialog so both menus drive the same instance. */
|
||||
onOpenRenameDialog?: (chat: TypesGen.Chat) => void;
|
||||
isSidebarCollapsed: boolean;
|
||||
onToggleSidebarCollapsed: () => void;
|
||||
onExpandSidebar: () => void;
|
||||
onChatReady: () => void;
|
||||
/** Ref attached to the chat scroll container by AgentChatPage. */
|
||||
scrollContainerRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const FILTER_MEMBERSHIP_EVENT_KINDS = new Set<TypesGen.ChatWatchEventKind>([
|
||||
"diff_status_change",
|
||||
@@ -92,7 +124,7 @@ export const shouldInvalidateFilteredChatList = (
|
||||
): boolean =>
|
||||
!chat.parent_chat_id && FILTER_MEMBERSHIP_EVENT_KINDS.has(eventKind);
|
||||
|
||||
const AgentsPage: FC = () => {
|
||||
const AgentsPageLayout: FC = () => {
|
||||
useAgentsPWA();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
@@ -662,48 +694,119 @@ const AgentsPage: FC = () => {
|
||||
const deleteDialogOpen =
|
||||
pendingArchiveAndDelete !== null && Boolean(pendingWorkspaceName);
|
||||
|
||||
// Mobile can't fit the sidebar nav and content side by side,
|
||||
// so we show one or the other depending on the route depth.
|
||||
const sidebarView = sidebarViewFromPath(location.pathname);
|
||||
const isSettingsPanel = isSettingsView(sidebarView);
|
||||
const isSettingsIndex = isSettingsPanel && !sidebarView.section;
|
||||
const isSettingsDetail = isSettingsPanel && Boolean(sidebarView.section);
|
||||
const isAnalytics = sidebarView.panel === "analytics";
|
||||
|
||||
// The sidebar expects plain string error messages, but the outlet
|
||||
// context carries structured ChatDetailError objects.
|
||||
const sidebarChatErrorReasons = Object.fromEntries(
|
||||
Object.entries(chatErrorReasons).map(([chatId, error]) => [
|
||||
chatId,
|
||||
error.message,
|
||||
]),
|
||||
);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// State for the shared rename-chat dialog. Lifted here so both the
|
||||
// sidebar menu and the chat top bar open the same dialog instance.
|
||||
const [chatPendingRename, setChatPendingRename] =
|
||||
useState<TypesGen.Chat | null>(null);
|
||||
|
||||
const outletContextValue: AgentsPageOutletContext = {
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
requestArchiveAgent,
|
||||
requestUnarchiveAgent,
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
requestPinAgent,
|
||||
requestUnpinAgent,
|
||||
requestReorderPinnedAgent,
|
||||
isArchiving,
|
||||
archivingChatId,
|
||||
onOpenRenameDialog: setChatPendingRename,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed: handleToggleSidebarCollapsed,
|
||||
onExpandSidebar: () => setIsSidebarCollapsed(false),
|
||||
onChatReady: () => {},
|
||||
scrollContainerRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AgentsPageView
|
||||
agentId={agentId}
|
||||
chatList={chatList}
|
||||
currentUserId={user.id}
|
||||
catalogModelOptions={catalogModelOptions}
|
||||
modelConfigs={chatModelConfigsQuery.data ?? []}
|
||||
handleNewAgent={handleNewAgent}
|
||||
isSearchDialogOpen={isSearchDialogOpen}
|
||||
onSearchDialogOpenChange={setIsSearchDialogOpen}
|
||||
isCreating={false}
|
||||
isArchiving={isArchiving}
|
||||
archivingChatId={archivingChatId}
|
||||
isChatsLoading={chatsQuery.isLoading}
|
||||
chatsLoadError={chatsQuery.error}
|
||||
onRetryChatsLoad={() => void chatsQuery.refetch()}
|
||||
onCollapseSidebar={() => setIsSidebarCollapsed(true)}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onExpandSidebar={() => setIsSidebarCollapsed(false)}
|
||||
chatErrorReasons={chatErrorReasons}
|
||||
setChatErrorReason={setChatErrorReason}
|
||||
clearChatErrorReason={clearChatErrorReason}
|
||||
requestArchiveAgent={requestArchiveAgent}
|
||||
requestUnarchiveAgent={requestUnarchiveAgent}
|
||||
requestArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
|
||||
requestPinAgent={requestPinAgent}
|
||||
requestUnpinAgent={requestUnpinAgent}
|
||||
requestReorderPinnedAgent={requestReorderPinnedAgent}
|
||||
onProposeTitle={requestProposeTitle}
|
||||
onRenameTitle={requestRenameTitle}
|
||||
onToggleSidebarCollapsed={handleToggleSidebarCollapsed}
|
||||
isPersonalModelOverridesEnabled={
|
||||
personalModelOverridesQuery.data?.enabled
|
||||
}
|
||||
isAgentsAdmin={isAgentsAdmin}
|
||||
hasNextPage={chatsQuery.hasNextPage}
|
||||
onLoadMore={() => void chatsQuery.fetchNextPage()}
|
||||
isFetchingNextPage={chatsQuery.isFetchingNextPage}
|
||||
sidebarFilters={sidebarFilters}
|
||||
onSidebarFiltersChange={setSidebarFilters}
|
||||
/>
|
||||
<div
|
||||
data-testid="agents-page-layout"
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary sm:flex-row"
|
||||
>
|
||||
<title>{pageTitle("Agents")}</title>
|
||||
<ResizableChatsSidebarFrame
|
||||
className={cn(
|
||||
"sm:h-full sm:min-h-0 sm:border-b-0",
|
||||
agentId
|
||||
? "hidden sm:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
|
||||
: isSettingsDetail || isAnalytics
|
||||
? "hidden sm:block shrink-0"
|
||||
: "order-2 sm:order-none flex-1 min-h-0 border-b border-border-default sm:flex-none sm:border-t-0 sm:border-b-0",
|
||||
isSidebarCollapsed && "sm:hidden",
|
||||
)}
|
||||
>
|
||||
<ChatsSidebar
|
||||
chats={chatList}
|
||||
currentUserId={user.id}
|
||||
chatErrorReasons={sidebarChatErrorReasons}
|
||||
modelOptions={catalogModelOptions}
|
||||
modelConfigs={chatModelConfigsQuery.data ?? []}
|
||||
onArchiveAgent={requestArchiveAgent}
|
||||
onUnarchiveAgent={requestUnarchiveAgent}
|
||||
onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
|
||||
onPinAgent={requestPinAgent}
|
||||
onUnpinAgent={requestUnpinAgent}
|
||||
onReorderPinnedAgent={requestReorderPinnedAgent}
|
||||
onRenameTitle={requestRenameTitle}
|
||||
onProposeTitle={requestProposeTitle}
|
||||
chatPendingRename={chatPendingRename}
|
||||
onChatPendingRenameChange={setChatPendingRename}
|
||||
onBeforeNewAgent={handleNewAgent}
|
||||
isSearchDialogOpen={isSearchDialogOpen}
|
||||
onSearchDialogOpenChange={setIsSearchDialogOpen}
|
||||
isCreating={false}
|
||||
isArchiving={isArchiving}
|
||||
archivingChatId={archivingChatId}
|
||||
isLoading={chatsQuery.isLoading}
|
||||
loadError={chatsQuery.error}
|
||||
onRetryLoad={() => void chatsQuery.refetch()}
|
||||
hasNextPage={chatsQuery.hasNextPage}
|
||||
onLoadMore={() => void chatsQuery.fetchNextPage()}
|
||||
isFetchingNextPage={chatsQuery.isFetchingNextPage}
|
||||
sidebarFilters={sidebarFilters}
|
||||
onSidebarFiltersChange={setSidebarFilters}
|
||||
onCollapse={() => setIsSidebarCollapsed(true)}
|
||||
isPersonalModelOverridesEnabled={
|
||||
personalModelOverridesQuery.data?.enabled
|
||||
}
|
||||
isAdmin={isAgentsAdmin}
|
||||
/>
|
||||
</ResizableChatsSidebarFrame>
|
||||
<div
|
||||
data-testid="agents-main-panel"
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 flex-1 flex-col bg-surface-primary",
|
||||
isSettingsIndex ? "hidden sm:flex" : "flex",
|
||||
!agentId &&
|
||||
!isSettingsDetail &&
|
||||
sidebarView.panel === "chats" &&
|
||||
"contents sm:flex sm:flex-1 sm:flex-col",
|
||||
)}
|
||||
>
|
||||
<Outlet context={outletContextValue} />
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={pendingArchiveChatId !== null}
|
||||
onClose={() => setPendingArchiveChatId(null)}
|
||||
@@ -729,4 +832,4 @@ const AgentsPage: FC = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default AgentsPage;
|
||||
export default AgentsPageLayout;
|
||||
@@ -1,235 +0,0 @@
|
||||
import { type FC, type RefObject, useRef, useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import type { ModelSelectorOption } from "./components/ChatElements";
|
||||
import {
|
||||
ChatsSidebar,
|
||||
isSettingsView,
|
||||
sidebarViewFromPath,
|
||||
} from "./components/ChatsSidebar/ChatsSidebar";
|
||||
import { ResizableChatsSidebarFrame } from "./components/ChatsSidebar/ResizableChatsSidebarFrame";
|
||||
import type { AgentSidebarFilters } from "./utils/agentSidebarFilters";
|
||||
import type { ChatDetailError } from "./utils/usageLimitMessage";
|
||||
|
||||
export interface AgentsOutletContext {
|
||||
chatErrorReasons: Record<string, ChatDetailError>;
|
||||
setChatErrorReason: (chatId: string, reason: ChatDetailError) => void;
|
||||
clearChatErrorReason: (chatId: string) => void;
|
||||
requestArchiveAgent: (chatId: string) => void;
|
||||
requestUnarchiveAgent: (chatId: string) => void;
|
||||
requestArchiveAndDeleteWorkspace: (
|
||||
chatId: string,
|
||||
workspaceId: string,
|
||||
) => void;
|
||||
requestPinAgent: (chatId: string) => void;
|
||||
requestUnpinAgent: (chatId: string) => void;
|
||||
requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void;
|
||||
isArchiving: boolean;
|
||||
archivingChatId: string | undefined;
|
||||
onRenameTitle?: (chatId: string, title: string) => Promise<void>;
|
||||
/** Opens the shared rename dialog so both menus drive the same instance. */
|
||||
onOpenRenameDialog?: (chat: TypesGen.Chat) => void;
|
||||
isSidebarCollapsed: boolean;
|
||||
onToggleSidebarCollapsed: () => void;
|
||||
onExpandSidebar: () => void;
|
||||
onChatReady: () => void;
|
||||
/** Ref attached to the chat scroll container by AgentChatPage. */
|
||||
scrollContainerRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
interface AgentsPageViewProps {
|
||||
agentId: string | undefined;
|
||||
chatList: TypesGen.Chat[];
|
||||
currentUserId: string;
|
||||
catalogModelOptions: readonly ModelSelectorOption[];
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
handleNewAgent: () => void;
|
||||
isSearchDialogOpen: boolean;
|
||||
onSearchDialogOpenChange: (open: boolean) => void;
|
||||
isCreating: boolean;
|
||||
isArchiving: boolean;
|
||||
archivingChatId: string | undefined;
|
||||
isChatsLoading: boolean;
|
||||
chatsLoadError: Error | null;
|
||||
onRetryChatsLoad: () => void;
|
||||
onCollapseSidebar: () => void;
|
||||
isSidebarCollapsed: boolean;
|
||||
onExpandSidebar: () => void;
|
||||
chatErrorReasons: Record<string, ChatDetailError>;
|
||||
setChatErrorReason: (chatId: string, reason: ChatDetailError) => void;
|
||||
clearChatErrorReason: (chatId: string) => void;
|
||||
requestArchiveAgent: (chatId: string) => void;
|
||||
requestUnarchiveAgent: (chatId: string) => void;
|
||||
requestArchiveAndDeleteWorkspace: (
|
||||
chatId: string,
|
||||
workspaceId: string,
|
||||
) => void;
|
||||
requestPinAgent: (chatId: string) => void;
|
||||
requestUnpinAgent: (chatId: string) => void;
|
||||
requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void;
|
||||
onProposeTitle: (chatId: string) => Promise<string>;
|
||||
onRenameTitle: (chatId: string, title: string) => Promise<void>;
|
||||
onToggleSidebarCollapsed: () => void;
|
||||
isPersonalModelOverridesEnabled?: boolean;
|
||||
isAgentsAdmin: boolean;
|
||||
hasNextPage: boolean | undefined;
|
||||
onLoadMore: () => void;
|
||||
isFetchingNextPage: boolean;
|
||||
sidebarFilters: AgentSidebarFilters;
|
||||
onSidebarFiltersChange: (filters: AgentSidebarFilters) => void;
|
||||
}
|
||||
|
||||
export const AgentsPageView: FC<AgentsPageViewProps> = ({
|
||||
agentId,
|
||||
chatList,
|
||||
currentUserId,
|
||||
catalogModelOptions,
|
||||
modelConfigs,
|
||||
handleNewAgent,
|
||||
isSearchDialogOpen,
|
||||
onSearchDialogOpenChange,
|
||||
isCreating,
|
||||
isArchiving,
|
||||
archivingChatId,
|
||||
isChatsLoading,
|
||||
chatsLoadError,
|
||||
onRetryChatsLoad,
|
||||
onCollapseSidebar,
|
||||
isSidebarCollapsed,
|
||||
onExpandSidebar,
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
requestArchiveAgent,
|
||||
requestUnarchiveAgent,
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
requestPinAgent,
|
||||
requestUnpinAgent,
|
||||
requestReorderPinnedAgent,
|
||||
onProposeTitle,
|
||||
onRenameTitle,
|
||||
onToggleSidebarCollapsed,
|
||||
isPersonalModelOverridesEnabled,
|
||||
isAgentsAdmin,
|
||||
hasNextPage,
|
||||
onLoadMore,
|
||||
isFetchingNextPage,
|
||||
sidebarFilters,
|
||||
onSidebarFiltersChange,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const sidebarView = sidebarViewFromPath(location.pathname);
|
||||
|
||||
// Mobile can't fit the sidebar nav and content side by side,
|
||||
// so we show one or the other depending on the route depth.
|
||||
const isSettingsPanel = isSettingsView(sidebarView);
|
||||
const isSettingsIndex = isSettingsPanel && !sidebarView.section;
|
||||
const isSettingsDetail = isSettingsPanel && Boolean(sidebarView.section);
|
||||
const isAnalytics = sidebarView.panel === "analytics";
|
||||
|
||||
// The sidebar expects plain string error messages, but the outlet
|
||||
// context now carries structured ChatDetailError objects.
|
||||
const sidebarChatErrorReasons = Object.fromEntries(
|
||||
Object.entries(chatErrorReasons).map(([chatId, error]) => [
|
||||
chatId,
|
||||
error.message,
|
||||
]),
|
||||
);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// State for the shared rename-chat dialog. Lifted here so both the
|
||||
// sidebar menu and the chat top bar open the same dialog instance.
|
||||
const [chatPendingRename, setChatPendingRename] =
|
||||
useState<TypesGen.Chat | null>(null);
|
||||
|
||||
const outletContextValue: AgentsOutletContext = {
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
requestArchiveAgent,
|
||||
requestUnarchiveAgent,
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
requestPinAgent,
|
||||
requestUnpinAgent,
|
||||
requestReorderPinnedAgent,
|
||||
isArchiving,
|
||||
archivingChatId,
|
||||
onOpenRenameDialog: setChatPendingRename,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
onExpandSidebar,
|
||||
onChatReady: () => {},
|
||||
scrollContainerRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="agents-page-layout"
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary sm:flex-row"
|
||||
>
|
||||
<title>{pageTitle("Agents")}</title>
|
||||
<ResizableChatsSidebarFrame
|
||||
className={cn(
|
||||
"sm:h-full sm:min-h-0 sm:border-b-0",
|
||||
agentId
|
||||
? "hidden sm:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
|
||||
: isSettingsDetail || isAnalytics
|
||||
? "hidden sm:block shrink-0"
|
||||
: "order-2 sm:order-none flex-1 min-h-0 border-b border-border-default sm:flex-none sm:border-t-0 sm:border-b-0",
|
||||
isSidebarCollapsed && "sm:hidden",
|
||||
)}
|
||||
>
|
||||
<ChatsSidebar
|
||||
chats={chatList}
|
||||
currentUserId={currentUserId}
|
||||
chatErrorReasons={sidebarChatErrorReasons}
|
||||
modelOptions={catalogModelOptions}
|
||||
modelConfigs={modelConfigs}
|
||||
onArchiveAgent={requestArchiveAgent}
|
||||
onUnarchiveAgent={requestUnarchiveAgent}
|
||||
onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
|
||||
onPinAgent={requestPinAgent}
|
||||
onUnpinAgent={requestUnpinAgent}
|
||||
onReorderPinnedAgent={requestReorderPinnedAgent}
|
||||
onRenameTitle={onRenameTitle}
|
||||
onProposeTitle={onProposeTitle}
|
||||
chatPendingRename={chatPendingRename}
|
||||
onChatPendingRenameChange={setChatPendingRename}
|
||||
onBeforeNewAgent={handleNewAgent}
|
||||
isSearchDialogOpen={isSearchDialogOpen}
|
||||
onSearchDialogOpenChange={onSearchDialogOpenChange}
|
||||
isCreating={isCreating}
|
||||
isArchiving={isArchiving}
|
||||
archivingChatId={archivingChatId}
|
||||
isLoading={isChatsLoading}
|
||||
loadError={chatsLoadError}
|
||||
onRetryLoad={onRetryChatsLoad}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={onLoadMore}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
sidebarFilters={sidebarFilters}
|
||||
onSidebarFiltersChange={onSidebarFiltersChange}
|
||||
onCollapse={onCollapseSidebar}
|
||||
isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled}
|
||||
isAdmin={isAgentsAdmin}
|
||||
/>
|
||||
</ResizableChatsSidebarFrame>
|
||||
<div
|
||||
data-testid="agents-main-panel"
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 flex-1 flex-col bg-surface-primary",
|
||||
isSettingsIndex ? "hidden sm:flex" : "flex",
|
||||
!agentId &&
|
||||
!isSettingsDetail &&
|
||||
sidebarView.panel === "chats" &&
|
||||
"contents sm:flex sm:flex-1 sm:flex-col",
|
||||
)}
|
||||
>
|
||||
<Outlet context={outletContextValue} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -728,7 +728,7 @@ export const OrgPickerTightSpacing: Story = {
|
||||
/**
|
||||
* Standalone story for the org-change confirmation dialog. Renders
|
||||
* the ConfirmDialog directly in its open state, following the same
|
||||
* pattern as DeleteConfirmationDialog in AgentsPageView.stories.
|
||||
* pattern as DeleteConfirmationDialog in AgentsPageLayout.stories.
|
||||
*/
|
||||
export const OrgChangeConfirmation: Story = {
|
||||
render: () => (
|
||||
|
||||
@@ -31,7 +31,7 @@ import { FeatureStageBadge } from "#/components/FeatureStageBadge/FeatureStageBa
|
||||
import { ProductLogo } from "#/components/Icons/ProductLogo";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { useWebpushNotifications } from "#/contexts/useWebpushNotifications";
|
||||
import type { AgentsOutletContext } from "../AgentsPageView";
|
||||
import type { AgentsPageOutletContext } from "../AgentsPageLayout";
|
||||
import { getChimeEnabled, setChimeEnabled } from "../utils/chime";
|
||||
|
||||
interface AgentPageHeaderProps {
|
||||
@@ -54,7 +54,7 @@ export const AgentPageHeader: FC<AgentPageHeaderProps> = ({
|
||||
onToggleNotifications,
|
||||
}) => {
|
||||
const { isSidebarCollapsed, onExpandSidebar } =
|
||||
useOutletContext<AgentsOutletContext>();
|
||||
useOutletContext<AgentsPageOutletContext>();
|
||||
const location = useLocation();
|
||||
|
||||
const [internalChimeEnabled, setInternalChimeEnabled] =
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentChatPageSkeleton, AgentsPageSkeleton } from "./AgentsSkeletons";
|
||||
import {
|
||||
AgentChatPageSkeleton,
|
||||
AgentsPageLayoutSkeleton,
|
||||
} from "./AgentsSkeletons";
|
||||
|
||||
const meta: Meta<typeof AgentsPageSkeleton> = {
|
||||
const meta: Meta<typeof AgentsPageLayoutSkeleton> = {
|
||||
title: "pages/AgentsPage/AgentsSkeletons",
|
||||
component: AgentsPageSkeleton,
|
||||
component: AgentsPageLayoutSkeleton,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: 600, width: "100%" }}>
|
||||
@@ -13,7 +16,7 @@ const meta: Meta<typeof AgentsPageSkeleton> = {
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentsPageSkeleton>;
|
||||
type Story = StoryObj<typeof AgentsPageLayoutSkeleton>;
|
||||
|
||||
export const Page: Story = {};
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@ function getRightPanelState(): { open: boolean; width: number } {
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton shown while the AgentsPage chunk is loading. Mimics the
|
||||
* Skeleton shown while the AgentsPageLayout chunk is loading. Mimics the
|
||||
* sidebar + empty main area layout so the user sees structure
|
||||
* immediately instead of a fullscreen spinner.
|
||||
*/
|
||||
export const AgentsPageSkeleton: FC = () => {
|
||||
export const AgentsPageLayoutSkeleton: FC = () => {
|
||||
const [leftSidebarWidth] = useState(() => loadPersistedLeftSidebarWidth());
|
||||
|
||||
return (
|
||||
|
||||
+9
-7
@@ -353,7 +353,9 @@ const ProvisionerJobsPage = lazy(
|
||||
"./pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPage"
|
||||
),
|
||||
);
|
||||
const AgentsPage = lazy(() => import("./pages/AgentsPage/AgentsPage"));
|
||||
const AgentsPageLayout = lazy(
|
||||
() => import("./pages/AgentsPage/AgentsPageLayout"),
|
||||
);
|
||||
const AgentChatPage = lazy(() => import("./pages/AgentsPage/AgentChatPage"));
|
||||
const AgentEmbedPage = lazy(() => import("./pages/AgentsPage/AgentEmbedPage"));
|
||||
const DesktopPopoutPage = lazy(
|
||||
@@ -362,8 +364,8 @@ const DesktopPopoutPage = lazy(
|
||||
const AgentCreatePage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentCreatePage"),
|
||||
);
|
||||
const AgentSettingsPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsPage"),
|
||||
const AgentSettingsLayout = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsLayout"),
|
||||
);
|
||||
const AgentSettingsGeneralPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsGeneralPage"),
|
||||
@@ -396,7 +398,7 @@ const AgentAnalyticsPage = lazy(
|
||||
|
||||
import {
|
||||
AgentChatPageSkeleton,
|
||||
AgentsPageSkeleton,
|
||||
AgentsPageLayoutSkeleton,
|
||||
} from "./pages/AgentsPage/components/AgentsSkeletons";
|
||||
|
||||
const CoderCupPage = lazy(() => import("./pages/CoderCupPage/CoderCupPage"));
|
||||
@@ -844,13 +846,13 @@ export const router = createBrowserRouter(
|
||||
<Route
|
||||
path="/agents"
|
||||
element={
|
||||
<Suspense fallback={<AgentsPageSkeleton />}>
|
||||
<AgentsPage />
|
||||
<Suspense fallback={<AgentsPageLayoutSkeleton />}>
|
||||
<AgentsPageLayout />
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<Route index element={<AgentCreatePage />} />
|
||||
<Route path="settings" element={<AgentSettingsPage />}>
|
||||
<Route path="settings" element={<AgentSettingsLayout />}>
|
||||
<Route index element={<AgentSettingsGeneralPage />} />
|
||||
<Route path="general" element={<AgentSettingsGeneralPage />} />
|
||||
<Route
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Shared WebSocket reconnection utility with capped exponential
|
||||
* backoff. Both the chat-list watcher (AgentsPage) and the per-chat
|
||||
* backoff. Both the chat-list watcher (AgentsPageLayout) and the per-chat
|
||||
* stream watcher (ChatContext) use the same reconnect-on-disconnect
|
||||
* pattern. This module extracts that logic into a single reusable
|
||||
* function so the two call sites stay in sync and the backoff math
|
||||
@@ -77,7 +77,7 @@ interface ReconnectingWebSocketOptions<TSocket extends Closable> {
|
||||
* Factory that creates and returns a new socket. Called on the
|
||||
* initial connection and on every reconnection attempt. The caller
|
||||
* is responsible for attaching any `message` listeners to the
|
||||
* returned socket — this utility only manages the lifecycle
|
||||
* returned socket. This utility only manages the lifecycle
|
||||
* (`open`, `close`, `error`) events.
|
||||
*/
|
||||
connect: () => TSocket;
|
||||
@@ -248,7 +248,7 @@ export function createReconnectingWebSocket<TSocket extends Closable>(
|
||||
activeSocket = socket;
|
||||
|
||||
const handleOpen = () => {
|
||||
// Connection succeeded — reset backoff.
|
||||
// Connection succeeded, reset backoff.
|
||||
lastReconnectAttempt = 0;
|
||||
onOpen?.(socket);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user