Files
hermes-agent/apps/desktop/src/app/messaging/index.tsx
Austin Pickett acce1a2452 feat(desktop): polish credentials settings and messaging env routing (#39217)
* feat(desktop): polish credentials settings and messaging env routing

Align Provider API Keys and Tools & Keys with Advanced ListRow inputs,
add Tools & Keys sidebar subnav, move platform env vars to Messaging via
channel_managed discovery, strip toolset emojis, and condense cron actions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): align Messaging credential inputs with settings ListRow style

Remove monospace inputs and use CREDENTIAL_CONTROL_CLASS + ListRow layout
to match Provider API Keys and Tools & Keys.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 14:01:15 -04:00

757 lines
25 KiB
TypeScript

import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
getMessagingPlatforms,
type MessagingEnvVarInfo,
type MessagingPlatformInfo,
updateMessagingPlatform
} from '@/hermes'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { PlatformAvatar } from './platform-icon'
interface MessagingViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
type EditMap = Record<string, Record<string, string>>
const STATE_LABELS: Record<string, string> = {
connected: 'Connected',
connecting: 'Connecting',
disabled: 'Disabled',
fatal: 'Error',
gateway_stopped: 'Messaging gateway stopped',
not_configured: 'Needs setup',
pending_restart: 'Restart needed',
retrying: 'Retrying',
startup_failed: 'Startup failed'
}
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
good: 'default',
muted: 'muted',
warn: 'warn',
bad: 'destructive'
}
const HINT_BY_STATE: Record<string, string> = {
pending_restart: 'Restart the gateway from the status bar to apply this change.',
gateway_stopped: 'Start the gateway from the status bar to connect.'
}
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
if (!enabled) {
return 'muted'
}
if (state === 'connected') {
return 'good'
}
if (state === 'fatal' || state === 'startup_failed') {
return 'bad'
}
return 'warn'
}
const trimEdits = (edits: Record<string, string>): Record<string, string> =>
Object.fromEntries(
Object.entries(edits)
.map(([k, v]) => [k, v.trim()])
.filter(([, v]) => v)
)
const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: string; placeholder?: string }> = {
TELEGRAM_BOT_TOKEN: {
label: 'Bot token',
help: 'Create a bot with @BotFather, then paste the token it gives you.',
placeholder: '123456:ABC...'
},
TELEGRAM_ALLOWED_USERS: {
label: 'Allowed Telegram user IDs',
help: 'Recommended. Comma-separated numeric IDs from @userinfobot. Without this, anyone can DM your bot.'
},
TELEGRAM_PROXY: {
label: 'Proxy URL',
help: 'Only needed on networks where Telegram is blocked.',
advanced: true
},
DISCORD_BOT_TOKEN: {
label: 'Bot token',
help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.'
},
DISCORD_ALLOWED_USERS: {
label: 'Allowed Discord user IDs',
help: 'Recommended. Comma-separated Discord user IDs.'
},
DISCORD_REPLY_TO_MODE: {
label: 'Reply style',
help: 'first, all, or off.',
advanced: true
},
DISCORD_ALLOW_ALL_USERS: {
label: 'Allow all Discord users',
help: 'Development only. When true, anyone can DM the bot without an allowlist.',
advanced: true
},
DISCORD_HOME_CHANNEL: {
label: 'Home channel ID',
help: 'Channel where the bot sends proactive messages (cron output, reminders).',
advanced: true
},
DISCORD_HOME_CHANNEL_NAME: {
label: 'Home channel name',
help: 'Display name for the home channel in logs and status output.',
advanced: true
},
BLUEBUBBLES_ALLOW_ALL_USERS: {
label: 'Allow all iMessage users',
help: 'When true, skip the BlueBubbles allowlist.',
advanced: true
},
MATTERMOST_ALLOW_ALL_USERS: {
label: 'Allow all Mattermost users',
advanced: true
},
MATTERMOST_HOME_CHANNEL: {
label: 'Home channel',
advanced: true
},
QQ_ALLOW_ALL_USERS: {
label: 'Allow all QQ users',
advanced: true
},
QQBOT_HOME_CHANNEL: {
label: 'QQ home channel',
help: 'Default channel or group for cron delivery.',
advanced: true
},
QQBOT_HOME_CHANNEL_NAME: {
label: 'QQ home channel name',
advanced: true
},
SLACK_BOT_TOKEN: {
label: 'Slack bot token',
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
placeholder: 'xoxb-...'
},
SLACK_APP_TOKEN: {
label: 'Slack app token',
help: 'Starts with xapp-. Required for Socket Mode.',
placeholder: 'xapp-...'
},
SLACK_ALLOWED_USERS: {
label: 'Allowed Slack user IDs',
help: 'Recommended. Comma-separated Slack user IDs.'
},
MATTERMOST_URL: {
label: 'Server URL',
placeholder: 'https://mattermost.example.com'
},
MATTERMOST_TOKEN: {
label: 'Bot token'
},
MATTERMOST_ALLOWED_USERS: {
label: 'Allowed user IDs',
help: 'Recommended. Comma-separated Mattermost user IDs.'
},
MATRIX_HOMESERVER: {
label: 'Homeserver URL',
placeholder: 'https://matrix.org'
},
MATRIX_ACCESS_TOKEN: {
label: 'Access token'
},
MATRIX_USER_ID: {
label: 'Bot user ID',
placeholder: '@hermes:example.org'
},
MATRIX_ALLOWED_USERS: {
label: 'Allowed Matrix user IDs',
help: 'Recommended. Comma-separated user IDs in @user:server format.'
},
SIGNAL_HTTP_URL: {
label: 'Signal bridge URL',
placeholder: 'http://127.0.0.1:8080',
help: 'URL of a running signal-cli REST bridge.'
},
SIGNAL_ACCOUNT: {
label: 'Phone number',
help: 'The number registered with your signal-cli bridge.'
},
SIGNAL_ALLOWED_USERS: {
label: 'Allowed Signal users',
help: 'Recommended. Comma-separated Signal identifiers.'
},
WHATSAPP_ENABLED: {
label: 'Enable WhatsApp bridge',
help: 'Set automatically by the toggle below. Leave alone unless you know you need it.',
advanced: true
},
WHATSAPP_MODE: {
label: 'Bridge mode',
advanced: true
},
WHATSAPP_ALLOWED_USERS: {
label: 'Allowed WhatsApp users',
help: 'Recommended. Comma-separated phone numbers or WhatsApp IDs.'
}
}
function fieldCopy(field: MessagingEnvVarInfo) {
const copy = FIELD_COPY[field.key] || {}
return {
label: copy.label || field.prompt || field.key,
help: copy.help || field.description,
placeholder: copy.placeholder || field.prompt,
advanced: Boolean(copy.advanced || field.advanced)
}
}
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState<string | null>(null)
const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms])
const [selectedId, setSelectedId] = useRouteEnumParam('platform', platformIds, platformIds[0] ?? '')
const refreshPlatforms = useCallback(async (silent = false) => {
if (!silent) {
setRefreshing(true)
}
try {
const result = await getMessagingPlatforms()
setPlatforms(result.platforms)
} catch (err) {
if (!silent) {
notifyError(err, 'Messaging platforms failed to load')
}
} finally {
if (!silent) {
setRefreshing(false)
}
}
}, [])
useRefreshHotkey(() => void refreshPlatforms())
useEffect(() => {
void refreshPlatforms()
}, [refreshPlatforms])
// Auto-poll while the user is on the messaging page so connection status
// updates without a manual "check" click. Pause when the tab is hidden.
useEffect(() => {
let cancelled = false
function tick() {
if (cancelled || document.hidden) {
return
}
void refreshPlatforms(true)
}
const id = window.setInterval(tick, 6000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [refreshPlatforms])
const selected = useMemo(() => {
if (!platforms) {
return null
}
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
}, [platforms, selectedId])
const visiblePlatforms = useMemo(() => {
if (!platforms) {
return []
}
const q = query.trim().toLowerCase()
if (!q) {
return platforms
}
return platforms.filter(platform =>
[platform.id, platform.name, platform.description, platform.state]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(q))
)
}, [platforms, query])
async function handleToggle(platform: MessagingPlatformInfo, enabled: boolean) {
setSaving(`enabled:${platform.id}`)
try {
await updateMessagingPlatform(platform.id, { enabled })
setPlatforms(
current =>
current?.map(row =>
row.id === platform.id
? {
...row,
enabled,
state: enabled ? (row.configured ? 'pending_restart' : 'not_configured') : 'disabled'
}
: row
) ?? current
)
notify({
kind: 'success',
title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`,
message: 'Restart the gateway for this change to take effect.'
})
} catch (err) {
notifyError(err, `Failed to update ${platform.name}`)
} finally {
setSaving(null)
}
}
async function handleSave(platform: MessagingPlatformInfo) {
const env = trimEdits(edits[platform.id] || {})
if (Object.keys(env).length === 0) {
return
}
setSaving(`env:${platform.id}`)
try {
await updateMessagingPlatform(platform.id, { env })
setEdits(current => ({ ...current, [platform.id]: {} }))
await refreshPlatforms()
notify({
kind: 'success',
title: `${platform.name} setup saved`,
message: 'Restart the gateway to reconnect with the new credentials.'
})
} catch (err) {
notifyError(err, `Failed to save ${platform.name}`)
} finally {
setSaving(null)
}
}
async function handleClear(platform: MessagingPlatformInfo, key: string) {
setSaving(`clear:${key}`)
try {
await updateMessagingPlatform(platform.id, { clear_env: [key] })
setEdits(current => ({
...current,
[platform.id]: {
...(current[platform.id] || {}),
[key]: ''
}
}))
await refreshPlatforms()
notify({ kind: 'success', title: `${key} cleared`, message: `${platform.name} setup was updated.` })
} catch (err) {
notifyError(err, `Failed to clear ${key}`)
} finally {
setSaving(null)
}
}
return (
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchHidden={(platforms?.length ?? 0) === 0}
searchPlaceholder="Search messaging..."
searchValue={query}
>
{!platforms ? (
<PageLoader label="Loading messaging platforms..." />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto p-2">
<ul className="space-y-1">
{visiblePlatforms.map(platform => (
<li key={platform.id}>
<PlatformRow
active={selected?.id === platform.id}
onSelect={() => setSelectedId(platform.id)}
platform={platform}
/>
</li>
))}
</ul>
</aside>
<main className="min-h-0 overflow-hidden">
{selected && (
<PlatformDetail
edits={edits[selected.id] || {}}
onClear={key => void handleClear(selected, key)}
onEdit={(key, value) =>
setEdits(current => ({
...current,
[selected.id]: {
...(current[selected.id] || {}),
[key]: value
}
}))
}
onSave={() => void handleSave(selected)}
onToggle={enabled => void handleToggle(selected, enabled)}
platform={selected}
saving={saving}
/>
)}
</main>
</div>
)}
</PageSearchShell>
)
}
function PlatformRow({
active,
onSelect,
platform
}: {
active: boolean
onSelect: () => void
platform: MessagingPlatformInfo
}) {
return (
<button
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
active
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
)}
onClick={onSelect}
type="button"
>
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-normal">{platform.name}</span>
<StatusDot tone={stateTone(platform)} />
</span>
</button>
)
}
function PlatformDetail({
edits,
onClear,
onEdit,
onSave,
onToggle,
platform,
saving
}: {
edits: Record<string, string>
onClear: (key: string) => void
onEdit: (key: string, value: string) => void
onSave: () => void
onToggle: (enabled: boolean) => void
platform: MessagingPlatformInfo
saving: string | null
}) {
const [showAdvanced, setShowAdvanced] = useState(false)
const hasEdits = Object.keys(trimEdits(edits)).length > 0
const requiredFields = platform.env_vars.filter(field => field.required)
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field).advanced)
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field).advanced)
const hiddenCount = advancedFields.length
const isSavingEnv = saving === `env:${platform.id}`
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-5 px-5 py-4">
<header className="flex items-start gap-3">
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<div className="min-w-0 flex-1">
<h3 className="text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{platform.description}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
<SetupPill active={platform.configured}>
{platform.configured ? 'Credentials set' : 'Needs setup'}
</SetupPill>
{!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>}
</div>
<PlatformHint platform={platform} />
</div>
</header>
{platform.error_message && (
<div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{platform.error_message}</span>
</div>
)}
<section>
<SectionTitle>Get your credentials</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
Open setup guide
<ExternalLink className="size-3.5" />
</a>
</Button>
</div>
</section>
<section>
<SectionTitle>Required</SectionTitle>
<div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))
) : (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
This platform does not need a token here. Use the setup guide above, then enable it below.
</p>
)}
</div>
</section>
{optionalFields.length > 0 && (
<section>
<SectionTitle>Recommended</SectionTitle>
<div className="mt-3 grid gap-1">
{optionalFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))}
</div>
</section>
)}
{hiddenCount > 0 && (
<section>
<button
className="flex w-full items-center justify-between gap-2 rounded-lg px-1 py-1 text-left text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
<span>Advanced ({hiddenCount})</span>
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
<div className="mt-3 grid gap-1">
{advancedFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))}
</div>
)}
</section>
)}
</div>
</div>
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<Switch
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
<Save />
{isSavingEnv ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
</footer>
</div>
)
}
const PLATFORM_INTRO: Record<string, string> = {
telegram:
'In Telegram, talk to @BotFather, run /newbot, and copy the token it gives you. Then grab your numeric user ID from @userinfobot.',
discord:
'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.',
slack:
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the Bot token (xoxb-) and App-level token (xapp-).',
mattermost:
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
signal:
'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
whatsapp:
'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.',
bluebubbles:
'Run BlueBubbles Server on a Mac with iMessage, expose its API, then point Hermes at the URL with the server password.',
homeassistant:
'In Home Assistant, open your profile and create a long-lived access token. Paste it here along with your HA URL.',
email:
'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.',
sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.',
dingtalk: 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
feishu:
'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.',
wecom:
'Add a group robot in WeCom and copy its webhook key as WECOM_BOT_ID. Send-only — use the WeCom (app) option for two-way.',
wecom_callback:
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
weixin:
'Sign in to the WeChat Official Account platform, copy the AppID and Token, and point the message callback URL at Hermes.',
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
api_server:
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
webhook:
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
}
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
function MessagingField({
edits,
field,
onClear,
onEdit,
saving
}: {
edits: Record<string, string>
field: MessagingEnvVarInfo
onClear: (key: string) => void
onEdit: (key: string, value: string) => void
saving: string | null
}) {
const copy = fieldCopy(field)
const fieldId = `messaging-field-${field.key}`
return (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
className={CREDENTIAL_CONTROL_CLASS}
id={fieldId}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{field.is_set && (
<Button
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
}
description={copy.help}
title={
<span className="flex flex-wrap items-center gap-2">
<label htmlFor={fieldId}>{copy.label}</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</span>
}
/>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
}
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
if (!platform.enabled || platform.state === 'connected') {
return null
}
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
}
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
return (
<Badge variant={TONE_VARIANT[tone]}>
<StatusDot tone={tone} />
{children}
</Badge>
)
}
function SetupPill({ active, children }: { active: boolean; children: string }) {
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
}