Skip to content

Commit 4a66207

Browse files
authored
🤖 fix: make compact-continue reliable and unify send options (#196)
Surface bug: After `/compact -c`, user message appeared but no stream started and no error artifacts were visible in AIView. ## Root causes 1. **Options mismatch**: `buildSendMessageOptions` read raw localStorage strings instead of using JSON parser, sometimes producing invalid/missing model. When model was invalid, backend added user message then returned error without throwing, causing silent failure (no stream, no UI error). 2. **Racy re-triggering**: `useAutoCompactContinue` effect could fire multiple times during state settlement after `replaceHistory`, causing flaky behavior and occasional duplicate sends. ## Fix - **Unified options building**: `buildSendMessageOptions` now delegates to `getSendOptionsFromStorage` (single source of truth with proper JSON parsing) ensuring valid model/toolPolicy/thinking options always supplied. - **Made auto-continue idempotent**: Added per-workspace guard that fires once per compaction completion and resets when state clears, preventing races. ## Files changed - `src/hooks/useSendMessageOptions.ts`: Use `getSendOptionsFromStorage` - `src/hooks/useAutoCompactContinue.ts`: Add idempotency guard ## Testing 1. Create history in any workspace 2. Run: `/compact -c "Keep going"` 3. When summary appears, auto-continue should: - Send your continue message with correct model/mode/thinking - Start streaming assistant reply 4. Verify with different models/thinking levels _Generated with `cmux`_
1 parent b3d113a commit 4a66207

File tree

2 files changed

+39
-38
lines changed

2 files changed

+39
-38
lines changed

src/hooks/useAutoCompactContinue.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect } from "react";
1+
import { useRef, useEffect } from "react";
22
import type { WorkspaceState } from "@/hooks/useWorkspaceAggregators";
33
import { getCompactContinueMessageKey } from "@/constants/storage";
44
import { buildSendMessageOptions } from "@/hooks/useSendMessageOptions";
@@ -18,27 +18,44 @@ import { buildSendMessageOptions } from "@/hooks/useSendMessageOptions";
1818
* metadata - frontend must pass complete options.
1919
*/
2020
export function useAutoCompactContinue(workspaceStates: Map<string, WorkspaceState>) {
21+
// Prevent duplicate auto-sends if effect runs more than once while the same
22+
// compacted summary is visible (e.g., rapid state updates after replaceHistory)
23+
const firedForWorkspace = useRef<Set<string>>(new Set());
24+
2125
useEffect(() => {
2226
// Check all workspaces for completed compaction
2327
for (const [workspaceId, state] of workspaceStates) {
24-
// Check if this workspace just compacted (single message marked as compacted)
25-
if (
28+
// Reset guard when compaction is no longer in the single-compacted-message state
29+
const isSingleCompacted =
2630
state.messages.length === 1 &&
2731
state.messages[0].type === "assistant" &&
28-
state.messages[0].isCompacted === true
29-
) {
30-
const continueMessage = localStorage.getItem(getCompactContinueMessageKey(workspaceId));
31-
32-
if (continueMessage) {
33-
// Clean up first to prevent duplicate sends
34-
localStorage.removeItem(getCompactContinueMessageKey(workspaceId));
35-
36-
// Build options and send message directly
37-
const options = buildSendMessageOptions(workspaceId);
38-
window.api.workspace.sendMessage(workspaceId, continueMessage, options).catch((error) => {
39-
console.error("Failed to send continue message:", error);
40-
});
41-
}
32+
state.messages[0].isCompacted === true;
33+
34+
if (!isSingleCompacted) {
35+
// Allow future auto-continue for this workspace when next compaction completes
36+
firedForWorkspace.current.delete(workspaceId);
37+
continue;
38+
}
39+
40+
// Only proceed once per compaction completion
41+
if (firedForWorkspace.current.has(workspaceId)) continue;
42+
43+
const continueMessage = localStorage.getItem(getCompactContinueMessageKey(workspaceId));
44+
45+
if (continueMessage) {
46+
// Mark as fired immediately to avoid re-entry on rapid renders
47+
firedForWorkspace.current.add(workspaceId);
48+
49+
// Clean up first to prevent duplicate sends (source of truth becomes history)
50+
localStorage.removeItem(getCompactContinueMessageKey(workspaceId));
51+
52+
// Build options and send message directly
53+
const options = buildSendMessageOptions(workspaceId);
54+
window.api.workspace.sendMessage(workspaceId, continueMessage, options).catch((error) => {
55+
console.error("Failed to send continue message:", error);
56+
// If sending failed, allow another attempt on next render by clearing the guard
57+
firedForWorkspace.current.delete(workspaceId);
58+
});
4259
}
4360
}
4461
}, [workspaceStates]);

src/hooks/useSendMessageOptions.ts

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,11 @@ import { useMode } from "@/contexts/ModeContext";
44
import { usePersistedState } from "./usePersistedState";
55
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/utils/ui/modeUtils";
66
import { defaultModel } from "@/utils/ai/models";
7-
import {
8-
getModelKey,
9-
getThinkingLevelKey,
10-
getModeKey,
11-
USE_1M_CONTEXT_KEY,
12-
} from "@/constants/storage";
7+
import { getModelKey } from "@/constants/storage";
138
import type { SendMessageOptions } from "@/types/ipc";
149
import type { UIMode } from "@/types/mode";
1510
import type { ThinkingLevel } from "@/types/thinking";
11+
import { getSendOptionsFromStorage } from "@/utils/messages/sendOptions";
1612

1713
/**
1814
* Construct SendMessageOptions from raw values
@@ -67,21 +63,9 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptions {
6763
}
6864

6965
/**
70-
* Build SendMessageOptions from localStorage (non-hook version)
71-
*
72-
* CRITICAL: Frontend is responsible for managing ALL sendMessage options.
73-
* Backend does NOT fall back to workspace metadata - all options must be passed explicitly.
74-
*
75-
* This function mirrors useSendMessageOptions logic but reads from localStorage directly,
76-
* allowing it to be called outside React component lifecycle (e.g., in callbacks).
66+
* Build SendMessageOptions outside React using the shared storage reader.
67+
* Single source of truth with getSendOptionsFromStorage to avoid JSON parsing bugs.
7768
*/
7869
export function buildSendMessageOptions(workspaceId: string): SendMessageOptions {
79-
// Read from localStorage matching the keys used by useSendMessageOptions
80-
const use1M = localStorage.getItem(USE_1M_CONTEXT_KEY) === "true";
81-
const thinkingLevel =
82-
(localStorage.getItem(getThinkingLevelKey(workspaceId)) as ThinkingLevel) || "medium";
83-
const mode = (localStorage.getItem(getModeKey(workspaceId)) as UIMode) || "edit";
84-
const preferredModel = localStorage.getItem(getModelKey(workspaceId));
85-
86-
return constructSendMessageOptions(mode, thinkingLevel, preferredModel, use1M);
70+
return getSendOptionsFromStorage(workspaceId);
8771
}

0 commit comments

Comments
 (0)