From 8bd87f8588caf94070eae2d8b242839d2def4a00 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Tue, 24 Mar 2026 22:01:10 -0400 Subject: [PATCH] feat(site): add AI sessions list page (#23388) Adds the AI Bridge sessions list page. --- site/src/api/queries/aiBridge.ts | 23 ++- .../AIBridgePage/AIBridgeSessionsLayout.tsx | 36 ++++ .../ListSessionsPage/ListSessionsFilter.tsx | 57 +++--- .../ListSessionsPage/ListSessionsPage.tsx | 85 +++++++++ .../ListSessionsPageView.stories.tsx | 99 +++++++++++ .../ListSessionsPage/ListSessionsPageView.tsx | 131 ++++++++++++++ .../ListSessionsRow.stories.tsx | 80 +++++++++ .../ListSessionsPage/ListSessionsRow.tsx | 168 ++++++++++++++++++ site/src/router.tsx | 12 ++ 9 files changed, 657 insertions(+), 34 deletions(-) create mode 100644 site/src/pages/AIBridgePage/AIBridgeSessionsLayout.tsx create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx diff --git a/site/src/api/queries/aiBridge.ts b/site/src/api/queries/aiBridge.ts index b44bd62bb8..96f417f3ee 100644 --- a/site/src/api/queries/aiBridge.ts +++ b/site/src/api/queries/aiBridge.ts @@ -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 => { + 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, + }), + }; +}; diff --git a/site/src/pages/AIBridgePage/AIBridgeSessionsLayout.tsx b/site/src/pages/AIBridgePage/AIBridgeSessionsLayout.tsx new file mode 100644 index 0000000000..9d4b44c6cd --- /dev/null +++ b/site/src/pages/AIBridgePage/AIBridgeSessionsLayout.tsx @@ -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 = () => { + return ( + + + +
+ AI Sessions +
+
+ + Centralized auditing for LLM usage across your organization.{" "} + + More about AI Governance + + +
+ +
+ ); +}; + +export default AIBridgeSessionsLayout; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx index 364777dbc4..fd1279092b 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx @@ -21,38 +21,29 @@ export const ListSessionsFilter: FC = ({ menus, }) => { return ( - <> -
- Organization: - {/* TODO: add organization filter */} - {/* console.info("Selected organization", org)} */} - {/* /> */} -
- } - isLoading={menus.user.isInitializing} - presets={[ - { - name: "All sessions", - query: "", - }, - { - name: "My sessions", - query: "initiator:me", - }, - ]} - error={error} - options={ - <> - - - {/* TODO: add client filter */} - {/* */} - - } - /> - + } + isLoading={menus.user.isInitializing} + presets={[ + { + name: "All sessions", + query: "", + }, + { + name: "My sessions", + query: "initiator:me", + }, + ]} + error={error} + options={ + <> + + + {/* TODO: add client filter */} + {/* */} + + } + /> ); }; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx new file mode 100644 index 0000000000..6282a0016a --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx @@ -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 ( + + {pageTitle("Sessions", "AI Bridge")} + + + navigate(`/aibridge/sessions/${sessionId}`) + } + filterProps={{ + filter, + error: sessionsQuery.error, + menus: { + user: userMenu, + provider: providerMenu, + }, + }} + /> + + ); +}; + +export default AISessionListPage; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx new file mode 100644 index 0000000000..3bed9090fb --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx @@ -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["filterProps"]; + +const defaultFilterProps = getDefaultFilterProps({ + query: "owner:me", + values: { + username: undefined, + provider: undefined, + }, + menus: { + user: MockMenu, + provider: MockMenu, + model: MockMenu, + }, +}); + +const meta: Meta = { + 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; + +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), + }, + })), + }, +}; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx new file mode 100644 index 0000000000..9086406cd7 --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx @@ -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; + onSessionRowClick?: (sessionId: string) => void; +} + +const ThreadTooltip: FC = ({ children }) => ( + + + +
{children}
+
+ +

+ A thread is a multi-part interaction between human and agent involving + an initial human prompt and a subsequent agentic loop. +

+
+
+
+); + +export const ListSessionsPageView: FC = ({ + isLoading, + isAISessionsEntitled, + isAISessionsEnabled, + sessions, + sessionsQuery, + filterProps, + onSessionRowClick, +}) => { + if (!isAISessionsEntitled) { + return ; + } + + if (!isAISessionsEnabled) { + return ( + + + AI Bridge is included in your license, but not set up yet. + + + You have access to AI Governance, but it still needs to be setup. + Check out the{" "} + + AI Bridge + {" "} + documentation to get started. + + + ); + } + + const utcOffset = formatDateTime(new Date(), DATE_FORMAT.UTC_OFFSET); + + return ( + <> + + + + + + + Last Prompt + User + Provider + Client + In/Out Tokens + + Threads + + + + + Timestamp [UTC{utcOffset}] + + + + {isLoading ? ( + + ) : sessions?.length === 0 ? ( + + ) : ( + sessions?.map((session) => ( + onSessionRowClick?.(session.id)} + /> + )) + )} + +
+
+ + ); +}; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx new file mode 100644 index 0000000000..2ec9c72633 --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx @@ -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 = { + title: "pages/AIBridgePage/ListSessionsRow", + component: ListSessionsRow, + args: { + onClick: fn(), + }, + decorators: [ + (Story) => ( + + + + +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +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, + }, + }, + }, +}; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx new file mode 100644 index 0000000000..8c5fd8005e --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx @@ -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 = ({ + session, + onClick, +}) => { + return ( + { + onClick?.(); + }} + > + + + + +

{session.last_prompt}

+
+ + {session.last_prompt} + +
+
+
+ +
+
+ +
+ {session.initiator.name ?? session.initiator.username} +
+
+
+
+ +
+ + + + +
+ +
+ + {getProviderDisplayName(session.providers[0])} + +
+
+ + {getProviderDisplayName(session.providers[0])} + +
+
+
+
+ +
+ + + + +
+ +
+ + {session.client ?? "Unknown"} + +
+
+ {session.client} +
+
+
+
+ +
+ + + + + + + {roundTokenDisplay( + session.token_usage_summary.input_tokens, + )} + + + + + {session.token_usage_summary.input_tokens} Input Tokens + + + + + + + + + + {roundTokenDisplay( + session.token_usage_summary.output_tokens, + )} + + + + + {session.token_usage_summary.output_tokens} Output Tokens + + + +
+
+ + + {session.threads} + + + +
+ + {formatDateTime( + new Date(session.started_at), + DATE_FORMAT.FULL_DATETIME, + )} + + +
+
+
+ ); +}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 61324cf088..ed9a8e8090 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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 ( }> @@ -609,6 +617,10 @@ export const router = createBrowserRouter( } /> + }> + } /> + + }> } /> } />