Skip to content

Commit 1c2dde8

Browse files
ammar-agentmux
andauthored
🤖 feat: draggable auto-compaction threshold slider (#821)
## Changes Adds a draggable threshold slider to the context usage meter for adjusting auto-compaction settings visually. ### Features - **Draggable threshold indicator** on both horizontal (Costs tab) and vertical (collapsed sidebar) context meters - **Visual feedback**: Triangle handles with connecting line, colored when enabled, muted when disabled - **Drag to 100%** to disable auto-compaction, drag back to re-enable - **Per-model threshold**: Settings persist per model via existing hook ### Implementation Notes - Slider uses **inline styles** (not Tailwind) due to intermittent class application issues with dynamically rendered components - Horizontal slider uses native `title` tooltip - Vertical slider uses portal-based tooltip to escape narrow container clipping - Tooltip component extended with `position='left'|'right'` support _Generated with `mux`_ --------- Co-authored-by: mux <mux@coder.com>
1 parent e87340f commit 1c2dde8

File tree

16 files changed

+737
-295
lines changed

16 files changed

+737
-295
lines changed

src/browser/App.stories.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,3 +1487,170 @@ These tables should render cleanly without any disruptive copy or download actio
14871487
return <AppWithTableMocks />;
14881488
},
14891489
};
1490+
1491+
/**
1492+
* Story showing the auto-compaction warning when context usage is approaching the threshold.
1493+
* The warning appears above the chat input when usage is >= 60% (threshold 70% minus 10% warning advance).
1494+
* claude-sonnet-4-5 has max_input_tokens: 200,000, so we set usage to ~130,000 tokens (65%) to trigger warning.
1495+
*/
1496+
export const AutoCompactionWarning: Story = {
1497+
render: () => {
1498+
const workspaceId = "ws-high-usage";
1499+
1500+
const projects = new Map<string, ProjectConfig>([
1501+
[
1502+
"/home/user/projects/my-app",
1503+
{
1504+
workspaces: [
1505+
{ path: "/home/user/.mux/src/my-app/feature", id: workspaceId, name: "main" },
1506+
],
1507+
},
1508+
],
1509+
]);
1510+
1511+
const workspaces: FrontendWorkspaceMetadata[] = [
1512+
{
1513+
id: workspaceId,
1514+
name: "main",
1515+
projectPath: "/home/user/projects/my-app",
1516+
projectName: "my-app",
1517+
namedWorkspacePath: "/home/user/.mux/src/my-app/feature",
1518+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
1519+
createdAt: new Date(NOW - 3600000).toISOString(),
1520+
},
1521+
];
1522+
1523+
const AppWithHighUsage: React.FC = () => {
1524+
const initialized = useRef(false);
1525+
if (!initialized.current) {
1526+
// Enable auto-compaction for this workspace (enabled per-workspace, threshold per-model)
1527+
localStorage.setItem(`autoCompaction:enabled:${workspaceId}`, "true");
1528+
localStorage.setItem(`autoCompaction:threshold:claude-sonnet-4-5`, "70");
1529+
1530+
setupMockAPI({
1531+
projects,
1532+
workspaces,
1533+
apiOverrides: {
1534+
tokenizer: {
1535+
countTokens: () => Promise.resolve(100),
1536+
countTokensBatch: (_model, texts) => Promise.resolve(texts.map(() => 100)),
1537+
calculateStats: () =>
1538+
Promise.resolve({
1539+
consumers: [],
1540+
totalTokens: 0,
1541+
model: "claude-sonnet-4-5",
1542+
tokenizerName: "claude",
1543+
usageHistory: [],
1544+
}),
1545+
},
1546+
providers: {
1547+
setProviderConfig: () => Promise.resolve({ success: true, data: undefined }),
1548+
setModels: () => Promise.resolve({ success: true, data: undefined }),
1549+
getConfig: () =>
1550+
Promise.resolve(
1551+
{} as Record<string, { apiKeySet: boolean; baseUrl?: string; models?: string[] }>
1552+
),
1553+
list: () => Promise.resolve(["anthropic"]),
1554+
},
1555+
workspace: {
1556+
create: (projectPath: string, branchName: string) =>
1557+
Promise.resolve({
1558+
success: true,
1559+
metadata: {
1560+
id: Math.random().toString(36).substring(2, 12),
1561+
name: branchName,
1562+
projectPath,
1563+
projectName: projectPath.split("/").pop() ?? "project",
1564+
namedWorkspacePath: `/mock/workspace/${branchName}`,
1565+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
1566+
},
1567+
}),
1568+
list: () => Promise.resolve(workspaces),
1569+
rename: (wsId: string) =>
1570+
Promise.resolve({ success: true, data: { newWorkspaceId: wsId } }),
1571+
remove: () => Promise.resolve({ success: true }),
1572+
fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }),
1573+
openTerminal: () => Promise.resolve(undefined),
1574+
onChat: (wsId, callback) => {
1575+
if (wsId === workspaceId) {
1576+
setTimeout(() => {
1577+
// User message
1578+
callback({
1579+
id: "msg-1",
1580+
role: "user",
1581+
parts: [{ type: "text", text: "Help me with this large codebase" }],
1582+
metadata: {
1583+
historySequence: 1,
1584+
timestamp: STABLE_TIMESTAMP - 60000,
1585+
},
1586+
});
1587+
1588+
// Assistant message with HIGH usage to trigger compaction warning
1589+
// 130,000 tokens = 65% of 200,000 max, which is above 60% warning threshold
1590+
callback({
1591+
id: "msg-2",
1592+
role: "assistant",
1593+
parts: [
1594+
{
1595+
type: "text",
1596+
text: "I've analyzed the codebase. The context window is getting full - notice the compaction warning below!",
1597+
},
1598+
],
1599+
metadata: {
1600+
historySequence: 2,
1601+
timestamp: STABLE_TIMESTAMP,
1602+
model: "claude-sonnet-4-5",
1603+
usage: {
1604+
inputTokens: 125000, // High input to trigger warning
1605+
outputTokens: 5000,
1606+
totalTokens: 130000,
1607+
},
1608+
duration: 5000,
1609+
},
1610+
});
1611+
1612+
callback({ type: "caught-up" });
1613+
}, 100);
1614+
}
1615+
return () => undefined;
1616+
},
1617+
onMetadata: () => () => undefined,
1618+
activity: {
1619+
list: () => Promise.resolve({}),
1620+
subscribe: () => () => undefined,
1621+
},
1622+
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
1623+
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
1624+
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
1625+
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
1626+
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
1627+
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
1628+
getInfo: () => Promise.resolve(null),
1629+
executeBash: () =>
1630+
Promise.resolve({
1631+
success: true,
1632+
data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 },
1633+
}),
1634+
},
1635+
},
1636+
});
1637+
1638+
localStorage.setItem(
1639+
"selectedWorkspace",
1640+
JSON.stringify({
1641+
workspaceId: workspaceId,
1642+
projectPath: "/home/user/projects/my-app",
1643+
projectName: "my-app",
1644+
namedWorkspacePath: "/home/user/.mux/src/my-app/feature",
1645+
})
1646+
);
1647+
1648+
initialized.current = true;
1649+
}
1650+
1651+
return <AppLoader />;
1652+
};
1653+
1654+
return <AppWithHighUsage />;
1655+
},
1656+
};

src/browser/components/AIView.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
8787
const workspaceUsage = useWorkspaceUsage(workspaceId);
8888
const { options } = useProviderOptions();
8989
const use1M = options.anthropic?.use1MContext ?? false;
90-
const { enabled: autoCompactionEnabled, threshold: autoCompactionThreshold } =
91-
useAutoCompactionSettings(workspaceId);
90+
// Get pending model for auto-compaction settings (threshold is per-model)
91+
const pendingSendOptions = useSendMessageOptions(workspaceId);
92+
const pendingModel = pendingSendOptions.model;
93+
94+
const { threshold: autoCompactionThreshold } = useAutoCompactionSettings(
95+
workspaceId,
96+
pendingModel
97+
);
9298
const handledModelErrorsRef = useRef<Set<string>>(new Set());
9399

94100
useEffect(() => {
@@ -121,9 +127,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
121127
undefined
122128
);
123129

124-
// Use send options for auto-compaction check
125-
const pendingSendOptions = useSendMessageOptions(workspaceId);
126-
127130
// Track if we've already triggered force compaction for this stream
128131
const forceCompactionTriggeredRef = useRef<string | null>(null);
129132

@@ -133,16 +136,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
133136
// Get active stream message ID for token counting
134137
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
135138

136-
// Use pending send model for auto-compaction check, not the last stream's model.
137-
// This ensures the threshold is based on the model the user will actually send with,
138-
// preventing context-length errors when switching from a large-context to smaller model.
139-
const pendingModel = pendingSendOptions.model;
140-
141139
const autoCompactionResult = checkAutoCompaction(
142140
workspaceUsage,
143141
pendingModel,
144142
use1M,
145-
autoCompactionEnabled,
146143
autoCompactionThreshold / 100
147144
);
148145

@@ -217,6 +214,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
217214
chatInputAPI.current?.appendText(note);
218215
}, []);
219216

217+
// Handler for manual compaction from CompactionWarning click
218+
const handleCompactClick = useCallback(() => {
219+
chatInputAPI.current?.prependText("/compact\n");
220+
}, []);
221+
220222
// Thinking level state from context
221223
const { thinkingLevel: currentWorkspaceThinking, setThinkingLevel } = useThinking();
222224

@@ -573,6 +575,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
573575
<CompactionWarning
574576
usagePercentage={autoCompactionResult.usagePercentage}
575577
thresholdPercentage={autoCompactionResult.thresholdPercentage}
578+
onCompactClick={handleCompactClick}
576579
/>
577580
)}
578581
<ChatInput

src/browser/components/ChatInput/index.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,15 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
260260
[setInput]
261261
);
262262

263+
// Method to prepend text to input (used by manual compact trigger)
264+
const prependText = useCallback(
265+
(text: string) => {
266+
setInput((prev) => text + prev);
267+
focusMessageInput();
268+
},
269+
[focusMessageInput, setInput]
270+
);
271+
263272
// Method to restore images to input (used by queued message edit)
264273
const restoreImages = useCallback((images: ImagePart[]) => {
265274
const attachments: ImageAttachment[] = images.map((img, index) => ({
@@ -277,10 +286,19 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
277286
focus: focusMessageInput,
278287
restoreText,
279288
appendText,
289+
prependText,
280290
restoreImages,
281291
});
282292
}
283-
}, [props.onReady, focusMessageInput, restoreText, appendText, restoreImages, props]);
293+
}, [
294+
props.onReady,
295+
focusMessageInput,
296+
restoreText,
297+
appendText,
298+
prependText,
299+
restoreImages,
300+
props,
301+
]);
284302

285303
useEffect(() => {
286304
const handleGlobalKeyDown = (event: KeyboardEvent) => {

src/browser/components/ChatInput/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface ChatInputAPI {
66
focus: () => void;
77
restoreText: (text: string) => void;
88
appendText: (text: string) => void;
9+
prependText: (text: string) => void;
910
restoreImages: (images: ImagePart[]) => void;
1011
}
1112

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,43 @@
11
import React from "react";
22

33
/**
4-
* Warning banner shown when context usage is approaching the compaction threshold.
4+
* Warning indicator shown when context usage is approaching the compaction threshold.
55
*
6-
* Displays progressive warnings:
7-
* - Below threshold: "Context left until Auto-Compact: X% remaining" (where X = threshold - current)
8-
* - At/above threshold: "Approaching context limit. Next message will trigger auto-compaction."
6+
* Displays as subtle right-aligned text:
7+
* - Below threshold: "Auto-Compact in X% usage" (where X = threshold - current)
8+
* - At/above threshold: Bold "Next message will Auto-Compact"
99
*
10-
* Displayed above ChatInput when:
11-
* - Token usage >= (threshold - 10%) of model's context window
12-
* - Not currently compacting (user can still send messages)
10+
* Both states are clickable to insert /compact command.
1311
*
1412
* @param usagePercentage - Current token usage as percentage (0-100)
1513
* @param thresholdPercentage - Auto-compaction trigger threshold (0-100, default 70)
14+
* @param onCompactClick - Callback when user clicks to trigger manual compaction
1615
*/
1716
export const CompactionWarning: React.FC<{
1817
usagePercentage: number;
1918
thresholdPercentage: number;
19+
onCompactClick?: () => void;
2020
}> = (props) => {
2121
// At threshold or above, next message will trigger compaction
2222
const willCompactNext = props.usagePercentage >= props.thresholdPercentage;
23+
const remaining = props.thresholdPercentage - props.usagePercentage;
2324

24-
// Urgent warning at/above threshold - prominent blue box
25-
if (willCompactNext) {
26-
return (
27-
<div className="text-plan-mode bg-plan-mode/10 mx-4 my-4 rounded-sm px-4 py-3 text-center text-xs font-medium">
28-
⚠️ Context limit reached. Next message will trigger Auto-Compaction.
29-
</div>
30-
);
31-
}
25+
const text = willCompactNext
26+
? "Next message will Auto-Compact"
27+
: `Auto-Compact in ${Math.round(remaining)}% usage`;
3228

33-
// Countdown warning below threshold - subtle grey text, right-aligned
34-
const remaining = props.thresholdPercentage - props.usagePercentage;
3529
return (
36-
<div className="text-muted mx-4 mt-2 mb-1 text-right text-[10px]">
37-
Context left until Auto-Compact: {Math.round(remaining)}%
30+
<div className="mx-4 mt-2 mb-1 text-right text-[10px]">
31+
<button
32+
type="button"
33+
onClick={props.onCompactClick}
34+
className={`cursor-pointer hover:underline ${
35+
willCompactNext ? "text-plan-mode font-semibold" : "text-muted"
36+
}`}
37+
title="Click to insert /compact command"
38+
>
39+
{text}
40+
</button>
3841
</div>
3942
);
4043
};

src/browser/components/RightSidebar.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState";
33
import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore";
44
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
55
import { useResizeObserver } from "@/browser/hooks/useResizeObserver";
6+
import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings";
67
import { CostsTab } from "./RightSidebar/CostsTab";
78
import { VerticalTokenMeter } from "./RightSidebar/VerticalTokenMeter";
89
import { ReviewPanel } from "./RightSidebar/CodeReview/ReviewPanel";
@@ -135,15 +136,18 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
135136
const reviewPanelId = `${baseId}-panel-review`;
136137

137138
const lastUsage = usage?.liveUsage ?? usage?.usageHistory[usage.usageHistory.length - 1];
139+
const model = lastUsage?.model ?? null;
140+
141+
// Auto-compaction settings: threshold per-model
142+
const { threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold } =
143+
useAutoCompactionSettings(workspaceId, model);
138144

139145
// Memoize vertical meter data calculation to prevent unnecessary re-renders
140146
const verticalMeterData = React.useMemo(() => {
141-
// Get model from last usage
142-
const model = lastUsage?.model ?? "unknown";
143147
return lastUsage
144-
? calculateTokenMeterData(lastUsage, model, use1M, true)
148+
? calculateTokenMeterData(lastUsage, model ?? "unknown", use1M, true)
145149
: { segments: [], totalTokens: 0, totalPercentage: 0 };
146-
}, [lastUsage, use1M]);
150+
}, [lastUsage, model, use1M]);
147151

148152
// Calculate if we should show collapsed view with hysteresis
149153
// Strategy: Observe ChatArea width directly (independent of sidebar width)
@@ -184,7 +188,16 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
184188
// Single render point for VerticalTokenMeter
185189
// Shows when: (1) collapsed, OR (2) Review tab is active
186190
const showMeter = showCollapsed || selectedTab === "review";
187-
const verticalMeter = showMeter ? <VerticalTokenMeter data={verticalMeterData} /> : null;
191+
const autoCompactionProps = React.useMemo(
192+
() => ({
193+
threshold: autoCompactThreshold,
194+
setThreshold: setAutoCompactThreshold,
195+
}),
196+
[autoCompactThreshold, setAutoCompactThreshold]
197+
);
198+
const verticalMeter = showMeter ? (
199+
<VerticalTokenMeter data={verticalMeterData} autoCompaction={autoCompactionProps} />
200+
) : null;
188201

189202
return (
190203
<SidebarContainer

0 commit comments

Comments
 (0)