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:
@@ -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={
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user