fix(web): keep chat dropdown menus inside the viewport on mobile (#41)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-23 00:58:39 +08:00
committed by GitHub
parent 45b5555b57
commit e0bf1706b9
4 changed files with 157 additions and 9 deletions
+104
View File
@@ -8,6 +8,11 @@
* - the models page at 390x844 must not overflow horizontally, and text must not overlap
* (the group header's provider name used to get pushed out of the button box and overlap
* the group-level actions);
* - every chat-page dropdown menu, opened at phone widths (375/390), must keep its panel
* inside the viewport and must not shove the page sideways (the model menu used to run
* ~34px off-screen left, the skills menu ~92px off-screen right — with its autofocused
* search box then horizontally scrolling the whole draft page — and the workspace menu
* ~143px off-screen right when the ownership pills share one row);
* - the sidebar's "New chat" button has no background fill (same gray-scale style as nav items);
* - the collapsed rail shows, in product-specified order, last conversation / new chat /
* Agents / Skills / Models / Costs / Traces / Benchmark with localized (en + zh) hover
@@ -240,6 +245,105 @@ test("layout: collapsed rail — order, bilingual tooltips, last conversation",
await expect(page.getByRole("button", { name: "收起侧栏" })).toBeVisible();
});
test("layout: mobile chat dropdowns stay inside the viewport", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("penguin.lang", "en"));
await provisionAndLogin(page.request, "layoutdropdowns", P);
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
const projectId = projects.projects[0].projectId;
// Keyed models unblock the draft page (no "missing key" modal); the long-id model pushes the
// model menu's w-max width to its clamp, and the key-less one adds the show-all expander row.
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
data: {
defaultModel: { provider: "custom", modelId: "claude-4-8" },
models: [
{ provider: "custom", modelId: "claude-4-8", apiKey: "sk-mock", contextWindow: 200000 },
{ provider: "openai", modelId: "gpt-5.5", apiKey: "sk-mock2" },
{
provider: "custom",
modelId: "anthropic/claude-sonnet-4-5-thinking-preview",
apiKey: "sk-mock3",
},
{ provider: "google", modelId: "gemini-3-pro" },
],
},
});
expect(put.ok(), "put models").toBeTruthy();
const panel = page.locator("div.anim-pop.z-40");
/** Assert the one open menu panel and the page itself stay inside the viewport. */
const checkPanel = async (name) => {
await expect(panel, `${name}: menu open`).toHaveCount(1);
await page.waitForTimeout(200); // let the pop-in scale animation settle before measuring
const m = await panel.evaluate((el) => {
const r = el.getBoundingClientRect();
// A horizontally scrolled ancestor is the old failure mode: the menu's autofocused
// search box dragged the overflowing panel into view, shoving the page sideways.
let scrolled = 0;
for (let p = el.parentElement; p; p = p.parentElement) {
scrolled = Math.max(scrolled, Math.abs(p.scrollLeft));
}
return { left: r.left, right: r.right, vw: window.innerWidth, scrolled };
});
expect(m.left, `${name}: panel left edge on-screen`).toBeGreaterThanOrEqual(0);
expect(m.right, `${name}: panel right edge on-screen`).toBeLessThanOrEqual(m.vw);
expect(m.scrolled, `${name}: page not scrolled sideways`).toBe(0);
const d = await docWidths(page);
expect(d.scrollWidth, `${name}: no horizontal overflow`).toBeLessThanOrEqual(d.clientWidth);
};
const open = async (label, name) => {
await page.locator(`button[aria-label="${label}"]`).click();
await checkPanel(name);
};
const close = async () => {
await page.keyboard.press("Escape");
await expect(panel).toHaveCount(0);
};
// Draft page, both common phone widths. The two widths exercise different geometry for the
// ownership pills below the card: at 375 they wrap onto two rows (workspace pill at the row
// start), at 390 they share one row (workspace pill anchored mid-screen).
for (const vp of [
{ width: 375, height: 667 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(vp);
await page.goto(`${BASE}/chat/new`);
await page.getByPlaceholder(/Type a message/).waitFor();
// Model / thinking-level buttons stay disabled until models and the agent config load.
await expect(page.locator('button[aria-label="Choose model"]')).toBeEnabled();
await expect(page.locator('button[aria-label="Thinking level"]')).toBeEnabled();
await open("Approval mode", `approval @${vp.width}`);
await close();
await open("Skills", `skills @${vp.width}`);
await close();
await open("Thinking level", `thinking @${vp.width}`);
await close();
await open("Choose model", `model @${vp.width}`);
// Reveal the key-less remainder — the widest state of the w-max panel — and re-check.
await page.getByRole("button", { name: /without a key/ }).click();
await checkPanel(`model show-all @${vp.width}`);
await close();
await open("Choose agent", `agent @${vp.width}`);
await close();
await open("Workspace", `workspace @${vp.width}`);
await close();
}
// Session state (bottom-docked composer, menus open upward): approval + skills render there
// too and share the left-anchored geometry; still at 390x844 from the loop above.
const sess = await (
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
data: { provider: "custom", modelId: "claude-4-8" },
})
).json();
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
await page.getByPlaceholder(/Type a message/).waitFor();
await open("Approval mode", "approval @session");
await close();
await open("Skills", "skills @session");
await close();
});
test("layout: login — blank start, non-crossing traces, lang/theme controls", async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.getByRole("heading", { name: "PenguinHarness" }).waitFor();
+5 -1
View File
@@ -4,7 +4,7 @@
* z-40, overlays are z-50). menuClass controls the docking direction and width.
*/
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
export function Dropdown({
button,
@@ -12,6 +12,7 @@ export function Dropdown({
setOpen,
children,
menuClass,
menuStyle,
className,
}: {
button: ReactNode;
@@ -20,6 +21,8 @@ export function Dropdown({
children: ReactNode;
/** Panel positioning and size (default: downward, left-aligned, w-64). */
menuClass?: string;
/** Inline overrides for the panel, for values a static class cannot know (e.g. a max-width measured from the trigger's viewport offset). */
menuStyle?: CSSProperties;
/** Extra classes for the root container (e.g. flex-1 in a flex layout). */
className?: string;
}) {
@@ -44,6 +47,7 @@ export function Dropdown({
{button}
{open && (
<div
{...(menuStyle !== undefined ? { style: menuStyle } : {})}
className={`anim-pop absolute z-40 max-h-[70vh] overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900 ${
menuClass ?? "left-0 top-full mt-1 w-64 max-w-[calc(100vw-2rem)] origin-top-left"
}`}
+15 -5
View File
@@ -269,7 +269,12 @@ function ModelSelect({
<Dropdown
open={open}
setOpen={setOpen}
menuClass="right-0 top-full mt-1 w-max min-w-56 max-w-[calc(100vw-2rem)] origin-top-right"
// right-0 docks the panel's right edge to the button, which itself sits ~4.5rem in from
// the viewport's right edge (page + card padding, gap, send button) — so the width clamp
// must reserve that anchor offset too, or the w-max panel's LEFT edge runs off-screen on
// phones (~34px off-screen at 375px with a 100vw-2rem clamp). Desktop is untouched:
// w-max stays far below the clamp there.
menuClass="right-0 top-full mt-1 w-max min-w-56 max-w-[calc(100vw-6rem)] origin-top-right"
button={
<button
type="button"
@@ -507,11 +512,16 @@ function SkillSelect({
open={open}
setOpen={setOpen}
menuClass={
// As wide as reasonably possible so descriptions stay readable; the viewport clamp
// keeps it inside phone screens.
// As wide as reasonably possible so descriptions stay readable. The panel is
// left-anchored to a button ~8rem into the toolbar (icon-only card; ~17rem once the
// card is wide enough for button labels, @md), so a plain 100vw clamp still let the
// right edge overrun the viewport on phones (~92px at 375px — the search box's
// autofocus then dragged the whole page sideways); the clamp must reserve the anchor
// offset plus a margin. Desktop is untouched: the fixed 26rem width stays below both
// clamps there.
direction === "down"
? "left-0 top-full mt-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-top-left"
: "bottom-full left-0 mb-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-bottom-left"
? "left-0 top-full mt-1 w-[26rem] max-w-[calc(100vw-10rem)] @md:max-w-[calc(100vw-17rem)] origin-top-left"
: "bottom-full left-0 mb-1 w-[26rem] max-w-[calc(100vw-10rem)] @md:max-w-[calc(100vw-17rem)] origin-bottom-left"
}
button={
<button
+33 -3
View File
@@ -25,6 +25,7 @@
* falls back to the cache.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useLocation, useNavigate } from "react-router";
import type {
AgentModelConfigDto,
@@ -765,7 +766,8 @@ function AgentSelect({
* auto temporary directory). The menu browses server-side directories: **the current path can be
* edited directly** at the top (Enter/blur commits it, an invalid directory toasts and reverts
* to the previous path), the list omits hidden directories, and the hint text sits at the bottom
* of the menu; only loads on first expand.
* of the menu; only loads on first expand. On narrow screens the menu docks to whichever side
* of the pill keeps it inside the viewport (measured on open — see menuDock).
*/
function WorkspaceSelect({
projectId,
@@ -777,6 +779,17 @@ function WorkspaceSelect({
onChange: (path: string) => void;
}) {
const [open, setOpen] = useState(false);
/**
* Menu docking, measured on each open: the pill follows the agent pill in a wrapping row, so
* its left offset varies with the agent's name — a statically left-anchored 20rem panel can
* cross the viewport's right edge on phones (measured ~143px past a 390px viewport). Keep the
* desktop left anchoring whenever the panel fits; otherwise dock to whichever side of the
* pill has more room, capping the width to that room via menuStyle. On desktop the panel
* always fits, so nothing changes there.
*/
const [menuDock, setMenuDock] = useState<{ right: boolean; maxWidth?: number }>({
right: false,
});
const browsedRef = useRef(false);
const [dir, setDir] = useState<DirListResponse | null>(null);
@@ -803,8 +816,22 @@ function WorkspaceSelect({
[projectId],
);
const toggle = () => {
const toggle = (e: ReactMouseEvent<HTMLButtonElement>) => {
const next = !open;
if (next) {
const r = e.currentTarget.getBoundingClientRect();
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
const margin = 12; // breathing room against the viewport edge
// The panel's effective width: w-80 capped by its max-w-[calc(100vw-2rem)] class
// (rem-derived — the root font size is not 16px here).
const width = Math.min(20 * rem, window.innerWidth - 2 * rem);
const roomRight = window.innerWidth - margin - r.left; // room for a left-anchored panel
const roomLeft = r.right - margin; // room for a right-anchored panel
if (roomRight >= width) setMenuDock({ right: false });
else if (roomLeft > roomRight)
setMenuDock({ right: true, ...(roomLeft < width ? { maxWidth: roomLeft } : {}) });
else setMenuDock({ right: false, maxWidth: roomRight });
}
setOpen(next);
// Only loads on first expand: an already-filled absolute path is used as the starting point, otherwise the server falls back to the home directory.
if (next && !browsedRef.current) {
@@ -839,7 +866,10 @@ function WorkspaceSelect({
<Dropdown
open={open}
setOpen={setOpen}
menuClass="left-0 top-full mt-1 w-80 max-w-[calc(100vw-2rem)] origin-top-left"
menuClass={`top-full mt-1 w-80 max-w-[calc(100vw-2rem)] ${
menuDock.right ? "right-0 origin-top-right" : "left-0 origin-top-left"
}`}
{...(menuDock.maxWidth !== undefined ? { menuStyle: { maxWidth: menuDock.maxWidth } } : {})}
button={
<button
type="button"