feat(site): add AI session thread page (#23391)

Adds the Session Thread page

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jake Howell <jacob@coder.com>
This commit is contained in:
Jeremy Ruppel
2026-03-30 08:03:52 -04:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Jake Howell
parent 7d0a49f54b
commit 548a648dcb
16 changed files with 1133 additions and 109 deletions
+13
View File
@@ -3013,6 +3013,19 @@ class ApiMethods {
return response.data;
};
getAIBridgeSessionThreads = async (
sessionId: string,
options?: { after_id?: string; before_id?: string; limit?: number },
) => {
const url = getURLWithSearchParams(
`/api/v2/aibridge/sessions/${sessionId}`,
options,
);
const response =
await this.axios.get<TypesGen.AIBridgeSessionThreadsResponse>(url);
return response.data;
};
getAIBridgeModels = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams("/api/v2/aibridge/models", options);
+23
View File
@@ -1,11 +1,15 @@
import type { UseInfiniteQueryOptions } from "react-query";
import { API } from "#/api/api";
import type {
AIBridgeListInterceptionsResponse,
AIBridgeListSessionsResponse,
AIBridgeSessionThreadsResponse,
} from "#/api/typesGenerated";
import { useFilterParamsKey } from "#/components/Filter/Filter";
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
const SESSION_THREADS_INFINITE_PAGE_SIZE = 20;
export const paginatedInterceptions = (
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<AIBridgeListInterceptionsResponse, string> => {
@@ -41,3 +45,22 @@ export const paginatedSessions = (
}),
};
};
export const infiniteSessionThreads = (sessionId: string) => {
return {
queryKey: ["aiBridgeSessionThreads", sessionId],
getNextPageParam: (lastPage: AIBridgeSessionThreadsResponse) => {
const threads = lastPage.threads;
if (threads.length < SESSION_THREADS_INFINITE_PAGE_SIZE) {
return undefined;
}
return threads.at(-1)?.id;
},
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam }) =>
API.getAIBridgeSessionThreads(sessionId, {
limit: SESSION_THREADS_INFINITE_PAGE_SIZE,
after_id: pageParam as string | undefined,
}),
} satisfies UseInfiniteQueryOptions<AIBridgeSessionThreadsResponse>;
};
+8
View File
@@ -57,6 +57,10 @@
--highlight-sky: 195, 61%, 22%;
--highlight-red: 0 74% 42%;
--highlight-magenta: 295, 68%, 40%;
--syntax-key: 211 95% 33%;
--syntax-string: 0 77% 36%;
--syntax-number: 158 88% 28%;
--syntax-boolean: 240 100% 50%;
--git-added: 142 72% 29%;
--git-deleted: 0 74% 42%;
--git-modified: 17 88% 40%;
@@ -125,6 +129,10 @@
--highlight-sky: 188, 75%, 80%;
--highlight-red: 0 91% 71%;
--highlight-magenta: 292, 100%, 78%;
--syntax-key: 201 98% 80%;
--syntax-string: 18 47% 64%;
--syntax-number: 100 28% 73%;
--syntax-boolean: 207 61% 59%;
--git-added: 142 77% 73%;
--git-deleted: 0 94% 82%;
--git-modified: 31 97% 72%;
@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { JsonPrettyPrinter } from "./JsonPrettyPrinter";
const PreviewBlock: FC<{ input: string; tool?: string }> = ({
input,
tool,
}) => (
<pre className="p-4 bg-surface-secondary rounded text-xs overflow-x-auto">
{tool} <JsonPrettyPrinter input={input} />
</pre>
);
const meta: Meta<typeof PreviewBlock> = {
title: "pages/AIBridgePage/JsonPrettyPrinter",
component: PreviewBlock,
};
export default meta;
type Story = StoryObj<typeof PreviewBlock>;
export const FlatObject: Story = {
args: {
input: JSON.stringify({
name: "claude-opus-4-5",
provider: "anthropic",
max_tokens: 4096,
streaming: true,
}),
},
};
export const NestedObject: Story = {
args: {
input: JSON.stringify({
model: "claude-opus-4-5",
usage: {
input_tokens: 1234,
output_tokens: 567,
cache_read_input_tokens: 800,
cache_creation_input_tokens: 200,
},
stop_reason: "end_turn",
stop_sequence: null,
}),
},
};
export const ArrayOfObjects: Story = {
args: {
input: JSON.stringify([
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
{ role: "user", content: "How are you?" },
]),
},
};
export const MixedTypes: Story = {
args: {
input: JSON.stringify({
string_value: "hello world",
number_value: 42,
float_value: 3.14,
bool_true: true,
bool_false: false,
null_value: null,
array_value: [1, "two", true, null],
}),
},
};
export const EmptyObject: Story = {
args: {
input: JSON.stringify({}),
},
};
export const EmptyArray: Story = {
args: {
input: JSON.stringify([]),
},
};
export const InvalidJSON: Story = {
args: {
input: "not valid json {",
},
};
export const DeepNesting: Story = {
args: {
input: JSON.stringify({
level1: {
level2: {
level3: {
value: "deep",
count: 3,
},
},
},
}),
},
};
export const WithToolName: Story = {
args: {
input: JSON.stringify({
pattern: "UTC_OFFSET|timeZoneName|DateTimeFormat",
path: "/home/coder/coder/site/src/utils/time.ts",
output_mode: "content",
}),
tool: "Grep",
},
};
@@ -0,0 +1,63 @@
import { type FC, Fragment, type ReactNode } from "react";
const formatJSONValue = (value: unknown, depth: number): ReactNode => {
switch (typeof value) {
case "boolean":
return <span className="text-syntax-boolean">{String(value)}</span>;
case "number":
return <span className="text-syntax-number">{value}</span>;
case "string":
return <span className="text-syntax-string">"{value}"</span>;
case "object":
if (value === null) return "null";
}
const pad = " ".repeat(depth);
const inner = " ".repeat(depth + 1);
if (Array.isArray(value)) {
if (value.length === 0) return "[]";
return (
<>
{/* biome-ignore lint/style/useConsistentCurlyBraces: \n requires a JS string literal */}
{"[\n"}
{value.map((v, i) => (
<Fragment key={i}>
{inner}
{formatJSONValue(v, depth + 1)}
{i < value.length - 1 ? ",\n" : "\n"}
</Fragment>
))}
{pad}]
</>
);
}
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return "{}";
return (
<>
{/* biome-ignore lint/style/useConsistentCurlyBraces: \n requires a JS string literal */}
{"{\n"}
{entries.map(([k, v], i) => (
<Fragment key={k}>
{inner}
<span className="text-syntax-key">"{k}"</span>
{/* biome-ignore lint/style/useConsistentCurlyBraces: keeps spacing explicit */}
{": "}
{formatJSONValue(v, depth + 1)}
{i < entries.length - 1 ? ",\n" : "\n"}
</Fragment>
))}
{pad}
{"}"}
</>
);
};
// input is not guaranteed to be valid JSON, so we need to catch any errors
// and return the original string if it is not valid
export const JsonPrettyPrinter: FC<{ input: string }> = ({ input }) => {
try {
return formatJSONValue(JSON.parse(input), 0);
} catch {
return input;
}
};
@@ -3,6 +3,7 @@ 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 { cn } from "#/utils/cn";
import { formatDateTime } from "#/utils/time";
import { TokenBadges } from "../TokenBadges";
import { getProviderDisplayName, getProviderIconName } from "../utils";
@@ -39,105 +40,108 @@ export const SessionSummaryTable = ({
tokenUsageMetadata,
}: SessionSummaryTableProps) => {
const durationInMs =
endTime != null
endTime !== undefined
? 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">
<dl
className={cn(
"text-xs text-content-secondary m-0",
"grid grid-cols-[auto_1fr] gap-y-0.5 [&_dd]:ml-0 [&_dd]:text-content-primary",
"[&_dd]:h-6 [&_dd]:flex [&_dd]:min-w-0 [&_dd]:items-center [&_dd]:justify-end",
"[&_dt]:h-6 [&_dt]:inline-flex [&_dt]:items-center [&_dt]:font-normal",
)}
>
<dt>Session ID</dt>
<dd className="font-mono min-w-0" title={sessionId}>
<span className="truncate w-full text-right">{sessionId}</span>
</dd>
<dt>Start time</dt>
<dd className="font-mono" title={formatDateTime(startTime)}>
{formatDateTime(startTime)}
</dd>
<dt>End time</dt>
<dd className="font-mono">{endTime ? formatDateTime(endTime) : "—"}</dd>
<dt>Duration</dt>
<dd
className="font-mono"
title={durationInMs !== undefined ? `${durationInMs} ms` : undefined}
>
{durationInMs !== undefined
? `${Math.round(durationInMs / 1000)} s`
: "—"}
</dd>
<dt>Initiator</dt>
<dd>
<div className="flex w-full min-w-0 items-center justify-end gap-2">
<Avatar
size="sm"
src={initiator.avatar_url}
fallback={initiator.name}
/>
<span className="truncate" title={initiator.name}>
<span className="truncate min-w-0 text-right" 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">
</dd>
<dt>Client</dt>
<dd>
<Badge className="gap-1.5 max-w-full min-w-0 overflow-hidden">
<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"}>
<span className="truncate min-w-0 flex-1" title={client ?? "Unknown"}>
{client ?? "Unknown"}
</span>
</Badge>
</dd>
<dt className="self-start">Provider</dt>
<dd>
{providers.map((p) => (
<Badge key={p} className="gap-1.5 max-w-full min-w-0 overflow-hidden">
<AIBridgeProviderIcon
provider={getProviderIconName(p)}
className="size-icon-xs"
/>
<span
className="truncate min-w-0 flex-1"
title={getProviderDisplayName(p)}
>
{getProviderDisplayName(p)}
</span>
</Badge>
))}
</dd>
<div className="col-span-2">
<Separator />
</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>
<dt>In / out tokens</dt>
<dd>
<TokenBadges
inputTokens={inputTokens}
outputTokens={outputTokens}
tokenUsageMetadata={tokenUsageMetadata}
/>
</div>
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">Threads</span>
</dd>
<dt>Threads</dt>
<dd>
<Badge>{threadCount}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="pr-4 whitespace-nowrap">Tool calls</span>
</dd>
<dt>Tool calls</dt>
<dd>
<Badge>{toolCallCount}</Badge>
</div>
</div>
</dd>
</dl>
);
};
@@ -0,0 +1,31 @@
import type { FC } from "react";
import { useInfiniteQuery } from "react-query";
import { useParams } from "react-router";
import { infiniteSessionThreads } from "#/api/queries/aiBridge";
import { SessionThreadsPageView } from "./SessionThreadsPageView";
const SessionThreadsPage: FC = () => {
const { sessionId } = useParams() as { sessionId: string };
const sessionQuery = useInfiniteQuery({
...infiniteSessionThreads(sessionId),
enabled: !!sessionId,
});
const firstPage = sessionQuery.data?.pages[0];
const allThreads =
sessionQuery.data?.pages.flatMap((page) => page.threads) ?? [];
return (
<SessionThreadsPageView
session={firstPage}
threads={allThreads}
loading={sessionQuery.isLoading}
hasNextPage={sessionQuery.hasNextPage}
isFetchingNextPage={sessionQuery.isFetchingNextPage}
onFetchNextPage={sessionQuery.fetchNextPage}
/>
);
};
export default SessionThreadsPage;
@@ -0,0 +1,118 @@
import { ArrowLeftIcon, InfoIcon } from "lucide-react";
import type { FC, PropsWithChildren } from "react";
import { Link as RouterLink } from "react-router";
import type {
AIBridgeSessionThreadsResponse,
AIBridgeThread,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { SessionSummaryTable } from "./SessionSummaryTable";
import { SessionTimeline } from "./SessionTimeline/SessionTimeline";
const SessionSummaryTooltip: FC<PropsWithChildren> = ({ children }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0 flex items-center">{children}</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs flex flex-col gap-1 text-xs p-3"
>
<p className="m-0 leading-snug">
A session is a set of threads or interceptions logically grouped by a
session key issued by the client.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
interface SessionThreadsPageViewProps {
session: AIBridgeSessionThreadsResponse | undefined;
threads: readonly AIBridgeThread[];
loading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
}
export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({
session,
threads,
loading,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
}) => {
// calculate the total number of tool calls across all loaded threads
const toolCallCount = threads.reduce(
(acc, thread) => acc + (thread.agentic_actions?.length ?? 0),
0,
);
return (
<>
<nav className="mb-6">
<Button
asChild
variant="outline"
size="lg"
title="Back to AI Bridge sessions list"
>
<RouterLink to="/aibridge/sessions">
<ArrowLeftIcon />
Back
</RouterLink>
</Button>
</nav>
<div className="flex flex-col md:flex-row md:items-start gap-6">
<aside className="md:w-64 md:shrink-0 px-3 py-2.5 border border-solid rounded-md flex flex-col gap-1">
<h2 className="text-sm font-semibold flex items-center m-0">
Session summary
<SessionSummaryTooltip>
<InfoIcon className="ml-2 h-4 w-4 text-content-secondary" />
</SessionSummaryTooltip>
</h2>
{loading && <Loader className="my-4" />}
{session && (
<SessionSummaryTable
sessionId={session.id}
startTime={new Date(session.started_at)}
endTime={
session.ended_at ? new Date(session.ended_at) : undefined
}
initiator={session.initiator}
client={session.client ?? "Unknown client"}
providers={session.providers}
inputTokens={session.token_usage_summary.input_tokens}
outputTokens={session.token_usage_summary.output_tokens}
threadCount={threads.length}
toolCallCount={toolCallCount}
tokenUsageMetadata={session.token_usage_summary.metadata}
/>
)}
</aside>
<main className="flex-1 min-w-0">
{session && (
<SessionTimeline
initiator={session.initiator}
threads={threads}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={onFetchNextPage}
/>
)}
</main>
</div>
</>
);
};
@@ -19,16 +19,21 @@ export const AgenticLoopTable: FC<AgenticLoopTableProps> = ({
className,
}) => {
return (
<div className={cn(className, "text-sm text-content-secondary")}>
<div className="flex items-center justify-between">
<div
className={cn(
"text-xs text-content-secondary flex flex-col gap-1",
className,
)}
>
<div className="flex items-center justify-between h-6">
<span className="pr-4">In / out tokens</span>
<TokenBadges inputTokens={inputTokens} outputTokens={outputTokens} />
</div>
<div className="flex items-center justify-between my-2">
<div className="flex items-center justify-between h-6">
<span className="pr-4">Tool calls</span>
<span>{toolCalls}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between h-6">
<span className="pr-4">Duration</span>
<span title={`${duration}ms`}>{roundDurationDisplay(duration)}</span>
</div>
@@ -29,38 +29,48 @@ export const PromptTable: FC<PromptTableProps> = ({
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)}
>
<dl
className={cn(
"text-xs text-content-secondary m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 items-center",
"[&_dt]:whitespace-nowrap py-1",
"[&_dt]:pr-4 [&_dt]:flex [&_dt]:items-center [&_dt]:h-6",
"[&_dd]:m-0 [&_dd]:min-w-0 [&_dd]:h-6",
className,
)}
>
<dt>Timestamp</dt>
<dd
className="text-right flex items-center justify-end"
title={formatDate(timestamp)}
>
<span className="block font-mono whitespace-nowrap truncate">
{formatDate(timestamp)}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="pr-4">Model</span>
</dd>
<dt>Model</dt>
<dd className="flex justify-end">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-1.5">
<Badge className="gap-1.5 max-w-full min-w-0 overflow-hidden">
<AIBridgeModelIcon model={model} className="size-icon-xs" />
<span className="truncate min-w-0">{model}</span>
<span className="truncate min-w-0 flex-1">{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>
</dd>
<dt>In / out tokens</dt>
<dd className="flex justify-end">
<TokenBadges
inputTokens={inputTokens}
outputTokens={outputTokens}
tokenUsageMetadata={tokenUsageMetadata}
/>
</div>
</div>
</dd>
</dl>
);
};
@@ -0,0 +1,577 @@
import {
ChevronDownIcon,
ChevronRightIcon,
InfoIcon,
LoaderIcon,
} from "lucide-react";
import { type FC, useEffect, useRef, useState } from "react";
import type {
AIBridgeAgenticAction,
AIBridgeThread,
MinimalUser,
} from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { Link } from "#/components/Link/Link";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { Spinner } from "#/components/Spinner/Spinner";
import { StatusIndicatorDot } from "#/components/StatusIndicator/StatusIndicator";
import { cn } from "#/utils/cn";
import { docs } from "#/utils/docs";
import { JsonPrettyPrinter } from "../../JsonPrettyPrinter";
import { TokenBadges } from "../../TokenBadges";
import { AgenticLoopTable } from "./AgenticLoopTable";
import { PromptTable } from "./PromptTable";
import { ToolCallTable } from "./ToolCallTable";
const EXPANDABLE_COLLAPSE_HEIGHT = 50;
interface ExpandableTextProps {
text: string;
className?: string;
}
const ExpandableText: FC<ExpandableTextProps> = ({ text, className }) => {
const contentRef = useRef<HTMLParagraphElement>(null);
const [isExpandable, setIsExpandable] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
const el = contentRef.current;
if (!el) return;
setIsExpandable(el.scrollHeight > EXPANDABLE_COLLAPSE_HEIGHT);
}, []);
return (
<div className="relative">
<p
ref={contentRef}
style={
isExpandable && !isExpanded
? {
maxHeight: EXPANDABLE_COLLAPSE_HEIGHT,
}
: undefined
}
className={cn(className, "overflow-hidden", isExpanded && "pb-9")}
>
{text}
</p>
{isExpandable && (
<div
className={cn(
"flex justify-end mt-1 absolute bottom-0 right-0 left-0",
!isExpanded &&
"bg-gradient-to-t from-surface-primary to-transparent",
)}
>
<Button
size="sm"
variant="outline"
onClick={() => setIsExpanded((v) => !v)}
>
{isExpanded ? "Collapse" : "Show more"}
</Button>
</div>
)}
</div>
);
};
interface CollapseButtonProps {
isOpen: boolean;
onClick: () => void;
children: React.ReactNode;
className?: string;
}
const CollapseButton: FC<CollapseButtonProps> = ({
isOpen,
onClick,
children,
}) => (
<Button
type="button"
variant="subtle"
onClick={onClick}
className="border-none bg-transparent text-content-secondary flex items-center"
size="sm"
>
{isOpen ? (
<ChevronDownIcon className="size-3.5 flex-shrink-0" />
) : (
<ChevronRightIcon className="size-3.5 flex-shrink-0" />
)}
{children}
</Button>
);
// Wraps content with a visual left-bracket connector: two rounded corner lines
// that flank the content row, creating an indented visual grouping.
interface BracketConnectorProps {
children: React.ReactNode;
contentClassName?: string;
firstRowHeight?: "2rem" | "60px";
hideBottomLine?: boolean;
}
const BracketConnector: FC<BracketConnectorProps> = ({
children,
contentClassName,
firstRowHeight = "2rem",
hideBottomLine = false,
}) => (
<div
className={cn(
"grid grid-cols-[1rem_1rem_1fr]",
firstRowHeight === "60px"
? "grid-rows-[60px_auto]"
: "grid-rows-[2rem_auto]",
)}
>
<div className="row-start-1 col-start-2 border-0 border-b border-l border-solid border-surface-secondary rounded-bl-lg">
{/* top rounded line */}
</div>
{!hideBottomLine && (
<div className="row-start-2 col-start-2 border-0 border-t border-l border-solid border-surface-secondary rounded-tl-lg -mt-px">
{/* bottom rounded line */}
</div>
)}
<div className={cn("row-start-1 col-start-3 row-span-2", contentClassName)}>
{children}
</div>
</div>
);
interface ThinkingBlockProps {
text: string;
}
const ThinkingBlock: FC<ThinkingBlockProps> = ({ text }) => (
<BracketConnector contentClassName="mt-5 pl-2 pr-4 text-sm text-content-secondary">
<div className="flex items-center">
<LoaderIcon className="size-icon-sm text-content-secondary" />
<span className="font-mono ml-2">Thinking...</span>
</div>
<ExpandableText text={text} className="text-pretty m-0" />
</BracketConnector>
);
interface ToolCallBlockProps {
tool: string;
serverURL: string;
input: string;
inputTokens: number;
outputTokens: number;
timestamp: Date;
tokenUsageMetadata?: Record<string, unknown>;
expandedByDefault?: boolean;
}
const ToolCallBlock: FC<ToolCallBlockProps> = ({
tool,
serverURL,
input,
inputTokens,
outputTokens,
timestamp,
tokenUsageMetadata,
expandedByDefault = true,
}) => {
const [isOpen, setIsOpen] = useState(expandedByDefault);
return (
<BracketConnector contentClassName="mt-2 mr-4 border border-solid border-surface-secondary rounded-md overflow-x-auto">
<div className="flex items-center">
<CollapseButton isOpen={isOpen} onClick={() => setIsOpen(!isOpen)}>
<span>Tool call</span>
<Badge size="xs" className="font-mono">
{tool}
</Badge>
</CollapseButton>
</div>
{isOpen && (
<>
<ToolCallTable
className="mt-2 ml-5 mr-4 lg:w-1/2 overflow-x-auto"
timestamp={timestamp}
serverURL={serverURL}
inputTokens={inputTokens}
outputTokens={outputTokens}
tokenUsageMetadata={tokenUsageMetadata}
/>
<pre className="bg-surface-secondary rounded-md m-4 p-4 text-xs font-mono text-content-primary overflow-x-auto m-0">
{tool} <JsonPrettyPrinter input={input} />
</pre>
</>
)}
</BracketConnector>
);
};
interface AgenticLoopCompletedBlockProps {
inputTokens: number;
outputTokens: number;
expandedByDefault?: boolean;
}
const AgenticLoopCompletedBlock: FC<AgenticLoopCompletedBlockProps> = ({
inputTokens,
outputTokens,
expandedByDefault = true,
}) => {
const [isOpen, setIsOpen] = useState(expandedByDefault);
return (
<BracketConnector
contentClassName="mt-3 border border-solid border-surface-secondary rounded-md mb-4 mr-4"
hideBottomLine
>
<div className="flex items-center">
<CollapseButton isOpen={isOpen} onClick={() => setIsOpen(!isOpen)}>
<span>Agentic loop completed</span>
</CollapseButton>
</div>
{isOpen && (
<div className="mb-4 ml-3 mr-4 flex flex-col gap-2 lg:w-1/2 text-xs text-content-secondary">
<div className="flex items-center justify-between">
<span className="font-medium">In / out tokens</span>
<TokenBadges
inputTokens={inputTokens}
outputTokens={outputTokens}
/>
</div>
</div>
)}
</BracketConnector>
);
};
interface AgenticActionItemProps {
action: AIBridgeAgenticAction;
}
const AgenticActionItem: FC<AgenticActionItemProps> = ({ action }) => {
return (
<>
{/* thinking blocks */}
{action.thinking.map((t) => (
<ThinkingBlock key={t.text} text={t.text} />
))}
{/* tool call blocks */}
{action.tool_calls.map((tool_call) => (
<ToolCallBlock
key={tool_call.id}
tool={tool_call.tool}
serverURL={tool_call.server_url}
input={tool_call.input}
inputTokens={action.token_usage.input_tokens}
outputTokens={action.token_usage.output_tokens}
tokenUsageMetadata={tool_call.metadata}
timestamp={new Date(tool_call.created_at)}
/>
))}
</>
);
};
interface ThreadItemProps {
thread: AIBridgeThread;
initiator: MinimalUser;
}
const ThreadItem: FC<ThreadItemProps> = ({ thread, initiator }) => {
const [agenticLoopOpen, setAgenticLoopOpen] = useState(true);
const durationInMs =
new Date(thread.ended_at ?? Date.now()).getTime() -
new Date(thread.started_at).getTime();
return (
<>
<div className="border border-surface-secondary border-solid rounded-md flex flex-col lg:flex-row gap-6 p-2">
{/* left column: avatar and username */}
<div className="flex flex-row items-items-start gap-1">
<Avatar
src={initiator.avatar_url}
fallback={initiator.name ?? initiator.username}
size="sm"
className="flex-shrink-0"
/>
<span className="text-xs text-content-secondary font-normal py-1">
{initiator.username}
</span>
</div>
{/* center column: prompt */}
<div className="flex-grow flex flex-col gap-1">
{thread.prompt && (
<>
<div className="text-xs text-content-secondary font-normal my-1">
Prompt
</div>
<p className="text-xs text-content-primary bg-surface-secondary leading-relaxed rounded-md p-3 overflow-auto m-0 text-pretty">
{thread.prompt}
</p>
</>
)}
</div>
{/* right column: details */}
<PromptTable
className="lg:max-w-64 flex-grow"
timestamp={new Date(thread.started_at)}
model={thread.model}
inputTokens={thread.token_usage.input_tokens}
outputTokens={thread.token_usage.output_tokens}
tokenUsageMetadata={thread.token_usage.metadata}
/>
</div>
<BracketConnector
firstRowHeight="60px"
contentClassName="border border-surface-secondary border-dashed rounded-md my-4"
>
{/* Agentic loop */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between">
<div>
<CollapseButton
isOpen={agenticLoopOpen}
onClick={() => setAgenticLoopOpen(!agenticLoopOpen)}
>
Agentic loop
</CollapseButton>
</div>
<AgenticLoopTable
className="lg:max-w-64 flex-1 my-3 mx-2"
duration={durationInMs}
toolCalls={thread.agentic_actions?.length ?? 0}
inputTokens={thread.token_usage.input_tokens}
outputTokens={thread.token_usage.output_tokens}
/>
</div>
{agenticLoopOpen && (
<>
{/* the little top rounded line above the thinking block */}
<div className="border-0 border-t border-r border-solid border-surface-secondary rounded-tr-lg w-[calc(1rem+1px)] h-[20px]">
{/* we need the 1px extra to line up with the left border on the other lines */}
</div>
{/* Agentic actions */}
{thread.agentic_actions?.map((action, i) => (
<AgenticActionItem key={`${thread.id}-${i}`} action={action} />
))}
{/* Agentic loop completed block */}
<AgenticLoopCompletedBlock
inputTokens={thread.token_usage.input_tokens}
outputTokens={thread.token_usage.output_tokens}
/>
</>
)}
</BracketConnector>
</>
);
};
interface SessionTimelineProps {
initiator: MinimalUser;
threads: readonly AIBridgeThread[];
hasNextPage: boolean;
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
}
export const SessionTimeline: FC<SessionTimelineProps> = ({
initiator,
threads,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
}) => {
const sentinelRef = useRef<HTMLDivElement>(null);
const [sort, setSort] = useState<"oldest" | "newest">("oldest");
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasNextPage) {
return;
}
const observer = new IntersectionObserver(
([div]) => {
if (div.isIntersecting && hasNextPage && !isFetchingNextPage) {
onFetchNextPage();
}
},
{ rootMargin: "200px" },
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, [hasNextPage, isFetchingNextPage, onFetchNextPage]);
return (
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="absolute top-0 right-0">
{sort === "oldest" ? "Sort by oldest" : "Sort by newest"}
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={sort}
onValueChange={(v) => setSort(v as "oldest" | "newest")}
>
<DropdownMenuRadioItem value="oldest">
Sort by oldest
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="newest">
Sort by newest
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="grid grid-cols-[16px_1rem_1px_1fr_auto_16px]">
{/* row 1: session start */}
<div className="row-start-1 col-start-2 relative h-10 py-1">
<StatusIndicatorDot
variant="inactive"
className="absolute right-0 translate-x-1/2 translate-y-1/2"
/>
</div>
<div className="row-start-1 col-start-4 col-span-2 flex items-center h-10">
<span className="text-content-secondary ml-4 py-1 text-sm">
Session started
</span>
</div>
{/* row 2: vertical line and timeline sort dropdown */}
<div className="row-start-2 col-start-3 border-0 border-l border-solid border-surface-secondary">
{/* vertical line */}
</div>
{/* row 3: sized intentionally to create the visual space above the timeline border */}
<div className="row-start-3 col-start-3 border-0 border-l border-t border-solid border-surface-secondary h-6">
{/* vertical line */}
</div>
{/* row 3/4: AI Governance tooltip */}
<div className="row-start-3 col-start-5 row-span-2 flex items-center text-xs text-content-secondary px-2 pt-1">
AI Governance
<Popover>
<PopoverTrigger asChild>
<InfoIcon className="size-icon-sm p-0.5 ml-1" />
</PopoverTrigger>
<PopoverContent className="max-w-64" align="end" side="top">
<div className="text-sm text-content-primary font-medium mb-1">
Controls and logs AI tooling so AI use stays secure, compliant,
and visible.
</div>
<div className="text-sm text-content-secondary">
<Link href={docs("/ai-coder/ai-governance")} target="_blank">
More about AI Governance
</Link>
</div>
</PopoverContent>
</Popover>
</div>
{/* row 4: */}
<div className="row-start-4 col-start-1 border-0 border-l border-t border-dashed border-surface-green rounded-tl-lg size-4">
{/* top left rounded corner */}
</div>
<div className="row-start-4 col-start-2 border-0 border-t border-dashed border-surface-green">
{/* horizontal border */}
</div>
<div className="row-start-4 col-start-3 border-0 border-l border-solid border-surface-secondary">
{/* vertical line */}
</div>
<div className="row-start-4 col-start-4 border-0 border-t border-dashed border-surface-green">
{/* horizontal border */}
</div>
<div className="row-start-4 col-start-6 border-0 border-r border-t border-dashed border-surface-green rounded-tr-lg size-4">
{/* top right rounded corner */}
</div>
{/* row 5: threads */}
<div className="row-start-5 col-start-1 border-0 border-l border-dashed border-surface-green">
{/* left vertical line */}
</div>
<div className="row-start-5 col-start-2 col-span-4">
{/* threads */}
{threads.map((thread) => (
<ThreadItem key={thread.id} thread={thread} initiator={initiator} />
))}
{/* infinite scroll sentinel — sits 200px below the last thread */}
<div ref={sentinelRef} />
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4 text-sm text-content-secondary">
<Spinner loading size="sm" />
</div>
)}
</div>
<div className="row-start-5 col-start-6 border-0 border-r border-dashed border-surface-green">
{/* right vertical line */}
</div>
{/* row 6: more design and session end */}
<div className="row-start-6 col-start-1 border-0 border-l border-b border-dashed border-surface-green rounded-bl-lg size-4">
{/* bottom left rounded corner */}
</div>
<div className="row-start-6 col-start-2 border-0 border-b border-dashed border-surface-green">
{/* horizontal line */}
</div>
<div className="row-start-6 col-start-3 border-0 border-l border-solid border-surface-secondary">
{/* vertical line */}
</div>
<div className="row-start-6 col-start-4 col-span-2 border-0 border-b border-dashed border-surface-green">
{/* horizontal line */}
</div>
<div className="row-start-6 col-start-6 border-0 border-r border-b border-dashed border-surface-green rounded-br-lg size-4">
{/* bottom right rounded corner */}
</div>
{/* row 7: sized intentionally to create the visual space below the timeline border */}
<div className="row-start-7 col-start-3 border-0 border-l border-t border-solid border-surface-secondary h-4">
{/* vertical line */}
</div>
{/* row 8: session start */}
<div className="row-start-8 col-start-2 relative">
<StatusIndicatorDot
variant="success"
className="absolute right-0 translate-x-1/2 translate-y-1/2"
/>
</div>
<div className="row-start-8 col-start-4 flex items-center">
<span className="text-content-success ml-4 text-sm py-1">
Session completed
</span>
</div>
</div>
</div>
);
};
@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TokenBadges } from "./TokenBadges";
const meta: Meta<typeof TokenBadges> = {
title: "pages/AIBridgePage/TokenBadges",
component: TokenBadges,
};
export default meta;
type Story = StoryObj<typeof TokenBadges>;
export const Default: Story = {
args: {
inputTokens: 1234,
outputTokens: 567,
},
};
export const LargeTokenCounts: Story = {
args: {
inputTokens: 128000,
outputTokens: 32000,
},
};
export const SmallTokenCounts: Story = {
args: {
inputTokens: 42,
outputTokens: 8,
},
};
export const WithMetadata: Story = {
args: {
inputTokens: 5000,
outputTokens: 2500,
tokenUsageMetadata: {
cache_read_input_tokens: 3200,
cache_creation_input_tokens: 800,
},
},
};
export const SizeXs: Story = {
args: {
size: "xs",
inputTokens: 1234,
outputTokens: 567,
},
};
export const SizeMd: Story = {
args: {
size: "md",
inputTokens: 1234,
outputTokens: 567,
},
};
+3 -2
View File
@@ -7,7 +7,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { prettyFormatJSON, roundTokenDisplay } from "./utils";
import { JsonPrettyPrinter } from "./JsonPrettyPrinter";
import { roundTokenDisplay } from "./utils";
interface TokenBadgesProps {
size?: "xs" | "sm" | "md";
@@ -82,7 +83,7 @@ export const TokenBadges: FC<TokenBadgesProps> = ({
Token usage metadata
</div>
<pre className="mt-2 p-4 bg-surface-secondary rounded text-xs overflow-x-auto">
{prettyFormatJSON(JSON.stringify(tokenUsageMetadata))}
<JsonPrettyPrinter input={JSON.stringify(tokenUsageMetadata)} />
</pre>
</>
)}
-12
View File
@@ -35,15 +35,3 @@ export const getProviderIconName = (provider: string) => {
}
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;
};
+4
View File
@@ -405,6 +405,9 @@ const AIBridgeSessionsLayout = lazy(
const AIBridgeListSessionsPage = lazy(
() => import("./pages/AIBridgePage/ListSessionsPage/ListSessionsPage"),
);
const AIBridgeSessionThreadsPage = lazy(
() => import("./pages/AIBridgePage/SessionThreadsPage/SessionThreadsPage"),
);
const GlobalLayout = () => {
return (
@@ -643,6 +646,7 @@ export const router = createBrowserRouter(
<Route path="/aibridge/sessions" element={<AIBridgeSessionsLayout />}>
<Route index element={<AIBridgeListSessionsPage />} />
<Route path=":sessionId" element={<AIBridgeSessionThreadsPage />} />
</Route>
<Route path="/health" element={<HealthLayout />}>
+6
View File
@@ -91,6 +91,12 @@ module.exports = {
red: "hsl(var(--highlight-red))",
magenta: "hsl(var(--highlight-magenta))",
},
syntax: {
key: "hsl(var(--syntax-key))",
string: "hsl(var(--syntax-string))",
number: "hsl(var(--syntax-number))",
boolean: "hsl(var(--syntax-boolean))",
},
git: {
added: "hsl(var(--git-added))",
deleted: "hsl(var(--git-deleted))",