diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 19e3f92fc2..9f2f538637 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -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, diff --git a/site/src/modules/resources/usePortsData.ts b/site/src/modules/resources/usePortsData.ts index 971ada5cbf..aa60494032 100644 --- a/site/src/modules/resources/usePortsData.ts +++ b/site/src/modules/resources/usePortsData.ts @@ -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 = ( diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 0307fe89c3..dcb4927712 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -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(null); @@ -71,7 +71,7 @@ const AgentChatPageLayout: FC = () => { onExpandSidebar: () => {}, onChatReady: () => {}, scrollContainerRef, - } satisfies AgentsOutletContext + } satisfies AgentsPageOutletContext } /> @@ -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: { diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 46f9afd018..c96001d1ce 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -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(); + } = useOutletContext(); 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 ; diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index c42b999774..37b7018271 100644 --- a/site/src/pages/AgentsPage/AgentEmbedPage.tsx +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -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, diff --git a/site/src/pages/AgentsPage/AgentSettingsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsLayout.tsx similarity index 90% rename from site/src/pages/AgentsPage/AgentSettingsPage.tsx rename to site/src/pages/AgentsPage/AgentSettingsLayout.tsx index aea8d054f7..622d78e5dc 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsLayout.tsx @@ -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; diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx similarity index 87% rename from site/src/pages/AgentsPage/AgentsPageView.stories.tsx rename to site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 14e867c331..f898d1ce80 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -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: , + element: , children: [ { index: true, element: }, { path: "general", element: }, @@ -285,7 +269,7 @@ const setInnerWidthForStory = (width: number) => { const AgentTopBarRouteElement = () => { const { isSidebarCollapsed, onToggleSidebarCollapsed } = - useOutletContext(); + useOutletContext(); return ( = { - 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 = { - title: "pages/AgentsPage/AgentsPageView", - component: AgentsPageView, - decorators: [withAuthProvider, withDashboardProvider], +const meta: Meta = { + 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 = { 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 = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; + +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(() => { diff --git a/site/src/pages/AgentsPage/AgentsPage.test.ts b/site/src/pages/AgentsPage/AgentsPageLayout.test.ts similarity index 99% rename from site/src/pages/AgentsPage/AgentsPage.test.ts rename to site/src/pages/AgentsPage/AgentsPageLayout.test.ts index 90cc143110..cefb9d8f19 100644 --- a/site/src/pages/AgentsPage/AgentsPage.test.ts +++ b/site/src/pages/AgentsPage/AgentsPageLayout.test.ts @@ -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, diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx similarity index 79% rename from site/src/pages/AgentsPage/AgentsPage.tsx rename to site/src/pages/AgentsPage/AgentsPageLayout.tsx index 178ef19b13..cc30a490d3 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -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; + 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; + /** 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; +} const FILTER_MEMBERSHIP_EVENT_KINDS = new Set([ "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(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(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 ( <> - 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} - /> +
+ {pageTitle("Agents")} + + 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} + /> + +
+ +
+
setPendingArchiveChatId(null)} @@ -729,4 +832,4 @@ const AgentsPage: FC = () => { ); }; -export default AgentsPage; +export default AgentsPageLayout; diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx deleted file mode 100644 index b2e9c49511..0000000000 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ /dev/null @@ -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; - 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; - /** 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; -} - -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; - 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; - onRenameTitle: (chatId: string, title: string) => Promise; - onToggleSidebarCollapsed: () => void; - isPersonalModelOverridesEnabled?: boolean; - isAgentsAdmin: boolean; - hasNextPage: boolean | undefined; - onLoadMore: () => void; - isFetchingNextPage: boolean; - sidebarFilters: AgentSidebarFilters; - onSidebarFiltersChange: (filters: AgentSidebarFilters) => void; -} - -export const AgentsPageView: FC = ({ - 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(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(null); - - const outletContextValue: AgentsOutletContext = { - chatErrorReasons, - setChatErrorReason, - clearChatErrorReason, - requestArchiveAgent, - requestUnarchiveAgent, - requestArchiveAndDeleteWorkspace, - requestPinAgent, - requestUnpinAgent, - requestReorderPinnedAgent, - isArchiving, - archivingChatId, - onOpenRenameDialog: setChatPendingRename, - isSidebarCollapsed, - onToggleSidebarCollapsed, - onExpandSidebar, - onChatReady: () => {}, - scrollContainerRef, - }; - - return ( -
- {pageTitle("Agents")} - - - -
- -
-
- ); -}; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 0ca8352bfd..3b1fe88b22 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -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: () => ( diff --git a/site/src/pages/AgentsPage/components/AgentPageHeader.tsx b/site/src/pages/AgentsPage/components/AgentPageHeader.tsx index 7ce5ba02ca..78df8037ca 100644 --- a/site/src/pages/AgentsPage/components/AgentPageHeader.tsx +++ b/site/src/pages/AgentsPage/components/AgentPageHeader.tsx @@ -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 = ({ onToggleNotifications, }) => { const { isSidebarCollapsed, onExpandSidebar } = - useOutletContext(); + useOutletContext(); const location = useLocation(); const [internalChimeEnabled, setInternalChimeEnabled] = diff --git a/site/src/pages/AgentsPage/components/AgentsSkeletons.stories.tsx b/site/src/pages/AgentsPage/components/AgentsSkeletons.stories.tsx index bdcdb88fb7..5040837ccd 100644 --- a/site/src/pages/AgentsPage/components/AgentsSkeletons.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentsSkeletons.stories.tsx @@ -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 = { +const meta: Meta = { title: "pages/AgentsPage/AgentsSkeletons", - component: AgentsPageSkeleton, + component: AgentsPageLayoutSkeleton, decorators: [ (Story) => (
@@ -13,7 +16,7 @@ const meta: Meta = { ], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Page: Story = {}; diff --git a/site/src/pages/AgentsPage/components/AgentsSkeletons.tsx b/site/src/pages/AgentsPage/components/AgentsSkeletons.tsx index 0c7494d79f..5f490cda5e 100644 --- a/site/src/pages/AgentsPage/components/AgentsSkeletons.tsx +++ b/site/src/pages/AgentsPage/components/AgentsSkeletons.tsx @@ -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 ( diff --git a/site/src/router.tsx b/site/src/router.tsx index c3fd85d72e..89a146ed59 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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( }> - + }> + } > } /> - }> + }> } /> } /> { * 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( activeSocket = socket; const handleOpen = () => { - // Connection succeeded — reset backoff. + // Connection succeeded, reset backoff. lastReconnectAttempt = 0; onOpen?.(socket); };