diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index b8a5d3ab3..3a6be0e43 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -36,8 +36,9 @@ import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds"; import { evictModelFromLRU } from "@/browser/hooks/useModelLRU"; import { QueuedMessage } from "./Messages/QueuedMessage"; import { CompactionWarning } from "./CompactionWarning"; -import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck"; +import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCheck"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; +import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings"; interface AIViewProps { workspaceId: string; @@ -84,6 +85,8 @@ const AIViewInner: React.FC = ({ const workspaceUsage = useWorkspaceUsage(workspaceId); const { options } = useProviderOptions(); const use1M = options.anthropic?.use1MContext ?? false; + const { enabled: autoCompactionEnabled, threshold: autoCompactionThreshold } = + useAutoCompactionSettings(workspaceId); const handledModelErrorsRef = useRef>(new Set()); useEffect(() => { @@ -328,12 +331,16 @@ const AIViewInner: React.FC = ({ // Get active stream message ID for token counting const activeStreamMessageId = aggregator.getActiveStreamMessageId(); - const autoCompactionCheck = currentModel - ? shouldAutoCompact(workspaceUsage, currentModel, use1M) - : { shouldShowWarning: false, usagePercentage: 0, thresholdPercentage: 70 }; + const autoCompactionResult = checkAutoCompaction( + workspaceUsage, + currentModel, + use1M, + autoCompactionEnabled, + autoCompactionThreshold / 100 + ); // Show warning when: shouldShowWarning flag is true AND not currently compacting - const shouldShowCompactionWarning = !isCompacting && autoCompactionCheck.shouldShowWarning; + const shouldShowCompactionWarning = !isCompacting && autoCompactionResult.shouldShowWarning; // Note: We intentionally do NOT reset autoRetry when streams start. // If user pressed the interrupt key, autoRetry stays false until they manually retry. @@ -522,8 +529,8 @@ const AIViewInner: React.FC = ({ {shouldShowCompactionWarning && ( )} = ({ onEditLastUserMessage={() => void handleEditLastUserMessage()} canInterrupt={canInterrupt} onReady={handleChatInputReady} - autoCompactionCheck={autoCompactionCheck} + autoCompactionCheck={autoCompactionResult} /> diff --git a/src/browser/components/RightSidebar/AutoCompactionSettings.tsx b/src/browser/components/RightSidebar/AutoCompactionSettings.tsx new file mode 100644 index 000000000..518c85ce4 --- /dev/null +++ b/src/browser/components/RightSidebar/AutoCompactionSettings.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings"; +import { useClampedNumberInput } from "@/browser/hooks/useClampedNumberInput"; +import { + AUTO_COMPACTION_THRESHOLD_MIN, + AUTO_COMPACTION_THRESHOLD_MAX, +} from "@/common/constants/ui"; +import { TooltipWrapper, Tooltip, HelpIndicator } from "../Tooltip"; + +interface AutoCompactionSettingsProps { + workspaceId: string; +} + +export const AutoCompactionSettings: React.FC = ({ 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 aeae58152..f5053c8d5 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -8,6 +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 { AutoCompactionSettings } from "./AutoCompactionSettings"; // Format token display - show k for thousands with 1 decimal const formatTokens = (tokens: number) => @@ -231,6 +232,8 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { )} + {hasUsageData && } + {hasUsageData && (
diff --git a/src/browser/hooks/useAutoCompactionSettings.ts b/src/browser/hooks/useAutoCompactionSettings.ts new file mode 100644 index 000000000..3a5b436ed --- /dev/null +++ b/src/browser/hooks/useAutoCompactionSettings.ts @@ -0,0 +1,40 @@ +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) */ + threshold: number; + /** Update threshold percentage (will be clamped to 50-90 range by UI) */ + setThreshold: (value: number) => void; +} + +/** + * Custom hook for auto-compaction settings per workspace. + * Persists both enabled state and threshold percentage to localStorage. + * + * @param workspaceId - Workspace identifier + * @returns Settings object with getters and setters + */ +export function useAutoCompactionSettings(workspaceId: string): AutoCompactionSettings { + const [enabled, setEnabled] = usePersistedState( + getAutoCompactionEnabledKey(workspaceId), + true, + { listener: true } + ); + + const [threshold, setThreshold] = usePersistedState( + getAutoCompactionThresholdKey(workspaceId), + DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT, + { listener: true } + ); + + return { enabled, setEnabled, threshold, setThreshold }; +} diff --git a/src/browser/hooks/useClampedNumberInput.ts b/src/browser/hooks/useClampedNumberInput.ts new file mode 100644 index 000000000..998ff0e97 --- /dev/null +++ b/src/browser/hooks/useClampedNumberInput.ts @@ -0,0 +1,56 @@ +import React from "react"; + +/** + * Hook for number input with local state, validation, and clamping on blur. + * Prevents typing interruption while ensuring valid persisted values. + * + * @param persistedValue - Current value from persistence layer + * @param setPersisted - Function to update persisted value + * @param min - Minimum allowed value + * @param max - Maximum allowed value + * @returns Object with localValue, handleChange, and handleBlur + */ +export function useClampedNumberInput( + persistedValue: number, + setPersisted: (value: number) => void, + min: number, + max: number +) { + const [localValue, setLocalValue] = React.useState(persistedValue.toString()); + + // Sync local state when persisted value changes (e.g., from other tabs) + React.useEffect(() => { + setLocalValue(persistedValue.toString()); + }, [persistedValue]); + + const handleChange = (e: React.ChangeEvent) => { + const input = e.target.value; + // Allow empty or valid partial numbers (1-3 digits for typical use) + if (input === "" || /^\d{1,3}$/.test(input)) { + setLocalValue(input); + } + }; + + const handleBlur = () => { + const num = parseInt(localValue); + + if (localValue === "" || isNaN(num)) { + // Invalid input - revert to persisted value + setLocalValue(persistedValue.toString()); + } else if (num < min) { + // Below minimum - clamp to min + setPersisted(min); + setLocalValue(min.toString()); + } else if (num > max) { + // Above maximum - clamp to max + setPersisted(max); + setLocalValue(max.toString()); + } else { + // Valid - persist the value + setPersisted(num); + setLocalValue(num.toString()); + } + }; + + return { localValue, handleChange, handleBlur }; +} diff --git a/src/browser/utils/compaction/autoCompactionCheck.ts b/src/browser/utils/compaction/autoCompactionCheck.ts index c532395c7..f25a64a28 100644 --- a/src/browser/utils/compaction/autoCompactionCheck.ts +++ b/src/browser/utils/compaction/autoCompactionCheck.ts @@ -18,6 +18,7 @@ import type { WorkspaceUsageState } from "@/browser/stores/WorkspaceStore"; import { getModelStats } from "@/common/utils/tokens/modelStats"; import { supports1MContext } from "@/common/utils/ai/models"; +import { DEFAULT_AUTO_COMPACTION_THRESHOLD } from "@/common/constants/ui"; export interface AutoCompactionCheckResult { shouldShowWarning: boolean; @@ -25,10 +26,6 @@ export interface AutoCompactionCheckResult { thresholdPercentage: number; } -// Auto-compaction threshold (0.7 = 70%) -// TODO: Make this configurable via settings -const AUTO_COMPACTION_THRESHOLD = 0.7; - // Show warning this many percentage points before threshold const WARNING_ADVANCE_PERCENT = 10; @@ -40,23 +37,26 @@ const WARNING_ADVANCE_PERCENT = 10; * preventing infinite compaction loops after the first compaction completes. * * @param usage - Current workspace usage state (from useWorkspaceUsage) - * @param model - Current model string + * @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 warningAdvancePercent - Show warning this many percentage points before threshold (default 10) * @returns Check result with warning flag and usage percentage */ -export function shouldAutoCompact( +export function checkAutoCompaction( usage: WorkspaceUsageState | undefined, - model: string, + model: string | null, use1M: boolean, - threshold: number = AUTO_COMPACTION_THRESHOLD, + enabled: boolean, + threshold: number = DEFAULT_AUTO_COMPACTION_THRESHOLD, warningAdvancePercent: number = WARNING_ADVANCE_PERCENT ): AutoCompactionCheckResult { const thresholdPercentage = threshold * 100; - // No usage data yet - safe default (don't trigger on first message) - if (!usage || usage.usageHistory.length === 0) { + // Short-circuit if auto-compaction is disabled + // Or if no usage data yet + if (!enabled || !model || !usage || usage.usageHistory.length === 0) { return { shouldShowWarning: false, usagePercentage: 0, @@ -67,6 +67,7 @@ export function shouldAutoCompact( // Determine max tokens for this model const modelStats = getModelStats(model); const maxTokens = use1M && supports1MContext(model) ? 1_000_000 : modelStats?.max_input_tokens; + const lastUsage = usage.usageHistory[usage.usageHistory.length - 1]; // No max tokens known - safe default (can't calculate percentage) if (!maxTokens) { @@ -77,16 +78,6 @@ export function shouldAutoCompact( }; } - // Use last usage entry to calculate current context size (matches UI display) - const lastUsage = usage.usageHistory[usage.usageHistory.length - 1]; - if (!lastUsage) { - return { - shouldShowWarning: false, - usagePercentage: 0, - thresholdPercentage, - }; - } - const currentContextTokens = lastUsage.input.tokens + lastUsage.cached.tokens + diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index e6e9e485e..1e06ba5ab 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -147,6 +147,22 @@ export function getReviewSearchStateKey(workspaceId: string): string { return `reviewSearchState:${workspaceId}`; } +/** + * Get the localStorage key for auto-compaction enabled preference per workspace + * Format: "autoCompaction:enabled:{workspaceId}" + */ +export function getAutoCompactionEnabledKey(workspaceId: string): string { + return `autoCompaction:enabled:${workspaceId}`; +} + +/** + * Get the localStorage key for auto-compaction threshold percentage per workspace + * Format: "autoCompaction:threshold:{workspaceId}" + */ +export function getAutoCompactionThresholdKey(workspaceId: string): string { + return `autoCompaction:threshold:${workspaceId}`; +} + /** * List of workspace-scoped key functions that should be copied on fork and deleted on removal */ @@ -160,6 +176,8 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getReviewExpandStateKey, getFileTreeExpandStateKey, getReviewSearchStateKey, + getAutoCompactionEnabledKey, + getAutoCompactionThresholdKey, ]; /** diff --git a/src/common/constants/ui.ts b/src/common/constants/ui.ts index d038b8fef..f4b7437a5 100644 --- a/src/common/constants/ui.ts +++ b/src/common/constants/ui.ts @@ -10,6 +10,23 @@ */ export const COMPACTED_EMOJI = "📦"; +/** + * Auto-compaction threshold bounds (percentage) + * Too low risks frequent interruptions; too high risks hitting context limits + */ +export const AUTO_COMPACTION_THRESHOLD_MIN = 50; +export const AUTO_COMPACTION_THRESHOLD_MAX = 90; + +/** + * Default auto-compaction threshold percentage (50-90 range) + * Applied when creating new workspaces + */ +export const DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT = 70; + +/** + * Default threshold as decimal for calculations (0.7 = 70%) + */ +export const DEFAULT_AUTO_COMPACTION_THRESHOLD = DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT / 100; /** * Duration (ms) to show "copied" feedback after copying to clipboard */