refactor(site): align AgentsPage layout naming with codebase conventions (#27378)

This commit is contained in:
Danielle Maywood
2026-07-22 13:16:14 +01:00
committed by GitHub
parent 6ec45f75c6
commit 507ce504f8
16 changed files with 315 additions and 499 deletions
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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: {
+5 -5
View File
@@ -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} />;
+2 -2
View File
@@ -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,
@@ -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;
@@ -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,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,
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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);
};