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
+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"