diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 3bd3141e41..ff9156dfe9 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -8,6 +8,8 @@ import { isCardTool, isFileEditTool, isSilentTool } from '@/lib/tool-render-clas import { toolResultRecord } from '@/lib/tool-result-metadata' import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary' +import { skillActivityTitle } from '../skill-activity' + import { browserExecStepLabel, compactPreview, @@ -1310,6 +1312,12 @@ function dynamicTitle( result: Record, fallback: ToolTitleParts ): ToolTitleParts { + const skillTitle = skillActivityTitle(part) + + if (skillTitle) { + return { title: skillTitle } + } + const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past) const titledAction = (action: string, title: string): ToolTitleParts => diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 6c9112e9ba..ef1bca8c1a 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -82,6 +82,7 @@ import { ToolRunTicker } from './run-ticker' // false, so every row currently owns its own chrome; kept as a seam for any // future embedding surface. const ToolEmbedContext = createContext(false) +const ToolRunDisclosureContext = createContext(null) // A search hit's title is result *content* inside an expanded row, not one of // the scaffolding lines, so it keeps the brighter secondary grey. @@ -355,6 +356,7 @@ function ToolEntry({ part }: ToolEntryProps) { const messageId = useAuiState(s => s.message.id) const messageRunning = useAuiState(selectMessageRunning) const embedded = useContext(ToolEmbedContext) + const runDisclosureId = useContext(ToolRunDisclosureContext) const toolViewMode = useStore($toolViewMode) // `ToolFallback` rebuilds the `part` wrapper each render, defeating the memos @@ -565,7 +567,18 @@ function ToolEntry({ part }: ToolEntryProps) {
setToolDisclosureOpen(disclosureId, !open) : undefined} + onToggle={ + hasExpandableContent + ? () => { + // Opening a row is newer intent than an earlier group collapse. + if (!open && runDisclosureId) { + setToolDisclosureOpen(runDisclosureId, true) + } + + setToolDisclosureOpen(disclosureId, !open) + } + : undefined + } open={open} trailing={trailing} > @@ -806,7 +819,7 @@ export function splitRunItems(toolNames: readonly string[]): RunItem[] { */ // The one grey line that stands in for a run of tool calls — "Explored 3 // files, ran 5 commands". Live, it narrates in the present tense above the -// ticker and offers no toggle, since there is nothing settled to unfold yet. +// ticker by default; its toggle can reveal the activity before it settles. function ToolRunHeader({ completedAt, live, @@ -855,7 +868,8 @@ interface ToolRunState { // re-render the group on every text delta in the turn. The run only changes // when a call arrives or one finishes; cache on exactly that. function useToolRun(startIndex: number, endIndex: number): ToolRunState { - const cache = useRef(null) + const { locale } = useI18n() + const cache = useRef(null) return useAuiState(state => { const parts = state.message.parts @@ -877,12 +891,28 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}:${tool.timestamp ?? ''}:${tool.completedAt ?? ''}` ) - .concat(String(live)) + .concat(String(live), state.message.id, locale) .join('|') - if (cache.current?.signature !== signature) { + // The arguments may arrive after tool.start. Compare references rather + // than stringify potentially huge args/results on every streaming tick. + const sameInputs = + cache.current?.tools.length === timelineTools.length && + timelineTools.every((tool, index) => { + const previous = cache.current!.tools[index] + + return ( + tool.args === previous.args && + tool.result === previous.result && + tool.toolName === previous.toolName && + tool.isError === previous.isError + ) + }) + + if (cache.current?.signature !== signature || !sameInputs) { cache.current = { signature, + tools: timelineTools, value: { completedAt: timelineTools.reduce( (latest, tool) => @@ -895,7 +925,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { ), count: tools.length, entryIds: tools.map(tool => toolEntryDisclosureId(state.message.id, tool)), - key: tools[0]?.toolCallId ?? '', + key: `${state.message.id}:${tools[0]?.toolCallId ?? ''}`, live, startedAt: timelineTools.reduce( (earliest, tool) => @@ -954,7 +984,7 @@ const ToolRun: FC> = // A lone call is already its own one-line summary; heading it with a second // line would say the same thing twice. if (count < 2) { - return <>{children} + return {children} } // Two things a one-line window can't hold. An approval is a question the @@ -963,27 +993,28 @@ const ToolRun: FC> = // keeps going. Either one hands the run back its full height until the run // settles and the row can be reached through the summary instead. const blocked = Boolean(approval) && pendingApprovalTool - const unfurled = blocked || rowOpen - const expanded = live ? unfurled : (persistedOpen ?? false) + const expanded = blocked || (persistedOpen ?? rowOpen) return ( -
- setToolDisclosureOpen(disclosureId, !expanded)} - open={expanded} - startedAt={startedAt} - summary={summary} - /> - {live && !unfurled && {children}} - {expanded &&
{children}
} -
+ +
+ setToolDisclosureOpen(disclosureId, !expanded)} + open={expanded} + startedAt={startedAt} + summary={summary} + /> + {live && !expanded && {children}} + {expanded &&
{children}
} +
+
) } diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts index 7693e67ed6..a55daf3af4 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -1,7 +1,10 @@ +import { translateNow } from '@/i18n' import { summarizeShellCommand } from '@/lib/summarize-command' import { firstStringField } from '@/lib/text' +import { extractToolErrorMessage } from '@/lib/tool-result-summary' import { fileEditBasename, isFileEditTool, parseMaybeObject } from './fallback-model' +import { skillActivityTitle } from './skill-activity' /** * The little a summary needs from a tool call, stated structurally so both @@ -10,6 +13,8 @@ import { fileEditBasename, isFileEditTool, parseMaybeObject } from './fallback-m */ export interface ToolCallLike { args?: unknown + completedAt?: number + isError?: boolean result?: unknown toolCallId?: string toolName: string @@ -64,7 +69,7 @@ function toolCategory(toolName: string): RunCategory { } function isPending(tool: ToolCallLike): boolean { - return tool.result === undefined + return tool.result === undefined && tool.completedAt === undefined } /** @@ -73,6 +78,10 @@ function isPending(tool: ToolCallLike): boolean { * described in the same words from the moment the model drafts it. */ export function toolPresentVerb(toolName: string): string { + if (toolName === 'skill_view') { + return translateNow('assistant.tool.skillActivity.loading') + } + return CATEGORY_COPY[toolCategory(toolName)].present } @@ -135,8 +144,17 @@ export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): const liveCategory = narrating ? toolCategory(narrating.toolName) : null const byCategory = new Map() + const skillClauses: string[] = [] for (const tool of tools) { + const skill = skillActivityTitle(tool, live) + + if (skill) { + skillClauses.push(skill) + + continue + } + const category = toolCategory(tool.toolName) const group = byCategory.get(category) @@ -153,5 +171,20 @@ export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): return group ? [clause(category, group, category === liveCategory)] : [] }) - return clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ') + const failed = tools.filter(tool => { + const result = parseMaybeObject(tool.result) + + // Explicit success beats stale envelope errors, as in individual rows. + return ( + result.success !== true && + result.ok !== true && + Boolean(tool.isError || result.success === false || result.ok === false || extractToolErrorMessage(tool.result)) + ) + }).length + + if (failed) { + clauses.push(translateNow('assistant.tool.failedCalls', failed)) + } + + return [...skillClauses, ...clauses].map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ') } diff --git a/apps/desktop/src/components/assistant-ui/tool/skill-activity.test.ts b/apps/desktop/src/components/assistant-ui/tool/skill-activity.test.ts new file mode 100644 index 0000000000..daf4c819de --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/skill-activity.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { buildToolView } from './fallback-model' +import { summarizeToolRun } from './run-summary' + +const skill = { type: 'tool-call' as const, toolName: 'skill_view', args: { name: 'research-notes' } } + +describe('skill activity identity', () => { + it('names instruction loads, resource reads, failures and unobserved outcomes without claiming task completion', () => { + expect(buildToolView(skill, '').title).toBe('Loading skill: research-notes') + expect(buildToolView({ ...skill, result: { success: true } }, '').title).toBe('Loaded skill: research-notes') + const resource = { ...skill, args: { ...skill.args, file_path: 'references/example.md' } } + expect(buildToolView(resource, '').title).toBe('Reading skill resource: research-notes → references/example.md') + expect(buildToolView({ ...resource, result: 'resource body' }, '').title).toBe( + 'Read skill resource: research-notes → references/example.md' + ) + expect(buildToolView({ ...skill, isError: true, result: 'missing' }, '').title).toBe( + 'Failed to load skill: research-notes' + ) + expect(summarizeToolRun([skill], false)).toBe('Skill result unavailable: research-notes') + }) + + it('keeps each skill identity and failed outcome in mixed collapsed summaries', () => { + const summary = summarizeToolRun( + [ + { ...skill, result: 'instructions' }, + { ...skill, args: { name: 'second-skill' }, result: { error: 'not found' } }, + { toolName: 'terminal', args: { command: 'echo ok' }, result: { stdout: 'ok' } } + ], + false + ) + + expect(summary).toContain('Loaded skill: research-notes') + expect(summary).toContain('failed to load skill: second-skill') + expect(summary).toContain('ran 1 command') + expect(summary).toContain('1 tool call failed') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool/skill-activity.ts b/apps/desktop/src/components/assistant-ui/tool/skill-activity.ts new file mode 100644 index 0000000000..e1eb5edc0d --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/skill-activity.ts @@ -0,0 +1,46 @@ +import { translateNow } from '@/i18n' +import { firstStringField } from '@/lib/text' +import { extractToolErrorMessage } from '@/lib/tool-result-summary' + +import { parseMaybeObject } from './fallback-model/format' + +interface SkillCall { + args?: unknown + completedAt?: number + isError?: boolean + result?: unknown + toolName: string +} + +/** Loading instructions is not the same action as reading a skill resource, + * and neither proves the task described by the skill has been completed. */ +export function skillActivityTitle(part: SkillCall, live = true): string | undefined { + if (part.toolName !== 'skill_view' && part.toolName !== 'skills_list') { + return undefined + } + + const args = parseMaybeObject(part.args) + const result = parseMaybeObject(part.result) + + const failed = Boolean( + part.isError || extractToolErrorMessage(part.result) || result.success === false || result.ok === false + ) + + const pending = live && part.result === undefined && part.completedAt === undefined + const missing = !pending && part.result === undefined + const file = firstStringField(args, ['file_path']) + const name = firstStringField(args, ['name']) + const target = [name, file].filter(Boolean).join(' → ') + + const keys = + part.toolName === 'skills_list' + ? { pending: 'listing', done: 'listed', failed: 'listFailed' } + : file + ? { pending: 'readingResource', done: 'readResource', failed: 'resourceFailed' } + : { pending: 'loading', done: 'loaded', failed: 'loadFailed' } + + const key = failed ? keys.failed : missing ? 'unavailable' : pending ? keys.pending : keys.done + const label = translateNow(`assistant.tool.skillActivity.${key}`) + + return target ? `${label}: ${target}` : label +} diff --git a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx index c385cfc92f..3d3d2f818f 100644 --- a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx @@ -478,14 +478,56 @@ describe('live tool run', () => { }) }) - it('cannot be collapsed while a tool is still running', async () => { - const { container } = render() + it('honors explicit disclosure across live updates and completion', async () => { + const message = groupedPendingMessage() + const { container, rerender } = render() + const toggle = () => container.querySelector('[data-tool-summary] button[aria-expanded]') as HTMLButtonElement - await waitFor(() => { - expect(container.querySelector('[data-tool-summary]')).not.toBeNull() - }) + await waitFor(() => expect(toggle()).not.toBeNull()) + fireEvent.click(toggle()) + expect(toggle().getAttribute('aria-expanded')).toBe('true') + expect(container.querySelector('[data-tool-ticker]')).toBeNull() + fireEvent.click(toggle()) + const row = container.querySelector('[data-tool-ticker] [data-tool-row] button[aria-expanded="false"]') + expect(row).not.toBeNull() + fireEvent.click(row as Element) + await waitFor(() => expect(container.querySelector('[data-tool-ticker]')).toBeNull()) - expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull() + const next = { + ...message, + content: [ + ...message.content, + { + type: 'tool-call', + toolCallId: 'read-next', + toolName: 'read_file', + args: { path: '/tmp/next' }, + argsText: '{}' + } + ] + } as ThreadMessage + + rerender() + await waitFor(() => expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(3)) + rerender() + await waitFor(() => expect(toggle().getAttribute('aria-expanded')).toBe('true')) + fireEvent.click(toggle()) + expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0) + }) + + it('updates named skill summaries when identifying arguments arrive late', async () => { + const message = groupedPendingMessage() + const first = { ...message.content[0], toolName: 'skill_view', args: {}, result: { success: true } } + + const { container, rerender } = render( + + ) + + const summary = () => container.querySelector('[data-tool-summary]')?.textContent + await waitFor(() => expect(summary()).toContain('Loaded skill')) + const named = { ...first, args: { name: 'research-notes' } } + rerender() + await waitFor(() => expect(summary()).toContain('research-notes')) }) // Liveness used to also require an unresolved call, which is false for the @@ -497,7 +539,7 @@ describe('live tool run', () => { expect(await screen.findByText('Running 2 commands')).toBeTruthy() expect(container.querySelector('[data-tool-ticker]')).not.toBeNull() - expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull() + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull() }) // The ticker is a one-line window, so a row opened inside it had its output diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index c8702f0a00..13bd6babe7 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2901,6 +2901,19 @@ export const ar = defineLocale({ copyQuery: 'نسخ الاستعلام', copyFile: 'نسخ الملف', copyPath: 'نسخ المسار', + failedCalls: (count: number) => `عدد استدعاءات الأدوات الفاشلة: ${count}`, + skillActivity: { + loading: 'جارٍ تحميل المهارة', + loaded: 'تم تحميل المهارة', + loadFailed: 'تعذر تحميل المهارة', + readingResource: 'جارٍ قراءة مورد المهارة', + readResource: 'تمت قراءة مورد المهارة', + resourceFailed: 'تعذرت قراءة مورد المهارة', + listing: 'جارٍ عرض المهارات', + listed: 'تم عرض المهارات', + listFailed: 'تعذر عرض المهارات', + unavailable: 'نتيجة المهارة غير متاحة' + }, outputAlt: 'إخراج الأداة', rawResponse: 'الرد الخام', copyActivity: 'نسخ النشاط', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 48323d57f3..3014a98acb 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -3964,6 +3964,19 @@ export const en: Translations = { copyQuery: 'Copy query', copyFile: 'Copy file', copyPath: 'Copy path', + failedCalls: (count: number) => `${count} tool call${count === 1 ? '' : 's'} failed`, + skillActivity: { + loading: 'Loading skill', + loaded: 'Loaded skill', + loadFailed: 'Failed to load skill', + readingResource: 'Reading skill resource', + readResource: 'Read skill resource', + resourceFailed: 'Failed to read skill resource', + listing: 'Listing skills', + listed: 'Listed skills', + listFailed: 'Failed to list skills', + unavailable: 'Skill result unavailable' + }, outputAlt: 'Tool output', rawResponse: 'Raw response', copyActivity: 'Copy activity', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index f725cb9b98..61dddd8668 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -3359,6 +3359,19 @@ export const ja = defineLocale({ copyQuery: 'クエリをコピー', copyFile: 'ファイルをコピー', copyPath: 'パスをコピー', + failedCalls: (count: number) => `失敗したツール呼び出し: ${count}`, + skillActivity: { + loading: 'スキルを読み込み中', + loaded: 'スキルを読み込みました', + loadFailed: 'スキルの読み込みに失敗しました', + readingResource: 'スキルのリソースを読み込み中', + readResource: 'スキルのリソースを読み込みました', + resourceFailed: 'スキルのリソースの読み込みに失敗しました', + listing: 'スキル一覧を取得中', + listed: 'スキル一覧を取得しました', + listFailed: 'スキル一覧の取得に失敗しました', + unavailable: 'スキルの結果を取得できません' + }, outputAlt: 'ツール出力', rawResponse: '生の応答', copyActivity: 'アクティビティをコピー', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 663e4dd7ab..a23794ea6f 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -3661,6 +3661,19 @@ export const ru = defineLocale({ copyQuery: 'Копировать запрос', copyFile: 'Копировать файл', copyPath: 'Копировать путь', + failedCalls: (count: number) => `Вызовов с ошибкой: ${count}`, + skillActivity: { + loading: 'Загружается скилл', + loaded: 'Загружен скилл', + loadFailed: 'Не удалось загрузить скилл', + readingResource: 'Читается ресурс скилла', + readResource: 'Прочитан ресурс скилла', + resourceFailed: 'Не удалось прочитать ресурс скилла', + listing: 'Загружается список скиллов', + listed: 'Получен список скиллов', + listFailed: 'Не удалось получить список скиллов', + unavailable: 'Результат работы со скиллом недоступен' + }, outputAlt: 'Вывод инструмента', rawResponse: 'Сырой ответ', copyActivity: 'Копировать активность', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 38bf01eb15..baf2a1b206 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -3490,6 +3490,19 @@ export interface Translations { copyQuery: string copyFile: string copyPath: string + failedCalls: (count: number) => string + skillActivity: { + loading: string + loaded: string + loadFailed: string + readingResource: string + readResource: string + resourceFailed: string + listing: string + listed: string + listFailed: string + unavailable: string + } outputAlt: string rawResponse: string copyActivity: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 5311161ed8..846b41d5ec 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -3312,6 +3312,19 @@ export const zhHant = defineLocale({ copyQuery: '複製查詢', copyFile: '複製檔案', copyPath: '複製路徑', + failedCalls: (count: number) => `${count} 次工具呼叫失敗`, + skillActivity: { + loading: '正在載入技能', + loaded: '已載入技能', + loadFailed: '技能載入失敗', + readingResource: '正在讀取技能資源', + readResource: '已讀取技能資源', + resourceFailed: '技能資源讀取失敗', + listing: '正在列出技能', + listed: '已列出技能', + listFailed: '技能清單取得失敗', + unavailable: '技能結果無法使用' + }, outputAlt: '工具輸出', rawResponse: '原始回應', copyActivity: '複製活動', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f5d17844cc..2d6f87f884 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -4118,6 +4118,19 @@ export const zh = defineLocale({ copyQuery: '复制查询', copyFile: '复制文件', copyPath: '复制路径', + failedCalls: (count: number) => `${count} 次工具调用失败`, + skillActivity: { + loading: '正在加载技能', + loaded: '已加载技能', + loadFailed: '技能加载失败', + readingResource: '正在读取技能资源', + readResource: '已读取技能资源', + resourceFailed: '技能资源读取失败', + listing: '正在列出技能', + listed: '已列出技能', + listFailed: '技能列表获取失败', + unavailable: '技能结果不可用' + }, outputAlt: '工具输出', rawResponse: '原始响应', copyActivity: '复制活动',