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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user