Merge origin/bb/gui into austin/bb/gui
Resolve the Command Center import conflict by keeping the Usage panel icon and dropping the unused haptics import from the base branch. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,143 +1,377 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo } from 'react'
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
|
||||
import { $previewServerRestart } from '@/store/preview'
|
||||
import { $sessions, $workingSessionIds } from '@/store/session'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import {
|
||||
$subagentsBySession,
|
||||
buildSubagentTree,
|
||||
type SubagentNode,
|
||||
type SubagentStatus,
|
||||
type SubagentStreamEntry
|
||||
} from '@/store/subagents'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { OverlayCard } from '../overlays/overlay-chrome'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
type AgentsSection = 'tree' | 'activity' | 'history'
|
||||
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
|
||||
// same visual vocabulary as the chat tool blocks.
|
||||
function statusGlyph(status: SubagentStatus): ReactNode {
|
||||
if (status === 'running' || status === 'queued') {
|
||||
return (
|
||||
<BrailleSpinner
|
||||
ariaLabel="Running"
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface SectionDef {
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
id: AgentsSection
|
||||
label: string
|
||||
if (status === 'failed' || status === 'interrupted') {
|
||||
return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" />
|
||||
}
|
||||
|
||||
return (
|
||||
<CheckCircle2
|
||||
aria-label="Done"
|
||||
className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SECTIONS: readonly SectionDef[] = [
|
||||
{ description: 'Live subagent spawn tree for the current turn', icon: Layers3, id: 'tree', label: 'Spawn tree' },
|
||||
{ description: 'Background work across sessions and the desktop', icon: Activity, id: 'activity', label: 'Activity' },
|
||||
{ description: 'Past spawn snapshots, replay, and diff', icon: RefreshCw, id: 'history', label: 'History' }
|
||||
]
|
||||
|
||||
const SECTION_IDS = SECTIONS.map(s => s.id) as readonly AgentsSection[]
|
||||
|
||||
const STATUS_TONE: Record<RailTaskStatus, string> = {
|
||||
error: 'text-destructive',
|
||||
running: 'text-foreground',
|
||||
success: 'text-emerald-500'
|
||||
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
|
||||
progress: 'text-muted-foreground/75',
|
||||
summary: 'text-foreground/85',
|
||||
thinking: 'text-muted-foreground/80',
|
||||
tool: 'text-foreground/85'
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<RailTaskStatus, LucideIcon> = {
|
||||
error: AlertCircle,
|
||||
running: Loader2,
|
||||
success: Sparkles
|
||||
function streamGlyph(entry: SubagentStreamEntry): ReactNode {
|
||||
if (entry.isError) {
|
||||
return <AlertCircle aria-hidden className="mt-0.5 size-3 shrink-0 text-destructive" />
|
||||
}
|
||||
|
||||
if (entry.kind === 'tool') {
|
||||
return <span aria-hidden className="mt-0.5 size-1.5 shrink-0 rounded-full bg-foreground/55" />
|
||||
}
|
||||
|
||||
if (entry.kind === 'summary') {
|
||||
return <CheckCircle2 aria-hidden className="mt-0.5 size-3 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
}
|
||||
|
||||
if (entry.kind === 'thinking') {
|
||||
return <span aria-hidden className="font-mono text-[0.7rem] leading-none text-muted-foreground/70">…</span>
|
||||
}
|
||||
|
||||
return <span aria-hidden className="mt-0.5 size-1 shrink-0 rounded-full bg-muted-foreground/55" />
|
||||
}
|
||||
|
||||
interface AgentsViewProps {
|
||||
initialSection?: AgentsSection
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) {
|
||||
const [section, setSection] = useRouteEnumParam('section', SECTION_IDS, initialSection)
|
||||
export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
|
||||
const sessions = useStore($sessions)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const previewRestart = useStore($previewServerRestart)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
|
||||
const activityTasks = useMemo(
|
||||
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
|
||||
[desktopActionTasks, previewRestart, sessions, workingSessionIds]
|
||||
const activeSubagents = useMemo(
|
||||
() => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []),
|
||||
[activeSessionId, subagentsBySession]
|
||||
)
|
||||
|
||||
const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]!
|
||||
const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents])
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close agents" onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => (
|
||||
<OverlayNavItem
|
||||
active={s.id === section}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
onClick={() => setSection(s.id)}
|
||||
/>
|
||||
))}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain>
|
||||
<header className="mb-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">{active.label}</h2>
|
||||
<p className="text-xs text-muted-foreground">{active.description}</p>
|
||||
</header>
|
||||
|
||||
{section === 'activity' ? <ActivityList tasks={activityTasks} /> : <SectionStub label={active.label} />}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
<OverlayView
|
||||
closeLabel="Close agents"
|
||||
contentClassName="px-5 pt-5 pb-4 sm:px-6"
|
||||
onClose={onClose}
|
||||
rootClassName="mx-auto max-w-3xl"
|
||||
>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">Spawn tree</h2>
|
||||
<p className="text-xs text-muted-foreground/80">Live subagent activity for the current turn.</p>
|
||||
</header>
|
||||
<SubagentTree tree={tree} />
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
|
||||
if (tasks.length === 0) {
|
||||
const fmtDuration = (seconds?: number) => {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`
|
||||
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
|
||||
const fmtTokens = (value?: number) => {
|
||||
if (!value) return ''
|
||||
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok`
|
||||
}
|
||||
|
||||
const fmtAge = (updatedAt: number, nowMs: number) => {
|
||||
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
|
||||
if (s < 2) return 'now'
|
||||
if (s < 60) return `${s}s ago`
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
|
||||
return `${Math.floor(m / 60)}h ago`
|
||||
}
|
||||
|
||||
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
||||
nodes.flatMap(node => [node, ...flatten(node.children)])
|
||||
|
||||
interface RootGroup {
|
||||
id: string
|
||||
label: string
|
||||
nodes: SubagentNode[]
|
||||
taskCount: number
|
||||
}
|
||||
|
||||
function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
|
||||
const groups: RootGroup[] = []
|
||||
let n = 0
|
||||
|
||||
for (const node of roots) {
|
||||
const prev = groups.at(-1)
|
||||
const prevTail = prev?.nodes.at(-1)
|
||||
const closeInTime = prevTail ? Math.abs(node.startedAt - prevTail.startedAt) <= 5_000 : false
|
||||
const sameShape = prev && node.taskCount > 1 && prev.taskCount === node.taskCount
|
||||
const uniqueStep = prev ? !prev.nodes.some(item => item.taskIndex === node.taskIndex) : false
|
||||
|
||||
if (prev && sameShape && closeInTime && uniqueStep) {
|
||||
prev.nodes.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.taskCount > 1) {
|
||||
n += 1
|
||||
groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount })
|
||||
continue
|
||||
}
|
||||
|
||||
groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
const flat = useMemo(() => flatten(tree), [tree])
|
||||
const groups = useMemo(() => groupDelegations(tree), [tree])
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
|
||||
const active = flat.filter(n => n.status === 'running' || n.status === 'queued').length
|
||||
const failed = flat.filter(n => n.status === 'failed' || n.status === 'interrupted').length
|
||||
const tools = flat.reduce((sum, n) => sum + (n.toolCount ?? 0), 0)
|
||||
const files = flat.reduce((sum, n) => sum + n.filesRead.length + n.filesWritten.length, 0)
|
||||
const tokens = flat.reduce((sum, n) => sum + (n.inputTokens ?? 0) + (n.outputTokens ?? 0), 0)
|
||||
const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (active <= 0 || typeof window === 'undefined') return
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 500)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [active])
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No background activity. Long-running tools, preview restarts, and parallel sessions surface here.
|
||||
</OverlayCard>
|
||||
<div className="grid place-items-center gap-3 py-12 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/60" />
|
||||
<p className="text-sm font-medium text-foreground/90">No live subagents</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">
|
||||
When a turn delegates work, child agents stream their progress here.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 gap-1.5 overflow-y-auto pr-1">
|
||||
{tasks.map(task => {
|
||||
const Icon = STATUS_ICON[task.status]
|
||||
const summary = [
|
||||
`${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`,
|
||||
active > 0 ? `${active} active` : '',
|
||||
failed > 0 ? `${failed} failed` : '',
|
||||
tools > 0 ? `${tools} tools` : '',
|
||||
files > 0 ? `${files} files` : '',
|
||||
tokens > 0 ? fmtTokens(tokens) : '',
|
||||
cost > 0 ? `$${cost.toFixed(2)}` : ''
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<OverlayCard className="flex items-start gap-2.5 px-3 py-2" key={task.id}>
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 size-3.5 shrink-0',
|
||||
STATUS_TONE[task.status],
|
||||
task.status === 'running' && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{task.label}</div>
|
||||
{task.detail && <div className="truncate text-xs text-muted-foreground">{task.detail}</div>}
|
||||
</div>
|
||||
</OverlayCard>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden">
|
||||
<p className="shrink-0 text-[0.7rem] text-muted-foreground/70">{summary.join(' · ')}</p>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{groups.map(group => (
|
||||
<DelegationGroup group={group} key={group.id} nowMs={nowMs} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionStub({ label }: { label: string }) {
|
||||
function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) {
|
||||
if (group.nodes.length === 1 && group.taskCount <= 1) {
|
||||
return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
|
||||
}
|
||||
|
||||
const activeWorkers = group.nodes.filter(n => n.status === 'running' || n.status === 'queued').length
|
||||
|
||||
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">{label} — coming soon</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
|
||||
Subagent stores aren't wired into the desktop yet. Once gateway events for{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">
|
||||
subagent.spawn / progress / complete
|
||||
</code>{' '}
|
||||
land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the
|
||||
TUI's <code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
|
||||
</p>
|
||||
<section className="grid min-w-0 gap-3">
|
||||
<p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
{group.label} <span className="text-muted-foreground/50">·</span> {group.nodes.length} workers
|
||||
{activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null}
|
||||
</p>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
{group.nodes.map(node => (
|
||||
<SubagentRow key={node.id} node={node} nowMs={nowMs} />
|
||||
))}
|
||||
</div>
|
||||
</OverlayCard>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed"
|
||||
ref={enterRef}
|
||||
>
|
||||
<span className="flex h-[0.95rem] shrink-0 items-center">{streamGlyph(entry)}</span>
|
||||
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
|
||||
{entry.text}
|
||||
{active ? (
|
||||
<BrailleSpinner
|
||||
ariaLabel="Streaming"
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')}
|
||||
data-slot="tool-block"
|
||||
ref={enterRef}
|
||||
>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="group flex w-full min-w-0 items-start gap-2.5 text-left"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status)}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
'wrap-anywhere text-[0.82rem] font-medium leading-[1.1rem] text-foreground/90 transition-colors group-hover:text-foreground',
|
||||
running && 'shimmer text-foreground/65'
|
||||
)}
|
||||
>
|
||||
{node.goal}
|
||||
</span>
|
||||
{subtitle.length > 0 ? (
|
||||
<FadeText className="text-[0.66rem] leading-[1.05rem] text-muted-foreground/65">
|
||||
{subtitle.join(' · ')}
|
||||
</FadeText>
|
||||
) : null}
|
||||
</span>
|
||||
{running ? <ActivityTimerText className="mt-1 shrink-0 text-[0.6rem]" seconds={durationSeconds} /> : null}
|
||||
</button>
|
||||
|
||||
{visibleRows.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-1 pl-6">
|
||||
{visibleRows.map((entry, i) => (
|
||||
<StreamLine
|
||||
active={running && i === visibleRows.length - 1}
|
||||
entry={entry}
|
||||
key={`${entry.kind}:${entry.at}:${i}`}
|
||||
parentRunning={running}
|
||||
rowKey={`${node.id}:${entry.kind}:${entry.at}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-0.5 pl-6">
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">Files</p>
|
||||
{fileLines.slice(0, 8).map(line => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{fileLines.length > 8 ? (
|
||||
<p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65">
|
||||
+{fileLines.length - 8} more files
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.children.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3 pl-6">
|
||||
{node.children.map(child => (
|
||||
<SubagentRow depth={depth + 1} key={child.id} node={child} nowMs={nowMs} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -426,6 +426,7 @@ export function DesktopController() {
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<SettingsView
|
||||
gateway={gatewayRef.current}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onConfigSaved={() => {
|
||||
void refreshHermesConfig()
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[2px] sm:p-8"
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
setCurrentUsage,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
@@ -60,17 +61,98 @@ 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.
|
||||
function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined {
|
||||
if (!payload) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos'))
|
||||
|
||||
return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function parseMaybeRecord(value: unknown): Record<string, unknown> {
|
||||
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<string, unknown>[] {
|
||||
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<Map<string, QueuedStreamDeltas>>(new Map())
|
||||
const flushHandleRef = useRef<number | null>(null)
|
||||
const nativeSubagentSessionsRef = useRef<Set<string>>(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<string, unknown>,
|
||||
event.type === 'subagent.spawn_requested' || event.type === 'subagent.start',
|
||||
event.type
|
||||
)
|
||||
}
|
||||
} 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: ''
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<OverlayView
|
||||
@@ -147,6 +143,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 +198,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'
|
||||
|
||||
@@ -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 ? (
|
||||
<AlertCircle className="size-3" />
|
||||
) : bgRunning > 0 ? (
|
||||
) : bgRunning > 0 || subagentsRunning > 0 ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-3" />
|
||||
@@ -223,6 +237,7 @@ export function useStatusbarItems({
|
||||
gatewayUp,
|
||||
openAgents,
|
||||
statusSnapshot?.gateway_state,
|
||||
subagentsRunning,
|
||||
toggleCommandCenter
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
|
||||
const TERMINAL: ReadonlySet<SubagentStatus> = new Set(['completed', 'failed', 'interrupted'])
|
||||
const MAX_STREAM = 24
|
||||
const PREVIEW_MAX = 220
|
||||
const TOOL_PREVIEW_MAX = 96
|
||||
|
||||
export const $subagentsBySession = atom<Record<string, SubagentProgress[]>>({})
|
||||
|
||||
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<string, unknown> => !!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<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.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
|
||||
Reference in New Issue
Block a user