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;
/**