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))
})
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 = () => {
</div>
</Dialog.Portal>
</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
when={!hasInstances()}
fallback={

View File

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

View File

@@ -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<void>
onRunShell?: (command: string) => Promise<void>
disabled?: boolean

View File

@@ -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<SessionViewProps> = (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<SessionViewProps> = (props) => {
</Show>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerPromptInputApi={registerPromptInputApi}
/>
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}
/>
</div>
)
}}

View File

@@ -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 {