diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 56292bde9..af5fa3688 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -406,12 +406,41 @@ export function AgentInputArea({ if (!req) return; if (req.agentKey !== `${serverId}:${agentId}`) return; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - messageInputRef.current?.focus(); + let cancelled = false; + const deadlineMs = Date.now() + 1500; + + const tryFocus = () => { + if (cancelled) return; + const ref = messageInputRef.current; + ref?.focus(); + + const el = ref?.getNativeElement?.() ?? null; + const active = typeof document !== "undefined" ? document.activeElement : null; + const didFocus = !!el && active === el; + + if (didFocus) { clearFocusChatInputRequest(); + return; + } + + if (Date.now() >= deadlineMs) { + // Don't keep stealing focus forever; allow other interactions. + clearFocusChatInputRequest(); + return; + } + + requestAnimationFrame(() => { + requestAnimationFrame(tryFocus); }); + }; + + requestAnimationFrame(() => { + requestAnimationFrame(tryFocus); }); + + return () => { + cancelled = true; + }; }, [agentId, clearFocusChatInputRequest, focusChatInputRequest, serverId]); // Handle command selection from autocomplete diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 441c6671d..afa19d242 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -62,6 +62,12 @@ export function CommandCenter() { return filtered; }, [agents, query]); + const agentKeyFromPathname = useMemo(() => { + const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/); + if (!match) return null; + return `${match[1]}:${match[2]}`; + }, [pathname]); + useEffect(() => { const prevOpen = prevOpenRef.current; prevOpenRef.current = open; @@ -72,18 +78,43 @@ export function CommandCenter() { if (prevOpen && !didNavigateRef.current) { const el = takeFocusRestoreElement(); - if (el && el.isConnected) { - // Modal unmount can steal focus; restore on next tick. + const deadlineMs = Date.now() + 1500; + const tryRestore = () => { + if (el && el.isConnected) { + try { + el.focus(); + } catch { + // ignore + } + } + + if ( + el && + typeof document !== "undefined" && + document.activeElement === el + ) { + return; + } + + if (Date.now() >= deadlineMs) { + // If we failed to restore focus to the original element (RN web can + // remount textareas), fall back to focusing the chat input for the + // current agent route. + if (agentKeyFromPathname) { + requestFocusChatInput(agentKeyFromPathname); + } + return; + } + requestAnimationFrame(() => { - requestAnimationFrame(() => { - try { - el.focus(); - } catch { - // ignore - } - }); + requestAnimationFrame(tryRestore); }); - } + }; + + // Modal unmount can steal focus; restore on next tick (and retry). + requestAnimationFrame(() => { + requestAnimationFrame(tryRestore); + }); } return; @@ -95,7 +126,7 @@ export function CommandCenter() { inputRef.current?.focus(); }, 0); return () => clearTimeout(id); - }, [open, takeFocusRestoreElement]); + }, [agentKeyFromPathname, open, requestFocusChatInput, takeFocusRestoreElement]); useEffect(() => { if (!open) return; diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index c68be2662..4b19e290e 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -72,6 +72,11 @@ export interface MessageInputProps { export interface MessageInputRef { focus: () => void; blur: () => void; + /** + * Web-only: return the underlying DOM element for focus assertions/retries. + * May return null if not mounted or on native. + */ + getNativeElement?: () => HTMLElement | null; } const MIN_INPUT_HEIGHT = 30; @@ -134,6 +139,17 @@ export const MessageInput = forwardRef( blur: () => { textInputRef.current?.blur?.(); }, + getNativeElement: () => { + if (!IS_WEB) return null; + const current = textInputRef.current as + | (TextInput & { getNativeRef?: () => unknown }) + | null; + const native = + typeof current?.getNativeRef === "function" + ? current.getNativeRef() + : current; + return native instanceof HTMLElement ? native : null; + }, })); const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const baselineInputHeightRef = useRef(null); diff --git a/packages/app/src/hooks/use-global-keyboard-nav.ts b/packages/app/src/hooks/use-global-keyboard-nav.ts index d85cc121f..2c1a06199 100644 --- a/packages/app/src/hooks/use-global-keyboard-nav.ts +++ b/packages/app/src/hooks/use-global-keyboard-nav.ts @@ -130,8 +130,16 @@ export function useGlobalKeyboardNav({ event.preventDefault(); const s = useKeyboardNavStore.getState(); if (!s.commandCenterOpen) { + const target = + event.target instanceof Element ? (event.target as Element) : null; + const targetEl = + target?.closest?.("textarea, input, [contenteditable='true']") ?? + (target instanceof HTMLElement ? target : null); const active = document.activeElement; - s.setFocusRestoreElement(active instanceof HTMLElement ? active : null); + const activeEl = active instanceof HTMLElement ? active : null; + s.setFocusRestoreElement( + (targetEl as HTMLElement | null) ?? activeEl ?? null + ); } s.setCommandCenterOpen(!s.commandCenterOpen); return;