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...