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:
parent
cbe5c5689f
commit
f2c45e2c81
8 changed files with 548 additions and 90 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue