From dc13d9a7d09ad6a76fdedb26660f0d511ddb0bcc Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 17 Feb 2026 18:00:48 +0000 Subject: [PATCH] fix(ui): avoid mobile prompt focus on switch Stops auto-focusing the prompt on phone session switches and scopes type-to-focus to the active visible prompt, disabling it on coarse pointers. --- packages/ui/src/App.tsx | 27 +++++++++++++- .../components/instance/instance-shell2.tsx | 1 + packages/ui/src/components/prompt-input.tsx | 36 ++++++++++++++----- .../ui/src/components/prompt-input/types.ts | 3 ++ .../src/components/session/session-view.tsx | 27 ++++++++------ .../ui/src/styles/messaging/prompt-input.css | 32 +++++++++++++++-- 6 files changed, 103 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index d19a5da3..64b40bae 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -143,6 +143,31 @@ const App: Component = () => { onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState)) }) + onMount(() => { + if (typeof window === "undefined") return + const vv = window.visualViewport + if (!vv) return + + const updateKeyboardOffset = () => { + // visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset. + const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop) + document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`) + } + + const schedule = () => requestAnimationFrame(updateKeyboardOffset) + schedule() + vv.addEventListener("resize", schedule) + vv.addEventListener("scroll", schedule) + window.addEventListener("orientationchange", schedule) + + onCleanup(() => { + vv.removeEventListener("resize", schedule) + vv.removeEventListener("scroll", schedule) + window.removeEventListener("orientationchange", schedule) + document.documentElement.style.removeProperty("--keyboard-offset") + }) + }) + // If the user exits browser fullscreen via browser UI, restore chrome. let lastBrowserFullscreen = false createEffect(() => { @@ -471,7 +496,7 @@ const App: Component = () => { -
+
= (props) => { instanceId={props.instance.id} instanceFolder={props.instance.folder} escapeInDebounce={props.escapeInDebounce} + isPhoneLayout={isPhoneLayout()} showSidebarToggle={showEmbeddedSidebarToggle()} onSidebarToggle={() => setLeftOpen(true)} forceCompactStatusLayout={showEmbeddedSidebarToggle()} diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 3277ac78..f4f834c1 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -176,15 +176,26 @@ export default function PromptInput(props: PromptInputProps) { ), ) - onMount(() => { + const isCoarsePointer = () => { + if (typeof window === "undefined") return false + return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches) + } + + createEffect(() => { + // Scope global "type-to-focus" behavior to the active, visible prompt only. + if (typeof document === "undefined") return + if (isCoarsePointer()) return + if (props.isActive === false) return + if (props.disabled) return + const handleGlobalKeyDown = (e: KeyboardEvent) => { - const activeElement = document.activeElement as HTMLElement + const activeElement = document.activeElement as HTMLElement | null const isInputElement = activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA" || activeElement?.tagName === "SELECT" || - activeElement?.isContentEditable + Boolean(activeElement?.isContentEditable) if (isInputElement) return @@ -192,16 +203,25 @@ export default function PromptInput(props: PromptInputProps) { if (isModifierKey) return const isSpecialKey = - e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete" + e.key === "Tab" || + e.key === "Enter" || + e.key.startsWith("Arrow") || + e.key === "Backspace" || + e.key === "Delete" if (isSpecialKey) return - if (e.key.length === 1 && textareaRef && !props.disabled) { - textareaRef.focus() + const textarea = textareaRef + if (!textarea || textarea.disabled) return + + // In session cache mode inactive panes are display:none; avoid stealing focus. + if (textarea.offsetParent === null) return + + if (e.key.length === 1) { + textarea.focus() } } document.addEventListener("keydown", handleGlobalKeyDown) - onCleanup(() => { document.removeEventListener("keydown", handleGlobalKeyDown) }) @@ -435,7 +455,7 @@ export default function PromptInput(props: PromptInputProps) { onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} disabled={props.disabled} - rows={expandState() === "expanded" ? 15 : 4} + rows={expandState() === "expanded" ? 15 : 3} spellcheck={false} autocorrect="off" autoCapitalize="off" diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts index 54757793..b4dc44c9 100644 --- a/packages/ui/src/components/prompt-input/types.ts +++ b/packages/ui/src/components/prompt-input/types.ts @@ -17,6 +17,9 @@ export interface PromptInputProps { instanceId: string instanceFolder: string sessionId: string + + // Used to scope global "type-to-focus" behavior. + isActive?: boolean onSend: (prompt: string, attachments: Attachment[]) => Promise onRunShell?: (command: string) => Promise disabled?: boolean diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index e0de2457..484546e3 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -28,6 +28,7 @@ interface SessionViewProps { instanceId: string instanceFolder: string escapeInDebounce: boolean + isPhoneLayout?: boolean showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean @@ -76,6 +77,9 @@ export const SessionView: Component = (props) => { (isActive) => { if (!isActive) return + // On phones, focusing the prompt on session switch is disruptive (it raises the OSK). + if (props.isPhoneLayout) return + // Don't steal focus from other inputs (command palette, dialogs, selectors, etc.) if (typeof document === "undefined") return const activeEl = document.activeElement as HTMLElement | null @@ -314,17 +318,18 @@ export const SessionView: Component = (props) => { + instanceId={props.instanceId} + instanceFolder={props.instanceFolder} + sessionId={activeSession.id} + isActive={props.isActive} + onSend={handleSendMessage} + onRunShell={handleRunShell} + escapeInDebounce={props.escapeInDebounce} + isSessionBusy={sessionBusy()} + disabled={sessionNeedsInput()} + onAbortSession={handleAbortSession} + registerPromptInputApi={registerPromptInputApi} + />
) }} diff --git a/packages/ui/src/styles/messaging/prompt-input.css b/packages/ui/src/styles/messaging/prompt-input.css index 4797c241..8cbfb796 100644 --- a/packages/ui/src/styles/messaging/prompt-input.css +++ b/packages/ui/src/styles/messaging/prompt-input.css @@ -295,7 +295,33 @@ } .prompt-input { - padding-bottom: 1.5rem; + /* Prevent iOS Safari input zoom + keep input compact. */ + font-size: 16px; + padding-bottom: 0.75rem; + } +} + +@media (max-width: 1279px) { + :root { + --prompt-input-compact-height: 104px; + } + + .prompt-input-wrapper { + min-height: var(--prompt-input-compact-height); + } + + .prompt-input-field-container { + min-height: var(--prompt-input-compact-height); + height: var(--prompt-input-compact-height); + } + + .prompt-input-field { + height: var(--prompt-input-compact-height); + } + + .prompt-input-field-container.is-expanded, + .prompt-input-field.is-expanded { + height: auto; } } @@ -307,9 +333,9 @@ @media (max-width: 640px) { .prompt-input { - min-height: 64px; + min-height: 0; padding: 0.5rem 0.75rem; - padding-bottom: 2.25rem; + padding-bottom: 0.75rem; } .prompt-input-wrapper {