fix: slash command queue race conditions and overlay state management (#246)
This commit is contained in:
587
src/cli/App.tsx
587
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<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 */}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
|
||||
24
src/cli/hooks/useSyncedState.ts
Normal file
24
src/cli/hooks/useSyncedState.ts
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user