feat(site): add AI Bridge sessions queries (#23385)

Introduces the query for paginated sessions
This commit is contained in:
Jeremy Ruppel
2026-03-24 20:56:29 -04:00
committed by GitHub
parent 4a0d707bca
commit 210dbb6d98
16 changed files with 815 additions and 3 deletions
+7
View File
@@ -2992,6 +2992,13 @@ class ApiMethods {
return response.data;
};
getAIBridgeSessionList = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams("/api/v2/aibridge/sessions", options);
const response =
await this.axios.get<TypesGen.AIBridgeListSessionsResponse>(url);
return response.data;
};
getAIBridgeModels = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams("/api/v2/aibridge/models", options);
+3 -3
View File
@@ -1,7 +1,7 @@
import { API } from "api/api";
import type { AIBridgeListInterceptionsResponse } from "api/typesGenerated";
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
import { API } from "#/api/api";
import type { AIBridgeListInterceptionsResponse } from "#/api/typesGenerated";
import { useFilterParamsKey } from "#/components/Filter/Filter";
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
export const paginatedInterceptions = (
searchParams: URLSearchParams,
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import {
getDefaultFilterProps,
MockMenu,
} from "#/components/Filter/storyHelpers";
import { ListSessionsFilter } from "./ListSessionsFilter";
type FilterProps = ComponentProps<typeof ListSessionsFilter>;
const defaultFilterProps = getDefaultFilterProps<
Pick<FilterProps, "filter" | "menus">
>({
query: "",
values: {
username: undefined,
provider: undefined,
},
menus: {
user: MockMenu,
provider: MockMenu,
},
});
const meta: Meta<typeof ListSessionsFilter> = {
title: "pages/AIBridgePage/ListSessionsFilter",
component: ListSessionsFilter,
};
export default meta;
type Story = StoryObj<typeof ListSessionsFilter>;
export const Default: Story = {
args: {
...defaultFilterProps,
},
};
export const WithQuery: Story = {
args: {
...getDefaultFilterProps<Pick<FilterProps, "filter" | "menus">>({
query: "initiator:me",
values: {
username: "me",
provider: undefined,
},
menus: {
user: MockMenu,
provider: MockMenu,
},
used: true,
}),
},
};
export const Loading: Story = {
args: {
...defaultFilterProps,
menus: {
user: { ...MockMenu, isInitializing: true },
provider: MockMenu,
},
},
};
@@ -0,0 +1,58 @@
import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter";
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
import {
ProviderFilter,
type ProviderFilterMenu,
} from "pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ProviderFilter";
import type { FC } from "react";
interface ListSessionsFilterProps {
filter: ReturnType<typeof useFilter>;
error?: unknown;
menus: {
user: UserFilterMenu;
provider: ProviderFilterMenu;
};
}
export const ListSessionsFilter: FC<ListSessionsFilterProps> = ({
filter,
error,
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} /> */}
</>
}
/>
</>
);
};
@@ -26,6 +26,13 @@ export const AIBridgeProviderIcon = ({
className={cn(iconClassName, className)}
/>
);
case "anthropic-neue":
return (
<ExternalImage
src="/icon/anthropic.svg"
className={cn(iconClassName, className)}
/>
);
case "copilot":
return (
<ExternalImage
@@ -0,0 +1,58 @@
import { MockSession } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SessionSummaryTable } from "./SessionSummaryTable";
const meta: Meta<typeof SessionSummaryTable> = {
title: "pages/AIBridgePage/SessionSummaryTable",
component: SessionSummaryTable,
};
export default meta;
type Story = StoryObj<typeof SessionSummaryTable>;
export const Default: Story = {
args: {
sessionId: MockSession.id,
startTime: new Date(MockSession.started_at),
endTime: new Date(MockSession.ended_at!),
initiator: MockSession.initiator,
client: MockSession.client ?? "Unknown",
providers: MockSession.providers,
inputTokens: MockSession.token_usage_summary.input_tokens,
outputTokens: MockSession.token_usage_summary.output_tokens,
threadCount: MockSession.threads,
toolCallCount: 12,
},
};
export const InProgress: Story = {
args: {
...Default.args,
endTime: undefined,
},
};
export const MultipleProviders: Story = {
args: {
...Default.args,
providers: ["anthropic", "openai", "copilot"],
},
};
export const WithTokenMetadata: Story = {
args: {
...Default.args,
tokenUsageMetadata: {
cache_read_input_tokens: 3200,
cache_creation_input_tokens: 800,
},
},
};
export const LargeTokenCounts: Story = {
args: {
...Default.args,
inputTokens: 198_000,
outputTokens: 32_000,
},
};
@@ -0,0 +1,143 @@
import type { MinimalUser } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { AIBridgeClientIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon";
import { AIBridgeProviderIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon";
import { formatDateTime } from "utils/time";
import { TokenBadges } from "../TokenBadges";
import { getProviderDisplayName, getProviderIconName } from "../utils";
const Separator = () => (
<div className="border-0 border-t border-solid border-border-content-secondary my-1" />
);
interface SessionSummaryTableProps {
sessionId: string;
startTime: Date;
endTime?: Date;
initiator: MinimalUser;
client: string;
providers: readonly string[];
inputTokens: number;
outputTokens: number;
threadCount: number;
toolCallCount: number;
tokenUsageMetadata?: Record<string, unknown>;
}
export const SessionSummaryTable = ({
sessionId,
startTime,
endTime,
initiator,
providers,
client,
inputTokens,
outputTokens,
threadCount,
toolCallCount,
tokenUsageMetadata,
}: SessionSummaryTableProps) => {
const durationInMs =
endTime != null
? new Date(endTime).getTime() - new Date(startTime).getTime()
: undefined;
return (
<div className="text-sm text-content-secondary flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">Session ID</span>
<span
className="text-content-primary font-mono truncate"
title={sessionId}
>
{sessionId}
</span>
</div>
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">Start time</span>
<span
className="text-content-primary font-mono truncate"
title={formatDateTime(startTime)}
>
{formatDateTime(startTime)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">End time</span>
<span className="text-content-primary font-mono truncate">
{endTime ? formatDateTime(endTime) : "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">Duration</span>
<span
className="text-content-primary font-mono truncate"
title={durationInMs != null ? `${durationInMs} ms` : undefined}
>
{durationInMs != null ? `${Math.round(durationInMs / 1000)} s` : "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">Initiator</span>
<div className="flex items-center gap-2">
<Avatar
size="sm"
src={initiator.avatar_url}
fallback={initiator.name}
/>
<span className="truncate" title={initiator.name}>
{initiator.name}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">Client</span>
<Badge className="gap-1.5 max-w-full">
<div className="flex-shrink-0 flex items-center">
<AIBridgeClientIcon client={client} className="size-icon-xs" />
</div>
<span className="truncate min-w-0" title={client ?? "Unknown"}>
{client ?? "Unknown"}
</span>
</Badge>
</div>
<div className="flex items-start justify-between">
<span className="pr-4 whitespace-nowrap">Provider</span>
<div className="flex flex-col items-end gap-1">
{providers.map((p) => (
<Badge key={p} className="gap-1.5 max-w-full">
<AIBridgeProviderIcon
provider={getProviderIconName(p)}
className="size-icon-xs"
/>
<span
className="truncate min-w-0"
title={getProviderDisplayName(p)}
>
{getProviderDisplayName(p)}
</span>
</Badge>
))}
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">In / out tokens</span>
<TokenBadges
inputTokens={inputTokens}
outputTokens={outputTokens}
tokenUsageMetadata={tokenUsageMetadata}
/>
</div>
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">Threads</span>
<Badge>{threadCount}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">Tool calls</span>
<Badge>{toolCallCount}</Badge>
</div>
</div>
);
};
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AgenticLoopTable } from "./AgenticLoopTable";
const meta: Meta<typeof AgenticLoopTable> = {
title: "pages/AIBridgePage/SessionTimeline/AgenticLoopTable",
component: AgenticLoopTable,
};
export default meta;
type Story = StoryObj<typeof AgenticLoopTable>;
export const Short: Story = {
args: {
duration: 4_200,
toolCalls: 3,
inputTokens: 1234,
outputTokens: 567,
},
};
export const Long: Story = {
args: {
duration: 125_000,
toolCalls: 42,
inputTokens: 150_000,
outputTokens: 12_000,
},
};
export const SingleToolCall: Story = {
args: {
duration: 980,
toolCalls: 1,
inputTokens: 320,
outputTokens: 88,
},
};
export const NoToolCalls: Story = {
args: {
duration: 500,
toolCalls: 0,
inputTokens: 100,
outputTokens: 50,
},
};
@@ -0,0 +1,37 @@
import type { FC } from "react";
import { cn } from "utils/cn";
import { TokenBadges } from "../../TokenBadges";
import { roundDurationDisplay } from "../../utils";
interface AgenticLoopTableProps {
duration: number; // in seconds
toolCalls: number;
inputTokens: number;
outputTokens: number;
className?: string;
}
export const AgenticLoopTable: FC<AgenticLoopTableProps> = ({
duration,
toolCalls,
inputTokens,
outputTokens,
className,
}) => {
return (
<div className={cn(className, "text-sm text-content-secondary")}>
<div className="flex items-center justify-between">
<span className="pr-4">In / out tokens</span>
<TokenBadges inputTokens={inputTokens} outputTokens={outputTokens} />
</div>
<div className="flex items-center justify-between my-2">
<span className="pr-4">Tool calls</span>
<span>{toolCalls}</span>
</div>
<div className="flex items-center justify-between">
<span className="pr-4">Duration</span>
<span title={`${duration}ms`}>{roundDurationDisplay(duration)}</span>
</div>
</div>
);
};
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PromptTable } from "./PromptTable";
const meta: Meta<typeof PromptTable> = {
title: "pages/AIBridgePage/SessionTimeline/PromptTable",
component: PromptTable,
};
export default meta;
type Story = StoryObj<typeof PromptTable>;
export const Anthropic: Story = {
args: {
timestamp: new Date("2025-03-19T14:22:00Z"),
model: "claude-sonnet-4-5",
inputTokens: 1234,
outputTokens: 567,
},
};
export const OpenAI: Story = {
args: {
timestamp: new Date("2025-03-19T14:22:00Z"),
model: "gpt-4o",
inputTokens: 8192,
outputTokens: 1024,
},
};
export const WithTokenMetadata: Story = {
args: {
timestamp: new Date("2025-03-19T14:22:00Z"),
model: "claude-sonnet-4-5",
inputTokens: 5000,
outputTokens: 2500,
tokenUsageMetadata: {
cache_read_input_tokens: 3200,
cache_creation_input_tokens: 800,
},
},
};
export const LargeTokenCounts: Story = {
args: {
timestamp: new Date("2025-03-19T14:22:00Z"),
model: "claude-opus-4-5",
inputTokens: 198_000,
outputTokens: 8_000,
},
};
@@ -0,0 +1,66 @@
import { Badge } from "components/Badge/Badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { AIBridgeModelIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeModelIcon";
import type { FC } from "react";
import { cn } from "utils/cn";
import { formatDate } from "utils/time";
import { TokenBadges } from "../../TokenBadges";
interface PromptTableProps {
timestamp: Date;
model: string;
inputTokens: number;
outputTokens: number;
tokenUsageMetadata?: Record<string, unknown>;
className?: string;
}
export const PromptTable: FC<PromptTableProps> = ({
timestamp,
model,
inputTokens,
outputTokens,
tokenUsageMetadata,
className,
}) => {
return (
<div className={cn(className, "text-sm text-content-secondary")}>
<div className="flex items-center justify-between mb-2">
<span className="pr-4">Timestamp</span>
<span
className="font-mono whitespace-nowrap truncate"
title={formatDate(timestamp)}
>
{formatDate(timestamp)}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="pr-4">Model</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-1.5">
<AIBridgeModelIcon model={model} className="size-icon-xs" />
<span className="truncate min-w-0">{model}</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{model}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center justify-between">
<span className="pr-4">In / out tokens</span>
<TokenBadges
inputTokens={inputTokens}
outputTokens={outputTokens}
tokenUsageMetadata={tokenUsageMetadata}
/>
</div>
</div>
);
};
@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ToolCallTable } from "./ToolCallTable";
const meta: Meta<typeof ToolCallTable> = {
title: "pages/AIBridgePage/SessionTimeline/ToolCallTable",
component: ToolCallTable,
};
export default meta;
type Story = StoryObj<typeof ToolCallTable>;
export const WithMCPServer: Story = {
args: {
timestamp: new Date("2025-03-19T14:22:00Z"),
serverURL: "http://localhost:3000/mcp",
inputTokens: 1234,
outputTokens: 567,
},
};
export const WithoutMCPServer: Story = {
args: {
timestamp: new Date("2025-03-19T14:22:00Z"),
serverURL: "",
inputTokens: 1234,
outputTokens: 567,
},
};
export const WithTokenMetadata: Story = {
args: {
timestamp: new Date("2025-03-19T14:22:00Z"),
serverURL: "https://mcp.example.com/api/v1",
inputTokens: 5000,
outputTokens: 2500,
tokenUsageMetadata: {
cache_read_input_tokens: 3200,
cache_creation_input_tokens: 800,
},
},
};
export const LongServerURL: Story = {
args: {
timestamp: new Date("2025-03-19T14:22:00Z"),
serverURL:
"https://very-long-mcp-server-hostname.internal.example.com/api/v2/mcp/tools",
inputTokens: 800,
outputTokens: 200,
},
};
@@ -0,0 +1,57 @@
import { CopyButton } from "components/CopyButton/CopyButton";
import type { FC } from "react";
import { cn } from "utils/cn";
import { formatDate } from "utils/time";
import { TokenBadges } from "../../TokenBadges";
interface ToolCallTableProps {
timestamp: Date;
serverURL: string;
inputTokens: number;
outputTokens: number;
tokenUsageMetadata?: Record<string, unknown>;
className?: string;
}
export const ToolCallTable: FC<ToolCallTableProps> = ({
timestamp,
serverURL,
inputTokens,
outputTokens,
tokenUsageMetadata,
className,
}) => {
return (
<div
className={cn(
className,
"flex flex-col gap-2 text-xs text-content-secondary",
)}
>
<div className="flex items-center justify-between whitespace-nowrap">
<span className="pr-4">In / out tokens</span>
<TokenBadges
inputTokens={inputTokens}
outputTokens={outputTokens}
tokenUsageMetadata={tokenUsageMetadata}
/>
</div>
<div className="flex items-center justify-between">
<span className="pr-4">Started at</span>
<span
className="font-mono whitespace-nowrap truncate"
title={formatDate(timestamp)}
>
{formatDate(timestamp)}
</span>
</div>
{serverURL && (
<div className="flex items-center justify-between">
<span className="pr-4">MCP server</span>
<span className="font-mono truncate">{serverURL}</span>
<CopyButton text={serverURL} label="Copy MCP server URL" />
</div>
)}
</div>
);
};
@@ -0,0 +1,93 @@
import { Badge } from "components/Badge/Badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
import type { FC } from "react";
import { prettyFormatJSON, roundTokenDisplay } from "./utils";
interface TokenBadgesProps {
size?: "xs" | "sm" | "md";
inputTokens: number;
outputTokens: number;
tokenUsageMetadata?: Record<string, unknown>;
}
export const TokenBadges: FC<TokenBadgesProps> = ({
size = "sm",
inputTokens,
outputTokens,
tokenUsageMetadata,
}) => (
<div className="flex items-center whitespace-nowrap">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Badge className="gap-0 rounded-e-none" size={size}>
<ArrowDownIcon className="size-icon-lg flex-shrink-0" />
<span className="truncate min-w-0">
{roundTokenDisplay(inputTokens)}
</span>
</Badge>
<Badge
className="gap-0 bg-surface-tertiary rounded-s-none"
size={size}
>
<ArrowUpIcon className="size-icon-lg flex-shrink-0" />
<span className="truncate min-w-0">
{roundTokenDisplay(outputTokens)}
</span>
</Badge>
</span>
</TooltipTrigger>
<TooltipContent>
<div className="grid grid-cols-2 gap-8">
<div>
<div className="flex items-center gap-1">
<ArrowDownIcon className="size-icon-sm flex-shrink-0" />
<span className="text-content-primary text-sm">
Input tokens
</span>
</div>
<div className="flex items-center justify-between gap-4">
<div className="text-xs text-content-secondary">Input</div>
<div className="text-xs text-content-secondary">
{roundTokenDisplay(inputTokens)}
</div>
</div>
</div>
<div>
<div className="flex items-center gap-1">
<ArrowUpIcon className="size-icon-sm flex-shrink-0" />
<span className="text-content-primary text-sm">
Output tokens
</span>
</div>
<div className="flex items-center justify-between gap-4">
<div className="text-xs text-content-secondary">Output</div>
<div className="text-xs text-content-secondary">
{roundTokenDisplay(outputTokens)}
</div>
</div>
</div>
</div>
{tokenUsageMetadata && (
<>
<div className="text-content-primary text-sm mt-4">
Token usage metadata
</div>
<pre className="mt-2 p-4 bg-surface-secondary rounded text-xs overflow-x-auto">
{prettyFormatJSON(JSON.stringify(tokenUsageMetadata))}
</pre>
</>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
+49
View File
@@ -0,0 +1,49 @@
export const roundTokenDisplay = (tokens: number) => {
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return tokens.toString();
};
export const roundDurationDisplay = (duration: number) => {
if (duration >= 1000) {
return `${(duration / 1000).toFixed(1)}s`;
}
return `${duration.toFixed(0)}ms`;
};
export const getProviderDisplayName = (provider: string) => {
switch (provider) {
case "anthropic":
return "Anthropic";
case "openai":
return "OpenAI";
case "copilot":
return "Github";
default:
return "Unknown";
}
};
// FIXME the current AIBridgeProviderIcon uses the claude icon for the
// anthropic provider. while it's still in use in the RequestLogsPage, we need
// to hack around it here, but when we delete that page, we can just swap the
// icon
export const getProviderIconName = (provider: string) => {
if (provider === "anthropic") {
return "anthropic-neue";
}
return provider;
};
export const prettyFormatJSON = (input: string) => {
let formattedInput = input;
try {
formattedInput = JSON.stringify(JSON.parse(input), null, 2);
} catch {
// not JSON, use as-is
}
return formattedInput;
};
+26
View File
@@ -5279,3 +5279,29 @@ export const MockInterceptionCopilot: TypesGen.AIBridgeInterception = {
},
],
};
export const MockSession: TypesGen.AIBridgeSession = {
id: "c8f2df8c-149c-43e1-9d51-898daaa2c505",
initiator: {
id: "59da0bfe-9c99-47fa-a563-f9fdb18449d0",
username: "bob",
name: "The Builder, Bob",
avatar_url:
"https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQeDqc5b7Ny5bJOKxDeFvy17kBQ2_ZmBE8vKw&s",
},
providers: ["anthropic", "openai"],
models: ["claude-opus-4-6", "gpt-5.4"],
client: "Mux",
metadata: {
request_user_agent:
"mux/0.20.1-next.8.g0f494106 ai-sdk/anthropic/3.0.58 ai-sdk/provider-utils/4.0.19 runtime/node.js/22",
},
started_at: "2026-03-09T09:28:15.03152Z",
ended_at: "2026-03-09T10:28:17.294897Z",
threads: 17,
token_usage_summary: {
input_tokens: 1234,
output_tokens: 4321,
},
last_prompt: "But *can* I really fix it?",
};