Skip to content

Commit b2aa99e

Browse files
authored
🤖 feat: add context usage indicator to ChatInput (#1188)
## Summary Adds a horizontal context usage progress bar to the ChatInput component, positioned to the left of the Exec/Plan mode selector. This replaces the need for the 20px collapsed sidebar view that was previously used to show context usage when the right sidebar auto-collapsed on small screens. ## Changes ### New Components - **`ContextUsageIndicatorButton`** - 80px horizontal progress bar in ChatInput - Shows current context window usage with colored segments - Hover shows tooltip with `ContextUsageBar` component - Click opens popover with detailed usage + auto-compaction threshold slider - Hidden when no token data available (`totalTokens === 0`) - **`ContextUsageBar`** - Shared horizontal bar component (also used in CostsTab) - Displays "Context Usage" title with token counts - Includes threshold slider when `autoCompaction` prop provided ### Modified Behavior - **Right sidebar** now fully hides on small screens instead of showing 20px collapsed meter - Same hysteresis logic (collapse at ≤800px, expand at ≥1100px) - Returns `null` when hidden - no DOM element rendered - Context usage info still visible via ChatInput indicator - **TokenMeter** - Added `trackClassName` prop for customizable background track color (`bg-dark` used in ChatInput for better contrast) ## Screenshots The indicator appears as a small progress bar next to the mode selector, showing context usage at a glance without taking up sidebar space. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent 429a6dd commit b2aa99e

File tree

7 files changed

+209
-208
lines changed

7 files changed

+209
-208
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ import {
4747
} from "@/browser/utils/slashCommands/suggestions";
4848
import { Tooltip, TooltipTrigger, TooltipContent, HelpIndicator } from "../ui/tooltip";
4949
import { ModeSelector } from "../ModeSelector";
50+
import { ContextUsageIndicatorButton } from "../ContextUsageIndicatorButton";
51+
import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore";
52+
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
53+
import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings";
54+
import { calculateTokenMeterData } from "@/common/utils/tokens/tokenMeterUtils";
5055
import {
5156
matchesKeybind,
5257
formatKeybind,
@@ -307,6 +312,25 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
307312
const preferredModel = sendMessageOptions.model;
308313
const baseModel = sendMessageOptions.baseModel;
309314

315+
// Context usage indicator data (workspace variant only)
316+
const workspaceIdForUsage = variant === "workspace" ? props.workspaceId : "";
317+
const usage = useWorkspaceUsage(workspaceIdForUsage);
318+
const { options: providerOptions } = useProviderOptions();
319+
const use1M = providerOptions.anthropic?.use1MContext ?? false;
320+
const lastUsage = usage?.liveUsage ?? usage?.lastContextUsage;
321+
const usageModel = lastUsage?.model ?? null;
322+
const contextUsageData = useMemo(() => {
323+
return lastUsage
324+
? calculateTokenMeterData(lastUsage, usageModel ?? "unknown", use1M, false)
325+
: { segments: [], totalTokens: 0, totalPercentage: 0 };
326+
}, [lastUsage, usageModel, use1M]);
327+
const { threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold } =
328+
useAutoCompactionSettings(workspaceIdForUsage, usageModel);
329+
const autoCompactionProps = useMemo(
330+
() => ({ threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold }),
331+
[autoCompactThreshold, setAutoCompactThreshold]
332+
);
333+
310334
const setPreferredModel = useCallback(
311335
(model: string) => {
312336
ensureModelInSettings(model); // Ensure model exists in Settings
@@ -1711,6 +1735,12 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
17111735
data-component="ModelControls"
17121736
data-tutorial="mode-selector"
17131737
>
1738+
{variant === "workspace" && (
1739+
<ContextUsageIndicatorButton
1740+
data={contextUsageData}
1741+
autoCompaction={autoCompactionProps}
1742+
/>
1743+
)}
17141744
<ModeSelector mode={mode} onChange={setMode} />
17151745
<Tooltip>
17161746
<TooltipTrigger asChild>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React from "react";
2+
import { Popover, PopoverTrigger, PopoverContent } from "./ui/popover";
3+
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
4+
import { ContextUsageBar } from "./RightSidebar/ContextUsageBar";
5+
import { TokenMeter } from "./RightSidebar/TokenMeter";
6+
import type { AutoCompactionConfig } from "./RightSidebar/ThresholdSlider";
7+
import { formatTokens, type TokenMeterData } from "@/common/utils/tokens/tokenMeterUtils";
8+
9+
interface ContextUsageIndicatorButtonProps {
10+
data: TokenMeterData;
11+
autoCompaction?: AutoCompactionConfig;
12+
}
13+
14+
export const ContextUsageIndicatorButton: React.FC<ContextUsageIndicatorButtonProps> = ({
15+
data,
16+
autoCompaction,
17+
}) => {
18+
const [popoverOpen, setPopoverOpen] = React.useState(false);
19+
20+
if (data.totalTokens === 0) return null;
21+
22+
const ariaLabel = data.maxTokens
23+
? `Context usage: ${formatTokens(data.totalTokens)} / ${formatTokens(data.maxTokens)} (${data.totalPercentage.toFixed(
24+
1
25+
)}%)`
26+
: `Context usage: ${formatTokens(data.totalTokens)} (unknown limit)`;
27+
28+
return (
29+
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
30+
<Tooltip {...(popoverOpen ? { open: false } : {})}>
31+
<TooltipTrigger asChild>
32+
<PopoverTrigger asChild>
33+
<button
34+
aria-label={ariaLabel}
35+
className="hover:bg-sidebar-hover flex h-6 w-20 cursor-pointer items-center rounded px-1"
36+
type="button"
37+
>
38+
<TokenMeter
39+
segments={data.segments}
40+
orientation="horizontal"
41+
className="h-2"
42+
trackClassName="bg-dark"
43+
/>
44+
</button>
45+
</PopoverTrigger>
46+
</TooltipTrigger>
47+
<TooltipContent side="bottom" align="end" className="w-80">
48+
<ContextUsageBar data={data} />
49+
</TooltipContent>
50+
</Tooltip>
51+
52+
<PopoverContent side="bottom" align="end" className="w-80 overflow-visible p-3">
53+
<ContextUsageBar data={data} autoCompaction={autoCompaction} />
54+
</PopoverContent>
55+
</Popover>
56+
);
57+
};

src/browser/components/RightSidebar.tsx

Lines changed: 41 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ export interface ReviewStats {
2222
}
2323

2424
interface SidebarContainerProps {
25-
collapsed: boolean;
2625
wide?: boolean;
2726
/** Custom width from drag-resize (persisted per-tab by AIView) */
2827
customWidth?: number;
@@ -37,37 +36,31 @@ interface SidebarContainerProps {
3736
* SidebarContainer - Main sidebar wrapper with dynamic width
3837
*
3938
* Width priority (first match wins):
40-
* 1. collapsed (20px) - Shows vertical token meter only
41-
* 2. customWidth - From drag-resize (persisted per-tab)
42-
* 3. wide - Auto-calculated max width for Review tab (when not drag-resizing)
43-
* 4. default (300px) - Costs tab when no customWidth saved
39+
* 1. customWidth - From drag-resize (persisted per-tab)
40+
* 2. wide - Auto-calculated max width for Review tab (when not drag-resizing)
41+
* 3. default (300px) - Costs tab when no customWidth saved
4442
*/
4543
const SidebarContainer: React.FC<SidebarContainerProps> = ({
46-
collapsed,
4744
wide,
4845
customWidth,
4946
isResizing,
5047
children,
5148
role,
5249
"aria-label": ariaLabel,
5350
}) => {
54-
const width = collapsed
55-
? "20px"
56-
: customWidth
57-
? `${customWidth}px`
58-
: wide
59-
? "min(1200px, calc(100vw - 400px))"
60-
: "300px";
51+
const width = customWidth
52+
? `${customWidth}px`
53+
: wide
54+
? "min(1200px, calc(100vw - 400px))"
55+
: "300px";
6156

6257
return (
6358
<div
6459
className={cn(
6560
"bg-sidebar border-l border-border-light flex flex-col overflow-hidden flex-shrink-0",
6661
!isResizing && "transition-[width] duration-200",
67-
collapsed && "sticky right-0 z-10 shadow-[-2px_0_4px_rgba(0,0,0,0.2)]",
68-
// Mobile: Show vertical meter when collapsed (20px), full width when expanded
69-
"max-md:border-l-0 max-md:border-t max-md:border-border-light",
70-
!collapsed && "max-md:w-full max-md:relative max-md:max-h-[50vh]"
62+
// Mobile: full width
63+
"max-md:border-l-0 max-md:border-t max-md:border-border-light max-md:w-full max-md:relative max-md:max-h-[50vh]"
7164
)}
7265
style={{ width }}
7366
role={role}
@@ -180,79 +173,73 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
180173
: { segments: [], totalTokens: 0, totalPercentage: 0 };
181174
}, [lastUsage, model, use1M]);
182175

183-
// Calculate if we should show collapsed view with hysteresis
184-
// Strategy: Observe ChatArea width directly (independent of sidebar width)
185-
// - ChatArea has min-width: 750px and flex: 1
186-
// - Use hysteresis to prevent oscillation:
187-
// * Collapse when chatAreaWidth <= 800px (tight space)
188-
// * Expand when chatAreaWidth >= 1100px (lots of space)
189-
// * Between 800-1100: maintain current state (dead zone)
190-
const COLLAPSE_THRESHOLD = 800; // Collapse below this
191-
const EXPAND_THRESHOLD = 1100; // Expand above this
176+
// Auto-hide sidebar on small screens using hysteresis to prevent oscillation
177+
// - Observe ChatArea width directly (independent of sidebar width)
178+
// - ChatArea has min-width and flex: 1
179+
// - Collapse when chatAreaWidth <= 800px (tight space)
180+
// - Expand when chatAreaWidth >= 1100px (lots of space)
181+
// - Between 800-1100: maintain current state (dead zone)
182+
const COLLAPSE_THRESHOLD = 800;
183+
const EXPAND_THRESHOLD = 1100;
192184
const chatAreaWidth = chatAreaSize?.width ?? 1000; // Default to large to avoid flash
193185

194186
// Persist collapsed state globally (not per-workspace) since chat area width is shared
195-
// This prevents animation flash when switching workspaces - sidebar maintains its state
196-
const [showCollapsed, setShowCollapsed] = usePersistedState<boolean>(
197-
RIGHT_SIDEBAR_COLLAPSED_KEY,
198-
false
199-
);
187+
const [isHidden, setIsHidden] = usePersistedState<boolean>(RIGHT_SIDEBAR_COLLAPSED_KEY, false);
200188

201189
React.useEffect(() => {
202-
// Never collapse when Review tab is active - code review needs space
190+
// Never hide when Review tab is active - code review needs space
203191
if (selectedTab === "review") {
204-
if (showCollapsed) {
205-
setShowCollapsed(false);
192+
if (isHidden) {
193+
setIsHidden(false);
206194
}
207195
return;
208196
}
209197

210-
// If the sidebar is custom-resized (wider than the default Costs width),
211-
// auto-collapse based on chatAreaWidth can oscillate between expanded and
212-
// collapsed states (because collapsed is 20px but expanded can be much wider),
213-
// which looks like a constant flash. In that case, keep it expanded and let
214-
// the user resize manually.
198+
// If sidebar is custom-resized wider than default, don't auto-hide
199+
// (would cause oscillation between hidden and wide states)
215200
if (width !== undefined && width > 300) {
216-
if (showCollapsed) {
217-
setShowCollapsed(false);
201+
if (isHidden) {
202+
setIsHidden(false);
218203
}
219204
return;
220205
}
221206

222-
// Normal hysteresis for Costs/Tools tabs
207+
// Normal hysteresis for Costs tab
223208
if (chatAreaWidth <= COLLAPSE_THRESHOLD) {
224-
setShowCollapsed(true);
209+
setIsHidden(true);
225210
} else if (chatAreaWidth >= EXPAND_THRESHOLD) {
226-
setShowCollapsed(false);
211+
setIsHidden(false);
227212
}
228213
// Between thresholds: maintain current state (no change)
229-
}, [chatAreaWidth, selectedTab, showCollapsed, setShowCollapsed, width]);
214+
}, [chatAreaWidth, selectedTab, isHidden, setIsHidden, width]);
230215

231-
// Single render point for VerticalTokenMeter
232-
// Shows when: (1) collapsed, OR (2) Review tab is active
233-
const showMeter = showCollapsed || selectedTab === "review";
216+
// Vertical meter only shows on Review tab (context usage indicator is now in ChatInput)
234217
const autoCompactionProps = React.useMemo(
235218
() => ({
236219
threshold: autoCompactThreshold,
237220
setThreshold: setAutoCompactThreshold,
238221
}),
239222
[autoCompactThreshold, setAutoCompactThreshold]
240223
);
241-
const verticalMeter = showMeter ? (
242-
<VerticalTokenMeter data={verticalMeterData} autoCompaction={autoCompactionProps} />
243-
) : null;
224+
const verticalMeter =
225+
selectedTab === "review" ? (
226+
<VerticalTokenMeter data={verticalMeterData} autoCompaction={autoCompactionProps} />
227+
) : null;
228+
229+
// Fully hide sidebar on small screens (context usage now shown in ChatInput)
230+
if (isHidden) {
231+
return null;
232+
}
244233

245234
return (
246235
<SidebarContainer
247-
collapsed={showCollapsed}
248236
wide={selectedTab === "review" && !width} // Auto-wide only if not drag-resizing
249237
customWidth={width} // Per-tab resized width from AIView
250238
isResizing={isResizing}
251239
role="complementary"
252240
aria-label="Workspace insights"
253241
>
254-
{/* Full view when not collapsed */}
255-
<div className={cn("flex-row h-full", !showCollapsed ? "flex" : "hidden")}>
242+
<div className="flex h-full flex-row">
256243
{/* Resize handle (left edge) */}
257244
{onStartResize && (
258245
<div
@@ -368,8 +355,6 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
368355
</div>
369356
</div>
370357
</div>
371-
{/* Render meter in collapsed view when sidebar is collapsed */}
372-
<div className={cn("h-full", showCollapsed ? "flex" : "hidden")}>{verticalMeter}</div>
373358
</SidebarContainer>
374359
);
375360
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React from "react";
2+
import { TokenMeter } from "./TokenMeter";
3+
import { HorizontalThresholdSlider, type AutoCompactionConfig } from "./ThresholdSlider";
4+
import { formatTokens, type TokenMeterData } from "@/common/utils/tokens/tokenMeterUtils";
5+
6+
interface ContextUsageBarProps {
7+
data: TokenMeterData;
8+
/** Auto-compaction settings for threshold slider */
9+
autoCompaction?: AutoCompactionConfig;
10+
showTitle?: boolean;
11+
testId?: string;
12+
}
13+
14+
const ContextUsageBarComponent: React.FC<ContextUsageBarProps> = ({
15+
data,
16+
autoCompaction,
17+
showTitle = true,
18+
testId,
19+
}) => {
20+
if (data.totalTokens === 0) return null;
21+
22+
const totalDisplay = formatTokens(data.totalTokens);
23+
const maxDisplay = data.maxTokens ? ` / ${formatTokens(data.maxTokens)}` : "";
24+
const percentageDisplay = data.maxTokens ? ` (${data.totalPercentage.toFixed(1)}%)` : "";
25+
26+
const showWarning = !data.maxTokens;
27+
28+
return (
29+
<div data-testid={testId} className="relative flex flex-col gap-1">
30+
<div className="flex items-baseline justify-between">
31+
{showTitle && (
32+
<span className="text-foreground inline-flex items-baseline gap-1 font-medium">
33+
Context Usage
34+
</span>
35+
)}
36+
<span className="text-muted text-xs">
37+
{totalDisplay}
38+
{maxDisplay}
39+
{percentageDisplay}
40+
</span>
41+
</div>
42+
43+
<div className="relative w-full py-2">
44+
<TokenMeter segments={data.segments} orientation="horizontal" />
45+
{autoCompaction && data.maxTokens && <HorizontalThresholdSlider config={autoCompaction} />}
46+
</div>
47+
48+
{showWarning && (
49+
<div className="text-subtle mt-2 text-[11px] italic">
50+
Unknown model limits - showing relative usage only
51+
</div>
52+
)}
53+
</div>
54+
);
55+
};
56+
57+
export const ContextUsageBar = React.memo(ContextUsageBarComponent);

0 commit comments

Comments
 (0)