From 3dfe79ebe5a5f84d157957212374ea1d5480a3de Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 3 Aug 2026 15:04:29 +0800 Subject: [PATCH 01/15] feat(schema,agent-adapter): carry codex skill brand identity on the command catalog --- eslint.config.cjs | 1 + .../schema/src/model/agent/input.ts | 10 ++ .../foundation/schema/src/wire/message.ts | 2 +- .../src/__tests__/codex-commands.test.ts | 102 +++++++++++++++++- .../agent-adapter/src/native/codex/adapter.ts | 50 ++++++++- 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/eslint.config.cjs b/eslint.config.cjs index c83e8b314..8674b90b9 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -122,6 +122,7 @@ module.exports = require('eslint-config-sukka').sukka( name: 'linkcode/unplugin-icons-virtual-modules', files: [ 'packages/presentation/ui/src/chat/agent-icon.tsx', + 'packages/presentation/ui/src/chat/integration-brand.tsx', 'packages/presentation/ui/src/lib/__tests__/file-icon.test.ts', 'packages/presentation/ui/src/lib/material-file-icons.ts', 'packages/presentation/ui/src/shell/service-icon.tsx', diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 1a00b8b94..8f82df566 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -94,6 +94,16 @@ export const AgentCommandSchema = z.object({ /** Alternate names that invoke the same command (claude-code, e.g. `/cost` → `/usage`), no * leading slash. Input matching accepts them; menus display only the canonical `name`. */ aliases: z.array(z.string().min(1)).optional(), + /** Provider-supplied human name (codex plugin skills: "Documents"), shown beside `name`. */ + displayName: z.string().min(1).optional(), + /** Small brand icon embedded as a data URI — size-capped at adapter ingest, so consumers can + * render it directly (no asset endpoint exists for command icons). */ + iconDataUri: z.string().startsWith('data:image/').optional(), + /** Brand accent for icon fallbacks (menu initial chips). */ + brandColor: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), }); export type AgentCommand = z.infer; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 8445c3c4a..d8bd5a45b 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 73 as const; +export const WIRE_PROTOCOL_VERSION = 74 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/host/agent-adapter/src/__tests__/codex-commands.test.ts b/packages/host/agent-adapter/src/__tests__/codex-commands.test.ts index cc471214e..a1f3977a5 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-commands.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-commands.test.ts @@ -1,7 +1,10 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { AgentEvent, StartOptions } from '@linkcode/schema'; -import { describe, expect, it, vi } from 'vitest'; +import { afterAll, describe, expect, it, vi } from 'vitest'; import type { CodexServerHandle } from '../native/codex/adapter'; -import { CodexAdapter, codexSkillCommands } from '../native/codex/adapter'; +import { CodexAdapter, codexSkillCommands, skillIconDataUri } from '../native/codex/adapter'; import type { CodexAppServerOptions } from '../native/codex/app-server'; class FakeCodexServer { @@ -95,6 +98,49 @@ describe('CodexAdapter slash commands', () => { ]); }); + it('projects skill brand identity, preferring the small icon and validating the color', () => { + expect( + codexSkillCommands( + response( + { + name: 'documents', + description: 'Docs', + path: '/skills/documents/SKILL.md', + enabled: true, + interface: { + displayName: 'Documents', + brandColor: '#2563EB', + iconSmall: '/plugins/documents/assets/small.png', + iconLarge: '/plugins/documents/assets/large.png', + }, + }, + { + name: 'plain', + description: 'No brand', + path: '/skills/plain/SKILL.md', + enabled: true, + interface: { brandColor: 'blue', iconLarge: '/plugins/plain/assets/large.png' }, + }, + ), + ), + ).toEqual([ + { + name: 'documents', + description: 'Docs', + displayName: 'Documents', + brandColor: '#2563EB', + path: '/skills/documents/SKILL.md', + iconPath: '/plugins/documents/assets/small.png', + }, + { + name: 'plain', + description: 'No brand', + path: '/skills/plain/SKILL.md', + iconPath: '/plugins/plain/assets/large.png', + }, + ]); + }); + it('publishes /compact plus the skills/list catalog at session start', async () => { const adapter = new TestCodex( response( @@ -313,3 +359,55 @@ describe('CodexAdapter slash commands', () => { ]); }); }); + +describe('codex skill icons', () => { + const dirPromise = mkdtemp(join(tmpdir(), 'codex-skill-icon-')); + afterAll(async () => { + await rm(await dirPromise, { force: true, recursive: true }); + }); + + it('embeds a small icon file as a data URI and refuses non-icons', async () => { + const dir = await dirPromise; + const iconPath = join(dir, 'icon.png'); + const iconBytes = Buffer.from('89504E470D0A1A0A6D6F636B', 'hex'); + await writeFile(iconPath, iconBytes); + const oversizedPath = join(dir, 'oversized.png'); + await writeFile(oversizedPath, Buffer.alloc(33 * 1024, 1)); + + expect(await skillIconDataUri(iconPath)).toBe( + `data:image/png;base64,${iconBytes.toString('base64')}`, + ); + expect(await skillIconDataUri(oversizedPath)).toBeUndefined(); + expect(await skillIconDataUri(join(dir, 'missing.png'))).toBeUndefined(); + expect(await skillIconDataUri(join(dir, 'icon.bmp'))).toBeUndefined(); + }); + + it('publishes the embedded icon on the catalog while keeping paths private', async () => { + const dir = await dirPromise; + const iconPath = join(dir, 'documents.svg'); + await writeFile(iconPath, ''); + const adapter = new TestCodex( + response({ + name: 'documents', + description: 'Docs', + path: '/skills/documents/SKILL.md', + enabled: true, + interface: { displayName: 'Documents', brandColor: '#2563EB', iconSmall: iconPath }, + }), + ); + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + + await adapter.start(start); + + const commands = catalog(events).at(-1)?.commands; + expect(commands?.find((command) => command.name === 'documents')).toEqual({ + name: 'documents', + description: 'Docs', + displayName: 'Documents', + brandColor: '#2563EB', + iconDataUri: `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`, + }); + expect(JSON.stringify(commands)).not.toContain(dir); + }); +}); diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index 5d3252c05..944caf9f5 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -1,4 +1,6 @@ +import { readFile, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; +import { extname } from 'node:path'; import type { AgentCommand, AgentHistoryBranchOptions, @@ -71,6 +73,8 @@ import { diffContentFromUnified } from './unified-diff'; interface CodexSkillCommand extends AgentCommand { path: string; + /** Brand icon file inside the skill/plugin package — read and embedded at publish time. */ + iconPath?: string; } type CodexTurnInput = @@ -90,6 +94,8 @@ function resolveCodexEnvironment(cwd?: string): Promise { /** Map the app-server's `skills/list` response onto the normalized command catalog: only enabled * skills are invokable, and duplicate names resolve to the first provider result, like the TUI's * name-based mention lookup. */ +const BRAND_COLOR_RE = /^#[0-9A-F]{6}$/i; + export function codexSkillCommands(response: unknown): CodexSkillCommand[] { if (!isRecord(response) || !Array.isArray(response.data)) return []; const commands = new Map(); @@ -101,19 +107,53 @@ export function codexSkillCommands(response: unknown): CodexSkillCommand[] { const path = stringField(skill, 'path'); if (!name || !path || commands.has(name)) continue; const interfaceMetadata = recordField(skill, 'interface'); + const brandColor = interfaceMetadata && stringField(interfaceMetadata, 'brandColor'); commands.set(name, { name, description: stringField(skill, 'description') ?? (interfaceMetadata && stringField(interfaceMetadata, 'shortDescription')) ?? stringField(skill, 'shortDescription'), + displayName: interfaceMetadata && stringField(interfaceMetadata, 'displayName'), + brandColor: brandColor && BRAND_COLOR_RE.test(brandColor) ? brandColor : undefined, path, + iconPath: + (interfaceMetadata && stringField(interfaceMetadata, 'iconSmall')) ?? + (interfaceMetadata && stringField(interfaceMetadata, 'iconLarge')), }); } } return [...commands.values()].sort((a, b) => a.name.localeCompare(b.name)); } +/** Composer icons render at chip size; anything bigger than this is not an icon. */ +const SKILL_ICON_MAX_BYTES = 32 * 1024; + +const SKILL_ICON_MIME: Record = { + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +}; + +/** Embed a skill's icon file as a `data:image/*` URI, or nothing when the file is missing, + * unreasonably large, or not a known image type. The path comes from the user's own codex + * install; failures degrade to the glyph fallback, never to an error. */ +export async function skillIconDataUri(iconPath: string): Promise { + const mime = SKILL_ICON_MIME[extname(iconPath).toLowerCase()]; + if (!mime) return undefined; + try { + const info = await stat(iconPath); + if (!info.isFile() || info.size === 0 || info.size > SKILL_ICON_MAX_BYTES) return undefined; + const data = await readFile(iconPath); + return `data:${mime};base64,${data.toString('base64')}`; + } catch { + return undefined; + } +} + interface CodexModelCatalog { defaultModel: string | undefined; models: AgentModelOption[]; @@ -931,9 +971,17 @@ export class CodexAdapter extends BaseAgentAdapter { const skills = codexSkillCommands(response).filter( (skill) => skill.name !== COMPACT_COMMAND.name, ); + const catalog = await Promise.all( + skills.map(async ({ path: _path, iconPath, ...command }) => ({ + ...command, + iconDataUri: iconPath === undefined ? undefined : await skillIconDataUri(iconPath), + })), + ); + // The icon reads awaited — re-check that a newer refresh or server didn't win meanwhile. + if (this.server !== server || generation !== this.skillsRefreshGeneration) return; this.skillCommands.clear(); for (const skill of skills) this.skillCommands.set(skill.name, skill); - this.emitCommands([COMPACT_COMMAND, ...skills.map(({ path: _path, ...command }) => command)]); + this.emitCommands([COMPACT_COMMAND, ...catalog]); } catch { if (this.server === server && generation === this.skillsRefreshGeneration) { this.skillCommands.clear(); From 9250b29153e22156466d47a4511ccb7daec5057e Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 3 Aug 2026 15:04:37 +0800 Subject: [PATCH 02/15] feat(ui,workbench): brand command menu, tool rows, and activity groups with integration icons --- apps/desktop/package.json | 1 + apps/webview/package.json | 1 + package.json | 1 + .../workbench/src/mock/data/commands.ts | 20 ++++++ .../integration/dev-mock-transport.test.ts | 14 ++++ .../ui/src/__tests__/activity-summary.test.ts | 31 +++++++++ .../ui/src/__tests__/composer-command.test.ts | 24 +++++++ .../chat/__tests__/integration-brand.test.tsx | 25 +++++++ .../__tests__/tool-call-metadata.test.tsx | 20 ++++++ .../presentation/ui/src/chat/activity-run.tsx | 23 +++++-- .../ui/src/chat/activity-summary.ts | 30 +++++++++ .../ui/src/chat/integration-brand.tsx | 65 +++++++++++++++++++ .../ui/src/chat/tool-call-item.tsx | 12 ++++ .../ui/src/shell/composer-command.tsx | 31 ++++++++- pnpm-lock.yaml | 19 ++++++ pnpm-workspace.yaml | 1 + 16 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 packages/presentation/ui/src/chat/__tests__/integration-brand.test.tsx create mode 100644 packages/presentation/ui/src/chat/integration-brand.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 529e6264d..501a6f474 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,6 +49,7 @@ "@electron/asar": "^4.2.1", "@hookform/resolvers": "^5.5.7", "@iconify-json/material-icon-theme": "catalog:", + "@iconify-json/simple-icons": "catalog:", "@linkcode/ui": "workspace:*", "@linkcode/workbench": "workspace:*", "@proj-airi/lobe-icons": "catalog:", diff --git a/apps/webview/package.json b/apps/webview/package.json index e82e5ff09..4db523730 100644 --- a/apps/webview/package.json +++ b/apps/webview/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@iconify-json/material-icon-theme": "catalog:", + "@iconify-json/simple-icons": "catalog:", "@proj-airi/lobe-icons": "catalog:", "@rolldown/plugin-babel": "catalog:", "@svgr/core": "catalog:", diff --git a/package.json b/package.json index 336d7c0ca..c3f9b5893 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@biomejs/biome": "^2.5.5", "@eslint-sukka/react": "^8.14.2", "@iconify-json/material-icon-theme": "catalog:", + "@iconify-json/simple-icons": "catalog:", "@proj-airi/lobe-icons": "catalog:", "@svgr/core": "catalog:", "@svgr/plugin-jsx": "catalog:", diff --git a/packages/client/workbench/src/mock/data/commands.ts b/packages/client/workbench/src/mock/data/commands.ts index bb4365ecb..ee7f5fe88 100644 --- a/packages/client/workbench/src/mock/data/commands.ts +++ b/packages/client/workbench/src/mock/data/commands.ts @@ -22,6 +22,26 @@ const MOCK_COMMAND_FIXTURES: MockCommandFixture[] = [ }, reply: 'Mock review complete: no blocking issues found.', }, + { + command: { + name: 'documents', + description: 'Create and edit Word and Google Docs files', + displayName: 'Documents', + brandColor: '#2563EB', + iconDataUri: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNiIgZmlsbD0iIzI1NjNFQiIvPjxwYXRoIGQ9Ik03IDZoN2wzIDN2OUg3eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=', + }, + reply: 'Mock document created.', + }, + { + command: { + name: 'sync-linear', + description: 'Sync issues into the tracker', + displayName: 'Linear', + brandColor: '#5E6AD2', + }, + reply: 'Mock issues synced.', + }, { command: { name: 'usage', diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 31893341a..997aef939 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'; import { createDevMockTransport } from '../../src/mock/dev-mock-transport'; const rBlobUrl = /^blob:/; +const RE_SVG_DATA_URI = /^data:image\/svg\+xml;base64,/; async function connectedClient(): Promise { const client = new LinkCodeClient(createDevMockTransport()); @@ -230,6 +231,19 @@ describe('dev mock transport', () => { description: 'Review the current changes', argumentHint: '', }, + { + name: 'documents', + description: 'Create and edit Word and Google Docs files', + displayName: 'Documents', + brandColor: '#2563EB', + iconDataUri: expect.stringMatching(RE_SVG_DATA_URI), + }, + { + name: 'sync-linear', + description: 'Sync issues into the tracker', + displayName: 'Linear', + brandColor: '#5E6AD2', + }, { name: 'usage', description: 'Show session usage and rate limits', diff --git a/packages/presentation/ui/src/__tests__/activity-summary.test.ts b/packages/presentation/ui/src/__tests__/activity-summary.test.ts index 813de9b0b..e672d6186 100644 --- a/packages/presentation/ui/src/__tests__/activity-summary.test.ts +++ b/packages/presentation/ui/src/__tests__/activity-summary.test.ts @@ -2,6 +2,7 @@ import type { ToolCall } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; import type { ActivityRunItem } from '../chat/activity-groups'; import { + activityRunBrand, activityRunCurrentDescriptor, settledActivityRunDescriptor, } from '../chat/activity-summary'; @@ -188,3 +189,33 @@ describe('activityRunCurrentDescriptor', () => { expect(JSON.stringify(descriptor)).not.toMatch(RE_SENSITIVE_ACTIVITY_DETAIL); }); }); + +describe('activityRunBrand', () => { + it('wears the single distinct brand and stays generic for mixed or unbranded runs', () => { + expect( + activityRunBrand([ + tool('other', { title: 'mcp__linear__get_issue' }), + tool('read'), + tool('other', { title: 'mcp__linear__save_issue' }), + ]), + ).toBe('linear'); + expect( + activityRunBrand([ + tool('other', { title: 'mcp__linear__get_issue' }), + tool('other', { title: 'mcp__slack__send_message' }), + ]), + ).toBeUndefined(); + expect( + activityRunBrand([tool('other', { title: 'mcp__f5fcc7d5-d616__get_issue' }), tool('read')]), + ).toBeUndefined(); + }); + + it('lets a running branded call win over a mixed settled run', () => { + expect( + activityRunBrand([ + tool('other', { title: 'mcp__linear__get_issue' }), + tool('other', { title: 'mcp__slack__send_message', status: 'in_progress' }), + ]), + ).toBe('slack'); + }); +}); diff --git a/packages/presentation/ui/src/__tests__/composer-command.test.ts b/packages/presentation/ui/src/__tests__/composer-command.test.ts index 259aa3b8d..4ca43e6cf 100644 --- a/packages/presentation/ui/src/__tests__/composer-command.test.ts +++ b/packages/presentation/ui/src/__tests__/composer-command.test.ts @@ -46,4 +46,28 @@ describe('buildComposerCommandGroups slash catalog', () => { it('has no slash results when the catalog is empty', () => { expect(slashGroups([])).toEqual([]); }); + + it('shows the display name beside the description and matches queries against it', () => { + const branded: AgentCommand[] = [ + { + name: 'documents', + description: 'Create and edit files', + displayName: 'Documents', + brandColor: '#2563EB', + }, + { name: 'sync-linear', displayName: 'Linear' }, + ]; + + const [group] = slashGroups(branded); + const commandEntries = group.items.filter((item) => item.kind === 'command'); + expect(commandEntries[0].hint).toBe('Documents · Create and edit files'); + expect(commandEntries[1].hint).toBe('Linear'); + + const [filtered] = slashGroups(branded, 'linear'); + const values = filtered.items.reduce((names, item) => { + if (item.kind === 'command') names.push(item.value); + return names; + }, []); + expect(values).toEqual(['sync-linear']); + }); }); diff --git a/packages/presentation/ui/src/chat/__tests__/integration-brand.test.tsx b/packages/presentation/ui/src/chat/__tests__/integration-brand.test.tsx new file mode 100644 index 000000000..fca7d75b6 --- /dev/null +++ b/packages/presentation/ui/src/chat/__tests__/integration-brand.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom + +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { IntegrationIcon, integrationBrand } from '../integration-brand'; + +afterEach(cleanup); + +describe('integration brand resolution', () => { + it('token-matches user-chosen MCP server names against known brands', () => { + expect(integrationBrand('linear')).toBe('linear'); + expect(integrationBrand('claude_ai_Gmail')).toBe('gmail'); + expect(integrationBrand('github-enterprise')).toBe('github'); + expect(integrationBrand('jira')).toBe('jira'); + expect(integrationBrand('f5fcc7d5-d616-4ac2-9cdb-55372529dad2')).toBeUndefined(); + expect(integrationBrand('workspace')).toBeUndefined(); + }); + + it('renders the brand glyph with its brand stamped for styling and tests', () => { + const { container } = render(); + const glyph = container.querySelector('[data-brand="linear"]'); + expect(glyph).not.toBeNull(); + expect(glyph?.tagName.toLowerCase()).toBe('svg'); + }); +}); diff --git a/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx b/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx index 5e13d39de..833b7c5fa 100644 --- a/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx @@ -357,6 +357,26 @@ describe('tool metadata policy', () => { expect(mcpToolName('linear_get_issue')).toBeUndefined(); }); + it('wears a known integration brand glyph in the header icon slot', () => { + const toolCall: ToolCall = { + toolCallId: 'mcp-brand-1', + title: 'mcp__linear__get_issue', + kind: 'other', + status: 'completed', + rawInput: { id: 'CODE-525' }, + content: [], + }; + + const { container } = render(); + expect(container.querySelector('[data-brand="linear"]')).not.toBeNull(); + + cleanup(); + const unbranded = render( + , + ); + expect(unbranded.container.querySelector('[data-brand]')).toBeNull(); + }); + it('headlines an MCP call with its tool name and server context without a redundant badge', () => { const toolCall: ToolCall = { toolCallId: 'mcp-1', diff --git a/packages/presentation/ui/src/chat/activity-run.tsx b/packages/presentation/ui/src/chat/activity-run.tsx index 678e1b1f0..dcda5f0ee 100644 --- a/packages/presentation/ui/src/chat/activity-run.tsx +++ b/packages/presentation/ui/src/chat/activity-run.tsx @@ -5,7 +5,11 @@ import { useTranslations } from 'use-intl'; import { cn } from '../lib/cn'; import type { TimelineEntry } from './activity-groups'; import type { ActivitySummaryCategory, ActivitySummaryClause } from './activity-summary'; -import { activityRunCurrentDescriptor, settledActivityRunDescriptor } from './activity-summary'; +import { + activityRunBrand, + activityRunCurrentDescriptor, + settledActivityRunDescriptor, +} from './activity-summary'; import type { QuestionConversationItem } from './conversation-prompts'; import { ChatDisclosureContent } from './disclosure-content'; import { @@ -15,6 +19,8 @@ import { ChatDisclosureChevron, ChatDisclosureIconSlot, } from './disclosure-header'; +import type { IntegrationBrand } from './integration-brand'; +import { IntegrationIcon } from './integration-brand'; import { QuestionCallItem } from './question-call-item'; import { Shimmer } from './shimmer'; import { ThoughtBlock } from './thought-block'; @@ -88,6 +94,7 @@ export function ActivityRun({ ? 'thinking' : undefined); const iconCategory = current?.category ?? primaryCategory; + const brand = activityRunBrand(run.items); const [open, setOpen] = useState(false); return ( @@ -98,6 +105,7 @@ export function ActivityRun({ > ; const Icon = category ? ACTIVITY_ICONS[category] : WrenchIcon; - if (failed) return ; - if (running) return ; - return ; + return ; } function primarySettledCategory( diff --git a/packages/presentation/ui/src/chat/activity-summary.ts b/packages/presentation/ui/src/chat/activity-summary.ts index 98cfb17ec..529caad28 100644 --- a/packages/presentation/ui/src/chat/activity-summary.ts +++ b/packages/presentation/ui/src/chat/activity-summary.ts @@ -1,4 +1,7 @@ +import { mcpToolName } from '../tool-utils'; import type { ActivityRunItem } from './activity-groups'; +import type { IntegrationBrand } from './integration-brand'; +import { integrationBrand } from './integration-brand'; import { publicReasoningSummary } from './reasoning-summary'; export type ActivitySummaryCategory = @@ -97,6 +100,33 @@ function activityCategory(item: ActivityRunItem): ActivityCategory { return item.kind === 'reasoning' ? 'thinking' : toolDescriptor(item.toolCall.kind).category; } +function itemBrand(item: ActivityRunItem): IntegrationBrand | undefined { + if (item.kind !== 'tool') return undefined; + const mcp = mcpToolName(item.toolCall.title); + return mcp ? integrationBrand(mcp.server) : undefined; +} + +/** The one brand a run's header may wear: a running branded call always wins (the group is + * visibly doing that integration's work right now); otherwise the run must resolve to a single + * distinct brand — mixed-brand runs keep their category glyph. */ +export function activityRunBrand(items: readonly ActivityRunItem[]): IntegrationBrand | undefined { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.kind !== 'tool' || !isActiveTool(item)) continue; + const brand = itemBrand(item); + if (brand) return brand; + } + + let single: IntegrationBrand | undefined; + for (const item of items) { + const brand = itemBrand(item); + if (!brand) continue; + if (single !== undefined && single !== brand) return undefined; + single = brand; + } + return single; +} + type ToolActivityDescriptor = Exclude; type ReasoningActivityDescriptor = Extract; diff --git a/packages/presentation/ui/src/chat/integration-brand.tsx b/packages/presentation/ui/src/chat/integration-brand.tsx new file mode 100644 index 000000000..822cd2aad --- /dev/null +++ b/packages/presentation/ui/src/chat/integration-brand.tsx @@ -0,0 +1,65 @@ +/// +import SiAsana from '~icons/simple-icons/asana'; +import SiAtlassian from '~icons/simple-icons/atlassian'; +import SiCloudflare from '~icons/simple-icons/cloudflare'; +import SiFigma from '~icons/simple-icons/figma'; +import SiGithub from '~icons/simple-icons/github'; +import SiGmail from '~icons/simple-icons/gmail'; +import SiGoogledrive from '~icons/simple-icons/googledrive'; +import SiIntercom from '~icons/simple-icons/intercom'; +import SiLinear from '~icons/simple-icons/linear'; +import SiNotion from '~icons/simple-icons/notion'; +import SiPostgresql from '~icons/simple-icons/postgresql'; +import SiSentry from '~icons/simple-icons/sentry'; +import SiSlack from '~icons/simple-icons/slack'; +import SiStripe from '~icons/simple-icons/stripe'; +import SiSupabase from '~icons/simple-icons/supabase'; +import SiVercel from '~icons/simple-icons/vercel'; +import { cn } from '../lib/cn'; + +/** Brand glyphs for well-known integrations, keyed by the token found in an MCP server name. + * Static imports only — never construct a virtual icon path dynamically. */ +const INTEGRATION_GLYPHS = { + asana: SiAsana, + atlassian: SiAtlassian, + cloudflare: SiCloudflare, + figma: SiFigma, + github: SiGithub, + gmail: SiGmail, + googledrive: SiGoogledrive, + intercom: SiIntercom, + jira: SiAtlassian, + linear: SiLinear, + notion: SiNotion, + postgres: SiPostgresql, + postgresql: SiPostgresql, + sentry: SiSentry, + slack: SiSlack, + stripe: SiStripe, + supabase: SiSupabase, + vercel: SiVercel, +} as const; + +export type IntegrationBrand = keyof typeof INTEGRATION_GLYPHS; + +const RE_SERVER_TOKEN_BOUNDARY = /[^a-z0-9]+/; + +/** MCP server names are user-chosen config keys (`linear`, `claude_ai_Gmail`, opaque ids) — + * token-match them against the known brands; no match means no branding. */ +export function integrationBrand(server: string): IntegrationBrand | undefined { + for (const token of server.toLowerCase().split(RE_SERVER_TOKEN_BOUNDARY)) { + if (token in INTEGRATION_GLYPHS) return token as IntegrationBrand; + } + return undefined; +} + +export function IntegrationIcon({ + brand, + className, +}: { + brand: IntegrationBrand; + className?: string; +}): React.ReactNode { + const Glyph = INTEGRATION_GLYPHS[brand]; + return ; +} diff --git a/packages/presentation/ui/src/chat/tool-call-item.tsx b/packages/presentation/ui/src/chat/tool-call-item.tsx index 0b7038eb3..d02d1eee2 100644 --- a/packages/presentation/ui/src/chat/tool-call-item.tsx +++ b/packages/presentation/ui/src/chat/tool-call-item.tsx @@ -13,6 +13,7 @@ import { toolCallMetadata, toolCallSearchCounts, } from '../tool-utils'; +import { IntegrationIcon, integrationBrand } from './integration-brand'; import { Tool, ToolContent, ToolHeader } from './tool'; import { toolCallDisplayText, toolSearchPresentation } from './tool-result-content'; import { ToolResultPreview } from './tool-result-preview'; @@ -103,6 +104,9 @@ export function ToolCallItem({ const searchCounts = toolSearch ? undefined : toolCallSearchCounts(toolCall); let title = mcp?.tool ?? toolCall.title; let summary = toolCallContextSummary(toolCall); + // Header glyph priority: caller-supplied plugin icon, then the ToolSearch toolbox, then the + // integration's brand glyph, then the kind icon; state glyphs still override inside ToolIcon. + const brand = mcp ? integrationBrand(mcp.server) : undefined; let headerIcon = icon; if (toolSearch && !headerIcon) { headerIcon = ( @@ -111,6 +115,14 @@ export function ToolCallItem({ /> ); } + if (brand && !headerIcon) { + headerIcon = ( + + ); + } if (toolSearch) { title = toolSearch.mode === 'select' diff --git a/packages/presentation/ui/src/shell/composer-command.tsx b/packages/presentation/ui/src/shell/composer-command.tsx index 2def1add3..3242c5ab1 100644 --- a/packages/presentation/ui/src/shell/composer-command.tsx +++ b/packages/presentation/ui/src/shell/composer-command.tsx @@ -186,16 +186,24 @@ export function buildComposerCommandGroups({ const commandItems: ComposerCommandEntry[] = []; if (commandSource === 'slash') { for (const command of agentCommands) { - // Aliases match too (typing /cost surfaces /usage); selection inserts the canonical name. + // Aliases and display names match too (typing /cost surfaces /usage); selection inserts + // the canonical name. if ( !matchesQuery(command.name, command.name, command.description, commandQuery) && + !command.displayName?.toLowerCase().includes(commandQuery) && !command.aliases?.some((alias) => alias.toLowerCase().includes(commandQuery)) ) { continue; } + const hint = + command.displayName === undefined + ? (command.description ?? command.argumentHint) + : [command.displayName, command.description ?? command.argumentHint] + .filter((part) => part !== undefined) + .join(' · '); commandItems.push({ command, - hint: command.description ?? command.argumentHint, + hint, icon: BookTextIcon, id: `command:${command.name}`, kind: 'command', @@ -233,6 +241,25 @@ export function buildComposerCommandGroups({ } function CommandIcon({ entry }: { entry: ComposerCommandEntry }): React.ReactNode { + // A provider-branded command shows its own icon; brandColor tints an initial chip when the + // catalog carried a color but no usable icon. Unbranded commands keep the shared glyph. + if (entry.kind === 'command') { + const { brandColor, displayName, iconDataUri, name } = entry.command; + if (iconDataUri) { + return ; + } + if (brandColor) { + return ( + + {(displayName ?? name).slice(0, 1)} + + ); + } + } const Icon = entry.icon; if (!Icon) return null; return ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 277d2c132..fd71fa17f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@iconify-json/material-icon-theme': specifier: ^1.2.69 version: 1.2.69 + '@iconify-json/simple-icons': + specifier: ^1.2.92 + version: 1.2.92 '@proj-airi/lobe-icons': specifier: ^1.0.19 version: 1.0.19 @@ -135,6 +138,9 @@ importers: '@iconify-json/material-icon-theme': specifier: 'catalog:' version: 1.2.69 + '@iconify-json/simple-icons': + specifier: 'catalog:' + version: 1.2.92 '@proj-airi/lobe-icons': specifier: 'catalog:' version: 1.0.19 @@ -326,6 +332,9 @@ importers: '@iconify-json/material-icon-theme': specifier: 'catalog:' version: 1.2.69 + '@iconify-json/simple-icons': + specifier: 'catalog:' + version: 1.2.92 '@linkcode/ui': specifier: workspace:* version: link:../../packages/presentation/ui @@ -694,6 +703,9 @@ importers: '@iconify-json/material-icon-theme': specifier: 'catalog:' version: 1.2.69 + '@iconify-json/simple-icons': + specifier: 'catalog:' + version: 1.2.92 '@proj-airi/lobe-icons': specifier: 'catalog:' version: 1.0.19 @@ -3207,6 +3219,9 @@ packages: '@iconify-json/material-icon-theme@1.2.69': resolution: {integrity: sha512-BqVadW0C19qyzp7Gtl2z0wExBFhfGKDPrgq4ajnGUe9atBcqSwz/yULCbZWzjPlOPrFFu4P3hhfs8Vl503nhEg==} + '@iconify-json/simple-icons@1.2.92': + resolution: {integrity: sha512-hR0ozxR97t1dzWw+esoxFijZ15gagt7EIgF3CNifu2yICXhS7gnun4Y+j+odJQtNSl7wvqMdoLbViIShwe/fdw==} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -13573,6 +13588,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/simple-icons@1.2.92': + dependencies: + '@iconify/types': 2.0.0 + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.3': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 39d5101e1..7946137a7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -104,6 +104,7 @@ catalog: "@svgr/plugin-jsx": ^8.1.0 "@proj-airi/lobe-icons": ^1.0.19 "@iconify-json/material-icon-theme": ^1.2.69 + "@iconify-json/simple-icons": ^1.2.92 minimumReleaseAgeExclude: # React canary snapshots publish daily; the renderers pin one exact snapshot (CODE-457). From 14448f552cc0ec93151228252bca5f0c5e5a0ce3 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 3 Aug 2026 15:27:50 +0800 Subject: [PATCH 03/15] feat(ui): brand command chips in the composer and transcript echoes --- .../agent-adapter/src/native/codex/adapter.ts | 4 +- .../src/chat/__tests__/user-message.test.tsx | 33 ++++++++++++ .../ui/src/chat/command-brand.tsx | 51 +++++++++++++++++++ .../ui/src/chat/command-catalog.ts | 25 +++++++++ .../presentation/ui/src/chat/user-message.tsx | 35 +++++++++++-- .../ui/src/shell/composer-command.tsx | 19 +------ .../ui/src/shell/composer-editor/chips.tsx | 8 +-- .../shell/composer-editor/directive-state.ts | 9 ++++ .../ui/src/shell/conversation-surface.tsx | 31 +++++++---- 9 files changed, 179 insertions(+), 36 deletions(-) create mode 100644 packages/presentation/ui/src/chat/command-brand.tsx create mode 100644 packages/presentation/ui/src/chat/command-catalog.ts diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index 944caf9f5..4eb205bd7 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -91,11 +91,11 @@ function resolveCodexEnvironment(cwd?: string): Promise { return resolveAgentShellEnvironment(cwd ?? homedir()); } +const BRAND_COLOR_RE = /^#[0-9A-F]{6}$/i; + /** Map the app-server's `skills/list` response onto the normalized command catalog: only enabled * skills are invokable, and duplicate names resolve to the first provider result, like the TUI's * name-based mention lookup. */ -const BRAND_COLOR_RE = /^#[0-9A-F]{6}$/i; - export function codexSkillCommands(response: unknown): CodexSkillCommand[] { if (!isRecord(response) || !Array.isArray(response.data)) return []; const commands = new Map(); diff --git a/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx b/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx index 7c6dd4e19..0c7e2161c 100644 --- a/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { asyncNoop } from 'foxts/noop'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CommandCatalogProvider } from '../command-brand'; import type { ConversationItem } from '../types'; import { UserMessage } from '../user-message'; @@ -125,4 +126,36 @@ describe('UserMessage', () => { expect(screen.getByRole('button', { name: label }).disabled).toBe(true); }); + + it('chips a catalog-matched command echo with its brand icon, leaving unknowns plain', () => { + const echo = (text: string): Extract => ({ + id: `user-${text}`, + kind: 'message', + role: 'user', + turnId: 'turn-1', + blocks: [{ type: 'text', text }], + isStreaming: false, + }); + const commands = [ + { name: 'documents', displayName: 'Documents', iconDataUri: 'data:image/png;base64,cG5n' }, + ]; + + const { container } = render( + + + , + ); + expect(screen.getByText('/documents')).toBeDefined(); + expect(screen.getByText('quarterly summary')).toBeDefined(); + expect(container.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,cG5n'); + + cleanup(); + const plain = render( + + + , + ); + expect(plain.container.querySelector('img')).toBeNull(); + expect(screen.getByText('/usr/bin/env is a path, not a command')).toBeDefined(); + }); }); diff --git a/packages/presentation/ui/src/chat/command-brand.tsx b/packages/presentation/ui/src/chat/command-brand.tsx new file mode 100644 index 000000000..75f788070 --- /dev/null +++ b/packages/presentation/ui/src/chat/command-brand.tsx @@ -0,0 +1,51 @@ +import type { AgentCommand } from '@linkcode/schema'; +import { BookTextIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { cn } from '../lib/cn'; +import { buildCommandLookup, CommandCatalogContext } from './command-catalog'; + +/** A provider-branded command's glyph: its own icon, else a brandColor-tinted initial chip, else + * the shared book glyph. One shape for menu rows, directive chips, and transcript echoes. */ +export function CommandBrandGlyph({ + command, + className, +}: { + command?: Pick; + className?: string; +}): React.ReactNode { + if (command?.iconDataUri) { + return ( + + ); + } + if (command?.brandColor) { + return ( + + {(command.displayName ?? command.name).slice(0, 1)} + + ); + } + return ; +} + +export function CommandCatalogProvider({ + commands, + children, +}: { + commands: readonly AgentCommand[] | null; + children: React.ReactNode; +}): React.ReactNode { + // Catalog updates are full-replace, so `commands` identity is the correct invalidation key. + const lookup = useMemo( + () => (commands === null ? null : buildCommandLookup(commands)), + [commands], + ); + return {children}; +} diff --git a/packages/presentation/ui/src/chat/command-catalog.ts b/packages/presentation/ui/src/chat/command-catalog.ts new file mode 100644 index 000000000..f7277654c --- /dev/null +++ b/packages/presentation/ui/src/chat/command-catalog.ts @@ -0,0 +1,25 @@ +import type { AgentCommand } from '@linkcode/schema'; +import { createContext, useContext } from 'react'; + +/** Command lookup keyed by canonical name AND every alias, so transcript chips resolve echoes + * in O(1) during render. Provided by the conversation surface; null when no catalog exists. */ +export const CommandCatalogContext = createContext | null>(null); + +export function buildCommandLookup( + commands: readonly AgentCommand[], +): ReadonlyMap { + const lookup = new Map(); + for (const command of commands) { + for (const name of [command.name, ...(command.aliases ?? [])]) { + if (!lookup.has(name)) lookup.set(name, command); + } + } + return lookup; +} + +/** The catalog entry a command name resolves to (canonical name or alias), if any. */ +export function useCatalogCommand(name: string | undefined): AgentCommand | undefined { + const lookup = useContext(CommandCatalogContext); + if (name === undefined) return undefined; + return lookup?.get(name); +} diff --git a/packages/presentation/ui/src/chat/user-message.tsx b/packages/presentation/ui/src/chat/user-message.tsx index ffe81bd6e..09c073a21 100644 --- a/packages/presentation/ui/src/chat/user-message.tsx +++ b/packages/presentation/ui/src/chat/user-message.tsx @@ -8,9 +8,12 @@ import { CheckIcon, ChevronDownIcon, CopyIcon, PencilIcon } from 'lucide-react'; import { useState } from 'react'; import { useFormatter, useTranslations } from 'use-intl'; import { cn } from '../lib/cn'; +import { CommandBrandGlyph } from './command-brand'; +import { useCatalogCommand } from './command-catalog'; import { ContentBlockView } from './content-block-view'; import { positionalBlockEntries } from './content-derived-keys'; import { contentBlocksText } from './conversation-text'; +import { Chip } from './link-chip'; import { Message, MessageAction, MessageActions, MessageContent } from './message'; import type { ConversationItem, PromptEditState } from './types'; import { useCopyButton } from './use-copy-button'; @@ -19,6 +22,18 @@ import { useCopyButton } from './use-copy-button'; const COLLAPSE_LINE_COUNT = 20; const COPY_FEEDBACK_MS = 2000; +const RE_WHITESPACE = /\s/; + +/** A command echo is exactly what the composer sent: `/name` and optional argument text. */ +function commandEcho(text: string): { name: string; args: string } | undefined { + if (text[0] !== '/') return undefined; + const body = text.slice(1); + const nameEnd = body.search(RE_WHITESPACE); + if (nameEnd === 0 || body.length === 0) return undefined; + if (nameEnd === -1) return { name: body, args: '' }; + return { name: body.slice(0, nameEnd), args: body.slice(nameEnd).trim() }; +} + type MessageItem = Extract; /** A user bubble: collapses long messages, with copy/edit and the send time revealed on hover. */ @@ -91,6 +106,11 @@ export function UserMessage({ } } + // A catalog-matched `/command args` echo chips its invocation like the composer draft did. + // Unknown leading slashes (paths, prose) stay plain text. + const echo = item.blocks.length === 1 ? commandEcho(text) : undefined; + const echoedCommand = useCatalogCommand(echo?.name); + return (
- {positionalBlockEntries(item.blocks).map(({ block, key }) => ( - - ))} + {echo && echoedCommand ? ( +

+ + /{echo.name} + + {echo.args ? {echo.args} : null} +

+ ) : ( + positionalBlockEntries(item.blocks).map(({ block, key }) => ( + + )) + )}
{collapsible ? (