Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ import {
} from "@/browser/utils/slashCommands/suggestions";
import { Tooltip, TooltipTrigger, TooltipContent, HelpIndicator } from "../ui/tooltip";
import { ModeSelector } from "../ModeSelector";
import { ContextUsageIndicatorButton } from "../ContextUsageIndicatorButton";
import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore";
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings";
import { calculateTokenMeterData } from "@/common/utils/tokens/tokenMeterUtils";
import {
matchesKeybind,
formatKeybind,
Expand Down Expand Up @@ -246,6 +251,25 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const preferredModel = sendMessageOptions.model;
const baseModel = sendMessageOptions.baseModel;

// Context usage indicator data (workspace variant only)
const workspaceIdForUsage = variant === "workspace" ? props.workspaceId : "";
const usage = useWorkspaceUsage(workspaceIdForUsage);
const { options: providerOptions } = useProviderOptions();
const use1M = providerOptions.anthropic?.use1MContext ?? false;
const lastUsage = usage?.liveUsage ?? usage?.lastContextUsage;
const usageModel = lastUsage?.model ?? null;
const contextUsageData = useMemo(() => {
return lastUsage
? calculateTokenMeterData(lastUsage, usageModel ?? "unknown", use1M, false)
: { segments: [], totalTokens: 0, totalPercentage: 0 };
}, [lastUsage, usageModel, use1M]);
const { threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold } =
useAutoCompactionSettings(workspaceIdForUsage, usageModel);
const autoCompactionProps = useMemo(
() => ({ threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold }),
[autoCompactThreshold, setAutoCompactThreshold]
);

const setPreferredModel = useCallback(
(model: string) => {
ensureModelInSettings(model); // Ensure model exists in Settings
Expand Down Expand Up @@ -1638,6 +1662,12 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
data-component="ModelControls"
data-tutorial="mode-selector"
>
{variant === "workspace" && (
<ContextUsageIndicatorButton
data={contextUsageData}
autoCompaction={autoCompactionProps}
/>
)}
<ModeSelector mode={mode} onChange={setMode} />
<Tooltip>
<TooltipTrigger asChild>
Expand Down
57 changes: 57 additions & 0 deletions src/browser/components/ContextUsageIndicatorButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from "react";
import { Popover, PopoverTrigger, PopoverContent } from "./ui/popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import { ContextUsageBar } from "./RightSidebar/ContextUsageBar";
import { TokenMeter } from "./RightSidebar/TokenMeter";
import type { AutoCompactionConfig } from "./RightSidebar/ThresholdSlider";
import { formatTokens, type TokenMeterData } from "@/common/utils/tokens/tokenMeterUtils";

interface ContextUsageIndicatorButtonProps {
data: TokenMeterData;
autoCompaction?: AutoCompactionConfig;
}

export const ContextUsageIndicatorButton: React.FC<ContextUsageIndicatorButtonProps> = ({
data,
autoCompaction,
}) => {
const [popoverOpen, setPopoverOpen] = React.useState(false);

if (data.totalTokens === 0) return null;

const ariaLabel = data.maxTokens
? `Context usage: ${formatTokens(data.totalTokens)} / ${formatTokens(data.maxTokens)} (${data.totalPercentage.toFixed(
1
)}%)`
: `Context usage: ${formatTokens(data.totalTokens)} (unknown limit)`;

return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Tooltip {...(popoverOpen ? { open: false } : {})}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
aria-label={ariaLabel}
className="hover:bg-sidebar-hover flex h-6 w-20 cursor-pointer items-center rounded px-1"
type="button"
>
<TokenMeter
segments={data.segments}
orientation="horizontal"
className="h-2"
trackClassName="bg-dark"
/>
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="end" className="w-80">
<ContextUsageBar data={data} />
</TooltipContent>
</Tooltip>

<PopoverContent side="bottom" align="end" className="w-80 overflow-visible p-3">
<ContextUsageBar data={data} autoCompaction={autoCompaction} />
</PopoverContent>
</Popover>
);
};
97 changes: 41 additions & 56 deletions src/browser/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export interface ReviewStats {
}

interface SidebarContainerProps {
collapsed: boolean;
wide?: boolean;
/** Custom width from drag-resize (persisted per-tab by AIView) */
customWidth?: number;
Expand All @@ -37,37 +36,31 @@ interface SidebarContainerProps {
* SidebarContainer - Main sidebar wrapper with dynamic width
*
* Width priority (first match wins):
* 1. collapsed (20px) - Shows vertical token meter only
* 2. customWidth - From drag-resize (persisted per-tab)
* 3. wide - Auto-calculated max width for Review tab (when not drag-resizing)
* 4. default (300px) - Costs tab when no customWidth saved
* 1. customWidth - From drag-resize (persisted per-tab)
* 2. wide - Auto-calculated max width for Review tab (when not drag-resizing)
* 3. default (300px) - Costs tab when no customWidth saved
*/
const SidebarContainer: React.FC<SidebarContainerProps> = ({
collapsed,
wide,
customWidth,
isResizing,
children,
role,
"aria-label": ariaLabel,
}) => {
const width = collapsed
? "20px"
: customWidth
? `${customWidth}px`
: wide
? "min(1200px, calc(100vw - 400px))"
: "300px";
const width = customWidth
? `${customWidth}px`
: wide
? "min(1200px, calc(100vw - 400px))"
: "300px";

return (
<div
className={cn(
"bg-sidebar border-l border-border-light flex flex-col overflow-hidden flex-shrink-0",
!isResizing && "transition-[width] duration-200",
collapsed && "sticky right-0 z-10 shadow-[-2px_0_4px_rgba(0,0,0,0.2)]",
// Mobile: Show vertical meter when collapsed (20px), full width when expanded
"max-md:border-l-0 max-md:border-t max-md:border-border-light",
!collapsed && "max-md:w-full max-md:relative max-md:max-h-[50vh]"
// Mobile: full width
"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]"
)}
style={{ width }}
role={role}
Expand Down Expand Up @@ -180,79 +173,73 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
: { segments: [], totalTokens: 0, totalPercentage: 0 };
}, [lastUsage, model, use1M]);

// Calculate if we should show collapsed view with hysteresis
// Strategy: Observe ChatArea width directly (independent of sidebar width)
// - ChatArea has min-width: 750px and flex: 1
// - Use hysteresis to prevent oscillation:
// * Collapse when chatAreaWidth <= 800px (tight space)
// * Expand when chatAreaWidth >= 1100px (lots of space)
// * Between 800-1100: maintain current state (dead zone)
const COLLAPSE_THRESHOLD = 800; // Collapse below this
const EXPAND_THRESHOLD = 1100; // Expand above this
// Auto-hide sidebar on small screens using hysteresis to prevent oscillation
// - Observe ChatArea width directly (independent of sidebar width)
// - ChatArea has min-width and flex: 1
// - Collapse when chatAreaWidth <= 800px (tight space)
// - Expand when chatAreaWidth >= 1100px (lots of space)
// - Between 800-1100: maintain current state (dead zone)
const COLLAPSE_THRESHOLD = 800;
const EXPAND_THRESHOLD = 1100;
const chatAreaWidth = chatAreaSize?.width ?? 1000; // Default to large to avoid flash

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

React.useEffect(() => {
// Never collapse when Review tab is active - code review needs space
// Never hide when Review tab is active - code review needs space
if (selectedTab === "review") {
if (showCollapsed) {
setShowCollapsed(false);
if (isHidden) {
setIsHidden(false);
}
return;
}

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

// Normal hysteresis for Costs/Tools tabs
// Normal hysteresis for Costs tab
if (chatAreaWidth <= COLLAPSE_THRESHOLD) {
setShowCollapsed(true);
setIsHidden(true);
} else if (chatAreaWidth >= EXPAND_THRESHOLD) {
setShowCollapsed(false);
setIsHidden(false);
}
// Between thresholds: maintain current state (no change)
}, [chatAreaWidth, selectedTab, showCollapsed, setShowCollapsed, width]);
}, [chatAreaWidth, selectedTab, isHidden, setIsHidden, width]);

// Single render point for VerticalTokenMeter
// Shows when: (1) collapsed, OR (2) Review tab is active
const showMeter = showCollapsed || selectedTab === "review";
// Vertical meter only shows on Review tab (context usage indicator is now in ChatInput)
const autoCompactionProps = React.useMemo(
() => ({
threshold: autoCompactThreshold,
setThreshold: setAutoCompactThreshold,
}),
[autoCompactThreshold, setAutoCompactThreshold]
);
const verticalMeter = showMeter ? (
<VerticalTokenMeter data={verticalMeterData} autoCompaction={autoCompactionProps} />
) : null;
const verticalMeter =
selectedTab === "review" ? (
<VerticalTokenMeter data={verticalMeterData} autoCompaction={autoCompactionProps} />
) : null;

// Fully hide sidebar on small screens (context usage now shown in ChatInput)
if (isHidden) {
return null;
}

return (
<SidebarContainer
collapsed={showCollapsed}
wide={selectedTab === "review" && !width} // Auto-wide only if not drag-resizing
customWidth={width} // Per-tab resized width from AIView
isResizing={isResizing}
role="complementary"
aria-label="Workspace insights"
>
{/* Full view when not collapsed */}
<div className={cn("flex-row h-full", !showCollapsed ? "flex" : "hidden")}>
<div className="flex h-full flex-row">
{/* Resize handle (left edge) */}
{onStartResize && (
<div
Expand Down Expand Up @@ -368,8 +355,6 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
</div>
</div>
</div>
{/* Render meter in collapsed view when sidebar is collapsed */}
<div className={cn("h-full", showCollapsed ? "flex" : "hidden")}>{verticalMeter}</div>
</SidebarContainer>
);
};
Expand Down
57 changes: 57 additions & 0 deletions src/browser/components/RightSidebar/ContextUsageBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from "react";
import { TokenMeter } from "./TokenMeter";
import { HorizontalThresholdSlider, type AutoCompactionConfig } from "./ThresholdSlider";
import { formatTokens, type TokenMeterData } from "@/common/utils/tokens/tokenMeterUtils";

interface ContextUsageBarProps {
data: TokenMeterData;
/** Auto-compaction settings for threshold slider */
autoCompaction?: AutoCompactionConfig;
showTitle?: boolean;
testId?: string;
}

const ContextUsageBarComponent: React.FC<ContextUsageBarProps> = ({
data,
autoCompaction,
showTitle = true,
testId,
}) => {
if (data.totalTokens === 0) return null;

const totalDisplay = formatTokens(data.totalTokens);
const maxDisplay = data.maxTokens ? ` / ${formatTokens(data.maxTokens)}` : "";
const percentageDisplay = data.maxTokens ? ` (${data.totalPercentage.toFixed(1)}%)` : "";

const showWarning = !data.maxTokens;

return (
<div data-testid={testId} className="relative flex flex-col gap-1">
<div className="flex items-baseline justify-between">
{showTitle && (
<span className="text-foreground inline-flex items-baseline gap-1 font-medium">
Context Usage
</span>
)}
<span className="text-muted text-xs">
{totalDisplay}
{maxDisplay}
{percentageDisplay}
</span>
</div>

<div className="relative w-full py-2">
<TokenMeter segments={data.segments} orientation="horizontal" />
{autoCompaction && data.maxTokens && <HorizontalThresholdSlider config={autoCompaction} />}
</div>

{showWarning && (
<div className="text-subtle mt-2 text-[11px] italic">
Unknown model limits - showing relative usage only
</div>
)}
</div>
);
};

export const ContextUsageBar = React.memo(ContextUsageBarComponent);
Loading