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:
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user