diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 1bed59f..0a85360 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -95,6 +95,7 @@ import { import { getRandomThinkingMessage } from "./helpers/thinkingMessages"; import { isFancyUITool, isTaskTool } from "./helpers/toolNameMapping.js"; import { useSuspend } from "./hooks/useSuspend/useSuspend.ts"; +import { useSyncedState } from "./hooks/useSyncedState"; import { useTerminalWidth } from "./hooks/useTerminalWidth"; const CLEAR_SCREEN_AND_HOME = "\u001B[2J\u001B[H"; @@ -323,13 +324,16 @@ export default function App({ }, [agentId]); // Whether a stream is in flight (disables input) - const [streaming, setStreaming] = useState(false); + // Uses synced state to keep ref in sync for reliable async checks + const [streaming, setStreaming, streamingRef] = useSyncedState(false); // Whether an interrupt has been requested for the current stream const [interruptRequested, setInterruptRequested] = useState(false); // Whether a command is running (disables input but no streaming UI) - const [commandRunning, setCommandRunning] = useState(false); + // Uses synced state to keep ref in sync for reliable async checks + const [commandRunning, setCommandRunning, commandRunningRef] = + useSyncedState(false); // Profile load confirmation - when loading a profile and current agent is unsaved const [profileConfirmPending, setProfileConfirmPending] = useState<{ @@ -377,11 +381,24 @@ export default function App({ // This is the approval currently being shown to the user const currentApproval = pendingApprovals[approvalResults.length]; - // Model selector state - const [modelSelectorOpen, setModelSelectorOpen] = useState(false); - const [toolsetSelectorOpen, setToolsetSelectorOpen] = useState(false); - const [systemPromptSelectorOpen, setSystemPromptSelectorOpen] = - useState(false); + // Overlay/selector state - only one can be open at a time + type ActiveOverlay = + | "model" + | "toolset" + | "system" + | "agent" + | "resume" + | "profile" + | "search" + | "subagent" + | null; + const [activeOverlay, setActiveOverlay] = useState(null); + const closeOverlay = useCallback(() => setActiveOverlay(null), []); + + // Derived: check if any selector/overlay is open (blocks queue processing and hides input) + const anySelectorOpen = activeOverlay !== null; + + // Other model/agent state const [currentSystemPromptId, setCurrentSystemPromptId] = useState< string | null >("default"); @@ -404,19 +421,6 @@ export default function App({ : (llmConfig?.model ?? null); const currentModelDisplay = currentModelLabel?.split("/").pop() ?? null; - // Agent selector state - const [agentSelectorOpen, setAgentSelectorOpen] = useState(false); - - // Resume selector state - const [resumeSelectorOpen, setResumeSelectorOpen] = useState(false); - const [messageSearchOpen, setMessageSearchOpen] = useState(false); - - // Subagent manager state (for /subagents command) - const [subagentManagerOpen, setSubagentManagerOpen] = useState(false); - - // Profile selector state - const [profileSelectorOpen, setProfileSelectorOpen] = useState(false); - // Token streaming preference (can be toggled at runtime) const [tokenStreamingEnabled, setTokenStreamingEnabled] = useState(tokenStreaming); @@ -465,6 +469,33 @@ export default function App({ restoreQueueOnCancelRef.current = restoreQueueOnCancel; }, [restoreQueueOnCancel]); + // Helper to check if agent is busy (streaming, executing tool, or running command) + // Uses refs for synchronous access outside React's closure system + // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable objects, .current is read dynamically + const isAgentBusy = useCallback(() => { + return ( + streamingRef.current || + isExecutingTool || + commandRunningRef.current || + abortControllerRef.current !== null + ); + }, [isExecutingTool]); + + // Helper to wrap async handlers that need to close overlay and lock input + // Closes overlay and sets commandRunning before executing, releases lock in finally + const withCommandLock = useCallback( + async (asyncFn: () => Promise) => { + setActiveOverlay(null); + setCommandRunning(true); + try { + await asyncFn(); + } finally { + setCommandRunning(false); + } + }, + [setCommandRunning], + ); + // Track terminal shrink events to refresh static output (prevents wrapped leftovers) const columns = useTerminalWidth(); const prevColumnsRef = useRef(columns); @@ -1270,7 +1301,13 @@ export default function App({ abortControllerRef.current = null; } }, - [appendError, refreshDerived, refreshDerivedThrottled, agentName], + [ + appendError, + refreshDerived, + refreshDerivedThrottled, + setStreaming, + agentName, + ], ); const handleExit = useCallback(() => { @@ -1336,6 +1373,14 @@ export default function App({ // Silently ignore - cancellation already happened client-side }); + // Reset cancellation flag after cleanup is complete. + // This allows the dequeue effect to process any queued messages. + // We use setTimeout to ensure React state updates (setStreaming, etc.) + // have been processed before the dequeue effect runs. + setTimeout(() => { + userCancelledRef.current = false; + }, 0); + return; } else { setInterruptRequested(true); @@ -1359,6 +1404,7 @@ export default function App({ appendError, isExecutingTool, refreshDerived, + setStreaming, ]); // Keep ref to latest processConversation to avoid circular deps in useEffect @@ -1376,9 +1422,10 @@ export default function App({ const handleAgentSelect = useCallback( async (targetAgentId: string, _opts?: { profileName?: string }) => { - setAgentSelectorOpen(false); + // Close selector immediately + setActiveOverlay(null); - // Skip if already on this agent + // Skip if already on this agent (no async work needed, queue can proceed) if (targetAgentId === agentId) { const label = agentName || targetAgentId.slice(0, 12); const cmdId = uid("cmd"); @@ -1395,10 +1442,11 @@ export default function App({ return; } - const inputCmd = "/pinned"; - + // Lock input for async operation (set before any await to prevent queue processing) setCommandRunning(true); + const inputCmd = "/pinned"; + try { const client = await getClient(); // Fetch new agent @@ -1478,9 +1526,10 @@ export default function App({ setCommandRunning(false); } }, - [refreshDerived, agentId, agentName], + [refreshDerived, agentId, agentName, setCommandRunning], ); + // biome-ignore lint/correctness/useExhaustiveDependencies: refs read .current dynamically, complex callback with intentional deps const onSubmit = useCallback( async (message?: string): Promise<{ submitted: boolean }> => { const msg = message?.trim() ?? ""; @@ -1524,18 +1573,27 @@ export default function App({ // Queue message if agent is busy (streaming, executing tool, or running command) // This allows messages to queue up while agent is working - const agentBusy = streaming || isExecutingTool || commandRunning; - if (agentBusy) { + // Reset cancellation flag before queue check - this ensures queued messages + // can be dequeued even if the user just cancelled. The dequeue effect checks + // userCancelledRef.current, so we must clear it here to prevent blocking. + userCancelledRef.current = false; + + if (isAgentBusy()) { setMessageQueue((prev) => { const newQueue = [...prev, msg]; - // Always update snapshot to include ALL queued messages - queueSnapshotRef.current = [...newQueue]; + // For slash commands, just queue and wait - don't interrupt the agent. + // For regular messages, cancel the stream so the new message can be sent. + const isSlashCommand = msg.startsWith("/"); - // If this is the first queued message, send cancel request - if (!waitingForQueueCancelRef.current) { + if ( + !isSlashCommand && + streamingRef.current && + !waitingForQueueCancelRef.current + ) { waitingForQueueCancelRef.current = true; + queueSnapshotRef.current = [...newQueue]; // Send cancel request to backend (fire-and-forget) getClient() @@ -1552,9 +1610,8 @@ export default function App({ return { submitted: true }; // Clears input } - // Reset cancellation flag when starting new submission - // This ensures that after an interrupt, new messages can be sent - userCancelledRef.current = false; + // Note: userCancelledRef.current was already reset above before the queue check + // to ensure the dequeue effect isn't blocked by a stale cancellation flag. let aliasedMsg = msg; if (msg === "exit" || msg === "quit") { @@ -1567,25 +1624,25 @@ export default function App({ // Special handling for /model command - opens selector if (trimmed === "/model") { - setModelSelectorOpen(true); + setActiveOverlay("model"); return { submitted: true }; } // Special handling for /toolset command - opens selector if (trimmed === "/toolset") { - setToolsetSelectorOpen(true); + setActiveOverlay("toolset"); return { submitted: true }; } // Special handling for /system command - opens system prompt selector if (trimmed === "/system") { - setSystemPromptSelectorOpen(true); + setActiveOverlay("system"); return { submitted: true }; } // Special handling for /subagents command - opens subagent manager if (trimmed === "/subagents") { - setSubagentManagerOpen(true); + setActiveOverlay("subagent"); return { submitted: true }; } @@ -1915,13 +1972,13 @@ export default function App({ // Special handling for /resume command - show session resume selector if (msg.trim() === "/agents" || msg.trim() === "/resume") { - setResumeSelectorOpen(true); + setActiveOverlay("resume"); return { submitted: true }; } // Special handling for /search command - show message search if (msg.trim() === "/search") { - setMessageSearchOpen(true); + setActiveOverlay("search"); return { submitted: true }; } @@ -1942,7 +1999,7 @@ export default function App({ // /profile - open profile selector if (!subcommand) { - setProfileSelectorOpen(true); + setActiveOverlay("profile"); return { submitted: true }; } @@ -2003,7 +2060,7 @@ export default function App({ // Special handling for /profiles and /pinned commands - open pinned agents selector if (msg.trim() === "/profiles" || msg.trim() === "/pinned") { - setProfileSelectorOpen(true); + setActiveOverlay("profile"); return { submitted: true }; } @@ -2701,6 +2758,9 @@ ${recentCommits} profileConfirmPending, handleAgentSelect, tokenStreamingEnabled, + isAgentBusy, + setStreaming, + setCommandRunning, ], ); @@ -2717,6 +2777,7 @@ ${recentCommits} pendingApprovals.length === 0 && !commandRunning && !isExecutingTool && + !anySelectorOpen && // Don't dequeue while a selector/overlay is open !waitingForQueueCancelRef.current && // Don't dequeue while waiting for cancel !userCancelledRef.current // Don't dequeue if user just cancelled ) { @@ -2733,6 +2794,7 @@ ${recentCommits} pendingApprovals, commandRunning, isExecutingTool, + anySelectorOpen, ]); // Helper to send all approval results when done @@ -2892,6 +2954,7 @@ ${recentCommits} refreshDerived, appendError, agentName, + setStreaming, ], ); @@ -2936,6 +2999,7 @@ ${recentCommits} sendAllResults, appendError, isExecutingTool, + setStreaming, ]); const handleApproveAlways = useCallback( @@ -3028,6 +3092,7 @@ ${recentCommits} appendError, isExecutingTool, agentName, + setStreaming, ], ); @@ -3059,213 +3124,202 @@ ${recentCommits} const handleModelSelect = useCallback( async (modelId: string) => { - setModelSelectorOpen(false); + await withCommandLock(async () => { + // Declare cmdId outside try block so it's accessible in catch + let cmdId: string | null = null; - // Declare cmdId outside try block so it's accessible in catch - let cmdId: string | null = null; + try { + // Find the selected model from models.json first (for loading message) + const { models } = await import("../agent/model"); + const selectedModel = models.find((m) => m.id === modelId); - try { - // Find the selected model from models.json first (for loading message) - const { models } = await import("../agent/model"); - const selectedModel = models.find((m) => m.id === modelId); + if (!selectedModel) { + // Create a failed command in the transcript + cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/model ${modelId}`, + output: `Model not found: ${modelId}`, + phase: "finished", + success: false, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return; + } - if (!selectedModel) { - // Create a failed command in the transcript + // Immediately add command to transcript with "running" phase and loading message cmdId = uid("cmd"); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: `/model ${modelId}`, - output: `Model not found: ${modelId}`, - phase: "finished", - success: false, + output: `Switching model to ${selectedModel.label}...`, + phase: "running", }); buffersRef.current.order.push(cmdId); refreshDerived(); - return; - } - // Immediately add command to transcript with "running" phase and loading message - cmdId = uid("cmd"); - buffersRef.current.byId.set(cmdId, { - kind: "command", - id: cmdId, - input: `/model ${modelId}`, - output: `Switching model to ${selectedModel.label}...`, - phase: "running", - }); - buffersRef.current.order.push(cmdId); - refreshDerived(); + // Update the agent with new model and config args + const { updateAgentLLMConfig } = await import("../agent/modify"); - // Lock input during async operation - setCommandRunning(true); - - // Update the agent with new model and config args - const { updateAgentLLMConfig } = await import("../agent/modify"); - - const updatedConfig = await updateAgentLLMConfig( - agentId, - selectedModel.handle, - selectedModel.updateArgs, - ); - setLlmConfig(updatedConfig); - - // After switching models, only switch toolset if it actually changes - const { isOpenAIModel, isGeminiModel } = await import( - "../tools/manager" - ); - const targetToolset: - | "codex" - | "codex_snake" - | "default" - | "gemini" - | "gemini_snake" - | "none" = isOpenAIModel(selectedModel.handle ?? "") - ? "codex" - : isGeminiModel(selectedModel.handle ?? "") - ? "gemini" - : "default"; - - let toolsetName: - | "codex" - | "codex_snake" - | "default" - | "gemini" - | "gemini_snake" - | "none" - | null = null; - if (currentToolset !== targetToolset) { - const { switchToolsetForModel } = await import("../tools/toolset"); - toolsetName = await switchToolsetForModel( - selectedModel.handle ?? "", + const updatedConfig = await updateAgentLLMConfig( agentId, + selectedModel.handle, + selectedModel.updateArgs, ); - setCurrentToolset(toolsetName); - } + setLlmConfig(updatedConfig); - // Update the same command with final result (include toolset info only if changed) - const autoToolsetLine = toolsetName - ? `Automatically switched toolset to ${toolsetName}. Use /toolset to change back if desired.\nConsider switching to a different system prompt using /system to match.` - : null; - const outputLines = [ - `Switched to ${selectedModel.label}`, - ...(autoToolsetLine ? [autoToolsetLine] : []), - ].join("\n"); + // After switching models, only switch toolset if it actually changes + const { isOpenAIModel, isGeminiModel } = await import( + "../tools/manager" + ); + const targetToolset: + | "codex" + | "codex_snake" + | "default" + | "gemini" + | "gemini_snake" + | "none" = isOpenAIModel(selectedModel.handle ?? "") + ? "codex" + : isGeminiModel(selectedModel.handle ?? "") + ? "gemini" + : "default"; + + let toolsetName: + | "codex" + | "codex_snake" + | "default" + | "gemini" + | "gemini_snake" + | "none" + | null = null; + if (currentToolset !== targetToolset) { + const { switchToolsetForModel } = await import("../tools/toolset"); + toolsetName = await switchToolsetForModel( + selectedModel.handle ?? "", + agentId, + ); + setCurrentToolset(toolsetName); + } + + // Update the same command with final result (include toolset info only if changed) + const autoToolsetLine = toolsetName + ? `Automatically switched toolset to ${toolsetName}. Use /toolset to change back if desired.\nConsider switching to a different system prompt using /system to match.` + : null; + const outputLines = [ + `Switched to ${selectedModel.label}`, + ...(autoToolsetLine ? [autoToolsetLine] : []), + ].join("\n"); - buffersRef.current.byId.set(cmdId, { - kind: "command", - id: cmdId, - input: `/model ${modelId}`, - output: outputLines, - phase: "finished", - success: true, - }); - refreshDerived(); - } catch (error) { - // Mark command as failed (only if cmdId was created) - const errorDetails = formatErrorDetails(error, agentId); - if (cmdId) { buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: `/model ${modelId}`, - output: `Failed to switch model: ${errorDetails}`, + output: outputLines, phase: "finished", - success: false, + success: true, }); refreshDerived(); + } catch (error) { + // Mark command as failed (only if cmdId was created) + const errorDetails = formatErrorDetails(error, agentId); + if (cmdId) { + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/model ${modelId}`, + output: `Failed to switch model: ${errorDetails}`, + phase: "finished", + success: false, + }); + refreshDerived(); + } } - } finally { - // Unlock input - setCommandRunning(false); - } + }); }, - [agentId, refreshDerived, currentToolset], + [agentId, refreshDerived, currentToolset, withCommandLock], ); const handleSystemPromptSelect = useCallback( async (promptId: string) => { - setSystemPromptSelectorOpen(false); + await withCommandLock(async () => { + const cmdId = uid("cmd"); - const cmdId = uid("cmd"); + try { + // Find the selected prompt + const { SYSTEM_PROMPTS } = await import("../agent/promptAssets"); + const selectedPrompt = SYSTEM_PROMPTS.find((p) => p.id === promptId); - try { - // Find the selected prompt - const { SYSTEM_PROMPTS } = await import("../agent/promptAssets"); - const selectedPrompt = SYSTEM_PROMPTS.find((p) => p.id === promptId); + if (!selectedPrompt) { + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/system ${promptId}`, + output: `System prompt not found: ${promptId}`, + phase: "finished", + success: false, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return; + } - if (!selectedPrompt) { + // Immediately add command to transcript with "running" phase buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: `/system ${promptId}`, - output: `System prompt not found: ${promptId}`, - phase: "finished", - success: false, + output: `Switching system prompt to ${selectedPrompt.label}...`, + phase: "running", }); buffersRef.current.order.push(cmdId); refreshDerived(); - return; - } - // Immediately add command to transcript with "running" phase - buffersRef.current.byId.set(cmdId, { - kind: "command", - id: cmdId, - input: `/system ${promptId}`, - output: `Switching system prompt to ${selectedPrompt.label}...`, - phase: "running", - }); - buffersRef.current.order.push(cmdId); - refreshDerived(); + // Update the agent's system prompt + const { updateAgentSystemPrompt } = await import("../agent/modify"); + const result = await updateAgentSystemPrompt( + agentId, + selectedPrompt.content, + ); - // Lock input during async operation - setCommandRunning(true); - - // Update the agent's system prompt - const { updateAgentSystemPrompt } = await import("../agent/modify"); - const result = await updateAgentSystemPrompt( - agentId, - selectedPrompt.content, - ); - - if (result.success) { - setCurrentSystemPromptId(promptId); + if (result.success) { + setCurrentSystemPromptId(promptId); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/system ${promptId}`, + output: `Switched system prompt to ${selectedPrompt.label}`, + phase: "finished", + success: true, + }); + } else { + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/system ${promptId}`, + output: result.message, + phase: "finished", + success: false, + }); + } + refreshDerived(); + } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: `/system ${promptId}`, - output: `Switched system prompt to ${selectedPrompt.label}`, - phase: "finished", - success: true, - }); - } else { - buffersRef.current.byId.set(cmdId, { - kind: "command", - id: cmdId, - input: `/system ${promptId}`, - output: result.message, + output: `Failed to switch system prompt: ${errorDetails}`, phase: "finished", success: false, }); + refreshDerived(); } - refreshDerived(); - } catch (error) { - const errorDetails = formatErrorDetails(error, agentId); - buffersRef.current.byId.set(cmdId, { - kind: "command", - id: cmdId, - input: `/system ${promptId}`, - output: `Failed to switch system prompt: ${errorDetails}`, - phase: "finished", - success: false, - }); - refreshDerived(); - } finally { - setCommandRunning(false); - } + }); }, - [agentId, refreshDerived], + [agentId, refreshDerived, withCommandLock], ); const handleToolsetSelect = useCallback( @@ -3278,57 +3332,51 @@ ${recentCommits} | "gemini_snake" | "none", ) => { - setToolsetSelectorOpen(false); + await withCommandLock(async () => { + const cmdId = uid("cmd"); - const cmdId = uid("cmd"); + try { + // Immediately add command to transcript with "running" phase + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/toolset ${toolsetId}`, + output: `Switching toolset to ${toolsetId}...`, + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); - try { - // Immediately add command to transcript with "running" phase - buffersRef.current.byId.set(cmdId, { - kind: "command", - id: cmdId, - input: `/toolset ${toolsetId}`, - output: `Switching toolset to ${toolsetId}...`, - phase: "running", - }); - buffersRef.current.order.push(cmdId); - refreshDerived(); + // Force switch to the selected toolset + const { forceToolsetSwitch } = await import("../tools/toolset"); + await forceToolsetSwitch(toolsetId, agentId); + setCurrentToolset(toolsetId); - // Lock input during async operation - setCommandRunning(true); - - // Force switch to the selected toolset - const { forceToolsetSwitch } = await import("../tools/toolset"); - await forceToolsetSwitch(toolsetId, agentId); - setCurrentToolset(toolsetId); - - // Update the command with final result - buffersRef.current.byId.set(cmdId, { - kind: "command", - id: cmdId, - input: `/toolset ${toolsetId}`, - output: `Switched toolset to ${toolsetId}`, - phase: "finished", - success: true, - }); - refreshDerived(); - } catch (error) { - const errorDetails = formatErrorDetails(error, agentId); - buffersRef.current.byId.set(cmdId, { - kind: "command", - id: cmdId, - input: `/toolset ${toolsetId}`, - output: `Failed to switch toolset: ${errorDetails}`, - phase: "finished", - success: false, - }); - refreshDerived(); - } finally { - // Unlock input - setCommandRunning(false); - } + // Update the command with final result + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/toolset ${toolsetId}`, + output: `Switched toolset to ${toolsetId}`, + phase: "finished", + success: true, + }); + refreshDerived(); + } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/toolset ${toolsetId}`, + output: `Failed to switch toolset: ${errorDetails}`, + phase: "finished", + success: false, + }); + refreshDerived(); + } + }); }, - [agentId, refreshDerived], + [agentId, refreshDerived, withCommandLock], ); // Handle escape when profile confirmation is pending @@ -3415,6 +3463,7 @@ ${recentCommits} appendError, refreshDerived, agentName, + setStreaming, ], ); @@ -3800,13 +3849,7 @@ Plan file path: ${planFilePath}`; visible={ !showExitStats && pendingApprovals.length === 0 && - !modelSelectorOpen && - !toolsetSelectorOpen && - !systemPromptSelectorOpen && - !agentSelectorOpen && - !resumeSelectorOpen && - !profileSelectorOpen && - !messageSearchOpen + !anySelectorOpen } streaming={ streaming && !abortControllerRef.current?.signal.aborted @@ -3830,7 +3873,7 @@ Plan file path: ${planFilePath}`; /> {/* Model Selector - conditionally mounted as overlay */} - {modelSelectorOpen && ( + {activeOverlay === "model" && ( setModelSelectorOpen(false)} + onCancel={closeOverlay} /> )} {/* Toolset Selector - conditionally mounted as overlay */} - {toolsetSelectorOpen && ( + {activeOverlay === "toolset" && ( setToolsetSelectorOpen(false)} + onCancel={closeOverlay} /> )} {/* System Prompt Selector - conditionally mounted as overlay */} - {systemPromptSelectorOpen && ( + {activeOverlay === "system" && ( setSystemPromptSelectorOpen(false)} + onCancel={closeOverlay} /> )} {/* Agent Selector - conditionally mounted as overlay */} - {agentSelectorOpen && ( + {activeOverlay === "agent" && ( setAgentSelectorOpen(false)} + onCancel={closeOverlay} /> )} {/* Subagent Manager - for managing custom subagents */} - {subagentManagerOpen && ( - setSubagentManagerOpen(false)} /> + {activeOverlay === "subagent" && ( + )} {/* Resume Selector - conditionally mounted as overlay */} - {resumeSelectorOpen && ( + {activeOverlay === "resume" && ( { - setResumeSelectorOpen(false); + closeOverlay(); await handleAgentSelect(id); }} - onCancel={() => setResumeSelectorOpen(false)} + onCancel={closeOverlay} /> )} {/* Profile Selector - conditionally mounted as overlay */} - {profileSelectorOpen && ( + {activeOverlay === "profile" && ( { - setProfileSelectorOpen(false); + closeOverlay(); await handleAgentSelect(id); }} onUnpin={(unpinAgentId) => { - setProfileSelectorOpen(false); + closeOverlay(); settingsManager.unpinBoth(unpinAgentId); const cmdId = uid("cmd"); buffersRef.current.byId.set(cmdId, { @@ -3910,13 +3953,13 @@ Plan file path: ${planFilePath}`; buffersRef.current.order.push(cmdId); refreshDerived(); }} - onCancel={() => setProfileSelectorOpen(false)} + onCancel={closeOverlay} /> )} {/* Message Search - conditionally mounted as overlay */} - {messageSearchOpen && ( - setMessageSearchOpen(false)} /> + {activeOverlay === "search" && ( + )} {/* Plan Mode Dialog - for ExitPlanMode tool */} diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 942a911..6d6c164 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -156,16 +156,8 @@ export function Input({ // When streaming, use Esc to interrupt if (streaming && onInterrupt && !interruptRequested) { onInterrupt(); - - // If there are queued messages, load them into the input box - if (messageQueue && messageQueue.length > 0) { - const queueText = messageQueue.join("\n"); - setValue(queueText); - // Signal to App.tsx to clear the queue - if (onEnterQueueEditMode) { - onEnterQueueEditMode(); - } - } + // Don't load queued messages into input - let the dequeue effect + // in App.tsx process them automatically after the interrupt completes. return; } @@ -540,8 +532,8 @@ export function Input({ )} - {/* Queue display - show when streaming with queued messages */} - {streaming && messageQueue && messageQueue.length > 0 && ( + {/* Queue display - show whenever there are queued messages */} + {messageQueue && messageQueue.length > 0 && ( )} diff --git a/src/cli/hooks/useSyncedState.ts b/src/cli/hooks/useSyncedState.ts new file mode 100644 index 0000000..2dc926d --- /dev/null +++ b/src/cli/hooks/useSyncedState.ts @@ -0,0 +1,24 @@ +import { useCallback, useRef, useState } from "react"; + +/** + * A custom hook that keeps a React state and ref in sync. + * Useful when you need immediate access to state values in async callbacks + * that may close over stale state. The ref is updated synchronously before + * the state, ensuring reliable checks in async operations. + * + * @param initialValue - The initial state value + * @returns A tuple of [state, setState, ref] + */ +export function useSyncedState( + initialValue: boolean, +): [boolean, (value: boolean) => void, React.MutableRefObject] { + const [state, setState] = useState(initialValue); + const ref = useRef(initialValue); + + const setSyncedState = useCallback((value: boolean) => { + ref.current = value; + setState(value); + }, []); + + return [state, setSyncedState, ref]; +}