feat(desktop): add Webhooks page for subscription CRUD (#69687)

* feat(desktop): add Webhooks page for subscription CRUD

Brings the desktop GUI to parity with the dashboard's Webhooks page.
Adds a /webhooks route that lists webhook subscriptions, enables the
webhook gateway platform, and creates/toggles/deletes subscriptions,
hitting the same /api/webhooks* endpoints the dashboard and CLI use.

- types/hermes.ts: WebhookRoute, WebhooksResponse, WebhookCreatePayload,
  WebhookCreateResponse, WebhookEnableResponse
- hermes.ts: getWebhooks, enableWebhooks, createWebhook, deleteWebhook,
  setWebhookEnabled (profile-scoped) + type re-exports
- app/webhooks/index.tsx: WebhooksView (enable card, restart banner,
  subscription list with copy/toggle/delete, create dialog with
  one-time secret reveal); optimistic toggle, profile re-home
- routing: routes.ts, contrib/surfaces.tsx, chat/route-tile.tsx
- nav: command palette, keybinds (nav.webhooks), sidebar row
- i18n: en + zh full, types interface; ja/zh-hant fall back to English
- test: webhooks-rest.test.ts covers the REST helper contracts

* feat(desktop): surface Webhooks in the status bar instead of nav

Moves the Webhooks entry point from the sidebar nav / command palette /
keybind to a status bar action next to Cron, matching where scheduled
jobs live. The /webhooks route, page, and REST helpers are unchanged.

- use-statusbar-items.tsx: add webhooks action (Globe icon) after cron
- i18n: shell.statusbar.webhooks / openWebhooks (en, zh, types)
- revert nav wiring: sidebar row, command palette entry, nav.webhooks
  keybind action + label, commandCenter.nav webhooks entry

* feat(desktop): add skills field to webhook create form

Exposes the backend's per-subscription skills list in the create dialog
(comma-separated) and shows skill badges on subscription rows, so this
page covers the skill-backed endpoint case as well as general CRUD.

- create form: Skills input; passes skills[] to createWebhook
- rows: render skill badges
- i18n: fieldSkills / fieldSkillsPlaceholder (en, zh, types)

Consolidates the skill-endpoint framing from #42817.

Co-authored-by: LionGateOS <98371158+LionGateOS@users.noreply.github.com>

* feat(desktop): render Webhooks as an overlay instead of a full page

Webhooks took over the whole workspace/chat pane because 'webhooks' was
missing from OVERLAY_VIEWS, so it routed through PageSearchShell while
cron rendered through the Panel overlay. Add it to OVERLAY_VIEWS and wire
it up like cron: mount WebhooksView as a floating overlay in wiring.tsx,
render null for the webhooks route in the workspace table, and convert
WebhooksView from PageSearchShell to the Panel primitive with an onClose.
Drop webhooks from route-tile BUILTIN_PAGES so it can't be tiled as a
page either.

* fix(desktop): place webhook URL copy button next to the URL

The URL span used flex-1, stretching it across the row and pushing the
copy button to the far right. Drop flex-1 so the span sizes to content
and the copy button sits directly after the URL.

* fix(desktop): top-align the deliver-only checkbox with its wrapped label

The label used h-9 items-center, centering the checkbox against the
two-line hint text. Switch to items-start so the checkbox aligns to the
first line.

* feat(desktop): give Webhooks a cron-style master/detail layout

Replace the single-column subscription list with the same Panel
master/detail cron uses: a left PanelList of subscription rows (status
dot + kebab menu) capped by a PanelAddButton, and a right PanelDetail
showing the selected subscription's deliver/events/skills, URL with copy,
description, and prompt. Enable/restart banners sit above the body; the
empty state keeps its own New subscription action.

* fix(desktop): drop header on the Webhooks empty state to match cron

The zero-subscriptions state still rendered the PanelHeader with the
title and the refresh/new buttons on the right. Remove it so the empty
state is just the centered PanelEmpty (icon, message, New subscription),
matching the cron empty state. Also drop the header from the loading
state.

* fix(desktop): top-align deliver-only checkbox and its wrapped label

Drop the min-h-9/pt-1.5 baseline shim that pushed the row down and made
the wrapped hint look misaligned. Use plain items-start with a mt-0.5 on
the checkbox so it sits at the first line, and wrap the hint in a
leading-snug span.

* fix(desktop): drop header refresh/new buttons; + button owns create flow

The populated Webhooks header carried a refresh icon and a New
subscription button. Remove both — the PanelAddButton at the bottom of
the list is the create flow, matching cron. Profile-change reload and the
refresh hotkey still run; the restart banner keeps its own refresh.

* fix(desktop): nudge deliver-only checkbox down 2px

Bump the checkbox top margin from mt-0.5 to mt-[4px] so it sits level
with the first line of the wrapped hint.

* refactor(desktop): reuse shared primitives on the Webhooks page

Address OutThisLife's review — stop reinventing primitives the app
already ships:
- copy: drop the local navigator.clipboard button for the shared
  CopyButton (routes through the Electron clipboard bridge + haptic +
  error state instead of swallowing failures)
- banners: enable/restart callouts now use Alert variant=warning
  (primary color-mix tokens) instead of a hand-rolled amber palette
- toggle: detail Enable/Disable is a Switch (messaging idiom), not a
  text ghost button
- checkbox: create dialog uses the Checkbox primitive, not a raw input

Rows/chips already moved to PanelListRow/PanelDetail/PanelPill in the
earlier cron-layout pass. Left the main-list fetch on manual load and
the local Field helper: cron itself does both, so useQuery here would
diverge from the reference idiom rather than align with it.

* refactor(desktop): move Webhooks fetch to the react-query layer

Replace the manual useState/useEffect load with useQuery keyed by
['webhooks', profileScope] — profile change re-fetches automatically, no
effect. reload() invalidates the query; the optimistic toggle writes the
cache via queryClient.setQueryData then invalidates so backend truth
wins. Load failures surface via an error-watching effect (react-query v5
dropped useQuery onError). Refresh hotkey calls refetch().

Left the create-dialog Field helper as-is: settings ListRow is a
side-by-side settings row, wrong for a stacked dialog form, and cron's
editor dialog (the reference) defines the same local Field.

* refactor(desktop): drop bespoke Field for shared ListRow/ToggleRow

Remove the local Field wrapper entirely. Every create-dialog field now
uses settings ListRow (wide, so label stacks over the full-width
control), the deliver-only pref uses ToggleRow (ListRow + Switch, haptic
baked in) instead of a bare Checkbox, and the created URL/secret reveal
rows use ListRow too. No component in this file is hand-rolled anymore.

* fix(desktop): pair webhook create fields into a 2-column layout

The single-column dialog scrolled awkwardly. Group fields: name +
description side by side, prompt full-width under them, events + skills
side by side, deliver-to + deliver-only side by side. Drop the
deliver-only help text and rename the label to 'Deliver payload only'
(remove the now-unused fieldDeliverOnlyHint i18n key from en/zh/types).

* fix(desktop): align Webhooks page with cron conventions and DESIGN.md

- Delete copy uses deleteDescPrefix + bolded name + deleteDescSuffix (no em-dash)
- Drop the duplicated detail-header Switch + Trash2; enable/disable and delete
  live only in the row kebab, matching CronJobDetail
- Collapse three copyable-value chromes into one flat token-backed CopyValueRow
- Delete success toast gains a w.deleted title
- Replace hand-rolled delete Dialog with shared ConfirmDialog

---------

Co-authored-by: LionGateOS <98371158+LionGateOS@users.noreply.github.com>
This commit is contained in:
Austin Pickett 2026-07-24 14:06:10 -04:00 committed by GitHub
parent 6ece52fc73
commit d82dfb6924
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1046 additions and 7 deletions

View file

@ -189,6 +189,7 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
<Route element={null} path="profiles" />
<Route element={null} path="settings" />
<Route element={null} path="starmap" />
<Route element={null} path="webhooks" />
{/* Registry-contributed pages (core features + plugins) render in the
workspace pane like any built-in view behind the same blast wall
as every other contribution mount. */}

View file

@ -111,6 +111,7 @@ import type { WiringActions, WiringApi } from './types'
const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView }))
const CronView = lazy(async () => ({ default: (await import('../cron')).CronView }))
const WebhooksView = lazy(async () => ({ default: (await import('../webhooks')).WebhooksView }))
const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView }))
const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView }))
const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView }))
@ -197,7 +198,8 @@ export function ContribWiring({ children }: { children: ReactNode }) {
resetOverlayReturnRoute,
settingsOpen,
starmapOpen,
toggleCommandCenter
toggleCommandCenter,
webhooksOpen
} = useOverlayRouting()
const {
@ -1006,6 +1008,12 @@ export function ContribWiring({ children }: { children: ReactNode }) {
</Suspense>
)}
{webhooksOpen && (
<Suspense fallback={null}>
<WebhooksView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />

View file

@ -9,6 +9,7 @@ export const SETTINGS_ROUTE = '/settings'
export const COMMAND_CENTER_ROUTE = '/command-center'
export const SKILLS_ROUTE = '/skills'
export const MESSAGING_ROUTE = '/messaging'
export const WEBHOOKS_ROUTE = '/webhooks'
export const ARTIFACTS_ROUTE = '/artifacts'
export const CRON_ROUTE = '/cron'
export const PROFILES_ROUTE = '/profiles'
@ -31,6 +32,7 @@ export type AppView =
| 'settings'
| 'skills'
| 'starmap'
| 'webhooks'
export type AppRouteId =
| 'agents'
@ -43,6 +45,7 @@ export type AppRouteId =
| 'settings'
| 'skills'
| 'starmap'
| 'webhooks'
export interface AppRoute {
id: AppRouteId
@ -56,6 +59,7 @@ export const APP_ROUTES = [
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
{ id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' },
{ id: 'webhooks', path: WEBHOOKS_ROUTE, view: 'webhooks' },
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
{ id: 'cron', path: CRON_ROUTE, view: 'cron' },
{ id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' },
@ -121,7 +125,8 @@ export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'cron',
'profiles',
'settings',
'starmap'
'starmap',
'webhooks'
])
export function isOverlayView(view: AppView): boolean {

View file

@ -24,6 +24,7 @@ export function useOverlayRouting() {
const starmapOpen = currentView === 'starmap'
const cronOpen = currentView === 'cron'
const profilesOpen = currentView === 'profiles'
const webhooksOpen = currentView === 'webhooks'
const chatOpen = currentView === 'chat'
const overlayOpen = isOverlayView(currentView)
@ -82,6 +83,7 @@ export function useOverlayRouting() {
resetOverlayReturnRoute,
settingsOpen,
starmapOpen,
toggleCommandCenter
toggleCommandCenter,
webhooksOpen
}
}

View file

@ -9,7 +9,7 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons'
import { Activity, AlertCircle, Clock, Command, FolderOpen, Globe, Hash, Loader2, Terminal } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
@ -43,7 +43,7 @@ import {
} from '@/store/updates'
import type { StatusResponse, UsageStats } from '@/types/hermes'
import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'
import { CRON_ROUTE, SETTINGS_ROUTE, WEBHOOKS_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const
@ -420,6 +420,14 @@ export function useStatusbarItems({
title: copy.openCron,
to: CRON_ROUTE,
variant: 'action'
},
{
icon: <Globe className="size-3" />,
id: 'webhooks',
label: copy.webhooks,
title: copy.openWebhooks,
to: WEBHOOKS_ROUTE,
variant: 'action'
}
],
[

View file

@ -0,0 +1,643 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createWebhook,
deleteWebhook,
enableWebhooks,
getWebhooks,
setWebhookEnabled,
type WebhookRoute,
type WebhooksResponse
} from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Globe, Plus, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profileScope } from '@/store/profile'
import { runGatewayRestart } from '@/store/system-actions'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import {
Panel,
PanelAddButton,
PanelBlock,
PanelBody,
PanelDetail,
PanelEmpty,
PanelHeader,
PanelList,
PanelListRow,
PanelMeta,
PanelPill,
PanelRowMenu,
PanelSectionLabel
} from '../overlays/panel'
import { ListRow, ToggleRow } from '../settings/primitives'
const DELIVER_OPTIONS: readonly string[] = ['log', 'telegram', 'discord', 'slack', 'email', 'github_comment']
interface CreatedWebhook {
secret: string
url: string
}
// One affordance for "value + CopyButton": flat, token-backed (no border, no
// raw literals). DESIGN.md Principle 1 (flat, not boxed) + 4 (tokens, not
// literals). Reused by the detail URL row and the create-result URL/secret so
// there is a single copyable-value chrome in this file.
function CopyValueRow({ copyLabel, mono = true, value }: { copyLabel: string; mono?: boolean; value: string }) {
return (
<div className="flex items-center gap-1 rounded bg-foreground/5 px-2.5 py-1.5 text-[0.7rem]">
<span className={cn('min-w-0 flex-1 truncate text-foreground/80', mono && 'font-mono')}>{value}</span>
<CopyButton appearance="icon" buttonSize="icon-sm" label={copyLabel} text={value} />
</div>
)
}
interface WebhooksViewProps {
onClose: () => void
}
export function WebhooksView({ onClose }: WebhooksViewProps) {
const { t } = useI18n()
const w = t.webhooks
// Re-load when the active profile changes so REST routes to the right backend.
const profileScope = useStore($profileScope)
const queryClient = useQueryClient()
const queryKey = useMemo(() => ['webhooks', profileScope] as const, [profileScope])
const [query, setQuery] = useState('')
const [enabling, setEnabling] = useState(false)
const [restartNeeded, setRestartNeeded] = useState(false)
const [restartError, setRestartError] = useState<null | string>(null)
const [restarting, setRestarting] = useState(false)
// Master/detail: the subscription whose config fills the right pane.
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [events, setEvents] = useState('')
const [deliver, setDeliver] = useState('log')
const [deliverOnly, setDeliverOnly] = useState(false)
const [prompt, setPrompt] = useState('')
const [skills, setSkills] = useState('')
const [creating, setCreating] = useState(false)
const [created, setCreated] = useState<CreatedWebhook | null>(null)
const [pendingDelete, setPendingDelete] = useState<null | string>(null)
const {
data,
error,
isPending: loading,
refetch
} = useQuery({
queryKey,
queryFn: getWebhooks
})
// React Query v5 dropped useQuery onError; surface a load failure toast once
// per error object instead.
useEffect(() => {
if (error) {
notifyError(error, w.loadFailed)
}
}, [error, w.loadFailed])
const enabled = data?.enabled ?? false
const subscriptions = useMemo(() => data?.subscriptions ?? [], [data])
// Pull fresh backend truth into the cache. `silent` swallows the error toast
// for post-mutation reconciles (the mutation already reported success/failure).
const reload = useCallback(
async (silent = false) => {
try {
await queryClient.invalidateQueries({ queryKey })
} catch (err) {
if (!silent) {
notifyError(err, w.loadFailed)
}
}
},
[queryClient, queryKey, w.loadFailed]
)
useRefreshHotkey(() => void refetch())
const restartGatewayNow = useCallback(async () => {
setRestarting(true)
try {
await runGatewayRestart()
setRestartNeeded(false)
setRestartError(null)
// Give the receiver a moment to bind before re-reading state.
window.setTimeout(() => void reload(true), 4000)
} catch (err) {
setRestartNeeded(true)
setRestartError(String(err))
notifyError(err, w.restartFailed(''))
} finally {
setRestarting(false)
}
}, [reload, w])
const handleEnable = useCallback(async () => {
setEnabling(true)
setRestartNeeded(false)
setRestartError(null)
try {
const result = await enableWebhooks()
await reload(true)
if (result.restart_started) {
notify({ kind: 'success', message: w.enabledRestarting })
window.setTimeout(() => void reload(true), 4000)
} else {
const detail = result.restart_error ? `: ${result.restart_error}` : '.'
setRestartNeeded(true)
setRestartError(w.restartFailed(detail))
notify({ kind: 'error', message: w.restartFailed(detail) })
}
} catch (err) {
notifyError(err, w.restartFailed(''))
} finally {
setEnabling(false)
}
}, [reload, w])
const resetForm = useCallback(() => {
setName('')
setDescription('')
setEvents('')
setDeliver('log')
setDeliverOnly(false)
setPrompt('')
setSkills('')
}, [])
const closeCreate = useCallback(() => {
if (creating) {
return
}
setCreateOpen(false)
setCreated(null)
}, [creating])
const handleCreate = useCallback(async () => {
if (!name.trim()) {
notify({ kind: 'error', message: w.nameRequired })
return
}
setCreating(true)
try {
const eventsList = events
.split(',')
.map(e => e.trim())
.filter(Boolean)
const skillsList = skills
.split(',')
.map(s => s.trim())
.filter(Boolean)
const res = await createWebhook({
deliver,
deliver_only: deliverOnly,
description: description.trim() || undefined,
events: eventsList.length ? eventsList : undefined,
name: name.trim(),
prompt: prompt.trim() || undefined,
skills: skillsList.length ? skillsList : undefined
})
notify({ kind: 'success', message: w.created })
setCreated({ secret: res.secret, url: res.url })
resetForm()
void reload(true)
} catch (err) {
notifyError(err, w.createFailed(''))
} finally {
setCreating(false)
}
}, [deliver, deliverOnly, description, events, name, prompt, reload, resetForm, skills, w])
const handleToggle = useCallback(
async (subName: string, nextEnabled: boolean) => {
// Optimistic cache paint; the invalidate below lets backend truth win.
queryClient.setQueryData<WebhooksResponse>(queryKey, current =>
current
? {
...current,
subscriptions: current.subscriptions.map(s =>
s.name === subName ? { ...s, enabled: nextEnabled } : s
)
}
: current
)
try {
await setWebhookEnabled(subName, nextEnabled)
notify({ kind: 'success', message: nextEnabled ? w.enabled(subName) : w.disabled(subName) })
void reload(true)
} catch (err) {
await reload(true)
notifyError(err, w.toggleFailed(subName))
}
},
[queryClient, queryKey, reload, w]
)
// ConfirmDialog owns the pending→done→close beat; throw to surface its inline
// error and keep the dialog open. Success toast matches the cron delete idiom
// (title + name).
const handleDelete = useCallback(async () => {
if (!pendingDelete) {
return
}
try {
await deleteWebhook(pendingDelete)
notify({ kind: 'success', title: w.deleted, message: pendingDelete })
void reload(true)
} catch (err) {
notifyError(err, w.deleteFailed(pendingDelete))
throw err
}
}, [pendingDelete, reload, w])
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) {
return subscriptions
}
return subscriptions.filter(s =>
[s.name, s.description, s.deliver, ...s.events].filter(Boolean).some(v => v.toLowerCase().includes(q))
)
}, [query, subscriptions])
// Detail always reflects a concrete sub: the explicitly selected one, else the
// first visible row, so the right pane is never empty while subs exist.
const selectedSub = useMemo(
() => visible.find(s => s.name === selectedName) ?? visible[0] ?? null,
[visible, selectedName]
)
const banners = (
<>
{!enabled && (
<Alert className="mb-4" variant="warning">
<Globe />
<AlertTitle>{w.disabledTitle}</AlertTitle>
<AlertDescription>
<p>{w.disabledBody}</p>
<Button className="mt-1" disabled={enabling} onClick={() => void handleEnable()} size="sm">
<Globe />
{enabling ? w.enabling : w.enable}
</Button>
</AlertDescription>
</Alert>
)}
{restartNeeded && (
<Alert className="mb-4" variant="warning">
<AlertTriangle />
<AlertDescription>
<p>{restartError ?? w.restartNeeded}</p>
<Button
className="mt-1"
disabled={restarting}
onClick={() => void restartGatewayNow()}
size="sm"
variant="secondary"
>
<RefreshCw />
{restarting ? w.restartingGateway : w.restartGateway}
</Button>
</AlertDescription>
</Alert>
)}
</>
)
return (
<Panel onClose={onClose}>
{loading ? (
<PageLoader label={w.loading} />
) : subscriptions.length === 0 ? (
<>
{banners}
<PanelEmpty
action={
<Button
disabled={!enabled || enabling}
onClick={() => {
setCreated(null)
setCreateOpen(true)
}}
size="sm"
>
<Plus />
{w.newSubscription}
</Button>
}
description={w.empty}
icon="globe"
/>
</>
) : (
<>
<PanelHeader subtitle={w.hint} title={w.subscriptions(subscriptions.length)} />
{banners}
<PanelBody>
<PanelList
onSearchChange={setQuery}
searchLabel={w.search}
searchPlaceholder={w.search}
searchValue={query}
>
{visible.map(sub => (
<PanelListRow
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' }
]}
/>
}
onSelect={() => setSelectedName(sub.name)}
title={sub.name}
/>
))}
{visible.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{w.empty}</p>
)}
<PanelAddButton
label={w.newSubscription}
onClick={() => {
setCreated(null)
setCreateOpen(true)
}}
/>
</PanelList>
{selectedSub ? (
<WebhookDetail sub={selectedSub} />
) : (
<PanelEmpty description={w.empty} icon="search" />
)}
</PanelBody>
</>
)}
{/* Create subscription dialog */}
<Dialog onOpenChange={open => !open && closeCreate()} open={createOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{created ? w.createdTitle : w.newSubscription}</DialogTitle>
{created && <DialogDescription>{w.createdSecretHint}</DialogDescription>}
</DialogHeader>
{created ? (
<div className="grid gap-2">
<ListRow action={<CopyValueRow copyLabel={w.copy} value={created.url} />} title={w.webhookUrl} wide />
<ListRow
action={<CopyValueRow copyLabel={w.copy} value={created.secret} />}
title={w.secretOnce}
wide
/>
<DialogFooter>
<Button onClick={closeCreate} size="sm">
{w.done}
</Button>
</DialogFooter>
</div>
) : (
<div className="grid gap-1">
<div className="grid grid-cols-2 gap-4">
<ListRow
action={
<Input
autoFocus
id="webhook-name"
onChange={e => setName(e.target.value)}
placeholder={w.fieldNamePlaceholder}
value={name}
/>
}
title={<label htmlFor="webhook-name">{w.fieldName}</label>}
wide
/>
<ListRow
action={
<Input
id="webhook-description"
onChange={e => setDescription(e.target.value)}
placeholder={w.fieldDescriptionPlaceholder}
value={description}
/>
}
title={<label htmlFor="webhook-description">{w.fieldDescription}</label>}
wide
/>
</div>
<ListRow
action={
<Textarea
className="min-h-[80px]"
id="webhook-prompt"
onChange={e => setPrompt(e.target.value)}
placeholder={w.fieldPromptPlaceholder}
value={prompt}
/>
}
title={<label htmlFor="webhook-prompt">{w.fieldPrompt}</label>}
wide
/>
<div className="grid grid-cols-2 gap-4">
<ListRow
action={
<Input
id="webhook-events"
onChange={e => setEvents(e.target.value)}
placeholder={w.fieldEventsPlaceholder}
value={events}
/>
}
title={<label htmlFor="webhook-events">{w.fieldEvents}</label>}
wide
/>
<ListRow
action={
<Input
id="webhook-skills"
onChange={e => setSkills(e.target.value)}
placeholder={w.fieldSkillsPlaceholder}
value={skills}
/>
}
title={<label htmlFor="webhook-skills">{w.fieldSkills}</label>}
wide
/>
</div>
<div className="grid grid-cols-2 items-start gap-4">
<ListRow
action={
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVER_OPTIONS.map(opt => (
<SelectItem key={opt} value={opt}>
{w.deliverOptions[opt] ?? opt}
</SelectItem>
))}
</SelectContent>
</Select>
}
title={<label htmlFor="webhook-deliver">{w.fieldDeliver}</label>}
wide
/>
<ToggleRow checked={deliverOnly} label={w.fieldDeliverOnly} onChange={setDeliverOnly} />
</div>
<DialogFooter>
<Button disabled={creating} onClick={() => void handleCreate()} size="sm">
{creating ? w.creating : w.create}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
busyLabel={w.deleting}
cancelLabel={t.common.cancel}
confirmLabel={w.delete}
description={
pendingDelete ? (
<>
{w.deleteDescPrefix}
<span className="font-medium text-foreground">{pendingDelete}</span>
{w.deleteDescSuffix}
</>
) : null
}
destructive
onClose={() => setPendingDelete(null)}
onConfirm={handleDelete}
open={pendingDelete !== null}
title={w.deleteTitle}
/>
</Panel>
)
}
function WebhookDetail({ sub }: { sub: WebhookRoute }) {
const { t } = useI18n()
const w = t.webhooks
return (
<PanelDetail>
<header className="space-y-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{sub.name}</h3>
<PanelPill tone={sub.enabled ? 'good' : 'muted'}>
{sub.enabled ? t.messaging.states.enabled : t.messaging.states.disabled}
</PanelPill>
{sub.deliver_only && <PanelPill tone="warn">{w.deliverOnly}</PanelPill>}
</div>
<PanelMeta
rows={[
{ label: w.fieldDeliver, value: w.deliverOptions[sub.deliver] ?? sub.deliver },
{
label: w.fieldEvents,
value:
sub.events.length === 0 ? (
w.all
) : (
<span className="flex flex-wrap gap-1">
{sub.events.map(evt => (
<PanelPill key={evt}>{evt}</PanelPill>
))}
</span>
)
},
...(sub.skills.length > 0
? [
{
label: w.fieldSkills,
value: (
<span className="flex flex-wrap gap-1">
{sub.skills.map(skill => (
<PanelPill key={skill}>{skill}</PanelPill>
))}
</span>
)
}
]
: [])
]}
/>
<CopyValueRow copyLabel={w.copy} value={sub.url} />
</header>
{sub.description ? (
<div className="space-y-1.5">
<PanelSectionLabel>{w.fieldDescription}</PanelSectionLabel>
<p className="text-xs leading-relaxed text-foreground/80">{sub.description}</p>
</div>
) : null}
{sub.prompt ? (
<div className="space-y-1.5">
<PanelSectionLabel>{w.fieldPrompt}</PanelSectionLabel>
<PanelBlock>{sub.prompt}</PanelBlock>
</div>
) : null}
</PanelDetail>
)
}

View file

@ -58,7 +58,11 @@ import type {
TerminalBackendsResponse,
ToolsetConfig,
ToolsetInfo,
ToolsetModelsResponse
ToolsetModelsResponse,
WebhookCreatePayload,
WebhookCreateResponse,
WebhookEnableResponse,
WebhooksResponse
} from '@/types/hermes'
// Desktop startup fires a burst of read-only data calls (config, profiles,
@ -202,7 +206,12 @@ export type {
ToolsetConfig,
ToolsetInfo,
ToolsetModel,
ToolsetModelsResponse
ToolsetModelsResponse,
WebhookCreatePayload,
WebhookCreateResponse,
WebhookEnableResponse,
WebhookRoute,
WebhooksResponse
} from '@/types/hermes'
export class HermesGateway extends JsonRpcGatewayClient {
@ -1122,6 +1131,55 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
})
}
// -- Webhooks (subscription CRUD) --------------------------------------------
// The webhook receiver is its own gateway platform; subscriptions live in a
// shared JSON store the CLI/dashboard also drive. Enable mutates config and
// best-effort restarts the gateway; subscription changes hot-reload.
export function getWebhooks(): Promise<WebhooksResponse> {
return window.hermesDesktop.api<WebhooksResponse>({
...profileScoped(),
path: '/api/webhooks'
})
}
export function enableWebhooks(): Promise<WebhookEnableResponse> {
return window.hermesDesktop.api<WebhookEnableResponse>({
...profileScoped(),
path: '/api/webhooks/enable',
method: 'POST'
})
}
export function createWebhook(body: WebhookCreatePayload): Promise<WebhookCreateResponse> {
return window.hermesDesktop.api<WebhookCreateResponse>({
...profileScoped(),
path: '/api/webhooks',
method: 'POST',
body
})
}
export function deleteWebhook(name: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}`,
method: 'DELETE'
})
}
export function setWebhookEnabled(
name: string,
enabled: boolean
): Promise<{ enabled: boolean; name: string; ok: boolean }> {
return window.hermesDesktop.api<{ enabled: boolean; name: string; ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}/enabled`,
method: 'PUT',
body: { enabled }
})
}
// Cron jobs are stored per-profile (<HERMES_HOME>/cron/jobs.json), and the
// backend's list endpoint defaults to 'all'. Pass a concrete profile key to
// list just that profile's jobs, or 'all' for the unified cross-profile view.

View file

@ -1422,6 +1422,73 @@ export const en: Translations = {
platformIntro: {}
},
webhooks: {
search: 'Search webhooks...',
loading: 'Loading webhooks...',
loadFailed: 'Webhooks failed to load',
subscriptions: (count: number) => `Subscriptions (${count})`,
hint: 'Subscription changes hot-reload once the receiver is running. Disabled subscriptions reject incoming events.',
empty: 'No webhook subscriptions yet.',
disabledTitle: 'Webhook receiver disabled',
disabledBody:
'Webhooks are their own gateway platform. Enable them here to accept incoming HTTP events; chat channels are only needed when a subscription delivers to Telegram, Discord, Slack, or another channel.',
enable: 'Enable webhooks',
enabling: 'Enabling...',
enabled: (name: string) => `Enabled: "${name}"`,
disabled: (name: string) => `Disabled: "${name}"`,
enableRow: 'Enable',
disableRow: 'Disable',
delete: 'Delete',
deleting: 'Deleting...',
deleted: 'Webhook deleted',
deleteTitle: 'Delete webhook',
deleteDescPrefix: 'This will permanently remove ',
deleteDescSuffix: '. This cannot be undone.',
deleteFailed: (name: string) => `Failed to delete "${name}"`,
toggleFailed: (name: string) => `Failed to update "${name}"`,
newSubscription: 'New subscription',
restarting: 'Gateway restarting...',
restartNeeded:
'Webhooks are enabled, but the gateway still needs a restart before the receiver can come online.',
restartGateway: 'Restart gateway',
restartingGateway: 'Restarting...',
restartFailed: (detail: string) => `Gateway restart failed${detail}`,
enabledRestarting: 'Webhooks enabled; gateway restarting...',
all: '(all)',
deliverOnly: 'deliver only',
createdTitle: 'Subscription created',
createdSecretHint: 'Copy the secret now — it is only shown once.',
webhookUrl: 'Webhook URL',
secretOnce: 'Secret (shown once)',
done: 'Done',
fieldName: 'Name',
fieldNamePlaceholder: 'e.g. github-push',
fieldDescription: 'Description',
fieldDescriptionPlaceholder: 'What this webhook does (optional)',
fieldEvents: 'Events',
fieldEventsPlaceholder: 'comma-separated, leave empty for all',
fieldSkills: 'Skills',
fieldSkillsPlaceholder: 'comma-separated skill names (optional)',
fieldDeliver: 'Deliver to',
fieldDeliverOnly: 'Deliver payload only',
fieldPrompt: 'Prompt',
fieldPromptPlaceholder: 'Instructions for the agent when this webhook fires (optional)',
nameRequired: 'Name required',
create: 'Create',
creating: 'Creating...',
created: 'Created',
createFailed: (detail: string) => `Failed to create: ${detail}`,
copy: 'Copy',
deliverOptions: {
log: 'Log',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: 'Email',
github_comment: 'GitHub comment'
}
},
profiles: {
close: 'Close profiles',
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
@ -2269,6 +2336,8 @@ export const en: Translations = {
running: count => `${count} running`,
cron: 'Cron',
openCron: 'Open cron jobs',
webhooks: 'Webhooks',
openWebhooks: 'Open webhooks',
starmap: 'Memory Graph',
openStarmap: 'Open memory graph',
turnRunning: 'Running',

View file

@ -1181,6 +1181,64 @@ export interface Translations {
platformIntro: Record<string, string>
}
webhooks: {
search: string
loading: string
loadFailed: string
subscriptions: (count: number) => string
hint: string
empty: string
disabledTitle: string
disabledBody: string
enable: string
enabling: string
enabled: (name: string) => string
disabled: (name: string) => string
enableRow: string
disableRow: string
delete: string
deleting: string
deleted: string
deleteTitle: string
deleteDescPrefix: string
deleteDescSuffix: string
deleteFailed: (name: string) => string
toggleFailed: (name: string) => string
newSubscription: string
restarting: string
restartNeeded: string
restartGateway: string
restartingGateway: string
restartFailed: (detail: string) => string
enabledRestarting: string
all: string
deliverOnly: string
createdTitle: string
createdSecretHint: string
webhookUrl: string
secretOnce: string
done: string
fieldName: string
fieldNamePlaceholder: string
fieldDescription: string
fieldDescriptionPlaceholder: string
fieldEvents: string
fieldEventsPlaceholder: string
fieldSkills: string
fieldSkillsPlaceholder: string
fieldDeliver: string
fieldDeliverOnly: string
fieldPrompt: string
fieldPromptPlaceholder: string
nameRequired: string
create: string
creating: string
created: string
createFailed: (detail: string) => string
copy: string
deliverOptions: Record<string, string>
}
profiles: {
close: string
nameHint: string
@ -1886,6 +1944,8 @@ export interface Translations {
running: (count: number) => string
cron: string
openCron: string
webhooks: string
openWebhooks: string
starmap: string
openStarmap: string
turnRunning: string

View file

@ -1614,6 +1614,72 @@ export const zh: Translations = {
}
},
webhooks: {
search: '搜索 Webhook…',
loading: '正在加载 Webhook…',
loadFailed: 'Webhook 加载失败',
subscriptions: (count: number) => `订阅(${count}`,
hint: '接收器运行后,订阅更改会热重载。已禁用的订阅会拒绝传入事件。',
empty: '暂无 Webhook 订阅。',
disabledTitle: 'Webhook 接收器已禁用',
disabledBody:
'Webhook 是独立的网关平台。在此启用以接受传入的 HTTP 事件;仅当订阅投递到 Telegram、Discord、Slack 或其他渠道时才需要聊天渠道。',
enable: '启用 Webhook',
enabling: '正在启用…',
enabled: (name: string) => `已启用:“${name}`,
disabled: (name: string) => `已禁用:“${name}`,
enableRow: '启用',
disableRow: '禁用',
delete: '删除',
deleting: '删除中…',
deleted: 'Webhook 已删除',
deleteTitle: '删除 Webhook',
deleteDescPrefix: '这将永久移除 ',
deleteDescSuffix: '。此操作无法撤销。',
deleteFailed: (name: string) => `删除“${name}”失败`,
toggleFailed: (name: string) => `更新“${name}”失败`,
newSubscription: '新建订阅',
restarting: '网关正在重启…',
restartNeeded: 'Webhook 已启用,但网关仍需重启后接收器才能上线。',
restartGateway: '重启网关',
restartingGateway: '正在重启…',
restartFailed: (detail: string) => `网关重启失败${detail}`,
enabledRestarting: 'Webhook 已启用;网关正在重启…',
all: '(全部)',
deliverOnly: '仅投递',
createdTitle: '订阅已创建',
createdSecretHint: '请立即复制密钥 —— 它只显示一次。',
webhookUrl: 'Webhook URL',
secretOnce: '密钥(仅显示一次)',
done: '完成',
fieldName: '名称',
fieldNamePlaceholder: '例如 github-push',
fieldDescription: '描述',
fieldDescriptionPlaceholder: '此 Webhook 的用途(可选)',
fieldEvents: '事件',
fieldEventsPlaceholder: '以逗号分隔,留空表示全部',
fieldSkills: '技能',
fieldSkillsPlaceholder: '以逗号分隔的技能名称(可选)',
fieldDeliver: '投递到',
fieldDeliverOnly: '仅投递载荷',
fieldPrompt: '提示词',
fieldPromptPlaceholder: '此 Webhook 触发时给智能体的说明(可选)',
nameRequired: '需要名称',
create: '创建',
creating: '正在创建…',
created: '已创建',
createFailed: (detail: string) => `创建失败:${detail}`,
copy: '复制',
deliverOptions: {
log: '日志',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: '电子邮件',
github_comment: 'GitHub 评论'
}
},
profiles: {
close: '关闭配置档案',
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
@ -2447,6 +2513,8 @@ export const zh: Translations = {
running: count => `${count} 个运行中`,
cron: '排程',
openCron: '打开排程任务',
webhooks: 'Webhook',
openWebhooks: '打开 Webhook',
starmap: '记忆图谱',
openStarmap: '打开记忆图谱',
turnRunning: '运行中',

View file

@ -243,6 +243,58 @@ export interface MessagingPlatformTestResponse {
state?: null | string
}
// -- Webhooks (subscription CRUD) --------------------------------------------
// Incoming HTTP event routes served by the webhook gateway platform. Backed by
// the same JSON store the CLI/dashboard use; per-route HMAC secrets are
// redacted on read and surfaced exactly once on create.
export interface WebhookRoute {
created_at: null | string
deliver: string
deliver_only: boolean
description: string
enabled: boolean
events: string[]
name: string
prompt: string
secret_set: boolean
skills: string[]
url: string
}
export interface WebhooksResponse {
base_url: string
enabled: boolean
subscriptions: WebhookRoute[]
}
export interface WebhookCreatePayload {
deliver?: string
deliver_chat_id?: string
deliver_only?: boolean
description?: string
events?: string[]
name: string
prompt?: string
skills?: string[]
}
// Create echoes the route summary plus the one-time secret.
export interface WebhookCreateResponse extends WebhookRoute {
secret: string
}
export interface WebhookEnableResponse {
enabled: true
needs_restart: boolean
ok: boolean
platform: 'webhook'
restart_action?: string
restart_error?: string
restart_pid?: null | number
restart_started?: boolean
}
export interface GatewayReadyPayload {
skin?: unknown
}

View file

@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createWebhook, deleteWebhook, enableWebhooks, getWebhooks, setWebhookEnabled } from './hermes'
describe('Webhook REST parity helpers', () => {
let api: ReturnType<typeof vi.fn>
beforeEach(() => {
api = vi.fn().mockResolvedValue({})
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
})
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'hermesDesktop')
})
it('lists webhooks from the admin endpoint', async () => {
await getWebhooks()
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/webhooks' }))
})
it('enables the webhook platform with POST', async () => {
await enableWebhooks()
expect(api).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', path: '/api/webhooks/enable' }))
})
it('creates a subscription with the full payload', async () => {
const body = {
deliver: 'telegram',
deliver_only: true,
description: 'push events',
events: ['push'],
name: 'github-push',
prompt: 'summarize the push'
}
await createWebhook(body)
expect(api).toHaveBeenCalledWith(expect.objectContaining({ body, method: 'POST', path: '/api/webhooks' }))
})
it('encodes the name when deleting a subscription', async () => {
await deleteWebhook('my hook')
expect(api).toHaveBeenCalledWith(expect.objectContaining({ method: 'DELETE', path: '/api/webhooks/my%20hook' }))
})
it('toggles a subscription enabled state via PUT', async () => {
await setWebhookEnabled('github-push', false)
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
body: { enabled: false },
method: 'PUT',
path: '/api/webhooks/github-push/enabled'
})
)
})
})