Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions src/browser/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1487,3 +1487,170 @@ These tables should render cleanly without any disruptive copy or download actio
return <AppWithTableMocks />;
},
};

/**
* 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<string, ProjectConfig>([
[
"/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<string, { apiKeySet: boolean; baseUrl?: string; models?: string[] }>
),
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 <AppLoader />;
};

return <AppWithHighUsage />;
},
};
25 changes: 14 additions & 11 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
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<Set<string>>(new Set());

useEffect(() => {
Expand Down Expand Up @@ -121,9 +127,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
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<string | null>(null);

Expand All @@ -133,16 +136,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
// 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
);

Expand Down Expand Up @@ -217,6 +214,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
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();

Expand Down Expand Up @@ -573,6 +575,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
<CompactionWarning
usagePercentage={autoCompactionResult.usagePercentage}
thresholdPercentage={autoCompactionResult.thresholdPercentage}
onCompactClick={handleCompactClick}
/>
)}
<ChatInput
Expand Down
20 changes: 19 additions & 1 deletion src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,15 @@ export const ChatInput: React.FC<ChatInputProps> = (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) => ({
Expand All @@ -277,10 +286,19 @@ export const ChatInput: React.FC<ChatInputProps> = (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) => {
Expand Down
1 change: 1 addition & 0 deletions src/browser/components/ChatInput/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
41 changes: 22 additions & 19 deletions src/browser/components/CompactionWarning.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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">
⚠️ Context limit reached. Next message will trigger Auto-Compaction.
</div>
);
}
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 (
<div className="text-muted mx-4 mt-2 mb-1 text-right text-[10px]">
Context left until Auto-Compact: {Math.round(remaining)}%
<div className="mx-4 mt-2 mb-1 text-right text-[10px]">
<button
type="button"
onClick={props.onCompactClick}
className={`cursor-pointer hover:underline ${
willCompactNext ? "text-plan-mode font-semibold" : "text-muted"
}`}
title="Click to insert /compact command"
>
{text}
</button>
</div>
);
};
23 changes: 18 additions & 5 deletions src/browser/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -135,15 +136,18 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
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)
Expand Down Expand Up @@ -184,7 +188,16 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
// Single render point for VerticalTokenMeter
// Shows when: (1) collapsed, OR (2) Review tab is active
const showMeter = showCollapsed || selectedTab === "review";
const verticalMeter = showMeter ? <VerticalTokenMeter data={verticalMeterData} /> : null;
const autoCompactionProps = React.useMemo(
() => ({
threshold: autoCompactThreshold,
setThreshold: setAutoCompactThreshold,
}),
[autoCompactThreshold, setAutoCompactThreshold]
);
const verticalMeter = showMeter ? (
<VerticalTokenMeter data={verticalMeterData} autoCompaction={autoCompactionProps} />
) : null;

return (
<SidebarContainer
Expand Down
Loading