Skip to content

Commit b6b2de3

Browse files
authored
🤖 Add collapsed token meter sidebar (#271)
## Summary Adds a collapsed token meter sidebar that displays the last request's token usage as a thin vertical bar when space is constrained. ## Features ### Responsive Collapse/Expand - **Full view (300px)**: Shows tabs for Costs and Tools with detailed breakdowns - **Collapsed view (20px)**: Shows vertical token meter bar - **Hysteresis thresholds**: Collapses at ≤800px ChatArea width, expands at ≥1100px - **Smooth transition**: 0.2s ease animation between states ### Vertical Token Meter - **Proportional bar height**: Scales 0-100% based on context window usage (e.g., 18% usage = 18% bar height) - **Colored segments**: Shows token composition (cached/input/output/reasoning) with proper colors - **Context percentage label**: Displays usage number at top (e.g., "18") - **Always visible**: Uses sticky positioning to stay pinned to right edge, even when window < 770px - **Hover tooltip**: Shows detailed breakdown with token counts and costs ### Technical Implementation - **ResizeObserver**: Measures ChatArea width with requestAnimationFrame throttling - **React.memo**: Prevents unnecessary re-renders of TokenMeter components - **useMemo**: Caches expensive vertical meter calculations - **Flex layout**: Proper vertical segment distribution without overlapping - **Sticky positioning**: Keeps collapsed bar visible during horizontal scroll ## Layout Behavior ### Window Width Scenarios | Window Width | ChatArea Width | Sidebar State | Behavior | |--------------|----------------|---------------|----------| | 1400px+ | 1100px+ | Full (300px) | All visible, no scroll | | 1100-1399px | 800-1099px | Hysteresis zone | Maintains current state | | 900-1099px | ~600-800px | Collapsed (20px) | Vertical bar visible | | 770-899px | 750px | Collapsed (20px) | Tight fit, all visible | | < 770px | 750px (min) | Collapsed (20px) | Horizontal scroll, bar sticky | ### Sticky Behavior (< 770px) - ViewContainer allows horizontal scrolling - Vertical bar (20px) stays pinned to right edge - ChatArea scrolls underneath with subtle shadow - Bar covers rightmost 20px of chat (minimal impact) ## Files Changed - - Responsive sidebar with collapse logic - - Collapsed view component - - Reusable bar renderer - - Wrapper for horizontal use - - ResizeObserver integration, sticky container support - - Throttled size observation - - Shared calculations and color constants ## Testing - [x] Verify collapse/expand at correct thresholds - [x] Test hysteresis prevents oscillation - [x] Confirm vertical bar scales with context usage - [x] Check segment colors match TOKEN_COMPONENT_COLORS - [x] Validate tooltip shows correct breakdown - [x] Test horizontal scroll behavior < 770px - [x] Verify sticky bar stays visible when scrolling - [x] Confirm no performance degradation or memory leaks _Generated with `cmux`_
1 parent c63c6c4 commit b6b2de3

File tree

7 files changed

+541
-64
lines changed

7 files changed

+541
-64
lines changed

src/components/AIView.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ const ViewContainer = styled.div`
3838
color: #d4d4d4;
3939
font-family: var(--font-monospace);
4040
font-size: 12px;
41-
overflow: hidden;
41+
overflow-x: auto;
42+
overflow-y: hidden;
4243
container-type: inline-size;
4344
`;
4445

@@ -203,6 +204,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
203204
workspacePath,
204205
className,
205206
}) => {
207+
const chatAreaRef = useRef<HTMLDivElement>(null);
208+
206209
// NEW: Get workspace state from store (only re-renders when THIS workspace changes)
207210
const workspaceState = useWorkspaceState(workspaceId);
208211
const aggregator = useWorkspaceAggregator(workspaceId);
@@ -346,7 +349,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
346349
if (!workspaceState) {
347350
return (
348351
<ViewContainer className={className}>
349-
<ChatArea>
352+
<ChatArea ref={chatAreaRef}>
350353
<OutputContainer>
351354
<LoadingIndicator>Loading workspace...</LoadingIndicator>
352355
</OutputContainer>
@@ -405,7 +408,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
405408
return (
406409
<ChatProvider messages={messages} cmuxMessages={cmuxMessages} model={currentModel ?? "unknown"}>
407410
<ViewContainer className={className}>
408-
<ChatArea>
411+
<ChatArea ref={chatAreaRef}>
409412
<ViewHeader>
410413
<WorkspaceTitle>
411414
<StatusIndicator
@@ -532,7 +535,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
532535
/>
533536
</ChatArea>
534537

535-
<ChatMetaSidebar workspaceId={workspaceId} />
538+
<ChatMetaSidebar workspaceId={workspaceId} chatAreaRef={chatAreaRef} />
536539
</ViewContainer>
537540
</ChatProvider>
538541
);

src/components/ChatMetaSidebar.tsx

Lines changed: 116 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,48 @@
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";
6+
import { useResizeObserver } from "@/hooks/useResizeObserver";
47
import { CostsTab } from "./ChatMetaSidebar/CostsTab";
58
import { ToolsTab } from "./ChatMetaSidebar/ToolsTab";
9+
import { VerticalTokenMeter } from "./ChatMetaSidebar/VerticalTokenMeter";
10+
import { calculateTokenMeterData } from "@/utils/tokens/tokenMeterUtils";
611

7-
const SidebarContainer = styled.div`
8-
width: 300px;
12+
interface SidebarContainerProps {
13+
collapsed: boolean;
14+
}
15+
16+
const SidebarContainer = styled.div<SidebarContainerProps>`
17+
width: ${(props) => (props.collapsed ? "20px" : "300px")};
918
background: #252526;
1019
border-left: 1px solid #3e3e42;
1120
display: flex;
1221
flex-direction: column;
1322
overflow: hidden;
23+
transition: width 0.2s ease;
24+
flex-shrink: 0;
1425
15-
@container (max-width: 949px) {
16-
display: none;
17-
}
26+
/* Keep vertical bar always visible when collapsed */
27+
${(props) =>
28+
props.collapsed &&
29+
`
30+
position: sticky;
31+
right: 0;
32+
z-index: 10;
33+
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.2);
34+
`}
35+
`;
36+
37+
const FullView = styled.div<{ visible: boolean }>`
38+
display: ${(props) => (props.visible ? "flex" : "none")};
39+
flex-direction: column;
40+
height: 100%;
41+
`;
42+
43+
const CollapsedView = styled.div<{ visible: boolean }>`
44+
display: ${(props) => (props.visible ? "flex" : "none")};
45+
height: 100%;
1846
`;
1947

2048
const TabBar = styled.div`
@@ -56,58 +84,103 @@ type TabType = "costs" | "tools";
5684

5785
interface ChatMetaSidebarProps {
5886
workspaceId: string;
87+
chatAreaRef: React.RefObject<HTMLDivElement>;
5988
}
6089

61-
export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId }) => {
90+
export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId, chatAreaRef }) => {
6291
const [selectedTab, setSelectedTab] = usePersistedState<TabType>(
6392
`chat-meta-sidebar-tab:${workspaceId}`,
6493
"costs"
6594
);
6695

96+
const { stats } = useChatContext();
97+
const [use1M] = use1MContext();
98+
const chatAreaSize = useResizeObserver(chatAreaRef);
99+
67100
const baseId = `chat-meta-${workspaceId}`;
68101
const costsTabId = `${baseId}-tab-costs`;
69102
const toolsTabId = `${baseId}-tab-tools`;
70103
const costsPanelId = `${baseId}-panel-costs`;
71104
const toolsPanelId = `${baseId}-panel-tools`;
72105

106+
const lastUsage = stats?.usageHistory[stats.usageHistory.length - 1];
107+
108+
// Memoize vertical meter data calculation to prevent unnecessary re-renders
109+
const verticalMeterData = React.useMemo(() => {
110+
return lastUsage && stats
111+
? calculateTokenMeterData(lastUsage, stats.model, use1M, true)
112+
: { segments: [], totalTokens: 0, totalPercentage: 0 };
113+
}, [lastUsage, stats, use1M]);
114+
115+
// Calculate if we should show collapsed view with hysteresis
116+
// Strategy: Observe ChatArea width directly (independent of sidebar width)
117+
// - ChatArea has min-width: 750px and flex: 1
118+
// - Use hysteresis to prevent oscillation:
119+
// * Collapse when chatAreaWidth <= 800px (tight space)
120+
// * Expand when chatAreaWidth >= 1100px (lots of space)
121+
// * Between 800-1100: maintain current state (dead zone)
122+
const COLLAPSE_THRESHOLD = 800; // Collapse below this
123+
const EXPAND_THRESHOLD = 1100; // Expand above this
124+
const chatAreaWidth = chatAreaSize?.width ?? 1000; // Default to large to avoid flash
125+
126+
const [showCollapsed, setShowCollapsed] = React.useState(false);
127+
128+
React.useEffect(() => {
129+
if (chatAreaWidth <= COLLAPSE_THRESHOLD) {
130+
setShowCollapsed(true);
131+
} else if (chatAreaWidth >= EXPAND_THRESHOLD) {
132+
setShowCollapsed(false);
133+
}
134+
// Between thresholds: maintain current state (no change)
135+
}, [chatAreaWidth]);
136+
73137
return (
74-
<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>
138+
<SidebarContainer
139+
collapsed={showCollapsed}
140+
role="complementary"
141+
aria-label="Workspace insights"
142+
>
143+
<FullView visible={!showCollapsed}>
144+
<TabBar role="tablist" aria-label="Metadata views">
145+
<TabButton
146+
active={selectedTab === "costs"}
147+
onClick={() => setSelectedTab("costs")}
148+
id={costsTabId}
149+
role="tab"
150+
type="button"
151+
aria-selected={selectedTab === "costs"}
152+
aria-controls={costsPanelId}
153+
>
154+
Costs
155+
</TabButton>
156+
<TabButton
157+
active={selectedTab === "tools"}
158+
onClick={() => setSelectedTab("tools")}
159+
id={toolsTabId}
160+
role="tab"
161+
type="button"
162+
aria-selected={selectedTab === "tools"}
163+
aria-controls={toolsPanelId}
164+
>
165+
Tools
166+
</TabButton>
167+
</TabBar>
168+
<TabContent>
169+
{selectedTab === "costs" && (
170+
<div role="tabpanel" id={costsPanelId} aria-labelledby={costsTabId}>
171+
<CostsTab />
172+
</div>
173+
)}
174+
{selectedTab === "tools" && (
175+
<div role="tabpanel" id={toolsPanelId} aria-labelledby={toolsTabId}>
176+
<ToolsTab />
177+
</div>
178+
)}
179+
</TabContent>
180+
</FullView>
181+
<CollapsedView visible={showCollapsed}>
182+
<VerticalTokenMeter data={verticalMeterData} />
183+
</CollapsedView>
111184
</SidebarContainer>
112185
);
113186
};

src/components/ChatMetaSidebar/CostsTab.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { usePersistedState } from "@/hooks/usePersistedState";
88
import { ToggleGroup, type ToggleOption } from "../ToggleGroup";
99
import { use1MContext } from "@/hooks/use1MContext";
1010
import { supports1MContext } from "@/utils/ai/models";
11+
import { TOKEN_COMPONENT_COLORS } from "@/utils/tokens/tokenMeterUtils";
1112

1213
const Container = styled.div`
1314
color: #d4d4d4;
@@ -86,14 +87,6 @@ interface SegmentProps {
8687
percentage: number;
8788
}
8889

89-
// Component color mapping - single source of truth for all cost component colors
90-
const COMPONENT_COLORS = {
91-
cached: "var(--color-token-cached)",
92-
input: "var(--color-token-input)",
93-
output: "var(--color-token-output)",
94-
thinking: "var(--color-thinking-mode)",
95-
} as const;
96-
9790
const FixedSegment = styled.div<SegmentProps>`
9891
height: 100%;
9992
width: ${(props) => props.percentage}%;
@@ -111,28 +104,28 @@ const VariableSegment = styled.div<SegmentProps>`
111104
const InputSegment = styled.div<SegmentProps>`
112105
height: 100%;
113106
width: ${(props) => props.percentage}%;
114-
background: ${COMPONENT_COLORS.input};
107+
background: ${TOKEN_COMPONENT_COLORS.input};
115108
transition: width 0.3s ease;
116109
`;
117110

118111
const OutputSegment = styled.div<SegmentProps>`
119112
height: 100%;
120113
width: ${(props) => props.percentage}%;
121-
background: ${COMPONENT_COLORS.output};
114+
background: ${TOKEN_COMPONENT_COLORS.output};
122115
transition: width 0.3s ease;
123116
`;
124117

125118
const ThinkingSegment = styled.div<SegmentProps>`
126119
height: 100%;
127120
width: ${(props) => props.percentage}%;
128-
background: ${COMPONENT_COLORS.thinking};
121+
background: ${TOKEN_COMPONENT_COLORS.thinking};
129122
transition: width 0.3s ease;
130123
`;
131124

132125
const CachedSegment = styled.div<SegmentProps>`
133126
height: 100%;
134127
width: ${(props) => props.percentage}%;
135-
background: ${COMPONENT_COLORS.cached};
128+
background: ${TOKEN_COMPONENT_COLORS.cached};
136129
transition: width 0.3s ease;
137130
`;
138131

@@ -452,35 +445,35 @@ export const CostsTab: React.FC = () => {
452445
name: "Cache Read",
453446
tokens: displayUsage.cached.tokens,
454447
cost: displayUsage.cached.cost_usd,
455-
color: COMPONENT_COLORS.cached,
448+
color: TOKEN_COMPONENT_COLORS.cached,
456449
show: displayUsage.cached.tokens > 0,
457450
},
458451
{
459452
name: "Cache Create",
460453
tokens: displayUsage.cacheCreate.tokens,
461454
cost: displayUsage.cacheCreate.cost_usd,
462-
color: COMPONENT_COLORS.cached,
455+
color: TOKEN_COMPONENT_COLORS.cached,
463456
show: displayUsage.cacheCreate.tokens > 0,
464457
},
465458
{
466459
name: "Input",
467460
tokens: displayUsage.input.tokens,
468461
cost: adjustedInputCost,
469-
color: COMPONENT_COLORS.input,
462+
color: TOKEN_COMPONENT_COLORS.input,
470463
show: true,
471464
},
472465
{
473466
name: "Output",
474467
tokens: displayUsage.output.tokens,
475468
cost: adjustedOutputCost,
476-
color: COMPONENT_COLORS.output,
469+
color: TOKEN_COMPONENT_COLORS.output,
477470
show: true,
478471
},
479472
{
480473
name: "Thinking",
481474
tokens: displayUsage.reasoning.tokens,
482475
cost: adjustedReasoningCost,
483-
color: COMPONENT_COLORS.thinking,
476+
color: TOKEN_COMPONENT_COLORS.thinking,
484477
show: displayUsage.reasoning.tokens > 0,
485478
},
486479
].filter((c) => c.show)

0 commit comments

Comments
 (0)