mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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:
parent
44c596cd14
commit
3e8b3c688f
3 changed files with 97 additions and 4 deletions
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue