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 ( +