diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 6860fa693..9c9109ca9 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -12,7 +12,6 @@ import { usePersistedState, updatePersistedState } from "./hooks/usePersistedSta import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; -import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; import { ChatInput } from "./components/ChatInput/index"; import type { ChatInputAPI } from "./components/ChatInput/types"; @@ -116,9 +115,6 @@ function AppInner() { // Auto-resume interrupted streams on app startup and when failures occur useResumeManager(); - // Handle auto-continue after compaction (when user uses /compact -c) - useAutoCompactContinue(); - // Sync selectedWorkspace with URL hash useEffect(() => { if (selectedWorkspace) { diff --git a/src/browser/api.ts b/src/browser/api.ts index 4314b5c90..c399b5aea 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -225,7 +225,7 @@ const webApi: IPCApi = { invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), interruptStream: (workspaceId, options) => invokeIPC(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), - clearQueue: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_QUEUE_CLEAR, workspaceId), + clearQueue: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_CLEAR_QUEUE, workspaceId), truncateHistory: (workspaceId, percentage) => invokeIPC(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage), replaceChatHistory: (workspaceId, summaryMessage) => diff --git a/src/browser/hooks/useAutoCompactContinue.ts b/src/browser/hooks/useAutoCompactContinue.ts deleted file mode 100644 index d9de923a5..000000000 --- a/src/browser/hooks/useAutoCompactContinue.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { useRef, useEffect } from "react"; -import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; -import { buildSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; - -/** - * Hook to manage auto-continue after compaction using structured message metadata - * - * Approach: - * - Watches all workspaces for single compacted message (compaction just completed) - * - Reads continueMessage from the summary message's compaction-result metadata - * - Sends continue message automatically - * - * Why summary metadata? When compaction completes, history is replaced with just the - * summary message. The original compaction-request message is deleted. To preserve - * the continueMessage across this replacement, we extract it before replacement and - * store it in the summary's metadata. - * - * Self-contained: No callback needed. Hook detects condition and handles action. - * No localStorage - metadata is the single source of truth. - * - * IMPORTANT: sendMessage options (model, thinking level, mode, etc.) are managed by the - * frontend via buildSendMessageOptions. The backend does NOT fall back to workspace - * metadata - frontend must pass complete options. - */ -export function useAutoCompactContinue() { - // Get workspace states from store - // NOTE: We use a ref-based approach instead of useSyncExternalStore to avoid - // re-rendering AppInner on every workspace state change. This hook only needs - // to react when messages change to a single compacted message state. - const store = useWorkspaceStoreRaw(); - // Track which specific compaction summary messages we've already processed. - // Key insight: Each compaction creates a unique message. Track by message ID, - // not workspace ID, to prevent processing the same compaction result multiple times. - // This is obviously correct because message IDs are immutable and unique. - const processedMessageIds = useRef>(new Set()); - - // Update ref and check for auto-continue condition - const checkAutoCompact = () => { - const newStates = store.getAllStates(); - - // Check all workspaces for completed compaction - for (const [workspaceId, state] of newStates) { - // Detect if workspace is in "single compacted message" state - // Skip workspace-init messages since they're UI-only metadata - const muxMessages = state.messages.filter((m) => m.type !== "workspace-init"); - const isSingleCompacted = - muxMessages.length === 1 && - muxMessages[0]?.type === "assistant" && - muxMessages[0].isCompacted === true; - - if (!isSingleCompacted) { - // Workspace no longer in compacted state - no action needed - // Processed message IDs will naturally accumulate but stay bounded - // (one per compaction), and get cleared when user sends new messages - continue; - } - - // After compaction, history is replaced with a single summary message - // The summary message has compaction-result metadata with the continueMessage - const summaryMessage = state.muxMessages[0]; // Single compacted message - const muxMeta = summaryMessage?.metadata?.muxMetadata; - const continueMessage = - muxMeta?.type === "compaction-result" ? muxMeta.continueMessage : undefined; - - if (!continueMessage) continue; - - // Prefer compaction-request ID for idempotency; fall back to summary message ID - const idForGuard = - muxMeta?.type === "compaction-result" && muxMeta.requestId - ? `req:${muxMeta.requestId}` - : `msg:${summaryMessage.id}`; - - // Have we already processed this specific compaction result? - if (processedMessageIds.current.has(idForGuard)) continue; - - // Mark THIS RESULT as processed before sending to prevent duplicates - processedMessageIds.current.add(idForGuard); - - // Build options and send message directly - const options = buildSendMessageOptions(workspaceId); - void (async () => { - try { - const result = await window.api.workspace.sendMessage( - workspaceId, - continueMessage, - options - ); - // Check if send failed (browser API returns error object, not throw) - if (!result.success && "error" in result) { - console.error("Failed to send continue message:", result.error); - // If sending failed, remove from processed set to allow retry - processedMessageIds.current.delete(idForGuard); - } - } catch (error) { - // Handle network/parsing errors (HTTP errors, etc.) - console.error("Failed to send continue message:", error); - processedMessageIds.current.delete(idForGuard); - } - })(); - } - }; - - useEffect(() => { - // Initial check - checkAutoCompact(); - - // Subscribe to store changes and check condition - // This doesn't trigger React re-renders, just our internal check - const unsubscribe = store.subscribe(() => { - checkAutoCompact(); - }); - - return unsubscribe; - }, [store]); // eslint-disable-line react-hooks/exhaustive-deps -} diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 8433834ae..ab7b79951 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -672,12 +672,6 @@ export class WorkspaceStore { const historicalUsage = currentUsage.usageHistory.length > 0 ? sumUsageHistory(currentUsage.usageHistory) : undefined; - // Extract continueMessage from compaction-request before history gets replaced - const compactRequestMsg = findCompactionRequestMessage(aggregator); - const muxMeta = compactRequestMsg?.metadata?.muxMetadata; - const continueMessage = - muxMeta?.type === "compaction-request" ? muxMeta.parsed.continueMessage : undefined; - const summaryMessage = createMuxMessage( `summary-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, "assistant", @@ -697,10 +691,7 @@ export class WorkspaceStore { metadata && "systemMessageTokens" in metadata ? (metadata.systemMessageTokens as number | undefined) : undefined, - // Store continueMessage in summary so it survives history replacement - muxMetadata: continueMessage - ? { type: "compaction-result", continueMessage, requestId: compactRequestMsg?.id } - : { type: "normal" }, + muxMetadata: { type: "normal" }, } ); diff --git a/src/common/constants/ipc-constants.ts b/src/common/constants/ipc-constants.ts index b02a06b47..8a118423a 100644 --- a/src/common/constants/ipc-constants.ts +++ b/src/common/constants/ipc-constants.ts @@ -25,7 +25,7 @@ export const IPC_CHANNELS = { WORKSPACE_SEND_MESSAGE: "workspace:sendMessage", WORKSPACE_RESUME_STREAM: "workspace:resumeStream", WORKSPACE_INTERRUPT_STREAM: "workspace:interruptStream", - WORKSPACE_QUEUE_CLEAR: "workspace:queue:clear", + WORKSPACE_CLEAR_QUEUE: "workspace:clearQueue", WORKSPACE_TRUNCATE_HISTORY: "workspace:truncateHistory", WORKSPACE_REPLACE_HISTORY: "workspace:replaceHistory", WORKSPACE_STREAM_HISTORY: "workspace:streamHistory", diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 5a2b2f121..6ce7477ec 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -126,15 +126,6 @@ export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel"; */ export const VIM_ENABLED_KEY = "vimEnabled"; -/** - * Get the localStorage key for the compact continue message for a workspace - * Temporarily stores the continuation prompt for the current compaction - * Should be deleted immediately after use to prevent bugs - */ -export function getCompactContinueMessageKey(workspaceId: string): string { - return `compactContinueMessage:${workspaceId}`; -} - /** * Get the localStorage key for hunk expand/collapse state in Review tab * Stores user's manual expand/collapse preferences per hunk @@ -164,7 +155,6 @@ export function getReviewSearchStateKey(workspaceId: string): string { /** * List of workspace-scoped key functions that should be copied on fork and deleted on removal - * Note: Excludes ephemeral keys like getCompactContinueMessageKey */ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> = [ getModelKey, @@ -183,7 +173,6 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> */ const EPHEMERAL_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> = [ getCancelledCompactionKey, - getCompactContinueMessageKey, ]; /** diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 458f545a3..0d88b52d4 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -20,11 +20,6 @@ export type MuxFrontendMetadata = rawCommand: string; // The original /compact command as typed by user (for display) parsed: CompactionRequestData; } - | { - type: "compaction-result"; - continueMessage: string; // Message to send after compaction completes - requestId?: string; // ID of the compaction-request user message that produced this summary (for idempotency) - } | { type: "normal"; // Regular messages }; diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index b8a910bd5..63df40b9a 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -75,7 +75,7 @@ const api: IPCApi = { interruptStream: (workspaceId: string, options?: { abandonPartial?: boolean }) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), clearQueue: (workspaceId: string) => - ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_QUEUE_CLEAR, workspaceId), + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CLEAR_QUEUE, workspaceId), truncateHistory: (workspaceId, percentage) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage), replaceChatHistory: (workspaceId, summaryMessage) => diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index f49f1d61c..a3ed32dff 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -314,6 +314,15 @@ export class AgentSession { this.emitChatEvent(userMessage); + // If this is a compaction request with a continue message, queue it for auto-send after compaction + const muxMeta = options?.muxMetadata; + if (muxMeta?.type === "compaction-request" && muxMeta.parsed.continueMessage && options) { + // Strip out edit-specific and compaction-specific fields so the queued message is a fresh user message + const { muxMetadata, mode, editMessageId, ...continueOptions } = options; + this.messageQueue.add(muxMeta.parsed.continueMessage, continueOptions); + this.emitQueuedMessageChanged(); + } + if (!options?.model || options.model.trim().length === 0) { return Err( createUnknownSendMessageError("No model specified. Please select a model using /model.") diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index d76819023..90dcf8e95 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1000,7 +1000,7 @@ export class IpcMain { } ); - ipcMain.handle(IPC_CHANNELS.WORKSPACE_QUEUE_CLEAR, (_event, workspaceId: string) => { + ipcMain.handle(IPC_CHANNELS.WORKSPACE_CLEAR_QUEUE, (_event, workspaceId: string) => { try { const session = this.getOrCreateSession(workspaceId); session.clearQueue();