Files
paseo/packages/app/src/components/ui/menu/menu-context.ts
T
Mohamed Boudra fa0f04058d Keep account usage accessible before the first turn and restore compact sheets
* fix(usage): keep details hoverable and restore compact sidebar sheets

Share hover-card mechanics with workspace details so context usage can stay open while users press Refresh. Honor Retry-After for rate-limited usage requests, and let both sidebar entry points share the compact sheet.

* fix(usage): restore settings cog with a fitted popover and compact sheet

Keep shared Usage preferences in the title controls on every surface. Settings rows use the menu surface directly, avoiding the trailing margin and duplicate card border from the page layout.

* fix(app): show account usage before the first agent turn

Keep the context meter available for existing agents without context readings so account usage can be opened before a turn. Show an empty ring and an explicit no-data state until readings arrive.

* fix(usage): preserve keyboard access and hover card positioning

Context details must remain reachable by keyboard after replacing the tooltip. Keep focus within the card, restore side flipping, and dismiss detached cards when the viewport moves. Prune expired cooldowns even after credential rotation.

* chore: remove QA screenshots from the source tree

* fix(app): let hover cards claim Escape before agent shortcuts

Register hover surfaces with the shared overlay keyboard owner without moving or trapping focus on pointer hover. Component-local Escape handling ran after the global interrupt shortcut and cancelled the underlying turn.

* fix(app): reopen focused hover cards with button keys

Keep focused triggers registered with overlay arbitration after dismissal so activation keys are handled before composer shortcuts.

* fix(app): release overlay ownership when hover cards close

Closed triggers handle activation locally, so a dismissed card does not block pane Find. Open cards retain shared Escape arbitration to protect running turns.

* test(app): pin Linux keyboard identity for composer Find

* test(app): synchronize context usage hover journeys

Return focus to the composer before asserting pointer dismissal, wait for the card to reach the viewport, and park the pointer before reloading so it cannot consume the next scripted usage response early.
2026-10-05 01:59:15 +01:00

183 lines
5.7 KiB
TypeScript

import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { Platform, type View } from "react-native";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import {
closeSubPage,
goBack as goBackPath,
MENU_ROOT_PATH,
openSubPage,
type MenuPath,
} from "./menu-navigation";
import type { Rect } from "../anchor";
/**
* How a menu draws itself once it is open.
*
* `popover` anchors a floating surface to the trigger and opens submenus as flyouts beside it.
* `sheet` presents from the bottom edge and opens submenus by replacing the page in place.
* The choice is made from form factor, not platform, so a tablet in a narrow split view sheets
* the same way a phone does.
*/
export type MenuPresentation = "popover" | "sheet";
/** What a menu does on a compact screen. Wide screens are always a popover. */
export type MenuCompactMode = "popover" | "sheet";
/** How long UIKit is given to finish removing the menu surface before an action runs. */
export const IOS_TEARDOWN_GRACE_MS = 250;
export interface MenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
/** Runs an item's action, closing the menu first when the item asks for it. */
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
triggerRef: React.RefObject<View | null>;
/** Point anchor for menus opened from a gesture rather than a trigger box. */
anchorRect: Rect | null;
setAnchorRect: (rect: Rect | null) => void;
presentation: MenuPresentation;
path: MenuPath;
openSub: (sub: { id: string; depth: number }) => void;
closeSub: (depth: number) => void;
goBack: () => void;
}
const MenuContext = createContext<MenuContextValue | null>(null);
export const MenuContextProvider = MenuContext.Provider;
export function useMenuContext(componentName: string): MenuContextValue {
const ctx = useContext(MenuContext);
if (!ctx) {
throw new Error(`${componentName} must be used within a menu root`);
}
return ctx;
}
/**
* How many submenu pages sit above the component reading it. A trigger on the root page is
* depth 0. Each open submenu re-provides this one higher, which is what lets a submenu trigger
* know whether it should nest below the current page or replace a sibling branch.
*/
const MenuDepthContext = createContext(0);
export const MenuDepthProvider = MenuDepthContext.Provider;
export function useMenuDepth(): number {
return useContext(MenuDepthContext);
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? open : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
return [value, setValue];
}
/**
* Everything one menu knows about itself. Both menu shapes build on this; they differ only in
* what opens them, which is why the trigger is the wrapper's job and nothing here.
*/
export function useMenuState({
open,
defaultOpen,
onOpenChange,
compactMode = "popover",
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
compactMode?: MenuCompactMode;
}): MenuContextValue {
const triggerRef = useRef<View>(null);
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
const [path, setPath] = useState<MenuPath>(MENU_ROOT_PATH);
const isCompact = useIsCompactFormFactor();
const presentation: MenuPresentation = isCompact && compactMode === "sheet" ? "sheet" : "popover";
const [isOpen, setIsOpenState] = useControllableOpenState({ open, defaultOpen, onOpenChange });
useDismissKeyboardOnOpen(isOpen);
// A menu always reopens on its root page, against a freshly measured anchor. Keeping either
// would reopen the surface three levels deep, or at the coordinates of the last right click.
const setOpen = useCallback(
(next: boolean) => {
if (!next) {
setPath(MENU_ROOT_PATH);
setAnchorRect(null);
}
setIsOpenState(next);
},
[setIsOpenState],
);
const selectItem = useCallback(
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
if (!closeOnSelect) {
onSelect?.();
return;
}
setOpen(false);
if (!onSelect) return;
if (Platform.OS === "ios") {
// Native presenters such as PHPicker can hang if launched while UIKit is still
// tearing down the surface this menu was drawn in. The wait is timed rather than
// driven by the surface's own dismissal callback: both surfaces unmount as soon as
// the menu closes, so any such callback is racing its own removal and usually loses.
setTimeout(onSelect, IOS_TEARDOWN_GRACE_MS);
return;
}
onSelect();
},
[setOpen],
);
const openSub = useCallback((sub: { id: string; depth: number }) => {
setPath((current) => openSubPage(current, sub));
}, []);
const closeSub = useCallback((depth: number) => {
setPath((current) => closeSubPage(current, depth));
}, []);
const goBack = useCallback(() => {
setPath((current) => goBackPath(current));
}, []);
return useMemo(
() => ({
open: isOpen,
setOpen,
selectItem,
triggerRef,
anchorRect,
setAnchorRect,
presentation,
path,
openSub,
closeSub,
goBack,
}),
[isOpen, setOpen, selectItem, anchorRect, presentation, path, openSub, closeSub, goBack],
);
}