From c37bc5205b718fb7b722c7019536ed5f5e7b4aa0 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 2 Nov 2026 13:25:05 +0700 Subject: [PATCH] fix(chat): + menu opens downward when the prompt box sits too near the top On the workspace home the box is high on the screen, so the menu and its submenus (Project files) opened upward and were clipped off the top. Measure the room above/below (viewport narrowed by clipping ancestors) at open and drop the menu below the button, submenus top-aligned, when it fits better. Co-Authored-By: Claude Opus 5.5 --- client/src/components/run/ChatAddMenu.tsx | 53 +++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/client/src/components/run/ChatAddMenu.tsx b/client/src/components/run/ChatAddMenu.tsx index e1562cd8..0f002352 100644 --- a/client/src/components/run/ChatAddMenu.tsx +++ b/client/src/components/run/ChatAddMenu.tsx @@ -13,7 +13,8 @@ * puts `/` (`/:`) at the front of the message — the same * text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists * are read when the menu first opens (`GET /api/cc-config/skills|plugins`), - * and Manage opens the Claude config page on that tab. + * and Manage opens the Claude config page on that tab. The menu opens + * downward when the box sits too near the top to fit it above. * @author Nguyễn Ngọc Trí Vĩ */ @@ -78,6 +79,35 @@ function loadLists(cwd: string): Promise { const PANEL = "absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in"; +/** Room the menu and its tallest submenu (Project files: search + list) need. */ +const MENU_ROOM = 380; + +/** The visible band the button sits in: the viewport, narrowed by any + * scrolling/clipping ancestor (the page body scrolls inside the layout). */ +function clipBand(el: HTMLElement): { top: number; bottom: number } { + let top = 0; + let bottom = window.innerHeight; + for (let p = el.parentElement; p; p = p.parentElement) { + const o = getComputedStyle(p); + if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) { + const r = p.getBoundingClientRect(); + top = Math.max(top, r.top); + bottom = Math.min(bottom, r.bottom); + } + } + return { top, bottom }; +} + +/** Open downward when there is not room above but more below. */ +function opensDown(el: HTMLElement | null): boolean { + if (!el) return false; + const r = el.getBoundingClientRect(); + const band = clipBand(el); + const above = r.top - band.top; + const below = band.bottom - r.bottom; + return above < MENU_ROOM && below > above; +} + const ROW = "flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"; @@ -195,6 +225,7 @@ export function ChatAddMenu({ const { t } = useTranslation("run"); const navigate = useNavigate(); const [open, setOpen] = useState(false); + const [down, setDown] = useState(false); const [sub, setSub] = useState(null); const [context, setContext] = useState(null); const [recent, setRecent] = useState([]); @@ -286,6 +317,8 @@ export function ChatAddMenu({ /> )) ); + // A submenu lines up with the menu's edge nearest the button. + const edge = down ? "top-0" : "bottom-0"; const shownPlugin = lists?.plugins.find((p) => p.name === plugin); return ( @@ -296,13 +329,17 @@ export function ChatAddMenu({ aria-label={t("chat.add")} title={t("chat.add")} aria-expanded={open} - onClick={() => (open ? close() : setOpen(true))} + onClick={() => { + if (open) return close(); + setDown(opensDown(ref.current)); + setOpen(true); + }} className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary" > {open && ( -
+
} label={t("chat.upload")} @@ -376,13 +413,13 @@ export function ChatAddMenu({ )} {sub === "project" && ( -
+
attach(projectAttachment(rel))} />
)} {(sub === "context" || sub === "recent") && ( -
+
{(sub === "context" ? context : recent)?.length ? ( (sub === "context" ? context! : recent).map((it) => ( @@ -405,7 +442,7 @@ export function ChatAddMenu({ )} {sub === "skills" && ( -
+
{entries(lists?.skills ?? [], "")}
+
{lists && lists.plugins.length === 0 && (

{t("chat.none")}

@@ -443,7 +480,7 @@ export function ChatAddMenu({ onClick={() => manage("plugins")} /> {shownPlugin && ( -
+
{entries( shownPlugin.items,