feat(site): add AI sessions list page (#23388)

<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->

Adds the AI Bridge sessions list page.
This commit is contained in:
Jeremy Ruppel
2026-03-24 22:01:10 -04:00
committed by GitHub
parent 210dbb6d98
commit 8bd87f8588
9 changed files with 657 additions and 34 deletions
+22 -1
View File
@@ -1,5 +1,8 @@
import { API } from "#/api/api";
import type { AIBridgeListInterceptionsResponse } from "#/api/typesGenerated";
import type {
AIBridgeListInterceptionsResponse,
AIBridgeListSessionsResponse,
} from "#/api/typesGenerated";
import { useFilterParamsKey } from "#/components/Filter/Filter";
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
@@ -20,3 +23,21 @@ export const paginatedInterceptions = (
}),
};
};
export const paginatedSessions = (
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<AIBridgeListSessionsResponse, string> => {
return {
searchParams,
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
queryKey: ({ payload, pageNumber }) => {
return ["aiBridgeSessions", payload, pageNumber] as const;
},
queryFn: ({ offset, limit, payload }) =>
API.getAIBridgeSessionList({
offset,
limit,
q: payload,
}),
};
};
@@ -0,0 +1,36 @@
import { Link } from "components/Link/Link";
import { Margins } from "components/Margins/Margins";
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader";
import type { FC, PropsWithChildren } from "react";
import { Outlet } from "react-router";
const AIBridgeSessionsLayout: FC<PropsWithChildren> = () => {
return (
<Margins className="pb-12">
<PageHeader>
<PageHeaderTitle>
<div className="flex items-center gap-2">
<span>AI Sessions</span>
</div>
</PageHeaderTitle>
<PageHeaderSubtitle>
Centralized auditing for LLM usage across your organization.{" "}
<Link
href="https://coder.com/docs/ai-coder/ai-governance"
className="ml-auto"
target="_blank"
>
More about AI Governance
</Link>
</PageHeaderSubtitle>
</PageHeader>
<Outlet />
</Margins>
);
};
export default AIBridgeSessionsLayout;
@@ -21,38 +21,29 @@ export const ListSessionsFilter: FC<ListSessionsFilterProps> = ({
menus,
}) => {
return (
<>
<div className="mb-4 flex items-center justify-end">
<span className="mr-2 text-content-secondary">Organization:</span>
{/* TODO: add organization filter */}
{/* <OrganizationAutocomplete */}
{/* onChange={(org) => console.info("Selected organization", org)} */}
{/* /> */}
</div>
<Filter
filter={filter}
optionsSkeleton={<MenuSkeleton />}
isLoading={menus.user.isInitializing}
presets={[
{
name: "All sessions",
query: "",
},
{
name: "My sessions",
query: "initiator:me",
},
]}
error={error}
options={
<>
<UserMenu menu={menus.user} placeholder="All users" />
<ProviderFilter menu={menus.provider} />
{/* TODO: add client filter */}
{/* <ClientFilter menu={menus.client} /> */}
</>
}
/>
</>
<Filter
filter={filter}
optionsSkeleton={<MenuSkeleton />}
isLoading={menus.user.isInitializing}
presets={[
{
name: "All sessions",
query: "",
},
{
name: "My sessions",
query: "initiator:me",
},
]}
error={error}
options={
<>
<UserMenu menu={menus.user} placeholder="All users" />
<ProviderFilter menu={menus.provider} />
{/* TODO: add client filter */}
{/* <ClientFilter menu={menus.client} /> */}
</>
}
/>
);
};
@@ -0,0 +1,85 @@
import { paginatedSessions } from "api/queries/aiBridge";
import { useFilter } from "components/Filter/Filter";
import { useUserFilterMenu } from "components/Filter/UserFilter";
import { useAuthenticated } from "hooks";
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
import { useDashboard } from "modules/dashboard/useDashboard";
import { RequirePermission } from "modules/permissions/RequirePermission";
import type { FC } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { pageTitle } from "utils/page";
import { useProviderFilterMenu } from "../RequestLogsPage/RequestLogsFilter/ProviderFilter";
import { ListSessionsPageView } from "./ListSessionsPageView";
const AISessionListPage: FC = () => {
const { permissions } = useAuthenticated();
const { entitlements } = useDashboard();
const navigate = useNavigate();
// Users are allowed to view their own request logs via the API,
// but this page is only visible if the feature is enabled and the user
// has the `viewAnyAIBridgeInterception` permission.
// (as its defined in the Admin settings dropdown).
const isEntitled =
entitlements.features.aibridge.entitlement === "entitled" ||
entitlements.features.aibridge.entitlement === "grace_period";
const isEnabled = entitlements.features.aibridge.enabled;
const hasPermission = permissions.viewAnyAIBridgeInterception;
const canViewSessions = isEntitled && hasPermission;
const [searchParams, setSearchParams] = useSearchParams();
const sessionsQuery = usePaginatedQuery({
...paginatedSessions(searchParams),
enabled: canViewSessions,
});
const filter = useFilter({
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: sessionsQuery.goToFirstPage,
});
const userMenu = useUserFilterMenu({
value: filter.values.initiator,
onChange: (option) =>
filter.update({
...filter.values,
initiator: option?.value,
}),
});
const providerMenu = useProviderFilterMenu({
value: filter.values.provider,
onChange: (option) =>
filter.update({
...filter.values,
provider: option?.value,
}),
});
return (
<RequirePermission isFeatureVisible={hasPermission}>
<title>{pageTitle("Sessions", "AI Bridge")}</title>
<ListSessionsPageView
isLoading={sessionsQuery.isLoading}
isAISessionsEntitled={isEntitled}
isAISessionsEnabled={isEnabled}
sessions={sessionsQuery.data?.sessions}
sessionsQuery={sessionsQuery}
onSessionRowClick={(sessionId) =>
navigate(`/aibridge/sessions/${sessionId}`)
}
filterProps={{
filter,
error: sessionsQuery.error,
menus: {
user: userMenu,
provider: providerMenu,
},
}}
/>
</RequirePermission>
);
};
export default AISessionListPage;
@@ -0,0 +1,99 @@
import { MockSession } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
getDefaultFilterProps,
MockMenu,
} from "components/Filter/storyHelpers";
import {
mockInitialRenderResult,
mockSuccessResult,
} from "components/PaginationWidget/PaginationContainer.mocks";
import type { ComponentProps } from "react";
import { fn } from "storybook/test";
import { ListSessionsPageView } from "./ListSessionsPageView";
type FilterProps = ComponentProps<typeof ListSessionsPageView>["filterProps"];
const defaultFilterProps = getDefaultFilterProps<FilterProps>({
query: "owner:me",
values: {
username: undefined,
provider: undefined,
},
menus: {
user: MockMenu,
provider: MockMenu,
model: MockMenu,
},
});
const meta: Meta<typeof ListSessionsPageView> = {
title: "pages/AIBridgePage/ListSessionsPageView",
component: ListSessionsPageView,
args: {
isLoading: false,
isAISessionsEntitled: true,
isAISessionsEnabled: true,
filterProps: defaultFilterProps,
sessionsQuery: mockSuccessResult,
onSessionRowClick: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ListSessionsPageView>;
export const Paywall: Story = {
args: {
isAISessionsEntitled: false,
isAISessionsEnabled: false,
},
};
export const NotEnabled: Story = {
args: {
isAISessionsEntitled: true,
isAISessionsEnabled: false,
},
};
export const Loading: Story = {
args: {
isLoading: true,
sessions: undefined,
sessionsQuery: mockInitialRenderResult,
},
};
export const Empty: Story = {
args: {
sessions: [],
},
};
export const Loaded: Story = {
args: {
sessions: [MockSession],
},
};
export const MultipleSessions: Story = {
args: {
sessions: Array.from({ length: 5 }, (_, i) => ({
...MockSession,
id: `session-${i}`,
threads: i + 1,
last_prompt: [
"But *can* I really fix it?",
"Can you refactor the entire authentication module to use JWT tokens instead of session cookies?",
"What's the best way to handle errors in Go?",
"Help me write a Terraform module for a Kubernetes cluster.",
"Explain how the agentic loop works in this codebase.",
][i],
token_usage_summary: {
input_tokens: 1000 * (i + 1),
output_tokens: 300 * (i + 1),
},
})),
},
};
@@ -0,0 +1,131 @@
import type { AIBridgeSession } from "api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert";
import { Link } from "components/Link/Link";
import {
PaginationContainer,
type PaginationResult,
} from "components/PaginationWidget/PaginationContainer";
import { PaywallAIGovernance } from "components/Paywall/PaywallAIGovernance";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import { TableEmpty } from "components/TableEmpty/TableEmpty";
import { TableLoader } from "components/TableLoader/TableLoader";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { InfoIcon } from "lucide-react";
import type { ComponentProps, FC, PropsWithChildren } from "react";
import { docs } from "utils/docs";
import { DATE_FORMAT, formatDateTime } from "utils/time";
import { ListSessionsFilter } from "./ListSessionsFilter";
import { ListSessionsRow } from "./ListSessionsRow";
interface ListSessionsPageViewProps {
isLoading: boolean;
isAISessionsEntitled: boolean;
isAISessionsEnabled: boolean;
sessions?: readonly AIBridgeSession[];
sessionsQuery: PaginationResult;
filterProps: ComponentProps<typeof ListSessionsFilter>;
onSessionRowClick?: (sessionId: string) => void;
}
const ThreadTooltip: FC<PropsWithChildren> = ({ children }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0 flex items-center">{children}</div>
</TooltipTrigger>
<TooltipContent side="top" align="end" className="max-w-xs">
<p className="text-sm">
A thread is a multi-part interaction between human and agent involving
an initial human prompt and a subsequent agentic loop.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export const ListSessionsPageView: FC<ListSessionsPageViewProps> = ({
isLoading,
isAISessionsEntitled,
isAISessionsEnabled,
sessions,
sessionsQuery,
filterProps,
onSessionRowClick,
}) => {
if (!isAISessionsEntitled) {
return <PaywallAIGovernance />;
}
if (!isAISessionsEnabled) {
return (
<Alert className="mb-12" severity="warning" prominent>
<AlertTitle>
AI Bridge is included in your license, but not set up yet.
</AlertTitle>
<AlertDescription>
You have access to AI Governance, but it still needs to be setup.
Check out the{" "}
<Link href={docs("/ai-coder/ai-bridge")} target="_blank">
AI Bridge
</Link>{" "}
documentation to get started.
</AlertDescription>
</Alert>
);
}
const utcOffset = formatDateTime(new Date(), DATE_FORMAT.UTC_OFFSET);
return (
<>
<ListSessionsFilter {...filterProps} />
<PaginationContainer query={sessionsQuery} paginationUnitLabel="sessions">
<Table className="text-sm">
<TableHeader>
<TableRow className="text-xs">
<TableHead>Last Prompt</TableHead>
<TableHead>User</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Client</TableHead>
<TableHead>In/Out Tokens</TableHead>
<TableHead className="flex items-center gap-1">
Threads
<ThreadTooltip>
<InfoIcon className="size-icon-xs" />
</ThreadTooltip>
</TableHead>
<TableHead>Timestamp [UTC{utcOffset}]</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableLoader />
) : sessions?.length === 0 ? (
<TableEmpty message="No session logs available" />
) : (
sessions?.map((session) => (
<ListSessionsRow
session={session}
key={session.id}
onClick={() => onSessionRowClick?.(session.id)}
/>
))
)}
</TableBody>
</Table>
</PaginationContainer>
</>
);
};
@@ -0,0 +1,80 @@
import { MockSession } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Table, TableBody } from "components/Table/Table";
import { fn } from "storybook/test";
import { ListSessionsRow } from "./ListSessionsRow";
const meta: Meta<typeof ListSessionsRow> = {
title: "pages/AIBridgePage/ListSessionsRow",
component: ListSessionsRow,
args: {
onClick: fn(),
},
decorators: [
(Story) => (
<Table>
<TableBody>
<Story />
</TableBody>
</Table>
),
],
};
export default meta;
type Story = StoryObj<typeof ListSessionsRow>;
export const Default: Story = {
args: {
session: MockSession,
},
};
export const NullClient: Story = {
args: {
session: { ...MockSession, client: null },
},
};
export const NoInitiatorName: Story = {
args: {
session: {
...MockSession,
initiator: { ...MockSession.initiator, name: "" },
},
},
};
export const LongPrompt: Story = {
args: {
session: {
...MockSession,
last_prompt:
"Can you refactor the entire authentication module to use JWT tokens instead of session cookies, and also update all the tests, documentation, and CI pipelines while you're at it?",
},
},
};
export const NoPrompt: Story = {
args: {
session: { ...MockSession, last_prompt: undefined },
},
};
export const ManyThreads: Story = {
args: {
session: { ...MockSession, threads: 128 },
},
};
export const LargeTokenCounts: Story = {
args: {
session: {
...MockSession,
token_usage_summary: {
input_tokens: 198_000,
output_tokens: 32_000,
},
},
},
};
@@ -0,0 +1,168 @@
import type { AIBridgeSession } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { TableCell, TableRow } from "components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon } from "lucide-react";
import { AIBridgeClientIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon";
import { AIBridgeProviderIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon";
import type { FC } from "react";
import { DATE_FORMAT, formatDateTime } from "utils/time";
import {
getProviderDisplayName,
getProviderIconName,
roundTokenDisplay,
} from "../utils";
type ListSessionsRowProps = {
session: AIBridgeSession;
onClick?: () => void;
};
export const ListSessionsRow: FC<ListSessionsRowProps> = ({
session,
onClick,
}) => {
return (
<TableRow
hover
className="cursor-pointer"
onClick={() => {
onClick?.();
}}
>
<TableCell className="max-w-32 flex-1 overflow-auto">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className="truncate">{session.last_prompt}</p>
</TooltipTrigger>
<TooltipContent className="max-w-64">
{session.last_prompt}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="w-48 max-w-48">
<div className="w-full min-w-0 overflow-hidden">
<div className="flex items-center gap-3 min-w-0">
<Avatar
fallback={session.initiator.username}
src={session.initiator.avatar_url}
size="lg"
className="flex-shrink-0"
/>
<div className="font-medium truncate min-w-0 flex-1 overflow-hidden">
{session.initiator.name ?? session.initiator.username}
</div>
</div>
</div>
</TableCell>
<TableCell className="w-40 max-w-40">
<div className="min-w-0 overflow-hidden">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-1.5 max-w-full">
<div className="flex-shrink-0 flex items-center">
<AIBridgeProviderIcon
provider={getProviderIconName(session.providers[0])}
className="size-icon-xs"
/>
</div>
<span className="truncate min-w-0">
{getProviderDisplayName(session.providers[0])}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>
{getProviderDisplayName(session.providers[0])}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
<TableCell className="w-40 max-w-40">
<div className="min-w-0 overflow-hidden">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-1.5 max-w-full">
<div className="flex-shrink-0 flex items-center">
<AIBridgeClientIcon
client={session.client}
className="size-icon-xs"
/>
</div>
<span className="truncate min-w-0">
{session.client ?? "Unknown"}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{session.client}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
<TableCell className="w-32">
<div className="flex items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-0 rounded-e-none">
<ArrowDownIcon className="size-icon-lg flex-shrink-0" />
<span className="truncate min-w-0 w-full">
{roundTokenDisplay(
session.token_usage_summary.input_tokens,
)}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>
{session.token_usage_summary.input_tokens} Input Tokens
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-0 bg-surface-tertiary rounded-s-none">
<ArrowUpIcon className="size-icon-lg flex-shrink-0" />
<span className="truncate min-w-0 w-full">
{roundTokenDisplay(
session.token_usage_summary.output_tokens,
)}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>
{session.token_usage_summary.output_tokens} Output Tokens
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
<TableCell className="w-32">
<Badge className="bg-surface-secondary align-end">
{session.threads}
</Badge>
</TableCell>
<TableCell className="w-48 whitespace-nowrap">
<div className="flex items-center justify-between">
<span>
{formatDateTime(
new Date(session.started_at),
DATE_FORMAT.FULL_DATETIME,
)}
</span>
<ChevronRightIcon className="ml-4" />
</div>
</TableCell>
</TableRow>
);
};
+12
View File
@@ -374,6 +374,14 @@ const AIBridgeRequestLogsPage = lazy(
() => import("./pages/AIBridgePage/RequestLogsPage/RequestLogsPage"),
);
const AIBridgeSessionsLayout = lazy(
() => import("./pages/AIBridgePage/AIBridgeSessionsLayout"),
);
const AIBridgeListSessionsPage = lazy(
() => import("./pages/AIBridgePage/ListSessionsPage/ListSessionsPage"),
);
const GlobalLayout = () => {
return (
<Suspense fallback={<Loader fullscreen />}>
@@ -609,6 +617,10 @@ export const router = createBrowserRouter(
<Route path="request-logs" element={<AIBridgeRequestLogsPage />} />
</Route>
<Route path="/aibridge/sessions" element={<AIBridgeSessionsLayout />}>
<Route index element={<AIBridgeListSessionsPage />} />
</Route>
<Route path="/health" element={<HealthLayout />}>
<Route index element={<Navigate to="access-url" replace />} />
<Route path="access-url" element={<AccessURLPage />} />