diff --git a/docs/images/pr/capability-loading-after.png b/docs/images/pr/capability-loading-after.png new file mode 100644 index 0000000000..c68bf6afe0 Binary files /dev/null and b/docs/images/pr/capability-loading-after.png differ diff --git a/docs/images/pr/capability-loading-before.png b/docs/images/pr/capability-loading-before.png new file mode 100644 index 0000000000..602b4e1014 Binary files /dev/null and b/docs/images/pr/capability-loading-before.png differ diff --git a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts index 74a0ee272e..88741551a0 100644 --- a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts @@ -217,7 +217,14 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' const capabilityToolNames = [tool.name, rejectedTool.name].sort((left, right) => left.localeCompare(right), ); - assert.deepEqual(loaded, { loaded: capabilityToolNames }); + assert.deepEqual(loaded, { + loaded: capabilityToolNames, + group: { + id: group.id, + label: 'Unknown fixture', + description: 'A capability the Host source does not enumerate.', + }, + }); assert.deepEqual( availability.projectActiveTools?.({ completedSteps: [ diff --git a/packages/runtime/src/__tests__/tool-availability.test.ts b/packages/runtime/src/__tests__/tool-availability.test.ts index 1c4fcbf7e4..26edfb3478 100644 --- a/packages/runtime/src/__tests__/tool-availability.test.ts +++ b/packages/runtime/src/__tests__/tool-availability.test.ts @@ -119,6 +119,38 @@ describe('ToolAvailabilityRuntime — economy mode', () => { assert.ok(connector); await assert.rejects(async () => connector!.impl({ group: 'nope' }, ctx), /Unknown tool group/); }); + + test('connector returns presentation metadata with the loaded tool ids', async () => { + const connector = runtime(true) + .prepare([]) + .providerTools.find((t) => t.name === LOAD_TOOLS_NAME); + assert.ok(connector); + assert.deepEqual(await connector.impl({ group: 'rive' }, ctx), { + loaded: ['rive_run'], + group: { + id: 'rive', + label: 'Rive', + }, + }); + assert.deepEqual(await connector.impl({ group: 'docs' }, ctx), { + loaded: ['docs_edit', 'docs_read'], + group: { + id: 'docs', + description: 'Document tools', + }, + }); + assert.deepEqual( + await connector.toModelOutput?.({ + toolCallId: 'tc', + input: { group: 'rive' }, + output: await connector.impl({ group: 'rive' }, ctx), + }), + { + type: 'json', + value: { loaded: ['rive_run'] }, + }, + ); + }); }); describe('ToolAvailabilityRuntime — durable ledger seed', () => { diff --git a/packages/runtime/src/tool-availability.ts b/packages/runtime/src/tool-availability.ts index 0bde180a5b..1298acebda 100644 --- a/packages/runtime/src/tool-availability.ts +++ b/packages/runtime/src/tool-availability.ts @@ -69,6 +69,17 @@ export interface ToolAvailabilityConfig { groups?: readonly ToolGroup[]; } +export interface LoadedToolGroup { + readonly id: string; + readonly label?: string; + readonly description?: string; +} + +export interface LoadToolsResult { + readonly loaded: string[]; + readonly group: LoadedToolGroup; +} + export function toolAvailabilityHash(config: ToolAvailabilityConfig): `sha256:${string}` { return stableHash({ economy: config.economy, @@ -272,7 +283,7 @@ export class ToolAvailabilityRuntime { return out; } - private buildConnector(): MakaTool<{ group: string }, { loaded: string[] }> { + private buildConnector(): MakaTool<{ group: string }, LoadToolsResult> { // Only reached when economy is on, which requires at least one gated group, // so `ids` is always non-empty — a plain enum, no empty fallback. const ids = this.groups.map((group) => group.id); @@ -289,8 +300,23 @@ export class ToolAvailabilityRuntime { if (!found) { throw new Error(`Unknown tool group "${group}". Available: ${ids.join(', ')}.`); } - return { loaded: [...found.toolNames] }; + return { + loaded: [...found.toolNames], + group: { + id: found.id, + ...(found.label ? { label: found.label } : {}), + ...(found.description ? { description: found.description } : {}), + }, + }; }, + // Keep presentation metadata in the durable result without spending + // provider context on copy the model already received in the catalog. + toModelOutput: ({ output }) => ({ + type: 'json', + value: { + loaded: [...(output as LoadToolsResult).loaded], + }, + }), }; } diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index de0fd00de7..e80279a09e 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -99,6 +99,143 @@ describe('tool activity presentation', () => { ); }); + it('renders a client capability activation as a localized capability summary', () => { + const item: ToolActivityItem = { + toolUseId: 'load-computer-use', + toolName: 'load_tools', + activityKind: 'tool', + status: 'completed', + args: { group: 'client_a5b9af66b60c5f5c_desktop_computer_use' }, + result: { + kind: 'json', + value: { + loaded: ['mcp__desktop_computer_use__maka_computer'], + group: { + id: 'client_a5b9af66b60c5f5c_desktop_computer_use', + label: 'Computer Use', + description: 'Observe and operate the desktop through this Desktop client.', + }, + }, + }, + }; + + const row = renderToStaticMarkup(createElement(ToolTrow, { items: [item] })); + assert.match(row, /启用桌面操作/); + const detail = renderToStaticMarkup(createElement(ToolCallDetail, { item })); + assert.match(detail, /桌面操作已启用/); + assert.match(detail, /可以查看和操作已授权的本地应用/); + assert.match(detail, /Computer Use/); + assert.match(detail, /1 项能力可用/); + assert.match(detail, /技术详情/); + assert.match(detail, /client_a5b9af66b60c5f5c_desktop_computer_use/); + }); + + it('keeps legacy Computer Use activations friendly without result metadata', () => { + const item: ToolActivityItem = { + toolUseId: 'legacy-load-computer-use', + toolName: 'load_tool', + status: 'completed', + args: { namespace: 'client_legacy_desktop_computer_use' }, + result: { + kind: 'json', + value: { loaded: ['mcp__desktop_computer_use__maka_computer'] }, + }, + }; + + const markup = renderToStaticMarkup(createElement(ToolCallDetail, { item })); + assert.match(markup, /桌面操作已启用/); + assert.doesNotMatch(markup, /已加载 client_legacy_desktop_computer_use 工具组/); + }); + + it('uses supplied labels for third-party capability groups', () => { + const item: ToolActivityItem = { + toolUseId: 'load-third-party', + toolName: 'load_tools', + status: 'completed', + args: { group: 'client_external_notionsuite' }, + result: { + kind: 'json', + value: { + loaded: ['mcp__notion__search', 'mcp__notion__create_page'], + group: { + id: 'client_external_notionsuite', + label: 'Notion', + description: 'Search and update the connected workspace.', + }, + }, + }, + }; + + const row = renderToStaticMarkup(createElement(ToolTrow, { items: [item] })); + assert.match(row, /启用 Notion/); + const detail = renderToStaticMarkup(createElement(ToolCallDetail, { item })); + assert.match(detail, /Notion 已启用/); + assert.match(detail, /Search and update the connected workspace/); + assert.match(detail, /2 项能力可用/); + }); + + it('uses one localized presentation model for every first-party capability group', () => { + const cases = [ + { + id: 'browser', + label: 'Browser', + tool: 'browser_navigate', + row: '启用浏览器操作', + title: '浏览器操作已启用', + }, + { + id: 'client_desktop_mcp', + label: 'MCP', + tool: 'mcp__desktop_mcp__list', + row: '连接 MCP', + title: 'MCP 工具已连接', + }, + { + id: 'rive', + label: 'Rive', + tool: 'RiveWorkflow', + row: '启用 Rive 工作流', + title: 'Rive 工作流已启用', + }, + { + id: 'agent', + label: 'Agent', + tool: 'agent_spawn', + row: '启用子智能体', + title: '子智能体协作已启用', + }, + { + id: 'client_desktop_settings', + label: 'Client settings', + tool: 'mcp__desktop_settings__MakaSettingsGet', + row: '启用设置工具', + title: '设置工具已启用', + }, + ] as const; + + for (const capability of cases) { + const item: ToolActivityItem = { + toolUseId: `load-${capability.id}`, + toolName: 'load_tools', + status: 'completed', + args: { group: capability.id }, + result: { + kind: 'json', + value: { + loaded: [capability.tool], + group: { id: capability.id, label: capability.label }, + }, + }, + }; + + const row = renderToStaticMarkup(createElement(ToolTrow, { items: [item] })); + assert.match(row, new RegExp(capability.row)); + const detail = renderToStaticMarkup(createElement(ToolCallDetail, { item })); + assert.match(detail, new RegExp(capability.title)); + assert.doesNotMatch(detail, new RegExp(`>${capability.id}
`)); + } + }); + it('contains a malformed persisted terminal result instead of crashing the renderer', () => { const malformed = { kind: 'terminal', diff --git a/packages/ui/src/primitives/chat.tsx b/packages/ui/src/primitives/chat.tsx index 1d5e7aa70b..8091b11eaa 100644 --- a/packages/ui/src/primitives/chat.tsx +++ b/packages/ui/src/primitives/chat.tsx @@ -244,10 +244,6 @@ const PREVIEW_PART_CLASSES = { "load-tool-title": "maka-load-tool-title", // `.maka-load-tool-count` "load-tool-count": "maka-load-tool-count", - // `.maka-load-tool-tools` - "load-tool-tools": "maka-load-tool-tools", - // `.maka-load-tool-footer` - "load-tool-footer": "maka-load-tool-footer", } as const; type PreviewPart = keyof typeof PREVIEW_PART_CLASSES; diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index fe9719eeab..a1a04d9b89 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -1006,20 +1006,93 @@ .maka-web-search-error-repair { color: var(--foreground-secondary); } .maka-load-tool-preview { - font: var(--maka-text-supporting); - display: grid; gap: var(--space-0-5); margin: var(--space-1) 0 0; padding: var(--space-1) var(--space-2); border-radius: var(--radius-control); background: var(--background); box-shadow: var(--ring-soft); } + display: grid; + grid-template-columns: var(--space-8) minmax(0, 1fr); + gap: var(--space-2-5); + align-items: start; + margin: var(--space-1) 0 0; + padding: var(--space-2) var(--space-2) var(--space-2-5); +} .maka-load-tool-preview p { margin: 0; } +.maka-load-tool-icon { + display: grid; + width: var(--space-8); + height: var(--space-8); + place-items: center; + border-radius: var(--radius-element); + color: var(--foreground-secondary); + background: var(--foreground-5); +} +.maka-load-tool-summary { + min-width: 0; + padding-top: var(--space-0-5); +} .maka-load-tool-title { font: var(--maka-text-heading-5); - } -.maka-load-tool-count, -.maka-load-tool-footer { color: var(--muted-foreground); } -.maka-load-tool-tools { +} +.maka-load-tool-description { font: var(--maka-text-supporting); - word-break: break-word; } -.maka-load-tool-footer { + margin-top: var(--space-0-5) !important; + color: var(--foreground-secondary); + overflow-wrap: anywhere; +} +.maka-load-tool-count { font: var(--maka-text-supporting); - } + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1); + margin-top: var(--space-1) !important; + color: var(--muted-foreground); +} +.maka-load-tool-separator { + width: 3px; + height: 3px; + border-radius: 50%; + background: currentColor; + opacity: 0.55; +} +.maka-load-tool-technical { + grid-column: 1 / -1; + margin-left: calc(var(--space-8) + var(--space-2-5)); + color: var(--muted-foreground); +} +.maka-load-tool-technical > summary { + width: fit-content; + padding: var(--space-0-5) 0; + font: var(--maka-text-supporting); + cursor: pointer; +} +.maka-load-tool-technical > summary:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring); + border-radius: var(--radius-control); +} +.maka-load-tool-technical dl { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: var(--space-1) var(--space-2); + margin: var(--space-1) 0 0; + padding-top: var(--space-2); + border-top: 1px solid var(--border-soft); + font: var(--maka-text-supporting); +} +.maka-load-tool-technical:not([open]) > dl { + display: none; +} +.maka-load-tool-technical dt { + color: var(--muted-foreground); +} +.maka-load-tool-technical dd { + min-width: 0; + margin: 0; + color: var(--foreground-secondary); +} +.maka-load-tool-technical code { + font: var(--maka-text-code); + white-space: pre-wrap; + overflow-wrap: anywhere; +} /* Turn-footer action rings go inset (T5 geometry probe): the Astryx chat layout's scrollport clips an outward ring on the first visible row's diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index fa521d945f..637711eb40 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -23,11 +23,19 @@ import { isInFlightToolStatus } from '@maka/core/tool-result-status'; import { type ToolResultContent } from '@maka/core/events'; import { type UiLocale } from '@maka/core/ui-locale'; import { + Blocks, ICON_SIZE, Check, ChevronRight, Copy, + GitBranch, + Globe, + Monitor, + Plug, + Settings, ShieldAlert, + Workflow, + type LucideIcon, } from './icons.js'; import { useClipboardCopyFeedback } from './clipboard-feedback.js'; import { useUiLocale } from './locale-context.js'; @@ -62,7 +70,11 @@ import { } from '@astryxdesign/core'; import { ToolCodeBlock, ToolDetailReveal } from './tool-activity/tool-code-block.js'; import { cn } from './ui.js'; -import { describeLoadToolResult, formatToolIntent } from './tool-format.js'; +import { + describeLoadToolResult, + formatToolIntent, + type LoadToolGroupKind, +} from './tool-format.js'; import { formatDuration, formatUserVisibleToolText, @@ -97,16 +109,64 @@ function LoadToolResultPreview(props: { /> ); } + const Icon = loadToolGroupIcon(desc.kind); + const copy = getToolActivityCopy(locale).loadTools; return ({desc.title}
-{desc.countLabel}
-{desc.toolsText}
-{desc.footer}
+ +{desc.title}
+{desc.description}
++ {desc.label} + + {desc.countLabel} +
+{desc.groupId}{desc.toolIds.join('\n')}