Fix agent input focus scoping
This commit is contained in:
@@ -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 = {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user