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:
4 files changed
+157
-9
No files matched your search
@@ -8,6 +8,11 @@
|
|||||||
* - the models page at 390x844 must not overflow horizontally, and text must not overlap
|
* - 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 header's provider name used to get pushed out of the button box and overlap
|
||||||
* the group-level actions);
|
* 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 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 /
|
* - the collapsed rail shows, in product-specified order, last conversation / new chat /
|
||||||
* Agents / Skills / Models / Costs / Traces / Benchmark with localized (en + zh) hover
|
* 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();
|
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 }) => {
|
test("layout: login — blank start, non-crossing traces, lang/theme controls", async ({ page }) => {
|
||||||
await page.goto(`${BASE}/login`);
|
await page.goto(`${BASE}/login`);
|
||||||
await page.getByRole("heading", { name: "PenguinHarness" }).waitFor();
|
await page.getByRole("heading", { name: "PenguinHarness" }).waitFor();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* z-40, overlays are z-50). menuClass controls the docking direction and width.
|
* z-40, overlays are z-50). menuClass controls the docking direction and width.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
export function Dropdown({
|
export function Dropdown({
|
||||||
button,
|
button,
|
||||||
@@ -12,6 +12,7 @@ export function Dropdown({
|
|||||||
setOpen,
|
setOpen,
|
||||||
children,
|
children,
|
||||||
menuClass,
|
menuClass,
|
||||||
|
menuStyle,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
button: ReactNode;
|
button: ReactNode;
|
||||||
@@ -20,6 +21,8 @@ export function Dropdown({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Panel positioning and size (default: downward, left-aligned, w-64). */
|
/** Panel positioning and size (default: downward, left-aligned, w-64). */
|
||||||
menuClass?: string;
|
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). */
|
/** Extra classes for the root container (e.g. flex-1 in a flex layout). */
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -44,6 +47,7 @@ export function Dropdown({
|
|||||||
{button}
|
{button}
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<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 ${
|
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"
|
menuClass ?? "left-0 top-full mt-1 w-64 max-w-[calc(100vw-2rem)] origin-top-left"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -269,7 +269,12 @@ function ModelSelect({
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
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={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -507,11 +512,16 @@ function SkillSelect({
|
|||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
menuClass={
|
menuClass={
|
||||||
// As wide as reasonably possible so descriptions stay readable; the viewport clamp
|
// As wide as reasonably possible so descriptions stay readable. The panel is
|
||||||
// keeps it inside phone screens.
|
// 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"
|
direction === "down"
|
||||||
? "left-0 top-full mt-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-top-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-2rem)] origin-bottom-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={
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
* falls back to the cache.
|
* falls back to the cache.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import type {
|
import type {
|
||||||
AgentModelConfigDto,
|
AgentModelConfigDto,
|
||||||
@@ -765,7 +766,8 @@ function AgentSelect({
|
|||||||
* auto temporary directory). The menu browses server-side directories: **the current path can be
|
* 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
|
* 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
|
* 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({
|
function WorkspaceSelect({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -777,6 +779,17 @@ function WorkspaceSelect({
|
|||||||
onChange: (path: string) => void;
|
onChange: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
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 browsedRef = useRef(false);
|
||||||
|
|
||||||
const [dir, setDir] = useState<DirListResponse | null>(null);
|
const [dir, setDir] = useState<DirListResponse | null>(null);
|
||||||
@@ -803,8 +816,22 @@ function WorkspaceSelect({
|
|||||||
[projectId],
|
[projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = (e: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
const next = !open;
|
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);
|
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.
|
// 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) {
|
if (next && !browsedRef.current) {
|
||||||
@@ -839,7 +866,10 @@ function WorkspaceSelect({
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
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={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in new issue
Block a user