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>
This commit is contained in:
pikachu 2026-04-09 14:24:59 +08:00 committed by GitHub
parent 44c596cd14
commit 3e8b3c688f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 97 additions and 4 deletions

View file

@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { Box } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
@ -16,6 +16,19 @@ import { theme } from '../../semantic-colors.js';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { useVerboseMode } from '../../contexts/VerboseModeContext.js';
import type { AgentResultDisplay } from '@qwen-code/qwen-code-core';
function isAgentWithPendingConfirmation(
rd: IndividualToolCallDisplay['resultDisplay'],
): rd is AgentResultDisplay {
return (
typeof rd === 'object' &&
rd !== null &&
'type' in rd &&
(rd as AgentResultDisplay).type === 'task_execution' &&
(rd as AgentResultDisplay).pendingConfirmation !== undefined
);
}
interface ToolGroupMessageProps {
groupId: number;
@ -60,6 +73,32 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
[toolCalls],
);
// Determine which subagent tools currently have a pending confirmation.
// Must be called unconditionally (Rules of Hooks) — before any early return.
const subagentsAwaitingApproval = useMemo(
() =>
toolCalls.filter((tc) =>
isAgentWithPendingConfirmation(tc.resultDisplay),
),
[toolCalls],
);
// "First-come, first-served" focus lock: once a subagent's confirmation
// appears, it keeps keyboard focus until the user resolves it. Only then
// does focus move to the next pending subagent. This prevents the jarring
// experience of focus jumping away while the user is mid-selection.
const focusedSubagentRef = useRef<string | null>(null);
const stillPending = subagentsAwaitingApproval.some(
(tc) => tc.callId === focusedSubagentRef.current,
);
if (!stillPending) {
// Release stale lock and promote the next pending subagent (if any).
focusedSubagentRef.current = subagentsAwaitingApproval[0]?.callId ?? null;
}
const focusedSubagentCallId = focusedSubagentRef.current;
// Compact mode: entire group → single line summary
// Force-expand when: user must interact (Confirming), tool errored,
// shell is focused, or user-initiated
@ -133,6 +172,19 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
>
{toolCalls.map((tool) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
// A subagent's inline confirmation should only receive keyboard focus
// when (1) there is no direct tool-level confirmation active, and
// (2) this tool currently holds the focus lock.
const isSubagentFocused =
isFocused &&
!toolAwaitingApproval &&
focusedSubagentCallId === tool.callId;
// Show the waiting indicator only when this subagent genuinely has a
// pending confirmation AND another subagent holds the focus lock.
const isWaitingForOtherApproval =
isAgentWithPendingConfirmation(tool.resultDisplay) &&
focusedSubagentCallId !== null &&
focusedSubagentCallId !== tool.callId;
return (
<Box key={tool.callId} flexDirection="column" minHeight={1}>
<Box flexDirection="row" alignItems="center">
@ -155,6 +207,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
tool.status === ToolCallStatus.Confirming ||
tool.status === ToolCallStatus.Error
}
isFocused={isSubagentFocused}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&

View file

@ -173,12 +173,23 @@ const SubagentExecutionRenderer: React.FC<{
availableHeight?: number;
childWidth: number;
config: Config;
}> = ({ data, availableHeight, childWidth, config }) => (
isFocused?: boolean;
isWaitingForOtherApproval?: boolean;
}> = ({
data,
availableHeight,
childWidth,
config,
isFocused,
isWaitingForOtherApproval,
}) => (
<AgentExecutionDisplay
data={data}
availableHeight={availableHeight}
childWidth={childWidth}
config={config}
isFocused={isFocused}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
);
@ -249,6 +260,10 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
embeddedShellFocused?: boolean;
config?: Config;
forceShowResult?: boolean;
/** Whether this tool's subagent confirmation prompt should respond to keyboard input. */
isFocused?: boolean;
/** Whether another subagent's approval currently holds the focus lock, blocking this one. */
isWaitingForOtherApproval?: boolean;
}
export const ToolMessage: React.FC<ToolMessageProps> = ({
@ -265,6 +280,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
ptyId,
config,
forceShowResult,
isFocused,
isWaitingForOtherApproval,
}) => {
const settings = useSettings();
const isThisShellFocused =
@ -370,6 +387,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
availableHeight={availableHeight}
childWidth={innerWidth}
config={config}
isFocused={isFocused}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
)}
{effectiveDisplayRenderer.type === 'diff' && (

View file

@ -24,6 +24,10 @@ export interface AgentExecutionDisplayProps {
availableHeight?: number;
childWidth: number;
config: Config;
/** Whether this display's confirmation prompt should respond to keyboard input. */
isFocused?: boolean;
/** Whether another subagent's approval currently holds the focus lock, blocking this one. */
isWaitingForOtherApproval?: boolean;
}
const getStatusColor = (
@ -78,6 +82,8 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
availableHeight,
childWidth,
config,
isFocused = true,
isWaitingForOtherApproval = false,
}) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('compact');
@ -168,9 +174,16 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
{isWaitingForOtherApproval && (
<Box marginBottom={0}>
<Text color={theme.text.secondary} dimColor>
Waiting for other approval...
</Text>
</Box>
)}
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={true}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 4}
compactMode={true}
@ -237,10 +250,17 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column">
{isWaitingForOtherApproval && (
<Box marginBottom={0}>
<Text color={theme.text.secondary} dimColor>
Waiting for other approval...
</Text>
</Box>
)}
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
config={config}
isFocused={true}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 4}
compactMode={true}