From ad565c2555d6d1a1bb8bb63ad181cf66b22c6d62 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Mar 2026 23:41:41 +0700 Subject: [PATCH] Fix agent input focus scoping --- .../app/src/components/agent-input-area.tsx | 15 ++++---- packages/app/src/components/message-input.tsx | 8 ++-- .../keyboard-action-dispatcher.test.ts | 37 +++++++++++++++++++ packages/app/src/panels/agent-panel.tsx | 1 + .../src/screens/agent/draft-agent-screen.tsx | 1 + .../workspace/workspace-draft-agent-tab.tsx | 1 + 6 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 405d91825..f79f10029 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -4,7 +4,6 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; import Animated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useIsFocused } from "@react-navigation/native"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { generateMessageId, type StreamItem } from "@/types/stream"; import { @@ -59,6 +58,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA interface AgentInputAreaProps { agentId: string; serverId: string; + isInputActive: boolean; onSubmitMessage?: (payload: MessagePayload) => Promise; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; @@ -91,6 +91,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands"; export function AgentInputArea({ agentId, serverId, + isInputActive, onSubmitMessage, isSubmitLoading = false, blurOnSubmit = false, @@ -112,8 +113,6 @@ export function AgentInputArea({ const { theme } = useUnistyles(); const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg; const insets = useSafeAreaInsets(); - const isScreenFocused = useIsFocused(); - const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); @@ -423,7 +422,7 @@ export function AgentInputArea({ const handleKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { - if (!isScreenFocused) { + if (!isInputActive) { return false; } @@ -459,7 +458,7 @@ export function AgentInputArea({ return false; } }, - [isScreenFocused], + [isInputActive], ); useKeyboardActionHandler({ @@ -471,9 +470,9 @@ export function AgentInputArea({ "message-input.voice-toggle", "message-input.voice-mute-toggle", ], - enabled: isScreenFocused, + enabled: isInputActive, priority: isMessageInputFocused ? 200 : 100, - isActive: () => isScreenFocused, + isActive: () => isInputActive, handle: handleKeyboardAction, }); @@ -715,7 +714,7 @@ export function AgentInputArea({ autoFocus={autoFocus && isDesktopWebBreakpoint} autoFocusKey={`${serverId}:${agentId}`} disabled={isSubmitLoading} - isScreenFocused={isScreenFocused} + isInputActive={isInputActive} leftContent={leftContent} rightContent={rightContent} voiceServerId={serverId} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 8519126b8..9a5cf16ce 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -66,8 +66,8 @@ export interface MessageInputProps { autoFocus?: boolean; autoFocusKey?: string; disabled?: boolean; - /** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */ - isScreenFocused?: boolean; + /** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */ + isInputActive?: boolean; /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ leftContent?: React.ReactNode; /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ @@ -190,7 +190,7 @@ export const MessageInput = forwardRef(funct autoFocus = false, autoFocusKey, disabled = false, - isScreenFocused = true, + isInputActive = true, leftContent, rightContent, voiceServerId, @@ -387,7 +387,7 @@ export const MessageInput = forwardRef(funct onError: handleDictationError, canStart: canStartDictation, canConfirm: canConfirmDictation, - autoStopWhenHidden: { isVisible: isScreenFocused }, + autoStopWhenHidden: { isVisible: isInputActive }, enableDuration: true, }); diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.test.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.test.ts index b9c90ac79..0f377ea50 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.test.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.test.ts @@ -89,6 +89,43 @@ describe("keyboard-action-dispatcher", () => { expect(handle).toHaveBeenCalledTimes(1); }); + it("dispatches to the active mounted tab when a newer hidden tab is inactive", () => { + const calls: string[] = []; + const action: KeyboardActionDefinition = { + id: "message-input.dictation-toggle", + scope: "message-input", + }; + + dispatcher.registerHandler({ + handlerId: "visible-tab", + actions: [action.id], + enabled: true, + priority: 100, + isActive: () => true, + handle: () => { + calls.push("visible-tab"); + return true; + }, + }); + + dispatcher.registerHandler({ + handlerId: "hidden-tab", + actions: [action.id], + enabled: true, + priority: 100, + isActive: () => false, + handle: () => { + calls.push("hidden-tab"); + return true; + }, + }); + + const handled = dispatcher.dispatch(action); + + expect(handled).toBe(true); + expect(calls).toEqual(["visible-tab"]); + }); + it("tries lower-priority handlers when a higher one does not consume the action", () => { const calls: string[] = []; const action: KeyboardActionDefinition = { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index c32116953..9a5f98123 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -807,6 +807,7 @@ function AgentPanelBody({