From f790c612075709911c97a79bc21b4492047fd745 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 8 May 2026 15:59:12 -0400 Subject: [PATCH] feat(gui): first-class Messaging page + gateway menu redesign - Add Messaging page to the desktop app with per-platform setup, status, and inline guidance. Catalog derives from gateway.config Platform enum + plugin registry, so every messaging adapter the CLI supports (Telegram, Discord, Slack, Mattermost, Matrix, WhatsApp, Signal, BlueBubbles, Home Assistant, Email, SMS, DingTalk, Feishu, WeCom, Weixin, QQ, Yuanbao, API server, Webhooks, plugins) shows up without per-platform code. - New REST endpoints: GET /api/messaging/platforms, PUT and POST /test on the same path. Secrets go through the existing .env pipeline; enable/disable writes config.yaml. - Replace gateway statusbar dropdown with a richer panel: status row, icon-only restart + system-panel actions, recent activity (with timestamps trimmed in display, full text on hover), platform list. - Auto-poll the messaging page every 6s (paused when hidden) so status updates without a manual check. - Drop Settings / Command Center from the sidebar nav (still reachable via shortcuts and the titlebar cog). - Flatten top corners on Messaging/Skills/Artifacts/Chat panes. - Share new StatusDot component across messaging + gateway menu. - Fix gateway/config.py so an explicit platforms..enabled=false in config.yaml is honored when env tokens are present. - pb-9 on the chat content area for breathing room above the composer. --- apps/desktop/src/app/artifacts/index.tsx | 4 +- apps/desktop/src/app/chat/index.tsx | 4 +- apps/desktop/src/app/chat/sidebar/index.tsx | 12 +- apps/desktop/src/app/command-center/index.tsx | 3 +- apps/desktop/src/app/desktop-controller.tsx | 12 + apps/desktop/src/app/messaging/index.tsx | 755 ++++++++++++++++++ apps/desktop/src/app/routes.ts | 6 +- .../src/app/session/hooks/use-route-resume.ts | 7 +- .../src/app/shell/gateway-menu-panel.tsx | 134 ++++ .../app/shell/hooks/use-statusbar-items.tsx | 61 +- .../src/app/shell/statusbar-controls.tsx | 18 +- apps/desktop/src/app/skills/index.tsx | 4 +- apps/desktop/src/app/types.ts | 2 +- .../src/components/assistant-ui/thread.tsx | 2 +- apps/desktop/src/components/status-dot.tsx | 22 + apps/desktop/src/hermes.ts | 33 + apps/desktop/src/types/hermes.ts | 51 ++ gateway/config.py | 60 +- hermes_cli/web_server.py | 523 ++++++++++++ tests/hermes_cli/test_web_server.py | 76 ++ 20 files changed, 1717 insertions(+), 72 deletions(-) create mode 100644 apps/desktop/src/app/messaging/index.tsx create mode 100644 apps/desktop/src/app/shell/gateway-menu-panel.tsx create mode 100644 apps/desktop/src/components/status-dot.tsx diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 177f29e72d..9ca07d52f9 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -497,13 +497,13 @@ export function ArtifactsView({ }, []) return ( -
+

Artifacts

{counts.all} found
-
+
@@ -278,7 +278,7 @@ export function ChatView({ -
+
({ default: (await import('./agents')).AgentsView })) const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) +const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) @@ -507,6 +508,17 @@ export function DesktopController() { } path="skills" /> + + + + } + path="messaging" + /> diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx new file mode 100644 index 0000000000..5ffc9c6aae --- /dev/null +++ b/apps/desktop/src/app/messaging/index.tsx @@ -0,0 +1,755 @@ +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 { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { + getMessagingPlatforms, + type MessagingEnvVarInfo, + type MessagingPlatformInfo, + updateMessagingPlatform +} from '@/hermes' +import { AlertTriangle, ChevronDown, ExternalLink, RefreshCw, Save, Trash2 } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' + +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' +import { titlebarHeaderBaseClass } from '../shell/titlebar' +import type { SetTitlebarToolGroup } from '../shell/titlebar-controls' + +interface MessagingViewProps extends React.ComponentProps<'section'> { + setStatusbarItemGroup?: SetStatusbarItemGroup + setTitlebarToolGroup?: SetTitlebarToolGroup +} + +type EditMap = Record> + +const STATE_LABELS: Record = { + connected: 'Connected', + connecting: 'Connecting', + disabled: 'Disabled', + fatal: 'Error', + gateway_stopped: 'Gateway stopped', + not_configured: 'Needs setup', + pending_restart: 'Restart needed', + retrying: 'Retrying', + startup_failed: 'Startup failed' +} + +const PLATFORM_TINTS: Record = { + telegram: 'bg-sky-500/15 text-sky-600 dark:text-sky-300', + discord: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300', + slack: 'bg-violet-500/15 text-violet-600 dark:text-violet-300', + mattermost: 'bg-blue-500/15 text-blue-600 dark:text-blue-300', + matrix: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300', + signal: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300', + whatsapp: 'bg-green-500/15 text-green-600 dark:text-green-300', + bluebubbles: 'bg-blue-500/15 text-blue-600 dark:text-blue-300', + homeassistant: 'bg-teal-500/15 text-teal-600 dark:text-teal-300', + email: 'bg-amber-500/15 text-amber-600 dark:text-amber-300', + sms: 'bg-rose-500/15 text-rose-600 dark:text-rose-300', + dingtalk: 'bg-blue-500/15 text-blue-600 dark:text-blue-300', + feishu: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300', + wecom: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300', + wecom_callback: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300', + weixin: 'bg-green-500/15 text-green-600 dark:text-green-300', + qqbot: 'bg-amber-500/15 text-amber-600 dark:text-amber-300', + yuanbao: 'bg-orange-500/15 text-orange-600 dark:text-orange-300', + api_server: 'bg-slate-500/15 text-slate-600 dark:text-slate-300', + webhook: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-300' +} + +const PILL_TONE: Record = { + good: 'bg-primary/10 text-primary', + muted: 'bg-muted text-muted-foreground', + warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', + bad: 'bg-destructive/10 text-destructive' +} + +const HINT_BY_STATE: Record = { + 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): Record => + Object.fromEntries( + Object.entries(edits) + .map(([k, v]) => [k, v.trim()]) + .filter(([, v]) => v) + ) + +const FIELD_COPY: Record = { + 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 + }, + 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, + setTitlebarToolGroup, + ...props +}: MessagingViewProps) { + const [platforms, setPlatforms] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [edits, setEdits] = useState({}) + const [refreshing, setRefreshing] = useState(false) + const [saving, setSaving] = useState(null) + + const refreshPlatforms = useCallback(async (silent = false) => { + if (!silent) { + setRefreshing(true) + } + + try { + const result = await getMessagingPlatforms() + setPlatforms(result.platforms) + setSelectedId(current => current || result.platforms[0]?.id || null) + } catch (err) { + if (!silent) { + notifyError(err, 'Messaging platforms failed to load') + } + } finally { + if (!silent) { + setRefreshing(false) + } + } + }, []) + + 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]) + + useEffect(() => { + if (!setTitlebarToolGroup) { + return + } + + setTitlebarToolGroup('messaging', [ + { + disabled: refreshing, + icon: , + id: 'refresh-messaging', + label: refreshing ? 'Refreshing messaging' : 'Refresh messaging', + onSelect: () => void refreshPlatforms() + } + ]) + + return () => setTitlebarToolGroup('messaging', []) + }, [refreshPlatforms, refreshing, setTitlebarToolGroup]) + + const selected = useMemo(() => { + if (!platforms) { + return null + } + + return platforms.find(platform => platform.id === selectedId) || platforms[0] || null + }, [platforms, selectedId]) + + const enabledCount = platforms?.filter(platform => platform.enabled).length || 0 + + 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 ( +
+
+

Messaging

+ + {enabledCount === 0 ? 'No platforms enabled' : `${enabledCount} enabled`} + +
+ +
+ {!platforms ? ( + + ) : ( +
+ + +
+ {selected && ( + 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} + /> + )} +
+
+ )} +
+
+ ) +} + +function PlatformRow({ + active, + onSelect, + platform +}: { + active: boolean + onSelect: () => void + platform: MessagingPlatformInfo +}) { + return ( + + ) +} + +function PlatformAvatar({ platformId, platformName }: { platformId: string; platformName: string }) { + return ( + + {platformName.charAt(0).toUpperCase()} + + ) +} + +function PlatformDetail({ + edits, + onClear, + onEdit, + onSave, + onToggle, + platform, + saving +}: { + edits: Record + 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 ( +
+
+
+
+ +
+

{platform.name}

+

{platform.description}

+
+ {stateLabel(platform.state)} + + {platform.configured ? 'Credentials set' : 'Needs setup'} + + {!platform.gateway_running && Gateway stopped} +
+ +
+
+ + {platform.error_message && ( +
+ + {platform.error_message} +
+ )} + +
+ Get your credentials +

+ {introCopy(platform)} +

+ +
+ +
+ Required +
+ {requiredFields.length > 0 ? ( + requiredFields.map(field => ( + + )) + ) : ( +

+ This platform does not need a token here. Use the setup guide above, then enable it below. +

+ )} +
+
+ + {optionalFields.length > 0 && ( +
+ Recommended +
+ {optionalFields.map(field => ( + + ))} +
+
+ )} + + {hiddenCount > 0 && ( +
+ + {showAdvanced && ( +
+ {advancedFields.map(field => ( + + ))} +
+ )} +
+ )} +
+
+ +
+
+ + +
+ {hasEdits && Unsaved changes} + +
+
+
+
+ ) +} + +const PLATFORM_INTRO: Record = { + 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 + field: MessagingEnvVarInfo + onClear: (key: string) => void + onEdit: (key: string, value: string) => void + saving: string | null +}) { + const copy = fieldCopy(field) + + return ( +
+
+ + {field.is_set && Saved} +
+
+ 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 && ( + + )} + {field.is_set && ( + + )} +
+ {copy.help &&

{copy.help}

} +
+ ) +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ) +} + +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 ?

{hint}

: null +} + +function StatePill({ children, tone }: { children: string; tone: StatusTone }) { + return ( + + + {children} + + ) +} + +function SetupPill({ active, children }: { active: boolean; children: string }) { + return ( + + {children} + + ) +} diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 608dda8d08..6859e887d7 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -3,12 +3,13 @@ export const NEW_CHAT_ROUTE = '/' export const SETTINGS_ROUTE = '/settings' export const COMMAND_CENTER_ROUTE = '/command-center' export const SKILLS_ROUTE = '/skills' +export const MESSAGING_ROUTE = '/messaging' export const ARTIFACTS_ROUTE = '/artifacts' export const AGENTS_ROUTE = '/agents' -export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents' +export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'messaging' | 'artifacts' | 'agents' -export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents' +export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'messaging' | 'artifacts' | 'agents' export interface AppRoute { id: AppRouteId @@ -21,6 +22,7 @@ export const APP_ROUTES = [ { id: 'settings', path: SETTINGS_ROUTE, view: 'settings' }, { 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: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }, { id: 'agents', path: AGENTS_ROUTE, view: 'agents' } ] as const satisfies readonly AppRoute[] diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts index 20fd090e85..86d7333ea8 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.ts +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -32,7 +32,12 @@ function rawHashLooksLikeSession(): boolean { return false } - return !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/artifacts') + return ( + !hash.startsWith('/settings') && + !hash.startsWith('/skills') && + !hash.startsWith('/messaging') && + !hash.startsWith('/artifacts') + ) } export function useRouteResume({ diff --git a/apps/desktop/src/app/shell/gateway-menu-panel.tsx b/apps/desktop/src/app/shell/gateway-menu-panel.tsx new file mode 100644 index 0000000000..6587747bf0 --- /dev/null +++ b/apps/desktop/src/app/shell/gateway-menu-panel.tsx @@ -0,0 +1,134 @@ +import { IconLayoutDashboard } from '@tabler/icons-react' + +import { StatusDot, type StatusTone } from '@/components/status-dot' +import { Button } from '@/components/ui/button' +import { Activity, AlertCircle, RefreshCw } from '@/lib/icons' +import { cn } from '@/lib/utils' +import type { StatusResponse } from '@/types/hermes' + +interface GatewayMenuPanelProps { + logLines: readonly string[] + onOpenSystem: () => void + onRestart: () => void + restarting: boolean + statusSnapshot: StatusResponse | null +} + +const PLATFORM_TONE: Record = { + connected: 'good', + connecting: 'warn', + retrying: 'warn', + pending_restart: 'warn', + startup_failed: 'bad', + fatal: 'bad' +} + +const prettyState = (state: string) => state.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase()) + +// Strip leading "YYYY-MM-DD HH:MM:SS,mmm " and "[runtime_id] " prefixes from +// log lines so they don't dominate the display. Full text preserved on hover. +const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}[,.\d]*\s+/ +const RUNTIME_BRACKET_RE = /^\[[^\]]+]\s+/ +const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replace(RUNTIME_BRACKET_RE, '') + +export function GatewayMenuPanel({ + logLines, + onOpenSystem, + onRestart, + restarting, + statusSnapshot +}: GatewayMenuPanelProps) { + const gatewayRunning = Boolean(statusSnapshot?.gateway_running) + const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r)) + const stateLabel = gatewayRunning ? prettyState(statusSnapshot?.gateway_state || 'online') : 'Offline' + const recentLogs = logLines.slice(-5) + + return ( +
+
+
+ {gatewayRunning ? ( + + ) : ( + + )} + Gateway + + + {stateLabel} + +
+
+ + +
+
+ + {recentLogs.length > 0 && ( +
+ Recent activity +
    + {recentLogs.map((line, index) => ( +
  • + {trimLogLine(line) || '\u00A0'} +
  • + ))} +
+ +
+ )} + + {platforms.length > 0 && ( +
+ Platforms +
    + {platforms.map(([name, platform]) => ( +
  • + {name} + + + {prettyState(platform.state)} + +
  • + ))} +
+
+ )} +
+ ) +} + +function SectionLabel({ children }: { children: string }) { + return ( +
{children}
+ ) +} diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 6ca14ad119..02529e6c7a 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,12 +1,14 @@ import { useStore } from '@nanostores/react' -import { useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import type { CommandCenterSection } from '@/app/command-center' -import { buildGatewayLogItems } from '@/lib/gateway-events' +import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' +import { restartGateway } from '@/hermes' import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Loader2, Sparkles } from '@/lib/icons' import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' import { $desktopActionTasks } from '@/store/activity' +import { notify, notifyError } from '@/store/notifications' import { $previewServerRestartStatus } from '@/store/preview' import { $busy, @@ -22,7 +24,7 @@ import { } from '@/store/session' import type { StatusResponse } from '@/types/hermes' -import type { StatusbarItem, StatusbarMenuItem } from '../statusbar-controls' +import type { StatusbarItem } from '../statusbar-controls' interface StatusbarItemsOptions { agentsOpen: boolean @@ -64,21 +66,40 @@ export function useStatusbarItems({ const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage]) const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage]) - const platformMenuItems = useMemo( - () => - Object.entries(statusSnapshot?.gateway_platforms || {}) - .sort(([l], [r]) => l.localeCompare(r)) - .map(([name, platform]) => ({ disabled: true, id: `platform:${name}`, label: `${name} · ${platform.state}` })), - [statusSnapshot?.gateway_platforms] - ) + const [restartingGateway, setRestartingGateway] = useState(false) - const gatewayMenuItems = useMemo( - () => [ - { id: 'gateway:open-system', label: 'Open system panel', onSelect: () => openCommandCenterSection('system') }, - ...buildGatewayLogItems(gatewayLogLines), - ...platformMenuItems - ], - [gatewayLogLines, openCommandCenterSection, platformMenuItems] + const handleRestartGateway = useCallback(async () => { + if (restartingGateway) { + return + } + + setRestartingGateway(true) + + try { + await restartGateway() + notify({ + kind: 'success', + title: 'Gateway restart requested', + message: 'Status will update once the gateway reconnects.' + }) + } catch (err) { + notifyError(err, 'Failed to restart gateway') + } finally { + setRestartingGateway(false) + } + }, [restartingGateway]) + + const gatewayMenuContent = useMemo( + () => ( + openCommandCenterSection('system')} + onRestart={() => void handleRestartGateway()} + restarting={restartingGateway} + statusSnapshot={statusSnapshot} + /> + ), + [gatewayLogLines, handleRestartGateway, openCommandCenterSection, restartingGateway, statusSnapshot] ) const { bgFailed, bgRunning } = useMemo(() => { @@ -109,8 +130,8 @@ export function useStatusbarItems({ icon: gatewayUp ? : , id: 'gateway-health', label: 'Gateway', - menuClassName: 'w-96', - menuItems: gatewayMenuItems, + menuClassName: 'w-72', + menuContent: gatewayMenuContent, title: 'Gateway and platform health', variant: 'menu' }, @@ -140,7 +161,7 @@ export function useStatusbarItems({ bgFailed, bgRunning, commandCenterOpen, - gatewayMenuItems, + gatewayMenuContent, gatewayUp, openAgents, statusSnapshot?.gateway_state, diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index a74c637a35..fc9075da1b 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -27,6 +27,7 @@ export interface StatusbarItem { hidden?: boolean href?: string menuClassName?: string + menuContent?: ReactNode menuItems?: readonly StatusbarMenuItem[] onSelect?: () => void title?: string @@ -85,7 +86,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined) - if (item.variant === 'menu' && item.menuItems && item.menuItems.length > 0) { + if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) { return ( @@ -98,10 +99,17 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: {content} - - {item.menuItems - .filter(menuItem => !menuItem.hidden) - .map(menuItem => ( + + {item.menuContent + ? item.menuContent + : (item.menuItems ?? []) + .filter(menuItem => !menuItem.hidden) + .map(menuItem => ( +

Skills

@@ -180,7 +180,7 @@ export function SkillsView({
-
+
setMode('skills')} text="Skills" /> diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 9b67f5c686..fffcaab484 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -51,7 +51,7 @@ export type CommandDispatchResponse = | SkillCommandDispatchResponse | SendCommandDispatchResponse -export type SidebarNavId = 'new-session' | 'command-center' | 'settings' | 'skills' | 'artifacts' +export type SidebarNavId = 'new-session' | 'command-center' | 'settings' | 'skills' | 'messaging' | 'artifacts' export interface SidebarNavItem { id: SidebarNavId diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 8c0d3007d6..d87bcbb7bb 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -137,7 +137,7 @@ export const Thread: FC<{ > diff --git a/apps/desktop/src/components/status-dot.tsx b/apps/desktop/src/components/status-dot.tsx new file mode 100644 index 0000000000..4617b2181d --- /dev/null +++ b/apps/desktop/src/components/status-dot.tsx @@ -0,0 +1,22 @@ +import type { ComponentProps } from 'react' + +import { cn } from '@/lib/utils' + +export type StatusTone = 'good' | 'muted' | 'warn' | 'bad' + +const TONE_BG: Record = { + good: 'bg-primary', + muted: 'bg-muted-foreground/40', + warn: 'bg-amber-500', + bad: 'bg-destructive' +} + +interface StatusDotProps extends ComponentProps<'span'> { + tone: StatusTone +} + +export function StatusDot({ className, tone, ...props }: StatusDotProps) { + return ( +