Skip to content
196 changes: 196 additions & 0 deletions src/renderer/components/AgentPeek.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import type { AgentSummary } from '../../shared/state/json-claude-agents'
import {
agentElapsedMs,
formatElapsed
} from '../../shared/state/json-claude-agents'
import { AgentStepLine } from './AgentStepLine'
import { AgentStatusDot, agentStatusLabel } from './AgentStatusDot'

/** How much of a text block to show inline in the feed. The peek is a
* "what is it doing" view, not a transcript — the full text is in the
* chat's own Task card. */
const TEXT_PREVIEW = 600

interface AgentPeekProps {
agent: AgentSummary
/** Live clock, passed down so every peek and panel row ticks off the
* same interval rather than each mounting its own. */
now: number
onClose: () => void
/** Opens a nested agent's peek, replacing this one. */
onPeekAgent: (toolUseId: string) => void
}

/** A flyout over the chat showing one agent's activity as it happens.
* Deliberately not the chat's own card renderer: that machinery needs a
* full render ctx (approval cards, find-highlighting, image fetching)
* and produces the collapsed-group chrome that makes scanning a running
* agent hard. Here every step gets exactly one line, newest at the
* bottom, and the view follows along. */
export function AgentPeek({
agent,
now,
onClose,
onPeekAgent
}: AgentPeekProps): JSX.Element {
const feedRef = useRef<HTMLDivElement>(null)
const pinnedToBottomRef = useRef(true)
const stepCount = agent.steps.length

useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])

// Follow the tail only while the user hasn't scrolled away — an agent
// mid-run appends constantly, and yanking the viewport back down while
// someone is reading an earlier step is worse than not following.
useLayoutEffect(() => {
const el = feedRef.current
if (!el || !pinnedToBottomRef.current) return
el.scrollTop = el.scrollHeight
}, [stepCount, agent.result])

const elapsed = formatElapsed(agentElapsedMs(agent, now))
const usage = agent.usage
const meta = [
agent.subagentType,
agent.model,
`${agent.toolCount} ${agent.toolCount === 1 ? 'tool' : 'tools'}`,
usage?.totalTokens !== undefined
? `${Math.round(usage.totalTokens / 1000)}k tokens`
: null,
elapsed
].filter(Boolean) as string[]

return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={onClose}
>
<div
className="flex flex-col bg-panel border border-border shadow-2xl overflow-hidden"
style={{
width: 'min(44rem, 88vw)',
height: 'min(36rem, 82vh)',
borderRadius: 'var(--chat-bubble-radius)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 shrink-0">
<Sparkles className="icon-sm text-muted shrink-0" />
<span className="text-sm text-fg-bright truncate flex-1 min-w-0">
{agent.description}
</span>
{agent.isBackground && (
<span className="uppercase tracking-wide text-muted bg-app/60 border border-border/50 rounded px-1 py-0.5 shrink-0 text-xs">
background
</span>
)}
<AgentStatusDot agent={agent} />
<span className="text-xs text-muted shrink-0">
{agentStatusLabel(agent)}
</span>
<button
type="button"
onClick={onClose}
aria-label="Close agent peek"
className="ml-1 p-1 text-muted hover:text-fg-bright cursor-pointer shrink-0"
>
<X className="icon-sm" />
</button>
</div>

<div className="px-3 py-1.5 border-b border-border/40 shrink-0 flex items-center gap-2 flex-wrap">
{meta.map((m) => (
<span key={m} className="text-xs text-faint tabular-nums">
{m}
</span>
))}
{agent.parentDescription && (
<span className="text-xs text-faint truncate">
via {agent.parentDescription}
</span>
)}
</div>

<div
ref={feedRef}
onScroll={(e) => {
const el = e.currentTarget
pinnedToBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 32
}}
className="flex-1 overflow-y-auto px-3 py-2 text-xs space-y-1"
>
{agent.steps.length === 0 && (
<div className="text-muted italic py-2">
{agent.status === 'running'
? 'agent is starting…'
: 'no activity recorded'}
</div>
)}
{agent.steps.map((step) =>
step.kind === 'text' ? (
<div
key={step.key}
className="py-1 pl-4 border-l-2 border-border/50 markdown"
>
<ReactMarkdown>
{step.text && step.text.length > TEXT_PREVIEW
? `${step.text.slice(0, TEXT_PREVIEW)}…`
: (step.text ?? '')}
</ReactMarkdown>
</div>
) : step.childAgentToolUseId ? (
<button
key={step.key}
type="button"
onClick={() => onPeekAgent(step.childAgentToolUseId!)}
className="w-full text-left hover:bg-app/60 rounded px-1 py-0.5 cursor-pointer"
>
<AgentStepLine step={step} />
</button>
) : (
<div key={step.key} className="px-1 py-0.5">
<AgentStepLine step={step} />
</div>
)
)}
{agent.status === 'running' && (
<div className="flex items-center gap-2 px-1 py-1">
<span className="json-claude-spinner shrink-0" />
<span className="text-muted italic">working…</span>
</div>
)}
</div>

{agent.result && (
<div className="shrink-0 border-t border-border/60 max-h-48 overflow-y-auto">
<div className="px-3 pt-2 text-xs text-faint uppercase tracking-wide">
{agent.resultIsError ? 'error' : 'report'}
</div>
<div
className={`px-3 pb-2 text-xs markdown ${
agent.resultIsError ? 'text-danger' : ''
}`}
>
<ReactMarkdown>{agent.result}</ReactMarkdown>
</div>
</div>
)}
</div>
</div>,
document.body
)
}
61 changes: 61 additions & 0 deletions src/renderer/components/AgentStatusDot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { AgentSummary } from '../../shared/state/json-claude-agents'

export function agentStatusLabel(agent: AgentSummary): string {
if (agent.hasPendingApproval) return 'needs approval'
switch (agent.status) {
case 'running':
return 'running'
case 'completed':
return 'done'
case 'failed':
return 'failed'
case 'interrupted':
return 'interrupted'
}
}

/** Status glyph for an agent. A spinner while running so a fleet of them
* reads as alive at a glance; a static mark once settled. */
export function AgentStatusDot({
agent
}: {
agent: AgentSummary
}): JSX.Element {
if (agent.hasPendingApproval) {
return (
<span
className="text-warning shrink-0"
title="needs approval"
aria-label="needs approval"
>
⚠
</span>
)
}
if (agent.status === 'running') {
return (
<span
className="json-claude-spinner shrink-0"
title="running"
aria-label="running"
/>
)
}
const cls =
agent.status === 'completed'
? 'text-success'
: agent.status === 'failed'
? 'text-danger'
: 'text-faint'
const glyph =
agent.status === 'completed' ? '✓' : agent.status === 'failed' ? '✕' : '⊘'
return (
<span
className={`${cls} shrink-0`}
title={agentStatusLabel(agent)}
aria-label={agentStatusLabel(agent)}
>
{glyph}
</span>
)
}
84 changes: 84 additions & 0 deletions src/renderer/components/AgentStepLine.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Brain, MessageSquare, Sparkles, Wrench } from 'lucide-react'
import type { AgentStep } from '../../shared/state/json-claude-agents'
import { isSubAgentToolName } from '../../shared/state/json-claude-agents'
import {
extractArgs,
getToolDisplay
} from './json-mode-cards/tool-display'

/** Argument keys that identify *what* a call acted on, in preference
* order. `extractArgs` preserves the input's own key order, which is
* usually right but not always — a Bash call leads with `command` on
* one version and `description` on another. Naming the interesting keys
* keeps the one line we have pointed at the subject. */
const SUBJECT_KEYS = [
'file_path',
'path',
'command',
'pattern',
'query',
'url',
'prompt',
'description',
'subject'
]

/** The one-line "what it acted on" for a tool step. */
export function summarizeStepArgs(step: AgentStep): string {
if (step.kind !== 'tool') return ''
const args = extractArgs(step.input)
if (args.length === 0) return ''
const preferred =
SUBJECT_KEYS.map((k) => args.find((a) => a.key === k)).find(Boolean) ??
args[0]
return preferred.value.replace(/\s+/g, ' ').trim()
}

/** One line of an agent's activity feed. Shared by the Agents panel's
* "currently" line and the peek view's step list, so a step reads the
* same in both places. */
export function AgentStepLine({
step,
dim = false
}: {
step: AgentStep
dim?: boolean
}): JSX.Element {
if (step.kind === 'thinking') {
return (
<span className="flex items-center gap-1.5 min-w-0">
<Brain className="icon-2xs text-muted shrink-0" />
<span className="text-muted italic truncate">thinking</span>
</span>
)
}
if (step.kind === 'text') {
return (
<span className="flex items-center gap-1.5 min-w-0">
<MessageSquare className="icon-2xs text-muted shrink-0" />
<span className={`truncate ${dim ? 'text-muted' : 'text-fg'}`}>
{step.text}
</span>
</span>
)
}
const nested = isSubAgentToolName(step.toolName)
const display = getToolDisplay(step.toolName)
const Icon = nested ? Sparkles : (display.icon ?? Wrench)
const detail = summarizeStepArgs(step)
return (
<span className="flex items-center gap-1.5 min-w-0">
<Icon
className={`icon-2xs shrink-0 ${
step.isError ? 'text-danger' : 'text-muted'
}`}
/>
<span className="text-muted shrink-0">{display.compactLabel}</span>
{detail && (
<span className={`truncate ${dim ? 'text-muted/70' : 'opacity-70'}`}>
{detail}
</span>
)}
</span>
)
}
Loading
Loading