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.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 19:04:39 -05:00
parent 96db67b849
commit 0d14864b94
3 changed files with 219 additions and 104 deletions

View file

@ -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<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial
const spec = (partial: Omit<ActionItemSpec, 'onSelect'> & { 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) => (
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
<Codicon name={icon} size="0.875rem" />
<span>{label}</span>
</Item>
)
const renderItems = (kit: MenuKit) => (
<>
{openItems.map(item => renderMenuItem(kit.Item, item))}
{openItems.map(item => renderActionItem(kit, item))}
{openItems.length > 0 && <kit.Separator />}
{identityItems.map(item => renderMenuItem(kit.Item, item))}
{identityItems.map(item => renderActionItem(kit, item))}
<kit.Sub>
<kit.SubTrigger disabled={!sessionId}>
<Codicon name="symbol-color" size="0.875rem" />
@ -393,7 +330,7 @@ function useSessionActions({
</kit.SubContent>
</kit.Sub>
<CopyButton
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
appearance={kit.copyAppearance}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
iconClassName="size-3.5 text-current"
@ -403,19 +340,19 @@ function useSessionActions({
text={sessionId}
/>
<kit.Separator />
{workItems.map(item => renderMenuItem(kit.Item, item))}
{workItems.map(item => renderActionItem(kit, item))}
{tabCloseItems.length > 0 && (
<>
<kit.Separator />
{tabCloseItems.map(item => renderMenuItem(kit.Item, item))}
{tabCloseItems.map(item => renderActionItem(kit, item))}
</>
)}
<kit.Separator />
{dangerItems.map(item => renderMenuItem(kit.Item, item))}
{dangerItems.map(item => renderActionItem(kit, item))}
{onHideTabBar && (
<>
<kit.Separator />
{renderMenuItem(kit.Item, {
{renderActionItem(kit, {
disabled: false,
icon: 'eye-closed',
label: r.hideTabBar,
@ -443,13 +380,9 @@ function useSessionActions({
}
interface SessionActionsMenuProps
extends SessionActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
extends SessionActions, Pick<React.ComponentProps<typeof ActionsMenu>, '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 (
<>
<DropdownMenu onOpenChange={setOpen} open={open}>
<Tip label={tooltip}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent
align={align}
aria-label={t.sidebar.row.actionsFor(actions.title)}
className="w-40"
sideOffset={sideOffset}
>
{renderItems(DROPDOWN_KIT)}
</DropdownMenuContent>
</DropdownMenu>
<ActionsMenu
align={align}
ariaLabel={t.sidebar.row.actionsFor(actions.title)}
contentClassName="w-40"
items={renderItems}
sideOffset={sideOffset}
tooltip={tooltip}
>
{children}
</ActionsMenu>
{renameDialog}
</>
)
@ -494,12 +423,13 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
{renderItems(CONTEXT_KIT)}
</ContextMenuContent>
</ContextMenu>
<ActionsContextMenu
ariaLabel={t.sidebar.row.actionsFor(actions.title)}
contentClassName="w-40"
items={renderItems}
>
{children}
</ActionsContextMenu>
{renameDialog}
</>
)

View file

@ -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<HTMLDivElement>
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}
>

View file

@ -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 (
<kit.Item
className={className}
disabled={disabled}
key={key ?? (typeof label === 'string' ? label : undefined)}
onSelect={onSelect}
variant={variant}
>
{iconNode ?? (icon ? <Codicon name={icon} size="0.875rem" /> : null)}
{typeof label === 'string' ? <span>{label}</span> : label}
</kit.Item>
)
}
interface ActionsMenuProps
extends Pick<React.ComponentProps<typeof DropdownMenuContent>, '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 = <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
return (
<DropdownMenu onOpenChange={onOpenChange} open={open}>
{tooltip ? <Tip label={tooltip}>{trigger}</Tip> : trigger}
<DropdownMenuContent
align={align}
aria-label={ariaLabel}
className={contentClassName}
side={side}
sideOffset={sideOffset}
>
{items(DROPDOWN_KIT)}
</DropdownMenuContent>
</DropdownMenu>
)
}
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 (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={ariaLabel} className={contentClassName}>
{items(CONTEXT_KIT)}
</ContextMenuContent>
</ContextMenu>
)
}