From a35095095768c560d2eae5ef0bb45d4d95a891f0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 11:02:51 -0600 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=A4=96=20feat:=20improve=20auto-com?= =?UTF-8?q?pact=20UI=20with=20draggable=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ThresholdSlider component for draggable threshold indicator on Context Usage bar (horizontal and vertical orientations) - Slider extends above/below bar for visibility, shows during drag - Dragging to 100% disables auto-compaction, dragging back enables - Tooltip shows live feedback during drag operation - Remove AutoCompactionSettings component (replaced by slider) - Store threshold per-model (not per-workspace) since different models have different context windows - Unify CompactionWarning styles: both states use subtle right-aligned text; at-threshold state is bold blue instead of large block - Add prependText method to ChatInputAPI for prepending text - Clicking warning text inserts /compact command - Add Storybook coverage for auto-compaction warning state _Generated with mux_ --- src/browser/App.stories.tsx | 167 ++++++++++++++++++ src/browser/components/AIView.tsx | 20 ++- src/browser/components/ChatInput/index.tsx | 20 ++- src/browser/components/ChatInput/types.ts | 1 + src/browser/components/CompactionWarning.tsx | 41 +++-- src/browser/components/RightSidebar.tsx | 29 ++- .../RightSidebar/AutoCompactionSettings.tsx | 65 ------- .../components/RightSidebar/CostsTab.tsx | 33 +++- .../RightSidebar/ThresholdSlider.tsx | 165 +++++++++++++++++ .../RightSidebar/VerticalTokenMeter.tsx | 31 +++- .../hooks/useAutoCompactionSettings.ts | 17 +- src/common/constants/storage.ts | 11 +- 12 files changed, 482 insertions(+), 118 deletions(-) delete mode 100644 src/browser/components/RightSidebar/AutoCompactionSettings.tsx create mode 100644 src/browser/components/RightSidebar/ThresholdSlider.tsx diff --git a/src/browser/App.stories.tsx b/src/browser/App.stories.tsx index ff4c30db0e..6358066be3 100644 --- a/src/browser/App.stories.tsx +++ b/src/browser/App.stories.tsx @@ -1487,3 +1487,170 @@ These tables should render cleanly without any disruptive copy or download actio return ; }, }; + +/** + * Story showing the auto-compaction warning when context usage is approaching the threshold. + * The warning appears above the chat input when usage is >= 60% (threshold 70% minus 10% warning advance). + * claude-sonnet-4-5 has max_input_tokens: 200,000, so we set usage to ~130,000 tokens (65%) to trigger warning. + */ +export const AutoCompactionWarning: Story = { + render: () => { + const workspaceId = "ws-high-usage"; + + const projects = new Map([ + [ + "/home/user/projects/my-app", + { + workspaces: [ + { path: "/home/user/.mux/src/my-app/feature", id: workspaceId, name: "main" }, + ], + }, + ], + ]); + + const workspaces: FrontendWorkspaceMetadata[] = [ + { + id: workspaceId, + name: "main", + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.mux/src/my-app/feature", + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + createdAt: new Date(NOW - 3600000).toISOString(), + }, + ]; + + const AppWithHighUsage: React.FC = () => { + const initialized = useRef(false); + if (!initialized.current) { + // Enable auto-compaction for this workspace (enabled per-workspace, threshold per-model) + localStorage.setItem(`autoCompaction:enabled:${workspaceId}`, "true"); + localStorage.setItem(`autoCompaction:threshold:claude-sonnet-4-5`, "70"); + + setupMockAPI({ + projects, + workspaces, + apiOverrides: { + tokenizer: { + countTokens: () => Promise.resolve(100), + countTokensBatch: (_model, texts) => Promise.resolve(texts.map(() => 100)), + calculateStats: () => + Promise.resolve({ + consumers: [], + totalTokens: 0, + model: "claude-sonnet-4-5", + tokenizerName: "claude", + usageHistory: [], + }), + }, + providers: { + setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), + setModels: () => Promise.resolve({ success: true, data: undefined }), + getConfig: () => + Promise.resolve( + {} as Record + ), + list: () => Promise.resolve(["anthropic"]), + }, + workspace: { + create: (projectPath: string, branchName: string) => + Promise.resolve({ + success: true, + metadata: { + id: Math.random().toString(36).substring(2, 12), + name: branchName, + projectPath, + projectName: projectPath.split("/").pop() ?? "project", + namedWorkspacePath: `/mock/workspace/${branchName}`, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }, + }), + list: () => Promise.resolve(workspaces), + rename: (wsId: string) => + Promise.resolve({ success: true, data: { newWorkspaceId: wsId } }), + remove: () => Promise.resolve({ success: true }), + fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), + openTerminal: () => Promise.resolve(undefined), + onChat: (wsId, callback) => { + if (wsId === workspaceId) { + setTimeout(() => { + // User message + callback({ + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "Help me with this large codebase" }], + metadata: { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 60000, + }, + }); + + // Assistant message with HIGH usage to trigger compaction warning + // 130,000 tokens = 65% of 200,000 max, which is above 60% warning threshold + callback({ + id: "msg-2", + role: "assistant", + parts: [ + { + type: "text", + text: "I've analyzed the codebase. The context window is getting full - notice the compaction warning below!", + }, + ], + metadata: { + historySequence: 2, + timestamp: STABLE_TIMESTAMP, + model: "claude-sonnet-4-5", + usage: { + inputTokens: 125000, // High input to trigger warning + outputTokens: 5000, + totalTokens: 130000, + }, + duration: 5000, + }, + }); + + callback({ type: "caught-up" }); + }, 100); + } + return () => undefined; + }, + onMetadata: () => () => undefined, + activity: { + list: () => Promise.resolve({}), + subscribe: () => () => undefined, + }, + sendMessage: () => Promise.resolve({ success: true, data: undefined }), + resumeStream: () => Promise.resolve({ success: true, data: undefined }), + interruptStream: () => Promise.resolve({ success: true, data: undefined }), + clearQueue: () => Promise.resolve({ success: true, data: undefined }), + truncateHistory: () => Promise.resolve({ success: true, data: undefined }), + replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), + getInfo: () => Promise.resolve(null), + executeBash: () => + Promise.resolve({ + success: true, + data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, + }), + }, + }, + }); + + localStorage.setItem( + "selectedWorkspace", + JSON.stringify({ + workspaceId: workspaceId, + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.mux/src/my-app/feature", + }) + ); + + initialized.current = true; + } + + return ; + }; + + return ; + }, +}; diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 9ebff9d198..dfa1d6de94 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -87,8 +87,12 @@ const AIViewInner: React.FC = ({ const workspaceUsage = useWorkspaceUsage(workspaceId); const { options } = useProviderOptions(); const use1M = options.anthropic?.use1MContext ?? false; + // Get pending model for auto-compaction settings (threshold is per-model) + const pendingSendOptions = useSendMessageOptions(workspaceId); + const pendingModel = pendingSendOptions.model; + const { enabled: autoCompactionEnabled, threshold: autoCompactionThreshold } = - useAutoCompactionSettings(workspaceId); + useAutoCompactionSettings(workspaceId, pendingModel); const handledModelErrorsRef = useRef>(new Set()); useEffect(() => { @@ -121,9 +125,6 @@ const AIViewInner: React.FC = ({ undefined ); - // Use send options for auto-compaction check - const pendingSendOptions = useSendMessageOptions(workspaceId); - // Track if we've already triggered force compaction for this stream const forceCompactionTriggeredRef = useRef(null); @@ -133,11 +134,6 @@ const AIViewInner: React.FC = ({ // Get active stream message ID for token counting const activeStreamMessageId = aggregator.getActiveStreamMessageId(); - // Use pending send model for auto-compaction check, not the last stream's model. - // This ensures the threshold is based on the model the user will actually send with, - // preventing context-length errors when switching from a large-context to smaller model. - const pendingModel = pendingSendOptions.model; - const autoCompactionResult = checkAutoCompaction( workspaceUsage, pendingModel, @@ -217,6 +213,11 @@ const AIViewInner: React.FC = ({ chatInputAPI.current?.appendText(note); }, []); + // Handler for manual compaction from CompactionWarning click + const handleCompactClick = useCallback(() => { + chatInputAPI.current?.prependText("/compact\n"); + }, []); + // Thinking level state from context const { thinkingLevel: currentWorkspaceThinking, setThinkingLevel } = useThinking(); @@ -573,6 +574,7 @@ const AIViewInner: React.FC = ({ )} = (props) => { [setInput] ); + // Method to prepend text to input (used by manual compact trigger) + const prependText = useCallback( + (text: string) => { + setInput((prev) => text + prev); + focusMessageInput(); + }, + [focusMessageInput, setInput] + ); + // Method to restore images to input (used by queued message edit) const restoreImages = useCallback((images: ImagePart[]) => { const attachments: ImageAttachment[] = images.map((img, index) => ({ @@ -277,10 +286,19 @@ export const ChatInput: React.FC = (props) => { focus: focusMessageInput, restoreText, appendText, + prependText, restoreImages, }); } - }, [props.onReady, focusMessageInput, restoreText, appendText, restoreImages, props]); + }, [ + props.onReady, + focusMessageInput, + restoreText, + appendText, + prependText, + restoreImages, + props, + ]); useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { diff --git a/src/browser/components/ChatInput/types.ts b/src/browser/components/ChatInput/types.ts index d8ce816872..c279930272 100644 --- a/src/browser/components/ChatInput/types.ts +++ b/src/browser/components/ChatInput/types.ts @@ -6,6 +6,7 @@ export interface ChatInputAPI { focus: () => void; restoreText: (text: string) => void; appendText: (text: string) => void; + prependText: (text: string) => void; restoreImages: (images: ImagePart[]) => void; } diff --git a/src/browser/components/CompactionWarning.tsx b/src/browser/components/CompactionWarning.tsx index 0216ad81aa..8c970ef81a 100644 --- a/src/browser/components/CompactionWarning.tsx +++ b/src/browser/components/CompactionWarning.tsx @@ -1,40 +1,43 @@ import React from "react"; /** - * Warning banner shown when context usage is approaching the compaction threshold. + * Warning indicator shown when context usage is approaching the compaction threshold. * - * Displays progressive warnings: - * - Below threshold: "Context left until Auto-Compact: X% remaining" (where X = threshold - current) - * - At/above threshold: "Approaching context limit. Next message will trigger auto-compaction." + * Displays as subtle right-aligned text: + * - Below threshold: "Auto-Compact in X% usage" (where X = threshold - current) + * - At/above threshold: Bold "Next message will Auto-Compact" * - * Displayed above ChatInput when: - * - Token usage >= (threshold - 10%) of model's context window - * - Not currently compacting (user can still send messages) + * Both states are clickable to insert /compact command. * * @param usagePercentage - Current token usage as percentage (0-100) * @param thresholdPercentage - Auto-compaction trigger threshold (0-100, default 70) + * @param onCompactClick - Callback when user clicks to trigger manual compaction */ export const CompactionWarning: React.FC<{ usagePercentage: number; thresholdPercentage: number; + onCompactClick?: () => void; }> = (props) => { // At threshold or above, next message will trigger compaction const willCompactNext = props.usagePercentage >= props.thresholdPercentage; + const remaining = props.thresholdPercentage - props.usagePercentage; - // Urgent warning at/above threshold - prominent blue box - if (willCompactNext) { - return ( -
- ⚠️ Context limit reached. Next message will trigger Auto-Compaction. -
- ); - } + const text = willCompactNext + ? "Next message will Auto-Compact" + : `Auto-Compact in ${Math.round(remaining)}% usage`; - // Countdown warning below threshold - subtle grey text, right-aligned - const remaining = props.thresholdPercentage - props.usagePercentage; return ( -
- Context left until Auto-Compact: {Math.round(remaining)}% +
+
); }; diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 2308c9d265..2820b4d004 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -3,6 +3,7 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { useResizeObserver } from "@/browser/hooks/useResizeObserver"; +import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings"; import { CostsTab } from "./RightSidebar/CostsTab"; import { VerticalTokenMeter } from "./RightSidebar/VerticalTokenMeter"; import { ReviewPanel } from "./RightSidebar/CodeReview/ReviewPanel"; @@ -135,15 +136,22 @@ const RightSidebarComponent: React.FC = ({ const reviewPanelId = `${baseId}-panel-review`; const lastUsage = usage?.liveUsage ?? usage?.usageHistory[usage.usageHistory.length - 1]; + const model = lastUsage?.model ?? null; + + // Auto-compaction settings: enabled per-workspace, threshold per-model + const { + enabled: autoCompactEnabled, + setEnabled: setAutoCompactEnabled, + threshold: autoCompactThreshold, + setThreshold: setAutoCompactThreshold, + } = useAutoCompactionSettings(workspaceId, model); // Memoize vertical meter data calculation to prevent unnecessary re-renders const verticalMeterData = React.useMemo(() => { - // Get model from last usage - const model = lastUsage?.model ?? "unknown"; return lastUsage - ? calculateTokenMeterData(lastUsage, model, use1M, true) + ? calculateTokenMeterData(lastUsage, model ?? "unknown", use1M, true) : { segments: [], totalTokens: 0, totalPercentage: 0 }; - }, [lastUsage, use1M]); + }, [lastUsage, model, use1M]); // Calculate if we should show collapsed view with hysteresis // Strategy: Observe ChatArea width directly (independent of sidebar width) @@ -184,7 +192,18 @@ const RightSidebarComponent: React.FC = ({ // Single render point for VerticalTokenMeter // Shows when: (1) collapsed, OR (2) Review tab is active const showMeter = showCollapsed || selectedTab === "review"; - const verticalMeter = showMeter ? : null; + const autoCompactionProps = React.useMemo( + () => ({ + enabled: autoCompactEnabled, + threshold: autoCompactThreshold, + setEnabled: setAutoCompactEnabled, + setThreshold: setAutoCompactThreshold, + }), + [autoCompactEnabled, autoCompactThreshold, setAutoCompactEnabled, setAutoCompactThreshold] + ); + const verticalMeter = showMeter ? ( + + ) : null; return ( = ({ workspaceId }) => { - const { enabled, setEnabled, threshold, setThreshold } = useAutoCompactionSettings(workspaceId); - const { localValue, handleChange, handleBlur } = useClampedNumberInput( - threshold, - setThreshold, - AUTO_COMPACTION_THRESHOLD_MIN, - AUTO_COMPACTION_THRESHOLD_MAX - ); - - return ( -
-
- {/* Left side: checkbox + label + tooltip */} -
- - - ? - - Automatically compact conversation history when context usage reaches the threshold - - -
- - {/* Right side: input + % symbol */} -
- - % -
-
-
- ); -}; diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index a48b5786b2..404aa96890 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -8,7 +8,8 @@ import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { supports1MContext } from "@/common/utils/ai/models"; import { TOKEN_COMPONENT_COLORS } from "@/common/utils/tokens/tokenMeterUtils"; import { ConsumerBreakdown } from "./ConsumerBreakdown"; -import { AutoCompactionSettings } from "./AutoCompactionSettings"; +import { ThresholdSlider } from "./ThresholdSlider"; +import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings"; // Format token display - show k for thousands with 1 decimal const formatTokens = (tokens: number) => @@ -63,6 +64,18 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { const { options } = useProviderOptions(); const use1M = options.anthropic?.use1MContext ?? false; + // Get model from context usage for per-model threshold storage + const contextUsage = usage.liveUsage ?? usage.usageHistory[usage.usageHistory.length - 1]; + const currentModel = contextUsage?.model ?? null; + + // Auto-compaction settings: enabled per-workspace, threshold per-model + const { + enabled: autoCompactEnabled, + setEnabled: setAutoCompactEnabled, + threshold: autoCompactThreshold, + setThreshold: setAutoCompactThreshold, + } = useAutoCompactionSettings(workspaceId, currentModel); + // Session usage for cost const sessionUsage = React.useMemo(() => { const historicalSum = sumUsageHistory(usage.usageHistory); @@ -164,7 +177,7 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { return ( <> -
+
Context Usage @@ -175,8 +188,8 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { {` (${totalPercentage.toFixed(1)}%)`}
-
-
+
+
{cachedPercentage > 0 && (
= ({ workspaceId }) => { }} /> )} + {/* Threshold slider overlay - only show when model limits are known */} + {maxTokens && ( + + )}
@@ -233,8 +256,6 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => {
)} - {hasUsageData && } - {hasUsageData && (
diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx new file mode 100644 index 0000000000..d9538b23a2 --- /dev/null +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -0,0 +1,165 @@ +import React, { useCallback, useRef, useState } from "react"; +import { + AUTO_COMPACTION_THRESHOLD_MIN, + AUTO_COMPACTION_THRESHOLD_MAX, +} from "@/common/constants/ui"; +import { TooltipWrapper, Tooltip } from "../Tooltip"; + +interface ThresholdSliderProps { + /** Current threshold percentage (50-90, or 100 for disabled) */ + threshold: number; + /** Whether auto-compaction is enabled */ + enabled: boolean; + /** Callback when threshold changes */ + onThresholdChange: (threshold: number) => void; + /** Callback when enabled state changes */ + onEnabledChange: (enabled: boolean) => void; + /** Orientation of the slider */ + orientation: "horizontal" | "vertical"; +} + +// Threshold at which we consider auto-compaction disabled (dragged all the way right/down) +const DISABLE_THRESHOLD = 100; + +export const ThresholdSlider: React.FC = ({ + threshold, + enabled, + onThresholdChange, + onEnabledChange, + orientation, +}) => { + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [dragValue, setDragValue] = useState(null); + + // Calculate position from threshold (50-100 -> 50%-100%) + const effectiveThreshold = enabled ? threshold : DISABLE_THRESHOLD; + const position = isDragging && dragValue !== null ? dragValue : effectiveThreshold; + + const updateThreshold = useCallback( + (clientX: number, clientY: number) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + let percentage: number; + + if (orientation === "horizontal") { + percentage = ((clientX - rect.left) / rect.width) * 100; + } else { + // Vertical: top = low %, bottom = high % + percentage = ((clientY - rect.top) / rect.height) * 100; + } + + // Clamp to valid range + percentage = Math.max(AUTO_COMPACTION_THRESHOLD_MIN, Math.min(100, percentage)); + + // Round to nearest 5 for nice values + percentage = Math.round(percentage / 5) * 5; + + // Update visual position during drag + setDragValue(percentage); + + if (percentage >= DISABLE_THRESHOLD) { + // Dragged to end - disable auto-compaction + onEnabledChange(false); + } else { + // Within valid range - update threshold and ensure enabled + if (!enabled) { + onEnabledChange(true); + } + onThresholdChange(Math.min(percentage, AUTO_COMPACTION_THRESHOLD_MAX)); + } + }, + [orientation, enabled, onThresholdChange, onEnabledChange] + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + updateThreshold(e.clientX, e.clientY); + + const handleMouseMove = (e: MouseEvent) => { + updateThreshold(e.clientX, e.clientY); + }; + + const handleMouseUp = () => { + setIsDragging(false); + setDragValue(null); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [updateThreshold] + ); + + // Tooltip shows live feedback during drag + const tooltipContent = isDragging + ? dragValue !== null && dragValue >= DISABLE_THRESHOLD + ? "Release to disable auto-compact" + : `Auto-compact at ${dragValue ?? threshold}%` + : enabled + ? `Auto-compact at ${threshold}% · Drag to adjust` + : "Auto-compact disabled · Drag left to enable"; + + if (orientation === "horizontal") { + return ( +
+ + {/* Vertical line indicator - extends above and below the bar */} +
+ + {tooltipContent} + + +
+ ); + } + + // Vertical orientation + return ( +
+ + {/* Horizontal line indicator - extends left and right of the bar */} +
+ + {tooltipContent} + + +
+ ); +}; diff --git a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx index 959a13d43b..8108c575e0 100644 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx @@ -1,13 +1,28 @@ import React from "react"; import { TooltipWrapper, Tooltip } from "../Tooltip"; import { TokenMeter } from "./TokenMeter"; +import { ThresholdSlider } from "./ThresholdSlider"; import { type TokenMeterData, formatTokens, getSegmentLabel, } from "@/common/utils/tokens/tokenMeterUtils"; -const VerticalTokenMeterComponent: React.FC<{ data: TokenMeterData }> = ({ data }) => { +interface VerticalTokenMeterProps { + data: TokenMeterData; + /** Auto-compaction settings - if provided, shows threshold slider */ + autoCompaction?: { + enabled: boolean; + threshold: number; + setEnabled: (enabled: boolean) => void; + setThreshold: (threshold: number) => void; + }; +} + +const VerticalTokenMeterComponent: React.FC = ({ + data, + autoCompaction, +}) => { if (data.segments.length === 0) return null; // Scale the bar based on context window usage (0-100%) @@ -15,7 +30,7 @@ const VerticalTokenMeterComponent: React.FC<{ data: TokenMeterData }> = ({ data return (
{data.maxTokens && ( @@ -27,9 +42,19 @@ const VerticalTokenMeterComponent: React.FC<{ data: TokenMeterData }> = ({ data
)}
+ {/* Threshold slider overlay */} + {data.maxTokens && autoCompaction && ( + + )}
( getAutoCompactionEnabledKey(workspaceId), true, { listener: true } ); + // Use model for threshold key, fall back to "default" if no model + const thresholdKey = getAutoCompactionThresholdKey(model ?? "default"); const [threshold, setThreshold] = usePersistedState( - getAutoCompactionThresholdKey(workspaceId), + thresholdKey, DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT, { listener: true } ); diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 1e17ee1ae9..4e5f5ee55d 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -180,11 +180,12 @@ export function getAutoCompactionEnabledKey(workspaceId: string): string { } /** - * Get the localStorage key for auto-compaction threshold percentage per workspace - * Format: "autoCompaction:threshold:{workspaceId}" + * Get the localStorage key for auto-compaction threshold percentage per model + * Format: "autoCompaction:threshold:{model}" + * Stored per-model because different models have different context windows */ -export function getAutoCompactionThresholdKey(workspaceId: string): string { - return `autoCompaction:threshold:${workspaceId}`; +export function getAutoCompactionThresholdKey(model: string): string { + return `autoCompaction:threshold:${model}`; } /** @@ -201,7 +202,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getFileTreeExpandStateKey, getReviewSearchStateKey, getAutoCompactionEnabledKey, - getAutoCompactionThresholdKey, + // Note: getAutoCompactionThresholdKey is per-model, not per-workspace ]; /** From 379d6a40a809001a1970d2789f939eb32925f2b7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 11:34:56 -0600 Subject: [PATCH 02/13] fix: make threshold slider visible with triangle handles - Move slider outside bar div to avoid clipping - Add triangle handles at line endpoints for visual affordance - Use proper absolute positioning with centered alignment - barHeight prop for accurate vertical centering --- .../components/RightSidebar/CostsTab.tsx | 23 +-- .../RightSidebar/ThresholdSlider.tsx | 150 +++++++++++++----- .../RightSidebar/VerticalTokenMeter.tsx | 4 +- 3 files changed, 126 insertions(+), 51 deletions(-) diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index 404aa96890..17089a0668 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -189,7 +189,7 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => {
-
+
{cachedPercentage > 0 && (
= ({ workspaceId }) => { }} /> )} - {/* Threshold slider overlay - only show when model limits are known */} - {maxTokens && ( - - )}
+ {/* Threshold slider overlay - only show when model limits are known */} + {maxTokens && ( + + )}
{showWarning && ( diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index d9538b23a2..efe88a30bb 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -16,6 +16,8 @@ interface ThresholdSliderProps { onEnabledChange: (enabled: boolean) => void; /** Orientation of the slider */ orientation: "horizontal" | "vertical"; + /** Height of the bar for vertical positioning (horizontal orientation only) */ + barHeight?: number; } // Threshold at which we consider auto-compaction disabled (dragged all the way right/down) @@ -27,6 +29,7 @@ export const ThresholdSlider: React.FC = ({ onThresholdChange, onEnabledChange, orientation, + barHeight = 6, }) => { const containerRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -107,59 +110,130 @@ export const ThresholdSlider: React.FC = ({ : "Auto-compact disabled · Drag left to enable"; if (orientation === "horizontal") { + // Render as a positioned overlay - the parent should have position:relative return ( -
- - {/* Vertical line indicator - extends above and below the bar */} + +
+ {/* Vertical line indicator with grab handle */}
- - {tooltipContent} - - -
+ > + {/* Top handle - small triangle */} +
+ {/* The line itself */} +
+ {/* Bottom handle - small triangle pointing up */} +
+
+
+ + {tooltipContent} + + ); } // Vertical orientation return ( -
- - {/* Horizontal line indicator - extends left and right of the bar */} + +
+ {/* Horizontal line indicator with grab handles */}
- - {tooltipContent} - - -
+ > + {/* Left handle - small triangle */} +
+ {/* The line itself */} +
+ {/* Right handle - small triangle pointing left */} +
+
+
+ + {tooltipContent} + + ); }; diff --git a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx index 8108c575e0..4b95627fcd 100644 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx @@ -42,10 +42,10 @@ const VerticalTokenMeterComponent: React.FC = ({
)}
- {/* Threshold slider overlay */} + {/* Threshold slider overlay - positioned over the entire meter area */} {data.maxTokens && autoCompaction && ( Date: Mon, 1 Dec 2025 11:38:44 -0600 Subject: [PATCH 03/13] fix: position slider correctly with inset-0 inside relative container - Use native title attribute for tooltip (simpler, no wrapper) - Slider positioned inside the bar container which has relative - Add overflow-visible to allow triangles to extend beyond bar - Remove barHeight prop - not needed with inset-0 --- .../components/RightSidebar/CostsTab.tsx | 26 +-- .../RightSidebar/ThresholdSlider.tsx | 202 ++++++++---------- .../RightSidebar/VerticalTokenMeter.tsx | 2 +- 3 files changed, 108 insertions(+), 122 deletions(-) diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index 17089a0668..ae6e4a333b 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -188,8 +188,9 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { {` (${totalPercentage.toFixed(1)}%)`}
-
-
+
+ {/* Bar container - relative for slider positioning */} +
{cachedPercentage > 0 && (
= ({ workspaceId }) => { }} /> )} + {/* Threshold slider overlay - inside bar for proper positioning */} + {maxTokens && ( + + )}
- {/* Threshold slider overlay - only show when model limits are known */} - {maxTokens && ( - - )}
{showWarning && ( diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index efe88a30bb..1a5c20b115 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -3,7 +3,6 @@ import { AUTO_COMPACTION_THRESHOLD_MIN, AUTO_COMPACTION_THRESHOLD_MAX, } from "@/common/constants/ui"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; interface ThresholdSliderProps { /** Current threshold percentage (50-90, or 100 for disabled) */ @@ -16,8 +15,6 @@ interface ThresholdSliderProps { onEnabledChange: (enabled: boolean) => void; /** Orientation of the slider */ orientation: "horizontal" | "vertical"; - /** Height of the bar for vertical positioning (horizontal orientation only) */ - barHeight?: number; } // Threshold at which we consider auto-compaction disabled (dragged all the way right/down) @@ -29,7 +26,6 @@ export const ThresholdSlider: React.FC = ({ onThresholdChange, onEnabledChange, orientation, - barHeight = 6, }) => { const containerRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -80,6 +76,7 @@ export const ThresholdSlider: React.FC = ({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); + e.stopPropagation(); setIsDragging(true); updateThreshold(e.clientX, e.clientY); @@ -100,8 +97,8 @@ export const ThresholdSlider: React.FC = ({ [updateThreshold] ); - // Tooltip shows live feedback during drag - const tooltipContent = isDragging + // Tooltip text + const tooltipText = isDragging ? dragValue !== null && dragValue >= DISABLE_THRESHOLD ? "Release to disable auto-compact" : `Auto-compact at ${dragValue ?? threshold}%` @@ -109,131 +106,120 @@ export const ThresholdSlider: React.FC = ({ ? `Auto-compact at ${threshold}% · Drag to adjust` : "Auto-compact disabled · Drag left to enable"; + const lineColor = enabled ? "var(--color-plan-mode)" : "var(--color-muted)"; + if (orientation === "horizontal") { - // Render as a positioned overlay - the parent should have position:relative + // Absolute overlay covering the bar area return ( - -
- {/* Vertical line indicator with grab handle */} -
- {/* Top handle - small triangle */} -
- {/* The line itself */} -
- {/* Bottom handle - small triangle pointing up */} -
-
-
- - {tooltipContent} - - - ); - } - - // Vertical orientation - return ( -
- {/* Horizontal line indicator with grab handles */} + {/* Vertical line indicator with triangles */}
- {/* Left handle - small triangle */} + {/* Top triangle */}
- {/* The line itself */} + {/* Line */}
- {/* Right handle - small triangle pointing left */} + {/* Bottom triangle */}
- - {tooltipContent} - - + ); + } + + // Vertical orientation - absolute overlay + return ( +
+ {/* Horizontal line indicator with triangles */} +
+ {/* Left triangle */} +
+ {/* Line */} +
+ {/* Right triangle */} +
+
+
); }; diff --git a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx index 4b95627fcd..8b53a8a708 100644 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx @@ -42,7 +42,7 @@ const VerticalTokenMeterComponent: React.FC = ({
)}
{/* Threshold slider overlay - positioned over the entire meter area */} From a759e864599a090f8e9994ddb308fd395344adb3 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 11:42:39 -0600 Subject: [PATCH 04/13] refactor: cleaner ThresholdSlider with proper types and hooks - Extract AutoCompactionConfig type and export it - Create useDraggableThreshold hook for drag logic - Extract Triangle and ThresholdIndicator sub-components - Simplify VerticalTokenMeter structure - Add proper TooltipWrapper for tooltips - Document container requirements in JSDoc --- .../components/RightSidebar/CostsTab.tsx | 10 +- .../RightSidebar/ThresholdSlider.tsx | 369 ++++++++++-------- .../RightSidebar/VerticalTokenMeter.tsx | 136 +++---- 3 files changed, 255 insertions(+), 260 deletions(-) diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index ae6e4a333b..a5c0c70812 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -235,10 +235,12 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { {/* Threshold slider overlay - inside bar for proper positioning */} {maxTokens && ( )} diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index 1a5c20b115..eaaa09b6d6 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -3,90 +3,93 @@ import { AUTO_COMPACTION_THRESHOLD_MIN, AUTO_COMPACTION_THRESHOLD_MAX, } from "@/common/constants/ui"; +import { TooltipWrapper, Tooltip } from "../Tooltip"; -interface ThresholdSliderProps { - /** Current threshold percentage (50-90, or 100 for disabled) */ - threshold: number; - /** Whether auto-compaction is enabled */ +// ----- Types ----- + +export interface AutoCompactionConfig { enabled: boolean; - /** Callback when threshold changes */ - onThresholdChange: (threshold: number) => void; - /** Callback when enabled state changes */ - onEnabledChange: (enabled: boolean) => void; - /** Orientation of the slider */ - orientation: "horizontal" | "vertical"; + threshold: number; + setEnabled: (enabled: boolean) => void; + setThreshold: (threshold: number) => void; } -// Threshold at which we consider auto-compaction disabled (dragged all the way right/down) -const DISABLE_THRESHOLD = 100; +type Orientation = "horizontal" | "vertical"; -export const ThresholdSlider: React.FC = ({ - threshold, - enabled, - onThresholdChange, - onEnabledChange, - orientation, -}) => { - const containerRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [dragValue, setDragValue] = useState(null); +interface ThresholdSliderProps { + config: AutoCompactionConfig; + orientation: Orientation; +} - // Calculate position from threshold (50-100 -> 50%-100%) - const effectiveThreshold = enabled ? threshold : DISABLE_THRESHOLD; - const position = isDragging && dragValue !== null ? dragValue : effectiveThreshold; +// ----- Constants ----- - const updateThreshold = useCallback( - (clientX: number, clientY: number) => { - const container = containerRef.current; - if (!container) return; +/** Threshold at which we consider auto-compaction disabled (dragged all the way right/down) */ +const DISABLE_THRESHOLD = 100; - const rect = container.getBoundingClientRect(); - let percentage: number; +// ----- Hook: useDraggableThreshold ----- - if (orientation === "horizontal") { - percentage = ((clientX - rect.left) / rect.width) * 100; - } else { - // Vertical: top = low %, bottom = high % - percentage = ((clientY - rect.top) / rect.height) * 100; - } +interface DragState { + isDragging: boolean; + dragValue: number | null; +} - // Clamp to valid range - percentage = Math.max(AUTO_COMPACTION_THRESHOLD_MIN, Math.min(100, percentage)); +function useDraggableThreshold( + containerRef: React.RefObject, + config: AutoCompactionConfig, + orientation: Orientation +) { + const [dragState, setDragState] = useState({ + isDragging: false, + dragValue: null, + }); - // Round to nearest 5 for nice values - percentage = Math.round(percentage / 5) * 5; + const calculatePercentage = useCallback( + (clientX: number, clientY: number): number => { + const container = containerRef.current; + if (!container) return config.threshold; - // Update visual position during drag - setDragValue(percentage); + const rect = container.getBoundingClientRect(); + const raw = + orientation === "horizontal" + ? ((clientX - rect.left) / rect.width) * 100 + : ((clientY - rect.top) / rect.height) * 100; + + // Clamp and round to nearest 5 + const clamped = Math.max(AUTO_COMPACTION_THRESHOLD_MIN, Math.min(100, raw)); + return Math.round(clamped / 5) * 5; + }, + [containerRef, orientation, config.threshold] + ); + const applyThreshold = useCallback( + (percentage: number) => { if (percentage >= DISABLE_THRESHOLD) { - // Dragged to end - disable auto-compaction - onEnabledChange(false); + config.setEnabled(false); } else { - // Within valid range - update threshold and ensure enabled - if (!enabled) { - onEnabledChange(true); - } - onThresholdChange(Math.min(percentage, AUTO_COMPACTION_THRESHOLD_MAX)); + if (!config.enabled) config.setEnabled(true); + config.setThreshold(Math.min(percentage, AUTO_COMPACTION_THRESHOLD_MAX)); } }, - [orientation, enabled, onThresholdChange, onEnabledChange] + [config] ); const handleMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setIsDragging(true); - updateThreshold(e.clientX, e.clientY); - const handleMouseMove = (e: MouseEvent) => { - updateThreshold(e.clientX, e.clientY); + const percentage = calculatePercentage(e.clientX, e.clientY); + setDragState({ isDragging: true, dragValue: percentage }); + applyThreshold(percentage); + + const handleMouseMove = (moveEvent: MouseEvent) => { + const newPercentage = calculatePercentage(moveEvent.clientX, moveEvent.clientY); + setDragState({ isDragging: true, dragValue: newPercentage }); + applyThreshold(newPercentage); }; const handleMouseUp = () => { - setIsDragging(false); - setDragValue(null); + setDragState({ isDragging: false, dragValue: null }); document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; @@ -94,132 +97,170 @@ export const ThresholdSlider: React.FC = ({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [updateThreshold] + [calculatePercentage, applyThreshold] ); - // Tooltip text - const tooltipText = isDragging - ? dragValue !== null && dragValue >= DISABLE_THRESHOLD + return { ...dragState, handleMouseDown }; +} + +// ----- Helper: compute display position ----- + +function computePosition(config: AutoCompactionConfig, dragValue: number | null): number { + if (dragValue !== null) return dragValue; + return config.enabled ? config.threshold : DISABLE_THRESHOLD; +} + +// ----- Helper: tooltip text ----- + +function getTooltipText( + config: AutoCompactionConfig, + isDragging: boolean, + dragValue: number | null, + orientation: Orientation +): string { + if (isDragging && dragValue !== null) { + return dragValue >= DISABLE_THRESHOLD ? "Release to disable auto-compact" - : `Auto-compact at ${dragValue ?? threshold}%` - : enabled - ? `Auto-compact at ${threshold}% · Drag to adjust` - : "Auto-compact disabled · Drag left to enable"; + : `Auto-compact at ${dragValue}%`; + } + const direction = orientation === "horizontal" ? "left" : "up"; + return config.enabled + ? `Auto-compact at ${config.threshold}% · Drag to adjust` + : `Auto-compact disabled · Drag ${direction} to enable`; +} + +// ----- Sub-components: Triangle indicators ----- + +interface TriangleProps { + direction: "up" | "down" | "left" | "right"; + color: string; + opacity: number; +} - const lineColor = enabled ? "var(--color-plan-mode)" : "var(--color-muted)"; +const Triangle: React.FC = ({ direction, color, opacity }) => { + const size = 4; + const tipSize = 5; + const styles: Record = { + up: { + borderLeft: `${size}px solid transparent`, + borderRight: `${size}px solid transparent`, + borderBottom: `${tipSize}px solid ${color}`, + }, + down: { + borderLeft: `${size}px solid transparent`, + borderRight: `${size}px solid transparent`, + borderTop: `${tipSize}px solid ${color}`, + }, + left: { + borderTop: `${size}px solid transparent`, + borderBottom: `${size}px solid transparent`, + borderRight: `${tipSize}px solid ${color}`, + }, + right: { + borderTop: `${size}px solid transparent`, + borderBottom: `${size}px solid transparent`, + borderLeft: `${tipSize}px solid ${color}`, + }, + }; + + return
; +}; + +// ----- Sub-component: ThresholdIndicator ----- + +interface ThresholdIndicatorProps { + position: number; + color: string; + opacity: number; + orientation: Orientation; +} + +const ThresholdIndicator: React.FC = ({ + position, + color, + opacity, + orientation, +}) => { if (orientation === "horizontal") { - // Absolute overlay covering the bar area return (
- {/* Vertical line indicator with triangles */} -
- {/* Top triangle */} -
- {/* Line */} -
- {/* Bottom triangle */} -
-
+ +
+
); } - // Vertical orientation - absolute overlay + // Vertical return (
- {/* Horizontal line indicator with triangles */} + +
+ +
+ ); +}; + +// ----- Main component ----- + +/** + * ThresholdSlider renders an interactive threshold indicator overlay. + * + * IMPORTANT: This component must be placed inside a container with: + * - `position: relative` (for absolute positioning) + * - `overflow: visible` (so triangles can extend beyond bounds) + * + * The slider fills its container via `inset-0` and positions the indicator + * line at the threshold percentage. + */ +export const ThresholdSlider: React.FC = ({ config, orientation }) => { + const containerRef = useRef(null); + const { isDragging, dragValue, handleMouseDown } = useDraggableThreshold( + containerRef, + config, + orientation + ); + + const position = computePosition(config, dragValue); + const lineColor = config.enabled ? "var(--color-plan-mode)" : "var(--color-muted)"; + const opacity = isDragging ? 1 : 0.8; + const tooltipText = getTooltipText(config, isDragging, dragValue, orientation); + const cursor = orientation === "horizontal" ? "cursor-ew-resize" : "cursor-ns-resize"; + + return ( +
- {/* Left triangle */} -
- {/* Line */} -
- {/* Right triangle */} -
-
+ + {tooltipText} + + ); }; diff --git a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx index 8b53a8a708..201072b116 100644 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx @@ -1,7 +1,7 @@ import React from "react"; import { TooltipWrapper, Tooltip } from "../Tooltip"; import { TokenMeter } from "./TokenMeter"; -import { ThresholdSlider } from "./ThresholdSlider"; +import { ThresholdSlider, type AutoCompactionConfig } from "./ThresholdSlider"; import { type TokenMeterData, formatTokens, @@ -11,12 +11,7 @@ import { interface VerticalTokenMeterProps { data: TokenMeterData; /** Auto-compaction settings - if provided, shows threshold slider */ - autoCompaction?: { - enabled: boolean; - threshold: number; - setEnabled: (enabled: boolean) => void; - setThreshold: (threshold: number) => void; - }; + autoCompaction?: AutoCompactionConfig; } const VerticalTokenMeterComponent: React.FC = ({ @@ -25,14 +20,14 @@ const VerticalTokenMeterComponent: React.FC = ({ }) => { if (data.segments.length === 0) return null; - // Scale the bar based on context window usage (0-100%) - const usagePercentage = data.maxTokens ? data.totalPercentage : 100; + const showThresholdSlider = data.maxTokens && autoCompaction; return (
+ {/* Percentage label at top */} {data.maxTokens && (
= ({ {Math.round(data.totalPercentage)}
)} + + {/* Main meter area - this is where the threshold slider lives */}
- {/* Threshold slider overlay - positioned over the entire meter area */} - {data.maxTokens && autoCompaction && ( - - )} -
-
- - - -
-
- Last Request -
-
- {data.segments.map((seg, i) => ( -
-
-
- {getSegmentLabel(seg.type)} -
- - {formatTokens(seg.tokens)} - + {/* Threshold slider: fills entire meter area so percentage maps correctly */} + {showThresholdSlider && } + + {/* The actual bar with tooltip */} +
+ + + +
+
Last Request
+
+ {data.segments.map((seg, i) => ( +
+
+
+ {getSegmentLabel(seg.type)}
- ))} -
-
- Total: {formatTokens(data.totalTokens)} - {data.maxTokens && ` / ${formatTokens(data.maxTokens)}`} - {data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`} -
-
- 💡 Expand your viewport to see full details + {formatTokens(seg.tokens)}
+ ))} +
+
+ Total: {formatTokens(data.totalTokens)} + {data.maxTokens && ` / ${formatTokens(data.maxTokens)}`} + {data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`}
- - -
+
+ 💡 Expand your viewport to see full details +
+
+ +
-
); From 13c12b1feb68c57eb6c169baa42e5dd0b2ef9f30 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 11:47:32 -0600 Subject: [PATCH 05/13] fix: simplify to HorizontalThresholdSlider only - Remove TooltipWrapper (was breaking absolute positioning) - Use native title attribute for tooltip - Export only HorizontalThresholdSlider for now - Vertical slider disabled until horizontal is confirmed working --- .../components/RightSidebar/CostsTab.tsx | 5 +- .../RightSidebar/ThresholdSlider.tsx | 249 +++++------------- .../RightSidebar/VerticalTokenMeter.tsx | 88 +++---- 3 files changed, 107 insertions(+), 235 deletions(-) diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index a5c0c70812..d417d24b09 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -8,7 +8,7 @@ import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { supports1MContext } from "@/common/utils/ai/models"; import { TOKEN_COMPONENT_COLORS } from "@/common/utils/tokens/tokenMeterUtils"; import { ConsumerBreakdown } from "./ConsumerBreakdown"; -import { ThresholdSlider } from "./ThresholdSlider"; +import { HorizontalThresholdSlider } from "./ThresholdSlider"; import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings"; // Format token display - show k for thousands with 1 decimal @@ -234,14 +234,13 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { )} {/* Threshold slider overlay - inside bar for proper positioning */} {maxTokens && ( - )}
diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index eaaa09b6d6..a3f3f83779 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -3,7 +3,6 @@ import { AUTO_COMPACTION_THRESHOLD_MIN, AUTO_COMPACTION_THRESHOLD_MAX, } from "@/common/constants/ui"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; // ----- Types ----- @@ -14,51 +13,44 @@ export interface AutoCompactionConfig { setThreshold: (threshold: number) => void; } -type Orientation = "horizontal" | "vertical"; - -interface ThresholdSliderProps { +interface HorizontalThresholdSliderProps { config: AutoCompactionConfig; - orientation: Orientation; } // ----- Constants ----- -/** Threshold at which we consider auto-compaction disabled (dragged all the way right/down) */ +/** Threshold at which we consider auto-compaction disabled (dragged all the way right) */ const DISABLE_THRESHOLD = 100; -// ----- Hook: useDraggableThreshold ----- +// ----- Main component: HorizontalThresholdSlider ----- -interface DragState { - isDragging: boolean; - dragValue: number | null; -} +/** + * A draggable threshold indicator for horizontal progress bars. + * + * Renders as a vertical line with triangle handles at the threshold position. + * Drag left/right to adjust threshold. Drag to 100% to disable. + * + * USAGE: Place as a sibling AFTER the progress bar, both inside a relative container. + */ +export const HorizontalThresholdSlider: React.FC = ({ config }) => { + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [dragValue, setDragValue] = useState(null); -function useDraggableThreshold( - containerRef: React.RefObject, - config: AutoCompactionConfig, - orientation: Orientation -) { - const [dragState, setDragState] = useState({ - isDragging: false, - dragValue: null, - }); + // Current display position + const position = dragValue ?? (config.enabled ? config.threshold : DISABLE_THRESHOLD); const calculatePercentage = useCallback( - (clientX: number, clientY: number): number => { + (clientX: number): number => { const container = containerRef.current; if (!container) return config.threshold; const rect = container.getBoundingClientRect(); - const raw = - orientation === "horizontal" - ? ((clientX - rect.left) / rect.width) * 100 - : ((clientY - rect.top) / rect.height) * 100; - - // Clamp and round to nearest 5 + const raw = ((clientX - rect.left) / rect.width) * 100; const clamped = Math.max(AUTO_COMPACTION_THRESHOLD_MIN, Math.min(100, raw)); return Math.round(clamped / 5) * 5; }, - [containerRef, orientation, config.threshold] + [config.threshold] ); const applyThreshold = useCallback( @@ -78,18 +70,20 @@ function useDraggableThreshold( e.preventDefault(); e.stopPropagation(); - const percentage = calculatePercentage(e.clientX, e.clientY); - setDragState({ isDragging: true, dragValue: percentage }); + const percentage = calculatePercentage(e.clientX); + setIsDragging(true); + setDragValue(percentage); applyThreshold(percentage); const handleMouseMove = (moveEvent: MouseEvent) => { - const newPercentage = calculatePercentage(moveEvent.clientX, moveEvent.clientY); - setDragState({ isDragging: true, dragValue: newPercentage }); + const newPercentage = calculatePercentage(moveEvent.clientX); + setDragValue(newPercentage); applyThreshold(newPercentage); }; const handleMouseUp = () => { - setDragState({ isDragging: false, dragValue: null }); + setIsDragging(false); + setDragValue(null); document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; @@ -100,90 +94,26 @@ function useDraggableThreshold( [calculatePercentage, applyThreshold] ); - return { ...dragState, handleMouseDown }; -} - -// ----- Helper: compute display position ----- - -function computePosition(config: AutoCompactionConfig, dragValue: number | null): number { - if (dragValue !== null) return dragValue; - return config.enabled ? config.threshold : DISABLE_THRESHOLD; -} - -// ----- Helper: tooltip text ----- - -function getTooltipText( - config: AutoCompactionConfig, - isDragging: boolean, - dragValue: number | null, - orientation: Orientation -): string { - if (isDragging && dragValue !== null) { - return dragValue >= DISABLE_THRESHOLD + // Tooltip text + const title = isDragging + ? dragValue !== null && dragValue >= DISABLE_THRESHOLD ? "Release to disable auto-compact" - : `Auto-compact at ${dragValue}%`; - } - const direction = orientation === "horizontal" ? "left" : "up"; - return config.enabled - ? `Auto-compact at ${config.threshold}% · Drag to adjust` - : `Auto-compact disabled · Drag ${direction} to enable`; -} + : `Auto-compact at ${dragValue}%` + : config.enabled + ? `Auto-compact at ${config.threshold}% · Drag to adjust` + : "Auto-compact disabled · Drag left to enable"; -// ----- Sub-components: Triangle indicators ----- - -interface TriangleProps { - direction: "up" | "down" | "left" | "right"; - color: string; - opacity: number; -} - -const Triangle: React.FC = ({ direction, color, opacity }) => { - const size = 4; - const tipSize = 5; - - const styles: Record = { - up: { - borderLeft: `${size}px solid transparent`, - borderRight: `${size}px solid transparent`, - borderBottom: `${tipSize}px solid ${color}`, - }, - down: { - borderLeft: `${size}px solid transparent`, - borderRight: `${size}px solid transparent`, - borderTop: `${tipSize}px solid ${color}`, - }, - left: { - borderTop: `${size}px solid transparent`, - borderBottom: `${size}px solid transparent`, - borderRight: `${tipSize}px solid ${color}`, - }, - right: { - borderTop: `${size}px solid transparent`, - borderBottom: `${size}px solid transparent`, - borderLeft: `${tipSize}px solid ${color}`, - }, - }; - - return
; -}; - -// ----- Sub-component: ThresholdIndicator ----- - -interface ThresholdIndicatorProps { - position: number; - color: string; - opacity: number; - orientation: Orientation; -} + const lineColor = config.enabled ? "var(--color-plan-mode)" : "var(--color-muted)"; + const opacity = isDragging ? 1 : 0.8; -const ThresholdIndicator: React.FC = ({ - position, - color, - opacity, - orientation, -}) => { - if (orientation === "horizontal") { - return ( + return ( +
+ {/* Vertical line with triangle handles */}
= ({ transform: "translateX(-50%)", }} > - -
- -
- ); - } - - // Vertical - return ( -
- -
- -
- ); -}; - -// ----- Main component ----- - -/** - * ThresholdSlider renders an interactive threshold indicator overlay. - * - * IMPORTANT: This component must be placed inside a container with: - * - `position: relative` (for absolute positioning) - * - `overflow: visible` (so triangles can extend beyond bounds) - * - * The slider fills its container via `inset-0` and positions the indicator - * line at the threshold percentage. - */ -export const ThresholdSlider: React.FC = ({ config, orientation }) => { - const containerRef = useRef(null); - const { isDragging, dragValue, handleMouseDown } = useDraggableThreshold( - containerRef, - config, - orientation - ); - - const position = computePosition(config, dragValue); - const lineColor = config.enabled ? "var(--color-plan-mode)" : "var(--color-muted)"; - const opacity = isDragging ? 1 : 0.8; - const tooltipText = getTooltipText(config, isDragging, dragValue, orientation); - const cursor = orientation === "horizontal" ? "cursor-ew-resize" : "cursor-ns-resize"; - - return ( - -
- + {/* Line */} +
+ {/* Bottom triangle (pointing up) */} +
- - {tooltipText} - - +
); }; diff --git a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx index 201072b116..ca29d73fca 100644 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx @@ -1,7 +1,7 @@ import React from "react"; import { TooltipWrapper, Tooltip } from "../Tooltip"; import { TokenMeter } from "./TokenMeter"; -import { ThresholdSlider, type AutoCompactionConfig } from "./ThresholdSlider"; +import type { AutoCompactionConfig } from "./ThresholdSlider"; import { type TokenMeterData, formatTokens, @@ -10,18 +10,13 @@ import { interface VerticalTokenMeterProps { data: TokenMeterData; - /** Auto-compaction settings - if provided, shows threshold slider */ + /** Auto-compaction settings - reserved for future vertical slider */ autoCompaction?: AutoCompactionConfig; } -const VerticalTokenMeterComponent: React.FC = ({ - data, - autoCompaction, -}) => { +const VerticalTokenMeterComponent: React.FC = ({ data }) => { if (data.segments.length === 0) return null; - const showThresholdSlider = data.maxTokens && autoCompaction; - return (
= ({
)} - {/* Main meter area - this is where the threshold slider lives */} -
- {/* Threshold slider: fills entire meter area so percentage maps correctly */} - {showThresholdSlider && } - - {/* The actual bar with tooltip */} -
- - - -
-
Last Request
-
- {data.segments.map((seg, i) => ( -
-
-
- {getSegmentLabel(seg.type)} -
- {formatTokens(seg.tokens)} + {/* The bar with tooltip */} +
+ + + +
+
Last Request
+
+ {data.segments.map((seg, i) => ( +
+
+
+ {getSegmentLabel(seg.type)}
- ))} -
-
- Total: {formatTokens(data.totalTokens)} - {data.maxTokens && ` / ${formatTokens(data.maxTokens)}`} - {data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`} -
-
- 💡 Expand your viewport to see full details + {formatTokens(seg.tokens)}
+ ))} +
+
+ Total: {formatTokens(data.totalTokens)} + {data.maxTokens && ` / ${formatTokens(data.maxTokens)}`} + {data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`}
- - -
+
+ 💡 Expand your viewport to see full details +
+
+ +
); From fe574e3a01b65f0c6a0de01158b76971a3a3b0c6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 11:51:44 -0600 Subject: [PATCH 06/13] =?UTF-8?q?=F0=9F=A4=96=20fix:=20extend=20horizontal?= =?UTF-8?q?=20slider=20hit=20area=20beyond=20thin=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/RightSidebar/ThresholdSlider.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index a3f3f83779..d3ab28c69f 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -109,7 +109,14 @@ export const HorizontalThresholdSlider: React.FC return (
From 0437bd40006427d917815c54036d16e0f1a2ba90 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 11:53:54 -0600 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=A4=96=20fix:=20separate=20slider?= =?UTF-8?q?=20hit=20area=20from=20visual=20positioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RightSidebar/ThresholdSlider.tsx | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index d3ab28c69f..b41c96e787 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -100,34 +100,44 @@ export const HorizontalThresholdSlider: React.FC ? "Release to disable auto-compact" : `Auto-compact at ${dragValue}%` : config.enabled - ? `Auto-compact at ${config.threshold}% · Drag to adjust` - : "Auto-compact disabled · Drag left to enable"; + ? `Auto-compact at ${config.threshold}% · Drag to adjust` + : "Auto-compact disabled · Drag left to enable"; - const lineColor = config.enabled ? "var(--color-plan-mode)" : "var(--color-muted)"; + const lineColor = config.enabled + ? "var(--color-plan-mode)" + : "var(--color-muted)"; const opacity = isDragging ? 1 : 0.8; return (
- {/* Vertical line with triangle handles */} + {/* Hit Area - Wider than the bar for easier grabbing */} +
+ + {/* Visual Indicator - Strictly positioned relative to the bar (containerRef) */}
{/* Top triangle (pointing down) */} From 5b45f79d1aeff3b02c207e71145f7a0ff5210fb1 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 12:05:32 -0600 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20unify=20auto-c?= =?UTF-8?q?ompaction=20state,=20improve=20slider=20interaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/AIView.tsx | 3 +- src/browser/components/RightSidebar.tsx | 8 +-- .../components/RightSidebar/CostsTab.tsx | 12 ++-- .../RightSidebar/ThresholdSlider.tsx | 18 +++--- .../hooks/useAutoCompactionSettings.ts | 21 ++----- .../compaction/autoCompactionCheck.test.ts | 62 +++++++++---------- .../utils/compaction/autoCompactionCheck.ts | 7 +-- 7 files changed, 60 insertions(+), 71 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index dfa1d6de94..703ba880a5 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -91,7 +91,7 @@ const AIViewInner: React.FC = ({ const pendingSendOptions = useSendMessageOptions(workspaceId); const pendingModel = pendingSendOptions.model; - const { enabled: autoCompactionEnabled, threshold: autoCompactionThreshold } = + const { threshold: autoCompactionThreshold } = useAutoCompactionSettings(workspaceId, pendingModel); const handledModelErrorsRef = useRef>(new Set()); @@ -138,7 +138,6 @@ const AIViewInner: React.FC = ({ workspaceUsage, pendingModel, use1M, - autoCompactionEnabled, autoCompactionThreshold / 100 ); diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 2820b4d004..3d6b2a91b5 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -138,10 +138,8 @@ const RightSidebarComponent: React.FC = ({ const lastUsage = usage?.liveUsage ?? usage?.usageHistory[usage.usageHistory.length - 1]; const model = lastUsage?.model ?? null; - // Auto-compaction settings: enabled per-workspace, threshold per-model + // Auto-compaction settings: threshold per-model const { - enabled: autoCompactEnabled, - setEnabled: setAutoCompactEnabled, threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold, } = useAutoCompactionSettings(workspaceId, model); @@ -194,12 +192,10 @@ const RightSidebarComponent: React.FC = ({ const showMeter = showCollapsed || selectedTab === "review"; const autoCompactionProps = React.useMemo( () => ({ - enabled: autoCompactEnabled, threshold: autoCompactThreshold, - setEnabled: setAutoCompactEnabled, setThreshold: setAutoCompactThreshold, }), - [autoCompactEnabled, autoCompactThreshold, setAutoCompactEnabled, setAutoCompactThreshold] + [autoCompactThreshold, setAutoCompactThreshold] ); const verticalMeter = showMeter ? ( diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index d417d24b09..b66c2a15ca 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -68,10 +68,8 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { const contextUsage = usage.liveUsage ?? usage.usageHistory[usage.usageHistory.length - 1]; const currentModel = contextUsage?.model ?? null; - // Auto-compaction settings: enabled per-workspace, threshold per-model + // Auto-compaction settings: threshold per-model (100 = disabled) const { - enabled: autoCompactEnabled, - setEnabled: setAutoCompactEnabled, threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold, } = useAutoCompactionSettings(workspaceId, currentModel); @@ -193,6 +191,7 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => {
{cachedPercentage > 0 && (
= ({ workspaceId }) => { )} {cacheCreatePercentage > 0 && (
= ({ workspaceId }) => { /> )}
= ({ workspaceId }) => { }} />
= ({ workspaceId }) => { /> {reasoningPercentage > 0 && (
= ({ workspaceId }) => { {/* Threshold slider overlay - inside bar for proper positioning */} {maxTokens && ( diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index b41c96e787..e96abec2bf 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -7,9 +7,7 @@ import { // ----- Types ----- export interface AutoCompactionConfig { - enabled: boolean; threshold: number; - setEnabled: (enabled: boolean) => void; setThreshold: (threshold: number) => void; } @@ -38,7 +36,10 @@ export const HorizontalThresholdSlider: React.FC const [dragValue, setDragValue] = useState(null); // Current display position - const position = dragValue ?? (config.enabled ? config.threshold : DISABLE_THRESHOLD); + const position = dragValue ?? config.threshold; + + // Derive enabled state from threshold + const isEnabled = config.threshold < DISABLE_THRESHOLD; const calculatePercentage = useCallback( (clientX: number): number => { @@ -47,6 +48,7 @@ export const HorizontalThresholdSlider: React.FC const rect = container.getBoundingClientRect(); const raw = ((clientX - rect.left) / rect.width) * 100; + // Allow dragging up to 100 (disabled state) const clamped = Math.max(AUTO_COMPACTION_THRESHOLD_MIN, Math.min(100, raw)); return Math.round(clamped / 5) * 5; }, @@ -56,9 +58,10 @@ export const HorizontalThresholdSlider: React.FC const applyThreshold = useCallback( (percentage: number) => { if (percentage >= DISABLE_THRESHOLD) { - config.setEnabled(false); + // Set to 100 to disable + config.setThreshold(100); } else { - if (!config.enabled) config.setEnabled(true); + // Clamp to max allowed active threshold (e.g. 90%) config.setThreshold(Math.min(percentage, AUTO_COMPACTION_THRESHOLD_MAX)); } }, @@ -99,11 +102,11 @@ export const HorizontalThresholdSlider: React.FC ? dragValue !== null && dragValue >= DISABLE_THRESHOLD ? "Release to disable auto-compact" : `Auto-compact at ${dragValue}%` - : config.enabled + : isEnabled ? `Auto-compact at ${config.threshold}% · Drag to adjust` : "Auto-compact disabled · Drag left to enable"; - const lineColor = config.enabled + const lineColor = isEnabled ? "var(--color-plan-mode)" : "var(--color-muted)"; const opacity = isDragging ? 1 : 0.8; @@ -124,6 +127,7 @@ export const HorizontalThresholdSlider: React.FC right: 0, pointerEvents: "auto", // Re-enable pointer events for the hit area zIndex: 20, + backgroundColor: "rgba(0,0,0,0)", // Ensure hit area captures events even if transparent }} onMouseDown={handleMouseDown} title={title} diff --git a/src/browser/hooks/useAutoCompactionSettings.ts b/src/browser/hooks/useAutoCompactionSettings.ts index aaf3bf2426..46cdfb6320 100644 --- a/src/browser/hooks/useAutoCompactionSettings.ts +++ b/src/browser/hooks/useAutoCompactionSettings.ts @@ -1,27 +1,22 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { - getAutoCompactionEnabledKey, getAutoCompactionThresholdKey, } from "@/common/constants/storage"; import { DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT } from "@/common/constants/ui"; export interface AutoCompactionSettings { - /** Whether auto-compaction is enabled for this workspace */ - enabled: boolean; - /** Update enabled state */ - setEnabled: (value: boolean) => void; - /** Current threshold percentage (50-90) */ + /** Current threshold percentage (50-100). 100 means disabled. */ threshold: number; - /** Update threshold percentage (will be clamped to 50-90 range by UI) */ + /** Update threshold percentage */ setThreshold: (value: number) => void; } /** * Custom hook for auto-compaction settings. - * - Enabled state is per-workspace * - Threshold is per-model (different models have different context windows) + * - Threshold >= 100% means disabled for that model * - * @param workspaceId - Workspace identifier for enabled state + * @param workspaceId - Workspace identifier (unused now, kept for API compatibility if needed) * @param model - Model identifier for threshold (e.g., "claude-sonnet-4-5") * @returns Settings object with getters and setters */ @@ -29,12 +24,6 @@ export function useAutoCompactionSettings( workspaceId: string, model: string | null ): AutoCompactionSettings { - const [enabled, setEnabled] = usePersistedState( - getAutoCompactionEnabledKey(workspaceId), - true, - { listener: true } - ); - // Use model for threshold key, fall back to "default" if no model const thresholdKey = getAutoCompactionThresholdKey(model ?? "default"); const [threshold, setThreshold] = usePersistedState( @@ -43,5 +32,5 @@ export function useAutoCompactionSettings( { listener: true } ); - return { enabled, setEnabled, threshold, setThreshold }; + return { threshold, setThreshold }; } diff --git a/src/browser/utils/compaction/autoCompactionCheck.test.ts b/src/browser/utils/compaction/autoCompactionCheck.test.ts index 662734dee1..1b511758a8 100644 --- a/src/browser/utils/compaction/autoCompactionCheck.test.ts +++ b/src/browser/utils/compaction/autoCompactionCheck.test.ts @@ -52,7 +52,7 @@ describe("checkAutoCompaction", () => { describe("Basic Functionality", () => { test("returns false when no usage data (first message)", () => { - const result = checkAutoCompaction(undefined, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(undefined, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -61,7 +61,7 @@ describe("checkAutoCompaction", () => { test("returns false when usage history is empty", () => { const usage: WorkspaceUsageState = { usageHistory: [], totalTokens: 0 }; - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -70,7 +70,7 @@ describe("checkAutoCompaction", () => { test("returns false when model has no max_input_tokens (unknown model)", () => { const usage = createMockUsage(50_000); - const result = checkAutoCompaction(usage, "unknown-model", false, true); + const result = checkAutoCompaction(usage, "unknown-model", false); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -79,7 +79,7 @@ describe("checkAutoCompaction", () => { test("returns false when usage is low (10%)", () => { const usage = createMockUsage(20_000); // 10% of 200k - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(10); @@ -88,7 +88,7 @@ describe("checkAutoCompaction", () => { test("returns true at warning threshold (60% with default 10% advance)", () => { const usage = createMockUsage(SONNET_60_PERCENT); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(60); @@ -97,7 +97,7 @@ describe("checkAutoCompaction", () => { test("returns true at compaction threshold (70%)", () => { const usage = createMockUsage(SONNET_70_PERCENT); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(70); @@ -106,7 +106,7 @@ describe("checkAutoCompaction", () => { test("returns true above threshold (80%)", () => { const usage = createMockUsage(160_000); // 80% of 200k - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(80); @@ -117,7 +117,7 @@ describe("checkAutoCompaction", () => { describe("Usage Calculation (Critical for infinite loop fix)", () => { test("uses last usage entry tokens, not cumulative sum", () => { const usage = createMockUsage(10_000); // Only 5% of context - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); // Should be 5%, not counting historical expect(result.usagePercentage).toBe(5); @@ -128,7 +128,7 @@ describe("checkAutoCompaction", () => { // Scenario: After compaction, historical = 70K, recent = 5K // Should calculate based on 5K (2.5%), not 75K (37.5%) const usage = createMockUsage(5_000, 70_000); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.usagePercentage).toBe(2.5); expect(result.shouldShowWarning).toBe(false); @@ -150,7 +150,7 @@ describe("checkAutoCompaction", () => { totalTokens: 0, }; - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); // Total: 10k + 5k + 2k + 3k + 1k = 21k tokens = 10.5% expect(result.usagePercentage).toBe(10.5); @@ -160,7 +160,7 @@ describe("checkAutoCompaction", () => { describe("1M Context Mode", () => { test("uses 1M tokens when use1M=true and model supports it (Sonnet 4)", () => { const usage = createMockUsage(600_000); // 60% of 1M - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, true, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, true); expect(result.usagePercentage).toBe(60); expect(result.shouldShowWarning).toBe(true); @@ -168,7 +168,7 @@ describe("checkAutoCompaction", () => { test("uses 1M tokens for Sonnet with use1M=true (model is claude-sonnet-4-5)", () => { const usage = createMockUsage(700_000); // 70% of 1M - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, true, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, true); expect(result.usagePercentage).toBe(70); expect(result.shouldShowWarning).toBe(true); @@ -176,7 +176,7 @@ describe("checkAutoCompaction", () => { test("uses standard max_input_tokens when use1M=false", () => { const usage = createMockUsage(140_000); // 70% of 200k - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.usagePercentage).toBe(70); expect(result.shouldShowWarning).toBe(true); @@ -185,7 +185,7 @@ describe("checkAutoCompaction", () => { test("ignores use1M for models that don't support it (GPT)", () => { const usage = createMockUsage(100_000, undefined, KNOWN_MODELS.GPT_MINI.id); // GPT Mini has 272k context, so 100k = 36.76% - const result = checkAutoCompaction(usage, KNOWN_MODELS.GPT_MINI.id, true, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.GPT_MINI.id, true); // Should use standard 272k, not 1M (use1M ignored for GPT) expect(result.usagePercentage).toBeCloseTo(36.76, 1); @@ -196,7 +196,7 @@ describe("checkAutoCompaction", () => { describe("Edge Cases", () => { test("empty usageHistory array returns safe defaults", () => { const usage: WorkspaceUsageState = { usageHistory: [], totalTokens: 0 }; - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -205,7 +205,7 @@ describe("checkAutoCompaction", () => { test("single entry in usageHistory works correctly", () => { const usage = createMockUsage(140_000); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(70); @@ -213,7 +213,7 @@ describe("checkAutoCompaction", () => { test("custom threshold parameter (80%)", () => { const usage = createMockUsage(140_000); // 70% of context - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true, 0.8); // 80% threshold + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, 0.8); // 80% threshold // At 70%, should NOT show warning for 80% threshold (needs 70% advance = 10%) expect(result.shouldShowWarning).toBe(true); // 70% >= (80% - 10% = 70%) @@ -223,7 +223,7 @@ describe("checkAutoCompaction", () => { test("custom warning advance (5% instead of 10%)", () => { const usage = createMockUsage(130_000); // 65% of context - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true, 0.7, 5); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, 0.7, 5); // At 65%, should show warning with 5% advance (70% - 5% = 65%) expect(result.shouldShowWarning).toBe(true); @@ -246,7 +246,7 @@ describe("checkAutoCompaction", () => { totalTokens: 0, }; - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -254,7 +254,7 @@ describe("checkAutoCompaction", () => { test("handles usage at exactly 100% of context", () => { const usage = createMockUsage(SONNET_MAX_TOKENS); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(100); @@ -263,7 +263,7 @@ describe("checkAutoCompaction", () => { test("handles usage beyond 100% of context", () => { const usage = createMockUsage(SONNET_MAX_TOKENS + 50_000); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(125); @@ -286,14 +286,14 @@ describe("checkAutoCompaction", () => { for (const { tokens, expectedPercent } of testCases) { const usage = createMockUsage(tokens); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.usagePercentage).toBe(expectedPercent); } }); test("handles fractional percentages correctly", () => { const usage = createMockUsage(123_456); // 61.728% - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.usagePercentage).toBeCloseTo(61.728, 2); expect(result.shouldShowWarning).toBe(true); // Above 60% @@ -306,7 +306,7 @@ describe("checkAutoCompaction", () => { test("shouldForceCompact is false when no liveUsage (falls back to lastUsage with room)", () => { const usage = createMockUsage(100_000); // 100k remaining - plenty of room - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldForceCompact).toBe(false); }); @@ -314,7 +314,7 @@ describe("checkAutoCompaction", () => { test("shouldForceCompact is false when currentUsage has plenty of room", () => { const liveUsage = createUsageEntry(100_000); // 100k remaining const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldForceCompact).toBe(false); }); @@ -323,7 +323,7 @@ describe("checkAutoCompaction", () => { // Exactly at buffer threshold const liveUsage = createUsageEntry(SONNET_MAX_TOKENS - BUFFER); const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldForceCompact).toBe(true); }); @@ -331,7 +331,7 @@ describe("checkAutoCompaction", () => { test("shouldForceCompact is true when over context limit", () => { const liveUsage = createUsageEntry(SONNET_MAX_TOKENS + 5000); const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldForceCompact).toBe(true); }); @@ -340,7 +340,7 @@ describe("checkAutoCompaction", () => { // 1 token above buffer threshold const liveUsage = createUsageEntry(SONNET_MAX_TOKENS - BUFFER - 1); const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldForceCompact).toBe(false); }); @@ -349,7 +349,7 @@ describe("checkAutoCompaction", () => { // With 1M context, exactly at buffer threshold const liveUsage = createUsageEntry(1_000_000 - BUFFER); const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, true, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, true); expect(result.shouldForceCompact).toBe(true); }); @@ -358,7 +358,7 @@ describe("checkAutoCompaction", () => { // Bug fix: empty history but liveUsage should still trigger const liveUsage = createUsageEntry(SONNET_MAX_TOKENS - BUFFER); const usage: WorkspaceUsageState = { usageHistory: [], totalTokens: 0, liveUsage }; - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false); expect(result.shouldForceCompact).toBe(true); expect(result.usagePercentage).toBe(0); // No lastUsage for percentage @@ -367,7 +367,7 @@ describe("checkAutoCompaction", () => { test("shouldForceCompact is false when auto-compaction disabled", () => { const liveUsage = createUsageEntry(199_000); // Very close to limit const usage = createMockUsage(50_000, undefined, KNOWN_MODELS.SONNET.id, liveUsage); - const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, false); // disabled + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, 1.0); // disabled expect(result.shouldForceCompact).toBe(false); }); diff --git a/src/browser/utils/compaction/autoCompactionCheck.ts b/src/browser/utils/compaction/autoCompactionCheck.ts index 01aec4b6e8..107221c424 100644 --- a/src/browser/utils/compaction/autoCompactionCheck.ts +++ b/src/browser/utils/compaction/autoCompactionCheck.ts @@ -56,8 +56,7 @@ const WARNING_ADVANCE_PERCENT = 10; * @param usage - Current workspace usage state (from useWorkspaceUsage) * @param model - Current model string (optional - returns safe default if not provided) * @param use1M - Whether 1M context is enabled - * @param enabled - Whether auto-compaction is enabled for this workspace - * @param threshold - Usage percentage threshold (0.0-1.0, default 0.7 = 70%) + * @param threshold - Usage percentage threshold (0.0-1.0, default 0.7 = 70%). If >= 1.0, auto-compaction is considered disabled. * @param warningAdvancePercent - Show warning this many percentage points before threshold (default 10) * @returns Check result with warning flag and usage percentage */ @@ -65,14 +64,14 @@ export function checkAutoCompaction( usage: WorkspaceUsageState | undefined, model: string | null, use1M: boolean, - enabled: boolean, threshold: number = DEFAULT_AUTO_COMPACTION_THRESHOLD, warningAdvancePercent: number = WARNING_ADVANCE_PERCENT ): AutoCompactionCheckResult { const thresholdPercentage = threshold * 100; + const isEnabled = threshold < 1.0; // Short-circuit if auto-compaction is disabled or missing required data - if (!enabled || !model || !usage) { + if (!isEnabled || !model || !usage) { return { shouldShowWarning: false, shouldForceCompact: false, From a4d98a110b240745ce36752419ef501c1649eee0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 12:32:26 -0600 Subject: [PATCH 09/13] =?UTF-8?q?=F0=9F=A4=96=20fix:=20make=20threshold=20?= =?UTF-8?q?slider=20draggable=20with=20inline=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slider was intermittently failing to render or receive pointer events when using Tailwind classes. Switched to inline styles which work reliably. Also: - Extracted Triangle subcomponent - Made triangle size configurable via constant - Reduced visual footprint (smaller triangles, thinner line) - Centered indicator vertically on bar - Updated tooltip to mention per-model storage --- .../components/RightSidebar/CostsTab.tsx | 20 +- .../RightSidebar/ThresholdSlider.tsx | 190 +++++++----------- 2 files changed, 83 insertions(+), 127 deletions(-) diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index b66c2a15ca..dd881fa156 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -235,17 +235,17 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { }} /> )} - {/* Threshold slider overlay - inside bar for proper positioning */} - {maxTokens && ( - - )}
+ {/* Threshold slider overlay */} + {maxTokens && ( + + )}
{showWarning && ( diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index e96abec2bf..6aa60532f0 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from "react"; +import React, { useRef } from "react"; import { AUTO_COMPACTION_THRESHOLD_MIN, AUTO_COMPACTION_THRESHOLD_MAX, @@ -20,6 +20,26 @@ interface HorizontalThresholdSliderProps { /** Threshold at which we consider auto-compaction disabled (dragged all the way right) */ const DISABLE_THRESHOLD = 100; +/** Size of the triangle markers in pixels */ +const TRIANGLE_SIZE = 4; + +// ----- Subcomponents ----- + +/** CSS triangle pointing in specified direction */ +const Triangle: React.FC<{ direction: "up" | "down"; color: string }> = ({ direction, color }) => ( +
+); + // ----- Main component: HorizontalThresholdSlider ----- /** @@ -29,145 +49,81 @@ const DISABLE_THRESHOLD = 100; * Drag left/right to adjust threshold. Drag to 100% to disable. * * USAGE: Place as a sibling AFTER the progress bar, both inside a relative container. + * + * NOTE: This component uses inline styles instead of Tailwind classes intentionally. + * When using Tailwind classes (e.g., `className="absolute cursor-ew-resize"`), the + * component would intermittently fail to render or receive pointer events, despite + * the React component mounting correctly. The root cause appears to be related to + * how Tailwind's JIT compiler or class application interacts with dynamically + * rendered components in this context. Inline styles work reliably. */ export const HorizontalThresholdSlider: React.FC = ({ config }) => { const containerRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [dragValue, setDragValue] = useState(null); - - // Current display position - const position = dragValue ?? config.threshold; - // Derive enabled state from threshold - const isEnabled = config.threshold < DISABLE_THRESHOLD; + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); - const calculatePercentage = useCallback( - (clientX: number): number => { - const container = containerRef.current; - if (!container) return config.threshold; + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; - const rect = container.getBoundingClientRect(); + const calcPercent = (clientX: number) => { const raw = ((clientX - rect.left) / rect.width) * 100; - // Allow dragging up to 100 (disabled state) const clamped = Math.max(AUTO_COMPACTION_THRESHOLD_MIN, Math.min(100, raw)); return Math.round(clamped / 5) * 5; - }, - [config.threshold] - ); + }; - const applyThreshold = useCallback( - (percentage: number) => { - if (percentage >= DISABLE_THRESHOLD) { - // Set to 100 to disable - config.setThreshold(100); - } else { - // Clamp to max allowed active threshold (e.g. 90%) - config.setThreshold(Math.min(percentage, AUTO_COMPACTION_THRESHOLD_MAX)); - } - }, - [config] - ); + const applyThreshold = (pct: number) => { + config.setThreshold(pct >= DISABLE_THRESHOLD ? 100 : Math.min(pct, AUTO_COMPACTION_THRESHOLD_MAX)); + }; - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const percentage = calculatePercentage(e.clientX); - setIsDragging(true); - setDragValue(percentage); - applyThreshold(percentage); - - const handleMouseMove = (moveEvent: MouseEvent) => { - const newPercentage = calculatePercentage(moveEvent.clientX); - setDragValue(newPercentage); - applyThreshold(newPercentage); - }; - - const handleMouseUp = () => { - setIsDragging(false); - setDragValue(null); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }, - [calculatePercentage, applyThreshold] - ); + applyThreshold(calcPercent(e.clientX)); - // Tooltip text - const title = isDragging - ? dragValue !== null && dragValue >= DISABLE_THRESHOLD - ? "Release to disable auto-compact" - : `Auto-compact at ${dragValue}%` - : isEnabled - ? `Auto-compact at ${config.threshold}% · Drag to adjust` - : "Auto-compact disabled · Drag left to enable"; + const onMove = (ev: MouseEvent) => applyThreshold(calcPercent(ev.clientX)); + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; - const lineColor = isEnabled - ? "var(--color-plan-mode)" - : "var(--color-muted)"; - const opacity = isDragging ? 1 : 0.8; + const isEnabled = config.threshold < DISABLE_THRESHOLD; + const color = isEnabled ? "var(--color-plan-mode)" : "var(--color-muted)"; + const title = isEnabled + ? `Auto-compact at ${config.threshold}% · Drag to adjust (per-model)` + : "Auto-compact disabled · Drag left to enable (per-model)"; return (
- {/* Hit Area - Wider than the bar for easier grabbing */} -
- - {/* Visual Indicator - Strictly positioned relative to the bar (containerRef) */} + {/* Indicator: top triangle + line + bottom triangle, centered on threshold */}
- {/* Top triangle (pointing down) */} -
- {/* Line */} -
- {/* Bottom triangle (pointing up) */} -
+ +
+
); From 28a6667ab75fcf9a4ca5457dcf1e461e5144bac4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 12:37:29 -0600 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=A4=96=20fix:=20restore=20VerticalT?= =?UTF-8?q?okenMeter=20proportional=20scaling,=20fix=20context=20bar=20rou?= =?UTF-8?q?nding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regressions fixed: - Restored usagePercentage-based flex scaling in VerticalTokenMeter (bar height reflects context usage) - Fixed context usage bar rounded corners by wrapping segments in overflow-hidden container - Kept slider outside the bar container for proper positioning --- src/browser/components/AIView.tsx | 6 +- src/browser/components/RightSidebar.tsx | 6 +- .../components/RightSidebar/CostsTab.tsx | 87 ++++++++++--------- .../RightSidebar/ThresholdSlider.tsx | 4 +- .../RightSidebar/VerticalTokenMeter.tsx | 85 ++++++++++-------- .../hooks/useAutoCompactionSettings.ts | 4 +- 6 files changed, 104 insertions(+), 88 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 703ba880a5..126ee4af26 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -91,8 +91,10 @@ const AIViewInner: React.FC = ({ const pendingSendOptions = useSendMessageOptions(workspaceId); const pendingModel = pendingSendOptions.model; - const { threshold: autoCompactionThreshold } = - useAutoCompactionSettings(workspaceId, pendingModel); + const { threshold: autoCompactionThreshold } = useAutoCompactionSettings( + workspaceId, + pendingModel + ); const handledModelErrorsRef = useRef>(new Set()); useEffect(() => { diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 3d6b2a91b5..736dad60bd 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -139,10 +139,8 @@ const RightSidebarComponent: React.FC = ({ const model = lastUsage?.model ?? null; // Auto-compaction settings: threshold per-model - const { - threshold: autoCompactThreshold, - setThreshold: setAutoCompactThreshold, - } = useAutoCompactionSettings(workspaceId, model); + const { threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold } = + useAutoCompactionSettings(workspaceId, model); // Memoize vertical meter data calculation to prevent unnecessary re-renders const verticalMeterData = React.useMemo(() => { diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index dd881fa156..b9da3f7cc0 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -69,10 +69,8 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { const currentModel = contextUsage?.model ?? null; // Auto-compaction settings: threshold per-model (100 = disabled) - const { - threshold: autoCompactThreshold, - setThreshold: setAutoCompactThreshold, - } = useAutoCompactionSettings(workspaceId, currentModel); + const { threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold } = + useAutoCompactionSettings(workspaceId, currentModel); // Session usage for cost const sessionUsage = React.useMemo(() => { @@ -187,56 +185,59 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => {
- {/* Bar container - relative for slider positioning */} -
- {cachedPercentage > 0 && ( -
- )} - {cacheCreatePercentage > 0 && ( + {/* Bar container - relative for slider positioning, overflow-hidden for rounded corners */} +
+ {/* Segments container - flex layout for stacked percentages */} +
+ {cachedPercentage > 0 && ( +
+ )} + {cacheCreatePercentage > 0 && ( +
+ )}
- )} -
-
- {reasoningPercentage > 0 && (
- )} + {reasoningPercentage > 0 && ( +
+ )} +
- {/* Threshold slider overlay */} + {/* Threshold slider overlay - positioned relative to outer container */} {maxTokens && ( }; const applyThreshold = (pct: number) => { - config.setThreshold(pct >= DISABLE_THRESHOLD ? 100 : Math.min(pct, AUTO_COMPACTION_THRESHOLD_MAX)); + config.setThreshold( + pct >= DISABLE_THRESHOLD ? 100 : Math.min(pct, AUTO_COMPACTION_THRESHOLD_MAX) + ); }; applyThreshold(calcPercent(e.clientX)); diff --git a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx index ca29d73fca..d9484fd317 100644 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx @@ -17,6 +17,9 @@ interface VerticalTokenMeterProps { const VerticalTokenMeterComponent: React.FC = ({ data }) => { if (data.segments.length === 0) return null; + // Scale the bar based on context window usage (0-100%) + const usagePercentage = data.maxTokens ? data.totalPercentage : 100; + return (
= ({ data }
)} - {/* The bar with tooltip */} -
- - - -
-
Last Request
-
- {data.segments.map((seg, i) => ( -
-
-
- {getSegmentLabel(seg.type)} + {/* Bar container - flex to scale bar proportionally to usage */} +
+ {/* Used portion - grows based on usage percentage */} +
+
+ + + +
+
Last Request
+
+ {data.segments.map((seg, i) => ( +
+
+
+ {getSegmentLabel(seg.type)} +
+ + {formatTokens(seg.tokens)} + +
+ ))} +
+
+ Total: {formatTokens(data.totalTokens)} + {data.maxTokens && ` / ${formatTokens(data.maxTokens)}`} + {data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`} +
+
+ 💡 Expand your viewport to see full details
- {formatTokens(seg.tokens)}
- ))} -
-
- Total: {formatTokens(data.totalTokens)} - {data.maxTokens && ` / ${formatTokens(data.maxTokens)}`} - {data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`} -
-
- 💡 Expand your viewport to see full details -
-
- - + + +
+
+ {/* Empty portion - takes remaining space */} +
); diff --git a/src/browser/hooks/useAutoCompactionSettings.ts b/src/browser/hooks/useAutoCompactionSettings.ts index 46cdfb6320..953a90ca4f 100644 --- a/src/browser/hooks/useAutoCompactionSettings.ts +++ b/src/browser/hooks/useAutoCompactionSettings.ts @@ -1,7 +1,5 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; -import { - getAutoCompactionThresholdKey, -} from "@/common/constants/storage"; +import { getAutoCompactionThresholdKey } from "@/common/constants/storage"; import { DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT } from "@/common/constants/ui"; export interface AutoCompactionSettings { From 4d31e9e43345ae172f964320aadafe9b1e18a213 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 12:46:05 -0600 Subject: [PATCH 11/13] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20unify=20Thresh?= =?UTF-8?q?oldSlider=20for=20horizontal=20and=20vertical=20orientations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThresholdSlider component now supports both orientations - Removed 50% minimum threshold - allow 0-90% (100% = disabled) - Added VerticalThresholdSlider to VerticalTokenMeter - Both sliders share the same per-model threshold state - Use native title tooltips for hover feedback Co-authored-by: mux --- .../RightSidebar/ThresholdSlider.tsx | 187 ++++++++++++------ .../RightSidebar/VerticalTokenMeter.tsx | 21 +- src/common/constants/ui.ts | 5 +- 3 files changed, 141 insertions(+), 72 deletions(-) diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index 5d1f5af625..24a984eccd 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -11,13 +11,14 @@ export interface AutoCompactionConfig { setThreshold: (threshold: number) => void; } -interface HorizontalThresholdSliderProps { +interface ThresholdSliderProps { config: AutoCompactionConfig; + orientation: "horizontal" | "vertical"; } // ----- Constants ----- -/** Threshold at which we consider auto-compaction disabled (dragged all the way right) */ +/** Threshold at which we consider auto-compaction disabled (dragged all the way to end) */ const DISABLE_THRESHOLD = 100; /** Size of the triangle markers in pixels */ @@ -26,27 +27,65 @@ const TRIANGLE_SIZE = 4; // ----- Subcomponents ----- /** CSS triangle pointing in specified direction */ -const Triangle: React.FC<{ direction: "up" | "down"; color: string }> = ({ direction, color }) => ( -
-); +const Triangle: React.FC<{ direction: "up" | "down" | "left" | "right"; color: string }> = ({ + direction, + color, +}) => { + const styles: React.CSSProperties = { width: 0, height: 0 }; + + if (direction === "up" || direction === "down") { + styles.borderLeft = `${TRIANGLE_SIZE}px solid transparent`; + styles.borderRight = `${TRIANGLE_SIZE}px solid transparent`; + if (direction === "down") { + styles.borderTop = `${TRIANGLE_SIZE}px solid ${color}`; + } else { + styles.borderBottom = `${TRIANGLE_SIZE}px solid ${color}`; + } + } else { + styles.borderTop = `${TRIANGLE_SIZE}px solid transparent`; + styles.borderBottom = `${TRIANGLE_SIZE}px solid transparent`; + if (direction === "right") { + styles.borderLeft = `${TRIANGLE_SIZE}px solid ${color}`; + } else { + styles.borderRight = `${TRIANGLE_SIZE}px solid ${color}`; + } + } + + return
; +}; + +// ----- Shared utilities ----- -// ----- Main component: HorizontalThresholdSlider ----- +/** Clamp and snap percentage to valid threshold values */ +const snapPercent = (raw: number): number => { + const clamped = Math.max(AUTO_COMPACTION_THRESHOLD_MIN, Math.min(100, raw)); + return Math.round(clamped / 5) * 5; +}; + +/** Apply threshold, handling the disable case */ +const applyThreshold = (pct: number, setThreshold: (v: number) => void): void => { + setThreshold(pct >= DISABLE_THRESHOLD ? 100 : Math.min(pct, AUTO_COMPACTION_THRESHOLD_MAX)); +}; + +/** Get tooltip text based on threshold */ +const getTooltip = (threshold: number, orientation: "horizontal" | "vertical"): string => { + const isEnabled = threshold < DISABLE_THRESHOLD; + const direction = orientation === "horizontal" ? "left" : "up"; + return isEnabled + ? `Auto-compact at ${threshold}% · Drag to adjust (per-model)` + : `Auto-compact disabled · Drag ${direction} to enable (per-model)`; +}; + +// ----- Main component: ThresholdSlider ----- /** - * A draggable threshold indicator for horizontal progress bars. + * A draggable threshold indicator for progress bars (horizontal or vertical). * - * Renders as a vertical line with triangle handles at the threshold position. - * Drag left/right to adjust threshold. Drag to 100% to disable. + * - Horizontal: Renders as a vertical line with up/down triangle handles. + * Drag left/right to adjust threshold. Drag to 100% (right) to disable. + * + * - Vertical: Renders as a horizontal line with left/right triangle handles. + * Drag up/down to adjust threshold. Drag to 100% (bottom) to disable. * * USAGE: Place as a sibling AFTER the progress bar, both inside a relative container. * @@ -57,8 +96,9 @@ const Triangle: React.FC<{ direction: "up" | "down"; color: string }> = ({ direc * how Tailwind's JIT compiler or class application interacts with dynamically * rendered components in this context. Inline styles work reliably. */ -export const HorizontalThresholdSlider: React.FC = ({ config }) => { +export const ThresholdSlider: React.FC = ({ config, orientation }) => { const containerRef = useRef(null); + const isHorizontal = orientation === "horizontal"; const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); @@ -66,21 +106,20 @@ export const HorizontalThresholdSlider: React.FC const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; - const calcPercent = (clientX: number) => { - const raw = ((clientX - rect.left) / rect.width) * 100; - const clamped = Math.max(AUTO_COMPACTION_THRESHOLD_MIN, Math.min(100, raw)); - return Math.round(clamped / 5) * 5; + const calcPercent = (clientX: number, clientY: number) => { + if (isHorizontal) { + return snapPercent(((clientX - rect.left) / rect.width) * 100); + } else { + // Vertical: top = low %, bottom = high % + return snapPercent(((clientY - rect.top) / rect.height) * 100); + } }; - const applyThreshold = (pct: number) => { - config.setThreshold( - pct >= DISABLE_THRESHOLD ? 100 : Math.min(pct, AUTO_COMPACTION_THRESHOLD_MAX) - ); - }; + const apply = (pct: number) => applyThreshold(pct, config.setThreshold); - applyThreshold(calcPercent(e.clientX)); + apply(calcPercent(e.clientX, e.clientY)); - const onMove = (ev: MouseEvent) => applyThreshold(calcPercent(ev.clientX)); + const onMove = (ev: MouseEvent) => apply(calcPercent(ev.clientX, ev.clientY)); const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); @@ -91,42 +130,64 @@ export const HorizontalThresholdSlider: React.FC const isEnabled = config.threshold < DISABLE_THRESHOLD; const color = isEnabled ? "var(--color-plan-mode)" : "var(--color-muted)"; - const title = isEnabled - ? `Auto-compact at ${config.threshold}% · Drag to adjust (per-model)` - : "Auto-compact disabled · Drag left to enable (per-model)"; + const title = getTooltip(config.threshold, orientation); + + // Container styles + const containerStyle: React.CSSProperties = { + position: "absolute", + cursor: isHorizontal ? "ew-resize" : "ns-resize", + top: 0, + bottom: 0, + left: 0, + right: 0, + zIndex: 50, + }; - return ( -
- {/* Indicator: top triangle + line + bottom triangle, centered on threshold */} -
- -
- + } + : { + top: `${config.threshold}%`, + left: "50%", + transform: "translate(-50%, -50%)", + flexDirection: "row", + }), + }; + + // Line between triangles + const lineStyle: React.CSSProperties = isHorizontal + ? { width: 1, height: 6, background: color } + : { width: 6, height: 1, background: color }; + + return ( +
+
+ +
+
); }; + +// ----- Convenience exports ----- + +/** Horizontal threshold slider (alias for backwards compatibility) */ +export const HorizontalThresholdSlider: React.FC<{ config: AutoCompactionConfig }> = ({ + config, +}) => ; + +/** Vertical threshold slider */ +export const VerticalThresholdSlider: React.FC<{ config: AutoCompactionConfig }> = ({ config }) => ( + +); diff --git a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx index d9484fd317..65acf1fdbf 100644 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx @@ -1,7 +1,7 @@ import React from "react"; import { TooltipWrapper, Tooltip } from "../Tooltip"; import { TokenMeter } from "./TokenMeter"; -import type { AutoCompactionConfig } from "./ThresholdSlider"; +import { VerticalThresholdSlider, type AutoCompactionConfig } from "./ThresholdSlider"; import { type TokenMeterData, formatTokens, @@ -10,11 +10,14 @@ import { interface VerticalTokenMeterProps { data: TokenMeterData; - /** Auto-compaction settings - reserved for future vertical slider */ + /** Auto-compaction settings for threshold slider */ autoCompaction?: AutoCompactionConfig; } -const VerticalTokenMeterComponent: React.FC = ({ data }) => { +const VerticalTokenMeterComponent: React.FC = ({ + data, + autoCompaction, +}) => { if (data.segments.length === 0) return null; // Scale the bar based on context window usage (0-100%) @@ -35,14 +38,15 @@ const VerticalTokenMeterComponent: React.FC = ({ data }
)} - {/* Bar container - flex to scale bar proportionally to usage */} -
+ {/* Bar container - relative for slider positioning, flex for proportional scaling */} +
{/* Used portion - grows based on usage percentage */}
-
+ {/* [&>*] selector makes TooltipWrapper fill available space */} +
= ({ data }
{/* Empty portion - takes remaining space */}
+ + {/* Threshold slider overlay - only when autoCompaction config provided and maxTokens known */} + {autoCompaction && data.maxTokens && }
); diff --git a/src/common/constants/ui.ts b/src/common/constants/ui.ts index d691b9702f..119653b0df 100644 --- a/src/common/constants/ui.ts +++ b/src/common/constants/ui.ts @@ -12,9 +12,10 @@ export const COMPACTED_EMOJI = "📦"; /** * Auto-compaction threshold bounds (percentage) - * Too low risks frequent interruptions; too high risks hitting context limits + * MIN: Allow any value - user can choose aggressive compaction if desired + * MAX: Cap at 90% to leave buffer before hitting context limit */ -export const AUTO_COMPACTION_THRESHOLD_MIN = 50; +export const AUTO_COMPACTION_THRESHOLD_MIN = 0; export const AUTO_COMPACTION_THRESHOLD_MAX = 90; /** From f5afb5c8828ad32f6a3111e70335034fb88acbb4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 12:49:08 -0600 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=A4=96=20feat:=20extend=20Tooltip?= =?UTF-8?q?=20to=20support=20left/right=20positioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added position='left' and position='right' to Tooltip component - Includes collision detection (flips to opposite side if needed) - Arrow properly positioned for horizontal tooltips - Used for vertical threshold slider tooltip (avoids overflow clipping) --- .../RightSidebar/ThresholdSlider.tsx | 34 ++++- src/browser/components/Tooltip.tsx | 132 ++++++++++++------ 2 files changed, 116 insertions(+), 50 deletions(-) diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index 24a984eccd..9ec459e124 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -3,6 +3,7 @@ import { AUTO_COMPACTION_THRESHOLD_MIN, AUTO_COMPACTION_THRESHOLD_MAX, } from "@/common/constants/ui"; +import { TooltipWrapper, Tooltip } from "../Tooltip"; // ----- Types ----- @@ -169,12 +170,37 @@ export const ThresholdSlider: React.FC = ({ config, orient ? { width: 1, height: 6, background: color } : { width: 6, height: 1, background: color }; + // Indicator content (triangles + line) + const indicatorContent = ( + <> + +
+ + + ); + return ( -
+
- -
- + {isHorizontal ? ( + // Horizontal: native title tooltip (works well positioned at cursor) +
+ {indicatorContent} +
+ ) : ( + // Vertical: portal-based Tooltip to avoid clipping by overflow:hidden containers + +
+ {indicatorContent} +
+ +
{title}
+
+
+ )}
); diff --git a/src/browser/components/Tooltip.tsx b/src/browser/components/Tooltip.tsx index d040658f14..6097f8acf8 100644 --- a/src/browser/components/Tooltip.tsx +++ b/src/browser/components/Tooltip.tsx @@ -60,7 +60,7 @@ export const TooltipWrapper: React.FC = ({ inline = false, interface TooltipProps { align?: "left" | "center" | "right"; width?: "auto" | "wide"; - position?: "top" | "bottom"; + position?: "top" | "bottom" | "left" | "right"; children: React.ReactNode; className?: string; interactive?: boolean; @@ -109,52 +109,99 @@ export const Tooltip: React.FC = ({ let left: number; let finalPosition = position; const gap = 8; // Gap between trigger and tooltip + const isHorizontalPosition = position === "left" || position === "right"; - // Vertical positioning with collision detection - if (position === "bottom") { - top = trigger.bottom + gap; - // Check if tooltip would overflow bottom of viewport - if (top + tooltip.height > viewportHeight) { - // Flip to top - finalPosition = "top"; - top = trigger.top - tooltip.height - gap; + if (isHorizontalPosition) { + // Horizontal positioning (left/right of trigger) + top = trigger.top + trigger.height / 2 - tooltip.height / 2; + + if (position === "left") { + left = trigger.left - tooltip.width - gap; + // Check if tooltip would overflow left of viewport + if (left < 8) { + finalPosition = "right"; + left = trigger.right + gap; + } + } else { + // position === "right" + left = trigger.right + gap; + // Check if tooltip would overflow right of viewport + if (left + tooltip.width > viewportWidth - 8) { + finalPosition = "left"; + left = trigger.left - tooltip.width - gap; + } } + + // Vertical collision detection for horizontal tooltips + top = Math.max(8, Math.min(viewportHeight - tooltip.height - 8, top)); } else { - // position === "top" - top = trigger.top - tooltip.height - gap; - // Check if tooltip would overflow top of viewport - if (top < 0) { - // Flip to bottom - finalPosition = "bottom"; + // Vertical positioning (top/bottom of trigger) with collision detection + if (position === "bottom") { top = trigger.bottom + gap; + // Check if tooltip would overflow bottom of viewport + if (top + tooltip.height > viewportHeight) { + // Flip to top + finalPosition = "top"; + top = trigger.top - tooltip.height - gap; + } + } else { + // position === "top" + top = trigger.top - tooltip.height - gap; + // Check if tooltip would overflow top of viewport + if (top < 0) { + // Flip to bottom + finalPosition = "bottom"; + top = trigger.bottom + gap; + } } - } - // Horizontal positioning based on align - if (align === "left") { - left = trigger.left; - } else if (align === "right") { - left = trigger.right - tooltip.width; - } else { - // center - left = trigger.left + trigger.width / 2 - tooltip.width / 2; + // Horizontal positioning based on align + if (align === "left") { + left = trigger.left; + } else if (align === "right") { + left = trigger.right - tooltip.width; + } else { + // center + left = trigger.left + trigger.width / 2 - tooltip.width / 2; + } + + // Horizontal collision detection + const minLeft = 8; // Min distance from viewport edge + const maxLeft = viewportWidth - tooltip.width - 8; + left = Math.max(minLeft, Math.min(maxLeft, left)); } - // Horizontal collision detection - const minLeft = 8; // Min distance from viewport edge - const maxLeft = viewportWidth - tooltip.width - 8; - const originalLeft = left; - left = Math.max(minLeft, Math.min(maxLeft, left)); - - // Calculate arrow position - stays aligned with trigger even if tooltip shifts - let arrowLeft: number; - if (align === "center") { - arrowLeft = trigger.left + trigger.width / 2 - left; - } else if (align === "right") { - arrowLeft = tooltip.width - 15; // 10px from right + 5px arrow width + // Calculate arrow style based on final position + const arrowStyle: React.CSSProperties = {}; + const finalIsHorizontal = finalPosition === "left" || finalPosition === "right"; + + if (finalIsHorizontal) { + // Arrow on left or right side of tooltip, vertically centered + arrowStyle.top = "50%"; + arrowStyle.transform = "translateY(-50%)"; + if (finalPosition === "left") { + arrowStyle.left = "100%"; + arrowStyle.borderColor = "transparent transparent transparent #2d2d30"; + } else { + arrowStyle.right = "100%"; + arrowStyle.borderColor = "transparent #2d2d30 transparent transparent"; + } } else { - // left - arrowLeft = Math.max(10, Math.min(originalLeft - left + 10, tooltip.width - 15)); + // Arrow on top or bottom of tooltip + let arrowLeft: number; + if (align === "center") { + arrowLeft = trigger.left + trigger.width / 2 - left; + } else if (align === "right") { + arrowLeft = tooltip.width - 15; + } else { + arrowLeft = Math.max(10, Math.min(trigger.left - left + 10, tooltip.width - 15)); + } + arrowStyle.left = `${arrowLeft}px`; + arrowStyle[finalPosition === "bottom" ? "bottom" : "top"] = "100%"; + arrowStyle.borderColor = + finalPosition === "bottom" + ? "transparent transparent #2d2d30 transparent" + : "#2d2d30 transparent transparent transparent"; } // Update all state atomically to prevent flashing @@ -166,14 +213,7 @@ export const Tooltip: React.FC = ({ visibility: "visible", opacity: 1, }, - arrowStyle: { - left: `${arrowLeft}px`, - [finalPosition === "bottom" ? "bottom" : "top"]: "100%", - borderColor: - finalPosition === "bottom" - ? "transparent transparent #2d2d30 transparent" - : "#2d2d30 transparent transparent transparent", - }, + arrowStyle, isPositioned: true, }); }; From b087fda04ee35ce777b49779971823154f7e3f02 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 13:04:11 -0600 Subject: [PATCH 13/13] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20simplify=20Thr?= =?UTF-8?q?esholdSlider=20tooltip=20and=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Horizontal slider uses native title attribute (simpler, no clipping) - Vertical slider uses dedicated VerticalSliderTooltip portal component - Removed unused orientation param from SliderTooltip - Fixed VerticalTokenMeter flex layout for proper segment display --- .../RightSidebar/ThresholdSlider.tsx | 101 ++++++++++++------ .../RightSidebar/VerticalTokenMeter.tsx | 6 +- 2 files changed, 72 insertions(+), 35 deletions(-) diff --git a/src/browser/components/RightSidebar/ThresholdSlider.tsx b/src/browser/components/RightSidebar/ThresholdSlider.tsx index 9ec459e124..e028946bf4 100644 --- a/src/browser/components/RightSidebar/ThresholdSlider.tsx +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -1,9 +1,9 @@ -import React, { useRef } from "react"; +import React, { useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { AUTO_COMPACTION_THRESHOLD_MIN, AUTO_COMPACTION_THRESHOLD_MAX, } from "@/common/constants/ui"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; // ----- Types ----- @@ -69,7 +69,7 @@ const applyThreshold = (pct: number, setThreshold: (v: number) => void): void => }; /** Get tooltip text based on threshold */ -const getTooltip = (threshold: number, orientation: "horizontal" | "vertical"): string => { +const getTooltipText = (threshold: number, orientation: "horizontal" | "vertical"): string => { const isEnabled = threshold < DISABLE_THRESHOLD; const direction = orientation === "horizontal" ? "left" : "up"; return isEnabled @@ -77,6 +77,46 @@ const getTooltip = (threshold: number, orientation: "horizontal" | "vertical"): : `Auto-compact disabled · Drag ${direction} to enable (per-model)`; }; +// ----- Portal Tooltip (vertical only) ----- + +interface VerticalSliderTooltipProps { + text: string; + anchorRect: DOMRect; + threshold: number; +} + +/** + * Portal-based tooltip for vertical slider only. + * Renders to document.body to escape the narrow container's clipping. + * Horizontal slider uses native `title` attribute instead (simpler, no clipping issues). + */ +const VerticalSliderTooltip: React.FC = ({ + text, + anchorRect, + threshold, +}) => { + // Position to the left of the bar, aligned with threshold position + const indicatorY = anchorRect.top + (anchorRect.height * threshold) / 100; + + const style: React.CSSProperties = { + position: "fixed", + zIndex: 9999, + background: "#2d2d30", + color: "#cccccc", + padding: "6px 10px", + borderRadius: 4, + fontSize: 12, + whiteSpace: "nowrap", + pointerEvents: "none", + boxShadow: "0 2px 8px rgba(0,0,0,0.3)", + right: window.innerWidth - anchorRect.left + 8, + top: indicatorY, + transform: "translateY(-50%)", + }; + + return createPortal(
{text}
, document.body); +}; + // ----- Main component: ThresholdSlider ----- /** @@ -99,6 +139,7 @@ const getTooltip = (threshold: number, orientation: "horizontal" | "vertical"): */ export const ThresholdSlider: React.FC = ({ config, orientation }) => { const containerRef = useRef(null); + const [isHovered, setIsHovered] = useState(false); const isHorizontal = orientation === "horizontal"; const handleMouseDown = (e: React.MouseEvent) => { @@ -131,7 +172,7 @@ export const ThresholdSlider: React.FC = ({ config, orient const isEnabled = config.threshold < DISABLE_THRESHOLD; const color = isEnabled ? "var(--color-plan-mode)" : "var(--color-muted)"; - const title = getTooltip(config.threshold, orientation); + const tooltipText = getTooltipText(config.threshold, orientation); // Container styles const containerStyle: React.CSSProperties = { @@ -170,38 +211,34 @@ export const ThresholdSlider: React.FC = ({ config, orient ? { width: 1, height: 6, background: color } : { width: 6, height: 1, background: color }; - // Indicator content (triangles + line) - const indicatorContent = ( - <> - -
- - - ); + // Get container rect for tooltip positioning (vertical only) + const containerRect = containerRef.current?.getBoundingClientRect(); return ( -
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + // Horizontal uses native title (simpler, no clipping issues with wide tooltips) + title={isHorizontal ? tooltipText : undefined} + > + {/* Visual indicator - pointer events disabled */}
- {isHorizontal ? ( - // Horizontal: native title tooltip (works well positioned at cursor) -
- {indicatorContent} -
- ) : ( - // Vertical: portal-based Tooltip to avoid clipping by overflow:hidden containers - -
- {indicatorContent} -
- -
{title}
-
-
- )} + +
+
+ + {/* Portal tooltip for vertical only - escapes narrow container clipping */} + {!isHorizontal && isHovered && containerRect && ( + + )}
); }; diff --git a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx index 65acf1fdbf..2673b01d36 100644 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx @@ -45,8 +45,8 @@ const VerticalTokenMeterComponent: React.FC = ({ className="flex min-h-[20px] w-full flex-col items-center" style={{ flex: usagePercentage }} > - {/* [&>*] selector makes TooltipWrapper fill available space */} -
+ {/* [&>*] selector makes TooltipWrapper span fill available space */} +
= ({ data-meter="token-bar" data-segment-count={data.segments.length} /> - +
Last Request