From f5aab159203b35424f492803bcdf5d100cd234ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 10 Jun 2026 20:26:35 -0400 Subject: [PATCH] Give cascading menus hover intent with a safe triangle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CascadeMenu/CascadeRow: a menu whose rows reveal a side submenu on hover with an open delay, a forgiving close delay, and a cursor→submenu safe triangle — while the cursor sweeps toward an open submenu, hovers over sibling rows are ignored, so a natural diagonal move never snaps the target shut. The triangle geometry (pointInTriangle) is unit-tested. Apply it to the org switcher: each organization reveals its actions (switch / delete) on hover instead of a hidden hover-only "x", while the destructive delete keeps its deliberate, non-hover-dismissible confirm. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/renderer/src/overlays.css | 22 ++ .../src/renderer/src/shared/CascadeMenu.tsx | 188 ++++++++++++++++++ .../src/renderer/src/shared/OrgSwitcher.tsx | 131 ++++++------ AgentCompany-New/src/renderer/src/styles.css | 2 + .../tests/unit/cascade-safe-triangle.test.ts | 30 +++ 5 files changed, 305 insertions(+), 68 deletions(-) create mode 100644 AgentCompany-New/src/renderer/src/shared/CascadeMenu.tsx create mode 100644 AgentCompany-New/tests/unit/cascade-safe-triangle.test.ts diff --git a/AgentCompany-New/src/renderer/src/overlays.css b/AgentCompany-New/src/renderer/src/overlays.css index 8e4c78d3..c97ae21b 100644 --- a/AgentCompany-New/src/renderer/src/overlays.css +++ b/AgentCompany-New/src/renderer/src/overlays.css @@ -170,6 +170,28 @@ .menu-row .menu-check { margin-left: auto; color: var(--accent); display: inline-flex; } .menu-divider { height: 1px; background: var(--border-soft); margin: var(--sp-1) 0; } +/* Cascading submenu (hover-intent, safe-triangle — see CascadeMenu.tsx). */ +.cascade-row-anchor { position: relative; } +.cascade-submenu { + position: absolute; + top: -5px; + left: 100%; + margin-left: 4px; + z-index: 40; + min-width: 200px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--r-md); + box-shadow: var(--shadow-md); + padding: var(--sp-1); + display: flex; + flex-direction: column; + gap: 2px; + animation: menu-enter var(--motion-fast) var(--ease-out-expo); +} +.org-menu-chevron { margin-left: auto; color: var(--text-3); display: inline-flex; flex-shrink: 0; } +.org-menu-row.active .org-menu-chevron { color: var(--text-2); } + ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: var(--r-full); } ::-webkit-scrollbar-track { background: transparent; } diff --git a/AgentCompany-New/src/renderer/src/shared/CascadeMenu.tsx b/AgentCompany-New/src/renderer/src/shared/CascadeMenu.tsx new file mode 100644 index 00000000..026aed06 --- /dev/null +++ b/AgentCompany-New/src/renderer/src/shared/CascadeMenu.tsx @@ -0,0 +1,188 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; + +/** Hover-intent timings. Open is brisk; close is forgiving so the cursor can + * cross the gap between a row and its submenu without it collapsing. */ +const OPEN_DELAY_MS = 90; +const CLOSE_DELAY_MS = 320; + +interface Point { + x: number; + y: number; +} + +interface CascadeController { + openId: string | null; + /** A row asks to become the open one (on hover). Honors the safe triangle. */ + hoverRow: (id: string) => void; + /** A row/submenu cancels the pending close (cursor is still inside the menu). */ + keepOpen: () => void; + /** The cursor left the whole menu — close after the grace delay. */ + scheduleClose: () => void; + /** Submenus register their element so the safe triangle can read its edge. */ + registerSubmenu: (id: string, element: HTMLElement | null) => void; + closeNow: () => void; +} + +const CascadeContext = createContext(null); + +function sign(a: Point, b: Point, c: Point): number { + return (a.x - c.x) * (b.y - c.y) - (b.x - c.x) * (a.y - c.y); +} + +/** Is point p inside triangle a-b-c (inclusive of edges)? Exported for tests. */ +export function pointInTriangle(p: Point, a: Point, b: Point, c: Point): boolean { + const d1 = sign(p, a, b); + const d2 = sign(p, b, c); + const d3 = sign(p, c, a); + const hasNeg = d1 < 0 || d2 < 0 || d3 < 0; + const hasPos = d1 > 0 || d2 > 0 || d3 > 0; + return !(hasNeg && hasPos); +} + +/** + * A menu whose rows can each reveal a side submenu on hover. The container owns + * the single open row and the cursor→submenu "safe triangle": when a submenu is + * open and the cursor is travelling toward it, hovers over sibling rows are + * ignored, so a natural diagonal move never snaps the target submenu shut. + */ +export function CascadeMenu({ children, className }: { children: ReactNode; className?: string }) { + const [openId, setOpenId] = useState(null); + const openIdRef = useRef(null); + const pointer = useRef({ x: 0, y: 0 }); + const priorPointer = useRef({ x: 0, y: 0 }); + const submenus = useRef>(new Map()); + const openTimer = useRef(null); + const closeTimer = useRef(null); + + const setOpen = useCallback((id: string | null) => { + openIdRef.current = id; + setOpenId(id); + }, []); + + const clearOpenTimer = () => { + if (openTimer.current !== null) { + window.clearTimeout(openTimer.current); + openTimer.current = null; + } + }; + const clearCloseTimer = () => { + if (closeTimer.current !== null) { + window.clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + + /** True when the cursor is sweeping toward the currently open submenu. The + * apex is the prior cursor sample; the base is the submenu's near (left) edge. */ + const movingTowardSubmenu = (): boolean => { + const current = openIdRef.current; + if (!current) return false; + const element = submenus.current.get(current); + if (!element) return false; + const rect = element.getBoundingClientRect(); + const apex = priorPointer.current; + const top: Point = { x: rect.left, y: rect.top }; + const bottom: Point = { x: rect.left, y: rect.bottom }; + return pointInTriangle(pointer.current, apex, top, bottom); + }; + + const hoverRow = useCallback((id: string) => { + clearCloseTimer(); + if (id === openIdRef.current) { + clearOpenTimer(); + return; + } + // Defer switching while the cursor is en route to the open submenu. + if (openIdRef.current && movingTowardSubmenu()) return; + clearOpenTimer(); + openTimer.current = window.setTimeout(() => setOpen(id), OPEN_DELAY_MS); + }, [setOpen]); + + const keepOpen = useCallback(() => { + clearCloseTimer(); + }, []); + + const scheduleClose = useCallback(() => { + clearOpenTimer(); + clearCloseTimer(); + closeTimer.current = window.setTimeout(() => setOpen(null), CLOSE_DELAY_MS); + }, [setOpen]); + + const closeNow = useCallback(() => { + clearOpenTimer(); + clearCloseTimer(); + setOpen(null); + }, [setOpen]); + + const registerSubmenu = useCallback((id: string, element: HTMLElement | null) => { + if (element) submenus.current.set(id, element); + else submenus.current.delete(id); + }, []); + + useEffect(() => () => { clearOpenTimer(); clearCloseTimer(); }, []); + + const controller: CascadeController = { openId, hoverRow, keepOpen, scheduleClose, registerSubmenu, closeNow }; + + return ( +
{ + priorPointer.current = pointer.current; + pointer.current = { x: event.clientX, y: event.clientY }; + }} + onMouseLeave={scheduleClose} + > + {children} +
+ ); +} + +/** + * A row that reveals `submenu` to its right on hover. Composes with sibling rows + * through the surrounding {@link CascadeMenu} so only one submenu shows and the + * safe triangle governs switching. + */ +export function CascadeRow({ + id, + children, + submenu, +}: { + id: string; + children: ReactNode; + submenu: (close: () => void) => ReactNode; +}) { + const controller = useContext(CascadeContext); + if (!controller) throw new Error("CascadeRow must be used inside a CascadeMenu."); + const open = controller.openId === id; + const submenuRef = useRef(null); + + useEffect(() => { + controller.registerSubmenu(id, open ? submenuRef.current : null); + return () => controller.registerSubmenu(id, null); + }, [controller, id, open]); + + return ( +
controller.hoverRow(id)}> + {children} + {open && ( +
+ {submenu(controller.closeNow)} +
+ )} +
+ ); +} diff --git a/AgentCompany-New/src/renderer/src/shared/OrgSwitcher.tsx b/AgentCompany-New/src/renderer/src/shared/OrgSwitcher.tsx index 762b07fb..b0dc2414 100644 --- a/AgentCompany-New/src/renderer/src/shared/OrgSwitcher.tsx +++ b/AgentCompany-New/src/renderer/src/shared/OrgSwitcher.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import type { AppSnapshot, OrganizationRecord } from "../../../shared/domain"; import { Icon } from "./Icon"; +import { CascadeMenu, CascadeRow } from "./CascadeMenu"; function orgInitial(name: string): string { return name.trim().charAt(0).toUpperCase(); @@ -33,8 +34,8 @@ export function OrgSwitcher({ snapshot, activeOrgId, onSelect, onNew, onDelete } return () => document.removeEventListener("mousedown", onDocClick); }, [open]); - const initial = orgInitial(active.name); const orgs = snapshot.organizations; + const confirmingOrg = confirmingDelete ? orgs.find((org) => org.id === confirmingDelete) ?? null : null; const handleSelect = (orgId: string) => { setOpen(false); @@ -58,96 +59,90 @@ export function OrgSwitcher({ snapshot, activeOrgId, onSelect, onNew, onDelete } aria-expanded={open} onClick={() => setOpen((value) => !value)} > - {initial} + {orgInitial(active.name)} {active.name} - + - {open && ( + {open && (confirmingOrg ? (
+
+
Delete “{confirmingOrg.name}”?
+
All agents, work, chats, and files are permanently removed.
+
+ + +
+
+
+ ) : ( +
Organizations
{orgs.map((org) => ( - handleSelect(org.id)} - confirming={confirmingDelete === org.id} - onAskDelete={() => setConfirmingDelete(org.id)} - onCancelDelete={() => setConfirmingDelete(null)} - onConfirmDelete={() => handleDelete(org.id)} - /> + id={org.id} + submenu={(close) => ( + { close(); handleSelect(org.id); }} + onDelete={() => { close(); setConfirmingDelete(org.id); }} + /> + )} + > + + ))}
-
- )} +
+ ))} ); } -function OrgRow({ +function OrgActions({ org, - active, - onSelect, - confirming, - onAskDelete, - onCancelDelete, - onConfirmDelete, + isActive, + onSwitch, + onDelete, }: { org: OrganizationRecord; - active: boolean; - onSelect: () => void; - confirming: boolean; - onAskDelete: () => void; - onCancelDelete: () => void; - onConfirmDelete: () => void; + isActive: boolean; + onSwitch: () => void; + onDelete: () => void; }) { - const initial = orgInitial(org.name); - if (confirming) { - return ( -
-
Delete “{org.name}”?
-
All agents, work, chats, and files are permanently removed.
-
- - -
-
- ); - } return ( - + <> +
{org.name}
+ {!isActive && ( + + )} + + ); } diff --git a/AgentCompany-New/src/renderer/src/styles.css b/AgentCompany-New/src/renderer/src/styles.css index 81113c11..e3dc071f 100644 --- a/AgentCompany-New/src/renderer/src/styles.css +++ b/AgentCompany-New/src/renderer/src/styles.css @@ -342,6 +342,8 @@ a { color: inherit; } transition: background var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); } .org-menu-action:hover { background: var(--hover); color: var(--text-1); } +.org-menu-action.danger { color: var(--red); } +.org-menu-action.danger:hover { background: rgba(210, 59, 59, 0.1); color: var(--red); } .brand-mark.sm { width: 20px; height: 20px; diff --git a/AgentCompany-New/tests/unit/cascade-safe-triangle.test.ts b/AgentCompany-New/tests/unit/cascade-safe-triangle.test.ts new file mode 100644 index 00000000..cab31d0f --- /dev/null +++ b/AgentCompany-New/tests/unit/cascade-safe-triangle.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { pointInTriangle } from "../../src/renderer/src/shared/CascadeMenu"; + +// The safe triangle: apex at the cursor's prior position, base on the open +// submenu's near (left) edge. A submenu sitting to the right spanning y∈[-20,40] +// at x=100, with the cursor having just been at the origin. +const apex = { x: 0, y: 0 }; +const top = { x: 100, y: -20 }; +const bottom = { x: 100, y: 40 }; + +describe("safe triangle (cursor → submenu)", () => { + test("a cursor sweeping diagonally toward the submenu stays inside", () => { + // Midway to the submenu, within the widening cone — switching is deferred. + expect(pointInTriangle({ x: 50, y: 5 }, apex, top, bottom)).toBe(true); + expect(pointInTriangle({ x: 90, y: 30 }, apex, top, bottom)).toBe(true); + }); + + test("a cursor that drifts off-course toward a sibling row falls outside", () => { + // At x=50 the cone only spans y∈[-10,20]; y=30 is below it — let the sibling win. + expect(pointInTriangle({ x: 50, y: 30 }, apex, top, bottom)).toBe(false); + // Straight down from the apex, away from the submenu entirely. + expect(pointInTriangle({ x: 5, y: 35 }, apex, top, bottom)).toBe(false); + }); + + test("the apex and the submenu corners are on the triangle", () => { + expect(pointInTriangle(apex, apex, top, bottom)).toBe(true); + expect(pointInTriangle(top, apex, top, bottom)).toBe(true); + expect(pointInTriangle(bottom, apex, top, bottom)).toBe(true); + }); +});