From ef3f95a7af380e64e41717e978904db2ea30bc52 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 22 May 2026 23:36:27 +0800 Subject: [PATCH] fix(site/src): account for iOS visual viewport offset (#25619) ## Summary - Compute mobile dropdown bottom offsets in layout-viewport coordinates, matching the fixed Radix popover wrapper. - Use `visualViewport.offsetTop` to clamp the above-composer popup height when iOS WebKit pans the visual viewport for the soft keyboard. - Align mobile dropdown width/left to the chat composer and add a Storybook regression for shifted visual viewports. ## Testing - `cd site && pnpm tsc --noEmit -p .` - `cd site && pnpm test:storybook src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx` - `cd site && pnpm lint` ## Manual mobile verification Start dev mode with `./scripts/develop.sh`, open the forwarded port 8080 URL on a real iPhone in Safari and Chrome, focus the Agents chat input, type `/`, and verify the personal skills popup appears directly above the composer, stays within the visible viewport while the keyboard is open, and scrolls internally for long lists. Generated by Coder Agents. --- site/src/index.css | 50 +++-- .../AgentsPage/components/AgentChatInput.tsx | 152 +++++++++---- .../ChatMessageInput.stories.tsx | 203 ++++++++++++++---- .../PersonalSkillsTriggerMenu.tsx | 4 +- 4 files changed, 305 insertions(+), 104 deletions(-) diff --git a/site/src/index.css b/site/src/index.css index 466ff1aac0..d40fc7c818 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -21,32 +21,30 @@ Full-width mobile dropdowns. We set custom properties on `document.documentElement` from the chat input container so dropdown positions track the actual input box, not hardcoded - offsets. The values are derived from - `window.visualViewport.height` (with `window.innerHeight` as a - fallback) so the dropdowns stay on-screen when the soft - keyboard shrinks the visual viewport on mobile: + offsets. Radix popover wrappers are fixed-positioned, so + bottom offsets are measured from the layout viewport. The + above-composer max-height also accounts for the visual + viewport offset so soft-keyboard viewport panning on mobile + keeps dropdowns inside the visible area: - - `--mobile-dropdown-bottom`: distance from the visual + - `--mobile-dropdown-bottom`: distance from the layout viewport bottom to the composer's bottom edge. Used by the `-bottom` variant; the dropdown's bottom edge sits at the - composer's bottom edge (so the dropdown overlaps the - composer going upward, anchored to the composer's bottom). + composer's bottom edge. + - `--mobile-dropdown-left` and `--mobile-dropdown-width`: + composer-aligned horizontal geometry for full-width mobile + dropdowns. - `--mobile-dropdown-above-composer-bottom`: distance from - the visual viewport bottom to a point just above the - composer's top edge. Used by the `-above-composer` variant; - the dropdown's bottom edge sits a small gap above the - composer's top edge, so the dropdown does not overlap the - composer. + the layout viewport bottom to a point just above the + composer's top edge. - `--mobile-dropdown-above-composer-max-height`: maximum height the `-above-composer` variant can grow to without - extending past the visible viewport top. Stored in - visual-viewport coordinates to stay consistent with - `--mobile-dropdown-above-composer-bottom`. + extending past the visible viewport top. */ [data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown) { position: fixed !important; - left: 1rem !important; - width: calc(100vw - 2rem) !important; + left: var(--mobile-dropdown-left, 1rem) !important; + width: var(--mobile-dropdown-width, calc(100vw - 2rem)) !important; min-width: 0 !important; transform: none !important; bottom: var(--mobile-dropdown-bottom, 5rem) !important; @@ -71,12 +69,30 @@ --mobile-dropdown-above-composer-max-height, calc(100vh - var(--mobile-dropdown-above-composer-bottom, 9rem) - 1rem) ) !important; + overflow: hidden !important; } .mobile-full-width-dropdown { width: 100% !important; min-width: 0 !important; max-width: none !important; } + .mobile-full-width-dropdown-above-composer { + max-height: var( + --mobile-dropdown-above-composer-max-height, + calc(100vh - var(--mobile-dropdown-above-composer-bottom, 9rem) - 1rem) + ) !important; + overflow: hidden !important; + } + .mobile-full-width-dropdown-above-composer + .mobile-full-width-dropdown-scroll-area { + max-height: calc( + var(--mobile-dropdown-above-composer-max-height, 18rem) - + 0.5rem + ) !important; + overflow-y: auto !important; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } } } diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 62d07ccded..a84c8f6e14 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -543,61 +543,129 @@ export const AgentChatInput: FC = ({ ); useEffect(() => { if (!composerElement) return; - // Read geometry from the visual viewport when available so - // popup positions track the actual visible area on mobile. - // `window.innerHeight` reports the layout viewport, which - // does not shrink when the soft keyboard opens on iOS Safari - // and some webviews. `getBoundingClientRect()` returns - // coordinates in the visual viewport, so mixing the two - // would overestimate the bottom offset and push popups - // off-screen above the keyboard. - const viewport = window.visualViewport; + // Radix popover wrappers are fixed-positioned, so their + // inset values need to be in layout-viewport coordinates. + // The visual viewport can be offset inside the layout + // viewport when the mobile keyboard is open. Treat + // `visualViewport.offsetTop` as a clamp only when it yields + // a positive height, since mobile WebKit can report mixed + // coordinate systems while the keyboard is settling. + const viewport = globalThis.visualViewport; + const root = document.documentElement; + const fixedProbe = document.createElement("div"); + Object.assign(fixedProbe.style, { + position: "fixed", + bottom: "0", + left: "0", + width: "0", + height: "0", + pointerEvents: "none", + visibility: "hidden", + }); + document.body.appendChild(fixedProbe); + const composerGap = 8; + const viewportPadding = 16; + const minimumMenuHeight = 96; const update = () => { const rect = composerElement.getBoundingClientRect(); - const viewportHeight = viewport?.height ?? window.innerHeight; - const bottom = Math.max(0, viewportHeight - rect.bottom); - // Distance from the viewport bottom to a point just above - // the composer's top edge, with a small gap so dropdowns - // anchored "above the composer" sit slightly above the - // composer rather than touching it. - const aboveComposerBottom = Math.max(0, viewportHeight - rect.top + 8); - // Maximum height the above-composer popup can grow to - // without extending past the top of the visible viewport. - // Computed from the composer's top edge in visual-viewport - // coordinates so it stays consistent with - // `aboveComposerBottom` regardless of the keyboard state. - const aboveComposerMaxHeight = Math.max(0, rect.top - 16); - document.documentElement.style.setProperty( - "--mobile-dropdown-bottom", - `${bottom}px`, + const fixedViewportBottom = fixedProbe.getBoundingClientRect().bottom; + const visibleViewportTop = viewport?.offsetTop ?? 0; + const bottom = Math.max(0, fixedViewportBottom - rect.bottom); + // Distance from the fixed-position viewport bottom to a point + // just above the composer's top edge. + const aboveComposerBottom = Math.max( + 0, + fixedViewportBottom - rect.top + composerGap, ); - document.documentElement.style.setProperty( + const maxHeightCandidates = [ + rect.top - visibleViewportTop - composerGap - viewportPadding, + rect.top - composerGap - viewportPadding, + ].filter((height) => height > 0); + const aboveComposerMaxHeight = Math.max( + minimumMenuHeight, + maxHeightCandidates.length > 0 ? Math.min(...maxHeightCandidates) : 0, + ); + root.style.setProperty("--mobile-dropdown-bottom", `${bottom}px`); + root.style.setProperty("--mobile-dropdown-left", `${rect.left}px`); + root.style.setProperty("--mobile-dropdown-width", `${rect.width}px`); + root.style.setProperty( "--mobile-dropdown-above-composer-bottom", `${aboveComposerBottom}px`, ); - document.documentElement.style.setProperty( + root.style.setProperty( "--mobile-dropdown-above-composer-max-height", `${aboveComposerMaxHeight}px`, ); }; - update(); - const ro = new ResizeObserver(update); + const animationFrameIDs = new Set(); + const timeoutIDs = new Set>(); + const cancelScheduledUpdates = () => { + for (const id of animationFrameIDs) { + cancelAnimationFrame(id); + } + animationFrameIDs.clear(); + for (const id of timeoutIDs) { + clearTimeout(id); + } + timeoutIDs.clear(); + }; + const queueAnimationFrame = (callback: () => void) => { + const id = requestAnimationFrame(() => { + animationFrameIDs.delete(id); + callback(); + }); + animationFrameIDs.add(id); + }; + const scheduleUpdate = () => { + cancelScheduledUpdates(); + update(); + // Mobile WebKit can finish keyboard panning after focus and + // input events. Re-read geometry after the viewport settles so + // the first slash-menu render is not stuck under the composer. + queueAnimationFrame(() => { + update(); + queueAnimationFrame(update); + }); + for (const delay of [50, 150, 300]) { + const id = setTimeout(() => { + timeoutIDs.delete(id); + update(); + }, delay); + timeoutIDs.add(id); + } + }; + scheduleUpdate(); + const ro = new ResizeObserver(scheduleUpdate); ro.observe(composerElement); - window.addEventListener("resize", update); - viewport?.addEventListener("resize", update); - viewport?.addEventListener("scroll", update); + addEventListener("resize", scheduleUpdate); + addEventListener("scroll", scheduleUpdate, { passive: true }); + addEventListener("focusin", scheduleUpdate); + addEventListener("focusout", scheduleUpdate); + composerElement.addEventListener("input", scheduleUpdate); + composerElement.addEventListener("keyup", scheduleUpdate); + document.addEventListener("selectionchange", scheduleUpdate); + viewport?.addEventListener("resize", scheduleUpdate); + viewport?.addEventListener("scroll", scheduleUpdate); + viewport?.addEventListener("scrollend", scheduleUpdate); return () => { ro.disconnect(); - window.removeEventListener("resize", update); - viewport?.removeEventListener("resize", update); - viewport?.removeEventListener("scroll", update); - document.documentElement.style.removeProperty("--mobile-dropdown-bottom"); - document.documentElement.style.removeProperty( - "--mobile-dropdown-above-composer-bottom", - ); - document.documentElement.style.removeProperty( - "--mobile-dropdown-above-composer-max-height", - ); + cancelScheduledUpdates(); + removeEventListener("resize", scheduleUpdate); + removeEventListener("scroll", scheduleUpdate); + removeEventListener("focusin", scheduleUpdate); + removeEventListener("focusout", scheduleUpdate); + composerElement.removeEventListener("input", scheduleUpdate); + composerElement.removeEventListener("keyup", scheduleUpdate); + document.removeEventListener("selectionchange", scheduleUpdate); + viewport?.removeEventListener("resize", scheduleUpdate); + viewport?.removeEventListener("scroll", scheduleUpdate); + viewport?.removeEventListener("scrollend", scheduleUpdate); + fixedProbe.remove(); + root.style.removeProperty("--mobile-dropdown-bottom"); + root.style.removeProperty("--mobile-dropdown-left"); + root.style.removeProperty("--mobile-dropdown-width"); + root.style.removeProperty("--mobile-dropdown-above-composer-bottom"); + root.style.removeProperty("--mobile-dropdown-above-composer-max-height"); }; }, [composerElement]); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index c3b8d7d74f..e06a9da113 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -1,4 +1,5 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; +import { type PropsWithChildren, useEffect } from "react"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import { ChatMessageInput } from "./ChatMessageInput"; @@ -273,41 +274,68 @@ const longSkillList: TypesGen.UserSkillMetadata[] = Array.from( }), ); -// Decorator that: -// 1. Pins a fake composer to the bottom of the viewport so the popup -// has a sensible anchor. -// 2. Sets `--mobile-dropdown-above-composer-bottom` and -// `--mobile-dropdown-above-composer-max-height` directly on the -// document element to simulate what `AgentChatInput` does in -// production. We use values that place the popup just above the -// fake composer and bound its height to the space above it. -// 3. Cleans up the CSS variables and the mocked matchMedia after the -// story unmounts. -const MobileDecorator: Decorator = (Story) => { - const composerHeight = 96; // matches mobile composer min-height - const gap = 8; - const aboveComposerBottom = composerHeight + gap; - // Use the visual viewport height when available so the simulated - // max-height matches what `AgentChatInput.tsx` computes in - // production from `window.visualViewport`. - const viewportHeight = window.visualViewport?.height ?? window.innerHeight; - const aboveComposerMaxHeight = Math.max( - 0, - viewportHeight - composerHeight - 16, +const mobileDropdownProperties = [ + "--mobile-dropdown-left", + "--mobile-dropdown-width", + "--mobile-dropdown-above-composer-bottom", + "--mobile-dropdown-above-composer-max-height", +] as const; + +const MOBILE_COMPOSER_HEIGHT = 96; // matches mobile composer min-height +const MOBILE_COMPOSER_GAP = 8; +const MOBILE_VIEWPORT_PADDING = 16; +const MOBILE_MINIMUM_MENU_HEIGHT = 96; + +const setMobileDropdownGeometry = (options?: { + visualViewportOffsetTop?: number; +}) => { + const composerTop = innerHeight - MOBILE_COMPOSER_HEIGHT; + const visualViewportOffsetTop = options?.visualViewportOffsetTop ?? 0; + document.documentElement.style.setProperty("--mobile-dropdown-left", "1rem"); + document.documentElement.style.setProperty( + "--mobile-dropdown-width", + "calc(100vw - 2rem)", ); document.documentElement.style.setProperty( "--mobile-dropdown-above-composer-bottom", - `${aboveComposerBottom}px`, + `${innerHeight - composerTop + MOBILE_COMPOSER_GAP}px`, + ); + const maxHeightCandidates = [ + composerTop - + visualViewportOffsetTop - + MOBILE_COMPOSER_GAP - + MOBILE_VIEWPORT_PADDING, + composerTop - MOBILE_COMPOSER_GAP - MOBILE_VIEWPORT_PADDING, + ].filter((height) => height > 0); + const maxHeight = Math.max( + MOBILE_MINIMUM_MENU_HEIGHT, + maxHeightCandidates.length > 0 ? Math.min(...maxHeightCandidates) : 0, ); document.documentElement.style.setProperty( "--mobile-dropdown-above-composer-max-height", - `${aboveComposerMaxHeight}px`, + `${maxHeight}px`, ); + + return { composerTop, maxHeight, visualViewportOffsetTop }; +}; + +const clearMobileDropdownGeometry = () => { + for (const property of mobileDropdownProperties) { + document.documentElement.style.removeProperty(property); + } +}; + +const MobileFrame = ({ children }: PropsWithChildren) => { + useEffect(() => { + setMobileDropdownGeometry(); + return clearMobileDropdownGeometry; + }, []); + return (
{ width: "calc(100vw - 2rem)", }} > - + {children}
); }; +// Decorator that pins a fake composer to the bottom of the viewport and sets +// mobile dropdown geometry custom properties to simulate `AgentChatInput`. +const MobileDecorator: Decorator = (Story) => ( + + + +); + // Verifies the popup wrapper is positioned above the chat input on // mobile: position: fixed, full chat-input width, bottom edge at the // CSS variable, and top edge inside the visible viewport. @@ -359,12 +395,76 @@ export const MobileAboveChatInput: Story = { expect(rect.bottom).toBeLessThanOrEqual(window.innerHeight); } finally { restoreMatchMedia(); - document.documentElement.style.removeProperty( - "--mobile-dropdown-above-composer-bottom", + } + }, +}; + +// Verifies that the popup remains inside a panned visual viewport, +// which is what iOS WebKit browsers do when the soft keyboard opens. +export const MobileShiftedVisualViewport: Story = { + decorators: [MobileDecorator], + parameters: { + viewport: { defaultViewport: "mobile1" }, + chromatic: { disableSnapshot: true }, + }, + play: async ({ canvasElement }) => { + const restoreMatchMedia = mockMobileMatchMedia(); + const { composerTop, visualViewportOffsetTop } = setMobileDropdownGeometry({ + visualViewportOffsetTop: Math.min( + 160, + Math.max(0, innerHeight - MOBILE_COMPOSER_HEIGHT - 80), + ), + }); + + try { + await typeInEditor(canvasElement, "/"); + const skillItem = await findVisibleText("/reviewer"); + const wrapper = skillItem.closest( + "[data-radix-popper-content-wrapper]", + ) as HTMLElement | null; + expect(wrapper).not.toBeNull(); + if (!wrapper) return; + + const rect = wrapper.getBoundingClientRect(); + expect(rect.top).toBeGreaterThanOrEqual(visualViewportOffsetTop); + expect(rect.bottom).toBeLessThanOrEqual( + composerTop - MOBILE_COMPOSER_GAP, ); - document.documentElement.style.removeProperty( - "--mobile-dropdown-above-composer-max-height", + } finally { + restoreMatchMedia(); + } + }, +}; + +// Verifies an over-large visual viewport offset does not collapse the menu. +export const MobileOffsetTopDoesNotCollapse: Story = { + decorators: [MobileDecorator], + parameters: { + viewport: { defaultViewport: "mobile1" }, + chromatic: { disableSnapshot: true }, + }, + play: async ({ canvasElement }) => { + const restoreMatchMedia = mockMobileMatchMedia(); + const { maxHeight } = setMobileDropdownGeometry({ + visualViewportOffsetTop: innerHeight, + }); + + try { + await typeInEditor(canvasElement, "/"); + const skillItem = await findVisibleText("/reviewer"); + const wrapper = skillItem.closest( + "[data-radix-popper-content-wrapper]", + ) as HTMLElement | null; + expect(wrapper).not.toBeNull(); + if (!wrapper) return; + + expect(maxHeight).toBeGreaterThanOrEqual(MOBILE_MINIMUM_MENU_HEIGHT); + expect(Number.parseFloat(getComputedStyle(wrapper).maxHeight)).toBe( + maxHeight, ); + expect(wrapper.getBoundingClientRect().height).toBeGreaterThan(0); + } finally { + restoreMatchMedia(); } }, }; @@ -384,6 +484,17 @@ export const MobileLongListScrolls: Story = { }, play: async ({ canvasElement }) => { const restoreMatchMedia = mockMobileMatchMedia(); + setMobileDropdownGeometry({ + visualViewportOffsetTop: Math.max( + 0, + innerHeight - + MOBILE_COMPOSER_HEIGHT - + MOBILE_COMPOSER_GAP - + MOBILE_VIEWPORT_PADDING - + MOBILE_MINIMUM_MENU_HEIGHT, + ), + }); + try { await typeInEditor(canvasElement, "/"); const skillItem = await findVisibleText("/skill-0"); @@ -397,25 +508,31 @@ export const MobileLongListScrolls: Story = { expect(rect.top).toBeGreaterThanOrEqual(0); expect(rect.bottom).toBeLessThanOrEqual(window.innerHeight); - // At least one of the popup's scroll containers (wrapper or - // inner list) must be scrollable, so the user can reach items - // that overflow the available space. - const scrollables: HTMLElement[] = [ + const commandList = skillItem.closest( + "[cmdk-list]", + ) as HTMLElement | null; + expect(commandList).not.toBeNull(); + if (!commandList) return; + + const hasVisibleVerticalScrollbar = (node: HTMLElement) => { + const overflowY = getComputedStyle(node).overflowY; + return ( + (overflowY === "auto" || overflowY === "scroll") && + node.scrollHeight > node.clientHeight + ); + }; + const scrollableNodes = [ wrapper, ...Array.from(wrapper.querySelectorAll("*")), - ]; - const hasScroll = scrollables.some( - (node) => node.scrollHeight > node.clientHeight, + ].filter(hasVisibleVerticalScrollbar); + + expect(commandList.scrollHeight).toBeGreaterThan( + commandList.clientHeight, ); - expect(hasScroll).toBe(true); + expect(scrollableNodes).toHaveLength(1); + expect(scrollableNodes[0]).toBe(commandList); } finally { restoreMatchMedia(); - document.documentElement.style.removeProperty( - "--mobile-dropdown-above-composer-bottom", - ); - document.documentElement.style.removeProperty( - "--mobile-dropdown-above-composer-max-height", - ); } }, }; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx index bef9e4b033..0c8dba1394 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx @@ -83,7 +83,7 @@ export const PersonalSkillsTriggerMenu = ({ event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} @@ -94,7 +94,7 @@ export const PersonalSkillsTriggerMenu = ({ onValueChange={handleHighlightedValueChange} value={skills[selectedIndex]?.name ?? ""} > - + {isLoading ? ( Loading personal skills...