Skip to content

Commit 01e48c6

Browse files
🤖 feat: idle compaction for inactive workspaces (#1160)
## Summary Automatically compacts workspaces that have been inactive for a configurable period. Runs eligibility checks hourly after an initial 60-second delay on startup. Closes #714. ## Configuration - **Per-project setting**: `idleCompactionHours` (null = disabled, number = hours threshold) - **UI**: Settings → Project → Idle Compaction (checkbox + hours input) - **Slash command**: `/idle <hours>` or `/idle off` ## UX Invariants Two critical behaviors to preserve sidebar ordering and provide feedback: 1. **Compaction preserves recency** — Idle compaction does not make a workspace appear "recently used" in the sidebar. The `computeRecencyFromMessages()` function calculates recency from the last user message or last user-initiated compaction, explicitly filtering out idle compaction requests. 2. **Sidebar shows status during compaction** — The compaction request includes `displayStatus` in metadata, which the `StreamingMessageAggregator` reads to show "Idle Compacting..." in the sidebar. Status clears when the `idleCompacted: true` summary message arrives. ## Eligibility Checks A workspace is eligible for idle compaction when: - Has at least one message - Idle time ≥ configured threshold (based on `computeRecencyFromMessages`) - Not currently streaming - Not already compacted (last message is not a compaction summary) - Last message is not from user (not awaiting a response) ## Visual Indicators - Regular compaction badge: `📦 compacted` - Idle compaction badge: `💤📦 idle-compacted` ## Architecture ``` Backend (hourly check) Frontend ────────────────────── ──────── IdleCompactionService.checkAllWorkspaces() │ ├─► checkEligibility() │ └─► emitIdleCompactionNeeded(workspaceId) │ ▼ WorkspaceService → session stream │ ══════════════════════════════════════════════► │ useIdleCompactionHandler hook ◄─┘ │ ▼ executeCompaction({ source: "idle-compaction" }) │ │ Adds: model, gateway, thinkingLevel │ (from localStorage) ▼ CompactionHandler.performCompaction() │ └─► recency = computeRecencyFromMessages() (preserves original timestamp) ``` ### Why the frontend relay? The backend cannot currently construct compaction requests because it lacks access to user preferences: - **Preferred compaction model** — stored in localStorage - **Gateway routing** — which models should use the mux gateway vs direct API These preferences live in the browser and are assembled by `buildSendMessageOptions()` when sending any message. Until we persist these preferences to backend-accessible storage, the frontend must relay compaction requests. ## Known Limitation **Requires frontend to be running.** Since compaction requests are relayed through the frontend, a standalone `mux server` instance cannot perform idle compaction. This will be resolved once user preferences are persisted to the backend. ## Future: Backend-Only Architecture Once compaction preferences are persisted to backend-accessible storage (per-workspace or per-project config file), the entire flow can happen on the backend: ``` Backend (hourly check) ────────────────────── IdleCompactionService.checkAllWorkspaces() │ ├─► checkEligibility() │ └─► WorkspaceService.sendMessage() directly │ │ Reads: model, gateway, thinkingLevel │ (from persisted config) ▼ CompactionHandler.performCompaction() ``` **Migration path:** 1. Persist compaction preferences (model, gateway, thinking level) to backend config 2. Have `IdleCompactionService` call `WorkspaceService.sendMessage()` directly 3. Remove `useIdleCompactionHandler` hook (~40 lines) and `idle-compaction-needed` event type This will enable standalone `mux server` deployments to perform auto-compaction without any frontend involvement. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent e793c89 commit 01e48c6

35 files changed

+1536
-50
lines changed

.storybook/mocks/orpc.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
188188
setEnabled: async () => ({ success: true, data: undefined }),
189189
setToolAllowlist: async () => ({ success: true, data: undefined }),
190190
},
191+
idleCompaction: {
192+
get: async () => ({ success: true, hours: null }),
193+
set: async () => ({ success: true }),
194+
},
191195
},
192196
workspace: {
193197
list: async () => workspaces,

src/browser/components/AIView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
5858
import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings";
5959
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
6060
import { useForceCompaction } from "@/browser/hooks/useForceCompaction";
61+
import { useIdleCompactionHandler } from "@/browser/hooks/useIdleCompactionHandler";
6162
import { useAPI } from "@/browser/contexts/API";
6263
import { useReviews } from "@/browser/hooks/useReviews";
6364
import { ReviewsBanner } from "./ReviewsBanner";
@@ -202,6 +203,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
202203
onTrigger: handleForceCompaction,
203204
});
204205

206+
// Idle compaction - trigger compaction when backend signals workspace has been idle
207+
useIdleCompactionHandler({ api });
208+
205209
// Auto-retry state - minimal setter for keybinds and message sent handler
206210
// RetryBarrier manages its own state, but we need this for interrupt keybind
207211
const [, setAutoRetry] = usePersistedState<boolean>(

src/browser/components/ChatInput/index.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,58 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
984984
}
985985
return;
986986
}
987+
// Handle /idle command
988+
if (parsed.type === "idle-compaction") {
989+
if (!api) {
990+
setToast({
991+
id: Date.now().toString(),
992+
type: "error",
993+
message: "Not connected to server",
994+
});
995+
return;
996+
}
997+
if (!selectedWorkspace?.projectPath) {
998+
setToast({
999+
id: Date.now().toString(),
1000+
type: "error",
1001+
message: "No project selected",
1002+
});
1003+
return;
1004+
}
1005+
setInput(""); // Clear input immediately
1006+
1007+
try {
1008+
const result = await api.projects.idleCompaction.set({
1009+
projectPath: selectedWorkspace.projectPath,
1010+
hours: parsed.hours,
1011+
});
1012+
1013+
if (!result.success) {
1014+
setToast({
1015+
id: Date.now().toString(),
1016+
type: "error",
1017+
message: result.error ?? "Failed to update setting",
1018+
});
1019+
setInput(messageText); // Restore input on error
1020+
} else {
1021+
setToast({
1022+
id: Date.now().toString(),
1023+
type: "success",
1024+
message: parsed.hours
1025+
? `Idle compaction set to ${parsed.hours} hours`
1026+
: "Idle compaction disabled",
1027+
});
1028+
}
1029+
} catch (error) {
1030+
setToast({
1031+
id: Date.now().toString(),
1032+
type: "error",
1033+
message: error instanceof Error ? error.message : "Failed to update setting",
1034+
});
1035+
setInput(messageText); // Restore input on error
1036+
}
1037+
return;
1038+
}
9871039
}
9881040

9891041
// Regular message - send directly via API

src/browser/components/Messages/AssistantMessage.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { COMPACTED_EMOJI } from "@/common/constants/ui";
1+
import { COMPACTED_EMOJI, IDLE_COMPACTED_EMOJI } from "@/common/constants/ui";
22
import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard";
33
import { useStartHere } from "@/browser/hooks/useStartHere";
44
import type { DisplayedMessage } from "@/common/types/message";
@@ -109,13 +109,16 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
109109
const renderLabel = () => {
110110
const modelName = message.model;
111111
const isCompacted = message.isCompacted;
112+
const isIdleCompacted = message.isIdleCompacted;
112113

113114
return (
114115
<div className="flex items-center gap-2">
115116
{modelName && <ModelDisplay modelString={modelName} />}
116117
{isCompacted && (
117118
<span className="text-plan-mode bg-plan-mode/10 rounded-sm px-1.5 py-0.5 text-[10px] font-medium uppercase">
118-
{COMPACTED_EMOJI} compacted
119+
{isIdleCompacted
120+
? `${IDLE_COMPACTED_EMOJI} idle-compacted`
121+
: `${COMPACTED_EMOJI} compacted`}
119122
</span>
120123
)}
121124
</div>

src/browser/components/Settings/sections/ProjectSettingsSection.tsx

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ export const ProjectSettingsSection: React.FC = () => {
195195
const [editing, setEditing] = useState<{ name: string; command: string } | null>(null);
196196
const [savingEdit, setSavingEdit] = useState(false);
197197

198+
// Idle compaction state
199+
const [idleHours, setIdleHours] = useState<number | null>(null);
200+
const [idleHoursInput, setIdleHoursInput] = useState<string>("");
201+
const [savingIdleHours, setSavingIdleHours] = useState(false);
202+
203+
// Sync input field when idleHours loads/changes
204+
// Show "24" as default placeholder when disabled
205+
useEffect(() => {
206+
setIdleHoursInput(idleHours?.toString() ?? "24");
207+
}, [idleHours]);
208+
198209
// Set default project when projects load
199210
useEffect(() => {
200211
if (projectList.length > 0 && !selectedProject) {
@@ -206,11 +217,15 @@ export const ProjectSettingsSection: React.FC = () => {
206217
if (!api || !selectedProject) return;
207218
setLoading(true);
208219
try {
209-
const result = await api.projects.mcp.list({ projectPath: selectedProject });
210-
setServers(result ?? {});
220+
const [mcpResult, idleResult] = await Promise.all([
221+
api.projects.mcp.list({ projectPath: selectedProject }),
222+
api.projects.idleCompaction.get({ projectPath: selectedProject }),
223+
]);
224+
setServers(mcpResult ?? {});
225+
setIdleHours(idleResult.hours);
211226
setError(null);
212227
} catch (err) {
213-
setError(err instanceof Error ? err.message : "Failed to load MCP servers");
228+
setError(err instanceof Error ? err.message : "Failed to load project settings");
214229
} finally {
215230
setLoading(false);
216231
}
@@ -380,6 +395,29 @@ export const ProjectSettingsSection: React.FC = () => {
380395
}
381396
}, [api, selectedProject, editing, refresh, clearTestResult]);
382397

398+
const handleIdleHoursChange = useCallback(
399+
async (hours: number | null) => {
400+
if (!api || !selectedProject) return;
401+
setSavingIdleHours(true);
402+
try {
403+
const result = await api.projects.idleCompaction.set({
404+
projectPath: selectedProject,
405+
hours,
406+
});
407+
if (result.success) {
408+
setIdleHours(hours);
409+
} else {
410+
setError(result.error ?? "Failed to update idle compaction setting");
411+
}
412+
} catch (err) {
413+
setError(err instanceof Error ? err.message : "Failed to update idle compaction setting");
414+
} finally {
415+
setSavingIdleHours(false);
416+
}
417+
},
418+
[api, selectedProject]
419+
);
420+
383421
if (projectList.length === 0) {
384422
return (
385423
<div className="flex flex-col items-center justify-center py-12 text-center">
@@ -417,8 +455,60 @@ export const ProjectSettingsSection: React.FC = () => {
417455
</p>
418456
</div>
419457

458+
{/* Idle Compaction */}
459+
<div className="space-y-4">
460+
<div>
461+
<h3 className="font-medium">Idle Compaction</h3>
462+
<p className="text-muted-foreground text-xs">
463+
Automatically compact workspaces after a period of inactivity to provide helpful
464+
summaries when returning
465+
</p>
466+
</div>
467+
468+
<div className="flex items-center gap-3">
469+
<label className="flex items-center gap-2">
470+
<input
471+
type="checkbox"
472+
checked={idleHours !== null}
473+
onChange={(e) => void handleIdleHoursChange(e.target.checked ? 24 : null)}
474+
disabled={savingIdleHours}
475+
className="accent-accent h-4 w-4 rounded"
476+
/>
477+
<span className="text-sm">Enable idle compaction</span>
478+
</label>
479+
{savingIdleHours && <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />}
480+
</div>
481+
482+
<div
483+
className={cn(
484+
"flex items-center gap-2",
485+
idleHours === null && "pointer-events-none opacity-50"
486+
)}
487+
>
488+
<span className="text-sm">Compact after</span>
489+
<input
490+
type="number"
491+
min={1}
492+
value={idleHoursInput}
493+
onChange={(e) => setIdleHoursInput(e.target.value)}
494+
onBlur={(e) => {
495+
const val = parseInt(e.target.value, 10);
496+
if (!isNaN(val) && val >= 1 && val !== idleHours) {
497+
void handleIdleHoursChange(val);
498+
} else if (e.target.value === "" || isNaN(val) || val < 1) {
499+
// Reset to current value on invalid input
500+
setIdleHoursInput(idleHours?.toString() ?? "24");
501+
}
502+
}}
503+
disabled={savingIdleHours || idleHours === null}
504+
className="border-border-medium bg-secondary/30 focus:ring-accent w-20 rounded-md border px-2 py-1 text-sm focus:ring-1 focus:outline-none disabled:cursor-not-allowed"
505+
/>
506+
<span className="text-sm">hours of inactivity</span>
507+
</div>
508+
</div>
509+
420510
{/* MCP Servers header */}
421-
<div className="flex items-center justify-between">
511+
<div className="border-border-medium flex items-center justify-between border-t pt-6">
422512
<div>
423513
<h3 className="font-medium">MCP Servers</h3>
424514
<p className="text-muted-foreground text-xs">

0 commit comments

Comments
 (0)