diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index deb87a06209..4b0f3d54894 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -142,6 +142,29 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => { }) }) +test("focused prompt stays visible after viewport shrink", async ({ page, sdk, gotoSession }) => { + await withDockSession(sdk, "e2e composer dock viewport", async (session) => { + await page.setViewportSize({ width: 390, height: 844 }) + await gotoSession(session.id) + + const prompt = page.locator(promptSelector) + await expect(prompt).toBeVisible() + + await prompt.click() + await expect(prompt).toBeFocused() + + await page.setViewportSize({ width: 390, height: 540 }) + await expect(prompt).toBeVisible() + await expect(prompt).toBeFocused() + + const viewport = page.viewportSize() + const box = await prompt.boundingBox() + expect(viewport).not.toBeNull() + expect(box).not.toBeNull() + expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual((viewport?.height ?? 0) + 1) + }) +}) + test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => { await withDockSession(sdk, "e2e composer dock question", async (session) => { await withDockSeed(sdk, session.id, async () => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 0d2718efbda..23b9cdd0b3f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -48,6 +48,7 @@ export default function Page() { const [ui, setUi] = createStore({ pendingMessage: undefined as string | undefined, scrollGesture: 0, + keyboardOffset: 0, scroll: { overflow: false, bottom: true, @@ -410,6 +411,12 @@ export default function Page() { return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable } + const isTouch = createMemo(() => { + const coarse = globalThis.window.matchMedia("(pointer: coarse)").matches + const touch = "ontouchstart" in globalThis.window || navigator.maxTouchPoints > 0 + return coarse || touch + }) + const deepActiveElement = () => { let current: Element | null = document.activeElement while (current instanceof HTMLElement && current.shadowRoot?.activeElement) { @@ -455,6 +462,39 @@ export default function Page() { } } + const promptFocused = () => { + const active = deepActiveElement() + if (!active) return false + if (!inputRef) return false + return active === inputRef || inputRef.contains(active) + } + + const clearKeyboardOffset = () => { + if (ui.keyboardOffset === 0) return + setUi("keyboardOffset", 0) + } + + const updateKeyboardOffset = () => { + if (!isTouch()) { + clearKeyboardOffset() + return + } + if (!promptFocused()) { + clearKeyboardOffset() + return + } + + const viewport = window.visualViewport + if (!viewport) { + clearKeyboardOffset() + return + } + + const overlap = Math.max(0, Math.round(window.innerHeight - (viewport.height + viewport.offsetTop))) + if (ui.keyboardOffset === overlap) return + setUi("keyboardOffset", overlap) + } + const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) const openedTabs = createMemo(() => tabs() @@ -1017,7 +1057,34 @@ export default function Page() { }) onMount(() => { + let raf: number | undefined + const queueKeyboardOffset = () => { + if (raf !== undefined) cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => { + raf = undefined + updateKeyboardOffset() + }) + } + document.addEventListener("keydown", handleKeyDown) + document.addEventListener("focusin", queueKeyboardOffset, true) + document.addEventListener("focusout", queueKeyboardOffset, true) + + const viewport = window.visualViewport + viewport?.addEventListener("resize", queueKeyboardOffset) + viewport?.addEventListener("scroll", queueKeyboardOffset) + window.addEventListener("resize", queueKeyboardOffset) + queueKeyboardOffset() + + onCleanup(() => { + document.removeEventListener("focusin", queueKeyboardOffset, true) + document.removeEventListener("focusout", queueKeyboardOffset, true) + viewport?.removeEventListener("resize", queueKeyboardOffset) + viewport?.removeEventListener("scroll", queueKeyboardOffset) + window.removeEventListener("resize", queueKeyboardOffset) + if (raf !== undefined) cancelAnimationFrame(raf) + clearKeyboardOffset() + }) }) onCleanup(() => { @@ -1028,7 +1095,10 @@ export default function Page() { }) return ( -
+