Skip to content

Commit 714677a

Browse files
🤖 feat: add auto-compaction configuration UI (#685)
## Stack 1. #685 ⬅ This PR 1. #683 1. #670 1. #650 (base) Relates to #651. Adds per-workspace settings for auto-compaction (any percentage between 50 and 90): <img width="305" height="130" alt="image" src="https://github.com/user-attachments/assets/039e19d9-d95c-4249-8274-6a34116ee062" /> <img width="295" height="163" alt="image" src="https://github.com/user-attachments/assets/6095b100-732e-4c2c-bc39-3e66298245e4" /> **New Features:** - Toggle to enable/disable auto-compaction - Configurable threshold percentage (50-90%, default 70%) - Settings persist to localStorage and sync across tabs - UI integrated into right sidebar below existing settings **Implementation:** - Extracted threshold constants to `ui.ts` for DRY - Created reusable `useClampedNumberInput` hook for numeric input validation - Updated `shouldAutoCompact` to accept settings parameters - Follows existing patterns (uses `HelpIndicator` for tooltips) Settings are forked with workspace and cleaned up on workspace deletion. _Generated with `mux`_
1 parent 9431e34 commit 714677a

File tree

9 files changed

+250
-53
lines changed

9 files changed

+250
-53
lines changed

‎src/browser/components/AIView.tsx‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds";
3636
import { evictModelFromLRU } from "@/browser/hooks/useModelLRU";
3737
import { QueuedMessage } from "./Messages/QueuedMessage";
3838
import { CompactionWarning } from "./CompactionWarning";
39-
import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck";
39+
import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCheck";
4040
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
41+
import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings";
4142
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
4243

4344
interface AIViewProps {
@@ -85,6 +86,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
8586
const workspaceUsage = useWorkspaceUsage(workspaceId);
8687
const { options } = useProviderOptions();
8788
const use1M = options.anthropic?.use1MContext ?? false;
89+
const { enabled: autoCompactionEnabled, threshold: autoCompactionThreshold } =
90+
useAutoCompactionSettings(workspaceId);
8891
const handledModelErrorsRef = useRef<Set<string>>(new Set());
8992

9093
useEffect(() => {
@@ -337,12 +340,16 @@ const AIViewInner: React.FC<AIViewProps> = ({
337340
// preventing context-length errors when switching from a large-context to smaller model.
338341
const pendingModel = pendingSendOptions.model;
339342

340-
const autoCompactionCheck = pendingModel
341-
? shouldAutoCompact(workspaceUsage, pendingModel, use1M)
342-
: { shouldShowWarning: false, usagePercentage: 0, thresholdPercentage: 70 };
343+
const autoCompactionResult = checkAutoCompaction(
344+
workspaceUsage,
345+
pendingModel,
346+
use1M,
347+
autoCompactionEnabled,
348+
autoCompactionThreshold / 100
349+
);
343350

344351
// Show warning when: shouldShowWarning flag is true AND not currently compacting
345-
const shouldShowCompactionWarning = !isCompacting && autoCompactionCheck.shouldShowWarning;
352+
const shouldShowCompactionWarning = !isCompacting && autoCompactionResult.shouldShowWarning;
346353

347354
// Note: We intentionally do NOT reset autoRetry when streams start.
348355
// If user pressed the interrupt key, autoRetry stays false until they manually retry.
@@ -531,8 +538,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
531538
</div>
532539
{shouldShowCompactionWarning && (
533540
<CompactionWarning
534-
usagePercentage={autoCompactionCheck.usagePercentage}
535-
thresholdPercentage={autoCompactionCheck.thresholdPercentage}
541+
usagePercentage={autoCompactionResult.usagePercentage}
542+
thresholdPercentage={autoCompactionResult.thresholdPercentage}
536543
/>
537544
)}
538545
<ChatInput
@@ -548,7 +555,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
548555
onEditLastUserMessage={() => void handleEditLastUserMessage()}
549556
canInterrupt={canInterrupt}
550557
onReady={handleChatInputReady}
551-
autoCompactionCheck={autoCompactionCheck}
558+
autoCompactionCheck={autoCompactionResult}
552559
/>
553560
</div>
554561

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import React from "react";
2+
import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings";
3+
import { useClampedNumberInput } from "@/browser/hooks/useClampedNumberInput";
4+
import {
5+
AUTO_COMPACTION_THRESHOLD_MIN,
6+
AUTO_COMPACTION_THRESHOLD_MAX,
7+
} from "@/common/constants/ui";
8+
import { TooltipWrapper, Tooltip, HelpIndicator } from "../Tooltip";
9+
10+
interface AutoCompactionSettingsProps {
11+
workspaceId: string;
12+
}
13+
14+
export const AutoCompactionSettings: React.FC<AutoCompactionSettingsProps> = ({ workspaceId }) => {
15+
const { enabled, setEnabled, threshold, setThreshold } = useAutoCompactionSettings(workspaceId);
16+
const { localValue, handleChange, handleBlur } = useClampedNumberInput(
17+
threshold,
18+
setThreshold,
19+
AUTO_COMPACTION_THRESHOLD_MIN,
20+
AUTO_COMPACTION_THRESHOLD_MAX
21+
);
22+
23+
return (
24+
<div data-testid="auto-compaction-settings" className="mb-6">
25+
<div className="flex items-baseline justify-between">
26+
{/* Left side: checkbox + label + tooltip */}
27+
<div className="flex items-baseline gap-1">
28+
<label className="text-foreground flex cursor-pointer items-baseline gap-1.5 font-medium select-none hover:text-white">
29+
<input
30+
type="checkbox"
31+
checked={enabled}
32+
onChange={(e) => setEnabled(e.target.checked)}
33+
className="cursor-pointer"
34+
/>
35+
Auto-Compaction
36+
</label>
37+
<TooltipWrapper inline>
38+
<HelpIndicator>?</HelpIndicator>
39+
<Tooltip className="tooltip" align="center" width="auto">
40+
Automatically compact conversation history when context usage reaches the threshold
41+
</Tooltip>
42+
</TooltipWrapper>
43+
</div>
44+
45+
{/* Right side: input + % symbol */}
46+
<div className="flex items-baseline gap-0.5">
47+
<input
48+
type="number"
49+
min={AUTO_COMPACTION_THRESHOLD_MIN}
50+
max={AUTO_COMPACTION_THRESHOLD_MAX}
51+
step={5}
52+
maxLength={2}
53+
value={localValue}
54+
onChange={handleChange}
55+
onBlur={handleBlur}
56+
disabled={!enabled}
57+
className="text-muted w-9 [appearance:textfield] border-none bg-transparent text-right text-xs outline-none disabled:cursor-not-allowed disabled:opacity-40 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
58+
aria-label="Auto-compaction threshold percentage"
59+
/>
60+
<span className="text-muted text-xs">%</span>
61+
</div>
62+
</div>
63+
</div>
64+
);
65+
};

‎src/browser/components/RightSidebar/CostsTab.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
88
import { supports1MContext } from "@/common/utils/ai/models";
99
import { TOKEN_COMPONENT_COLORS } from "@/common/utils/tokens/tokenMeterUtils";
1010
import { ConsumerBreakdown } from "./ConsumerBreakdown";
11+
import { AutoCompactionSettings } from "./AutoCompactionSettings";
1112

1213
// Format token display - show k for thousands with 1 decimal
1314
const formatTokens = (tokens: number) =>
@@ -231,6 +232,8 @@ const CostsTabComponent: React.FC<CostsTabProps> = ({ workspaceId }) => {
231232
</div>
232233
)}
233234

235+
{hasUsageData && <AutoCompactionSettings workspaceId={workspaceId} />}
236+
234237
{hasUsageData && (
235238
<div data-testid="cost-section" className="mb-6">
236239
<div className="flex flex-col gap-3">
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
2+
import {
3+
getAutoCompactionEnabledKey,
4+
getAutoCompactionThresholdKey,
5+
} from "@/common/constants/storage";
6+
import { DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT } from "@/common/constants/ui";
7+
8+
export interface AutoCompactionSettings {
9+
/** Whether auto-compaction is enabled for this workspace */
10+
enabled: boolean;
11+
/** Update enabled state */
12+
setEnabled: (value: boolean) => void;
13+
/** Current threshold percentage (50-90) */
14+
threshold: number;
15+
/** Update threshold percentage (will be clamped to 50-90 range by UI) */
16+
setThreshold: (value: number) => void;
17+
}
18+
19+
/**
20+
* Custom hook for auto-compaction settings per workspace.
21+
* Persists both enabled state and threshold percentage to localStorage.
22+
*
23+
* @param workspaceId - Workspace identifier
24+
* @returns Settings object with getters and setters
25+
*/
26+
export function useAutoCompactionSettings(workspaceId: string): AutoCompactionSettings {
27+
const [enabled, setEnabled] = usePersistedState<boolean>(
28+
getAutoCompactionEnabledKey(workspaceId),
29+
true,
30+
{ listener: true }
31+
);
32+
33+
const [threshold, setThreshold] = usePersistedState<number>(
34+
getAutoCompactionThresholdKey(workspaceId),
35+
DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT,
36+
{ listener: true }
37+
);
38+
39+
return { enabled, setEnabled, threshold, setThreshold };
40+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from "react";
2+
3+
/**
4+
* Hook for number input with local state, validation, and clamping on blur.
5+
* Prevents typing interruption while ensuring valid persisted values.
6+
*
7+
* @param persistedValue - Current value from persistence layer
8+
* @param setPersisted - Function to update persisted value
9+
* @param min - Minimum allowed value
10+
* @param max - Maximum allowed value
11+
* @returns Object with localValue, handleChange, and handleBlur
12+
*/
13+
export function useClampedNumberInput(
14+
persistedValue: number,
15+
setPersisted: (value: number) => void,
16+
min: number,
17+
max: number
18+
) {
19+
const [localValue, setLocalValue] = React.useState(persistedValue.toString());
20+
21+
// Sync local state when persisted value changes (e.g., from other tabs)
22+
React.useEffect(() => {
23+
setLocalValue(persistedValue.toString());
24+
}, [persistedValue]);
25+
26+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
27+
const input = e.target.value;
28+
// Allow empty or valid partial numbers (1-3 digits for typical use)
29+
if (input === "" || /^\d{1,3}$/.test(input)) {
30+
setLocalValue(input);
31+
}
32+
};
33+
34+
const handleBlur = () => {
35+
const num = parseInt(localValue);
36+
37+
if (localValue === "" || isNaN(num)) {
38+
// Invalid input - revert to persisted value
39+
setLocalValue(persistedValue.toString());
40+
} else if (num < min) {
41+
// Below minimum - clamp to min
42+
setPersisted(min);
43+
setLocalValue(min.toString());
44+
} else if (num > max) {
45+
// Above maximum - clamp to max
46+
setPersisted(max);
47+
setLocalValue(max.toString());
48+
} else {
49+
// Valid - persist the value
50+
setPersisted(num);
51+
setLocalValue(num.toString());
52+
}
53+
};
54+
55+
return { localValue, handleChange, handleBlur };
56+
}

0 commit comments

Comments
 (0)