mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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:
co-authored by
Claude Sonnet 4.6
Jake Howell
parent
7d0a49f54b
commit
548a648dcb
@@ -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);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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))",
|
||||
|
||||
Reference in New Issue
Block a user