Skip to content

Commit 6c73d4c

Browse files
committed
🤖 Add collapsed token meter sidebar
Shows Last Request token usage as 20px vertical bar when space constrained (container ≤949px). Replaces complete sidebar hide with always-visible meter. Features: - Thin 8px vertical bar with colored segments (cached/input/output/thinking) - Rich tooltip on hover showing token breakdown and context usage - Shared utilities for token calculations and formatting - Responsive container queries (300px → 20px) Implementation: - New: tokenMeterUtils.ts - shared token meter logic - New: VerticalTokenMeter.tsx - collapsed vertical display - Modified: ChatMetaSidebar.tsx - two-state responsive design Code optimized via data-driven approach using SEGMENT_DEFS array, reducing repetition and improving maintainability. _Generated with `cmux`_
1 parent 93d2670 commit 6c73d4c

File tree

3 files changed

+261
-36
lines changed

3 files changed

+261
-36
lines changed

‎src/components/ChatMetaSidebar.tsx‎

Lines changed: 72 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import React from "react";
22
import styled from "@emotion/styled";
33
import { usePersistedState } from "@/hooks/usePersistedState";
4+
import { useChatContext } from "@/contexts/ChatContext";
5+
import { use1MContext } from "@/hooks/use1MContext";
46
import { CostsTab } from "./ChatMetaSidebar/CostsTab";
57
import { ToolsTab } from "./ChatMetaSidebar/ToolsTab";
8+
import { VerticalTokenMeter } from "./ChatMetaSidebar/VerticalTokenMeter";
9+
import { calculateTokenMeterData } from "@/utils/tokens/tokenMeterUtils";
610

711
const SidebarContainer = styled.div`
812
width: 300px;
@@ -12,11 +16,30 @@ const SidebarContainer = styled.div`
1216
flex-direction: column;
1317
overflow: hidden;
1418
19+
@container (max-width: 949px) {
20+
width: 20px;
21+
}
22+
`;
23+
24+
const FullView = styled.div`
25+
display: flex;
26+
flex-direction: column;
27+
height: 100%;
28+
1529
@container (max-width: 949px) {
1630
display: none;
1731
}
1832
`;
1933

34+
const CollapsedView = styled.div`
35+
display: none;
36+
height: 100%;
37+
38+
@container (max-width: 949px) {
39+
display: flex;
40+
}
41+
`;
42+
2043
const TabBar = styled.div`
2144
display: flex;
2245
background: #2d2d2d;
@@ -64,50 +87,63 @@ export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId })
6487
"costs"
6588
);
6689

90+
const { stats } = useChatContext();
91+
const [use1M] = use1MContext();
92+
6793
const baseId = `chat-meta-${workspaceId}`;
6894
const costsTabId = `${baseId}-tab-costs`;
6995
const toolsTabId = `${baseId}-tab-tools`;
7096
const costsPanelId = `${baseId}-panel-costs`;
7197
const toolsPanelId = `${baseId}-panel-tools`;
7298

99+
const lastUsage = stats?.usageHistory[stats.usageHistory.length - 1];
100+
const meterData = lastUsage
101+
? calculateTokenMeterData(lastUsage, stats.model, use1M)
102+
: { segments: [], totalTokens: 0, totalPercentage: 0 };
103+
73104
return (
74105
<SidebarContainer role="complementary" aria-label="Workspace insights">
75-
<TabBar role="tablist" aria-label="Metadata views">
76-
<TabButton
77-
active={selectedTab === "costs"}
78-
onClick={() => setSelectedTab("costs")}
79-
id={costsTabId}
80-
role="tab"
81-
type="button"
82-
aria-selected={selectedTab === "costs"}
83-
aria-controls={costsPanelId}
84-
>
85-
Costs
86-
</TabButton>
87-
<TabButton
88-
active={selectedTab === "tools"}
89-
onClick={() => setSelectedTab("tools")}
90-
id={toolsTabId}
91-
role="tab"
92-
type="button"
93-
aria-selected={selectedTab === "tools"}
94-
aria-controls={toolsPanelId}
95-
>
96-
Tools
97-
</TabButton>
98-
</TabBar>
99-
<TabContent>
100-
{selectedTab === "costs" && (
101-
<div role="tabpanel" id={costsPanelId} aria-labelledby={costsTabId}>
102-
<CostsTab />
103-
</div>
104-
)}
105-
{selectedTab === "tools" && (
106-
<div role="tabpanel" id={toolsPanelId} aria-labelledby={toolsTabId}>
107-
<ToolsTab />
108-
</div>
109-
)}
110-
</TabContent>
106+
<FullView>
107+
<TabBar role="tablist" aria-label="Metadata views">
108+
<TabButton
109+
active={selectedTab === "costs"}
110+
onClick={() => setSelectedTab("costs")}
111+
id={costsTabId}
112+
role="tab"
113+
type="button"
114+
aria-selected={selectedTab === "costs"}
115+
aria-controls={costsPanelId}
116+
>
117+
Costs
118+
</TabButton>
119+
<TabButton
120+
active={selectedTab === "tools"}
121+
onClick={() => setSelectedTab("tools")}
122+
id={toolsTabId}
123+
role="tab"
124+
type="button"
125+
aria-selected={selectedTab === "tools"}
126+
aria-controls={toolsPanelId}
127+
>
128+
Tools
129+
</TabButton>
130+
</TabBar>
131+
<TabContent>
132+
{selectedTab === "costs" && (
133+
<div role="tabpanel" id={costsPanelId} aria-labelledby={costsTabId}>
134+
<CostsTab />
135+
</div>
136+
)}
137+
{selectedTab === "tools" && (
138+
<div role="tabpanel" id={toolsPanelId} aria-labelledby={toolsTabId}>
139+
<ToolsTab />
140+
</div>
141+
)}
142+
</TabContent>
143+
</FullView>
144+
<CollapsedView>
145+
<VerticalTokenMeter data={meterData} />
146+
</CollapsedView>
111147
</SidebarContainer>
112148
);
113149
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import React from "react";
2+
import styled from "@emotion/styled";
3+
import { TooltipWrapper, Tooltip } from "../Tooltip";
4+
import { type TokenMeterData, formatTokens, getSegmentLabel } from "@/utils/tokens/tokenMeterUtils";
5+
6+
const Container = styled.div`
7+
width: 20px;
8+
height: 100%;
9+
display: flex;
10+
flex-direction: column;
11+
align-items: center;
12+
padding: 12px 0;
13+
background: #252526;
14+
border-left: 1px solid #3e3e42;
15+
`;
16+
17+
const Bar = styled.div`
18+
width: 8px;
19+
flex: 1;
20+
background: #3e3e42;
21+
border-radius: 4px;
22+
overflow: hidden;
23+
display: flex;
24+
flex-direction: column;
25+
min-height: 100px;
26+
`;
27+
28+
const Segment = styled.div<{ percentage: number; color: string }>`
29+
width: 100%;
30+
height: ${(props) => props.percentage}%;
31+
background: ${(props) => props.color};
32+
transition: height 0.3s ease;
33+
`;
34+
35+
const Content = styled.div`
36+
display: flex;
37+
flex-direction: column;
38+
gap: 8px;
39+
font-family: var(--font-primary);
40+
font-size: 12px;
41+
`;
42+
43+
const Row = styled.div`
44+
display: flex;
45+
justify-content: space-between;
46+
gap: 16px;
47+
`;
48+
49+
const Dot = styled.div<{ color: string }>`
50+
width: 8px;
51+
height: 8px;
52+
border-radius: 50%;
53+
background: ${(props) => props.color};
54+
flex-shrink: 0;
55+
`;
56+
57+
const Divider = styled.div`
58+
border-top: 1px solid #3e3e42;
59+
margin: 4px 0;
60+
`;
61+
62+
export const VerticalTokenMeter: React.FC<{ data: TokenMeterData }> = ({ data }) => {
63+
if (data.segments.length === 0) return null;
64+
65+
return (
66+
<TooltipWrapper>
67+
<Container>
68+
<Bar>
69+
{data.segments.map((seg, i) => (
70+
<Segment key={i} percentage={seg.percentage} color={seg.color} />
71+
))}
72+
</Bar>
73+
</Container>
74+
<Tooltip>
75+
<Content>
76+
<div style={{ fontWeight: 600, fontSize: 13, color: "#cccccc" }}>Last Request</div>
77+
<Divider />
78+
{data.segments.map((seg, i) => (
79+
<Row key={i}>
80+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
81+
<Dot color={seg.color} />
82+
<span>{getSegmentLabel(seg.type)}</span>
83+
</div>
84+
<span style={{ color: "#cccccc", fontWeight: 500 }}>{formatTokens(seg.tokens)}</span>
85+
</Row>
86+
))}
87+
<Divider />
88+
<div style={{ color: "#888888", fontSize: 11 }}>
89+
Total: {formatTokens(data.totalTokens)}
90+
{data.maxTokens && ` / ${formatTokens(data.maxTokens)}`}
91+
{data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`}
92+
</div>
93+
</Content>
94+
</Tooltip>
95+
</TooltipWrapper>
96+
);
97+
};
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { ChatUsageDisplay } from "./usageAggregator";
2+
import { getModelStats } from "./modelStats";
3+
import { supports1MContext } from "../ai/models";
4+
5+
export const TOKEN_COMPONENT_COLORS = {
6+
cached: "var(--color-token-cached)",
7+
input: "var(--color-token-input)",
8+
output: "var(--color-token-output)",
9+
thinking: "var(--color-thinking-mode)",
10+
} as const;
11+
12+
export interface TokenSegment {
13+
type: "cached" | "cacheCreate" | "input" | "output" | "reasoning";
14+
tokens: number;
15+
percentage: number;
16+
color: string;
17+
}
18+
19+
export interface TokenMeterData {
20+
segments: TokenSegment[];
21+
totalTokens: number;
22+
maxTokens?: number;
23+
totalPercentage: number;
24+
}
25+
26+
interface SegmentDef {
27+
type: TokenSegment["type"];
28+
key: keyof ChatUsageDisplay;
29+
color: string;
30+
label: string;
31+
}
32+
33+
const SEGMENT_DEFS: SegmentDef[] = [
34+
{ type: "cached", key: "cached", color: TOKEN_COMPONENT_COLORS.cached, label: "Cache Read" },
35+
{
36+
type: "cacheCreate",
37+
key: "cacheCreate",
38+
color: TOKEN_COMPONENT_COLORS.cached,
39+
label: "Cache Create",
40+
},
41+
{ type: "input", key: "input", color: TOKEN_COMPONENT_COLORS.input, label: "Input" },
42+
{ type: "output", key: "output", color: TOKEN_COMPONENT_COLORS.output, label: "Output" },
43+
{
44+
type: "reasoning",
45+
key: "reasoning",
46+
color: TOKEN_COMPONENT_COLORS.thinking,
47+
label: "Thinking",
48+
},
49+
];
50+
51+
export function calculateTokenMeterData(
52+
usage: ChatUsageDisplay | undefined,
53+
model: string,
54+
use1M: boolean
55+
): TokenMeterData {
56+
if (!usage) return { segments: [], totalTokens: 0, totalPercentage: 0 };
57+
58+
const modelStats = getModelStats(model);
59+
const maxTokens = use1M && supports1MContext(model) ? 1_000_000 : modelStats?.max_input_tokens;
60+
61+
const totalUsed =
62+
usage.input.tokens +
63+
usage.cached.tokens +
64+
usage.cacheCreate.tokens +
65+
usage.output.tokens +
66+
usage.reasoning.tokens;
67+
68+
const toPercentage = (tokens: number) =>
69+
maxTokens ? (tokens / maxTokens) * 100 : totalUsed > 0 ? (tokens / totalUsed) * 100 : 0;
70+
71+
const segments = SEGMENT_DEFS.filter((def) => usage[def.key].tokens > 0).map((def) => ({
72+
type: def.type,
73+
tokens: usage[def.key].tokens,
74+
percentage: toPercentage(usage[def.key].tokens),
75+
color: def.color,
76+
}));
77+
78+
return {
79+
segments,
80+
totalTokens: totalUsed,
81+
maxTokens,
82+
totalPercentage: maxTokens ? (totalUsed / maxTokens) * 100 : 100,
83+
};
84+
}
85+
86+
export function formatTokens(tokens: number): string {
87+
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : tokens.toLocaleString();
88+
}
89+
90+
export function getSegmentLabel(type: TokenSegment["type"]): string {
91+
return SEGMENT_DEFS.find((def) => def.type === type)?.label ?? type;
92+
}

0 commit comments

Comments
 (0)