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.<name>.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.
This commit is contained in:
Brooklyn Nicholson
2026-05-08 15:59:43 -04:00
parent 9ec0f7cbff
commit f790c61207
20 changed files with 1717 additions and 72 deletions
@@ -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<string, StatusTone> = {
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 (
<div className="text-sm">
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
{gatewayRunning ? (
<Activity className="size-3.5 text-primary" />
) : (
<AlertCircle className="size-3.5 text-destructive" />
)}
<span className="font-medium">Gateway</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<StatusDot tone={gatewayRunning ? 'good' : 'bad'} />
{stateLabel}
</span>
</div>
<div className="flex items-center">
<Button
aria-label={restarting ? 'Restarting gateway' : 'Restart gateway'}
className="size-7 text-muted-foreground hover:text-foreground"
disabled={restarting}
onClick={onRestart}
size="icon-sm"
title={restarting ? 'Restarting gateway' : 'Restart gateway'}
variant="ghost"
>
<RefreshCw className={cn(restarting && 'animate-spin')} />
</Button>
<Button
aria-label="Open system panel"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="icon-sm"
title="Open system panel"
variant="ghost"
>
<IconLayoutDashboard />
</Button>
</div>
</div>
{recentLogs.length > 0 && (
<div className="border-t border-border/50 px-3 py-2">
<SectionLabel>Recent activity</SectionLabel>
<ul className="mt-1.5 space-y-0.5">
{recentLogs.map((line, index) => (
<li
className="truncate font-mono text-[0.68rem] text-muted-foreground/85"
key={`${index}:${line}`}
title={line.trim()}
>
{trimLogLine(line) || '\u00A0'}
</li>
))}
</ul>
<button
className="mt-1.5 text-[0.66rem] font-medium text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
type="button"
>
View all logs
</button>
</div>
)}
{platforms.length > 0 && (
<div className="border-t border-border/50 px-3 py-2">
<SectionLabel>Platforms</SectionLabel>
<ul className="mt-1.5 space-y-1">
{platforms.map(([name, platform]) => (
<li className="flex items-center justify-between gap-2 text-xs" key={name}>
<span className="truncate capitalize">{name}</span>
<span className="flex items-center gap-1.5 text-[0.66rem] text-muted-foreground">
<StatusDot tone={PLATFORM_TONE[platform.state] || 'muted'} />
{prettyState(platform.state)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
)
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80">{children}</div>
)
}
@@ -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<readonly StatusbarMenuItem[]>(
() =>
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<readonly StatusbarMenuItem[]>(
() => [
{ 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(
() => (
<GatewayMenuPanel
logLines={gatewayLogLines}
onOpenSystem={() => 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 ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
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,
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -98,10 +99,17 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
{content}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn('w-56', item.menuClassName)} side="top" sideOffset={8}>
{item.menuItems
.filter(menuItem => !menuItem.hidden)
.map(menuItem => (
<DropdownMenuContent
align="start"
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
side="top"
sideOffset={8}
>
{item.menuContent
? item.menuContent
: (item.menuItems ?? [])
.filter(menuItem => !menuItem.hidden)
.map(menuItem => (
<DropdownMenuItem
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
disabled={menuItem.disabled}