-
{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}
+ >
+
setOpen(v => !v)}
+ type="button"
+ >
+ {statusGlyph(node.status)}
+
+
+ {node.goal}
+
+ {subtitle.length > 0 ? (
+
+ {subtitle.join(' · ')}
+
+ ) : null}
+
+ {running ? : null}
+
+
+ {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 (
+
setSelected(serverName)}
+ type="button"
+ >
+ {serverName}
+
+
{transportLabel(server)}
+ {server.disabled === true &&
disabled }
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ {selected ? 'Edit server' : 'New server'}
+
+
+ Name
+ setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
+
+
+ Server JSON
+
+
+ {selected ? (
+ void removeServer(selected)} tone="danger">
+ Remove
+
+ ) : (
+
+ )}
+ void saveServer()}>
+ {saving ? 'Saving...' : 'Save server'}
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts
index 7ab8563fd2..adf97501dd 100644
--- a/apps/desktop/src/app/settings/types.ts
+++ b/apps/desktop/src/app/settings/types.ts
@@ -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>
export interface SettingsPageProps {
+ gateway?: HermesGateway | null
onClose: () => void
onConfigSaved?: () => void
}
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index a0afb9b821..0f1d4c7887 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -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'
@@ -64,6 +65,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)
@@ -107,15 +109,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)
@@ -190,11 +197,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 ? (
- ) : bgRunning > 0 ? (
+ ) : bgRunning > 0 || subagentsRunning > 0 ? (
) : (
@@ -223,6 +237,7 @@ export function useStatusbarItems({
gatewayUp,
openAgents,
statusSnapshot?.gateway_state,
+ subagentsRunning,
toggleCommandCenter
]
)
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
index 5615fe8fe2..df0e38103b 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
@@ -833,30 +833,14 @@ export function inlineDiffFromResult(result: unknown): string {
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
}
+// Falls back to a string only when there's something concrete to render —
+// counts of opaque items/fields are noise, not signal.
function minimalValueSummary(value: unknown): string {
- if (value == null) {
- return ''
- }
+ if (value == null) return ''
+ if (typeof value === 'string') return value
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
- if (typeof value === 'string') {
- return value
- }
-
- if (typeof value === 'number' || typeof value === 'boolean') {
- return String(value)
- }
-
- if (Array.isArray(value)) {
- return value.length ? `Returned ${value.length} items.` : 'No items returned.'
- }
-
- if (isRecord(value)) {
- const count = Object.keys(value).length
-
- return count ? `Returned object with ${count} fields.` : 'Returned an empty object.'
- }
-
- return String(value)
+ return ''
}
function fallbackDetailText(args: unknown, result: unknown): string {
diff --git a/apps/desktop/src/lib/tool-result-summary.ts b/apps/desktop/src/lib/tool-result-summary.ts
index 8defcd626e..615473568c 100644
--- a/apps/desktop/src/lib/tool-result-summary.ts
+++ b/apps/desktop/src/lib/tool-result-summary.ts
@@ -185,7 +185,7 @@ function formatFieldValue(value: unknown, depth: number): string {
if (Array.isArray(v)) {
if (!v.length) {
- return '0 items'
+ return ''
}
const scalars = v.map(summarizeScalar).filter(Boolean)
@@ -204,10 +204,10 @@ function formatFieldValue(value: unknown, depth: number): string {
return clipInline(String(v))
}
+// "Returned N items" / "0 items" / "Returned an empty object" are all
+// noise — better to render nothing and let the title carry the signal.
function formatArraySummary(value: unknown[], depth: number): string {
- if (!value.length) {
- return 'No items returned.'
- }
+ if (!value.length) return ''
const max = 6
const lines = value
@@ -216,9 +216,7 @@ function formatArraySummary(value: unknown[], depth: number): string {
.filter(Boolean)
.map(l => `- ${l}`)
- if (!lines.length) {
- return `Returned ${pluralize(value.length, 'item')}.`
- }
+ if (!lines.length) return ''
if (value.length > max) {
const remaining = value.length - max
@@ -230,10 +228,7 @@ function formatArraySummary(value: unknown[], depth: number): string {
function formatRecordSummary(record: Json, depth: number): string {
const keys = Object.keys(record)
-
- if (!keys.length) {
- return 'Returned an empty object.'
- }
+ if (!keys.length) return ''
if (depth <= 2) {
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
@@ -261,9 +256,7 @@ function formatRecordSummary(record: Json, depth: number): string {
}
}
- if (!lines.length) {
- return `Returned object with ${pluralize(keys.length, 'field')}.`
- }
+ if (!lines.length) return ''
if (candidates.length > lines.length) {
const remaining = candidates.length - lines.length
diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts
new file mode 100644
index 0000000000..4d4d079aeb
--- /dev/null
+++ b/apps/desktop/src/store/subagents.test.ts
@@ -0,0 +1,105 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ $subagentsBySession,
+ activeSubagentCount,
+ buildSubagentTree,
+ clearSessionSubagents,
+ pruneDelegateFallbackSubagents,
+ upsertSubagent
+} from './subagents'
+
+const listFor = (sid: string) => $subagentsBySession.get()[sid] ?? []
+
+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 = listFor('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(listFor('s1'))
+ expect(tree).toHaveLength(1)
+ expect(tree[0]?.children[0]?.goal).toBe('child')
+ expect(activeSubagentCount(listFor('s1'))).toBe(2)
+ })
+
+ it('keeps root nodes in spawn order, not task index order', () => {
+ const nowSpy = vi.spyOn(Date, 'now')
+ nowSpy.mockReturnValueOnce(1_000)
+ upsertSubagent('s1', { goal: 'first spawn', status: 'running', subagent_id: 'a', task_index: 2 })
+ nowSpy.mockReturnValueOnce(2_000)
+ upsertSubagent('s1', { goal: 'second spawn', status: 'running', subagent_id: 'b', task_index: 0 })
+ nowSpy.mockRestore()
+
+ expect(buildSubagentTree(listFor('s1')).map(n => n.id)).toEqual(['a', 'b'])
+ })
+
+ it('captures live thinking/progress/tool stream lines', () => {
+ upsertSubagent(
+ 's1',
+ { goal: 'scan files', status: 'queued', subagent_id: 'a1', task_index: 0 },
+ true,
+ 'subagent.spawn_requested'
+ )
+ upsertSubagent(
+ 's1',
+ { status: 'running', subagent_id: 'a1', task_index: 0, tool_name: 'search_files', tool_preview: 'pattern=hermes' },
+ false,
+ 'subagent.tool'
+ )
+ upsertSubagent(
+ 's1',
+ { status: 'running', subagent_id: 'a1', task_index: 0, text: 'plan the search order' },
+ false,
+ 'subagent.thinking'
+ )
+ upsertSubagent(
+ 's1',
+ { status: 'running', subagent_id: 'a1', task_index: 0, text: 'found candidate matches' },
+ false,
+ 'subagent.progress'
+ )
+ upsertSubagent(
+ 's1',
+ { status: 'completed', subagent_id: 'a1', summary: 'search complete', task_index: 0 },
+ false,
+ 'subagent.complete'
+ )
+
+ const item = listFor('s1')[0]
+ expect(item?.stream.map(e => e.kind)).toEqual(['tool', 'thinking', 'progress', 'summary'])
+ expect(item?.stream.find(e => e.kind === 'tool')?.text).toContain('Search Files')
+ expect(item?.stream.find(e => e.kind === 'thinking')?.text).toBe('plan the search order')
+ expect(item?.stream.find(e => e.kind === 'summary')?.text).toBe('search complete')
+ })
+
+ it('prunes delegate fallback rows once native events arrive', () => {
+ upsertSubagent('s1', { goal: 'fallback', status: 'running', subagent_id: 'delegate-tool:abc:0', task_index: 0 })
+ upsertSubagent('s1', { goal: 'native', status: 'running', subagent_id: 'sa-0-xyz', task_index: 0 })
+
+ pruneDelegateFallbackSubagents('s1')
+
+ expect(listFor('s1').map(item => item.id)).toEqual(['sa-0-xyz'])
+ })
+
+ 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)
+ })
+})
diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts
new file mode 100644
index 0000000000..db01e2db35
--- /dev/null
+++ b/apps/desktop/src/store/subagents.ts
@@ -0,0 +1,214 @@
+import { atom } from 'nanostores'
+
+export type SubagentStatus = 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
+export type SubagentStreamKind = 'progress' | 'summary' | 'thinking' | 'tool'
+
+export interface SubagentStreamEntry {
+ at: number
+ isError?: boolean
+ kind: SubagentStreamKind
+ text: string
+}
+
+export interface SubagentProgress {
+ id: string
+ parentId: null | string
+ goal: string
+ model?: string
+ status: SubagentStatus
+ taskCount: number
+ taskIndex: number
+ startedAt: number
+ updatedAt: number
+ durationSeconds?: number
+ costUsd?: number
+ inputTokens?: number
+ outputTokens?: number
+ toolCount?: number
+ filesRead: string[]
+ filesWritten: string[]
+ stream: SubagentStreamEntry[]
+ summary?: string
+ /** Active tool while running — cleared on terminal status. */
+ currentTool?: string
+}
+
+export interface SubagentNode extends SubagentProgress {
+ children: SubagentNode[]
+}
+
+export type SubagentPayload = Record
+
+const TERMINAL: ReadonlySet = new Set(['completed', 'failed', 'interrupted'])
+const MAX_STREAM = 24
+const PREVIEW_MAX = 220
+const TOOL_PREVIEW_MAX = 96
+
+export const $subagentsBySession = atom>({})
+
+const isStr = (v: unknown): v is string => typeof v === 'string'
+const str = (v: unknown) => (isStr(v) ? v : '')
+const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
+const strList = (v: unknown) => (Array.isArray(v) ? v.filter(isStr) : [])
+
+const asStatus = (v: unknown): SubagentStatus =>
+ v === 'completed' || v === 'failed' || v === 'interrupted' || v === 'queued' ? v : 'running'
+
+const compact = (text: string, max = PREVIEW_MAX) => {
+ const line = text.replace(/\s+/g, ' ').trim()
+ if (!line) return ''
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line
+}
+
+const toolLabel = (name: string) =>
+ name.split('_').filter(Boolean).map(p => p[0]!.toUpperCase() + p.slice(1)).join(' ') || name
+
+const formatTool = (name: string, preview = '') => {
+ const snippet = compact(preview, TOOL_PREVIEW_MAX)
+ return snippet ? `${toolLabel(name)}("${snippet}")` : toolLabel(name)
+}
+
+interface TailEntry {
+ isError?: boolean
+ preview?: string
+ tool?: string
+}
+
+const asTail = (v: unknown): TailEntry[] =>
+ Array.isArray(v)
+ ? v
+ .filter((item): item is Record => !!item && typeof item === 'object')
+ .map(item => ({
+ isError: item.is_error === true,
+ preview: str(item.preview) || undefined,
+ tool: str(item.tool) || undefined
+ }))
+ : []
+
+const idOf = (p: SubagentPayload) =>
+ str(p.subagent_id) || `${str(p.parent_id) || 'root'}:${num(p.task_index) ?? 0}:${str(p.goal)}`
+
+const appendStream = (stream: SubagentStreamEntry[], entry: SubagentStreamEntry) => {
+ const last = stream.at(-1)
+ if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) return stream
+
+ return [...stream, entry].slice(-MAX_STREAM)
+}
+
+function streamFromPayload(
+ payload: SubagentPayload,
+ status: SubagentStatus,
+ eventType: string,
+ at: number
+): SubagentStreamEntry[] {
+ const out: SubagentStreamEntry[] = []
+ const tool = str(payload.tool_name)
+ const preview = str(payload.tool_preview) || str(payload.text)
+ const text = compact(str(payload.text) || preview)
+
+ for (const tail of asTail(payload.output_tail)) {
+ const line = tail.tool ? formatTool(tail.tool, tail.preview ?? '') : compact(tail.preview ?? '')
+ if (line) out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line })
+ }
+
+ if (tool) out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) })
+
+ if (eventType === 'subagent.progress' && text)
+ out.push({ at, isError: !!payload.error, kind: 'progress', text })
+
+ if (eventType === 'subagent.thinking' && text) out.push({ at, kind: 'thinking', text })
+
+ const summary = compact(str(payload.summary) || str(payload.text))
+ if (TERMINAL.has(status) && summary)
+ out.push({ at, isError: status === 'failed', kind: 'summary', text: summary })
+
+ return out
+}
+
+function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined, eventType = ''): SubagentProgress {
+ const at = Date.now()
+ const status = asStatus(payload.status)
+ const tool = str(payload.tool_name)
+ const stream = streamFromPayload(payload, status, eventType, at).reduce(appendStream, prev?.stream ?? [])
+ const filesRead = strList(payload.files_read)
+ const filesWritten = strList(payload.files_written)
+
+ return {
+ id: prev?.id ?? idOf(payload),
+ parentId: str(payload.parent_id) || prev?.parentId || null,
+ goal: str(payload.goal) || prev?.goal || 'Subagent',
+ model: str(payload.model) || prev?.model,
+ status,
+ taskCount: num(payload.task_count) ?? prev?.taskCount ?? 1,
+ taskIndex: num(payload.task_index) ?? prev?.taskIndex ?? 0,
+ startedAt: prev?.startedAt ?? at,
+ updatedAt: at,
+ durationSeconds: num(payload.duration_seconds) ?? prev?.durationSeconds,
+ costUsd: num(payload.cost_usd) ?? prev?.costUsd,
+ inputTokens: num(payload.input_tokens) ?? prev?.inputTokens,
+ outputTokens: num(payload.output_tokens) ?? prev?.outputTokens,
+ toolCount: num(payload.tool_count) ?? prev?.toolCount,
+ filesRead: filesRead.length ? filesRead : (prev?.filesRead ?? []),
+ filesWritten: filesWritten.length ? filesWritten : (prev?.filesWritten ?? []),
+ stream,
+ summary: str(payload.summary) || prev?.summary,
+ currentTool: TERMINAL.has(status) ? undefined : tool || prev?.currentTool
+ }
+}
+
+export function clearSessionSubagents(sid: string) {
+ const map = $subagentsBySession.get()
+ if (!(sid in map)) return
+
+ const { [sid]: _drop, ...rest } = map
+ $subagentsBySession.set(rest)
+}
+
+export function pruneDelegateFallbackSubagents(sid: string) {
+ const map = $subagentsBySession.get()
+ const list = map[sid]
+ if (!list?.length) return
+
+ const next = list.filter(item => !item.id.startsWith('delegate-tool:'))
+ if (next.length === list.length) return
+
+ $subagentsBySession.set({ ...map, [sid]: next })
+}
+
+export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMissing = true, eventType?: string) {
+ const map = $subagentsBySession.get()
+ const list = map[sid] ?? []
+ const id = idOf(payload)
+ const idx = list.findIndex(item => item.id === id)
+ if (idx < 0 && !createIfMissing) return
+
+ const prev = idx >= 0 ? list[idx] : undefined
+ if (prev && TERMINAL.has(prev.status)) return
+
+ const next = toProgress(payload, prev, eventType)
+ const nextList = idx >= 0 ? list.map(item => (item.id === id ? next : item)) : [...list, next]
+
+ $subagentsBySession.set({ ...map, [sid]: nextList })
+}
+
+export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
+ const nodes = new Map()
+ 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.startedAt - b.startedAt || 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