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:
@@ -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