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:
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user