Files
hermes-agent/apps/desktop/src/app/agents/index.tsx
T
2026-05-16 21:26:50 -05:00

393 lines
13 KiB
TypeScript

import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useMemo, useState } from 'react'
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 { $activeSessionId } from '@/store/session'
import {
$subagentsBySession,
buildSubagentTree,
type SubagentNode,
type SubagentStatus,
type SubagentStreamEntry
} from '@/store/subagents'
import { OverlayView } from '../overlays/overlay-view'
// 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"
/>
)
}
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 STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
progress: 'text-muted-foreground/75',
summary: 'text-foreground/85',
thinking: 'text-muted-foreground/80',
tool: 'text-foreground/85'
}
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 {
onClose: () => void
}
export function AgentsView({ onClose }: AgentsViewProps) {
const activeSessionId = useStore($activeSessionId)
const subagentsBySession = useStore($subagentsBySession)
const activeSubagents = useMemo(
() => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []),
[activeSessionId, subagentsBySession]
)
const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents])
return (
<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>
)
}
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 (
<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>
)
}
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 (
<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 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 (
<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>
</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>
)
}