mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +00:00
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.
This commit is contained in:
parent
ca2c3d4ab4
commit
17e86dddc7
10 changed files with 620 additions and 12 deletions
|
|
@ -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 (
|
||||
<OverlayView closeLabel="Close agents" onClose={onClose}>
|
||||
|
|
@ -82,13 +87,91 @@ export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps
|
|||
<p className="text-xs text-muted-foreground">{active.description}</p>
|
||||
</header>
|
||||
|
||||
{section === 'activity' ? <ActivityList tasks={activityTasks} /> : <SectionStub label={active.label} />}
|
||||
{section === 'tree' ? (
|
||||
<SubagentTree tree={tree} />
|
||||
) : section === 'activity' ? (
|
||||
<ActivityList tasks={activityTasks} />
|
||||
) : (
|
||||
<SectionStub label={active.label} />
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_CLASS: Record<SubagentStatus, string> = {
|
||||
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 (
|
||||
<OverlayCard className="grid place-items-center gap-3 px-6 py-12 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/70" />
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium text-foreground">No live subagents</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
|
||||
When a turn delegates work, child agents appear here as a live spawn tree.
|
||||
</p>
|
||||
</div>
|
||||
</OverlayCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 overflow-y-auto pr-1">
|
||||
{tree.map(node => (
|
||||
<SubagentRow key={node.id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SubagentRow({ node, depth = 0 }: { node: SubagentNode; depth?: number }) {
|
||||
const running = node.status === 'running' || node.status === 'queued'
|
||||
|
||||
return (
|
||||
<OverlayCard className="px-3 py-2" style={{ marginLeft: depth ? `${Math.min(depth, 4) * 1.25}rem` : undefined }}>
|
||||
<div className="flex items-start gap-2">
|
||||
{running ? (
|
||||
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-primary" />
|
||||
) : (
|
||||
<Sparkles className={cn('mt-0.5 size-3.5 shrink-0', STATUS_CLASS[node.status])} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="truncate text-sm font-medium text-foreground">{node.goal}</div>
|
||||
<span className={cn('shrink-0 text-[0.65rem]', STATUS_CLASS[node.status])}>{node.status}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.68rem] text-muted-foreground">
|
||||
{node.model && <span>{node.model}</span>}
|
||||
{typeof node.durationSeconds === 'number' && <span>{node.durationSeconds.toFixed(1)}s</span>}
|
||||
{typeof node.costUsd === 'number' && <span>${node.costUsd.toFixed(4)}</span>}
|
||||
{typeof node.apiCalls === 'number' && <span>{node.apiCalls} calls</span>}
|
||||
</div>
|
||||
{(node.toolName || node.toolPreview || node.summary) && (
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{node.summary || [node.toolName, node.toolPreview].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{node.children.length > 0 && (
|
||||
<div className="mt-2 grid gap-2">
|
||||
{node.children.map(child => (
|
||||
<SubagentRow depth={depth + 1} key={child.id} node={child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -424,6 +424,7 @@ export function DesktopController() {
|
|||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<SettingsView
|
||||
gateway={gatewayRef.current}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onConfigSaved={() => {
|
||||
void refreshHermesConfig()
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>, event.type === 'subagent.spawn_requested')
|
||||
}
|
||||
} else if (event.type === 'clarify.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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...'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<SettingsQueryKey, string>>({
|
||||
|
|
@ -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')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
label="MCP"
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
|
|
@ -196,6 +205,8 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
|
|||
/>
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings query={queries.keys} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
|
||||
) : (
|
||||
<ToolsSettings query={queries.tools} />
|
||||
)}
|
||||
|
|
|
|||
259
apps/desktop/src/app/settings/mcp-settings.tsx
Normal file
259
apps/desktop/src/app/settings/mcp-settings.tsx
Normal file
|
|
@ -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<string, Record<string, unknown>>
|
||||
|
||||
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<string, unknown>) =>
|
||||
typeof server.transport === 'string'
|
||||
? server.transport
|
||||
: typeof server.url === 'string'
|
||||
? 'http'
|
||||
: typeof server.command === 'string'
|
||||
? 'stdio'
|
||||
: 'custom'
|
||||
|
||||
function serverMatches(name: string, server: Record<string, unknown>, 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<HermesConfigRecord | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(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 <LoadingState label="Loading MCP servers..." />
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
|
||||
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<string, unknown>
|
||||
} 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 (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<SectionHeading icon={Package} meta={`${names.length} configured`} title="MCP servers" />
|
||||
<div className="flex items-center gap-2">
|
||||
<OverlayActionButton onClick={() => setSelected(null)}>New server</OverlayActionButton>
|
||||
<OverlayActionButton disabled={reloading} onClick={() => void reloadMcp()}>
|
||||
{reloading ? 'Reloading...' : 'Reload MCP'}
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]">
|
||||
<OverlayCard className="min-h-64 overflow-hidden p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState description="Add a stdio or HTTP server to expose MCP tools." title="No MCP servers" />
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{filtered.map(serverName => {
|
||||
const server = servers[serverName]
|
||||
const active = selected === serverName
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover) ${
|
||||
active ? 'bg-accent/45 text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
key={serverName}
|
||||
onClick={() => setSelected(serverName)}
|
||||
type="button"
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{serverName}</div>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<Pill>{transportLabel(server)}</Pill>
|
||||
{server.disabled === true && <Pill>disabled</Pill>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="grid gap-3 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
{selected ? 'Edit server' : 'New server'}
|
||||
</div>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">Name</span>
|
||||
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">Server JSON</span>
|
||||
<Textarea
|
||||
className="min-h-80 font-mono text-xs"
|
||||
onChange={event => setBody(event.currentTarget.value)}
|
||||
spellCheck={false}
|
||||
value={body}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
{selected ? (
|
||||
<OverlayActionButton disabled={saving} onClick={() => void removeServer(selected)} tone="danger">
|
||||
Remove
|
||||
</OverlayActionButton>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<OverlayActionButton disabled={saving} onClick={() => void saveServer()}>
|
||||
{saving ? 'Saving...' : 'Save server'}
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'tools'
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
gateway?: HermesGateway | null
|
||||
onClose: () => void
|
||||
onConfigSaved?: () => void
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
$workingSessionIds,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ export function useStatusbarItems({
|
|||
const sessionStartedAt = useStore($sessionStartedAt)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
|
|
@ -106,15 +108,20 @@ export function useStatusbarItems({
|
|||
[gatewayLogLines, handleRestartGateway, openCommandCenterSection, restartingGateway, statusSnapshot]
|
||||
)
|
||||
|
||||
const { bgFailed, bgRunning } = useMemo(() => {
|
||||
const { bgFailed, bgRunning, subagentsRunning } = useMemo(() => {
|
||||
const actions = Object.values(desktopActionTasks)
|
||||
const running = actions.filter(t => t.status.running).length
|
||||
const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length
|
||||
const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0
|
||||
const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0
|
||||
const subagentsRunning = Object.values(subagentsBySession).reduce((sum, items) => sum + activeSubagentCount(items), 0)
|
||||
|
||||
return { bgFailed: failed + previewFailed, bgRunning: workingSessionIds.length + running + previewRunning }
|
||||
}, [desktopActionTasks, previewServerRestartStatus, workingSessionIds])
|
||||
return {
|
||||
bgFailed: failed + previewFailed,
|
||||
bgRunning: workingSessionIds.length + running + previewRunning,
|
||||
subagentsRunning
|
||||
}
|
||||
}, [desktopActionTasks, previewServerRestartStatus, subagentsBySession, workingSessionIds])
|
||||
|
||||
const gatewayUp = Boolean(statusSnapshot?.gateway_running)
|
||||
|
||||
|
|
@ -189,11 +196,18 @@ export function useStatusbarItems({
|
|||
agentsOpen && 'bg-accent/55 text-foreground',
|
||||
bgFailed > 0 && 'text-destructive hover:text-destructive'
|
||||
),
|
||||
detail: bgFailed > 0 ? `${bgFailed} failed` : bgRunning > 0 ? `${bgRunning} running` : undefined,
|
||||
detail:
|
||||
subagentsRunning > 0
|
||||
? `${subagentsRunning} subagent${subagentsRunning === 1 ? '' : 's'}`
|
||||
: bgFailed > 0
|
||||
? `${bgFailed} failed`
|
||||
: bgRunning > 0
|
||||
? `${bgRunning} running`
|
||||
: undefined,
|
||||
icon:
|
||||
bgFailed > 0 ? (
|
||||
<AlertCircle className="size-3" />
|
||||
) : bgRunning > 0 ? (
|
||||
) : bgRunning > 0 || subagentsRunning > 0 ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-3" />
|
||||
|
|
@ -214,6 +228,7 @@ export function useStatusbarItems({
|
|||
gatewayUp,
|
||||
openAgents,
|
||||
statusSnapshot?.gateway_state,
|
||||
subagentsRunning,
|
||||
toggleCommandCenter
|
||||
]
|
||||
)
|
||||
|
|
|
|||
63
apps/desktop/src/store/subagents.test.ts
Normal file
63
apps/desktop/src/store/subagents.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $subagentsBySession, activeSubagentCount, buildSubagentTree, clearSessionSubagents, upsertSubagent } from './subagents'
|
||||
|
||||
describe('subagent store', () => {
|
||||
beforeEach(() => {
|
||||
$subagentsBySession.set({})
|
||||
})
|
||||
|
||||
it('upserts subagent progress and keeps terminal status stable', () => {
|
||||
upsertSubagent('s1', {
|
||||
goal: 'scan files',
|
||||
status: 'running',
|
||||
subagent_id: 'a1',
|
||||
task_index: 0
|
||||
})
|
||||
upsertSubagent('s1', {
|
||||
goal: 'scan files',
|
||||
status: 'completed',
|
||||
subagent_id: 'a1',
|
||||
summary: 'done',
|
||||
task_index: 0
|
||||
})
|
||||
upsertSubagent('s1', {
|
||||
goal: 'scan files',
|
||||
status: 'running',
|
||||
subagent_id: 'a1',
|
||||
task_index: 0,
|
||||
text: 'late'
|
||||
})
|
||||
|
||||
const item = $subagentsBySession.get().s1?.[0]
|
||||
expect(item?.status).toBe('completed')
|
||||
expect(item?.summary).toBe('done')
|
||||
})
|
||||
|
||||
it('builds parent/child trees', () => {
|
||||
upsertSubagent('s1', { goal: 'parent', status: 'running', subagent_id: 'p', task_index: 0 })
|
||||
upsertSubagent('s1', {
|
||||
goal: 'child',
|
||||
parent_id: 'p',
|
||||
status: 'queued',
|
||||
subagent_id: 'c',
|
||||
task_index: 1
|
||||
})
|
||||
|
||||
const tree = buildSubagentTree($subagentsBySession.get().s1 ?? [])
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0]?.children[0]?.goal).toBe('child')
|
||||
expect(activeSubagentCount($subagentsBySession.get().s1 ?? [])).toBe(2)
|
||||
})
|
||||
|
||||
it('clears one session without touching another', () => {
|
||||
upsertSubagent('s1', { goal: 'one', status: 'running', subagent_id: 'a1', task_index: 0 })
|
||||
upsertSubagent('s2', { goal: 'two', status: 'running', subagent_id: 'a2', task_index: 0 })
|
||||
|
||||
clearSessionSubagents('s1')
|
||||
|
||||
expect($subagentsBySession.get().s1).toBeUndefined()
|
||||
expect($subagentsBySession.get().s2).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
159
apps/desktop/src/store/subagents.ts
Normal file
159
apps/desktop/src/store/subagents.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
export type SubagentStatus = 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
|
||||
|
||||
export interface SubagentProgress {
|
||||
id: string
|
||||
apiCalls?: number
|
||||
costUsd?: number
|
||||
depth: number
|
||||
durationSeconds?: number
|
||||
filesRead: string[]
|
||||
filesWritten: string[]
|
||||
goal: string
|
||||
inputTokens?: number
|
||||
model?: string
|
||||
outputTail: { isError?: boolean; preview?: string; tool?: string }[]
|
||||
outputTokens?: number
|
||||
parentId: null | string
|
||||
reasoningTokens?: number
|
||||
sessionId: string
|
||||
status: SubagentStatus
|
||||
summary?: string
|
||||
taskCount: number
|
||||
taskIndex: number
|
||||
toolName?: string
|
||||
toolPreview?: string
|
||||
toolsets: string[]
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface SubagentNode extends SubagentProgress {
|
||||
children: SubagentNode[]
|
||||
}
|
||||
|
||||
export type SubagentPayload = Record<string, unknown>
|
||||
|
||||
export const $subagentsBySession = atom<Record<string, SubagentProgress[]>>({})
|
||||
|
||||
const TERMINAL = new Set<SubagentStatus>(['completed', 'failed', 'interrupted'])
|
||||
|
||||
const asString = (value: unknown) => (typeof value === 'string' ? value : '')
|
||||
const asNumber = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
|
||||
const asStatus = (value: unknown): SubagentStatus =>
|
||||
value === 'completed' || value === 'failed' || value === 'interrupted' || value === 'queued' ? value : 'running'
|
||||
|
||||
const asStringList = (value: unknown) => (Array.isArray(value) ? value.map(asString).filter(Boolean) : [])
|
||||
|
||||
const asOutputTail = (value: unknown): SubagentProgress['outputTail'] =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.map(item => (item && typeof item === 'object' ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item))
|
||||
.map(item => ({
|
||||
isError: item.is_error === true,
|
||||
preview: asString(item.preview) || undefined,
|
||||
tool: asString(item.tool) || undefined
|
||||
}))
|
||||
: []
|
||||
|
||||
function idFor(payload: SubagentPayload) {
|
||||
return (
|
||||
asString(payload.subagent_id) ||
|
||||
`${asString(payload.parent_id) || 'root'}:${asNumber(payload.task_index) ?? 0}:${asString(payload.goal)}`
|
||||
)
|
||||
}
|
||||
|
||||
function toProgress(sessionId: string, payload: SubagentPayload, previous?: SubagentProgress): SubagentProgress {
|
||||
return {
|
||||
apiCalls: asNumber(payload.api_calls) ?? previous?.apiCalls,
|
||||
costUsd: asNumber(payload.cost_usd) ?? previous?.costUsd,
|
||||
depth: asNumber(payload.depth) ?? previous?.depth ?? 0,
|
||||
durationSeconds: asNumber(payload.duration_seconds) ?? previous?.durationSeconds,
|
||||
filesRead: asStringList(payload.files_read).length ? asStringList(payload.files_read) : (previous?.filesRead ?? []),
|
||||
filesWritten: asStringList(payload.files_written).length
|
||||
? asStringList(payload.files_written)
|
||||
: (previous?.filesWritten ?? []),
|
||||
goal: asString(payload.goal) || previous?.goal || 'Subagent',
|
||||
id: previous?.id || idFor(payload),
|
||||
inputTokens: asNumber(payload.input_tokens) ?? previous?.inputTokens,
|
||||
model: asString(payload.model) || previous?.model,
|
||||
outputTail: asOutputTail(payload.output_tail).length ? asOutputTail(payload.output_tail) : (previous?.outputTail ?? []),
|
||||
outputTokens: asNumber(payload.output_tokens) ?? previous?.outputTokens,
|
||||
parentId: asString(payload.parent_id) || previous?.parentId || null,
|
||||
reasoningTokens: asNumber(payload.reasoning_tokens) ?? previous?.reasoningTokens,
|
||||
sessionId,
|
||||
status: asStatus(payload.status),
|
||||
summary: asString(payload.summary) || previous?.summary,
|
||||
taskCount: asNumber(payload.task_count) ?? previous?.taskCount ?? 1,
|
||||
taskIndex: asNumber(payload.task_index) ?? previous?.taskIndex ?? 0,
|
||||
toolName: asString(payload.tool_name) || previous?.toolName,
|
||||
toolPreview: asString(payload.tool_preview) || asString(payload.text) || previous?.toolPreview,
|
||||
toolsets: asStringList(payload.toolsets).length ? asStringList(payload.toolsets) : (previous?.toolsets ?? []),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSessionSubagents(sessionId: string) {
|
||||
const current = $subagentsBySession.get()
|
||||
|
||||
if (!(sessionId in current)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...current }
|
||||
delete next[sessionId]
|
||||
$subagentsBySession.set(next)
|
||||
}
|
||||
|
||||
export function upsertSubagent(sessionId: string, payload: SubagentPayload, createIfMissing = true) {
|
||||
const current = $subagentsBySession.get()
|
||||
const list = current[sessionId] ?? []
|
||||
const id = idFor(payload)
|
||||
const index = list.findIndex(item => item.id === id)
|
||||
|
||||
if (index < 0 && !createIfMissing) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = index >= 0 ? list[index] : undefined
|
||||
|
||||
if (previous && TERMINAL.has(previous.status)) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextItem = toProgress(sessionId, payload, previous)
|
||||
const nextList = index >= 0 ? list.map(item => (item.id === id ? nextItem : item)) : [...list, nextItem]
|
||||
|
||||
$subagentsBySession.set({ ...current, [sessionId]: nextList })
|
||||
}
|
||||
|
||||
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
|
||||
const nodes = new Map<string, SubagentNode>()
|
||||
|
||||
for (const item of items) {
|
||||
nodes.set(item.id, { ...item, children: [] })
|
||||
}
|
||||
|
||||
const roots: SubagentNode[] = []
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
const parent = node.parentId ? nodes.get(node.parentId) : null
|
||||
|
||||
if (parent) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
const sort = (a: SubagentNode, b: SubagentNode) => a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal)
|
||||
const walk = (node: SubagentNode) => node.children.sort(sort).forEach(walk)
|
||||
|
||||
roots.sort(sort).forEach(walk)
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
export const activeSubagentCount = (items: readonly SubagentProgress[]) =>
|
||||
items.filter(item => item.status === 'queued' || item.status === 'running').length
|
||||
Loading…
Add table
Add a link
Reference in a new issue