diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 0281eb1bc7..cc5afa17f4 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -1,143 +1,377 @@ import { useStore } from '@nanostores/react' -import { useMemo } from 'react' +import { type ReactNode, useEffect, useMemo, useState } from 'react' -import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sparkles } from '@/lib/icons' +import { useElapsedSeconds } from '@/components/chat/activity-timer' +import { ActivityTimerText } from '@/components/chat/activity-timer-text' +import { BrailleSpinner } from '@/components/ui/braille-spinner' +import { FadeText } from '@/components/ui/fade-text' +import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' +import { useEnterAnimation } from '@/lib/use-enter-animation' 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 } from '@/store/session' +import { + $subagentsBySession, + buildSubagentTree, + type SubagentNode, + type SubagentStatus, + type SubagentStreamEntry +} from '@/store/subagents' -import { useRouteEnumParam } from '../hooks/use-route-enum-param' -import { OverlayCard } from '../overlays/overlay-chrome' -import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' import { OverlayView } from '../overlays/overlay-view' -type AgentsSection = 'tree' | 'activity' | 'history' +// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the +// same visual vocabulary as the chat tool blocks. +function statusGlyph(status: SubagentStatus): ReactNode { + if (status === 'running' || status === 'queued') { + return ( + + ) + } -interface SectionDef { - description: string - icon: LucideIcon - id: AgentsSection - label: string + if (status === 'failed' || status === 'interrupted') { + return + } + + return ( + + ) } -const SECTIONS: readonly SectionDef[] = [ - { description: 'Live subagent spawn tree for the current turn', icon: Layers3, id: 'tree', label: 'Spawn tree' }, - { description: 'Background work across sessions and the desktop', icon: Activity, id: 'activity', label: 'Activity' }, - { description: 'Past spawn snapshots, replay, and diff', icon: RefreshCw, id: 'history', label: 'History' } -] - -const SECTION_IDS = SECTIONS.map(s => s.id) as readonly AgentsSection[] - -const STATUS_TONE: Record = { - error: 'text-destructive', - running: 'text-foreground', - success: 'text-emerald-500' +const STREAM_TONE: Record = { + progress: 'text-muted-foreground/75', + summary: 'text-foreground/85', + thinking: 'text-muted-foreground/80', + tool: 'text-foreground/85' } -const STATUS_ICON: Record = { - error: AlertCircle, - running: Loader2, - success: Sparkles +function streamGlyph(entry: SubagentStreamEntry): ReactNode { + if (entry.isError) { + return + } + + if (entry.kind === 'tool') { + return + } + + if (entry.kind === 'summary') { + return + } + + if (entry.kind === 'thinking') { + return + } + + return } interface AgentsViewProps { - initialSection?: AgentsSection onClose: () => void } -export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) { - const [section, setSection] = useRouteEnumParam('section', SECTION_IDS, initialSection) +export function AgentsView({ onClose }: AgentsViewProps) { + const activeSessionId = useStore($activeSessionId) + const subagentsBySession = useStore($subagentsBySession) - const sessions = useStore($sessions) - const workingSessionIds = useStore($workingSessionIds) - const previewRestart = useStore($previewServerRestart) - const desktopActionTasks = useStore($desktopActionTasks) - - const activityTasks = useMemo( - () => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks), - [desktopActionTasks, previewRestart, sessions, workingSessionIds] + const activeSubagents = useMemo( + () => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []), + [activeSessionId, subagentsBySession] ) - const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]! + const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents]) return ( - - - - {SECTIONS.map(s => ( - setSection(s.id)} - /> - ))} - - - -
-

{active.label}

-

{active.description}

-
- - {section === 'activity' ? : } -
-
+ +
+

Spawn tree

+

Live subagent activity for the current turn.

+
+
) } -function ActivityList({ tasks }: { tasks: readonly RailTask[] }) { - if (tasks.length === 0) { +const fmtDuration = (seconds?: number) => { + if (!seconds || seconds <= 0) return '' + if (seconds < 60) return `${seconds.toFixed(1)}s` + + const m = Math.floor(seconds / 60) + const s = Math.round(seconds % 60) + + return `${m}m ${s}s` +} + +const fmtTokens = (value?: number) => { + if (!value) return '' + + return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok` +} + +const fmtAge = (updatedAt: number, nowMs: number) => { + const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000)) + if (s < 2) return 'now' + if (s < 60) return `${s}s ago` + + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + + return `${Math.floor(m / 60)}h ago` +} + +const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] => + nodes.flatMap(node => [node, ...flatten(node.children)]) + +interface RootGroup { + id: string + label: string + nodes: SubagentNode[] + taskCount: number +} + +function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] { + const groups: RootGroup[] = [] + let n = 0 + + for (const node of roots) { + const prev = groups.at(-1) + const prevTail = prev?.nodes.at(-1) + const closeInTime = prevTail ? Math.abs(node.startedAt - prevTail.startedAt) <= 5_000 : false + const sameShape = prev && node.taskCount > 1 && prev.taskCount === node.taskCount + const uniqueStep = prev ? !prev.nodes.some(item => item.taskIndex === node.taskIndex) : false + + if (prev && sameShape && closeInTime && uniqueStep) { + prev.nodes.push(node) + continue + } + + if (node.taskCount > 1) { + n += 1 + groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount }) + continue + } + + groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount }) + } + + return groups +} + +function SubagentTree({ tree }: { tree: SubagentNode[] }) { + const flat = useMemo(() => flatten(tree), [tree]) + const groups = useMemo(() => groupDelegations(tree), [tree]) + const [nowMs, setNowMs] = useState(() => Date.now()) + + const active = flat.filter(n => n.status === 'running' || n.status === 'queued').length + const failed = flat.filter(n => n.status === 'failed' || n.status === 'interrupted').length + const tools = flat.reduce((sum, n) => sum + (n.toolCount ?? 0), 0) + const files = flat.reduce((sum, n) => sum + n.filesRead.length + n.filesWritten.length, 0) + const tokens = flat.reduce((sum, n) => sum + (n.inputTokens ?? 0) + (n.outputTokens ?? 0), 0) + const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0) + + useEffect(() => { + if (active <= 0 || typeof window === 'undefined') return + + const id = window.setInterval(() => setNowMs(Date.now()), 500) + + return () => window.clearInterval(id) + }, [active]) + + if (tree.length === 0) { return ( - - No background activity. Long-running tools, preview restarts, and parallel sessions surface here. - +
+ +

No live subagents

+

+ When a turn delegates work, child agents stream their progress here. +

+
) } - return ( -
- {tasks.map(task => { - const Icon = STATUS_ICON[task.status] + const summary = [ + `${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`, + active > 0 ? `${active} active` : '', + failed > 0 ? `${failed} failed` : '', + tools > 0 ? `${tools} tools` : '', + files > 0 ? `${files} files` : '', + tokens > 0 ? fmtTokens(tokens) : '', + cost > 0 ? `$${cost.toFixed(2)}` : '' + ].filter(Boolean) - return ( - - -
-
{task.label}
- {task.detail &&
{task.detail}
} -
-
- ) - })} + return ( +
+

{summary.join(' · ')}

+
+
+ {groups.map(group => ( + + ))} +
+
) } -function SectionStub({ label }: { label: string }) { +function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) { + if (group.nodes.length === 1 && group.taskCount <= 1) { + return + } + + const activeWorkers = group.nodes.filter(n => n.status === 'running' || n.status === 'queued').length + return ( - - -
-

{label} — coming soon

-

- Subagent stores aren't wired into the desktop yet. Once gateway events for{' '} - - subagent.spawn / progress / complete - {' '} - land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the - TUI's /agents overlay. -

+
+

+ {group.label} · {group.nodes.length} workers + {activeWorkers > 0 ? · {activeWorkers} active : null} +

+
+ {group.nodes.map(node => ( + + ))}
- +
) } + +function StreamLine({ + active, + entry, + parentRunning, + rowKey +}: { + active: boolean + entry: SubagentStreamEntry + parentRunning: boolean + rowKey: string +}) { + const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`) + const isMono = entry.kind === 'tool' + const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind] + + return ( +
+ {streamGlyph(entry)} + + {entry.text} + {active ? ( + + ) : null} + +
+ ) +} + +function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) { + const running = node.status === 'running' || node.status === 'queued' + const elapsed = useElapsedSeconds(running, `subagent:${node.id}`) + const durationSeconds = + typeof node.durationSeconds === 'number' ? Math.max(0, Math.round(node.durationSeconds)) : elapsed + const [open, setOpen] = useState(() => running || depth < 2) + const enterRef = useEnterAnimation(true, `subagent-row:${node.id}`) + + useEffect(() => { + if (running) setOpen(true) + }, [running]) + + const visibleRows = open ? node.stream.slice(-10) : node.stream.slice(-2) + const fileLines = [...node.filesWritten.map(p => `+ ${p}`), ...node.filesRead.map(p => `· ${p}`)] + + const subtitle = [ + node.model, + fmtDuration(durationSeconds), + node.toolCount ? `${node.toolCount} tools` : '', + fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)), + `updated ${fmtAge(node.updatedAt, nowMs)}` + ].filter(Boolean) + + return ( +
0 && 'pl-4')} + data-slot="tool-block" + ref={enterRef} + > + + + {visibleRows.length > 0 ? ( +
+ {visibleRows.map((entry, i) => ( + + ))} +
+ ) : null} + + {open && fileLines.length > 0 ? ( +
+

Files

+ {fileLines.slice(0, 8).map(line => ( +

+ {line} +

+ ))} + {fileLines.length > 8 ? ( +

+ +{fileLines.length - 8} more files +

+ ) : null} +
+ ) : null} + + {node.children.length > 0 ? ( +
+ {node.children.map(child => ( + + ))} +
+ ) : null} +
+ ) +} + diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 6982926c99..9a691caa09 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -33,7 +33,6 @@ import type { StatusResponse } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' -import { triggerHaptic } from '@/lib/haptics' import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons' import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' @@ -392,20 +391,6 @@ export function CommandCenterView({ [] ) - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault() - triggerHaptic('close') - onClose() - } - } - - window.addEventListener('keydown', onKeyDown) - - return () => window.removeEventListener('keydown', onKeyDown) - }, [onClose]) - useEffect(() => { if (!debouncedQuery) { setSearchGroups([]) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index d4097260a7..1a7990d984 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -426,6 +426,7 @@ export function DesktopController() { {settingsOpen && ( { void refreshHermesConfig() diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx index 715e3eb617..3886c22b21 100644 --- a/apps/desktop/src/app/overlays/overlay-view.tsx +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import { type ReactNode, useEffect } from 'react' import { Button } from '@/components/ui/button' import { triggerHaptic } from '@/lib/haptics' @@ -27,6 +27,23 @@ export function OverlayView({ onClose() } + // Esc dismisses every OverlayView-based overlay. Nested Radix dialogs + // stop propagation themselves, so opening (e.g.) the model picker inside + // Settings still closes the picker first instead of the underlying overlay. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return + + event.preventDefault() + triggerHaptic('close') + onClose() + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + return (
{ + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} +} + +function parseMaybeRecord(value: unknown): Record { + if (typeof value === 'string') { + try { + return asRecord(JSON.parse(value)) + } catch { + return {} + } + } + + return asRecord(value) +} + +const firstString = (...candidates: unknown[]): string => { + for (const v of candidates) { + if (typeof v === 'string' && v) return v + } + + return '' +} + +function delegateTaskPayloads( + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete', + sourceEventType?: string +): Record[] { + if (payload?.name !== 'delegate_task') return [] + + const args = parseMaybeRecord(payload.args ?? payload.input) + const result = parseMaybeRecord(payload.result) + const rawTasks = Array.isArray(args.tasks) ? args.tasks : [] + const tasks = rawTasks.length ? rawTasks.map(parseMaybeRecord) : [args] + const status = phase === 'complete' ? (payload.error ? 'failed' : 'completed') : 'running' + const toolId = payload.tool_id || payload.tool_call_id || payload.id || 'delegate_task' + const progressText = firstString(payload.preview, payload.message, payload.context) + const eventType = + phase === 'complete' + ? 'subagent.complete' + : sourceEventType === 'tool.start' + ? 'subagent.start' + : 'subagent.progress' + + return tasks.map((task, index) => { + const goal = firstString(task.goal, args.goal, payload.context) || 'Delegated task' + const summary = firstString(result.summary, payload.summary, payload.message) + + return { + depth: 0, + duration_seconds: payload.duration_s, + goal, + status, + subagent_id: `delegate-tool:${toolId}:${index}`, + summary: summary || undefined, + task_count: tasks.length, + task_index: index, + text: eventType === 'subagent.progress' ? progressText || goal : undefined, + tool_name: eventType === 'subagent.start' ? 'delegate_task' : undefined, + tool_preview: eventType === 'subagent.start' ? progressText : undefined, + toolsets: Array.isArray(task.toolsets) ? task.toolsets : Array.isArray(args.toolsets) ? args.toolsets : [], + event_type: eventType, + output_tail: + phase === 'complete' && summary + ? [{ is_error: Boolean(payload.error), preview: summary, tool: 'delegate_task' }] + : undefined + } + }) +} + export function useMessageStream({ activeSessionIdRef, hydrateFromStoredSession, @@ -145,6 +227,7 @@ export function useMessageStream({ const queuedDeltasRef = useRef>(new Map()) const flushHandleRef = useRef(null) + const nativeSubagentSessionsRef = useRef>(new Set()) const flushQueuedDeltas = useCallback( (sessionId?: string) => { @@ -281,7 +364,18 @@ export function useMessageStream({ ) const upsertToolCall = useCallback( - (sessionId: string, payload: GatewayEventPayload | undefined, phase: 'running' | 'complete') => { + ( + sessionId: string, + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete', + sourceEventType?: string + ) => { + if (!nativeSubagentSessionsRef.current.has(sessionId)) { + for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) { + upsertSubagent(sessionId, subagentPayload, true, phase === 'complete' ? 'delegate.complete' : 'delegate.running') + } + } + mutateStream( sessionId, parts => upsertToolPart(parts, payload, phase), @@ -506,6 +600,8 @@ export function useMessageStream({ } flushQueuedDeltas(sessionId) + clearSessionSubagents(sessionId) + nativeSubagentSessionsRef.current.delete(sessionId) if (isActiveEvent) { triggerHaptic('streamStart') @@ -564,17 +660,32 @@ export function useMessageStream({ if (!sessionId) { return } + flushQueuedDeltas(sessionId) - upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running') + upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type) } else if (event.type === 'tool.complete') { if (sessionId) { flushQueuedDeltas(sessionId) - upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete') + upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type) } 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) { + if (!nativeSubagentSessionsRef.current.has(sessionId)) { + pruneDelegateFallbackSubagents(sessionId) + } + + nativeSubagentSessionsRef.current.add(sessionId) + upsertSubagent( + sessionId, + payload as Record, + event.type === 'subagent.spawn_requested' || event.type === 'subagent.start', + event.type + ) + } } 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 b58e68931d..605737b043 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 2e40c2ff62..a7c2d67a6f 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: '' }) @@ -77,16 +80,9 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { } } + // OverlayView handles Esc; this just adds Cmd/Ctrl+P → focus search. useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - triggerHaptic('close') - onClose() - - return - } - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') { e.preventDefault() searchInputRef.current?.focus() @@ -97,7 +93,7 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [onClose]) + }, []) return ( 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 0000000000..428b76bbd6 --- /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'} +
+ +