From 548a648dcb4572966882f391cd8d4b9cc5414267 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Mon, 30 Mar 2026 08:03:52 -0400 Subject: [PATCH] feat(site): add AI session thread page (#23391) Adds the Session Thread page --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Jake Howell --- site/src/api/api.ts | 13 + site/src/api/queries/aiBridge.ts | 23 + site/src/index.css | 8 + .../JsonPrettyPrinter.stories.tsx | 115 ++++ .../pages/AIBridgePage/JsonPrettyPrinter.tsx | 63 ++ .../SessionSummaryTable.tsx | 152 ++--- .../SessionThreadsPage/SessionThreadsPage.tsx | 31 + .../SessionThreadsPageView.tsx | 118 ++++ .../SessionTimeline/AgenticLoopTable.tsx | 13 +- .../SessionTimeline/PromptTable.tsx | 44 +- .../SessionTimeline/SessionTimeline.tsx | 577 ++++++++++++++++++ .../AIBridgePage/TokenBadges.stories.tsx | 58 ++ site/src/pages/AIBridgePage/TokenBadges.tsx | 5 +- site/src/pages/AIBridgePage/utils.ts | 12 - site/src/router.tsx | 4 + site/tailwind.config.js | 6 + 16 files changed, 1133 insertions(+), 109 deletions(-) create mode 100644 site/src/pages/AIBridgePage/JsonPrettyPrinter.stories.tsx create mode 100644 site/src/pages/AIBridgePage/JsonPrettyPrinter.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPage.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx create mode 100644 site/src/pages/AIBridgePage/TokenBadges.stories.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a34d040909..021cecc607 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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(url); + return response.data; + }; + getAIBridgeModels = async (options: SearchParamOptions) => { const url = getURLWithSearchParams("/api/v2/aibridge/models", options); diff --git a/site/src/api/queries/aiBridge.ts b/site/src/api/queries/aiBridge.ts index 778aea6d38..45a9d11fce 100644 --- a/site/src/api/queries/aiBridge.ts +++ b/site/src/api/queries/aiBridge.ts @@ -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 => { @@ -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; +}; diff --git a/site/src/index.css b/site/src/index.css index d96de720c2..5d52d787c7 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -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%; diff --git a/site/src/pages/AIBridgePage/JsonPrettyPrinter.stories.tsx b/site/src/pages/AIBridgePage/JsonPrettyPrinter.stories.tsx new file mode 100644 index 0000000000..947e6843ed --- /dev/null +++ b/site/src/pages/AIBridgePage/JsonPrettyPrinter.stories.tsx @@ -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, +}) => ( +
+		{tool} 
+	
+); + +const meta: Meta = { + title: "pages/AIBridgePage/JsonPrettyPrinter", + component: PreviewBlock, +}; + +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/site/src/pages/AIBridgePage/JsonPrettyPrinter.tsx b/site/src/pages/AIBridgePage/JsonPrettyPrinter.tsx new file mode 100644 index 0000000000..63a72159cf --- /dev/null +++ b/site/src/pages/AIBridgePage/JsonPrettyPrinter.tsx @@ -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 {String(value)}; + case "number": + return {value}; + case "string": + return "{value}"; + 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) => ( + + {inner} + {formatJSONValue(v, depth + 1)} + {i < value.length - 1 ? ",\n" : "\n"} + + ))} + {pad}] + + ); + } + const entries = Object.entries(value as Record); + if (entries.length === 0) return "{}"; + return ( + <> + {/* biome-ignore lint/style/useConsistentCurlyBraces: \n requires a JS string literal */} + {"{\n"} + {entries.map(([k, v], i) => ( + + {inner} + "{k}" + {/* biome-ignore lint/style/useConsistentCurlyBraces: keeps spacing explicit */} + {": "} + {formatJSONValue(v, depth + 1)} + {i < entries.length - 1 ? ",\n" : "\n"} + + ))} + {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; + } +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx index e6198b93bd..fef6378235 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx @@ -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 ( -
-
- Session ID - - {sessionId} - -
-
- Start time - - {formatDateTime(startTime)} - -
-
- End time - - {endTime ? formatDateTime(endTime) : "—"} - -
-
- Duration - - {durationInMs != null ? `${Math.round(durationInMs / 1000)} s` : "—"} - -
-
- Initiator -
+
+
Session ID
+
+ {sessionId} +
+ +
Start time
+
+ {formatDateTime(startTime)} +
+ +
End time
+
{endTime ? formatDateTime(endTime) : "—"}
+ +
Duration
+
+ {durationInMs !== undefined + ? `${Math.round(durationInMs / 1000)} s` + : "—"} +
+ +
Initiator
+
+
- + {initiator.name}
-
-
- Client - + + +
Client
+
+
- + {client ?? "Unknown"}
+
+ +
Provider
+
+ {providers.map((p) => ( + + + + {getProviderDisplayName(p)} + + + ))} +
+ +
+
-
- Provider -
- {providers.map((p) => ( - - - - {getProviderDisplayName(p)} - - - ))} -
-
- -
- In / out tokens + +
In / out tokens
+
-
-
- Threads + + +
Threads
+
{threadCount} -
-
- Tool calls + + +
Tool calls
+
{toolCallCount} -
-
+ + ); }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPage.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPage.tsx new file mode 100644 index 0000000000..e162127421 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPage.tsx @@ -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 ( + + ); +}; + +export default SessionThreadsPage; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx new file mode 100644 index 0000000000..b0912bb375 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx @@ -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 = ({ children }) => ( + + + +
{children}
+
+ +

+ A session is a set of threads or interceptions logically grouped by a + session key issued by the client. +

+
+
+
+); + +interface SessionThreadsPageViewProps { + session: AIBridgeSessionThreadsResponse | undefined; + threads: readonly AIBridgeThread[]; + loading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onFetchNextPage: () => void; +} + +export const SessionThreadsPageView: FC = ({ + 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 ( + <> + +
+ +
+ {session && ( + + )} +
+
+ + ); +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.tsx index 56f896ec41..02961230ae 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.tsx @@ -19,16 +19,21 @@ export const AgenticLoopTable: FC = ({ className, }) => { return ( -
-
+
+
In / out tokens
-
+
Tool calls {toolCalls}
-
+
Duration {roundDurationDisplay(duration)}
diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.tsx index 42ad047617..6ce111eade 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.tsx @@ -29,38 +29,48 @@ export const PromptTable: FC = ({ className, }) => { return ( -
-
- Timestamp - +
+
Timestamp
+
+ {formatDate(timestamp)} -
-
- Model + + +
Model
+
- + - {model} + {model} {model} -
-
- In / out tokens + + +
In / out tokens
+
-
-
+ + ); }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx new file mode 100644 index 0000000000..7fb29ac228 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx @@ -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 = ({ text, className }) => { + const contentRef = useRef(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 ( +
+

+ {text} +

+ {isExpandable && ( +
+ +
+ )} +
+ ); +}; + +interface CollapseButtonProps { + isOpen: boolean; + onClick: () => void; + children: React.ReactNode; + className?: string; +} + +const CollapseButton: FC = ({ + isOpen, + onClick, + children, +}) => ( + +); + +// 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 = ({ + children, + contentClassName, + firstRowHeight = "2rem", + hideBottomLine = false, +}) => ( +
+
+ {/* top rounded line */} +
+ {!hideBottomLine && ( +
+ {/* bottom rounded line */} +
+ )} +
+ {children} +
+
+); + +interface ThinkingBlockProps { + text: string; +} + +const ThinkingBlock: FC = ({ text }) => ( + +
+ + Thinking... +
+ +
+); + +interface ToolCallBlockProps { + tool: string; + serverURL: string; + input: string; + inputTokens: number; + outputTokens: number; + timestamp: Date; + tokenUsageMetadata?: Record; + expandedByDefault?: boolean; +} + +const ToolCallBlock: FC = ({ + tool, + serverURL, + input, + inputTokens, + outputTokens, + timestamp, + tokenUsageMetadata, + expandedByDefault = true, +}) => { + const [isOpen, setIsOpen] = useState(expandedByDefault); + + return ( + +
+ setIsOpen(!isOpen)}> + Tool call + + {tool} + + +
+ {isOpen && ( + <> + +
+						{tool} 
+					
+ + )} +
+ ); +}; + +interface AgenticLoopCompletedBlockProps { + inputTokens: number; + outputTokens: number; + expandedByDefault?: boolean; +} + +const AgenticLoopCompletedBlock: FC = ({ + inputTokens, + outputTokens, + expandedByDefault = true, +}) => { + const [isOpen, setIsOpen] = useState(expandedByDefault); + + return ( + +
+ setIsOpen(!isOpen)}> + Agentic loop completed + +
+ {isOpen && ( +
+
+ In / out tokens + +
+
+ )} +
+ ); +}; + +interface AgenticActionItemProps { + action: AIBridgeAgenticAction; +} + +const AgenticActionItem: FC = ({ action }) => { + return ( + <> + {/* thinking blocks */} + {action.thinking.map((t) => ( + + ))} + + {/* tool call blocks */} + {action.tool_calls.map((tool_call) => ( + + ))} + + ); +}; + +interface ThreadItemProps { + thread: AIBridgeThread; + initiator: MinimalUser; +} + +const ThreadItem: FC = ({ thread, initiator }) => { + const [agenticLoopOpen, setAgenticLoopOpen] = useState(true); + + const durationInMs = + new Date(thread.ended_at ?? Date.now()).getTime() - + new Date(thread.started_at).getTime(); + + return ( + <> +
+ {/* left column: avatar and username */} +
+ + + {initiator.username} + +
+ + {/* center column: prompt */} +
+ {thread.prompt && ( + <> +
+ Prompt +
+

+ {thread.prompt} +

+ + )} +
+ + {/* right column: details */} + +
+ + + {/* Agentic loop */} +
+
+ setAgenticLoopOpen(!agenticLoopOpen)} + > + Agentic loop + +
+ + +
+ + {agenticLoopOpen && ( + <> + {/* the little top rounded line above the thinking block */} +
+ {/* we need the 1px extra to line up with the left border on the other lines */} +
+ + {/* Agentic actions */} + {thread.agentic_actions?.map((action, i) => ( + + ))} + + {/* Agentic loop completed block */} + + + )} +
+ + ); +}; + +interface SessionTimelineProps { + initiator: MinimalUser; + threads: readonly AIBridgeThread[]; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onFetchNextPage: () => void; +} + +export const SessionTimeline: FC = ({ + initiator, + threads, + hasNextPage, + isFetchingNextPage, + onFetchNextPage, +}) => { + const sentinelRef = useRef(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 ( +
+ + + + + + setSort(v as "oldest" | "newest")} + > + + Sort by oldest + + + Sort by newest + + + + + +
+ {/* row 1: session start */} +
+ +
+
+ + Session started + +
+ + {/* row 2: vertical line and timeline sort dropdown */} +
+ {/* vertical line */} +
+ + {/* row 3: sized intentionally to create the visual space above the timeline border */} +
+ {/* vertical line */} +
+ + {/* row 3/4: AI Governance tooltip */} +
+ AI Governance + + + + + +
+ Controls and logs AI tooling so AI use stays secure, compliant, + and visible. +
+
+ + More about AI Governance + +
+
+
+
+ + {/* row 4: */} +
+ {/* top left rounded corner */} +
+
+ {/* horizontal border */} +
+
+ {/* vertical line */} +
+
+ {/* horizontal border */} +
+
+ {/* top right rounded corner */} +
+ + {/* row 5: threads */} +
+ {/* left vertical line */} +
+
+ {/* threads */} + {threads.map((thread) => ( + + ))} + {/* infinite scroll sentinel — sits 200px below the last thread */} +
+ {isFetchingNextPage && ( +
+ +
+ )} +
+
+ {/* right vertical line */} +
+ + {/* row 6: more design and session end */} +
+ {/* bottom left rounded corner */} +
+
+ {/* horizontal line */} +
+
+ {/* vertical line */} +
+
+ {/* horizontal line */} +
+
+ {/* bottom right rounded corner */} +
+ + {/* row 7: sized intentionally to create the visual space below the timeline border */} +
+ {/* vertical line */} +
+ + {/* row 8: session start */} +
+ +
+
+ + Session completed + +
+
+
+ ); +}; diff --git a/site/src/pages/AIBridgePage/TokenBadges.stories.tsx b/site/src/pages/AIBridgePage/TokenBadges.stories.tsx new file mode 100644 index 0000000000..109c310da2 --- /dev/null +++ b/site/src/pages/AIBridgePage/TokenBadges.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TokenBadges } from "./TokenBadges"; + +const meta: Meta = { + title: "pages/AIBridgePage/TokenBadges", + component: TokenBadges, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/site/src/pages/AIBridgePage/TokenBadges.tsx b/site/src/pages/AIBridgePage/TokenBadges.tsx index c929b8dce8..1a860bd773 100644 --- a/site/src/pages/AIBridgePage/TokenBadges.tsx +++ b/site/src/pages/AIBridgePage/TokenBadges.tsx @@ -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 = ({ Token usage metadata
-								{prettyFormatJSON(JSON.stringify(tokenUsageMetadata))}
+								
 							
)} diff --git a/site/src/pages/AIBridgePage/utils.ts b/site/src/pages/AIBridgePage/utils.ts index 53055c33f8..c1357dee25 100644 --- a/site/src/pages/AIBridgePage/utils.ts +++ b/site/src/pages/AIBridgePage/utils.ts @@ -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; -}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 03ea217052..d23928e9d7 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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( }> } /> + } /> }> diff --git a/site/tailwind.config.js b/site/tailwind.config.js index dc01ff4d2a..65d0c87200 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -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))",