Merge pull request #73040 from NousResearch/bb/context-menu-parity-2

Right-click parity for cron, webhooks, and profile rows
This commit is contained in:
brooklyn! 2026-07-27 21:03:32 -05:00 committed by GitHub
commit b6244959a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 207 additions and 136 deletions

View file

@ -1,15 +1,18 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import { Tip } from '@/components/ui/tooltip'
import { getCronJobRuns, type SessionInfo } from '@/hermes'
import { deleteCronJob, getCronJobRuns, pauseCronJob, resumeCronJob, type SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { fmtDayTime, relativeTime } from '@/lib/time'
import { cn } from '@/lib/utils'
import { updateCronJobs } from '@/store/cron'
import { notify, notifyError } from '@/store/notifications'
import { $selectedStoredSessionId } from '@/store/session'
import type { CronJob } from '@/types/hermes'
@ -191,75 +194,128 @@ function CronJobSidebarRow({
const state = jobState(job)
const next = nextRunMs(job)
const label = jobTitle(job)
const isPaused = state === 'paused'
const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) : next !== null ? relativeTime(next, nowMs) : '—'
// Pause/resume and delete aren't threaded through the sidebar's prop chain, so
// drive them against the shared $cronJobs atom directly (same path the cron
// overlay uses) — the sidebar and overlay render from that one atom, so the
// row updates in place.
const togglePause = async () => {
try {
const updated = isPaused ? await resumeCronJob(job.id) : await pauseCronJob(job.id)
updateCronJobs(rows => rows.map(row => (row.id === job.id ? updated : row)))
notify({ kind: 'success', title: isPaused ? c.resumed : c.paused, message: label })
} catch (err) {
notifyError(err, c.failedUpdate)
}
}
const remove = async () => {
if (!window.confirm(`${c.deleteDescPrefix}${label}${c.deleteDescSuffix}`)) {
return
}
try {
await deleteCronJob(job.id)
updateCronJobs(rows => rows.filter(row => row.id !== job.id))
notify({ kind: 'success', title: c.deleted, message: label })
} catch (err) {
notifyError(err, c.failedDelete)
}
}
// One action set for both the hover buttons and the right-click menu.
const items = (kit: MenuKit) => (
<>
{renderActionItem(kit, { icon: 'zap', key: 'trigger', label: c.triggerNow, onSelect: onTrigger })}
{renderActionItem(kit, {
icon: isPaused ? 'play' : 'debug-pause',
key: 'pause',
label: isPaused ? c.resume : c.pause,
onSelect: () => void togglePause()
})}
{renderActionItem(kit, { icon: 'watch', key: 'manage', label: c.manage, onSelect: onManage })}
<kit.Separator />
{renderActionItem(kit, {
icon: 'trash',
key: 'delete',
label: t.common.delete,
onSelect: () => void remove(),
variant: 'destructive'
})}
</>
)
return (
<div>
<div className="group/cron relative grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_auto] items-center rounded-md hover:bg-(--chrome-action-hover)">
{/* Lead with the dot in the same w-3.5 cell + pl-2 the session rows use
so the cron dots line up with the sessions above; the caret sits next
to the label (matching the other sidebar disclosures) and the whole
label area toggles the run peek. */}
<Tip label={label}>
<button
aria-expanded={expanded}
aria-label={expanded ? c.hideRuns : c.showRuns}
className="flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onTogglePeek}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<span
aria-hidden="true"
<ActionsContextMenu ariaLabel={c.actionsFor(label)} contentClassName="w-44" items={items}>
<div className="group/cron relative grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_auto] items-center rounded-md hover:bg-(--chrome-action-hover)">
{/* Lead with the dot in the same w-3.5 cell + pl-2 the session rows use
so the cron dots line up with the sessions above; the caret sits next
to the label (matching the other sidebar disclosures) and the whole
label area toggles the run peek. */}
<Tip label={label}>
<button
aria-expanded={expanded}
aria-label={expanded ? c.hideRuns : c.showRuns}
className="flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onTogglePeek}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<span
aria-hidden="true"
className={cn(
'size-1 rounded-full',
STATE_DOT[state] ?? 'bg-(--ui-text-quaternary)',
state === 'running' && 'size-1.5 animate-pulse'
)}
/>
</span>
<span className="min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary) group-hover/cron:text-foreground">
{label}
</span>
<DisclosureCaret
className={cn(
'size-1 rounded-full',
STATE_DOT[state] ?? 'bg-(--ui-text-quaternary)',
state === 'running' && 'size-1.5 animate-pulse'
'shrink-0 text-(--ui-text-tertiary) transition',
expanded ? 'opacity-100' : 'opacity-0 group-hover/cron:opacity-100'
)}
open={expanded}
/>
</button>
</Tip>
{/* Trailing cluster: countdown by default, quick actions on hover. */}
<div className="flex items-center gap-0.5 justify-self-end pr-1">
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">
{meta}
</span>
<span className="min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary) group-hover/cron:text-foreground">
{label}
</span>
<DisclosureCaret
className={cn(
'shrink-0 text-(--ui-text-tertiary) transition',
expanded ? 'opacity-100' : 'opacity-0 group-hover/cron:opacity-100'
)}
open={expanded}
/>
</button>
</Tip>
{/* Trailing cluster: countdown by default, quick actions on hover. */}
<div className="flex items-center gap-0.5 justify-self-end pr-1">
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">
{meta}
</span>
<div className="hidden items-center gap-0.5 group-hover/cron:flex">
<Tip label={c.triggerNow}>
<button
aria-label={c.triggerNow}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onTrigger}
type="button"
>
<Codicon name="zap" size="0.75rem" />
</button>
</Tip>
<Tip label={c.manage}>
<button
aria-label={c.manage}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onManage}
type="button"
>
<Codicon name="watch" size="0.75rem" />
</button>
</Tip>
<div className="hidden items-center gap-0.5 group-hover/cron:flex">
<Tip label={c.triggerNow}>
<button
aria-label={c.triggerNow}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onTrigger}
type="button"
>
<Codicon name="zap" size="0.75rem" />
</button>
</Tip>
<Tip label={c.manage}>
<button
aria-label={c.manage}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onManage}
type="button"
>
<Codicon name="watch" size="0.75rem" />
</button>
</Tip>
</div>
</div>
</div>
</div>
</ActionsContextMenu>
{expanded && <CronJobSidebarRuns jobId={job.id} onOpenRun={onOpenRun} />}
</div>
)

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