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:
Brooklyn Nicholson 2026-07-27 20:56:48 -05:00
parent 71e7eb3c16
commit 48368bc4a7
4 changed files with 92 additions and 77 deletions

View file

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

View file

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

View file

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

View file

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