feat(site): move Settings and Analytics from dialogs to sidebar sub-navigation (#23126)

This commit is contained in:
Danielle Maywood
2026-03-17 14:48:09 +00:00
committed by GitHub
parent 075dfecd12
commit 635c5d52a8
12 changed files with 711 additions and 1178 deletions
+3 -6
View File
@@ -418,7 +418,7 @@ export const chatModels = () => ({
queryFn: (): Promise<TypesGen.ChatModelsResponse> => 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,
@@ -117,6 +117,9 @@ const buildChat = (overrides: Partial<Chat> = {}): 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);
},
};
+30 -42
View File
@@ -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<AgentsPageViewProps> = ({
requestUnarchiveAgent,
requestArchiveAndDeleteWorkspace,
} = outletContext;
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
const [configDialogKey, setConfigDialogKey] = useState(0);
const [configureAgentsInitialSection, setConfigureAgentsInitialSection] =
useState<ConfigureAgentsSection>("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<AgentsPageViewProps> = ({
),
[chatErrorReasons],
);
const outletContextValue = useMemo(
() => ({ ...outletContext, onOpenAnalytics: handleOpenAnalytics }),
[outletContext, handleOpenAnalytics],
);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<title>{pageTitle("Agents")}</title>
@@ -150,7 +147,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
onArchiveAgent={requestArchiveAgent}
onUnarchiveAgent={requestUnarchiveAgent}
onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
onNewAgent={handleNewAgent}
onBeforeNewAgent={handleNewAgent}
isCreating={isCreating}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
@@ -163,22 +160,27 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
onCollapse={onCollapseSidebar}
onOpenAnalytics={handleOpenAnalytics}
onOpenSettings={() => {
setConfigureAgentsInitialSection("behavior");
setConfigDialogKey((key) => key + 1);
setConfigureAgentsDialogOpen(true);
}}
isAdmin={isAgentsAdmin}
/>
</div>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary",
!agentId && "order-1 md:order-none flex-none md:flex-1",
!agentId &&
sidebarView.panel === "chats" &&
"order-1 md:order-none flex-none md:flex-1",
)}
>
{agentId ? (
{sidebarView.panel === "settings" ? (
<SettingsPageContent
activeSection={sidebarView.section}
canManageChatModelConfigs={isAgentsAdmin}
canSetSystemPrompt={isAgentsAdmin}
/>
) : sidebarView.panel === "analytics" ? (
<AnalyticsPageContent />
) : agentId ? (
<Outlet key={agentId} context={outletContextValue} />
) : (
<>
@@ -225,20 +227,6 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
</>
)}
</div>
<ConfigureAgentsDialog
key={configDialogKey}
open={isConfigureAgentsDialogOpen}
onOpenChange={setConfigureAgentsDialogOpen}
canManageChatModelConfigs={isAgentsAdmin}
canSetSystemPrompt={isAgentsAdmin}
initialSection={configureAgentsInitialSection}
/>
<UserAnalyticsDialog
key={analyticsDialogKey}
open={isUserAnalyticsDialogOpen}
onOpenChange={setUserAnalyticsDialogOpen}
/>
</div>
);
};
@@ -66,7 +66,7 @@ const meta: Meta<typeof AgentsSidebar> = {
onArchiveAgent: fn(),
onUnarchiveAgent: fn(),
onArchiveAndDeleteWorkspace: fn(),
onNewAgent: fn(),
onBeforeNewAgent: fn(),
isCreating: false,
archivedFilter: "active" as const,
onArchivedFilterChange: fn(),
@@ -103,7 +103,7 @@ const defaultProps: React.ComponentProps<typeof AgentsSidebar> = {
onArchiveAgent: vi.fn(),
onUnarchiveAgent: vi.fn(),
onArchiveAndDeleteWorkspace: vi.fn(),
onNewAgent: vi.fn(),
onBeforeNewAgent: vi.fn(),
isCreating: false,
archivedFilter: "active" as const,
};
+411 -220
View File
@@ -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<string, string>;
@@ -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<AgentsSidebarProps> = (props) => {
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
onNewAgent,
onBeforeNewAgent,
isCreating,
isArchiving = false,
archivingChatId = null,
@@ -578,8 +602,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
archivedFilter,
onArchivedFilterChange,
onCollapse,
onOpenAnalytics,
onOpenSettings,
isAdmin = false,
} = props;
const { agentId, chatId } = useParams<{
agentId?: string;
@@ -588,6 +611,8 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (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<Record<string, boolean>>({});
@@ -671,235 +696,401 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
],
);
const subNavTitle = "Settings";
return (
<div className="flex h-full w-full min-h-0 flex-col border-0 border-r border-solid">
<div className="hidden border-b border-border-default px-3 pb-3 pt-1.5 md:block md:px-3.5">
<div className="mb-2.5 flex items-center justify-between">
<NavLink to="/workspaces" className="inline-flex">
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
<div className="flex items-center gap-0.5 -mr-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="relative flex h-full w-full min-h-0 border-0 border-r border-solid overflow-hidden">
{/* ── Panel 1: Chats ── */}
<div
className={cn(
"absolute inset-0 flex flex-col transition-transform duration-200 ease-in-out",
sidebarView.panel === "settings" && "-translate-x-full",
)}
aria-hidden={sidebarView.panel === "settings"}
inert={sidebarView.panel === "settings" ? true : undefined}
>
<div className="hidden border-b border-border-default px-3 pb-3 pt-1.5 md:block md:px-3.5">
<div className="mb-2.5 flex items-center justify-between">
<NavLink to="/workspaces" className="inline-flex">
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
<div className="flex items-center gap-0.5 -mr-1.5">
<Button
asChild
variant="subtle"
size="icon"
aria-label="Settings"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
sidebarView.panel === "settings" && "text-content-primary",
)}
>
<Link to="/agents/settings" state={{ from: location.pathname }}>
<SettingsIcon />
</Link>
</Button>
<Button
asChild
variant="subtle"
size="icon"
aria-label="Analytics"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
sidebarView.panel === "analytics" && "text-content-primary",
)}
>
<Link to="/agents/analytics">
<BarChart3Icon />
</Link>
</Button>{" "}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Filter agents"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
archivedFilter === "archived" && "text-content-primary",
)}
>
<FilterIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onArchivedFilterChange?.("active")}
>
Active
{archivedFilter === "active" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onArchivedFilterChange?.("archived")}
>
Archived
{archivedFilter === "archived" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onCollapse && (
<Button
variant="subtle"
size="icon"
aria-label="Filter agents"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
archivedFilter === "archived" && "text-content-primary",
)}
onClick={onCollapse}
aria-label="Collapse sidebar"
className="h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary"
>
<FilterIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onArchivedFilterChange?.("active")}
>
Active
{archivedFilter === "active" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onArchivedFilterChange?.("archived")}
>
Archived
{archivedFilter === "archived" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onCollapse && (
<Button
variant="subtle"
size="icon"
onClick={onCollapse}
aria-label="Collapse sidebar"
className="h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary"
>
<PanelLeftCloseIcon />
</Button>
)}
</div>
</div>
<Button
size="sm"
variant="subtle"
onClick={onNewAgent}
disabled={isCreating}
className="-mx-1 w-[calc(100%+0.5rem)] justify-start gap-1.5 rounded-md py-1 pl-1 pr-2 text-sm text-content-secondary hover:bg-surface-tertiary/50 md:-mx-1.5 md:w-[calc(100%+0.75rem)]"
>
<SquarePenIcon className="!h-[18px] !w-[18px] shrink-0" />
New Agent
</Button>
</div>
<ScrollArea
className="flex-1 [&_[data-radix-scroll-area-viewport]>div]:!block"
scrollBarClassName="w-1.5"
>
<div className="flex flex-col gap-2 px-2 py-3 md:px-2">
{loadError ? (
<div className="space-y-3 px-1">
<ErrorAlert error={loadError} />
{onRetryLoad && (
<Button size="sm" variant="outline" onClick={onRetryLoad}>
Retry
<PanelLeftCloseIcon />
</Button>
)}
</div>
) : isLoading ? (
<>
<Skeleton className="ml-2.5 h-3.5 w-16" />
<div className="flex flex-col gap-0.5">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1"
>
<Skeleton className="mt-0.5 h-5 w-5 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${55 + ((i * 17) % 35)}%` }}
/>
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
</>
) : (
<ChatTreeContext.Provider value={chatTreeCtx}>
{visibleRootIDs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
{normalizedSearch
? "No matching agents"
: archivedFilter === "archived"
? "No archived agents"
: "No agents yet"}
</div>
) : (
<div>
{visibleRootIDs.length > 0 && (
<div className="pb-2">
{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 (
<div
key={group}
className="[&:not(:first-child)]:mt-3"
>
<div className="mb-1 ml-2.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>{group}</span>
</div>
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{(hasNextPage || isFetchingNextPage) && (
<LoadMoreSentinel
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
/>
)}
</ChatTreeContext.Provider>
)}
</div>
<SettingsNavItem
icon={SquarePenIcon}
label="New Agent"
active={!activeChatId && sidebarView.panel === "chats"}
to="/agents"
onClick={onBeforeNewAgent}
disabled={isCreating}
/>
</div>
</ScrollArea>
<div className="hidden border-0 border-t border-solid md:block">
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 bg-transparent border-0 cursor-pointer px-3 py-3 text-left hover:bg-surface-tertiary/50 transition-colors"
>
<Avatar
fallback={user.username}
src={user.avatar_url}
size="sm"
className="rounded-full"
/>
<span className="truncate text-sm text-content-secondary">
{user.name || user.username}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
alignOffset={4}
className="min-w-auto w-[260px]"
>
<UserDropdownContent
user={user}
buildInfo={buildInfo}
supportLinks={
appearance.support_links?.filter(
(link) => link.location !== "navbar",
) ?? []
}
onSignOut={signOut}
/>
</DropdownMenuContent>
</DropdownMenu>
{onOpenAnalytics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="icon"
onClick={onOpenAnalytics}
aria-label="Analytics"
className="mr-1"
<ScrollArea
className="flex-1 [&_[data-radix-scroll-area-viewport]>div]:!block"
scrollBarClassName="w-1.5"
>
<div className="flex flex-col gap-2 px-2 py-3 md:px-2">
{loadError ? (
<div className="space-y-3 px-1">
<ErrorAlert error={loadError} />
{onRetryLoad && (
<Button size="sm" variant="outline" onClick={onRetryLoad}>
Retry
</Button>
)}
</div>
) : isLoading ? (
<>
<Skeleton className="ml-2.5 h-3.5 w-16" />
<div className="flex flex-col gap-0.5">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1"
>
<Skeleton className="mt-0.5 h-5 w-5 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${55 + ((i * 17) % 35)}%` }}
/>
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
</>
) : (
<ChatTreeContext.Provider value={chatTreeCtx}>
{visibleRootIDs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
{normalizedSearch
? "No matching agents"
: archivedFilter === "archived"
? "No archived agents"
: "No agents yet"}
</div>
) : (
<div>
{visibleRootIDs.length > 0 && (
<div className="pb-2">
{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 (
<div
key={group}
className="[&:not(:first-child)]:mt-3"
>
<div className="mb-1 ml-2.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>{group}</span>
</div>
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{(hasNextPage || isFetchingNextPage) && (
<LoadMoreSentinel
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
/>
)}
</ChatTreeContext.Provider>
)}
</div>
</ScrollArea>
<div className="hidden border-0 border-t border-solid md:block">
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 bg-transparent border-0 cursor-pointer px-3 py-3 text-left hover:bg-surface-tertiary/50 transition-colors"
>
<BarChart3Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Analytics</TooltipContent>
</Tooltip>
)}
{onOpenSettings && (
<button
type="button"
onClick={onOpenSettings}
className="flex shrink-0 items-center justify-center bg-transparent border-0 cursor-pointer p-2 mr-1 rounded-md text-content-secondary hover:text-content-primary hover:bg-surface-tertiary/50 transition-colors"
aria-label="Settings"
>
<SettingsIcon className="h-4 w-4" />
</button>
)}
<Avatar
fallback={user.username}
src={user.avatar_url}
size="sm"
className="rounded-full"
/>
<span className="truncate text-sm text-content-secondary">
{user.name || user.username}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-auto w-[260px]"
>
<UserDropdownContent
user={user}
buildInfo={buildInfo}
supportLinks={
appearance.support_links?.filter(
(link) => link.location !== "navbar",
) ?? []
}
onSignOut={signOut}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* ── Panel 2: Sub-navigation (Settings) ── */}
<div
className={cn(
"absolute inset-0 flex flex-col transition-transform duration-200 ease-in-out",
sidebarView.panel !== "settings" && "translate-x-full",
)}
aria-hidden={sidebarView.panel !== "settings"}
inert={sidebarView.panel !== "settings" ? true : undefined}
>
{/* Back header */}
<div className="hidden border-b border-border-default px-3 py-2.5 md:block">
<Link
to={(location.state as { from?: string })?.from || "/agents"}
className="flex items-center gap-1.5 rounded-md bg-transparent px-0 py-1 text-sm font-medium text-content-secondary no-underline cursor-pointer hover:text-content-primary transition-colors"
aria-label={`Back to chats from ${subNavTitle}`}
>
<ChevronLeftIcon className="h-4 w-4 shrink-0" />
{subNavTitle}
</Link>
</div>
{/* Sub-navigation items */}
{sidebarView.panel === "settings" && (
<nav className="flex flex-col gap-0.5 px-2 py-2">
<SettingsNavItem
icon={UserIcon}
label="Behavior"
active={sidebarView.section === "behavior"}
to="/agents/settings/behavior"
replace
state={location.state}
/>
{isAdmin && (
<>
<SettingsNavItem
icon={KeyRoundIcon}
label="Providers"
active={sidebarView.section === "providers"}
to="/agents/settings/providers"
replace
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={BoxesIcon}
label="Models"
active={sidebarView.section === "models"}
to="/agents/settings/models"
replace
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={ShieldAlertIcon}
label="Limits"
active={sidebarView.section === "limits"}
to="/agents/settings/limits"
replace
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={BarChart3Icon}
label="Usage"
active={sidebarView.section === "usage"}
to="/agents/settings/usage"
replace
state={location.state}
adminOnly
/>
</>
)}
</nav>
)}
</div>
</div>
);
};
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 }) => (
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex flex-1 items-center gap-2">
{label}
{adminOnly && (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-auto inline-flex">
<ShieldIcon className="h-3 w-3 shrink-0 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="right">Admin only</TooltipContent>
</Tooltip>
)}
</span>
</>
);
const SettingsNavItem: FC<SettingsNavItemProps> = ({
icon,
label,
active,
adminOnly,
disabled,
...rest
}) => {
if (rest.to != null) {
return (
<Link
to={rest.to}
replace={rest.replace}
state={rest.state}
onClick={rest.onClick}
className={navItemClassName(active, disabled)}
aria-current={active ? "page" : undefined}
tabIndex={disabled ? -1 : undefined}
>
<NavItemContent icon={icon} label={label} adminOnly={adminOnly} />
</Link>
);
}
return (
<button
type="button"
onClick={rest.onClick}
disabled={disabled}
className={navItemClassName(active, disabled)}
aria-current={active ? "page" : undefined}
>
<NavItemContent icon={icon} label={label} adminOnly={adminOnly} />
</button>
);
};
const LoadMoreSentinel: FC<{
onLoadMore?: () => void;
isFetchingNextPage?: boolean;
@@ -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<AnalyticsPageContentProps> = ({
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 (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4 pt-8 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<div className="mx-auto w-full max-w-3xl">
<SectionHeader
label="Analytics"
description="Review your personal chat usage and cost breakdowns."
action={
<div className="flex items-center gap-2 text-xs text-content-secondary">
<BarChart3Icon className="h-4 w-4" />
<span>{dateRange.rangeLabel}</span>
</div>
}
/>
<ChatCostSummaryView
summary={summaryQuery.data}
isLoading={summaryQuery.isLoading}
error={summaryQuery.error}
onRetry={() => void summaryQuery.refetch()}
loadingLabel="Loading analytics"
emptyMessage="No usage data for you in this period."
/>
</div>
</div>
);
};
@@ -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> = {},
): 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<typeof ConfigureAgentsDialog> = {
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<typeof ConfigureAgentsDialog>;
/** 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();
},
};
@@ -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 = () => (
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -356,22 +324,19 @@ const UsageContent: FC<UsageContentProps> = ({ 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<ConfigureAgentsDialogProps> = ({
open,
onOpenChange,
export const SettingsPageContent: FC<SettingsPageContentProps> = ({
activeSection,
canManageChatModelConfigs,
canSetSystemPrompt,
initialSection = "behavior",
now,
}) => {
const queryClient = useQueryClient();
@@ -426,245 +391,137 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
},
[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<ConfigureAgentsSection>(initialSection);
const activeSection = configureSectionOptions.some(
(s) => s.id === userActiveSection,
)
? userActiveSection
: (configureSectionOptions[0]?.id ?? "behavior");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="grid h-[min(88dvh,720px)] max-w-4xl grid-cols-1 gap-0 overflow-hidden p-0 md:grid-cols-[220px_minmax(0,1fr)]">
<DialogHeader className="sr-only">
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Manage your personal preferences and agent configuration.
</DialogDescription>
</DialogHeader>
<nav className="flex flex-row gap-0.5 overflow-x-auto border-b border-border bg-surface-secondary/40 p-2 md:flex-col md:gap-0.5 md:overflow-x-visible md:border-b-0 md:border-r md:p-4">
<DialogClose asChild>
<Button
variant="subtle"
size="icon-lg"
className="mb-3 shrink-0 border-none bg-transparent shadow-none hover:bg-surface-tertiary/50"
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4 pt-8 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<div className="mx-auto w-full max-w-3xl">
{activeSection === "behavior" && (
<>
<SectionHeader
label="Behavior"
description="Custom instructions that shape how the agent responds in your chats."
/>
{/* ── Personal prompt (always visible) ── */}
<form
className="space-y-2"
onSubmit={(event) => void handleSaveUserPrompt(event)}
>
<XIcon className="text-content-secondary" />
<span className="sr-only">Close</span>
</Button>
</DialogClose>
{configureSectionOptions.map((section) => {
const isActive = section.id === activeSection;
const SectionIcon = section.icon;
return (
<Button
key={section.id}
variant="subtle"
className={cn(
"h-auto justify-start gap-3 rounded-lg border-none px-3 py-1.5 text-left shadow-none",
isActive
? "bg-surface-tertiary/60 text-content-primary hover:bg-surface-tertiary/60"
: "bg-transparent text-content-secondary hover:bg-surface-tertiary/30 hover:text-content-primary",
)}
onClick={() => setUserActiveSection(section.id)}
>
<SectionIcon className="h-5 w-5 shrink-0" />
<span className="flex flex-1 items-center gap-2 text-sm font-medium">
{section.label}
{section.adminOnly && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-auto inline-flex">
<ShieldIcon className="h-3 w-3 shrink-0 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="right">Admin only</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
</Button>
);
})}
</nav>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{activeSection === "behavior" && (
<div className="flex-1 overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<SectionHeader
label="Behavior"
description="Custom instructions that shape how the agent responds in your chats."
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Personal Instructions
</h3>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Applied to all your chats. Only visible to you.
</p>
<TextareaAutosize
className={textareaClassName}
placeholder="Additional behavior, style, and tone preferences"
value={userPromptDraft}
onChange={(event) => setLocalUserEdit(event.target.value)}
disabled={isDisabled}
minRows={1}
/>
{/* ── Personal prompt (always visible) ── */}
<form
className="space-y-2"
onSubmit={(event) => void handleSaveUserPrompt(event)}
>
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Personal Instructions
</h3>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Applied to all your chats. Only visible to you.
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => setLocalUserEdit("")}
disabled={isDisabled || !userPromptDraft}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isDisabled || !isUserPromptDirty}
>
Save
</Button>
</div>
{isSaveUserPromptError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save personal instructions.
</p>
<TextareaAutosize
className={textareaClassName}
placeholder="Additional behavior, style, and tone preferences"
value={userPromptDraft}
onChange={(event) => setLocalUserEdit(event.target.value)}
disabled={isDisabled}
minRows={1}
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => setLocalUserEdit("")}
disabled={isDisabled || !userPromptDraft}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isDisabled || !isUserPromptDirty}
>
Save
</Button>
</div>
{isSaveUserPromptError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save personal instructions.
</p>
)}
</form>
{/* ── Admin system prompt (admin only) ── */}
{canSetSystemPrompt && (
<>
<hr className="my-5 border-0 border-t border-solid border-border" />
<form
className="space-y-2"
onSubmit={(event) => void handleSaveSystemPrompt(event)}
>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
System Instructions
</h3>
<AdminBadge />
</div>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Applied to all chats for every user. When empty, the
built-in default is used.
</p>
<TextareaAutosize
className={textareaClassName}
placeholder="Additional behavior, style, and tone preferences for all users"
value={systemPromptDraft}
onChange={(event) => setLocalEdit(event.target.value)}
disabled={isDisabled}
minRows={1}
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => setLocalEdit("")}
disabled={isDisabled || !systemPromptDraft}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isDisabled || !isSystemPromptDirty}
>
Save
</Button>
</div>
{isSaveSystemPromptError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save system prompt.
</p>
)}
</form>
</>
)}
</div>
)}
{activeSection === "providers" && canManageChatModelConfigs && (
<div className="flex-1 overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<ChatModelAdminPanel
section="providers"
sectionLabel="Providers"
sectionDescription="Connect third-party LLM services like OpenAI, Anthropic, or Google. Each provider supplies models that users can select for their chats."
sectionBadge={<AdminBadge />}
/>
</div>
)}
{activeSection === "models" && canManageChatModelConfigs && (
<div className="flex-1 overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<ChatModelAdminPanel
section="models"
sectionLabel="Models"
sectionDescription="Choose which models from your configured providers are available for users to select. You can set a default and adjust context limits."
sectionBadge={<AdminBadge />}
/>
</div>
)}
{activeSection === "limits" && canManageChatModelConfigs && (
<LimitsTab />
)}
{activeSection === "usage" && canManageChatModelConfigs && (
<div className="flex-1 overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<UsageContent now={now} />
</div>
)}
</div>
</DialogContent>
</Dialog>
</form>
{/* ── Admin system prompt (admin only) ── */}
{canSetSystemPrompt && (
<>
<hr className="my-5 border-0 border-t border-solid border-border" />
<form
className="space-y-2"
onSubmit={(event) => void handleSaveSystemPrompt(event)}
>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
System Instructions
</h3>
<AdminBadge />
</div>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Applied to all chats for every user. When empty, the
built-in default is used.
</p>
<TextareaAutosize
className={textareaClassName}
placeholder="Additional behavior, style, and tone preferences for all users"
value={systemPromptDraft}
onChange={(event) => setLocalEdit(event.target.value)}
disabled={isDisabled}
minRows={1}
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => setLocalEdit("")}
disabled={isDisabled || !systemPromptDraft}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isDisabled || !isSystemPromptDirty}
>
Save
</Button>
</div>
{isSaveSystemPromptError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save system prompt.
</p>
)}
</form>
</>
)}
</>
)}
{activeSection === "providers" && canManageChatModelConfigs && (
<ChatModelAdminPanel
section="providers"
sectionLabel="Providers"
sectionDescription="Connect third-party LLM services like OpenAI, Anthropic, or Google. Each provider supplies models that users can select for their chats."
sectionBadge={<AdminBadge />}
/>
)}
{activeSection === "models" && canManageChatModelConfigs && (
<ChatModelAdminPanel
section="models"
sectionLabel="Models"
sectionDescription="Choose which models from your configured providers are available for users to select. You can set a default and adjust context limits."
sectionBadge={<AdminBadge />}
/>
)}
{activeSection === "limits" && canManageChatModelConfigs && (
<LimitsTab />
)}
{activeSection === "usage" && canManageChatModelConfigs && (
<UsageContent now={now} />
)}
</div>
</div>
);
};
@@ -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<typeof UserAnalyticsDialog> = {
title: "pages/AgentsPage/UserAnalyticsDialog",
component: UserAnalyticsDialog,
decorators: [withAuthProvider],
parameters: {
user: MockUserOwner,
},
beforeEach: () => {
spyOn(API, "getChatCostSummary").mockResolvedValue(mockSummary);
},
};
export default meta;
type Story = StoryObj<typeof UserAnalyticsDialog>;
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();
});
},
};
@@ -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<UserAnalyticsDialogProps> = ({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl overflow-hidden p-0">
<DialogHeader className="sr-only">
<DialogTitle>Analytics</DialogTitle>
<DialogDescription>
Review your personal chat usage for the last 30 days.
</DialogDescription>
</DialogHeader>
<div className="flex max-h-[min(88dvh,720px)] min-h-0 flex-col overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<div className="mb-6 flex items-start justify-between gap-4">
<SectionHeader
label="Analytics"
description="Review your personal chat usage and cost breakdowns."
/>
<DialogClose asChild>
<Button
variant="subtle"
size="icon-lg"
className="shrink-0 border-none bg-transparent shadow-none hover:bg-surface-tertiary/50"
>
<XIcon className="text-content-secondary" />
<span className="sr-only">Close</span>
</Button>
</DialogClose>
</div>
<div className="mb-4 flex w-fit items-center gap-2 text-xs text-content-secondary">
<BarChart3Icon className="h-4 w-4" />
<span>{dateRange.rangeLabel}</span>
</div>
<ChatCostSummaryView
summary={summaryQuery.data}
isLoading={summaryQuery.isLoading}
error={summaryQuery.error}
onRetry={() => void summaryQuery.refetch()}
loadingLabel="Loading analytics"
emptyMessage="No usage data for you in this period."
/>
</div>
</DialogContent>
</Dialog>
);
};
+3
View File
@@ -645,6 +645,9 @@ export const router = createBrowserRouter(
</Suspense>
}
>
<Route path="settings" />
<Route path="settings/:section" />
<Route path="analytics" />
<Route
path=":agentId"
element={