diff --git a/docs/bug-lessons.md b/docs/bug-lessons.md index fa600ec..351b506 100644 --- a/docs/bug-lessons.md +++ b/docs/bug-lessons.md @@ -7,10 +7,17 @@ Symptom-match new bug reports against these entries before theorising. - **Symptom signature:** Model scrolling works with the iOS keyboard hidden, but gestures pan the background while the search input has the keyboard open. - **Root cause:** The dialog's Base UI scroll lock only hides document overflow on iOS. That does not contain the visual viewport when the software keyboard covers part of the layout viewport. -- **Resolution:** Use the existing Octane Aria `usePreventScroll` hook while the model picker is open. Its iOS path handles touch containment and input focus; cleanup restores page scrolling and native focus. +- **Initial mitigation:** Use the existing Octane Aria `usePreventScroll` hook while the model picker is open. Its iOS path handles touch containment and input focus; cleanup restores page scrolling and native focus. The user subsequently confirmed that the keyboard-open failure persisted with this code deployed. - **Regression signal:** A browser test selects the iOS platform path, focuses search, and reduces/pans the visual viewport without resizing the layout viewport. The current code fails touch cancellation outside the list; the test also checks that the list allows native touch scrolling and dismissal restores the lock state. This simulates the event policy, not a physical iOS keyboard. - **Prevention rule:** Resizing a desktop viewport and dispatching touch swipes does not reproduce an iOS software keyboard. Verify the platform-specific scroll lock as well as list overflow, and retain a physical-device validation step for keyboard behaviour. +## 2026-09-09 — Dismiss the iOS search keyboard when browsing model results + +- **Symptom signature:** The deployed scroll lock still permits background scrolling on iOS with the search keyboard open. The user confirmed that the same model list scrolls correctly when the keyboard is hidden, and desktop is unaffected. +- **Mitigation:** On a single-finger touch in iOS model results, move focus from the search input to the dialog. This dismisses the keyboard without closing the picker, clearing the filter, cancelling native touch scrolling, or allowing the focus trap to reopen the keyboard. Desktop and multi-touch gestures are unchanged. +- **Regression signal:** The browser test fails the keyboard-dismissal assertion before the change and verifies focus transfer, retained filtering, and the ability to focus search again. Existing wheel/touch tests cover the native list scrolling path. +- **Validation limit:** These browser tests verify interaction policy; real iOS keyboard animation and scroll handoff still require an iPhone check after deployment. Do not call a synthetic keyboard test a device reproduction. + ## 2026-09-08 — Domain fixtures inherited the publisher's production route - **Symptom signature:** Domain Workflow tests receive 403 instead of unauthenticated 401, and the domain UI test times out finding the Hostname field. diff --git a/src/components/chat-model-picker.tsx b/src/components/chat-model-picker.tsx index 1a5e87f..91e8c58 100644 --- a/src/components/chat-model-picker.tsx +++ b/src/components/chat-model-picker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "octane"; +import { useEffect, useRef, useState, type OctaneNode } from "octane"; import { usePreventScroll } from "@octanejs/aria"; import { CaretDown, X } from "@octanejs/phosphor-icons"; import { Badge } from "octane-kumo/components/badge"; @@ -116,7 +116,7 @@ export function ChatModelPicker({ } > setOpen(false)} /> - + select(null)}> Installation default @@ -166,13 +166,40 @@ export function ChatModelPicker({ ))} No models found. - + ); } +function ModelResults({ children }: { children: OctaneNode }) { + const results = useRef(null); + useEffect(() => { + const isIOS = + /iPad|iPhone|iPod/.test(navigator.platform) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + const list = results.current; + if (!isIOS || !list) return; + const dismissKeyboard = (event: TouchEvent) => { + if (event.touches.length !== 1) return; + const dialog = list.closest('[role="dialog"]'); + const active = document.activeElement; + if (active instanceof HTMLInputElement && dialog?.contains(active)) { + // Keep focus inside the modal so its focus trap cannot reopen the keyboard. + dialog.focus({ preventScroll: true }); + } + }; + list.addEventListener("touchstart", dismissKeyboard, { passive: true }); + return () => list.removeEventListener("touchstart", dismissKeyboard); + }, []); + return ( + + {children} + + ); +} + function ModelSearchInput({ onClose }: { onClose: () => void }) { const input = useRef(null); useEffect(() => { diff --git a/tests/chat-ui.test.mjs b/tests/chat-ui.test.mjs index 537d78c..315dacc 100644 --- a/tests/chat-ui.test.mjs +++ b/tests/chat-ui.test.mjs @@ -345,7 +345,8 @@ test( const picker = mobile.getByRole("dialog", { name: "Choose a model", }); - await picker.getByRole("searchbox").focus(); + const search = picker.getByRole("searchbox"); + await search.fill("@cf"); // iOS shrinks and pans the visual viewport without resizing the layout viewport. await mobile.evaluate(() => { Object.defineProperty(visualViewport, "height", { @@ -399,10 +400,55 @@ test( false, "model list retains native touch scrolling", ); - await picker - .getByRole("button", { name: "Close model picker" }) - .click(); + assert.equal( + await search.evaluate( + (input) => input === document.activeElement, + ), + false, + "touching iOS results dismisses the search keyboard", + ); + assert.equal( + await search.inputValue(), + "@cf", + "keyboard dismissal preserves filtering", + ); + assert.equal(await picker.isVisible(), true); + await search.focus(); + assert.equal( + await search.evaluate( + (input) => input === document.activeElement, + ), + true, + "search can be focused again", + ); + const touch = await context.newCDPSession(mobile); + await touch.send("Emulation.setTouchEmulationEnabled", { + enabled: true, + }); + const option = await picker + .getByRole("option") + .first() + .boundingBox(); + await touch.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [ + { + x: option.x + option.width / 2, + y: option.y + option.height / 2, + }, + ], + }); + await touch.send("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }); await picker.waitFor({ state: "hidden" }); + await touch.detach(); + assert.match( + await trigger.innerText(), + /llama-3\.3-70b/, + "a native touch still selects the model after dismissing the keyboard", + ); await until( () => mobile.evaluate(