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..126ee4af26 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -87,8 +87,14 @@ const AIViewInner: React.FC = ({ const workspaceUsage = useWorkspaceUsage(workspaceId); const { options } = useProviderOptions(); const use1M = options.anthropic?.use1MContext ?? false; - const { enabled: autoCompactionEnabled, threshold: autoCompactionThreshold } = - useAutoCompactionSettings(workspaceId); + // Get pending model for auto-compaction settings (threshold is per-model) + const pendingSendOptions = useSendMessageOptions(workspaceId); + const pendingModel = pendingSendOptions.model; + + const { threshold: autoCompactionThreshold } = useAutoCompactionSettings( + workspaceId, + pendingModel + ); const handledModelErrorsRef = useRef>(new Set()); useEffect(() => { @@ -121,9 +127,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,16 +136,10 @@ 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, use1M, - autoCompactionEnabled, autoCompactionThreshold / 100 ); @@ -217,6 +214,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 +575,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..736dad60bd 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,18 @@ 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: threshold per-model + const { 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 +188,16 @@ 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( + () => ({ + threshold: autoCompactThreshold, + setThreshold: setAutoCompactThreshold, + }), + [autoCompactThreshold, 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..b9da3f7cc0 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 { HorizontalThresholdSlider } 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,14 @@ 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: threshold per-model (100 = disabled) + const { threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold } = + useAutoCompactionSettings(workspaceId, currentModel); + // Session usage for cost const sessionUsage = React.useMemo(() => { const historicalSum = sumUsageHistory(usage.usageHistory); @@ -164,7 +173,7 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { return ( <> -
+
Context Usage @@ -175,50 +184,69 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { {` (${totalPercentage.toFixed(1)}%)`}
-
-
- {cachedPercentage > 0 && ( +
+ {/* Bar container - relative for slider positioning, overflow-hidden for rounded corners */} +
+ {/* Segments container - flex layout for stacked percentages */} +
+ {cachedPercentage > 0 && ( +
+ )} + {cacheCreatePercentage > 0 && ( +
+ )}
- )} - {cacheCreatePercentage > 0 && (
- )} -
-
0 && ( +
+ )} +
+
+ {/* Threshold slider overlay - positioned relative to outer container */} + {maxTokens && ( + - {reasoningPercentage > 0 && ( -
- )} -
+ )}
{showWarning && ( @@ -233,8 +261,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..e028946bf4 --- /dev/null +++ b/src/browser/components/RightSidebar/ThresholdSlider.tsx @@ -0,0 +1,256 @@ +import React, { useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + AUTO_COMPACTION_THRESHOLD_MIN, + AUTO_COMPACTION_THRESHOLD_MAX, +} from "@/common/constants/ui"; + +// ----- Types ----- + +export interface AutoCompactionConfig { + threshold: number; + setThreshold: (threshold: number) => void; +} + +interface ThresholdSliderProps { + config: AutoCompactionConfig; + orientation: "horizontal" | "vertical"; +} + +// ----- Constants ----- + +/** 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 */ +const TRIANGLE_SIZE = 4; + +// ----- Subcomponents ----- + +/** CSS triangle pointing in specified direction */ +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 ----- + +/** 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 getTooltipText = (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)`; +}; + +// ----- 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 ----- + +/** + * A draggable threshold indicator for progress bars (horizontal or vertical). + * + * - 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. + * + * 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 ThresholdSlider: React.FC = ({ config, orientation }) => { + const containerRef = useRef(null); + const [isHovered, setIsHovered] = useState(false); + const isHorizontal = orientation === "horizontal"; + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + 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 apply = (pct: number) => applyThreshold(pct, config.setThreshold); + + apply(calcPercent(e.clientX, e.clientY)); + + const onMove = (ev: MouseEvent) => apply(calcPercent(ev.clientX, ev.clientY)); + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + const isEnabled = config.threshold < DISABLE_THRESHOLD; + const color = isEnabled ? "var(--color-plan-mode)" : "var(--color-muted)"; + const tooltipText = getTooltipText(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, + }; + + // Indicator positioning - use transform for centering on both axes + const indicatorStyle: React.CSSProperties = { + position: "absolute", + pointerEvents: "none", + display: "flex", + alignItems: "center", + ...(isHorizontal + ? { + left: `${config.threshold}%`, + top: "50%", + transform: "translate(-50%, -50%)", + flexDirection: "column", + } + : { + 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 }; + + // 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 */} +
+ +
+ +
+ + {/* Portal tooltip for vertical only - escapes narrow container clipping */} + {!isHorizontal && isHovered && containerRect && ( + + )} +
+ ); +}; + +// ----- 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 959a13d43b..2673b01d36 100644 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx @@ -1,13 +1,23 @@ import React from "react"; import { TooltipWrapper, Tooltip } from "../Tooltip"; import { TokenMeter } from "./TokenMeter"; +import { VerticalThresholdSlider, type AutoCompactionConfig } 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 for threshold slider */ + autoCompaction?: AutoCompactionConfig; +} + +const VerticalTokenMeterComponent: React.FC = ({ + data, + autoCompaction, +}) => { if (data.segments.length === 0) return null; // Scale the bar based on context window usage (0-100%) @@ -18,6 +28,7 @@ const VerticalTokenMeterComponent: React.FC<{ data: TokenMeterData }> = ({ data className="bg-separator border-border-light flex h-full w-5 flex-col items-center border-l py-3" data-component="vertical-token-meter" > + {/* Percentage label at top */} {data.maxTokens && (
= ({ data {Math.round(data.totalPercentage)}
)} -
+ + {/* Bar container - relative for slider positioning, flex for proportional scaling */} +
+ {/* Used portion - grows based on usage percentage */}
-
- + {/* [&>*] selector makes TooltipWrapper span fill available space */} +
+ - -
-
- Last Request -
-
+ +
+
Last Request
+
{data.segments.map((seg, i) => ( -
+
- {getSegmentLabel(seg.type)} + {getSegmentLabel(seg.type)}
- + {formatTokens(seg.tokens)}
))} -
-
+
+
Total: {formatTokens(data.totalTokens)} {data.maxTokens && ` / ${formatTokens(data.maxTokens)}`} {data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`} @@ -100,12 +86,11 @@ const VerticalTokenMeterComponent: React.FC<{ data: TokenMeterData }> = ({ data
-
+ {/* Empty portion - takes remaining space */} +
+ + {/* Threshold slider overlay - only when autoCompaction config provided and maxTokens known */} + {autoCompaction && data.maxTokens && }
); 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, }); }; diff --git a/src/browser/hooks/useAutoCompactionSettings.ts b/src/browser/hooks/useAutoCompactionSettings.ts index 3a5b436edb..953a90ca4f 100644 --- a/src/browser/hooks/useAutoCompactionSettings.ts +++ b/src/browser/hooks/useAutoCompactionSettings.ts @@ -1,40 +1,34 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; -import { - getAutoCompactionEnabledKey, - getAutoCompactionThresholdKey, -} from "@/common/constants/storage"; +import { 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 per workspace. - * Persists both enabled state and threshold percentage to localStorage. + * Custom hook for auto-compaction settings. + * - Threshold is per-model (different models have different context windows) + * - Threshold >= 100% means disabled for that model * - * @param workspaceId - Workspace identifier + * @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 */ -export function useAutoCompactionSettings(workspaceId: string): AutoCompactionSettings { - const [enabled, setEnabled] = usePersistedState( - getAutoCompactionEnabledKey(workspaceId), - true, - { listener: true } - ); - +export function useAutoCompactionSettings( + workspaceId: string, + model: string | null +): AutoCompactionSettings { + // 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 } ); - 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, 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 ]; /** 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; /**