fix: improve AI Bridge request logs UI/UX (#21252)

Closes #21052 and is related to #21218 

This pull request implements a bunch of changes the team requested to
improve the overall user experience when navigating to the Request Logs
page. Since these are mostly UI changes, screenshots do a much better
job of showing what's different than me trying to explain everything in
words, so I've included them below.

> [!WARNING]  
> More globally we've changed how we're rendering `<PaginationHeader
/>`, therefore we've renamed it to `<PaginationAmount />`. This will
effect multiple layouts however its a global improvement we're
intentionally looking to make.

### Preview 


![ai-bridge-improvements](https://github.com/user-attachments/assets/ce92852e-d0fc-4268-b52b-9aefd431033b)

### Changes

1. Date formatting set to a standardised way including the month as a
`MMM` (i.e `Dec`)
2. Names and avatars are now rendered larger, furthermore they render
the `.name` field falling back on `.username`.
3. Font-size increased from `text-xs` to `text-sm` to increase overall
legibility globally.
4. Initiator is now rendered inside of a `<Badge />` and gives longer
names a chance to render.
5. Models are now rendered in a `<Badge />` alongside their icons.
6. Input/Output tokens are in a shared `<Badge />`-group alongside
truncation (when necessary).
7. Badge from `5` is rendered with the proper name being visible on
hover (for use if truncated).
8. Move globally the `Showing X of X` counts to the bottom of the tables
(effects other components outside of AI Bridge).
9. Icons are now included within the dropdown for the `Provider` filter.
10. Truncation of elements is now handled so larger prompts don't cause
the table to require further scrolling.
11. New merged Input/Output `<dt>` element with the badge 

| Position | Pull-request |
| -------- | ------------ |
|  | [fix: improve AI Bridge request logs
UI/UX](https://github.com/coder/coder/pull/21252) |
| | [feat: add AI Bridge request logs model
filter](https://github.com/coder/coder/pull/21259) |
| | [chore!: promote AIBridge from
`ExperimentalHandler`](https://github.com/coder/coder/pull/21278) |
| | [feat: implement request log collapsing prompt (`<RequestLogsPrompt
/>`)](https://github.com/coder/coder/pull/21313) |

---------

Co-authored-by: ケイラ <mckayla@hey.com>
This commit is contained in:
Jake Howell
2025-12-19 00:47:20 +11:00
committed by GitHub
co-authored by ケイラ
parent 8248fa3b84
commit ca971dda29
9 changed files with 233 additions and 77 deletions
@@ -13,7 +13,7 @@ type PaginationHeaderProps = {
className?: string;
};
export const PaginationHeader: FC<PaginationHeaderProps> = ({
export const PaginationAmount: FC<PaginationHeaderProps> = ({
paginationUnitLabel,
limit,
totalRecords,
@@ -30,7 +30,6 @@ export const PaginationHeader: FC<PaginationHeaderProps> = ({
alignItems: "center",
margin: 0,
fontSize: "13px",
paddingBottom: "8px",
color: theme.palette.text.secondary,
height: "36px", // The size of a small button
"& strong": {
@@ -1,6 +1,6 @@
import type { PaginationResultInfo } from "hooks/usePaginatedQuery";
import type { FC, HTMLAttributes } from "react";
import { PaginationHeader } from "./PaginationHeader";
import { PaginationAmount } from "./PaginationAmount";
import { PaginationWidgetBase } from "./PaginationWidgetBase";
export type PaginationResult = PaginationResultInfo & {
@@ -19,35 +19,27 @@ export const PaginationContainer: FC<PaginationProps> = ({
...delegatedProps
}) => {
return (
<>
<PaginationHeader
<div className="flex flex-col gap-y-4" {...delegatedProps}>
{children}
<PaginationAmount
limit={query.limit}
totalRecords={query.totalRecords}
currentOffsetStart={query.currentOffsetStart}
paginationUnitLabel={paginationUnitLabel}
className="justify-end"
/>
<div
css={{
display: "flex",
flexFlow: "column nowrap",
rowGap: "16px",
}}
{...delegatedProps}
>
{children}
{query.isSuccess && (
<PaginationWidgetBase
totalRecords={query.totalRecords}
currentPage={query.currentPage}
pageSize={query.limit}
onPageChange={query.onPageChange}
hasPreviousPage={query.hasPreviousPage}
hasNextPage={query.hasNextPage}
/>
)}
</div>
</>
{query.isSuccess && (
<PaginationWidgetBase
totalRecords={query.totalRecords}
currentPage={query.currentPage}
pageSize={query.limit}
onPageChange={query.onPageChange}
hasPreviousPage={query.hasPreviousPage}
hasNextPage={query.hasNextPage}
/>
)}
</div>
);
};
@@ -0,0 +1,36 @@
import type { AIBridgeInterception } from "api/typesGenerated";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CircleQuestionMarkIcon } from "lucide-react";
import { cn } from "utils/cn";
export const AIBridgeProviderIcon = ({
provider,
...props
}: {
provider: AIBridgeInterception["provider"];
} & React.ComponentProps<"svg">) => {
const iconClassName = "flex-shrink-0";
switch (provider) {
case "openai":
return (
<ExternalImage
src="/icon/openai.svg"
className={cn(iconClassName, props.className)}
/>
);
case "anthropic":
return (
<ExternalImage
src="/icon/claude.svg"
className={cn(iconClassName, props.className)}
/>
);
default:
return (
<CircleQuestionMarkIcon
className={cn(iconClassName, props.className)}
{...props}
/>
);
}
};
@@ -51,13 +51,14 @@ export const RequestLogsPageView: FC<RequestLogsPageViewProps> = ({
query={interceptionsQuery}
paginationUnitLabel="interceptions"
>
<Table>
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableRow className="text-xs">
<TableHead>Timestamp</TableHead>
<TableHead>User</TableHead>
<TableHead>Initiator</TableHead>
<TableHead>Prompt</TableHead>
<TableHead>Tokens</TableHead>
<TableHead>Model</TableHead>
<TableHead>Tool Calls</TableHead>
</TableRow>
</TableHeader>
@@ -1,5 +1,6 @@
import type { AIBridgeInterception } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { TableCell, TableRow } from "components/Table/Table";
import {
Tooltip,
@@ -16,11 +17,22 @@ import {
import { type FC, Fragment, useState } from "react";
import { cn } from "utils/cn";
import { humanDuration } from "utils/time";
import { AIBridgeProviderIcon } from "../AIBridgeProviderIcon";
type RequestLogsRowProps = {
interception: AIBridgeInterception;
};
const customisedDateLocale: Intl.DateTimeFormatOptions = {
second: "2-digit",
minute: "2-digit",
hour: "2-digit",
day: "numeric",
// Show the month as a short name
month: "short",
year: "numeric",
};
type TokenUsageMetadataMerged =
| null
| Record<string, unknown>
@@ -132,10 +144,11 @@ export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
return (
<>
<TableRow
className="select-none cursor-pointer hover:bg-surface-secondary"
className="select-none cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
hover
>
<TableCell>
<TableCell className="w-48 whitespace-nowrap">
<div
className={cn([
"flex items-center gap-2",
@@ -148,55 +161,105 @@ export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
<ChevronRightIcon className="size-icon-xs" />
)}
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
{new Date(interception.started_at).toLocaleString()}
{new Date(interception.started_at).toLocaleString(
undefined,
customisedDateLocale,
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Avatar
fallback={interception.initiator.username}
src={interception.initiator.avatar_url}
/>
<div className="font-medium">{interception.initiator.username}</div>
<TableCell className="w-48 max-w-48">
<div className="w-full min-w-0 overflow-hidden">
<div className="flex items-center gap-3 min-w-0">
<Avatar
fallback={interception.initiator.username}
src={interception.initiator.avatar_url}
size={"lg"}
className="flex-shrink-0"
/>
<div className="font-medium truncate min-w-0 flex-1 overflow-hidden">
{interception.initiator.name ?? interception.initiator.username}
</div>
</div>
</div>
</TableCell>
<TableCell>{firstPrompt?.prompt}</TableCell>
<TableCell>
<div className="flex items-center gap-4">
<TooltipProvider delayDuration={100}>
<TableCell className="min-w-0">
{/*
This is ensuring that the prompt is truncated and won't escape its bounding
container with an `absolute`.
Alternatively we could use a `table-fixed` table, but that would break worse
on mobile with the `min-w-0` column required.
This is a bit of a hack, but it works.
*/}
<div className="w-full h-4 min-w-48 relative">
<div className="absolute inset-0 leading-none overflow-hidden truncate">
{firstPrompt?.prompt}
</div>
</div>
</TableCell>
<TableCell className="w-32">
<div className="flex items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<ArrowDownIcon className="size-icon-xs" />
<div>{inputTokens}</div>
</div>
<Badge className="gap-0 rounded-e-none">
<ArrowDownIcon className="size-icon-lg flex-shrink-0" />
<span className="truncate min-w-0 w-full">
{inputTokens}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>Input Tokens</TooltipContent>
<TooltipContent>{inputTokens} Input Tokens</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={100}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<ArrowUpIcon className="size-icon-xs" />
<div>{outputTokens}</div>
</div>
<Badge className="gap-0 bg-surface-tertiary rounded-s-none">
<ArrowUpIcon className="size-icon-lg flex-shrink-0" />
<span className="truncate min-w-0 w-full">
{outputTokens}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>Output Tokens</TooltipContent>
<TooltipContent>{outputTokens} Output Tokens</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
<TableCell>{toolCalls}</TableCell>
<TableCell className="w-40 max-w-40">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full min-w-0 overflow-hidden">
<Badge className="gap-1.5 w-full">
<div className="flex-shrink-0 flex items-center">
<AIBridgeProviderIcon
provider={interception.provider}
className="size-icon-xs"
/>
</div>
<span className="truncate min-w-0 w-full">
{interception.model}
</span>
</Badge>
</div>
</TooltipTrigger>
<TooltipContent>{interception.model}</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="w-32 text-center">{toolCalls}</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={999} className="p-4 border-t-0">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-6">
<dl
className={cn([
"text-xs text-content-secondary",
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
"m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 items-center",
"[&_dd]:text-content-primary [&_dd]:font-mono [&_dd]:leading-[22px] [&_dt]:font-medium",
])}
>
@@ -205,14 +268,20 @@ export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
<dt>Start Time:</dt>
<dd data-chromatic="ignore">
{new Date(interception.started_at).toLocaleString()}
{new Date(interception.started_at).toLocaleString(
undefined,
customisedDateLocale,
)}
</dd>
{interception.ended_at && (
<>
<dt>End Time:</dt>
<dd data-chromatic="ignore">
{new Date(interception.ended_at).toLocaleString()}
{new Date(interception.ended_at).toLocaleString(
undefined,
customisedDateLocale,
)}
</dd>
</>
)}
@@ -227,30 +296,84 @@ export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
)}
<dt>Initiator:</dt>
<dd data-chromatic="ignore">
{interception.initiator.username}
<dd
data-chromatic="ignore"
className="flex items-center gap-1.5"
>
<Avatar
fallback={interception.initiator.username}
src={interception.initiator.avatar_url}
size={"sm"}
className="flex-shrink-0"
/>
<span className="truncate min-w-0 w-full">
{interception.initiator.name ??
interception.initiator.username}
</span>
</dd>
<dt>Model:</dt>
<dd data-chromatic="ignore">{interception.model}</dd>
<dt>Input Tokens:</dt>
<dd data-chromatic="ignore">{inputTokens}</dd>
<dt>Output Tokens:</dt>
<dd data-chromatic="ignore">{outputTokens}</dd>
<dd data-chromatic="ignore">
<Badge className="gap-2">
<div className="flex-shrink-0 flex items-center">
<AIBridgeProviderIcon
provider={interception.provider}
className="size-icon-xs"
/>
</div>
<span className="truncate min-w-0 w-full text-2xs">
{interception.model}
</span>
</Badge>
</dd>
<dt>Tool Calls:</dt>
<dd data-chromatic="ignore">
{interception.tool_usages.length}
</dd>
<dt>Input/Output Tokens:</dt>
<dd data-chromatic="ignore">
<div className="flex items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-0 rounded-e-none">
<ArrowDownIcon className="size-icon-lg flex-shrink-0" />
<span className="truncate min-w-0 w-full">
{inputTokens}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>
{inputTokens} Input Tokens
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-0 bg-surface-tertiary rounded-s-none">
<ArrowUpIcon className="size-icon-lg flex-shrink-0" />
<span className="truncate min-w-0 w-full">
{outputTokens}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>
{outputTokens} Output Tokens
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</dd>
</dl>
{interception.user_prompts.length > 0 && (
<div className="flex flex-col gap-2">
<div>Prompts</div>
<div
className="bg-surface-secondary rounded-md p-4"
className="bg-surface-secondary rounded-md p-4 text-xs leading-4"
data-chromatic="ignore"
>
{interception.user_prompts.map((prompt) => (
@@ -35,7 +35,7 @@ export const RequestLogsFilter: FC<RequestLogsFilterProps> = ({
error={error}
options={
<>
<UserMenu menu={menus.user} />
<UserMenu menu={menus.user} placeholder="All initiators" />
<ProviderFilter menu={menus.provider} />
</>
}
@@ -7,15 +7,22 @@ import {
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import type { FC } from "react";
import { AIBridgeProviderIcon } from "../AIBridgeProviderIcon";
const AIBRIDGE_PROVIDERS: SelectFilterOption[] = [
{
label: "OpenAI",
value: "openai",
startIcon: (
<AIBridgeProviderIcon provider="openai" className="size-icon-sm" />
),
},
{
label: "Anthropic",
value: "anthropic",
startIcon: (
<AIBridgeProviderIcon provider="anthropic" className="size-icon-sm" />
),
},
];
@@ -12,7 +12,7 @@ import {
import { EmptyState } from "components/EmptyState/EmptyState";
import { Margins } from "components/Margins/Margins";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { PaginationHeader } from "components/PaginationWidget/PaginationHeader";
import { PaginationAmount } from "components/PaginationWidget/PaginationAmount";
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
@@ -196,12 +196,11 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
</>
) : (
!pageNumberIsInvalid && (
<PaginationHeader
<PaginationAmount
paginationUnitLabel="workspaces"
limit={limit}
totalRecords={count}
currentOffsetStart={(page - 1) * limit + 1}
css={{ paddingBottom: "0" }}
/>
)
)}
+2 -3
View File
@@ -1,4 +1,3 @@
<svg width="512" height="510" viewBox="0 0 512 510" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M115.612 0H396.387C459.974 0 512 52.026 512 115.612V394.027C512 457.614 459.974 509.639 396.387 509.639H115.612C52.026 509.639 0 457.614 0 394.027V115.612C0 52.026 52.026 0 115.612 0Z" fill="#D77655"/>
<path d="M142.27 316.619L215.925 275.293L217.163 271.704L215.925 269.708L212.336 269.707L200.026 268.948L157.942 267.81L121.444 266.294L86.083 264.398L77.186 262.503L68.846 251.508L69.705 246.024L77.187 240.994L87.904 241.929L111.587 243.546L147.124 245.998L172.906 247.515L211.099 251.483H217.163L218.023 249.032L215.95 247.515L214.332 245.998L177.556 221.076L137.746 194.738L116.894 179.572L105.621 171.889L99.934 164.685L97.483 148.964L107.72 137.691L121.47 138.626L124.983 139.562L138.911 150.278L168.66 173.305L207.508 201.917L213.195 206.644L215.47 205.027L215.748 203.889L213.195 199.618L192.065 161.425L169.519 122.577L159.484 106.476L156.83 96.821C155.895 92.853 155.213 89.517 155.213 85.447L166.865 69.624L173.31 67.551L188.855 69.624L195.402 75.311L205.057 97.403L220.703 132.183L244.968 179.474L252.071 193.502L255.862 206.494L257.278 210.462L259.727 210.461V208.186L261.724 181.545L265.414 148.838L269.003 106.754L270.242 94.9L276.105 80.694L287.757 73.011L296.856 77.359L304.338 88.075L303.302 95.001L298.853 123.916L290.133 169.21L284.446 199.541H287.759L291.551 195.75L306.893 175.378L332.675 143.151L344.049 130.362L357.319 116.233L365.836 109.509L381.936 109.508L393.79 127.125L388.483 145.324L371.902 166.353L358.152 184.172L338.436 210.712L326.127 231.943L327.265 233.637L330.197 233.359L374.733 223.88L398.795 219.533L427.509 214.605L440.501 220.671L441.917 226.838L436.811 239.451L406.101 247.034L370.083 254.238L316.447 266.927L315.79 267.407L316.548 268.342L340.712 270.617L351.049 271.173H376.35L423.464 274.687L435.773 282.826L443.154 292.785L441.916 300.368L422.959 310.023L397.38 303.957L337.678 289.752L317.204 284.646L314.374 284.645V286.339L331.435 303.021L362.701 331.254L401.853 367.651L403.85 376.65L398.82 383.752L393.513 382.994L359.112 357.111L345.842 345.46L315.789 320.158L313.793 320.157V322.811L320.719 332.947L357.293 387.922L359.188 404.781L356.535 410.266L347.056 413.577L336.642 411.682L315.234 381.628L293.142 347.784L275.323 317.453L273.15 318.691L262.635 431.952L257.706 437.74L246.332 442.088L236.854 434.884L231.824 423.232L236.854 400.205L242.92 370.153L247.848 346.267L252.297 316.593L254.951 306.735L254.774 306.078L252.601 306.356L230.231 337.066L196.21 383.043L169.291 411.858L162.846 414.411L151.673 408.622L152.71 398.285L158.953 389.085L196.21 341.693L218.68 312.322L233.188 295.361L233.087 292.91H232.228L133.274 357.161L115.656 359.436L108.073 352.333L109.009 340.681L112.598 336.89L142.347 316.416L142.246 316.518L142.27 316.619Z" fill="#FCF2EE"/>
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M188.146 515.368L325.645 438.161L327.946 431.432L325.645 427.713H318.921L295.915 426.296L217.344 424.172L149.214 421.338L83.2071 417.797L66.5727 414.255L51 393.714L52.5927 383.443L66.5727 374.058L86.5694 375.829L130.81 378.839L197.171 383.443L245.304 386.276L316.62 393.714H327.946L329.538 389.11L325.645 386.276L322.637 383.443L253.975 336.871L179.651 287.642L140.72 259.309L119.661 244.966L109.044 231.508L104.443 202.112L123.554 181.04L149.214 182.811L155.762 184.581L181.775 204.591L237.341 247.622L309.895 301.1L320.513 309.954L324.76 306.944L325.291 304.819L320.513 296.851L281.051 225.487L238.934 152.884L220.176 122.78L215.221 104.718C213.451 97.2806 212.212 91.0827 212.212 83.4683L233.979 53.8958L246.012 50L275.034 53.8958L287.244 64.5206L305.294 105.78L334.493 170.769L379.795 259.132L393.068 285.34L400.146 309.6L402.801 317.038H407.402V312.788L411.118 263.028L418.019 201.935L424.744 123.311L427.044 101.176L438.016 74.6142L459.782 60.2707L476.771 68.4164L490.751 88.4265L488.804 101.353L480.487 155.363L464.206 240.008L453.589 296.673H459.782L466.861 289.59L495.529 251.518L543.662 191.31L564.898 167.405L589.672 141.019L605.599 128.447H635.683L657.803 161.384L647.893 195.383L616.925 234.695L591.265 267.986L554.457 317.569L531.452 357.235L533.575 360.422L539.061 359.891L622.233 342.183L667.182 334.037L720.801 324.829L745.045 336.162L747.7 347.673L738.144 371.224L680.808 385.391L613.562 398.849L513.402 422.578L512.163 423.463L513.579 425.234L558.704 429.484L577.993 430.546H625.242L713.192 437.098L736.197 452.327L750 470.921L747.7 485.087L712.307 503.15L664.527 491.816L553.041 465.254L514.817 455.692H509.509V458.879L541.362 490.046L599.759 542.816L672.845 610.815L676.561 627.637L667.182 640.918L657.272 639.502L593.035 591.159L568.26 569.378L512.163 522.097H508.447V527.055L521.365 546.003L589.672 648.71L593.212 680.23L588.257 690.501L570.56 696.699L551.095 693.157L511.101 637.023L469.869 573.805L436.6 517.139L432.53 519.441L412.887 731.052L403.685 741.854L382.45 750L364.754 736.542L355.375 714.761L364.754 671.73L376.079 615.596L385.281 570.971L393.598 515.545L398.553 497.129L398.2 495.889L394.129 496.42L352.366 553.795L288.837 639.679L238.58 693.511L226.546 698.292L205.665 687.491L207.611 668.189L219.291 651.012L288.837 562.472L330.777 507.577L357.852 475.879L357.675 471.275H356.083L171.334 591.336L138.419 595.586L124.262 582.305L126.032 560.524L132.756 553.44L188.323 515.191L188.146 515.368Z" fill="#D97757"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB