From de386b36bee3d179fb9dd2b126f176bed8a41b6b Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 18 Nov 2025 13:58:26 +1100 Subject: [PATCH] refactor: use message queue for compact continue messages Previously, compact continue messages were handled by a frontend hook (useAutoCompactContinue) that watched for completed compactions and then sent the continue message. This introduced complexity, race conditions, and required tracking processed message IDs. Now leverages the existing message queue system: - Backend queues continue message when compaction starts - Queue auto-sends when compaction stream ends (existing behavior) - Continue message shown in queue UI during compaction - Proper cleanup on all error paths - Strip editMessageId to prevent truncation failures after compaction Net reduction of 134 lines. Simpler, more reliable, better UX. --- src/browser/App.tsx | 4 - src/browser/api.ts | 2 +- src/browser/hooks/useAutoCompactContinue.ts | 115 -------------------- src/browser/stores/WorkspaceStore.ts | 11 +- src/common/constants/ipc-constants.ts | 2 +- src/common/constants/storage.ts | 11 -- src/common/types/message.ts | 5 - src/desktop/preload.ts | 2 +- src/node/services/agentSession.ts | 9 ++ src/node/services/ipcMain.ts | 2 +- 10 files changed, 14 insertions(+), 149 deletions(-) delete mode 100644 src/browser/hooks/useAutoCompactContinue.ts 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();