fix(desktop): limit pending tool shimmer to action verb

Localize tool titles and split pending rows so only the action segment
shimmers — paths, commands, and URLs stay static.
This commit is contained in:
Brooklyn Nicholson 2026-06-24 21:59:41 -05:00
parent cbe5c5689f
commit f2c45e2c81
8 changed files with 548 additions and 90 deletions

View file

@ -1,6 +1,6 @@
import { type ToolTitleKey, translateNow } from '@/i18n'
import { normalizeExternalUrl } from '@/lib/external-link'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
import { translateNow } from '@/i18n'
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
@ -20,6 +20,12 @@ export interface SearchResultRow {
url: string
}
export interface ToolTitleAction {
prefix: string
suffix: string
text: string
}
interface CountMetric {
count: number
noun: string
@ -51,6 +57,7 @@ export interface ToolView {
status: ToolStatus
subtitle: string
title: string
titleAction?: ToolTitleAction
tone: ToolTone
}
@ -58,6 +65,12 @@ interface ToolMeta {
done: string
icon?: string
pending: string
pendingAction: string
tone: ToolTone
}
interface ToolMetaSpec {
icon?: string
tone: ToolTone
}
@ -112,44 +125,78 @@ function fileEditBasename(path: string): string {
return normalized.split('/').filter(Boolean).pop() || normalized
}
const TOOL_META: Record<string, ToolMeta> = {
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' },
browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' },
browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: 'globe', tone: 'browser' },
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
browser_click: {
icon: 'globe',
tone: 'browser'
},
browser_fill: {
icon: 'globe',
tone: 'browser'
},
browser_navigate: {
icon: 'globe',
tone: 'browser'
},
browser_snapshot: {
done: 'Captured page snapshot',
pending: 'Capturing page snapshot',
icon: 'globe',
tone: 'browser'
},
browser_take_screenshot: {
done: 'Captured screenshot',
pending: 'Capturing screenshot',
icon: 'file-media',
tone: 'browser'
},
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' },
cronjob: { done: 'Cron job', pending: 'Scheduling cron job', icon: 'watch', tone: 'agent' },
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' },
patch: { done: 'Patched file', pending: 'Patching file', icon: 'edit', tone: 'file' },
read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' },
search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
browser_type: {
icon: 'globe',
tone: 'browser'
},
clarify: {
icon: 'question',
tone: 'agent'
},
cronjob: {
icon: 'watch',
tone: 'agent'
},
edit_file: { icon: 'edit', tone: 'file' },
execute_code: {
icon: 'terminal',
tone: 'terminal'
},
image_generate: {
icon: 'file-media',
tone: 'image'
},
list_files: {
icon: 'files',
tone: 'file'
},
patch: { icon: 'edit', tone: 'file' },
read_file: { icon: 'file', tone: 'file' },
search_files: {
icon: 'search',
tone: 'file'
},
session_search_recall: {
done: 'Searched session history',
pending: 'Searching session history',
icon: 'search',
tone: 'agent'
},
terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' },
todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' },
vision_analyze: { done: 'Analyzed image', pending: 'Analyzing image', icon: 'eye', tone: 'image' },
web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' },
web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' },
write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }
terminal: {
icon: 'terminal',
tone: 'terminal'
},
todo: { icon: 'tools', tone: 'agent' },
vision_analyze: {
icon: 'eye',
tone: 'image'
},
web_extract: { icon: 'globe', tone: 'web' },
web_search: { icon: 'search', tone: 'web' },
write_file: { icon: 'edit', tone: 'file' }
}
function isToolTitleKey(name: string): name is ToolTitleKey {
return name in TOOL_META
}
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
@ -171,27 +218,45 @@ function titleForTool(name: string): string {
)
}
const PREFIX_META: { icon?: string; prefix: string; tone: ToolTone; verb: string }[] = [
{ prefix: 'browser_', verb: 'Browser', icon: 'globe', tone: 'browser' },
{ prefix: 'web_', verb: 'Web', icon: 'globe', tone: 'web' }
const PREFIX_META: { icon?: string; labelKey: string; prefix: string; tone: ToolTone }[] = [
{ prefix: 'browser_', labelKey: 'browser', icon: 'globe', tone: 'browser' },
{ prefix: 'web_', labelKey: 'web', icon: 'globe', tone: 'web' }
]
function toolMeta(name: string): ToolMeta {
if (TOOL_META[name]) {
return TOOL_META[name]
if (isToolTitleKey(name)) {
const meta = TOOL_META[name]
return {
done: translateNow(`assistant.tool.titles.${name}.done`),
pending: translateNow(`assistant.tool.titles.${name}.pending`),
pendingAction: translateNow(`assistant.tool.titles.${name}.pendingAction`),
icon: meta.icon,
tone: meta.tone
}
}
const action = titleForTool(name)
const prefix = PREFIX_META.find(p => name.startsWith(p.prefix))
return prefix
? {
done: `${prefix.verb} ${action}`,
pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`,
icon: prefix.icon,
tone: prefix.tone
}
: { done: action, pending: `Running ${action.toLowerCase()}`, tone: 'default' }
if (prefix) {
const prefixLabel = translateNow(`assistant.tool.prefixes.${prefix.labelKey}`)
return {
done: translateNow('assistant.tool.titleTemplates.prefixedDone', prefixLabel, action),
pending: translateNow('assistant.tool.titleTemplates.runningPrefixedTool', prefixLabel, action),
pendingAction: translateNow('assistant.tool.actions.running'),
icon: prefix.icon,
tone: prefix.tone
}
}
return {
done: action,
pending: translateNow('assistant.tool.titleTemplates.runningTool', action),
pendingAction: translateNow('assistant.tool.actions.running'),
tone: 'default'
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
@ -967,8 +1032,13 @@ function fallbackDetailText(args: unknown, result: unknown): string {
}
function cronScalar(value: unknown): string {
if (typeof value === 'string') return value.trim()
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'string') {
return value.trim()
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return ''
}
@ -976,7 +1046,9 @@ function cronScalar(value: unknown): string {
function formatCronTime(iso: string): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) return iso
if (Number.isNaN(ts)) {
return iso
}
return new Date(ts).toLocaleString(undefined, {
month: 'short',
@ -986,10 +1058,7 @@ function formatCronTime(iso: string): string {
})
}
function cronjobSubtitle(
argsRecord: Record<string, unknown>,
resultRecord: Record<string, unknown>
): string {
function cronjobSubtitle(argsRecord: Record<string, unknown>, resultRecord: Record<string, unknown>): string {
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
if (jobs) {
@ -998,7 +1067,9 @@ function cronjobSubtitle(
const message = firstStringField(resultRecord, ['message'])
if (message) return message
if (message) {
return message
}
const action = firstStringField(argsRecord, ['action']) || 'manage'
const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id'])
@ -1007,14 +1078,13 @@ function cronjobSubtitle(
return name ? `${label} ${name}` : `Cron ${action}`
}
function cronjobDetail(
argsRecord: Record<string, unknown>,
resultRecord: Record<string, unknown>
): string {
function cronjobDetail(argsRecord: Record<string, unknown>, resultRecord: Record<string, unknown>): string {
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
if (jobs) {
if (!jobs.length) return 'No cron jobs scheduled'
if (!jobs.length) {
return 'No cron jobs scheduled'
}
return jobs
.slice(0, 20)
@ -1029,12 +1099,14 @@ function cronjobDetail(
}
const nextRun = cronScalar(resultRecord.next_run_at)
const rows: [string, string][] = [
['Schedule', cronScalar(resultRecord.schedule)],
['Repeat', cronScalar(resultRecord.repeat)],
['Delivery', cronScalar(resultRecord.deliver)],
['Next run', nextRun ? formatCronTime(nextRun) : '']
]
const lines = rows.filter(([, value]) => value).map(([key, value]) => `${key}: ${value}`)
return lines.length ? lines.join('\n') : fallbackDetailText(argsRecord, resultRecord)
@ -1277,6 +1349,7 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
url: translateNow('assistant.tool.copyUrl'),
generic: translateNow('common.copy')
}
const args = parseMaybeObject(part.args)
const result = parseMaybeObject(part.result)
const detail = view.detail.trim()
@ -1359,39 +1432,90 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
return { label: copy.generic, text: view.title }
}
interface ToolTitleParts {
action?: ToolTitleAction
title: string
}
function titlePartsFromAction(title: string, action?: string): ToolTitleParts {
if (!action) {
return { title }
}
const actionStart = title.indexOf(action)
if (actionStart < 0) {
return { title }
}
return {
action: {
prefix: title.slice(0, actionStart),
suffix: title.slice(actionStart + action.length),
text: action
},
title
}
}
function dynamicTitle(
part: ToolPart,
args: Record<string, unknown>,
result: Record<string, unknown>,
fallback: string
): string {
fallback: ToolTitleParts
): ToolTitleParts {
const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past)
const titledAction = (action: string, title: string): ToolTitleParts =>
titlePartsFromAction(title, part.result === undefined ? action : undefined)
if (part.toolName === 'web_extract') {
const url = findFirstUrl(args, result)
const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read'))
return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback
return url
? titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url)))
: fallback
}
if (part.toolName === 'browser_navigate') {
const url = findFirstUrl(args, result)
const action = verb(translateNow('assistant.tool.actions.opening'), translateNow('assistant.tool.actions.opened'))
return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback
return url
? titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url)))
: fallback
}
if (part.toolName === 'web_search') {
const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
return query ? `${verb('Searching', 'Searched')}${compactPreview(query, 48)}` : fallback
const action = verb(
translateNow('assistant.tool.actions.searching'),
translateNow('assistant.tool.actions.searched')
)
return query
? titledAction(
action,
translateNow('assistant.tool.titleTemplates.actionQuoted', action, compactPreview(query, 48))
)
: fallback
}
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
if (command) {
const verbText = part.toolName === 'execute_code' ? verb('Running code', 'Ran code') : verb('Running', 'Ran')
const action =
part.toolName === 'execute_code'
? verb(translateNow('assistant.tool.actions.runningCode'), translateNow('assistant.tool.actions.ranCode'))
: verb(translateNow('assistant.tool.actions.running'), translateNow('assistant.tool.actions.ran'))
return `${verbText} · ${compactPreview(command, 160)}`
return titledAction(
action,
translateNow('assistant.tool.titleTemplates.actionCommand', action, compactPreview(command, 160))
)
}
}
@ -1399,7 +1523,7 @@ function dynamicTitle(
const path = fileEditPath(args, result)
if (path) {
return fileEditBasename(path)
return { title: fileEditBasename(path) }
}
}
@ -1413,7 +1537,15 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
const status = toolStatus(part, resultRecord)
const error = toolErrorText(part, resultRecord)
const baseTitle = part.result === undefined ? meta.pending : meta.done
const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle)
const titleParts = dynamicTitle(
part,
argsRecord,
resultRecord,
titlePartsFromAction(baseTitle, part.result === undefined ? meta.pendingAction : undefined)
)
const title = titleParts.title
const titleEnriched = title !== baseTitle
const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord)
@ -1467,6 +1599,7 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
status,
subtitle,
title,
titleAction: titleParts.action,
tone: meta.tone
}
}

View file

@ -46,7 +46,8 @@ import {
toolCopyPayload,
type ToolPart,
toolPartDisclosureId,
type ToolStatus
type ToolStatus,
type ToolTitleAction
} from './tool-fallback-model'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
@ -203,6 +204,39 @@ function LinkifiedText({ className, text }: { className?: string; text: string }
return <SharedLinkifiedText className={className} pretty text={cleanVisibleText(text)} />
}
function ToolTitle({
isPending,
status,
title,
titleAction
}: {
isPending: boolean
status: ToolStatus
title: string
titleAction?: ToolTitleAction
}) {
return (
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
isPending && 'text-(--ui-text-tertiary)',
status === 'error' && 'text-destructive',
status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{isPending && titleAction ? (
<>
{titleAction.prefix}
<span className="shimmer">{titleAction.text}</span>
{titleAction.suffix}
</>
) : (
title
)}
</FadeText>
)
}
interface ToolEntryProps {
part: ToolPart
}
@ -414,16 +448,7 @@ function ToolEntry({ part }: ToolEntryProps) {
icon={view.icon}
status={leadingStatus(isPending, view.status)}
/>
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
isPending && 'shimmer text-(--ui-text-tertiary)',
view.status === 'error' && 'text-destructive',
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{view.title}
</FadeText>
<ToolTitle isPending={isPending} status={view.status} title={view.title} titleAction={view.titleAction} />
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
{showDiffStats && diffStats && (
<span className="flex shrink-0 items-center gap-1 font-mono text-[0.625rem] tabular-nums">