diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index c8487140b89..b4b0feaf95a 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -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 })} + + {renderActionItem(kit, { + icon: 'trash', + key: 'delete', + label: t.common.delete, + onSelect: () => void remove(), + variant: 'destructive' + })} + + ) + return (
-
- {/* 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. */} - - + + {/* Trailing cluster: countdown by default, quick actions on hover. */} +
+ + {meta} - - {label} - - - - - {/* Trailing cluster: countdown by default, quick actions on hover. */} -
- - {meta} - -
- - - - - - +
+ + + + + + +
-
+ {expanded && }
) diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 75e0020a793..a8c74e2a9c6 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -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={ - 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({ ). 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 - - - - {items.map(item => ( - - {item.icon ? : null} - {item.label} - - ))} - - + + + ) } diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index b04d9c6c32b..6b30a0789f6 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -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) { 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} diff --git a/apps/desktop/src/app/webhooks/index.tsx b/apps/desktop/src/app/webhooks/index.tsx index c2f86cb29ed..6e9c0fe1263 100644 --- a/apps/desktop/src/app/webhooks/index.tsx +++ b/apps/desktop/src/app/webhooks/index.tsx @@ -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={ - 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} />