mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 09:11:54 +00:00
refactor(desktop): subagent rows borrow chat tool patterns (fade-in, lucide glyphs, shimmer)
Pull the agents view closer to how chat tool blocks render: - statusGlyph() returns the same lucide BrailleSpinner / CheckCircle2 / AlertCircle vocabulary as tool-fallback's statusGlyph - Stream lines fade-in via useEnterAnimation (one-shot WAAPI), keyed per entry so streamed deltas settle in instead of popping - Subagent rows fade in too, and pick up the existing data-slot=tool-block spacing rules between blocks - Active stream line trails a BrailleSpinner instead of a hand-rolled pulsing rectangle - Goal text drops FadeText (which forces nowrap); keep FadeText only for the single-line meta subtitle - Running rows shimmer the title — same affordance the chat thinking row uses
This commit is contained in:
parent
4afbdf58b3
commit
b96bee7f5c
1 changed files with 86 additions and 44 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
|
|
@ -8,12 +8,14 @@ import { FadeText } from '@/components/ui/fade-text'
|
|||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Layers3,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
RefreshCw,
|
||||
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'
|
||||
|
|
@ -59,19 +61,29 @@ const RAIL_ICON: Record<RailTaskStatus, LucideIcon> = {
|
|||
success: Sparkles
|
||||
}
|
||||
|
||||
const STATUS_GLYPH: Record<SubagentStatus, string> = {
|
||||
completed: '✓',
|
||||
failed: '✗',
|
||||
interrupted: '■',
|
||||
queued: '○',
|
||||
running: '●'
|
||||
}
|
||||
// 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const STREAM_GLYPH: Record<SubagentStreamEntry['kind'], string> = {
|
||||
progress: '·',
|
||||
summary: '✓',
|
||||
thinking: '💭',
|
||||
tool: '●'
|
||||
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> = {
|
||||
|
|
@ -81,6 +93,26 @@ const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
|
|||
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 {
|
||||
initialSection?: AgentsSection
|
||||
onClose: () => void
|
||||
|
|
@ -287,17 +319,35 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number })
|
|||
)
|
||||
}
|
||||
|
||||
function StreamLine({ active, entry }: { active: boolean; entry: SubagentStreamEntry }) {
|
||||
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">
|
||||
<span className={cn('shrink-0 text-[0.65rem]', tone)}>{STREAM_GLYPH[entry.kind]}</span>
|
||||
<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 ? (
|
||||
<span className="ml-1 inline-block h-2.5 w-0.5 animate-pulse rounded-sm bg-primary/75 align-middle" />
|
||||
<BrailleSpinner
|
||||
ariaLabel="Streaming"
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -310,6 +360,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
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)
|
||||
|
|
@ -327,61 +378,52 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')}>
|
||||
<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={cn(
|
||||
'mt-1 flex size-4 shrink-0 items-center justify-center text-[0.82rem] leading-none',
|
||||
running
|
||||
? 'text-primary'
|
||||
: node.status === 'failed' || node.status === 'interrupted'
|
||||
? 'text-destructive'
|
||||
: node.status === 'completed'
|
||||
? 'text-emerald-500'
|
||||
: 'text-muted-foreground/70'
|
||||
)}
|
||||
>
|
||||
{running ? (
|
||||
<BrailleSpinner ariaLabel="Running" className="text-[0.82rem]" spinner="breathe" />
|
||||
) : (
|
||||
STATUS_GLYPH[node.status]
|
||||
)}
|
||||
</span>
|
||||
<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">
|
||||
<FadeText
|
||||
<span
|
||||
className={cn(
|
||||
'wrap-anywhere text-[0.84rem] font-medium leading-snug text-foreground/95 transition-colors group-hover:text-foreground',
|
||||
running && 'text-foreground/85'
|
||||
'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}
|
||||
</FadeText>
|
||||
</span>
|
||||
{subtitle.length > 0 ? (
|
||||
<FadeText className="text-[0.66rem] leading-snug text-muted-foreground/65">{subtitle.join(' · ')}</FadeText>
|
||||
<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-7">
|
||||
<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-7">
|
||||
<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}>
|
||||
|
|
@ -397,7 +439,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
) : null}
|
||||
|
||||
{node.children.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3 pl-7">
|
||||
<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} />
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue