From 635c5d52a8355e68e6c3696f57fdd2df94d124ed Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 17 Mar 2026 14:48:09 +0000 Subject: [PATCH] feat(site): move Settings and Analytics from dialogs to sidebar sub-navigation (#23126) --- site/src/api/queries/chats.ts | 9 +- .../AgentsPage/AgentsPageView.stories.tsx | 181 ++--- site/src/pages/AgentsPage/AgentsPageView.tsx | 72 +- .../AgentsPage/AgentsSidebar.stories.tsx | 2 +- .../pages/AgentsPage/AgentsSidebar.test.tsx | 2 +- site/src/pages/AgentsPage/AgentsSidebar.tsx | 631 ++++++++++++------ .../pages/AgentsPage/AnalyticsPageContent.tsx | 66 ++ .../ConfigureAgentsDialog.stories.tsx | 344 ---------- ...entsDialog.tsx => SettingsPageContent.tsx} | 409 ++++-------- .../UserAnalyticsDialog.stories.tsx | 74 -- .../pages/AgentsPage/UserAnalyticsDialog.tsx | 96 --- site/src/router.tsx | 3 + 12 files changed, 711 insertions(+), 1178 deletions(-) create mode 100644 site/src/pages/AgentsPage/AnalyticsPageContent.tsx delete mode 100644 site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx rename site/src/pages/AgentsPage/{ConfigureAgentsDialog.tsx => SettingsPageContent.tsx} (56%) delete mode 100644 site/src/pages/AgentsPage/UserAnalyticsDialog.stories.tsx delete mode 100644 site/src/pages/AgentsPage/UserAnalyticsDialog.tsx diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 5382f81092..e9b01f53d7 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -418,7 +418,7 @@ export const chatModels = () => ({ queryFn: (): Promise => API.getChatModels(), }); -export const chatProviderConfigsKey = ["chat-provider-configs"] as const; +const chatProviderConfigsKey = ["chat-provider-configs"] as const; export const chatProviderConfigs = () => ({ queryKey: chatProviderConfigsKey, @@ -426,7 +426,7 @@ export const chatProviderConfigs = () => ({ API.getChatProviderConfigs(), }); -export const chatModelConfigsKey = ["chat-model-configs"] as const; +const chatModelConfigsKey = ["chat-model-configs"] as const; export const chatModelConfigs = () => ({ queryKey: chatModelConfigsKey, @@ -531,10 +531,7 @@ export const chatCostUsers = (params?: ChatCostUsersParams) => ({ staleTime: 60_000, }); -export const chatUsageLimitConfigKey = [ - ...chatsKey, - "usageLimitConfig", -] as const; +const chatUsageLimitConfigKey = [...chatsKey, "usageLimitConfig"] as const; export const chatUsageLimitConfig = () => ({ queryKey: chatUsageLimitConfigKey, diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 50b5a05e92..eb0b360e15 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -117,6 +117,9 @@ const buildChat = (overrides: Partial = {}): Chat => ({ }); const agentsRouting = [ + { path: "/agents/settings/:section", useStoryElement: true }, + { path: "/agents/settings", useStoryElement: true }, + { path: "/agents/analytics", useStoryElement: true }, { path: "/agents/:agentId", useStoryElement: true }, { path: "/agents", useStoryElement: true }, ] satisfies [ @@ -382,32 +385,14 @@ export const WithErrorReasons: Story = { }, }; -type ChatCostSummaryCall = [ - user: string, - params?: { - start_date?: string; - end_date?: string; - }, -]; - -const getChatCostSummaryCalls = (): ChatCostSummaryCall[] => { - return ( - API.getChatCostSummary as typeof API.getChatCostSummary & { - mock: { calls: ChatCostSummaryCall[] }; - } - ).mock.calls; +const openAnalyticsView = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("link", { name: "Analytics" })); }; -const openAnalyticsDialog = async (canvasElement: HTMLElement) => { +const openSettingsView = async (canvasElement: HTMLElement) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button", { name: "Analytics" })); - return screen.findByRole("dialog", { name: "Analytics" }); -}; - -const openSettingsDialog = async (canvasElement: HTMLElement) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button", { name: "Settings" })); - return screen.findByRole("dialog", { name: "Settings" }); + await userEvent.click(canvas.getByRole("link", { name: "Settings" })); }; export const OpensAnalyticsForAdmins: Story = { @@ -415,12 +400,15 @@ export const OpensAnalyticsForAdmins: Story = { isAgentsAdmin: true, }, play: async ({ canvasElement }) => { - const dialog = await openAnalyticsDialog(canvasElement); + await openAnalyticsView(canvasElement); - await expect(dialog).toBeInTheDocument(); - expect( - screen.queryByRole("dialog", { name: "Settings" }), - ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByText( + "Review your personal chat usage and cost breakdowns.", + ), + ).toBeInTheDocument(); + }); }, }; @@ -429,12 +417,15 @@ export const OpensAnalyticsForNonAdmins: Story = { isAgentsAdmin: false, }, play: async ({ canvasElement }) => { - const dialog = await openAnalyticsDialog(canvasElement); + await openAnalyticsView(canvasElement); - await expect(dialog).toBeInTheDocument(); - expect( - screen.queryByRole("dialog", { name: "Settings" }), - ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByText( + "Review your personal chat usage and cost breakdowns.", + ), + ).toBeInTheDocument(); + }); }, }; @@ -443,17 +434,15 @@ export const OpensSettingsForAdmins: Story = { isAgentsAdmin: true, }, play: async ({ canvasElement }) => { - const dialog = await openSettingsDialog(canvasElement); + await openSettingsView(canvasElement); - await expect(dialog).toBeInTheDocument(); - await expect( - within(dialog).getByText( - "Custom instructions that shape how the agent responds in your chats.", - ), - ).toBeInTheDocument(); - expect( - screen.queryByRole("dialog", { name: "Analytics" }), - ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByText( + "Custom instructions that shape how the agent responds in your chats.", + ), + ).toBeInTheDocument(); + }); }, }; @@ -462,27 +451,36 @@ export const OpensSettingsForNonAdmins: Story = { isAgentsAdmin: false, }, play: async ({ canvasElement }) => { - const dialog = await openSettingsDialog(canvasElement); + await openSettingsView(canvasElement); - await expect(dialog).toBeInTheDocument(); - await expect( - within(dialog).getByText( - "Custom instructions that shape how the agent responds in your chats.", - ), - ).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByText( + "Custom instructions that shape how the agent responds in your chats.", + ), + ).toBeInTheDocument(); + }); }, }; -export const RemountsConfigureDialogWhenReopened: Story = { +export const SettingsViewResets: Story = { args: { isAgentsAdmin: true, }, play: async ({ canvasElement }) => { - let dialog = await openSettingsDialog(canvasElement); + // Open settings + await openSettingsView(canvasElement); - await userEvent.click( - within(dialog).getByRole("button", { name: "Usage" }), - ); + await waitFor(() => { + expect( + screen.getByText( + "Custom instructions that shape how the agent responds in your chats.", + ), + ).toBeInTheDocument(); + }); + + // Navigate to Usage section + await userEvent.click(screen.getByText("Usage")); await waitFor(() => { expect( screen.getByText( @@ -491,71 +489,18 @@ export const RemountsConfigureDialogWhenReopened: Story = { ).toBeInTheDocument(); }); - await userEvent.click( - within(dialog).getByRole("button", { name: "Close" }), - ); + // Go back to chats + const backButton = screen.getByLabelText("Back to chats from Settings"); + await userEvent.click(backButton); + + // Re-open settings, should reset to Behavior + await openSettingsView(canvasElement); await waitFor(() => { expect( - screen.queryByRole("dialog", { name: "Settings" }), - ).not.toBeInTheDocument(); + screen.getByText( + "Custom instructions that shape how the agent responds in your chats.", + ), + ).toBeInTheDocument(); }); - - dialog = await openSettingsDialog(canvasElement); - - await expect( - within(dialog).getByText( - "Custom instructions that shape how the agent responds in your chats.", - ), - ).toBeInTheDocument(); - expect( - screen.queryByText( - "Review deployment chat usage and drill into individual users.", - ), - ).not.toBeInTheDocument(); - }, -}; - -export const RemountsAnalyticsDialogWhenReopened: Story = { - args: { - isAgentsAdmin: true, - }, - play: async ({ canvasElement }) => { - let dialog = await openAnalyticsDialog(canvasElement); - - await expect(dialog).toBeInTheDocument(); - await waitFor(() => { - expect(getChatCostSummaryCalls().length).toBeGreaterThan(0); - }); - const initialCallCount = getChatCostSummaryCalls().length; - const initialEndDates = new Set( - getChatCostSummaryCalls() - .map(([, params]) => params?.end_date) - .filter((endDate): endDate is string => Boolean(endDate)), - ); - - await userEvent.click( - within(dialog).getByRole("button", { name: "Close" }), - ); - await waitFor(() => { - expect( - screen.queryByRole("dialog", { name: "Analytics" }), - ).not.toBeInTheDocument(); - }); - - dialog = await openAnalyticsDialog(canvasElement); - await expect(dialog).toBeInTheDocument(); - await waitFor(() => { - expect(getChatCostSummaryCalls().length).toBeGreaterThan( - initialCallCount, - ); - }); - - const reopenedCalls = getChatCostSummaryCalls().slice(initialCallCount); - expect( - reopenedCalls.some(([, params]) => { - const endDate = params?.end_date; - return typeof endDate === "string" && !initialEndDates.has(endDate); - }), - ).toBe(true); }, }; diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 4b3a6defad..9d630c7968 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -4,18 +4,15 @@ import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { CoderIcon } from "components/Icons/CoderIcon"; import { PanelLeftIcon } from "lucide-react"; -import { type FC, useCallback, useMemo, useState } from "react"; -import { NavLink, Outlet } from "react-router"; +import { type FC, useCallback, useMemo } from "react"; +import { NavLink, Outlet, useLocation, useNavigate } from "react-router"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; import { AgentCreateForm, type CreateChatOptions } from "./AgentCreateForm"; -import { AgentsSidebar } from "./AgentsSidebar"; +import { AgentsSidebar, sidebarViewFromPath } from "./AgentsSidebar"; +import { AnalyticsPageContent } from "./AnalyticsPageContent"; import { ChimeButton } from "./ChimeButton"; -import { - ConfigureAgentsDialog, - type ConfigureAgentsSection, -} from "./ConfigureAgentsDialog"; -import { UserAnalyticsDialog } from "./UserAnalyticsDialog"; +import { SettingsPageContent } from "./SettingsPageContent"; import type { ChatDetailError } from "./usageLimitMessage"; import { WebPushButton } from "./WebPushButton"; @@ -103,18 +100,16 @@ export const AgentsPageView: FC = ({ requestUnarchiveAgent, requestArchiveAndDeleteWorkspace, } = outletContext; - const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] = - useState(false); - const [configDialogKey, setConfigDialogKey] = useState(0); - const [configureAgentsInitialSection, setConfigureAgentsInitialSection] = - useState("behavior"); - const [isUserAnalyticsDialogOpen, setUserAnalyticsDialogOpen] = - useState(false); - const [analyticsDialogKey, setAnalyticsDialogKey] = useState(0); + const location = useLocation(); + const navigate = useNavigate(); + const sidebarView = sidebarViewFromPath(location.pathname); + const handleOpenAnalytics = useCallback(() => { - setAnalyticsDialogKey((key) => key + 1); - setUserAnalyticsDialogOpen(true); - }, []); + navigate("/agents/analytics"); + }, [navigate]); + + // The sidebar expects plain string error messages, but the outlet + // context now carries structured ChatDetailError objects. const sidebarChatErrorReasons = useMemo( () => Object.fromEntries( @@ -125,10 +120,12 @@ export const AgentsPageView: FC = ({ ), [chatErrorReasons], ); + const outletContextValue = useMemo( () => ({ ...outletContext, onOpenAnalytics: handleOpenAnalytics }), [outletContext, handleOpenAnalytics], ); + return (
{pageTitle("Agents")} @@ -150,7 +147,7 @@ export const AgentsPageView: FC = ({ onArchiveAgent={requestArchiveAgent} onUnarchiveAgent={requestUnarchiveAgent} onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace} - onNewAgent={handleNewAgent} + onBeforeNewAgent={handleNewAgent} isCreating={isCreating} isArchiving={isArchiving} archivingChatId={archivingChatId} @@ -163,22 +160,27 @@ export const AgentsPageView: FC = ({ archivedFilter={archivedFilter} onArchivedFilterChange={onArchivedFilterChange} onCollapse={onCollapseSidebar} - onOpenAnalytics={handleOpenAnalytics} - onOpenSettings={() => { - setConfigureAgentsInitialSection("behavior"); - setConfigDialogKey((key) => key + 1); - setConfigureAgentsDialogOpen(true); - }} + isAdmin={isAgentsAdmin} />
- {agentId ? ( + {sidebarView.panel === "settings" ? ( + + ) : sidebarView.panel === "analytics" ? ( + + ) : agentId ? ( ) : ( <> @@ -225,20 +227,6 @@ export const AgentsPageView: FC = ({ )}
- - - ); }; diff --git a/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx index 908ee43304..ae59fdc9ac 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx @@ -66,7 +66,7 @@ const meta: Meta = { onArchiveAgent: fn(), onUnarchiveAgent: fn(), onArchiveAndDeleteWorkspace: fn(), - onNewAgent: fn(), + onBeforeNewAgent: fn(), isCreating: false, archivedFilter: "active" as const, onArchivedFilterChange: fn(), diff --git a/site/src/pages/AgentsPage/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/AgentsSidebar.test.tsx index a8c8bad04a..37fe353c50 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.test.tsx @@ -103,7 +103,7 @@ const defaultProps: React.ComponentProps = { onArchiveAgent: vi.fn(), onUnarchiveAgent: vi.fn(), onArchiveAndDeleteWorkspace: vi.fn(), - onNewAgent: vi.fn(), + onBeforeNewAgent: vi.fn(), isCreating: false, archivedFilter: "active" as const, }; diff --git a/site/src/pages/AgentsPage/AgentsSidebar.tsx b/site/src/pages/AgentsPage/AgentsSidebar.tsx index 8d2e34269a..cbf89f317b 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.tsx @@ -30,8 +30,10 @@ import { ArchiveIcon, ArchiveRestoreIcon, BarChart3Icon, + BoxesIcon, CheckIcon, ChevronDownIcon, + ChevronLeftIcon, ChevronRightIcon, EllipsisIcon, FilterIcon, @@ -39,12 +41,16 @@ import { GitPullRequestArrowIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, + KeyRoundIcon, Loader2Icon, PanelLeftCloseIcon, PauseIcon, SettingsIcon, + ShieldAlertIcon, + ShieldIcon, SquarePenIcon, Trash2Icon, + UserIcon, } from "lucide-react"; import { UserDropdownContent } from "modules/dashboard/Navbar/UserDropdown/UserDropdownContent"; import { useDashboard } from "modules/dashboard/useDashboard"; @@ -59,11 +65,30 @@ import { useRef, useState, } from "react"; -import { NavLink, useParams } from "react-router"; +import { Link, NavLink, useLocation, useParams } from "react-router"; import { cn } from "utils/cn"; import { shortRelativeTime } from "utils/time"; import { getTimeGroup, TIME_GROUPS } from "./timeGroups"; +type SidebarView = + | { panel: "chats" } + | { panel: "settings"; section: string } + | { panel: "analytics" }; + +/** + * Derive the current sidebar view from the URL pathname. + */ +export function sidebarViewFromPath(pathname: string): SidebarView { + if (pathname.startsWith("/agents/analytics")) { + return { panel: "analytics" }; + } + const settingsMatch = pathname.match(/^\/agents\/settings(?:\/([^/]+))?/); + if (settingsMatch) { + return { panel: "settings", section: settingsMatch[1] || "behavior" }; + } + return { panel: "chats" }; +} + interface AgentsSidebarProps { chats: readonly Chat[]; chatErrorReasons: Record; @@ -73,7 +98,7 @@ interface AgentsSidebarProps { onArchiveAgent: (chatId: string) => void; onUnarchiveAgent: (chatId: string) => void; onArchiveAndDeleteWorkspace: (chatId: string, workspaceId: string) => void; - onNewAgent: () => void; + onBeforeNewAgent?: () => void; isCreating: boolean; isArchiving?: boolean; archivingChatId?: string | null; @@ -86,8 +111,7 @@ interface AgentsSidebarProps { archivedFilter: "active" | "archived"; onArchivedFilterChange?: (filter: "active" | "archived") => void; onCollapse?: () => void; - onOpenAnalytics?: () => void; - onOpenSettings?: () => void; + isAdmin?: boolean; } const statusConfig = { @@ -565,7 +589,7 @@ export const AgentsSidebar: FC = (props) => { onArchiveAgent, onUnarchiveAgent, onArchiveAndDeleteWorkspace, - onNewAgent, + onBeforeNewAgent, isCreating, isArchiving = false, archivingChatId = null, @@ -578,8 +602,7 @@ export const AgentsSidebar: FC = (props) => { archivedFilter, onArchivedFilterChange, onCollapse, - onOpenAnalytics, - onOpenSettings, + isAdmin = false, } = props; const { agentId, chatId } = useParams<{ agentId?: string; @@ -588,6 +611,8 @@ export const AgentsSidebar: FC = (props) => { const activeChatId = agentId ?? chatId; const { user, signOut } = useAuthenticated(); const { appearance, buildInfo } = useDashboard(); + const location = useLocation(); + const sidebarView = sidebarViewFromPath(location.pathname); const normalizedSearch = ""; const [expandedById, setExpandedById] = useState>({}); @@ -671,235 +696,401 @@ export const AgentsSidebar: FC = (props) => { ], ); + const subNavTitle = "Settings"; + return ( -
-
-
- - {logoUrl ? ( - - ) : ( - - )} - -
- - +
+ {/* ── Panel 1: Chats ── */} +
+
+
+ + {logoUrl ? ( + + ) : ( + + )} + +
+ + {" "} + + + + + + onArchivedFilterChange?.("active")} + > + Active + {archivedFilter === "active" && ( + + )} + + onArchivedFilterChange?.("archived")} + > + Archived + {archivedFilter === "archived" && ( + + )} + + + + {onCollapse && ( - - - onArchivedFilterChange?.("active")} - > - Active - {archivedFilter === "active" && ( - - )} - - onArchivedFilterChange?.("archived")} - > - Archived - {archivedFilter === "archived" && ( - - )} - - - - {onCollapse && ( - - )} -
-
- -
- -
- {loadError ? ( -
- - {onRetryLoad && ( - )}
- ) : isLoading ? ( - <> - -
- {Array.from({ length: 6 }, (_, i) => ( -
- -
- - -
-
- ))} -
- - ) : ( - - {visibleRootIDs.length === 0 ? ( -
- {normalizedSearch - ? "No matching agents" - : archivedFilter === "archived" - ? "No archived agents" - : "No agents yet"} -
- ) : ( -
- {visibleRootIDs.length > 0 && ( -
- {TIME_GROUPS.map((group) => { - const groupChats = visibleRootIDs - .map((id) => chatById.get(id)) - .filter( - (chat): chat is Chat => - chat !== undefined && - getTimeGroup(chat.updated_at) === group, - ); - if (groupChats.length === 0) return null; - return ( -
-
- {group} -
-
- {groupChats.map((chat) => ( - - ))} -
-
- ); - })} -
- )} -
- )} - {(hasNextPage || isFetchingNextPage) && ( - - )} -
- )} +
+
- -
-
- - - - - - link.location !== "navbar", - ) ?? [] - } - onSignOut={signOut} - /> - - - {onOpenAnalytics && ( - - - + )} +
+ ) : isLoading ? ( + <> + +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+ + ) : ( + + {visibleRootIDs.length === 0 ? ( +
+ {normalizedSearch + ? "No matching agents" + : archivedFilter === "archived" + ? "No archived agents" + : "No agents yet"} +
+ ) : ( +
+ {visibleRootIDs.length > 0 && ( +
+ {TIME_GROUPS.map((group) => { + const groupChats = visibleRootIDs + .map((id) => chatById.get(id)) + .filter( + (chat): chat is Chat => + chat !== undefined && + getTimeGroup(chat.updated_at) === group, + ); + if (groupChats.length === 0) return null; + return ( +
+
+ {group} +
+
+ {groupChats.map((chat) => ( + + ))} +
+
+ ); + })} +
+ )} +
+ )} + {(hasNextPage || isFetchingNextPage) && ( + + )} +
+ )} +
+ +
+
+ + + - - Analytics - - )} - {onOpenSettings && ( - - )} + + + {user.name || user.username} + + + + + link.location !== "navbar", + ) ?? [] + } + onSignOut={signOut} + /> + + +
+ + {/* ── Panel 2: Sub-navigation (Settings) ── */} +
+ {/* Back header */} +
+ + + {subNavTitle} + +
+ {/* Sub-navigation items */} + {sidebarView.panel === "settings" && ( + + )} +
); }; +type SettingsNavItemProps = { + icon: FC<{ className?: string }>; + label: string; + active: boolean; + adminOnly?: boolean; + disabled?: boolean; +} & ( + | { to: string; replace?: boolean; state?: unknown; onClick?: () => void } + | { to?: never; replace?: never; state?: never; onClick: () => void } +); + +const navItemClassName = (active: boolean, disabled: boolean | undefined) => + cn( + "flex w-full items-center gap-2.5 rounded-md border-0 px-2.5 py-2 text-left text-sm cursor-pointer transition-colors no-underline", + active + ? "bg-surface-quaternary/25 text-content-primary font-medium" + : "bg-transparent text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary", + disabled && "opacity-50 pointer-events-none", + ); + +const NavItemContent: FC<{ + icon: FC<{ className?: string }>; + label: string; + adminOnly?: boolean; +}> = ({ icon: Icon, label, adminOnly }) => ( + <> + + + {label} + {adminOnly && ( + + + + + + + Admin only + + )} + + +); + +const SettingsNavItem: FC = ({ + icon, + label, + active, + adminOnly, + disabled, + ...rest +}) => { + if (rest.to != null) { + return ( + + + + ); + } + + return ( + + ); +}; + const LoadMoreSentinel: FC<{ onLoadMore?: () => void; isFetchingNextPage?: boolean; diff --git a/site/src/pages/AgentsPage/AnalyticsPageContent.tsx b/site/src/pages/AgentsPage/AnalyticsPageContent.tsx new file mode 100644 index 0000000000..10b802d36a --- /dev/null +++ b/site/src/pages/AgentsPage/AnalyticsPageContent.tsx @@ -0,0 +1,66 @@ +import { chatCostSummary } from "api/queries/chats"; +import { useAuthContext } from "contexts/auth/AuthProvider"; +import dayjs from "dayjs"; +import { BarChart3Icon } from "lucide-react"; +import { type FC, useMemo } from "react"; +import { useQuery } from "react-query"; +import { ChatCostSummaryView } from "./ChatCostSummaryView"; +import { SectionHeader } from "./SectionHeader"; + +const createDateRange = (now?: dayjs.Dayjs) => { + const end = now ?? dayjs(); + const start = end.subtract(30, "day"); + return { + startDate: start.toISOString(), + endDate: end.toISOString(), + rangeLabel: `${start.format("MMM D")} – ${end.format("MMM D, YYYY")}`, + }; +}; + +interface AnalyticsPageContentProps { + /** Override the current time for date range calculation. + * Used for deterministic Storybook snapshots. + */ + now?: dayjs.Dayjs; +} + +export const AnalyticsPageContent: FC = ({ + now, +}) => { + const { user } = useAuthContext(); + const dateRange = useMemo(() => createDateRange(now), [now]); + + const summaryQuery = useQuery({ + ...chatCostSummary(user?.id ?? "me", { + start_date: dateRange.startDate, + end_date: dateRange.endDate, + }), + enabled: Boolean(user?.id), + }); + + return ( +
+
+ + + {dateRange.rangeLabel} +
+ } + /> + + void summaryQuery.refetch()} + loadingLabel="Loading analytics" + emptyMessage="No usage data for you in this period." + /> +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx b/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx deleted file mode 100644 index eba2fde6af..0000000000 --- a/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { API } from "api/api"; -import { - chatModelConfigsKey, - chatModelsKey, - chatProviderConfigsKey, - chatUsageLimitConfigKey, -} from "api/queries/chats"; -import { groupsQueryKey } from "api/queries/groups"; -import type { - ChatCostSummary, - ChatCostUserRollup, - ChatCostUsersResponse, - ChatModelConfig, - ChatModelsResponse, - ChatProviderConfig, - ChatUsageLimitConfigResponse, - ChatUsageLimitGroupOverride, - ChatUsageLimitOverride, - Group, -} from "api/typesGenerated"; -import { - expect, - fn, - screen, - spyOn, - userEvent, - waitFor, - within, -} from "storybook/test"; -import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog"; - -// Pre-seeded query data so that ChatModelAdminPanel renders -// without hitting a real backend. -const mockProviderConfigs: ChatProviderConfig[] = [ - { - id: "provider-1", - provider: "openai", - display_name: "OpenAI", - enabled: true, - has_api_key: true, - base_url: "https://api.openai.com/v1", - source: "database", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }, -]; - -const mockModelConfigs: ChatModelConfig[] = [ - { - id: "model-cfg-1", - provider: "openai", - model: "gpt-4o", - display_name: "GPT-4o", - enabled: true, - is_default: false, - context_limit: 128000, - compression_threshold: 80000, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }, -]; - -const mockChatModels: ChatModelsResponse = { - providers: [ - { - provider: "openai", - available: true, - models: [ - { - id: "openai:gpt-4o", - provider: "openai", - model: "gpt-4o", - display_name: "GPT-4o", - }, - ], - }, - ], -}; - -const chatQueries = [ - { key: chatProviderConfigsKey, data: mockProviderConfigs }, - { key: chatModelConfigsKey, data: mockModelConfigs }, - { key: chatModelsKey, data: mockChatModels }, -]; - -const buildUsageUser = ( - overrides: Partial = {}, -): ChatCostUserRollup => ({ - user_id: "user-1", - username: "alice", - name: "Alice Example", - avatar_url: "https://example.com/alice.png", - total_cost_micros: 1_200_000, - message_count: 12, - chat_count: 3, - total_input_tokens: 120_000, - total_output_tokens: 45_000, - total_cache_read_tokens: 6_789, - total_cache_creation_tokens: 2_468, - ...overrides, -}); - -const mockUsageUsers: ChatCostUsersResponse = { - start_date: "2026-02-10T00:00:00Z", - end_date: "2026-03-12T00:00:00Z", - count: 2, - users: [ - buildUsageUser(), - buildUsageUser({ - user_id: "user-2", - username: "bob", - name: "Bob Example", - avatar_url: "https://example.com/bob.png", - total_cost_micros: 900_000, - message_count: 8, - chat_count: 2, - total_input_tokens: 80_000, - total_output_tokens: 30_000, - total_cache_read_tokens: 4_321, - total_cache_creation_tokens: 1_234, - }), - ], -}; - -const mockUsageSummary: ChatCostSummary = { - start_date: "2026-02-10T00:00:00Z", - end_date: "2026-03-12T00:00:00Z", - total_cost_micros: 1_200_000, - priced_message_count: 12, - unpriced_message_count: 0, - total_input_tokens: 120_000, - total_output_tokens: 45_000, - total_cache_read_tokens: 6_789, - total_cache_creation_tokens: 2_468, - by_model: [ - { - model_config_id: "model-cfg-1", - display_name: "GPT-4o", - provider: "OpenAI", - model: "gpt-4o", - total_cost_micros: 1_200_000, - message_count: 12, - total_input_tokens: 120_000, - total_output_tokens: 45_000, - total_cache_read_tokens: 6_789, - total_cache_creation_tokens: 2_468, - }, - ], - by_chat: [ - { - root_chat_id: "chat-1", - chat_title: "Quarterly review", - total_cost_micros: 1_200_000, - message_count: 12, - total_input_tokens: 120_000, - total_output_tokens: 45_000, - total_cache_read_tokens: 6_789, - total_cache_creation_tokens: 2_468, - }, - ], -}; - -const mockGroupOverrides: ChatUsageLimitGroupOverride[] = [ - { - group_id: "grp-1", - group_name: "engineering", - group_display_name: "Engineering", - group_avatar_url: "", - member_count: 12, - spend_limit_micros: 20_000_000, - }, -]; - -const mockUserOverrides: ChatUsageLimitOverride[] = [ - { - user_id: "user-1", - username: "alice", - name: "Alice Example", - avatar_url: "https://example.com/alice.png", - spend_limit_micros: 50_000_000, - }, -]; - -const mockLimitConfig: ChatUsageLimitConfigResponse = { - spend_limit_micros: 10_000_000, - period: "month", - updated_at: "2026-03-01T00:00:00Z", - unpriced_model_count: 1, - group_overrides: mockGroupOverrides, - overrides: mockUserOverrides, -}; - -const mockGroups: Group[] = [ - { - id: "grp-1", - name: "engineering", - display_name: "Engineering", - organization_id: "org-1", - members: [], - total_member_count: 12, - avatar_url: "", - quota_allowance: 0, - source: "user", - organization_name: "default", - organization_display_name: "Default", - }, - { - id: "grp-2", - name: "design", - display_name: "Design", - organization_id: "org-1", - members: [], - total_member_count: 5, - avatar_url: "", - quota_allowance: 0, - source: "user", - organization_name: "default", - organization_display_name: "Default", - }, -]; - -const meta: Meta = { - title: "pages/AgentsPage/ConfigureAgentsDialog", - component: ConfigureAgentsDialog, - args: { - open: true, - onOpenChange: fn(), - canManageChatModelConfigs: false, - canSetSystemPrompt: false, - }, - beforeEach: () => { - spyOn(API, "getChatSystemPrompt").mockResolvedValue({ - system_prompt: "", - }); - spyOn(API, "updateChatSystemPrompt").mockResolvedValue(); - spyOn(API, "getUserChatCustomPrompt").mockResolvedValue({ - custom_prompt: "", - }); - spyOn(API, "updateUserChatCustomPrompt").mockResolvedValue({ - custom_prompt: "", - }); - }, -}; - -export default meta; -type Story = StoryObj; - -/** Regular user sees only the Personal Prompt section. */ -export const UserOnly: Story = {}; - -/** Admin sees Personal Prompt + System Prompt in the same Prompts tab. */ -export const AdminPrompts: Story = { - args: { - canSetSystemPrompt: true, - }, - beforeEach: () => { - spyOn(API, "getChatSystemPrompt").mockResolvedValue({ - system_prompt: "You are a helpful coding assistant.", - }); - }, -}; - -/** Admin with model config permissions sees Providers/Models tabs. */ -export const AdminFull: Story = { - args: { - canSetSystemPrompt: true, - canManageChatModelConfigs: true, - }, - parameters: { queries: chatQueries }, - beforeEach: () => { - spyOn(API, "getChatSystemPrompt").mockResolvedValue({ - system_prompt: "Follow company coding standards.", - }); - }, -}; - -/** Verifies that typing and saving the system prompt calls the API. */ -export const SavesBehaviorPromptAndRestores: Story = { - args: { - canSetSystemPrompt: true, - }, - play: async () => { - const dialog = await screen.findByRole("dialog"); - - // Find the System Instructions textarea by its unique placeholder. - const textareas = await within(dialog).findAllByPlaceholderText( - "Additional behavior, style, and tone preferences for all users", - ); - const textarea = textareas[0]; - - await userEvent.type(textarea, "You are a focused coding assistant."); - - // Click the Save button inside the System Instructions form. - // There are multiple Save buttons (one per form), so grab all and - // pick the last one which belongs to the system prompt section. - const saveButtons = within(dialog).getAllByRole("button", { name: "Save" }); - await userEvent.click(saveButtons[saveButtons.length - 1]); - - await waitFor(() => { - expect(API.updateChatSystemPrompt).toHaveBeenCalledWith({ - system_prompt: "You are a focused coding assistant.", - }); - }); - }, -}; - -/** Admin can open the Usage tab and review user chat spend. */ -export const UsageTab: Story = { - args: { - initialSection: "usage", - canManageChatModelConfigs: true, - }, - beforeEach: () => { - spyOn(API, "getChatCostUsers").mockResolvedValue(mockUsageUsers); - spyOn(API, "getChatCostSummary").mockResolvedValue(mockUsageSummary); - }, -}; - -/** Admin sees the Limits tab with global, group, and user override data. */ -export const LimitsTab: Story = { - args: { - initialSection: "limits", - canManageChatModelConfigs: true, - }, - parameters: { - queries: [ - ...chatQueries, - { key: chatUsageLimitConfigKey, data: mockLimitConfig }, - { key: groupsQueryKey, data: mockGroups }, - ], - }, - beforeEach: () => { - spyOn(API, "updateChatUsageLimitConfig").mockResolvedValue(mockLimitConfig); - spyOn(API, "upsertChatUsageLimitGroupOverride").mockResolvedValue( - mockGroupOverrides[0]!, - ); - spyOn(API, "deleteChatUsageLimitGroupOverride").mockResolvedValue(); - spyOn(API, "upsertChatUsageLimitOverride").mockResolvedValue( - mockUserOverrides[0]!, - ); - spyOn(API, "deleteChatUsageLimitOverride").mockResolvedValue(); - }, -}; diff --git a/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx b/site/src/pages/AgentsPage/SettingsPageContent.tsx similarity index 56% rename from site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx rename to site/src/pages/AgentsPage/SettingsPageContent.tsx index c008373578..d8d819ef1a 100644 --- a/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx +++ b/site/src/pages/AgentsPage/SettingsPageContent.tsx @@ -10,14 +10,6 @@ import { import type * as TypesGen from "api/typesGenerated"; import { AvatarData } from "components/Avatar/AvatarData"; import { Button } from "components/Button/Button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "components/Dialog/Dialog"; import { PaginationAmount } from "components/PaginationWidget/PaginationAmount"; import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase"; import { SearchField } from "components/SearchField/SearchField"; @@ -39,16 +31,7 @@ import { import dayjs from "dayjs"; import { useDebouncedValue } from "hooks/debounce"; import { useClickableTableRow } from "hooks/useClickableTableRow"; -import type { LucideIcon } from "lucide-react"; -import { - BarChart3Icon, - BoxesIcon, - KeyRoundIcon, - ShieldAlertIcon, - ShieldIcon, - UserIcon, - XIcon, -} from "lucide-react"; +import { ShieldIcon } from "lucide-react"; import { type FC, type FormEvent, useCallback, useMemo, useState } from "react"; import { keepPreviousData, @@ -58,27 +41,12 @@ import { } from "react-query"; import TextareaAutosize from "react-textarea-autosize"; import { formatTokenCount } from "utils/analytics"; -import { cn } from "utils/cn"; import { formatCostMicros } from "utils/currency"; import { ChatCostSummaryView } from "./ChatCostSummaryView"; import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel"; import { LimitsTab } from "./LimitsTab"; import { SectionHeader } from "./SectionHeader"; -export type ConfigureAgentsSection = - | "providers" - | "models" - | "limits" - | "behavior" - | "usage"; - -type ConfigureAgentsSectionOption = { - id: ConfigureAgentsSection; - label: string; - icon: LucideIcon; - adminOnly?: boolean; -}; - const AdminBadge: FC = () => ( @@ -356,22 +324,19 @@ const UsageContent: FC = ({ now }) => { const textareaClassName = "max-h-[240px] w-full resize-none overflow-y-auto rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30 [scrollbar-width:thin]"; -interface ConfigureAgentsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; +interface SettingsPageContentProps { + activeSection: string; canManageChatModelConfigs: boolean; canSetSystemPrompt: boolean; - initialSection?: ConfigureAgentsSection; - /** Override the current time for date range calculation. Used for deterministic Storybook snapshots. */ + /** Override the current time for date range calculation. Used for + * deterministic Storybook snapshots. */ now?: dayjs.Dayjs; } -export const ConfigureAgentsDialog: FC = ({ - open, - onOpenChange, +export const SettingsPageContent: FC = ({ + activeSection, canManageChatModelConfigs, canSetSystemPrompt, - initialSection = "behavior", now, }) => { const queryClient = useQueryClient(); @@ -426,245 +391,137 @@ export const ConfigureAgentsDialog: FC = ({ }, [isUserPromptDirty, userPromptDraft, saveUserPrompt], ); - const configureSectionOptions = useMemo< - readonly ConfigureAgentsSectionOption[] - >(() => { - const options: ConfigureAgentsSectionOption[] = []; - options.push({ - id: "behavior", - label: "Behavior", - icon: UserIcon, - }); - if (canManageChatModelConfigs) { - options.push({ - id: "providers", - label: "Providers", - icon: KeyRoundIcon, - adminOnly: true, - }); - options.push({ - id: "models", - label: "Models", - icon: BoxesIcon, - adminOnly: true, - }); - options.push({ - id: "limits", - label: "Limits", - icon: ShieldAlertIcon, - adminOnly: true, - }); - options.push({ - id: "usage", - label: "Usage", - icon: BarChart3Icon, - adminOnly: true, - }); - } - return options; - }, [canManageChatModelConfigs]); - - const [userActiveSection, setUserActiveSection] = - useState(initialSection); - - const activeSection = configureSectionOptions.some( - (s) => s.id === userActiveSection, - ) - ? userActiveSection - : (configureSectionOptions[0]?.id ?? "behavior"); return ( - - - - Settings - - Manage your personal preferences and agent configuration. - - - - - -
- {activeSection === "behavior" && ( -
- + Personal Instructions + +

+ Applied to all your chats. Only visible to you. +

+ setLocalUserEdit(event.target.value)} + disabled={isDisabled} + minRows={1} /> - {/* ── Personal prompt (always visible) ── */} - void handleSaveUserPrompt(event)} - > -

- Personal Instructions -

-

- Applied to all your chats. Only visible to you. +

+ + +
+ {isSaveUserPromptError && ( +

+ Failed to save personal instructions.

- setLocalUserEdit(event.target.value)} - disabled={isDisabled} - minRows={1} - /> -
- - -
- {isSaveUserPromptError && ( -

- Failed to save personal instructions. -

- )} - - - {/* ── Admin system prompt (admin only) ── */} - {canSetSystemPrompt && ( - <> -
-
void handleSaveSystemPrompt(event)} - > -
-

- System Instructions -

- -
-

- Applied to all chats for every user. When empty, the - built-in default is used. -

- setLocalEdit(event.target.value)} - disabled={isDisabled} - minRows={1} - /> -
- - -
- {isSaveSystemPromptError && ( -

- Failed to save system prompt. -

- )} - - )} -
- )} - {activeSection === "providers" && canManageChatModelConfigs && ( -
- } - /> -
- )} - {activeSection === "models" && canManageChatModelConfigs && ( -
- } - /> -
- )} - {activeSection === "limits" && canManageChatModelConfigs && ( - - )} - {activeSection === "usage" && canManageChatModelConfigs && ( -
- -
- )} -
-
-
+ + + {/* ── Admin system prompt (admin only) ── */} + {canSetSystemPrompt && ( + <> +
+
void handleSaveSystemPrompt(event)} + > +
+

+ System Instructions +

+ +
+

+ Applied to all chats for every user. When empty, the + built-in default is used. +

+ setLocalEdit(event.target.value)} + disabled={isDisabled} + minRows={1} + /> +
+ + +
+ {isSaveSystemPromptError && ( +

+ Failed to save system prompt. +

+ )} + + + )} + + )} + {activeSection === "providers" && canManageChatModelConfigs && ( + } + /> + )} + {activeSection === "models" && canManageChatModelConfigs && ( + } + /> + )} + {activeSection === "limits" && canManageChatModelConfigs && ( + + )} + {activeSection === "usage" && canManageChatModelConfigs && ( + + )} +
+
); }; diff --git a/site/src/pages/AgentsPage/UserAnalyticsDialog.stories.tsx b/site/src/pages/AgentsPage/UserAnalyticsDialog.stories.tsx deleted file mode 100644 index 5d4753a5f5..0000000000 --- a/site/src/pages/AgentsPage/UserAnalyticsDialog.stories.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { MockUserOwner } from "testHelpers/entities"; -import { withAuthProvider } from "testHelpers/storybook"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { API } from "api/api"; -import type * as TypesGen from "api/typesGenerated"; -import dayjs from "dayjs"; -import { expect, fn, screen, spyOn, waitFor } from "storybook/test"; -import { UserAnalyticsDialog } from "./UserAnalyticsDialog"; - -const mockSummary: TypesGen.ChatCostSummary = { - start_date: "2026-02-10T00:00:00Z", - end_date: "2026-03-12T00:00:00Z", - total_cost_micros: 1_500_000, - priced_message_count: 12, - unpriced_message_count: 1, - total_input_tokens: 123_456, - total_output_tokens: 654_321, - total_cache_read_tokens: 9_876, - total_cache_creation_tokens: 5_432, - by_model: [ - { - model_config_id: "model-config-1", - display_name: "GPT-4.1", - provider: "OpenAI", - model: "gpt-4.1", - total_cost_micros: 1_250_000, - message_count: 9, - total_input_tokens: 100_000, - total_output_tokens: 200_000, - total_cache_read_tokens: 7_654, - total_cache_creation_tokens: 3_210, - }, - ], - by_chat: [ - { - root_chat_id: "chat-1", - chat_title: "Quarterly review", - total_cost_micros: 750_000, - message_count: 5, - total_input_tokens: 60_000, - total_output_tokens: 80_000, - total_cache_read_tokens: 4_321, - total_cache_creation_tokens: 1_234, - }, - ], -}; - -const meta: Meta = { - title: "pages/AgentsPage/UserAnalyticsDialog", - component: UserAnalyticsDialog, - decorators: [withAuthProvider], - parameters: { - user: MockUserOwner, - }, - beforeEach: () => { - spyOn(API, "getChatCostSummary").mockResolvedValue(mockSummary); - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - open: true, - onOpenChange: fn(), - now: dayjs("2026-03-12T12:00:00Z"), - }, - play: async () => { - await waitFor(() => { - expect(screen.getByText(/Feb 10\s*–\s*Mar 12, 2026/)).toBeInTheDocument(); - }); - }, -}; diff --git a/site/src/pages/AgentsPage/UserAnalyticsDialog.tsx b/site/src/pages/AgentsPage/UserAnalyticsDialog.tsx deleted file mode 100644 index 3db3d04823..0000000000 --- a/site/src/pages/AgentsPage/UserAnalyticsDialog.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { chatCostSummary } from "api/queries/chats"; -import { Button } from "components/Button/Button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "components/Dialog/Dialog"; -import { useAuthContext } from "contexts/auth/AuthProvider"; -import dayjs from "dayjs"; -import { BarChart3Icon, XIcon } from "lucide-react"; -import { type FC, useMemo } from "react"; -import { useQuery } from "react-query"; -import { ChatCostSummaryView } from "./ChatCostSummaryView"; -import { SectionHeader } from "./SectionHeader"; - -const createDateRange = (now?: dayjs.Dayjs) => { - const end = now ?? dayjs(); - const start = end.subtract(30, "day"); - return { - startDate: start.toISOString(), - endDate: end.toISOString(), - rangeLabel: `${start.format("MMM D")} – ${end.format("MMM D, YYYY")}`, - }; -}; - -interface UserAnalyticsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - /** Override the current time for date range calculation. Used for deterministic Storybook snapshots. */ - now?: dayjs.Dayjs; -} - -export const UserAnalyticsDialog: FC = ({ - open, - onOpenChange, - now, -}) => { - const { user } = useAuthContext(); - const dateRange = useMemo(() => createDateRange(now), [now]); - - const summaryQuery = useQuery({ - ...chatCostSummary(user?.id ?? "me", { - start_date: dateRange.startDate, - end_date: dateRange.endDate, - }), - enabled: open && Boolean(user?.id), - }); - - return ( - - - - Analytics - - Review your personal chat usage for the last 30 days. - - -
-
- - - - -
- -
- - {dateRange.rangeLabel} -
- - void summaryQuery.refetch()} - loadingLabel="Loading analytics" - emptyMessage="No usage data for you in this period." - /> -
-
-
- ); -}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 601149775c..f555f71a8c 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -645,6 +645,9 @@ export const router = createBrowserRouter( } > + + +