fix: slash command queue race conditions and overlay state management (#246)

This commit is contained in:
Devansh Jain
2025-12-16 15:10:35 -08:00
committed by GitHub
parent 322ab19f11
commit c064d99883
3 changed files with 343 additions and 284 deletions

View File

@@ -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<ActiveOverlay>(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<void>) => {
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" && (
<ModelSelector
currentModel={
llmConfig?.model_endpoint_type && llmConfig?.model
@@ -3839,64 +3882,64 @@ Plan file path: ${planFilePath}`;
}
currentEnableReasoner={llmConfig?.enable_reasoner}
onSelect={handleModelSelect}
onCancel={() => setModelSelectorOpen(false)}
onCancel={closeOverlay}
/>
)}
{/* Toolset Selector - conditionally mounted as overlay */}
{toolsetSelectorOpen && (
{activeOverlay === "toolset" && (
<ToolsetSelector
currentToolset={currentToolset ?? undefined}
onSelect={handleToolsetSelect}
onCancel={() => setToolsetSelectorOpen(false)}
onCancel={closeOverlay}
/>
)}
{/* System Prompt Selector - conditionally mounted as overlay */}
{systemPromptSelectorOpen && (
{activeOverlay === "system" && (
<SystemPromptSelector
currentPromptId={currentSystemPromptId ?? undefined}
onSelect={handleSystemPromptSelect}
onCancel={() => setSystemPromptSelectorOpen(false)}
onCancel={closeOverlay}
/>
)}
{/* Agent Selector - conditionally mounted as overlay */}
{agentSelectorOpen && (
{activeOverlay === "agent" && (
<AgentSelector
currentAgentId={agentId}
onSelect={handleAgentSelect}
onCancel={() => setAgentSelectorOpen(false)}
onCancel={closeOverlay}
/>
)}
{/* Subagent Manager - for managing custom subagents */}
{subagentManagerOpen && (
<SubagentManager onClose={() => setSubagentManagerOpen(false)} />
{activeOverlay === "subagent" && (
<SubagentManager onClose={closeOverlay} />
)}
{/* Resume Selector - conditionally mounted as overlay */}
{resumeSelectorOpen && (
{activeOverlay === "resume" && (
<ResumeSelector
currentAgentId={agentId}
onSelect={async (id) => {
setResumeSelectorOpen(false);
closeOverlay();
await handleAgentSelect(id);
}}
onCancel={() => setResumeSelectorOpen(false)}
onCancel={closeOverlay}
/>
)}
{/* Profile Selector - conditionally mounted as overlay */}
{profileSelectorOpen && (
{activeOverlay === "profile" && (
<ProfileSelector
currentAgentId={agentId}
onSelect={async (id) => {
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 && (
<MessageSearch onClose={() => setMessageSearchOpen(false)} />
{activeOverlay === "search" && (
<MessageSearch onClose={closeOverlay} />
)}
{/* Plan Mode Dialog - for ExitPlanMode tool */}

View File

@@ -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({
</Box>
)}
{/* 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 && (
<QueuedMessages messages={messageQueue} />
)}

View File

@@ -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<boolean>] {
const [state, setState] = useState(initialValue);
const ref = useRef(initialValue);
const setSyncedState = useCallback((value: boolean) => {
ref.current = value;
setState(value);
}, []);
return [state, setSyncedState, ref];
}