mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): move Settings and Analytics from dialogs to sidebar sub-navigation (#23126)
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
+133
-276
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -645,6 +645,9 @@ export const router = createBrowserRouter(
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<Route path="settings" />
|
||||
<Route path="settings/:section" />
|
||||
<Route path="analytics" />
|
||||
<Route
|
||||
path=":agentId"
|
||||
element={
|
||||
|
||||
Reference in New Issue
Block a user