From 0d14864b94c1b04aa5e0ff6f0ec7273a2d3940c5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:04:39 -0500 Subject: [PATCH] refactor(desktop): extract a shared kebab + right-click actions-menu primitive Promote the session row's inline MenuKit device into components/ui/ actions-menu.tsx: one ActionsMenu (kebab) + ActionsContextMenu (right-click) pair driven by a single items(kit) render function, so a row's dropdown and its context menu can't drift. Refactor the session menu onto it and let StatusRow forward ref/onContextMenu so any row can host a context menu. --- .../app/chat/sidebar/session-actions-menu.tsx | 136 ++++---------- .../src/components/chat/status-row.tsx | 10 +- .../src/components/ui/actions-menu.tsx | 177 ++++++++++++++++++ 3 files changed, 219 insertions(+), 104 deletions(-) create mode 100644 apps/desktop/src/components/ui/actions-menu.tsx diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 7b0d1a1e649..47095263e72 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -8,19 +8,10 @@ import { closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store' +import { type ActionItemSpec, ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { ColorSwatches } from '@/components/ui/color-swatches' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger -} from '@/components/ui/context-menu' import { CopyButton } from '@/components/ui/copy-button' import { Dialog, @@ -30,18 +21,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' -import { Tip } from '@/components/ui/tooltip' import { renameSession } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -131,42 +111,6 @@ interface SessionActions { onHideTabBar?: () => void } -type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem - -/** A menu flavour (dropdown / context) — item + separator + submenu components. */ -interface MenuKit { - Item: MenuItem - Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator - Sub: typeof DropdownMenuSub | typeof ContextMenuSub - SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger - SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent -} - -const DROPDOWN_KIT: MenuKit = { - Item: DropdownMenuItem, - Separator: DropdownMenuSeparator, - Sub: DropdownMenuSub, - SubContent: DropdownMenuSubContent, - SubTrigger: DropdownMenuSubTrigger -} - -const CONTEXT_KIT: MenuKit = { - Item: ContextMenuItem, - Separator: ContextMenuSeparator, - Sub: ContextMenuSub, - SubContent: ContextMenuSubContent, - SubTrigger: ContextMenuSubTrigger -} - -interface ItemSpec { - className?: string - disabled: boolean - icon: string - label: string - onSelect: (event: Event) => void - variant?: 'destructive' -} - // The color picker inside the session menu's Appearance submenu. Its own // component so only an OPEN submenu subscribes to the stores (not every row's // menu). Reads/writes the override keyed by the DURABLE id so a color survives @@ -212,11 +156,11 @@ function useSessionActions({ // a tab): offering "Open in new tab" again is noise. const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId) - const spec = (partial: Omit & { onSelect: () => void }): ItemSpec => partial + const spec = (partial: Omit & { onSelect: () => void }): ActionItemSpec => partial // OPEN — where else this session can go. A tab surface IS a tab already, // so it only offers the window hop (and its own Close, below). - const openItems: ItemSpec[] = [ + const openItems: ActionItemSpec[] = [ ...(surface === 'row' && !alreadyTabbed ? [ spec({ @@ -248,7 +192,7 @@ function useSessionActions({ ] // IDENTITY — name/mark/reference the session. - const identityItems: ItemSpec[] = [ + const identityItems: ActionItemSpec[] = [ spec({ disabled: !sessionId, icon: 'edit', @@ -270,7 +214,7 @@ function useSessionActions({ ] // WORK — derive/extract from the session. - const workItems: ItemSpec[] = [ + const workItems: ActionItemSpec[] = [ spec({ disabled: !onBranch, // Fork glyph to match the inline message action's GitFork icon @@ -297,7 +241,7 @@ function useSessionActions({ // TAB — close verbs that act on the strip (tabs only; a row isn't a tab). const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null - const tabCloseItems: ItemSpec[] = + const tabCloseItems: ActionItemSpec[] = surface === 'tab' ? [ ...(onClose @@ -348,7 +292,7 @@ function useSessionActions({ : [] // DANGER — put it away / destroy it (delete stays last, destructive-red). - const dangerItems: ItemSpec[] = [ + const dangerItems: ActionItemSpec[] = [ spec({ disabled: !onArchive, icon: 'archive', @@ -371,18 +315,11 @@ function useSessionActions({ } ] - const renderMenuItem = (Item: MenuItem, { className, disabled, icon, label, onSelect, variant }: ItemSpec) => ( - - - {label} - - ) - const renderItems = (kit: MenuKit) => ( <> - {openItems.map(item => renderMenuItem(kit.Item, item))} + {openItems.map(item => renderActionItem(kit, item))} {openItems.length > 0 && } - {identityItems.map(item => renderMenuItem(kit.Item, item))} + {identityItems.map(item => renderActionItem(kit, item))} @@ -393,7 +330,7 @@ function useSessionActions({ - {workItems.map(item => renderMenuItem(kit.Item, item))} + {workItems.map(item => renderActionItem(kit, item))} {tabCloseItems.length > 0 && ( <> - {tabCloseItems.map(item => renderMenuItem(kit.Item, item))} + {tabCloseItems.map(item => renderActionItem(kit, item))} )} - {dangerItems.map(item => renderMenuItem(kit.Item, item))} + {dangerItems.map(item => renderActionItem(kit, item))} {onHideTabBar && ( <> - {renderMenuItem(kit.Item, { + {renderActionItem(kit, { disabled: false, icon: 'eye-closed', label: r.hideTabBar, @@ -443,13 +380,9 @@ function useSessionActions({ } interface SessionActionsMenuProps - extends SessionActions, Pick, 'align' | 'sideOffset'> { + extends SessionActions, Pick, 'align' | 'sideOffset'> { children: React.ReactNode - /** Tooltip label for the trigger. Composed INSIDE the dropdown trigger - * (Tip wraps DropdownMenuTrigger, not the other way around) — Tip doesn't - * forward the extra props/ref an `asChild` clone injects, so putting it as - * the trigger's direct child silently drops onClick/aria-haspopup/ref and - * the menu stops opening (#67500). */ + /** Tooltip label for the trigger. */ tooltip?: React.ReactNode } @@ -462,23 +395,19 @@ export function SessionActionsMenu({ }: SessionActionsMenuProps) { const { t } = useI18n() const { renameDialog, renderItems } = useSessionActions(actions) - const [open, setOpen] = useState(false) return ( <> - - - {children} - - - {renderItems(DROPDOWN_KIT)} - - + + {children} + {renameDialog} ) @@ -494,12 +423,13 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP return ( <> - - {children} - - {renderItems(CONTEXT_KIT)} - - + + {children} + {renameDialog} ) diff --git a/apps/desktop/src/components/chat/status-row.tsx b/apps/desktop/src/components/chat/status-row.tsx index 575fb561742..316b8b0619c 100644 --- a/apps/desktop/src/components/chat/status-row.tsx +++ b/apps/desktop/src/components/chat/status-row.tsx @@ -1,4 +1,4 @@ -import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { type KeyboardEvent, type MouseEvent, type ReactNode, type Ref } from 'react' import { cn } from '@/lib/utils' @@ -15,6 +15,10 @@ interface StatusRowProps { /** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */ trailing?: ReactNode trailingVisible?: boolean + /** Forwarded to the row's root — lets a wrapper (e.g. a context-menu trigger + * using `asChild`) attach `ref` / `onContextMenu` to the real DOM node. */ + ref?: Ref + onContextMenu?: (event: MouseEvent) => void } /** @@ -29,6 +33,8 @@ export function StatusRow({ className, leading, onActivate, + onContextMenu, + ref, trailing, trailingVisible = false }: StatusRowProps) { @@ -41,6 +47,7 @@ export function StatusRow({ className )} onClick={onActivate} + onContextMenu={onContextMenu} onKeyDown={ onActivate ? event => { @@ -51,6 +58,7 @@ export function StatusRow({ } : undefined } + ref={ref} role={onActivate ? 'button' : undefined} tabIndex={onActivate ? 0 : undefined} > diff --git a/apps/desktop/src/components/ui/actions-menu.tsx b/apps/desktop/src/components/ui/actions-menu.tsx new file mode 100644 index 00000000000..2aa7f7d0773 --- /dev/null +++ b/apps/desktop/src/components/ui/actions-menu.tsx @@ -0,0 +1,177 @@ +import type * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tip } from '@/components/ui/tooltip' + +// One place to define a set of actions and get BOTH a kebab dropdown and a +// matching right-click context menu — so a row's ⋯ menu and its right-click menu +// never drift. The dropdown and context primitives share an identical item +// surface (Item / Separator / Sub…), so a caller writes `items={kit => …}` once +// and hands the render function to both wrappers. +// +// The pattern originated inline in the session row menu; it lives here so every +// kebab in the app can add right-click parity in one line. + +/** A menu flavour (dropdown / context) — the item + separator + submenu parts. */ +export interface MenuKit { + Item: typeof DropdownMenuItem | typeof ContextMenuItem + Label: typeof DropdownMenuLabel | typeof ContextMenuLabel + Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator + Sub: typeof DropdownMenuSub | typeof ContextMenuSub + SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger + SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent + /** `CopyButton`'s `appearance` for this flavour — pass to a menu-item copy. */ + copyAppearance: 'context-menu-item' | 'menu-item' +} + +export const DROPDOWN_KIT: MenuKit = { + Item: DropdownMenuItem, + Label: DropdownMenuLabel, + Separator: DropdownMenuSeparator, + Sub: DropdownMenuSub, + SubContent: DropdownMenuSubContent, + SubTrigger: DropdownMenuSubTrigger, + copyAppearance: 'menu-item' +} + +export const CONTEXT_KIT: MenuKit = { + Item: ContextMenuItem, + Label: ContextMenuLabel, + Separator: ContextMenuSeparator, + Sub: ContextMenuSub, + SubContent: ContextMenuSubContent, + SubTrigger: ContextMenuSubTrigger, + copyAppearance: 'context-menu-item' +} + +/** A single action row. Provide `icon` (codicon name) or `iconNode` (any node). */ +export interface ActionItemSpec { + className?: string + disabled?: boolean + icon?: string + iconNode?: React.ReactNode + /** Stable key; defaults to `label` when it's a string. */ + key?: string + label: React.ReactNode + onSelect: (event: Event) => void + variant?: 'default' | 'destructive' +} + +/** Render one `ActionItemSpec` with the given kit's Item component. */ +export function renderActionItem(kit: MenuKit, { className, disabled, icon, iconNode, key, label, onSelect, variant }: ActionItemSpec) { + return ( + + {iconNode ?? (icon ? : null)} + {typeof label === 'string' ? {label} : label} + + ) +} + +interface ActionsMenuProps + extends Pick, 'align' | 'side' | 'sideOffset'> { + /** The trigger (a kebab button). Wrapped in `DropdownMenuTrigger asChild`. */ + children: React.ReactNode + /** The action rows, rendered with `DROPDOWN_KIT`. Share this with `ActionsContextMenu`. */ + items: (kit: MenuKit) => React.ReactNode + ariaLabel?: string + contentClassName?: string + /** Optional tooltip on the trigger (composed INSIDE the asChild chain). */ + tooltip?: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +/** + * A kebab dropdown menu. Pair it with `ActionsContextMenu` using the same + * `items` render function so the two menus stay identical. + */ +export function ActionsMenu({ + align = 'end', + ariaLabel, + children, + contentClassName, + items, + onOpenChange, + open, + side, + sideOffset = 6, + tooltip +}: ActionsMenuProps) { + // Tip wraps the trigger, not the reverse: Tip doesn't forward the ref/props an + // `asChild` clone injects, so a Tip placed as the trigger's child silently + // drops onClick/aria-haspopup and the menu stops opening (#67500). + const trigger = {children} + + return ( + + {tooltip ? {trigger} : trigger} + + {items(DROPDOWN_KIT)} + + + ) +} + +interface ActionsContextMenuProps { + /** The area that receives right-click. Wrapped in `ContextMenuTrigger asChild`. */ + children: React.ReactNode + /** The action rows, rendered with `CONTEXT_KIT`. Share this with `ActionsMenu`. */ + items: (kit: MenuKit) => React.ReactNode + ariaLabel?: string + contentClassName?: string + /** Skip the wrapper (render children bare) — e.g. nothing is actionable yet. */ + disabled?: boolean +} + +/** + * Wrap a row so right-clicking it opens the same menu as its kebab. Pass the + * kebab's `items` render function so both surfaces mirror each other. + */ +export function ActionsContextMenu({ ariaLabel, children, contentClassName, disabled, items }: ActionsContextMenuProps) { + if (disabled) { + return <>{children} + } + + return ( + + {children} + + {items(CONTEXT_KIT)} + + + ) +}