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,