Skip to content

Commit 3e8b3c6

Browse files
pic4xiu思晗
andauthored
fix(cli): serialize subagent confirmation focus to prevent concurrent input conflicts (#2930)
* fix: serialize subagent confirmation focus to prevent concurrent input conflicts When multiple subagents run in parallel and each triggers a confirmation prompt, all prompts previously received keyboard focus simultaneously, causing a single keypress to be dispatched to every active confirmation. This change introduces a first-come-first-served focus lock mechanism: - Track subagents with pending confirmations via a type guard - Use a useRef-based lock so only one confirmation is focused at a time - Automatically promote focus to the next pending subagent on resolution - Show a waiting indicator on non-focused confirmations Fixes #2929 * fix(cli): use dedicated prop for subagent approval waiting state --------- Co-authored-by: 思晗 <housihan.hsh@alibaba-inc.com>
1 parent 44c596c commit 3e8b3c6

File tree

3 files changed

+97
-4
lines changed

3 files changed

+97
-4
lines changed

packages/cli/src/ui/components/messages/ToolGroupMessage.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import type React from 'react';
8-
import { useMemo } from 'react';
8+
import { useMemo, useRef } from 'react';
99
import { Box } from 'ink';
1010
import type { IndividualToolCallDisplay } from '../../types.js';
1111
import { ToolCallStatus } from '../../types.js';
@@ -16,6 +16,19 @@ import { theme } from '../../semantic-colors.js';
1616
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
1717
import { useConfig } from '../../contexts/ConfigContext.js';
1818
import { useVerboseMode } from '../../contexts/VerboseModeContext.js';
19+
import type { AgentResultDisplay } from '@qwen-code/qwen-code-core';
20+
21+
function isAgentWithPendingConfirmation(
22+
rd: IndividualToolCallDisplay['resultDisplay'],
23+
): rd is AgentResultDisplay {
24+
return (
25+
typeof rd === 'object' &&
26+
rd !== null &&
27+
'type' in rd &&
28+
(rd as AgentResultDisplay).type === 'task_execution' &&
29+
(rd as AgentResultDisplay).pendingConfirmation !== undefined
30+
);
31+
}
1932

2033
interface ToolGroupMessageProps {
2134
groupId: number;
@@ -60,6 +73,32 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
6073
[toolCalls],
6174
);
6275

76+
// Determine which subagent tools currently have a pending confirmation.
77+
// Must be called unconditionally (Rules of Hooks) — before any early return.
78+
const subagentsAwaitingApproval = useMemo(
79+
() =>
80+
toolCalls.filter((tc) =>
81+
isAgentWithPendingConfirmation(tc.resultDisplay),
82+
),
83+
[toolCalls],
84+
);
85+
86+
// "First-come, first-served" focus lock: once a subagent's confirmation
87+
// appears, it keeps keyboard focus until the user resolves it. Only then
88+
// does focus move to the next pending subagent. This prevents the jarring
89+
// experience of focus jumping away while the user is mid-selection.
90+
const focusedSubagentRef = useRef<string | null>(null);
91+
92+
const stillPending = subagentsAwaitingApproval.some(
93+
(tc) => tc.callId === focusedSubagentRef.current,
94+
);
95+
if (!stillPending) {
96+
// Release stale lock and promote the next pending subagent (if any).
97+
focusedSubagentRef.current = subagentsAwaitingApproval[0]?.callId ?? null;
98+
}
99+
100+
const focusedSubagentCallId = focusedSubagentRef.current;
101+
63102
// Compact mode: entire group → single line summary
64103
// Force-expand when: user must interact (Confirming), tool errored,
65104
// shell is focused, or user-initiated
@@ -133,6 +172,19 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
133172
>
134173
{toolCalls.map((tool) => {
135174
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
175+
// A subagent's inline confirmation should only receive keyboard focus
176+
// when (1) there is no direct tool-level confirmation active, and
177+
// (2) this tool currently holds the focus lock.
178+
const isSubagentFocused =
179+
isFocused &&
180+
!toolAwaitingApproval &&
181+
focusedSubagentCallId === tool.callId;
182+
// Show the waiting indicator only when this subagent genuinely has a
183+
// pending confirmation AND another subagent holds the focus lock.
184+
const isWaitingForOtherApproval =
185+
isAgentWithPendingConfirmation(tool.resultDisplay) &&
186+
focusedSubagentCallId !== null &&
187+
focusedSubagentCallId !== tool.callId;
136188
return (
137189
<Box key={tool.callId} flexDirection="column" minHeight={1}>
138190
<Box flexDirection="row" alignItems="center">
@@ -155,6 +207,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
155207
tool.status === ToolCallStatus.Confirming ||
156208
tool.status === ToolCallStatus.Error
157209
}
210+
isFocused={isSubagentFocused}
211+
isWaitingForOtherApproval={isWaitingForOtherApproval}
158212
/>
159213
</Box>
160214
{tool.status === ToolCallStatus.Confirming &&

packages/cli/src/ui/components/messages/ToolMessage.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,23 @@ const SubagentExecutionRenderer: React.FC<{
173173
availableHeight?: number;
174174
childWidth: number;
175175
config: Config;
176-
}> = ({ data, availableHeight, childWidth, config }) => (
176+
isFocused?: boolean;
177+
isWaitingForOtherApproval?: boolean;
178+
}> = ({
179+
data,
180+
availableHeight,
181+
childWidth,
182+
config,
183+
isFocused,
184+
isWaitingForOtherApproval,
185+
}) => (
177186
<AgentExecutionDisplay
178187
data={data}
179188
availableHeight={availableHeight}
180189
childWidth={childWidth}
181190
config={config}
191+
isFocused={isFocused}
192+
isWaitingForOtherApproval={isWaitingForOtherApproval}
182193
/>
183194
);
184195

@@ -249,6 +260,10 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
249260
embeddedShellFocused?: boolean;
250261
config?: Config;
251262
forceShowResult?: boolean;
263+
/** Whether this tool's subagent confirmation prompt should respond to keyboard input. */
264+
isFocused?: boolean;
265+
/** Whether another subagent's approval currently holds the focus lock, blocking this one. */
266+
isWaitingForOtherApproval?: boolean;
252267
}
253268

254269
export const ToolMessage: React.FC<ToolMessageProps> = ({
@@ -265,6 +280,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
265280
ptyId,
266281
config,
267282
forceShowResult,
283+
isFocused,
284+
isWaitingForOtherApproval,
268285
}) => {
269286
const settings = useSettings();
270287
const isThisShellFocused =
@@ -370,6 +387,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
370387
availableHeight={availableHeight}
371388
childWidth={innerWidth}
372389
config={config}
390+
isFocused={isFocused}
391+
isWaitingForOtherApproval={isWaitingForOtherApproval}
373392
/>
374393
)}
375394
{effectiveDisplayRenderer.type === 'diff' && (

packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export interface AgentExecutionDisplayProps {
2424
availableHeight?: number;
2525
childWidth: number;
2626
config: Config;
27+
/** Whether this display's confirmation prompt should respond to keyboard input. */
28+
isFocused?: boolean;
29+
/** Whether another subagent's approval currently holds the focus lock, blocking this one. */
30+
isWaitingForOtherApproval?: boolean;
2731
}
2832

2933
const getStatusColor = (
@@ -78,6 +82,8 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
7882
availableHeight,
7983
childWidth,
8084
config,
85+
isFocused = true,
86+
isWaitingForOtherApproval = false,
8187
}) => {
8288
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('compact');
8389

@@ -168,9 +174,16 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
168174
{/* Inline approval prompt when awaiting confirmation */}
169175
{data.pendingConfirmation && (
170176
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
177+
{isWaitingForOtherApproval && (
178+
<Box marginBottom={0}>
179+
<Text color={theme.text.secondary} dimColor>
180+
⏳ Waiting for other approval...
181+
</Text>
182+
</Box>
183+
)}
171184
<ToolConfirmationMessage
172185
confirmationDetails={data.pendingConfirmation}
173-
isFocused={true}
186+
isFocused={isFocused}
174187
availableTerminalHeight={availableHeight}
175188
contentWidth={childWidth - 4}
176189
compactMode={true}
@@ -237,10 +250,17 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
237250
{/* Inline approval prompt when awaiting confirmation */}
238251
{data.pendingConfirmation && (
239252
<Box flexDirection="column">
253+
{isWaitingForOtherApproval && (
254+
<Box marginBottom={0}>
255+
<Text color={theme.text.secondary} dimColor>
256+
⏳ Waiting for other approval...
257+
</Text>
258+
</Box>
259+
)}
240260
<ToolConfirmationMessage
241261
confirmationDetails={data.pendingConfirmation}
242262
config={config}
243-
isFocused={true}
263+
isFocused={isFocused}
244264
availableTerminalHeight={availableHeight}
245265
contentWidth={childWidth - 4}
246266
compactMode={true}

0 commit comments

Comments
 (0)