Fix agent input focus scoping

This commit is contained in:
Mohamed Boudra
2026-03-24 23:41:41 +07:00
parent ac991e064c
commit ad565c2555
6 changed files with 51 additions and 12 deletions
@@ -4,7 +4,6 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream"; import { generateMessageId, type StreamItem } from "@/types/stream";
import { import {
@@ -59,6 +58,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
interface AgentInputAreaProps { interface AgentInputAreaProps {
agentId: string; agentId: string;
serverId: string; serverId: string;
isInputActive: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>; onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** Externally controlled loading state. When true, disables the submit button. */ /** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
@@ -91,6 +91,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({ export function AgentInputArea({
agentId, agentId,
serverId, serverId,
isInputActive,
onSubmitMessage, onSubmitMessage,
isSubmitLoading = false, isSubmitLoading = false,
blurOnSubmit = false, blurOnSubmit = false,
@@ -112,8 +113,6 @@ export function AgentInputArea({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg; const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isScreenFocused = useIsFocused();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -423,7 +422,7 @@ export function AgentInputArea({
const handleKeyboardAction = useCallback( const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => { (action: KeyboardActionDefinition): boolean => {
if (!isScreenFocused) { if (!isInputActive) {
return false; return false;
} }
@@ -459,7 +458,7 @@ export function AgentInputArea({
return false; return false;
} }
}, },
[isScreenFocused], [isInputActive],
); );
useKeyboardActionHandler({ useKeyboardActionHandler({
@@ -471,9 +470,9 @@ export function AgentInputArea({
"message-input.voice-toggle", "message-input.voice-toggle",
"message-input.voice-mute-toggle", "message-input.voice-mute-toggle",
], ],
enabled: isScreenFocused, enabled: isInputActive,
priority: isMessageInputFocused ? 200 : 100, priority: isMessageInputFocused ? 200 : 100,
isActive: () => isScreenFocused, isActive: () => isInputActive,
handle: handleKeyboardAction, handle: handleKeyboardAction,
}); });
@@ -715,7 +714,7 @@ export function AgentInputArea({
autoFocus={autoFocus && isDesktopWebBreakpoint} autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`} autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading} disabled={isSubmitLoading}
isScreenFocused={isScreenFocused} isInputActive={isInputActive}
leftContent={leftContent} leftContent={leftContent}
rightContent={rightContent} rightContent={rightContent}
voiceServerId={serverId} voiceServerId={serverId}
@@ -66,8 +66,8 @@ export interface MessageInputProps {
autoFocus?: boolean; autoFocus?: boolean;
autoFocusKey?: string; autoFocusKey?: string;
disabled?: boolean; disabled?: boolean;
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */ /** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
isScreenFocused?: boolean; isInputActive?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */ /** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode; leftContent?: React.ReactNode;
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
@@ -190,7 +190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
autoFocus = false, autoFocus = false,
autoFocusKey, autoFocusKey,
disabled = false, disabled = false,
isScreenFocused = true, isInputActive = true,
leftContent, leftContent,
rightContent, rightContent,
voiceServerId, voiceServerId,
@@ -387,7 +387,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError, onError: handleDictationError,
canStart: canStartDictation, canStart: canStartDictation,
canConfirm: canConfirmDictation, canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isScreenFocused }, autoStopWhenHidden: { isVisible: isInputActive },
enableDuration: true, enableDuration: true,
}); });
@@ -89,6 +89,43 @@ describe("keyboard-action-dispatcher", () => {
expect(handle).toHaveBeenCalledTimes(1); 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", () => { it("tries lower-priority handlers when a higher one does not consume the action", () => {
const calls: string[] = []; const calls: string[] = [];
const action: KeyboardActionDefinition = { const action: KeyboardActionDefinition = {
+1
View File
@@ -807,6 +807,7 @@ function AgentPanelBody({
<AgentInputArea <AgentInputArea
agentId={agentId} agentId={agentId}
serverId={serverId} serverId={serverId}
isInputActive={isPaneFocused}
value={agentInputDraft.text} value={agentInputDraft.text}
onChangeText={agentInputDraft.setText} onChangeText={agentInputDraft.setText}
images={agentInputDraft.images} images={agentInputDraft.images}
@@ -1219,6 +1219,7 @@ function DraftAgentScreenContent({
<AgentInputArea <AgentInputArea
agentId={draftAgentIdRef.current} agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""} serverId={selectedServerId ?? ""}
isInputActive={isFocused}
onSubmitMessage={handleCreateFromInput} onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting} isSubmitLoading={isSubmitting}
blurOnSubmit={true} blurOnSubmit={true}
@@ -268,6 +268,7 @@ export function WorkspaceDraftAgentTab({
<AgentInputArea <AgentInputArea
agentId={tabId} agentId={tabId}
serverId={serverId} serverId={serverId}
isInputActive={isPaneFocused}
onSubmitMessage={handleCreateFromInput} onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting} isSubmitLoading={isSubmitting}
blurOnSubmit={true} blurOnSubmit={true}