feat(desktop): StreamingIndicator surfaces active tool + target

Replace the random spinner verb with a friendly description of the
tool currently running — "Reading foo.ts", "Editing bar.tsx",
"Running npm", "Searching <pattern>". Falls back to the verb / chatState
label when no tool is active.

Why: users were seeing "Percolating... 51s" with no idea whether the
agent was stuck or genuinely working. The chatStore already tracks
activeToolName + streamingToolInput, the indicator just wasn't using
either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:08:47 +08:00
co-authored by Claude Opus 4.7
parent 2052b5b140
commit c17be9ef3b
@@ -8,6 +8,78 @@ function formatElapsed(seconds: number): string {
return `${m}m ${s}s`
}
function basename(p: string): string {
const parts = p.split(/[/\\]/)
return parts[parts.length - 1] || p
}
function truncate(s: string, n: number): string {
return s.length > n ? `${s.slice(0, n - 1)}…` : s
}
/**
* Build a short, user-facing description of the tool that is currently
* executing. Tries to parse the partially-streamed JSON input so the
* user sees e.g. "Reading foo.ts" instead of "Read".
*/
function describeActiveTool(toolName: string, rawInput: string): string {
let parsed: Record<string, unknown> | null = null
if (rawInput) {
try {
parsed = JSON.parse(rawInput) as Record<string, unknown>
} catch {
// Partial JSON during streaming — try regex extraction of common keys.
const grab = (key: string): string | null => {
const m = rawInput.match(new RegExp(`"${key}"\\s*:\\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)`))
return m && m[1] ? m[1] : null
}
parsed = {
file_path: grab('file_path'),
command: grab('command'),
pattern: grab('pattern'),
path: grab('path'),
url: grab('url'),
description: grab('description'),
}
}
}
const filePath = (parsed?.file_path as string | undefined) ?? null
const command = (parsed?.command as string | undefined) ?? null
const pattern = (parsed?.pattern as string | undefined) ?? null
const path = (parsed?.path as string | undefined) ?? null
const url = (parsed?.url as string | undefined) ?? null
const description = (parsed?.description as string | undefined) ?? null
switch (toolName) {
case 'Read':
return filePath ? `Reading ${basename(filePath)}` : 'Reading file'
case 'Edit':
case 'Write':
return filePath ? `Editing ${basename(filePath)}` : 'Editing file'
case 'Bash':
case 'PowerShell':
if (description) return truncate(description, 48)
return command ? `Running ${truncate(command.split(/\s+/)[0] || command, 32)}` : 'Running command'
case 'Grep':
return pattern ? `Searching ${truncate(pattern, 40)}` : 'Searching code'
case 'Glob':
return pattern ? `Finding ${truncate(pattern, 40)}` : 'Finding files'
case 'WebFetch':
case 'WebSearch':
return url ? `Fetching ${truncate(url, 40)}` : toolName === 'WebSearch' ? 'Searching web' : 'Fetching URL'
case 'TodoWrite':
return 'Updating plan'
case 'Agent':
return description ? `Delegating: ${truncate(description, 36)}` : 'Delegating to subagent'
case 'Task':
return description ? `Subtask: ${truncate(description, 40)}` : 'Running subtask'
default:
if (filePath) return `${toolName} ${basename(filePath)}`
if (path) return `${toolName} ${basename(path)}`
return `Running ${toolName}`
}
}
export function StreamingIndicator() {
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
@@ -15,17 +87,22 @@ export function StreamingIndicator() {
const statusVerb = sessionState?.statusVerb ?? ''
const elapsedSeconds = sessionState?.elapsedSeconds ?? 0
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
let verb: string
if (statusVerb) {
verb = statusVerb
const activeToolName = sessionState?.activeToolName ?? null
const streamingToolInput = sessionState?.streamingToolInput ?? ''
let label: string
if (activeToolName) {
label = describeActiveTool(activeToolName, streamingToolInput)
} else if (statusVerb) {
label = statusVerb
} else {
verb = chatState === 'thinking' ? 'Thinking' : chatState === 'tool_executing' ? 'Running' : 'Working'
label = chatState === 'thinking' ? 'Thinking' : chatState === 'tool_executing' ? 'Running' : 'Working'
}
return (
<div className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">
<span className="text-[var(--color-brand)] animate-shimmer text-xs">✦</span>
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{verb}...</span>
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{label}...</span>
{elapsedSeconds > 0 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{formatElapsed(elapsedSeconds)}