mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(desktop): right-click parity for every panel list row
Teach PanelListRow a menuItems prop that renders BOTH the hover kebab and a matching right-click menu from one PanelMenuItem[] (via the shared actions-menu primitive), so a panel row's two menus can't drift. Migrate the cron, webhooks, and profiles panels onto it — right-clicking a row now opens the same edit/delete/enable actions as its kebab.
This commit is contained in:
parent
71e7eb3c16
commit
48368bc4a7
4 changed files with 92 additions and 77 deletions
|
|
@ -63,10 +63,10 @@ import {
|
|||
PanelHeader,
|
||||
PanelList,
|
||||
PanelListRow,
|
||||
type PanelMenuItem,
|
||||
PanelMeta,
|
||||
PanelPill,
|
||||
type PanelPillTone,
|
||||
PanelRowMenu,
|
||||
PanelSectionLabel
|
||||
} from '../overlays/panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
|
@ -501,14 +501,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
active={selectedJob?.id === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
menu={
|
||||
<PanelRowMenu
|
||||
items={[
|
||||
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
|
||||
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
menuItems={[
|
||||
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
|
||||
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
|
||||
]}
|
||||
menuLabel={c.manage}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -571,12 +568,14 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
function CronJobListRow({
|
||||
active,
|
||||
job,
|
||||
menu,
|
||||
menuItems,
|
||||
menuLabel,
|
||||
onSelect
|
||||
}: {
|
||||
active: boolean
|
||||
job: CronJob
|
||||
menu?: React.ReactNode
|
||||
menuItems?: PanelMenuItem[]
|
||||
menuLabel?: string
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const state = jobState(job)
|
||||
|
|
@ -585,7 +584,8 @@ function CronJobListRow({
|
|||
<PanelListRow
|
||||
active={active}
|
||||
dotClassName={STATE_DOT[state] ?? 'bg-muted-foreground'}
|
||||
menu={menu}
|
||||
menuItems={menuItems}
|
||||
menuLabel={menuLabel}
|
||||
onSelect={onSelect}
|
||||
rowKey={job.id}
|
||||
title={jobTitle(job)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { RowButton } from '@/components/ui/row-button'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
|
|
@ -149,8 +149,13 @@ interface PanelListRowProps {
|
|||
icon?: string
|
||||
// Custom leading element (colored swatch, avatar, …). Wins over dot/icon.
|
||||
lead?: ReactNode
|
||||
// Trailing per-row kebab menu (pass a <PanelRowMenu/>). Reveals on hover/focus.
|
||||
// Per-row actions. Pass `menuItems` to get BOTH the hover kebab and a matching
|
||||
// right-click menu from one array (preferred). `menu` takes a raw node for the
|
||||
// rare custom trigger; it gets no right-click parity.
|
||||
menu?: ReactNode
|
||||
menuItems?: PanelMenuItem[]
|
||||
// aria/tooltip label for the kebab + right-click menu built from `menuItems`.
|
||||
menuLabel?: string
|
||||
// Short always-visible trailing meta (a tag/time, like the trace label's duration).
|
||||
meta?: ReactNode
|
||||
onSelect: () => void
|
||||
|
|
@ -160,19 +165,22 @@ interface PanelListRowProps {
|
|||
|
||||
// A row is a container (not a <button>) so it can host both the select target
|
||||
// and a kebab menu without nesting interactive elements. Hover/active bg lives
|
||||
// on the wrapper so the whole row highlights as one.
|
||||
// on the wrapper so the whole row highlights as one. When `menuItems` is passed,
|
||||
// the whole row also answers right-click with the same actions as its kebab.
|
||||
export function PanelListRow({
|
||||
active,
|
||||
dotClassName,
|
||||
icon,
|
||||
lead,
|
||||
menu,
|
||||
menuItems,
|
||||
menuLabel,
|
||||
meta,
|
||||
onSelect,
|
||||
rowKey,
|
||||
title
|
||||
}: PanelListRowProps) {
|
||||
return (
|
||||
const row = (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row row-hover relative flex h-7 w-full items-center rounded-md text-[0.78rem] hover:text-foreground',
|
||||
|
|
@ -193,9 +201,25 @@ export function PanelListRow({
|
|||
<span className="min-w-0 flex-1 truncate font-medium text-foreground/85">{title}</span>
|
||||
</RowButton>
|
||||
{meta ? <span className="shrink-0 pr-2 text-[0.62rem] tabular-nums text-muted-foreground/45">{meta}</span> : null}
|
||||
{menu ? <div className="shrink-0 pr-1">{menu}</div> : null}
|
||||
{menuItems ? (
|
||||
<div className="shrink-0 pr-1">
|
||||
<PanelRowMenu items={menuItems} label={menuLabel} />
|
||||
</div>
|
||||
) : menu ? (
|
||||
<div className="shrink-0 pr-1">{menu}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Right-click parity: same items as the kebab. `disabled` (no actionable
|
||||
// items) renders the row bare.
|
||||
return menuItems ? (
|
||||
<ActionsContextMenu ariaLabel={menuLabel} contentClassName="w-40" disabled={menuItems.length === 0} items={renderPanelMenuItems(menuItems)}>
|
||||
{row}
|
||||
</ActionsContextMenu>
|
||||
) : (
|
||||
row
|
||||
)
|
||||
}
|
||||
|
||||
export interface PanelMenuItem {
|
||||
|
|
@ -206,6 +230,22 @@ export interface PanelMenuItem {
|
|||
tone?: 'danger' | 'default'
|
||||
}
|
||||
|
||||
// Bridge PanelMenuItem[] → the shared actions-menu render fn, so a panel row's
|
||||
// kebab and its right-click menu render from one source.
|
||||
function renderPanelMenuItems(items: PanelMenuItem[]) {
|
||||
return (kit: MenuKit) =>
|
||||
items.map(item =>
|
||||
renderActionItem(kit, {
|
||||
disabled: item.disabled,
|
||||
icon: item.icon,
|
||||
key: item.label,
|
||||
label: item.label,
|
||||
onSelect: item.onSelect,
|
||||
variant: item.tone === 'danger' ? 'destructive' : 'default'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Per-row "⋮" actions menu — mirrors the sidebar session row's settled pattern
|
||||
// (size-5 ghost trigger + kebab-vertical codicon + w-40 content). Hidden until
|
||||
// the row is hovered/focused (or the menu is open). Returns null with no items
|
||||
|
|
@ -216,33 +256,16 @@ export function PanelRowMenu({ items, label = 'Actions' }: { items: PanelMenuIte
|
|||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tip label={label}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="size-5 rounded-[4px] bg-transparent text-(--ui-text-tertiary) opacity-0 transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 focus-visible:ring-0 group-hover/row:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100 [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-40" sideOffset={6}>
|
||||
{items.map(item => (
|
||||
<DropdownMenuItem
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
onSelect={item.onSelect}
|
||||
variant={item.tone === 'danger' ? 'destructive' : undefined}
|
||||
>
|
||||
{item.icon ? <Codicon name={item.icon} size="0.875rem" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ActionsMenu ariaLabel={label} contentClassName="w-40" items={renderPanelMenuItems(items)} tooltip={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="size-5 rounded-[4px] bg-transparent text-(--ui-text-tertiary) opacity-0 transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 focus-visible:ring-0 group-hover/row:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100 [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.875rem" />
|
||||
</Button>
|
||||
</ActionsMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ import {
|
|||
PanelHeader,
|
||||
PanelList,
|
||||
PanelListRow,
|
||||
type PanelMenuItem,
|
||||
PanelMeta,
|
||||
PanelPill,
|
||||
PanelRowMenu,
|
||||
PanelSectionLabel
|
||||
} from '../overlays/panel'
|
||||
|
||||
|
|
@ -197,22 +197,18 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
key={profile.name}
|
||||
menu={
|
||||
<PanelRowMenu
|
||||
items={
|
||||
profile.is_default
|
||||
? []
|
||||
: [
|
||||
{ icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) },
|
||||
{
|
||||
icon: 'trash',
|
||||
label: t.common.delete,
|
||||
onSelect: () => setPendingDelete(profile),
|
||||
tone: 'danger'
|
||||
}
|
||||
]
|
||||
}
|
||||
/>
|
||||
menuItems={
|
||||
profile.is_default
|
||||
? []
|
||||
: [
|
||||
{ icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) },
|
||||
{
|
||||
icon: 'trash',
|
||||
label: t.common.delete,
|
||||
onSelect: () => setPendingDelete(profile),
|
||||
tone: 'danger'
|
||||
}
|
||||
]
|
||||
}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
|
|
@ -281,12 +277,12 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
|
||||
function ProfileRow({
|
||||
active,
|
||||
menu,
|
||||
menuItems,
|
||||
onSelect,
|
||||
profile
|
||||
}: {
|
||||
active: boolean
|
||||
menu?: React.ReactNode
|
||||
menuItems: PanelMenuItem[]
|
||||
onSelect: () => void
|
||||
profile: ProfileInfo
|
||||
}) {
|
||||
|
|
@ -302,7 +298,8 @@ function ProfileRow({
|
|||
name={profile.name}
|
||||
/>
|
||||
}
|
||||
menu={menu}
|
||||
menuItems={menuItems}
|
||||
menuLabel={profile.name}
|
||||
onSelect={onSelect}
|
||||
rowKey={profile.name}
|
||||
title={profile.name}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ import {
|
|||
PanelListRow,
|
||||
PanelMeta,
|
||||
PanelPill,
|
||||
PanelRowMenu,
|
||||
PanelSectionLabel
|
||||
} from '../overlays/panel'
|
||||
import { ListRow } from '../settings/primitives'
|
||||
|
|
@ -387,18 +386,14 @@ export function WebhooksView({ onClose }: WebhooksViewProps) {
|
|||
active={selectedSub?.name === sub.name}
|
||||
dotClassName={sub.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/50'}
|
||||
key={sub.name}
|
||||
menu={
|
||||
<PanelRowMenu
|
||||
items={[
|
||||
{
|
||||
icon: sub.enabled ? 'circle-slash' : 'check',
|
||||
label: sub.enabled ? w.disableRow : w.enableRow,
|
||||
onSelect: () => void handleToggle(sub.name, !sub.enabled)
|
||||
},
|
||||
{ icon: 'trash', label: w.delete, onSelect: () => setPendingDelete(sub.name), tone: 'danger' }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
menuItems={[
|
||||
{
|
||||
icon: sub.enabled ? 'circle-slash' : 'check',
|
||||
label: sub.enabled ? w.disableRow : w.enableRow,
|
||||
onSelect: () => void handleToggle(sub.name, !sub.enabled)
|
||||
},
|
||||
{ icon: 'trash', label: w.delete, onSelect: () => setPendingDelete(sub.name), tone: 'danger' }
|
||||
]}
|
||||
onSelect={() => setSelectedName(sub.name)}
|
||||
title={sub.name}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue