Skip to content

Commit b68e4e4

Browse files
committed
feat(sessions): context breakdown popover
Replaces the simple tooltip on the inline context indicator with a Radix popover that mirrors the design spec: an aggregate header ("~74K / 200K tokens", "37% full"), a multi-segment progress bar colored by source, and a per-category token list (System prompt, Tools, Rules, Skills, MCP, Subagents, Conversation) with zero-token rows hidden. The inline label now reads "X/Y · Z%" so the percent is glanceable without opening the popover. The popover consumes the new `breakdown` field from `useContextUsage` (plumbed in B3). When the breakdown is missing — e.g. before the first response of a session — the popover falls back to a single percent bar and surfaces a one-line placeholder explaining that detail will arrive after the first response. Color choices and the `formatTokensCompact` helper are lifted into a shared `contextColors` module so both the indicator and popover read from the same source. Generated-By: PostHog Code Task-Id: bac06178-1ab1-4000-9a56-1901215bd4af Generated-By: PostHog Code Task-Id: bac06178-1ab1-4000-9a56-1901215bd4af
1 parent 78a3903 commit b68e4e4

4 files changed

Lines changed: 266 additions & 56 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { ContextUsage } from "@features/sessions/hooks/useContextUsage";
2+
import { Theme } from "@radix-ui/themes";
3+
import { render, screen } from "@testing-library/react";
4+
import { describe, expect, it } from "vitest";
5+
import { ContextBreakdownPopover } from "./ContextBreakdownPopover";
6+
7+
function usageWith(
8+
breakdown: ContextUsage["breakdown"],
9+
overrides?: Partial<ContextUsage>,
10+
): ContextUsage {
11+
return {
12+
used: 74_000,
13+
size: 200_000,
14+
percentage: 37,
15+
cost: null,
16+
breakdown,
17+
...overrides,
18+
};
19+
}
20+
21+
describe("ContextBreakdownPopover", () => {
22+
it("renders the header with aggregate tokens", () => {
23+
render(
24+
<Theme>
25+
<ContextBreakdownPopover usage={usageWith(null)} />
26+
</Theme>,
27+
);
28+
expect(screen.getByText(/74K \/ 200K tokens/)).toBeInTheDocument();
29+
expect(screen.getByText("37% full")).toBeInTheDocument();
30+
});
31+
32+
it("shows the placeholder copy when breakdown is missing", () => {
33+
render(
34+
<Theme>
35+
<ContextBreakdownPopover usage={usageWith(null)} />
36+
</Theme>,
37+
);
38+
expect(
39+
screen.getByText(/Detailed breakdown available after the first response/),
40+
).toBeInTheDocument();
41+
});
42+
43+
it("renders one row per non-zero category", () => {
44+
render(
45+
<Theme>
46+
<ContextBreakdownPopover
47+
usage={usageWith({
48+
systemPrompt: 4000,
49+
tools: 0,
50+
rules: 0,
51+
skills: 0,
52+
mcp: 1500,
53+
subagents: 0,
54+
conversation: 68_500,
55+
})}
56+
/>
57+
</Theme>,
58+
);
59+
expect(screen.getByText("System prompt")).toBeInTheDocument();
60+
expect(screen.getByText("MCP")).toBeInTheDocument();
61+
expect(screen.getByText("Conversation")).toBeInTheDocument();
62+
expect(screen.queryByText("Tools")).not.toBeInTheDocument();
63+
expect(screen.queryByText("Rules")).not.toBeInTheDocument();
64+
});
65+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { ContextUsage } from "@features/sessions/hooks/useContextUsage";
2+
import {
3+
CONTEXT_CATEGORIES,
4+
formatTokensCompact,
5+
getOverallUsageColor,
6+
} from "@features/sessions/utils/contextColors";
7+
import { Flex, Text } from "@radix-ui/themes";
8+
9+
interface ContextBreakdownPopoverProps {
10+
usage: ContextUsage;
11+
}
12+
13+
export function ContextBreakdownPopover({
14+
usage,
15+
}: ContextBreakdownPopoverProps) {
16+
const { used, size, percentage, breakdown } = usage;
17+
const fillColor = getOverallUsageColor(percentage);
18+
19+
return (
20+
<Flex direction="column" gap="3" className="min-w-[280px]">
21+
<Flex align="center" justify="between">
22+
<Text className="font-medium text-[13px] text-(--gray-12)">
23+
Context
24+
</Text>
25+
<Text className="text-(--gray-10) text-[12px] tabular-nums">
26+
~{formatTokensCompact(used)} / {formatTokensCompact(size)} tokens
27+
</Text>
28+
</Flex>
29+
30+
<Text className="font-semibold text-[15px] text-(--gray-12)">
31+
{percentage}% full
32+
</Text>
33+
34+
{breakdown ? (
35+
<SegmentedBar breakdown={breakdown} total={used} fallback={fillColor} />
36+
) : (
37+
<SinglePercentBar percentage={percentage} color={fillColor} />
38+
)}
39+
40+
{breakdown ? (
41+
<Flex direction="column" gap="2">
42+
{CONTEXT_CATEGORIES.filter((c) => breakdown[c.key] > 0).map((cat) => (
43+
<Flex
44+
key={cat.key}
45+
align="center"
46+
justify="between"
47+
className="text-[13px]"
48+
>
49+
<Flex align="center" gap="2">
50+
<span
51+
className="inline-block size-2.5 rounded-sm"
52+
style={{ backgroundColor: cat.color }}
53+
/>
54+
<Text className="text-(--gray-12)">{cat.label}</Text>
55+
</Flex>
56+
<Text className="text-(--gray-11) tabular-nums">
57+
{formatTokensCompact(breakdown[cat.key])}
58+
</Text>
59+
</Flex>
60+
))}
61+
</Flex>
62+
) : (
63+
<Text className="text-(--gray-10) text-[12px]">
64+
Detailed breakdown available after the first response.
65+
</Text>
66+
)}
67+
</Flex>
68+
);
69+
}
70+
71+
function SegmentedBar({
72+
breakdown,
73+
total,
74+
fallback,
75+
}: {
76+
breakdown: NonNullable<ContextUsage["breakdown"]>;
77+
total: number;
78+
fallback: string;
79+
}) {
80+
if (total <= 0) {
81+
return <div className="h-1.5 w-full rounded-full bg-(--gray-4)" />;
82+
}
83+
return (
84+
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-(--gray-4)">
85+
{CONTEXT_CATEGORIES.map((cat) => {
86+
const value = breakdown[cat.key];
87+
if (value <= 0) return null;
88+
return (
89+
<div
90+
key={cat.key}
91+
style={{
92+
width: `${(value / total) * 100}%`,
93+
backgroundColor: cat.color || fallback,
94+
}}
95+
/>
96+
);
97+
})}
98+
</div>
99+
);
100+
}
101+
102+
function SinglePercentBar({
103+
percentage,
104+
color,
105+
}: {
106+
percentage: number;
107+
color: string;
108+
}) {
109+
return (
110+
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--gray-4)">
111+
<div
112+
className="h-full rounded-full"
113+
style={{ width: `${percentage}%`, backgroundColor: color }}
114+
/>
115+
</div>
116+
);
117+
}
Lines changed: 51 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,10 @@
1-
import { Tooltip } from "@components/ui/Tooltip";
21
import type { ContextUsage } from "@features/sessions/hooks/useContextUsage";
3-
import { Flex, Text } from "@radix-ui/themes";
4-
5-
function formatTokensCompact(tokens: number): string {
6-
if (tokens >= 1_000_000) {
7-
return `${(tokens / 1_000_000).toFixed(1)}M`;
8-
}
9-
return `${Math.round(tokens / 1000)}K`;
10-
}
11-
12-
function formatTokensFull(tokens: number): string {
13-
return tokens.toLocaleString();
14-
}
15-
16-
function getUsageColor(percentage: number): string {
17-
if (percentage >= 90) return "var(--red-9)";
18-
if (percentage >= 75) return "var(--orange-9)";
19-
if (percentage >= 50) return "var(--amber-9)";
20-
return "var(--green-9)";
21-
}
2+
import {
3+
formatTokensCompact,
4+
getOverallUsageColor,
5+
} from "@features/sessions/utils/contextColors";
6+
import { Flex, Popover, Text } from "@radix-ui/themes";
7+
import { ContextBreakdownPopover } from "./ContextBreakdownPopover";
228

239
const CIRCLE_SIZE = 20;
2410
const STROKE_WIDTH = 2.5;
@@ -34,45 +20,54 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) {
3420

3521
const { used, size, percentage } = usage;
3622
const strokeDashoffset = CIRCUMFERENCE - (percentage / 100) * CIRCUMFERENCE;
37-
const color = getUsageColor(percentage);
23+
const color = getOverallUsageColor(percentage);
3824

3925
return (
40-
<Tooltip
41-
content={`${formatTokensFull(used)} / ${formatTokensFull(size)} tokens (${percentage}%)`}
42-
side="top"
43-
>
44-
<Flex align="center" gap="1" className="cursor-default select-none">
45-
<svg
46-
width={CIRCLE_SIZE}
47-
height={CIRCLE_SIZE}
48-
className="-rotate-90 shrink-0"
49-
role="img"
26+
<Popover.Root>
27+
<Popover.Trigger>
28+
<button
29+
type="button"
30+
className="flex cursor-pointer select-none items-center gap-1 bg-transparent"
5031
aria-label={`Context usage: ${percentage}%`}
5132
>
52-
<circle
53-
cx={CIRCLE_SIZE / 2}
54-
cy={CIRCLE_SIZE / 2}
55-
r={RADIUS}
56-
fill="none"
57-
stroke="var(--gray-5)"
58-
strokeWidth={STROKE_WIDTH}
59-
/>
60-
<circle
61-
cx={CIRCLE_SIZE / 2}
62-
cy={CIRCLE_SIZE / 2}
63-
r={RADIUS}
64-
fill="none"
65-
stroke={color}
66-
strokeWidth={STROKE_WIDTH}
67-
strokeDasharray={CIRCUMFERENCE}
68-
strokeDashoffset={strokeDashoffset}
69-
strokeLinecap="round"
70-
/>
71-
</svg>
72-
<Text className="text-[13px] text-gray-10 tabular-nums">
73-
{formatTokensCompact(used)}/{formatTokensCompact(size)}
74-
</Text>
75-
</Flex>
76-
</Tooltip>
33+
<Flex align="center" gap="1">
34+
<svg
35+
width={CIRCLE_SIZE}
36+
height={CIRCLE_SIZE}
37+
className="-rotate-90 shrink-0"
38+
role="img"
39+
aria-hidden="true"
40+
>
41+
<circle
42+
cx={CIRCLE_SIZE / 2}
43+
cy={CIRCLE_SIZE / 2}
44+
r={RADIUS}
45+
fill="none"
46+
stroke="var(--gray-5)"
47+
strokeWidth={STROKE_WIDTH}
48+
/>
49+
<circle
50+
cx={CIRCLE_SIZE / 2}
51+
cy={CIRCLE_SIZE / 2}
52+
r={RADIUS}
53+
fill="none"
54+
stroke={color}
55+
strokeWidth={STROKE_WIDTH}
56+
strokeDasharray={CIRCUMFERENCE}
57+
strokeDashoffset={strokeDashoffset}
58+
strokeLinecap="round"
59+
/>
60+
</svg>
61+
<Text className="text-[13px] text-gray-10 tabular-nums">
62+
{formatTokensCompact(used)}/{formatTokensCompact(size)} ·{" "}
63+
{percentage}%
64+
</Text>
65+
</Flex>
66+
</button>
67+
</Popover.Trigger>
68+
<Popover.Content size="2" side="top" align="end" sideOffset={6}>
69+
<ContextBreakdownPopover usage={usage} />
70+
</Popover.Content>
71+
</Popover.Root>
7772
);
7873
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ContextBreakdown } from "@features/sessions/hooks/useContextUsage";
2+
3+
export interface CategoryStyle {
4+
key: keyof ContextBreakdown;
5+
label: string;
6+
color: string;
7+
}
8+
9+
// Ordered like the design spec: System prompt, Tools, Rules, Skills, MCP,
10+
// Subagents, Conversation. Colors reuse Radix scales so they read in both
11+
// light/dark modes.
12+
export const CONTEXT_CATEGORIES: readonly CategoryStyle[] = [
13+
{ key: "systemPrompt", label: "System prompt", color: "var(--gray-9)" },
14+
{ key: "tools", label: "Tools", color: "var(--violet-9)" },
15+
{ key: "rules", label: "Rules", color: "var(--green-9)" },
16+
{ key: "skills", label: "Skills", color: "var(--amber-9)" },
17+
{ key: "mcp", label: "MCP", color: "var(--pink-9)" },
18+
{ key: "subagents", label: "Subagents", color: "var(--blue-9)" },
19+
{ key: "conversation", label: "Conversation", color: "var(--orange-9)" },
20+
] as const;
21+
22+
export function getOverallUsageColor(percentage: number): string {
23+
if (percentage >= 90) return "var(--red-9)";
24+
if (percentage >= 75) return "var(--orange-9)";
25+
if (percentage >= 50) return "var(--amber-9)";
26+
return "var(--green-9)";
27+
}
28+
29+
export function formatTokensCompact(tokens: number): string {
30+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
31+
if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`;
32+
return tokens.toString();
33+
}

0 commit comments

Comments
 (0)