import type { ClientContext } from '../compat/types.ts' /** Phone breakpoint — same query every phone-only effect in this plugin uses. */ const PHONE_QUERY = '(max-width: 767px)' /** The official composer slot wrapper (same marker composer.css.ts styles). */ const COMPOSER = '[data-slot="conversation.composer.bar"]' /** * T72: the model sheet's search input on DSH 0.2.0-rc.2 — a `role=searchbox` * `` inside the portaled model menu (composer.css.ts's MODEL_MENU * body form; on rc.2 the model pane portals as `role=group` with the search * row inside). The host focuses it the moment the user drills into the * model pane, which pops the phone keyboard over the sheet. */ const MODEL_MENU_ROOT = 'body > [id$="-menu"]' const MODEL_SEARCH = `${MODEL_MENU_ROOT} [role="searchbox"]` /** The focus target when the search's autofocus must be retracted: the * currently selected model row, the exact element the host's own arrow-key * navigation focuses first (model-selection-client.js moveFocus). */ const SELECTED_OPTION = '[role="menuitemradio"][aria-checked="true"]:not([disabled])' /** The host's fallback when no row is checked (filtered/empty list): the * first selectable option row. */ const ANY_OPTION = '[role="menuitemradio"]:not([disabled])' /** A tap/keystroke older than this no longer explains a focus. */ const INTENT_WINDOW_MS = 1000 /** * S9 — keep the phone keyboard down until the user asks for it. * * dsh-client-ui-conversation focuses the composer's editing host on every * sessionId change (0.1.2: `el.focus({preventScroll:true})` on the textarea; * 0.1.5: the same on the Lexical contenteditable). Sensible on desktop; on a * phone it pops the software keyboard over half the screen every time a * session opens. * * Rule: focus on the composer field survives only when the user asked for * it — a tap on the field itself or typing on a hardware keyboard. Anything * else (session-open autofocus, push-deep-link opens, the refocus side * effects of the other composer buttons — the official attach paperclip's * `keepFocus` mousedown, the slash-command toggle, send) is blurred. That * attach case is not hypothetical: 0.1.5's official paperclip refocuses the * editor on MOUSEDOWN, and while this guard was blind to the contenteditable * (2026-09-11 report) every tap on it popped the keyboard and shoved the * composer up the screen. * * Two triggers, because one is not enough: * - focusin catches the autofocus the moment it happens; * - a body MutationObserver re-runs the check after transcript swaps * (opening a session re-renders the flow but may reuse the same field, * and a focus that landed before this plugin loaded never fired focusin * for us at all). * Once focus is user-granted it stays granted until the field blurs, so * the observer never yanks a keyboard the user opened (e.g. while the agent * streams and the user pauses typing). */ export function installKeyboardGuard(ctx: ClientContext): void { ctx.effect(() => { const narrow = window.matchMedia(PHONE_QUERY) let lastIntent = 0 let granted = false // T72: the search field's own grant — separate from the composer's, // cleared when the field blurs, so a later programmatic focus (drilling // back into the pane) is retracted again. let searchGranted = false let observer: MutationObserver | null = null let frame = 0 /** The composer's editing host: the `data-composer-input` contenteditable * DSH 0.1.5 binds Lexical to, or the textarea older hosts rendered (the * attribute also sat on that textarea — both spellings match both hosts, * belt and braces). Also matches descendants of the contenteditable, as * mobile browsers frequently report inner text nodes / spans / paragraphs * as the event target or activeElement. */ const composerField = (node: unknown): HTMLElement | null => { if (!(node instanceof HTMLElement)) return null if (node.closest(COMPOSER) === null) return null const editable = node.closest('[data-composer-input]') if (editable !== null) return editable return node.tagName === 'TEXTAREA' ? node : null } /** * T72: the model sheet's search input. Unlike the composer field this * one is NOT blurred to the body — the model menu's root has an onBlur * that CLOSES the menu when the focus leaves root and menu (rc.2 model- * selection-client.js: `relatedTarget` outside both → `close()`), so * blurring would slam the sheet shut. The focus moves INSIDE the menu * instead: to the selected model row, the element the host's own * keyboard navigation focuses first. Phone keyboards have no arrow keys, * so the host's search-focus-follows-highlight logic losing focus costs * nothing there. */ const modelSearchField = (node: unknown): HTMLElement | null => { if (!(node instanceof HTMLElement)) return null if (node.getAttribute('role') !== 'searchbox') return null return node.closest(MODEL_SEARCH) } /** Retract the search autofocus WITHOUT closing the menu (see above). */ const retractSearchFocus = (field: HTMLElement): void => { const menu = field.closest(MODEL_MENU_ROOT) const selected = menu?.querySelector(SELECTED_OPTION) ?? menu?.querySelector(ANY_OPTION) if (selected !== null && selected !== undefined) selected.focus() else field.blur() } const onPointerDown = (event: PointerEvent): void => { // Only the textarea itself grants the keyboard. A tap on any other // composer control (slash-command toggle, attach, model menu, send) // must not: several of them refocus the input as a side effect, which // popped the keyboard on every command-button tap. if (composerField(event.target) !== null) { lastIntent = Date.now() granted = true } // T72: a tap ON the search field is the user asking for the keyboard // — typing to filter models must work. if (modelSearchField(event.target) !== null) searchGranted = true } const onKeyDown = (): void => { lastIntent = Date.now() if (composerField(document.activeElement) !== null) granted = true } const sweep = (): void => { // The search field is judged on its own grant, and a retracted focus // never leaves the menu (see modelSearchField — blur would close it). const search = modelSearchField(document.activeElement) if (search !== null) { if (searchGranted) return retractSearchFocus(search) return } const el = composerField(document.activeElement) if (el === null) return if (granted || Date.now() - lastIntent < INTENT_WINDOW_MS) { granted = true return } el.blur() } const onFocusIn = (event: FocusEvent): void => { if (composerField(event.target) === null && modelSearchField(event.target) === null) return sweep() } const onFocusOut = (event: FocusEvent): void => { // T72: the search field's grant dies with its focus — the next // programmatic focus is retracted again. if (modelSearchField(event.target) !== null) { searchGranted = false return } if (composerField(event.target) === null) return granted = false } const schedule = (): void => { if (observer === null || frame !== 0) return // setTimeout, not requestAnimationFrame: rAF pauses in hidden/ // backgrounded pages, where the autofocus still happens — the sweep // must run there too or the keyboard pops when the page is foregrounded. frame = window.setTimeout(() => { frame = 0 sweep() }, 100) } const attach = (): void => { if (observer !== null) return document.addEventListener('pointerdown', onPointerDown, { capture: true, passive: true }) document.addEventListener('keydown', onKeyDown, { capture: true, passive: true }) document.addEventListener('focusin', onFocusIn, true) document.addEventListener('focusout', onFocusOut, true) observer = new MutationObserver(schedule) observer.observe(document.body, { childList: true, subtree: true }) sweep() } const detach = (): void => { document.removeEventListener('pointerdown', onPointerDown, true) document.removeEventListener('keydown', onKeyDown, true) document.removeEventListener('focusin', onFocusIn, true) document.removeEventListener('focusout', onFocusOut, true) observer?.disconnect() observer = null if (frame !== 0) window.clearTimeout(frame) frame = 0 granted = false searchGranted = false } if (narrow.matches) attach() const onChange = (event: MediaQueryListEvent): void => (event.matches ? attach() : detach()) narrow.addEventListener('change', onChange) return () => { narrow.removeEventListener('change', onChange) detach() } }, 'dsh-mobile-nav: composer keyboard guard') }