From 17e86dddc7f2a8aed28f148dfe1db418063e1192 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 13 May 2026 12:12:12 -0400 Subject: [PATCH] feat(desktop): add MCP settings and live subagent tree Surface configured MCP servers in Settings with JSON edit/save and a gateway-backed reload action so users can manage tool servers without falling back to slash commands. Track live subagent gateway events in a desktop store, show active subagent counts in the Agents statusbar item, and replace the Agents overlay stub with a live spawn tree for the active session. --- apps/desktop/src/app/agents/index.tsx | 87 +++++- apps/desktop/src/app/desktop-controller.tsx | 1 + .../app/session/hooks/use-message-stream.ts | 14 + apps/desktop/src/app/settings/constants.ts | 3 +- apps/desktop/src/app/settings/index.tsx | 15 +- .../desktop/src/app/settings/mcp-settings.tsx | 259 ++++++++++++++++++ apps/desktop/src/app/settings/types.ts | 6 +- .../app/shell/hooks/use-statusbar-items.tsx | 25 +- apps/desktop/src/store/subagents.test.ts | 63 +++++ apps/desktop/src/store/subagents.ts | 159 +++++++++++ 10 files changed, 620 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/app/settings/mcp-settings.tsx create mode 100644 apps/desktop/src/store/subagents.test.ts create mode 100644 apps/desktop/src/store/subagents.ts diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 0281eb1bc77..f58d211293e 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -5,7 +5,8 @@ import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sp import { cn } from '@/lib/utils' import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity' import { $previewServerRestart } from '@/store/preview' -import { $sessions, $workingSessionIds } from '@/store/session' +import { $activeSessionId, $sessions, $workingSessionIds } from '@/store/session' +import { $subagentsBySession, buildSubagentTree, type SubagentNode, type SubagentStatus } from '@/store/subagents' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { OverlayCard } from '../overlays/overlay-chrome' @@ -49,10 +50,12 @@ interface AgentsViewProps { export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) { const [section, setSection] = useRouteEnumParam('section', SECTION_IDS, initialSection) + const activeSessionId = useStore($activeSessionId) const sessions = useStore($sessions) const workingSessionIds = useStore($workingSessionIds) const previewRestart = useStore($previewServerRestart) const desktopActionTasks = useStore($desktopActionTasks) + const subagentsBySession = useStore($subagentsBySession) const activityTasks = useMemo( () => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks), @@ -60,6 +63,8 @@ export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps ) const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]! + const activeSubagents = activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : [] + const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents]) return ( @@ -82,13 +87,91 @@ export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps

{active.description}

- {section === 'activity' ? : } + {section === 'tree' ? ( + + ) : section === 'activity' ? ( + + ) : ( + + )}
) } +const STATUS_CLASS: Record = { + completed: 'text-emerald-500', + failed: 'text-destructive', + interrupted: 'text-amber-500', + queued: 'text-muted-foreground', + running: 'text-primary' +} + +function SubagentTree({ tree }: { tree: SubagentNode[] }) { + if (tree.length === 0) { + return ( + + +
+

No live subagents

+

+ When a turn delegates work, child agents appear here as a live spawn tree. +

+
+
+ ) + } + + return ( +
+ {tree.map(node => ( + + ))} +
+ ) +} + +function SubagentRow({ node, depth = 0 }: { node: SubagentNode; depth?: number }) { + const running = node.status === 'running' || node.status === 'queued' + + return ( + +
+ {running ? ( + + ) : ( + + )} +
+
+
{node.goal}
+ {node.status} +
+
+ {node.model && {node.model}} + {typeof node.durationSeconds === 'number' && {node.durationSeconds.toFixed(1)}s} + {typeof node.costUsd === 'number' && ${node.costUsd.toFixed(4)}} + {typeof node.apiCalls === 'number' && {node.apiCalls} calls} +
+ {(node.toolName || node.toolPreview || node.summary) && ( +
+ {node.summary || [node.toolName, node.toolPreview].filter(Boolean).join(' ยท ')} +
+ )} +
+
+ {node.children.length > 0 && ( +
+ {node.children.map(child => ( + + ))} +
+ )} +
+ ) +} + function ActivityList({ tasks }: { tasks: readonly RailTask[] }) { if (tasks.length === 0) { return ( diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index f1794d844a6..ebe54edba04 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -424,6 +424,7 @@ export function DesktopController() { {settingsOpen && ( { void refreshHermesConfig() diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 7bd1b956e37..f30e122b693 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -31,6 +31,7 @@ import { setCurrentUsage, setTurnStartedAt } from '@/store/session' +import { clearSessionSubagents, upsertSubagent } from '@/store/subagents' import { recordToolDiff } from '@/store/tool-diffs' import type { RpcEvent } from '@/types/hermes' @@ -59,6 +60,14 @@ interface QueuedStreamDeltas { } const STREAM_DELTA_FLUSH_MS = 16 +const SUBAGENT_EVENT_TYPES = new Set([ + 'subagent.spawn_requested', + 'subagent.start', + 'subagent.thinking', + 'subagent.tool', + 'subagent.progress', + 'subagent.complete' +]) // Anonymous progress events that carry todos but no name still belong to the // todo stream; named todo events are obviously routed there too. @@ -506,6 +515,7 @@ export function useMessageStream({ } flushQueuedDeltas(sessionId) + clearSessionSubagents(sessionId) if (isActiveEvent) { triggerHaptic('streamStart') @@ -575,6 +585,10 @@ export function useMessageStream({ if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) { recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff) } + } else if (SUBAGENT_EVENT_TYPES.has(event.type)) { + if (sessionId && payload) { + upsertSubagent(sessionId, payload as Record, event.type === 'subagent.spawn_requested') + } } else if (event.type === 'clarify.request') { if (!isActiveEvent) { return diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index b58e68931d6..605737b043f 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -311,10 +311,11 @@ export const MODE_OPTIONS: ModeOption[] = [ { id: 'system', label: 'System', description: 'Follow macOS appearance', icon: Monitor } ] -export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'tools', string> = { +export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = { about: 'About Hermes Desktop', config: 'Search settings...', gateway: 'Gateway connection...', keys: 'Search API keys...', + mcp: 'Search MCP servers...', tools: 'Search skills and tools...' } diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 2e40c2ff626..a15f02350d0 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { triggerHaptic } from '@/lib/haptics' -import { Globe, Info, KeyRound, Package } from '@/lib/icons' +import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -18,6 +18,7 @@ import { ConfigSettings } from './config-settings' import { SEARCH_PLACEHOLDER, SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' import { KeysSettings } from './keys-settings' +import { McpSettings } from './mcp-settings' import { ToolsSettings } from './tools-settings' import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types' @@ -25,11 +26,12 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ ...SECTIONS.map(s => `config:${s.id}` as SettingsViewId), 'gateway', 'keys', + 'mcp', 'tools', 'about' ] -export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { +export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPageProps) { const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId) const [queries, setQueries] = useState>({ @@ -37,6 +39,7 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { config: '', gateway: '', keys: '', + mcp: '', tools: '' }) @@ -147,6 +150,12 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { label="Skills & Tools" onClick={() => setActiveView('tools')} /> + setActiveView('mcp')} + />
) : activeView === 'keys' ? ( + ) : activeView === 'mcp' ? ( + ) : ( )} diff --git a/apps/desktop/src/app/settings/mcp-settings.tsx b/apps/desktop/src/app/settings/mcp-settings.tsx new file mode 100644 index 00000000000..428b76bbd64 --- /dev/null +++ b/apps/desktop/src/app/settings/mcp-settings.tsx @@ -0,0 +1,259 @@ +import { useEffect, useMemo, useState } from 'react' + +import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { getHermesConfigRecord, saveHermesConfig, type HermesGateway } from '@/hermes' +import { Package, Wrench } from '@/lib/icons' +import { notify, notifyError } from '@/store/notifications' +import { $activeSessionId } from '@/store/session' +import { useStore } from '@nanostores/react' +import type { HermesConfigRecord } from '@/types/hermes' + +import { includesQuery } from './helpers' +import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives' +import type { SearchProps } from './types' + +interface McpSettingsProps extends SearchProps { + gateway?: HermesGateway | null + onConfigSaved?: () => void +} + +type McpServers = Record> + +const EMPTY_SERVER = { + command: '', + args: [], + env: {} +} + +function getServers(config: HermesConfigRecord | null): McpServers { + const raw = config?.mcp_servers + + return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {} +} + +const transportLabel = (server: Record) => + typeof server.transport === 'string' + ? server.transport + : typeof server.url === 'string' + ? 'http' + : typeof server.command === 'string' + ? 'stdio' + : 'custom' + +function serverMatches(name: string, server: Record, query: string) { + if (!query) { + return true + } + + return includesQuery(name, query) || includesQuery(JSON.stringify(server), query) +} + +export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps) { + const activeSessionId = useStore($activeSessionId) + const [config, setConfig] = useState(null) + const [selected, setSelected] = useState(null) + const [name, setName] = useState('') + const [body, setBody] = useState('') + const [saving, setSaving] = useState(false) + const [reloading, setReloading] = useState(false) + + useEffect(() => { + let cancelled = false + + getHermesConfigRecord() + .then(next => { + if (cancelled) return + setConfig(next) + const first = Object.keys(getServers(next)).sort()[0] ?? null + setSelected(first) + }) + .catch(err => notifyError(err, 'MCP config failed to load')) + + return () => void (cancelled = true) + }, []) + + const servers = useMemo(() => getServers(config), [config]) + const names = useMemo(() => Object.keys(servers).sort(), [servers]) + const filtered = useMemo( + () => names.filter(serverName => serverMatches(serverName, servers[serverName], query.trim().toLowerCase())), + [names, query, servers] + ) + + useEffect(() => { + const server = selected ? servers[selected] : null + + setName(selected ?? '') + setBody(JSON.stringify(server ?? EMPTY_SERVER, null, 2)) + }, [selected, servers]) + + if (!config) { + return + } + + const saveServer = async () => { + const nextName = name.trim() + + if (!nextName) { + notify({ kind: 'error', title: 'Name required', message: 'Give this MCP server a config key.' }) + return + } + + let parsed: Record + + try { + const raw = JSON.parse(body) + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Server config must be a JSON object') + } + + parsed = raw as Record + } catch (err) { + notifyError(err, 'Invalid MCP JSON') + return + } + + setSaving(true) + + try { + const nextServers = { ...servers } + + if (selected && selected !== nextName) { + delete nextServers[selected] + } + + nextServers[nextName] = parsed + + const nextConfig = { ...config, mcp_servers: nextServers } + await saveHermesConfig(nextConfig) + setConfig(nextConfig) + setSelected(nextName) + onConfigSaved?.() + notify({ kind: 'success', title: 'MCP server saved', message: `${nextName} applies after MCP reload.` }) + } catch (err) { + notifyError(err, 'Save failed') + } finally { + setSaving(false) + } + } + + const removeServer = async (serverName: string) => { + setSaving(true) + + try { + const nextServers = { ...servers } + delete nextServers[serverName] + + const nextConfig = { ...config, mcp_servers: nextServers } + await saveHermesConfig(nextConfig) + setConfig(nextConfig) + setSelected(Object.keys(nextServers).sort()[0] ?? null) + onConfigSaved?.() + } catch (err) { + notifyError(err, 'Remove failed') + } finally { + setSaving(false) + } + } + + const reloadMcp = async () => { + if (!gateway) { + notify({ kind: 'warning', title: 'Gateway unavailable', message: 'Reconnect the gateway before reloading MCP.' }) + return + } + + setReloading(true) + + try { + await gateway.request('reload.mcp', { + confirm: true, + session_id: activeSessionId ?? undefined + }) + notify({ kind: 'success', title: 'MCP tools reloaded', message: 'New tool schemas apply to fresh turns.' }) + } catch (err) { + notifyError(err, 'MCP reload failed') + } finally { + setReloading(false) + } + } + + return ( + +
+ +
+ setSelected(null)}>New server + void reloadMcp()}> + {reloading ? 'Reloading...' : 'Reload MCP'} + +
+
+ +
+ + {filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map(serverName => { + const server = servers[serverName] + const active = selected === serverName + + return ( + + ) + })} +
+ )} +
+ + +
+ + {selected ? 'Edit server' : 'New server'} +
+ +