From 14c9203bc148f601c15e86d65d72432424f8c6cd Mon Sep 17 00:00:00 2001 From: Anaya Date: Sat, 22 Aug 2026 17:44:28 -0700 Subject: [PATCH 1/3] fix(web): stop project-scope menu from highlighting first row without hover Base UI focuses the first tabbable element when a menu opens with the mouse. Inside the sidebar project-scope menu every row is tabIndex=-1 until highlighted, so the first tabbable element was the settings gear inside the first project row; focus bubbling into that row made Base UI render it as highlighted although the pointer was elsewhere. Keyboard opens were unaffected because the checked row is pre-highlighted and receives focus itself. When the trigger is activated by pointer, focus the popup element during commit instead: Base UI's queued open-time autofocus sees focus is already inside the popup and skips. Keyboard opens keep the default behavior; arrow keys, Tab-to-gear, and mouse clicks on the gear still work as before. --- apps/web/src/components/Sidebar.tsx | 32 ++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af7cdc8a94a2..72530cdc1eea 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -59,6 +59,7 @@ import { memo, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -1791,6 +1792,25 @@ export default function Sidebar() { }, }); const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); + // Refs for the project-scope menu's open-focus redirect (see the + // useLayoutEffect next to its MenuPopup). + const projectScopePopupRef = useRef(null); + const projectScopeOpenedWithPointerRef = useRef(false); + + // Base UI focuses the first tabbable element when a menu opens. Inside the + // project-scope menu that is the settings gear inside the first project + // row, so opening with the mouse rendered that row as highlighted without + // any hover. When the menu was opened with a pointer, focus the popup + // itself during commit instead — Base UI's queued autofocus then sees + // focus is already inside the popup and skips. Keyboard opens keep the + // default behavior, which pre-highlights the checked row. + useLayoutEffect(() => { + if (!projectScopeMenuOpen || !projectScopeOpenedWithPointerRef.current) { + return; + } + projectScopePopupRef.current?.focus(); + }, [projectScopeMenuOpen]); + const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), @@ -3487,6 +3507,12 @@ export default function Sidebar() { { + projectScopeOpenedWithPointerRef.current = true; + }} + onKeyDown={() => { + projectScopeOpenedWithPointerRef.current = false; + }} /> } > @@ -3505,7 +3531,11 @@ export default function Sidebar() { - + From 437a995cc62fb5c432e0bc93f63cae34a341efe4 Mon Sep 17 00:00:00 2001 From: Anaya Date: Sat, 22 Aug 2026 18:03:53 -0700 Subject: [PATCH 2/3] fix(web): focus project-scope popup from inside its own subtree The Sidebar-level layout effect raced Base UI's FloatingFocusManager: it only helps if the popup mounts in the same commit, and the manager's autofocus decision can still land on the settings gear otherwise. Move the redirect into the popup subtree as a sentinel component. React runs child layout effects before the popup's own effect, so the manager snapshots activeElement already inside the popup and skips its queued autofocus deterministically, regardless of mount timing. Behavior is unchanged for keyboard opens (sentinel not mounted) and for arrow keys, Tab-to-gear, and gear clicks. --- apps/web/src/components/Sidebar.tsx | 51 ++++++++++++++++------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 72530cdc1eea..672a3ac4565a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -254,6 +254,28 @@ function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } +/** + * Focus the enclosing menu popup as soon as it mounts. Rendered only for + * pointer-opened project-scope menus: Base UI's open-time autofocus would + * otherwise land on the first tabbable descendant — the settings gear inside + * the first project row — making that row render as highlighted without any + * hover. React runs this sentinel's layout effect before the popup's own + * focus manager effect (child-first), so the manager snapshots focus already + * inside the popup and skips its autofocus entirely. Keyboard opens never + * mount the sentinel and keep Base UI's default pre-highlighted-row focus. + */ +function ProjectScopeOpenFocusRedirect({ enabled }: { enabled: boolean }) { + const sentinelRef = useRef(null); + useLayoutEffect(() => { + if (!enabled) { + return; + } + const popup = sentinelRef.current?.closest("div[data-slot='menu-popup']"); + popup?.focus(); + }, [enabled]); + return - + + From 3fe939880a0195ed8b39189aa021b7771b7c760f Mon Sep 17 00:00:00 2001 From: Anaya Date: Mon, 24 Aug 2026 17:21:44 -0700 Subject: [PATCH 3/3] fix(web): move popup focus redirect into MenuPopup behind opt-in prop Addresses the three Macroscope UI-consistency findings on this PR: - The sentinel no longer reaches through MenuPopup's internal DOM via closest(). MenuPopup now owns a ref to its own popup element and exposes an opt-in focusOnMountRef prop; the FocusPopupOnMount helper lives beside it so the Base UI ordering workaround is documented in one place. - The mount focus now passes preventScroll:true, matching Base UI's own open-focus behavior while the popup is still unpositioned. - projectScopeOpenedWithPointerRef.current is no longer read during render. The ref object is passed down and read inside the layout effect, keeping the component React Compiler-safe. Verified: pnpm exec tsgo --noEmit, vp lint on changed files, menu + sidebar unit tests. Model: ox-alpha (opencode/x-preview-f-free), opencode --- apps/web/src/components/Sidebar.tsx | 38 ++++++------------------- apps/web/src/components/ui/menu.tsx | 43 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 672a3ac4565a..d68cb003e974 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -59,7 +59,6 @@ import { memo, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -254,28 +253,6 @@ function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } -/** - * Focus the enclosing menu popup as soon as it mounts. Rendered only for - * pointer-opened project-scope menus: Base UI's open-time autofocus would - * otherwise land on the first tabbable descendant — the settings gear inside - * the first project row — making that row render as highlighted without any - * hover. React runs this sentinel's layout effect before the popup's own - * focus manager effect (child-first), so the manager snapshots focus already - * inside the popup and skips its autofocus entirely. Keyboard opens never - * mount the sentinel and keep Base UI's default pre-highlighted-row focus. - */ -function ProjectScopeOpenFocusRedirect({ enabled }: { enabled: boolean }) { - const sentinelRef = useRef(null); - useLayoutEffect(() => { - if (!enabled) { - return; - } - const popup = sentinelRef.current?.closest("div[data-slot='menu-popup']"); - popup?.focus(); - }, [enabled]); - return - - + diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index b66782ebe2d1..a0fa471222b1 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -2,6 +2,7 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { ChevronRightIcon } from "lucide-react"; +import { useLayoutEffect, useRef } from "react"; import type * as React from "react"; import { cn } from "~/lib/utils"; @@ -10,6 +11,36 @@ const MenuCreateHandle = MenuPrimitive.createHandle; const Menu = MenuPrimitive.Root; +/** + * Focus the popup element itself on mount when `enabled.current` is true. + * + * Base UI's open-time autofocus lands on the first tabbable descendant, which + * mis-highlights rows whose first tabbable control is a nested button (e.g. a + * settings gear) rather than the row itself. React runs this child's layout + * effect before the popup's own focus manager effect (child-first), so + * focusing the popup makes the manager see focus already inside the popup and + * skip its autofocus. Keyboard-opened menus leave `enabled.current` false and + * keep Base UI's default pre-highlighted-row focus. + */ +function FocusPopupOnMount({ + enabled, + popupRef, +}: { + enabled: { current: boolean }; + popupRef: { current: HTMLElement | null }; +}) { + useLayoutEffect(() => { + if (!enabled.current) { + return; + } + // preventScroll matches Base UI's own open-focus behavior: this runs + // before the positioner has positioned the popup, so a scrolling focus + // could jump ancestor scrollers. + popupRef.current?.focus({ preventScroll: true }); + }, [enabled, popupRef]); + return null; +} + const MenuPortal = MenuPrimitive.Portal; function MenuTrigger({ className, children, ...props }: MenuPrimitive.Trigger.Props) { @@ -28,6 +59,7 @@ function MenuPopup({ alignOffset, side = "bottom", anchor, + focusOnMountRef, ...props }: MenuPrimitive.Popup.Props & { align?: MenuPrimitive.Positioner.Props["align"]; @@ -35,7 +67,14 @@ function MenuPopup({ alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"]; side?: MenuPrimitive.Positioner.Props["side"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; + /** + * When `focusOnMountRef.current` is true at mount, focus the popup itself + * instead of letting Base UI autofocus the first tabbable item. Read inside + * a layout effect: pass the ref object, never read `.current` during render. + */ + focusOnMountRef?: { current: boolean }; }) { + const popupRef = useRef(null); const hasExplicitWidthClass = typeof className === "string" && className.split(/\s+/).some((classToken) => { @@ -61,9 +100,13 @@ function MenuPopup({ className, )} data-slot="menu-popup" + ref={popupRef} {...props} >
{children}
+ {focusOnMountRef ? ( + + ) : null}