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.
This commit is contained in:
Jaayden Halko
2026-05-22 16:36:27 +01:00
committed by GitHub
parent 0d9718e217
commit ef3f95a7af
4 changed files with 305 additions and 104 deletions
+33 -17
View File
@@ -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;
}
}
}
@@ -543,61 +543,129 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
);
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<number>();
const timeoutIDs = new Set<ReturnType<typeof setTimeout>>();
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]);
@@ -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 (
<div
data-testid="mobile-frame"
style={{
paddingBottom: composerHeight,
paddingBottom: MOBILE_COMPOSER_HEIGHT,
height: "100vh",
position: "relative",
}}
@@ -323,12 +351,20 @@ const MobileDecorator: Decorator = (Story) => {
width: "calc(100vw - 2rem)",
}}
>
<Story />
{children}
</div>
</div>
);
};
// 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) => (
<MobileFrame>
<Story />
</MobileFrame>
);
// 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<HTMLElement>("*")),
];
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",
);
}
},
};
@@ -83,7 +83,7 @@ export const PersonalSkillsTriggerMenu = ({
<PopoverContent
align="start"
side="bottom"
className="w-80 p-1 mobile-full-width-dropdown mobile-full-width-dropdown-above-composer"
className="w-80 overflow-hidden p-1 mobile-full-width-dropdown mobile-full-width-dropdown-above-composer"
onMouseDown={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
@@ -94,7 +94,7 @@ export const PersonalSkillsTriggerMenu = ({
onValueChange={handleHighlightedValueChange}
value={skills[selectedIndex]?.name ?? ""}
>
<CommandList className="max-h-72 border-t-0">
<CommandList className="max-h-72 border-t-0 mobile-full-width-dropdown-scroll-area">
{isLoading ? (
<CommandItem value="loading" disabled>
Loading personal skills...