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.
This commit is contained in:
Shantur Rathore
2026-02-17 18:00:48 +00:00
parent 29557fba6d
commit dc13d9a7d0
6 changed files with 103 additions and 23 deletions

View File

@@ -143,6 +143,31 @@ const App: Component = () => {
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState)) 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. // If the user exits browser fullscreen via browser UI, restore chrome.
let lastBrowserFullscreen = false let lastBrowserFullscreen = false
createEffect(() => { createEffect(() => {
@@ -471,7 +496,7 @@ const App: Component = () => {
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog> </Dialog>
<div class="h-screen w-screen flex flex-col"> <div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
<Show <Show
when={!hasInstances()} when={!hasInstances()}
fallback={ fallback={

View File

@@ -823,6 +823,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
instanceId={props.instance.id} instanceId={props.instance.id}
instanceFolder={props.instance.folder} instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce} escapeInDebounce={props.escapeInDebounce}
isPhoneLayout={isPhoneLayout()}
showSidebarToggle={showEmbeddedSidebarToggle()} showSidebarToggle={showEmbeddedSidebarToggle()}
onSidebarToggle={() => setLeftOpen(true)} onSidebarToggle={() => setLeftOpen(true)}
forceCompactStatusLayout={showEmbeddedSidebarToggle()} forceCompactStatusLayout={showEmbeddedSidebarToggle()}

View File

@@ -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 handleGlobalKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement const activeElement = document.activeElement as HTMLElement | null
const isInputElement = const isInputElement =
activeElement?.tagName === "INPUT" || activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" || activeElement?.tagName === "TEXTAREA" ||
activeElement?.tagName === "SELECT" || activeElement?.tagName === "SELECT" ||
activeElement?.isContentEditable Boolean(activeElement?.isContentEditable)
if (isInputElement) return if (isInputElement) return
@@ -192,16 +203,25 @@ export default function PromptInput(props: PromptInputProps) {
if (isModifierKey) return if (isModifierKey) return
const isSpecialKey = 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 (isSpecialKey) return
if (e.key.length === 1 && textareaRef && !props.disabled) { const textarea = textareaRef
textareaRef.focus() 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) document.addEventListener("keydown", handleGlobalKeyDown)
onCleanup(() => { onCleanup(() => {
document.removeEventListener("keydown", handleGlobalKeyDown) document.removeEventListener("keydown", handleGlobalKeyDown)
}) })
@@ -435,7 +455,7 @@ export default function PromptInput(props: PromptInputProps) {
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
disabled={props.disabled} disabled={props.disabled}
rows={expandState() === "expanded" ? 15 : 4} rows={expandState() === "expanded" ? 15 : 3}
spellcheck={false} spellcheck={false}
autocorrect="off" autocorrect="off"
autoCapitalize="off" autoCapitalize="off"

View File

@@ -17,6 +17,9 @@ export interface PromptInputProps {
instanceId: string instanceId: string
instanceFolder: string instanceFolder: string
sessionId: string sessionId: string
// Used to scope global "type-to-focus" behavior.
isActive?: boolean
onSend: (prompt: string, attachments: Attachment[]) => Promise<void> onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void> onRunShell?: (command: string) => Promise<void>
disabled?: boolean disabled?: boolean

View File

@@ -28,6 +28,7 @@ interface SessionViewProps {
instanceId: string instanceId: string
instanceFolder: string instanceFolder: string
escapeInDebounce: boolean escapeInDebounce: boolean
isPhoneLayout?: boolean
showSidebarToggle?: boolean showSidebarToggle?: boolean
onSidebarToggle?: () => void onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean forceCompactStatusLayout?: boolean
@@ -76,6 +77,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
(isActive) => { (isActive) => {
if (!isActive) return 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.) // Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
if (typeof document === "undefined") return if (typeof document === "undefined") return
const activeEl = document.activeElement as HTMLElement | null const activeEl = document.activeElement as HTMLElement | null
@@ -314,17 +318,18 @@ export const SessionView: Component<SessionViewProps> = (props) => {
</Show> </Show>
<PromptInput <PromptInput
instanceId={props.instanceId} instanceId={props.instanceId}
instanceFolder={props.instanceFolder} instanceFolder={props.instanceFolder}
sessionId={activeSession.id} sessionId={activeSession.id}
onSend={handleSendMessage} isActive={props.isActive}
onRunShell={handleRunShell} onSend={handleSendMessage}
escapeInDebounce={props.escapeInDebounce} onRunShell={handleRunShell}
isSessionBusy={sessionBusy()} escapeInDebounce={props.escapeInDebounce}
disabled={sessionNeedsInput()} isSessionBusy={sessionBusy()}
onAbortSession={handleAbortSession} disabled={sessionNeedsInput()}
registerPromptInputApi={registerPromptInputApi} onAbortSession={handleAbortSession}
/> registerPromptInputApi={registerPromptInputApi}
/>
</div> </div>
) )
}} }}

View File

@@ -295,7 +295,33 @@
} }
.prompt-input { .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) { @media (max-width: 640px) {
.prompt-input { .prompt-input {
min-height: 64px; min-height: 0;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
padding-bottom: 2.25rem; padding-bottom: 0.75rem;
} }
.prompt-input-wrapper { .prompt-input-wrapper {