mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
96db67b849
commit
0d14864b94
3 changed files with 219 additions and 104 deletions
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
177
apps/desktop/src/components/ui/actions-menu.tsx
Normal file
177
apps/desktop/src/components/ui/actions-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue