diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index dd565f2ea..c4500a84b 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -11,6 +11,7 @@ import { parseDesignSystemRenameArgs } from './design-system-rename-args.js'; import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js'; import { splitResearchSubcommand } from './research/cli-args.js'; import { resolveDaemonUrl } from './daemon-url.js'; +import { buildSkillCatalogTree, parseSkillSummaries } from '@open-design/contracts'; import { requestJsonIpc } from '@open-design/sidecar'; import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto'; @@ -196,6 +197,7 @@ const RECOVERABLE_EXIT_CODES = { 'plugin-requires-daemon': 71, 'snapshot-stale': 72, 'genui-surface-awaiting': 73, + 'daemon-protocol-error': 74, 'desktop-auth-pending': 74, 'desktop-import-token-rejected': 75, }; @@ -5408,7 +5410,60 @@ async function runLibraryList(name, args) { } } -async function runSkills(args) { return runLibraryList('skills', args); } +async function runSkills(args) { + if (args[0] === 'tree') return runSkillsTree(args.slice(1)); + if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { + console.log(`Usage: + od skills list List skills. + od skills show Print one skill. + od skills tree [--json] Print skills grouped by mode and scenario.`); + process.exit(args.length === 0 ? 2 : 0); + } + return runLibraryList('skills', args); +} + +async function runSkillsTree(args) { + if (args[0] === 'help' || args.includes('--help') || args.includes('-h')) { + console.log(`Usage: + od skills tree [--json] Print skills grouped by mode and scenario.`); + process.exit(0); + } + const flags = parseFlags(args, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); + const resp = await fetch(`${base}/api/skills`); + if (!resp.ok) return structuredHttpFailure(resp); + const data = await resp.json().catch(() => null); + let skills; + try { + skills = data && Array.isArray(data.skills) ? parseSkillSummaries(data.skills) : null; + } catch { + skills = null; + } + if (!skills) { + return exitWithStructuredError({ + code: 'daemon-protocol-error', + message: 'Malformed /api/skills response: expected { skills: SkillSummary[] }', + data: { endpoint: '/api/skills' }, + }); + } + const tree = buildSkillCatalogTree(skills); + if (flags.json) return process.stdout.write(JSON.stringify(tree, null, 2) + '\n'); + console.log(`Skills tree (${tree.total})`); + for (const mode of tree.modes) { + console.log(`${mode.label} (${mode.count})`); + for (const scenario of mode.scenarios) { + console.log(` ${scenario.label} (${scenario.count})`); + for (const skill of scenario.skills) { + const metadata = [ + skill.platform, + skill.previewType, + skill.designSystemRequired ? 'design system' : null, + ].filter(Boolean).join(' · '); + console.log(` - ${skill.id}${metadata ? ` [${metadata}]` : ''}`); + } + } + } +} async function runCraft(args) { return runLibraryList('craft', args); } async function runDesignSystems(args) { diff --git a/apps/daemon/tests/skills-cli.test.ts b/apps/daemon/tests/skills-cli.test.ts new file mode 100644 index 000000000..0e72ba4ae --- /dev/null +++ b/apps/daemon/tests/skills-cli.test.ts @@ -0,0 +1,210 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); +const daemonRoot = fileURLToPath(new URL('..', import.meta.url)); +const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url)); + +async function withSkillsServer( + handler: (req: IncomingMessage, res: ServerResponse) => void, + run: (baseUrl: string) => Promise, +): Promise { + const server = createServer(handler); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('test server did not bind to a TCP port'); + } + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe('od skills CLI', () => { + it('prints the skills tree for the plain command', async () => { + await withSkillsServer( + (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + skills: [ + { + id: 'dashboard', + name: 'Dashboard', + description: 'A dashboard skill.', + triggers: [], + mode: 'prototype', + scenario: 'operation', + platform: 'desktop', + previewType: 'html', + designSystemRequired: true, + defaultFor: [], + upstream: null, + hasBody: true, + examplePrompt: 'Build a dashboard.', + aggregatesExamples: false, + }, + ], + })); + }, + async (baseUrl) => { + const result = await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'skills', + 'tree', + '--daemon-url', + baseUrl, + ], + { + cwd: daemonRoot, + env: process.env, + }, + ); + + expect(result.stdout).toContain('Skills tree (1)'); + expect(result.stdout).toContain('Prototype (1)'); + expect(result.stdout).toContain('Operation (1)'); + expect(result.stdout).toContain('- dashboard [desktop · html · design system]'); + expect(result.stderr).toBe(''); + }, + ); + }); + + it('prints skills tree help without contacting the daemon', async () => { + const result = await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'skills', + 'tree', + '--help', + '--daemon-url', + 'http://127.0.0.1:1', + ], + { + cwd: daemonRoot, + env: process.env, + }, + ); + + expect(result.stdout).toContain('Usage:'); + expect(result.stdout).toContain('od skills tree [--json]'); + expect(result.stderr).toBe(''); + }); + + it('rejects malformed skills tree responses', async () => { + await withSkillsServer( + (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }, + async (baseUrl) => { + try { + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'skills', + 'tree', + '--json', + '--daemon-url', + baseUrl, + ], + { + cwd: daemonRoot, + env: process.env, + }, + ); + throw new Error('skills tree command unexpectedly succeeded'); + } catch (error: unknown) { + const failed = error as { code?: number; stderr?: string; stdout?: string }; + expect(failed.code).toBe(74); + expect(failed.stdout ?? '').toBe(''); + const envelope = JSON.parse(failed.stderr ?? '{}') as { + error?: { code?: string; message?: string; data?: { endpoint?: string } }; + }; + expect(envelope.error?.code).toBe('daemon-protocol-error'); + expect(envelope.error?.message).toContain('Malformed /api/skills response'); + expect(envelope.error?.data?.endpoint).toBe('/api/skills'); + } + }, + ); + }); + + it('rejects malformed skill elements in skills tree responses', async () => { + await withSkillsServer( + (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + skills: [ + { + id: 'dashboard', + name: 'Dashboard', + description: 'A dashboard skill.', + triggers: [], + mode: 'prototype', + scenario: 'operation', + platform: 'desktop', + previewType: 'html', + designSystemRequired: true, + defaultFor: [], + upstream: null, + hasBody: true, + examplePrompt: 'Build a dashboard.', + aggregatesExamples: false, + }, + { id: 'broken', mode: 'prototype' }, + ], + })); + }, + async (baseUrl) => { + try { + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'skills', + 'tree', + '--json', + '--daemon-url', + baseUrl, + ], + { + cwd: daemonRoot, + env: process.env, + }, + ); + throw new Error('skills tree command unexpectedly succeeded'); + } catch (error: unknown) { + const failed = error as { code?: number; stderr?: string; stdout?: string }; + expect(failed.code).toBe(74); + expect(failed.stdout ?? '').toBe(''); + const envelope = JSON.parse(failed.stderr ?? '{}') as { + error?: { code?: string; message?: string; data?: { endpoint?: string } }; + }; + expect(envelope.error?.code).toBe('daemon-protocol-error'); + expect(envelope.error?.message).toContain('Malformed /api/skills response'); + expect(envelope.error?.data?.endpoint).toBe('/api/skills'); + } + }, + ); + }); +}); diff --git a/apps/web/src/components/IntegrationsView.tsx b/apps/web/src/components/IntegrationsView.tsx index 73b572361..de645fd9b 100644 --- a/apps/web/src/components/IntegrationsView.tsx +++ b/apps/web/src/components/IntegrationsView.tsx @@ -1,9 +1,13 @@ -import { useEffect, useRef, useState } from 'react'; -import type { AppConfig } from '../types'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { AppConfig, SkillSummary } from '../types'; +import { + buildSkillCatalogTree, + type SkillCatalogTree, + type SkillCatalogTreeSkill, +} from '@open-design/contracts'; import { useAnalytics } from '../analytics/provider'; import { trackIntegrationsConnectorsTabClick, - trackIntegrationsSkillsTabClick, trackIntegrationsTabClick, trackPageView, trackSettingsConnectorAuthResult, @@ -12,7 +16,13 @@ import { ConnectorSection } from './SettingsDialog'; import { Icon } from './Icon'; import { McpClientSection } from './McpClientSection'; import { UseEverywhereGuidePanel } from './UseEverywhereModal'; -import { useT } from '../i18n'; +import { fetchSkills } from '../providers/registry'; +import { useI18n, useT } from '../i18n'; +import { + localizeSkillDescription, + localizeSkillName, + localizeSkillPrompt, +} from '../i18n/content'; export type IntegrationTab = 'mcp' | 'connectors' | 'skills' | 'use-everywhere'; @@ -148,7 +158,7 @@ export function IntegrationsView({ /> ) : null} - {activeTab === 'skills' ? : null} + {activeTab === 'skills' ? : null} {activeTab === 'use-everywhere' ? (
@@ -163,35 +173,783 @@ export function IntegrationsView({ ); } -function SkillsComingSoonPanel() { +function SkillsCatalogTreePanel() { const t = useT(); - const analytics = useAnalytics(); + const { locale } = useI18n(); + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [search, setSearch] = useState(''); + const [viewMode, setViewMode] = useState('list'); + const [filters, setFilters] = useState(DEFAULT_SKILL_CATALOG_FILTERS); + const [selectedSkillId, setSelectedSkillId] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setLoadError(false); + void fetchSkills({ throwOnError: true }) + .then((list) => { + if (cancelled) return; + setSkills(list); + }) + .catch(() => { + if (cancelled) return; + setSkills([]); + setLoadError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const filteredSkills = useMemo(() => { + const query = search.trim().toLowerCase(); + return skills.filter((skill) => { + if (filters.mode !== 'all' && skill.mode !== filters.mode) return false; + if (filters.scenario !== 'all' && skillCatalogScenario(skill) !== filters.scenario) return false; + if (filters.category !== 'all' && (skill.category ?? '') !== filters.category) return false; + if (filters.platform !== 'all' && (skill.platform ?? '') !== filters.platform) return false; + if (filters.previewType !== 'all' && skill.previewType !== filters.previewType) return false; + if (filters.designSystem === 'required' && !skill.designSystemRequired) return false; + if (filters.designSystem === 'optional' && skill.designSystemRequired) return false; + if (!query) return true; + const name = localizeSkillName(locale, skill) || skill.name; + const description = localizeSkillDescription(locale, skill); + const haystack = [ + skill.id, + name, + description, + skill.mode, + skillCatalogScenario(skill), + skill.platform ?? '', + skill.previewType, + skill.category ?? '', + ...(skill.triggers ?? []), + ].join('\n'); + return haystack.toLowerCase().includes(query); + }); + }, [filters, locale, search, skills]); + + const tree = useMemo( + () => buildSkillCatalogTree(filteredSkills), + [filteredSkills], + ); + const filterOptions = useMemo( + () => buildSkillCatalogFilterOptions(skills), + [skills], + ); + + const selectedSkill = useMemo(() => { + if (!selectedSkillId) return null; + return filteredSkills.find((skill) => skill.id === selectedSkillId) ?? null; + }, [filteredSkills, selectedSkillId]); + + useEffect(() => { + if (selectedSkillId && filteredSkills.some((skill) => skill.id === selectedSkillId)) return; + if (selectedSkillId) setSelectedSkillId(null); + }, [filteredSkills, selectedSkillId]); + return (
- trackIntegrationsSkillsTabClick(analytics.track, { - page_name: 'integrations', - area: 'skills_tab', - element: 'coming_soon', - }) - } > - -
-

{t('tasks.comingSoon')}

-

{t('integrations.skillsTitle')}

+
+
+

{t('integrations.tabLabel.skills')}

+

{t('integrations.skillsTitle')}

+
+
+
+ + +
+ +
+
+

{t('integrations.skillsBody')}

+ {tree.total}
+ + {loading ? ( +
+ {t('integrations.skillsLoading')} +
+ ) : loadError ? ( +
+ {t('integrations.skillsLoadFailed')} +
+ ) : tree.total === 0 ? ( +
+ {t('integrations.skillsNoFilterResults')} +
+ ) : viewMode === 'list' ? ( + + ) : ( + + )}
); } +type SkillCatalogViewMode = 'tree' | 'list'; +type SkillDesignSystemFilter = 'all' | 'required' | 'optional'; + +interface SkillCatalogFilters { + mode: string; + scenario: string; + category: string; + platform: string; + previewType: string; + designSystem: SkillDesignSystemFilter; +} + +const DEFAULT_SKILL_CATALOG_FILTERS: SkillCatalogFilters = { + mode: 'all', + scenario: 'all', + category: 'all', + platform: 'all', + previewType: 'all', + designSystem: 'all', +}; + +interface SkillCatalogFilterOption { + id: string; + label: string; + count: number; +} + +interface SkillCatalogFilterOptions { + modes: SkillCatalogFilterOption[]; + scenarios: SkillCatalogFilterOption[]; + categories: SkillCatalogFilterOption[]; + platforms: SkillCatalogFilterOption[]; + previewTypes: SkillCatalogFilterOption[]; +} + +function SkillCatalogFiltersBar({ + filters, + options, + onChange, +}: { + filters: SkillCatalogFilters; + options: SkillCatalogFilterOptions; + onChange: (filters: SkillCatalogFilters) => void; +}) { + const t = useT(); + + const update = (key: K, value: SkillCatalogFilters[K]) => { + onChange({ ...filters, [key]: value }); + }; + + return ( +
+ update('mode', value)} + /> + update('scenario', value)} + /> + update('category', value)} + /> + update('platform', value)} + /> + update('previewType', value)} + /> + +
+ ); +} + +function SkillCatalogSelect({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: SkillCatalogFilterOption[]; + onChange: (value: string) => void; +}) { + const t = useT(); + + return ( + + ); +} + +function SkillListView({ + skills, + selectedSkill, + selectedSkillId, + onSelectSkill, +}: { + skills: SkillSummary[]; + selectedSkill: SkillSummary | null; + selectedSkillId: string | null; + onSelectSkill: (skillId: string) => void; +}) { + const t = useT(); + const { locale } = useI18n(); + const selectedTreeSkill = selectedSkill + ? skillSummaryToTreeSkill(selectedSkill) + : null; + + return ( +
+
+ {skills.map((skill) => { + const name = localizeSkillName(locale, skill) || skill.name || skill.id; + const description = localizeSkillDescription(locale, skill); + const isActive = skill.id === selectedSkillId; + + return ( + + ); + })} +
+ +
+ ); +} + +interface SkillTreeGraphProps { + tree: SkillCatalogTree; + selectedSkillId: string | null; + selectedSkill: SkillSummary | null; + onSelectSkill: (skillId: string) => void; +} + +function SkillTreeGraph({ + tree, + selectedSkillId, + selectedSkill, + onSelectSkill, +}: SkillTreeGraphProps) { + const t = useT(); + const { locale } = useI18n(); + const layout = useMemo( + () => buildSkillTreeSvgLayout(tree, selectedSkillId, locale, { + mode: t('integrations.skillsTreeGuideMode'), + scenario: t('integrations.skillsTreeGuideScenario'), + skill: t('integrations.skillsTreeGuideSkill'), + }), + [locale, selectedSkillId, t, tree], + ); + const selectedTreeSkill = selectedSkill + ? skillSummaryToTreeSkill(selectedSkill) + : null; + + return ( +
+
+ + + + + + + + + + + {layout.guides.map((guide) => ( + + + {guide.label} + + ))} + {layout.edges.map((edge) => ( + + + + + ))} + {layout.nodes.map((node) => { + const interactive = Boolean(node.skillId); + return ( + onSelectSkill(node.skillId!) : undefined} + onKeyDown={interactive ? (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onSelectSkill(node.skillId!); + } : undefined} + data-testid={node.kind === 'skill' ? `integrations-skill-node-${node.skillId}` : undefined} + > + {node.title} + {node.active ? : null} + {interactive ? : null} + {interactive && !node.active ? : null} + + {interactive ? ( + + ) : ( + + )} + + {node.label} + + + {node.subLabel} + + + ); + })} + +
+ {t('integrations.skillsTreeLegendMode')} + {t('integrations.skillsTreeLegendScenario')} + {t('integrations.skillsTreeLegendSkill')} +
+
+ +
+ ); +} + +interface SkillTreeSvgNode { + id: string; + kind: 'mode' | 'scenario' | 'skill'; + x: number; + y: number; + radius: number; + label: string; + subLabel: string; + title: string; + skillId: string | null; + active: boolean; + onPath: boolean; +} + +interface SkillTreeSvgEdge { + id: string; + d: string; + x1: number; + y1: number; + x2: number; + y2: number; + active: boolean; +} + +interface SkillTreeSvgGuide { + id: string; + label: string; + y: number; +} + +interface SkillTreeSvgLayout { + width: number; + height: number; + nodes: SkillTreeSvgNode[]; + edges: SkillTreeSvgEdge[]; + guides: SkillTreeSvgGuide[]; +} + +function buildSkillTreeSvgLayout( + tree: SkillCatalogTree, + selectedSkillId: string | null, + locale: ReturnType['locale'], + guideLabels: { + mode: string; + scenario: string; + skill: string; + }, +): SkillTreeSvgLayout { + const modeGap = 330; + const modeStartX = 170; + const modeY = 74; + const scenarioStartY = 178; + const scenarioGap = 118; + const skillRowGap = 78; + const skillColOffset = 74; + const nodes: SkillTreeSvgNode[] = []; + const edges: SkillTreeSvgEdge[] = []; + let height = 620; + + tree.modes.forEach((mode, modeIndex) => { + const modeX = modeStartX + modeIndex * modeGap; + const modeContainsSelected = mode.scenarios.some((scenario) => + scenario.skills.some((skill) => skill.id === selectedSkillId), + ); + + nodes.push({ + id: `mode-${mode.id}`, + kind: 'mode', + x: modeX, + y: modeY, + radius: 34, + label: truncateSvgLabel(mode.label, 10), + subLabel: String(mode.count), + title: `${mode.label} · ${mode.count}`, + skillId: null, + active: false, + onPath: modeContainsSelected, + }); + + let cursorY = scenarioStartY; + mode.scenarios.forEach((scenario, scenarioIndex) => { + const scenarioContainsSelected = scenario.skills.some((skill) => skill.id === selectedSkillId); + nodes.push({ + id: `scenario-${mode.id}-${scenario.id}`, + kind: 'scenario', + x: modeX, + y: cursorY, + radius: 27, + label: truncateSvgLabel(scenario.label, 9), + subLabel: String(scenario.count), + title: `${mode.label} / ${scenario.label} · ${scenario.count}`, + skillId: null, + active: false, + onPath: scenarioContainsSelected, + }); + edges.push({ + id: `edge-${mode.id}-${scenario.id}`, + d: skillTreeConnectorPath(modeX, modeY + 34, modeX, cursorY - 27), + x1: modeX, + y1: modeY + 34, + x2: modeX, + y2: cursorY - 27, + active: scenarioContainsSelected, + }); + + scenario.skills.forEach((skill, skillIndex) => { + const row = Math.floor(skillIndex / 2); + const side = skillIndex % 2 === 0 ? -1 : 1; + const skillX = modeX + side * skillColOffset; + const skillY = cursorY + 72 + row * skillRowGap; + const name = localizeSkillName(locale, skill.skill) || skill.id; + const active = skill.id === selectedSkillId; + nodes.push({ + id: `skill-${skill.id}`, + kind: 'skill', + x: skillX, + y: skillY, + radius: 27, + label: truncateSvgLabel(name, 8), + subLabel: skill.platform ?? skill.previewType, + title: `${name} · ${skill.platform ?? skill.previewType}`, + skillId: skill.id, + active, + onPath: active, + }); + edges.push({ + id: `edge-${scenario.id}-${skill.id}`, + d: skillTreeLeafConnectorPath(modeX, cursorY + 27, skillX, skillY, 27), + x1: modeX, + y1: cursorY + 27, + x2: skillX, + y2: skillY, + active, + }); + }); + + const rows = Math.max(1, Math.ceil(scenario.skills.length / 2)); + cursorY += 72 + rows * skillRowGap + scenarioGap; + }); + height = Math.max(height, cursorY + 40); + }); + + const width = Math.max(720, modeStartX + Math.max(0, tree.modes.length - 1) * modeGap + 190); + + return { + width, + height, + nodes, + edges, + guides: [ + { id: 'mode', label: guideLabels.mode, y: modeY }, + { id: 'scenario', label: guideLabels.scenario, y: scenarioStartY }, + { id: 'skill', label: guideLabels.skill, y: scenarioStartY + 72 }, + ], + }; +} + +function truncateSvgLabel(value: string, max: number): string { + return value.length > max ? `${value.slice(0, Math.max(1, max - 1))}…` : value; +} + +function skillTreeConnectorPath( + x1: number, + y1: number, + x2: number, + y2: number, +): string { + if (Math.abs(x2 - x1) < 1) { + return `M ${x1} ${y1} V ${y2}`; + } + + const direction = x2 > x1 ? 1 : -1; + const distanceX = Math.abs(x2 - x1); + const distanceY = Math.abs(y2 - y1); + const corner = Math.min(18, distanceX / 2, distanceY / 2); + const busY = y1 + Math.max(28, Math.min(64, distanceY * 0.48)); + const c1x = x1 + direction * corner; + const c2x = x2 - direction * corner; + const c2y = y2 - corner; + + return [ + `M ${x1} ${y1}`, + `V ${busY - corner}`, + `Q ${x1} ${busY} ${c1x} ${busY}`, + `H ${c2x}`, + `Q ${x2} ${busY} ${x2} ${busY + corner}`, + `V ${c2y}`, + `Q ${x2} ${y2} ${x2} ${y2}`, + ].join(' '); +} + +function skillTreeLeafConnectorPath( + x1: number, + y1: number, + x2: number, + y2: number, + targetRadius: number, +): string { + if (Math.abs(x2 - x1) < 1) { + return `M ${x1} ${y1} V ${y2 - targetRadius}`; + } + + const direction = x2 > x1 ? 1 : -1; + const endX = x2 - direction * targetRadius; + const distanceX = Math.abs(endX - x1); + const distanceY = Math.abs(y2 - y1); + const corner = Math.min(16, distanceX / 2, distanceY / 2); + + return [ + `M ${x1} ${y1}`, + `V ${y2 - corner}`, + `Q ${x1} ${y2} ${x1 + direction * corner} ${y2}`, + `H ${endX}`, + ].join(' '); +} + +function SkillTreeInfoPanel({ + skill, + emptyLabel, +}: { + skill: SkillCatalogTreeSkill | null; + emptyLabel: string; +}) { + const t = useT(); + const { locale } = useI18n(); + + if (!skill) { + return ( + + ); + } + + const name = localizeSkillName(locale, skill.skill) || skill.id; + const description = localizeSkillDescription(locale, skill.skill); + const prompt = localizeSkillPrompt(locale, skill.skill) || skill.examplePrompt; + + return ( + + ); +} + +function skillSummaryToTreeSkill(skill: SkillSummary): SkillCatalogTreeSkill { + return buildSkillCatalogTree([skill]).modes[0]!.scenarios[0]!.skills[0]!; +} + +function skillCatalogScenario(skill: SkillSummary): string { + return skill.scenario?.trim() || 'general'; +} + +function buildSkillCatalogFilterOptions(skills: SkillSummary[]): SkillCatalogFilterOptions { + return { + modes: buildSkillCatalogFacetOptions(skills.map((skill) => skill.mode)), + scenarios: buildSkillCatalogFacetOptions(skills.map((skill) => skillCatalogScenario(skill))), + categories: buildSkillCatalogFacetOptions(skills.map((skill) => skill.category)), + platforms: buildSkillCatalogFacetOptions(skills.map((skill) => skill.platform)), + previewTypes: buildSkillCatalogFacetOptions(skills.map((skill) => skill.previewType)), + }; +} + +function buildSkillCatalogFacetOptions(values: Array): SkillCatalogFilterOption[] { + const counts = new Map(); + values.forEach((value) => { + const id = value?.trim(); + if (!id) return; + counts.set(id, (counts.get(id) ?? 0) + 1); + }); + + return Array.from(counts, ([id, count]) => ({ + id, + label: labelSkillCatalogFacet(id), + count, + })).sort((left, right) => { + if (left.id === 'general') return -1; + if (right.id === 'general') return 1; + return left.label.localeCompare(right.label, undefined, { sensitivity: 'base' }); + }); +} + +function labelSkillCatalogFacet(value: string): string { + return value + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + function integrationTabLabel(id: IntegrationTab, t: ReturnType): string { switch (id) { case 'mcp': return t('integrations.tabLabel.mcp'); @@ -205,7 +963,7 @@ function integrationTabHint(id: IntegrationTab, t: ReturnType): str switch (id) { case 'mcp': return t('integrations.tabHint.mcp'); case 'connectors': return t('integrations.tabHint.connectors'); - case 'skills': return t('tasks.comingSoon'); + case 'skills': return t('integrations.skillsTreeView'); case 'use-everywhere': return t('integrations.tabHint.useEverywhere'); } } diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index f2cd3cd78..024322076 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -684,8 +684,39 @@ export const en: Dict = { 'integrations.tabHint.mcp': 'External tools', 'integrations.tabHint.connectors': 'Accounts and APIs', 'integrations.tabHint.useEverywhere': 'CLI, HTTP, MCP', - 'integrations.skillsTitle': 'Skills integrations', - 'integrations.skillsBody': 'Skill-level integration management is being carried over from another branch. This tab is reserved so MCP, Connectors, and future Skills setup live in the same Integration route.', + 'integrations.skillsTitle': 'Skill tree', + 'integrations.skillsBody': 'Browse the installed Open Design skills by mode and scenario. Select any node to inspect its platform, preview type, design-system requirement, and example prompt.', + 'integrations.skillsViewMode': 'Skills catalog view', + 'integrations.skillsTreeView': 'Skill tree', + 'integrations.skillsListView': 'List view', + 'integrations.skillsSearch': 'Search skills...', + 'integrations.skillsFilters': 'Skill filters', + 'integrations.skillsFilterMode': 'Mode', + 'integrations.skillsFilterScenario': 'Scenario', + 'integrations.skillsFilterCategory': 'Category', + 'integrations.skillsFilterPlatform': 'Platform', + 'integrations.skillsFilterPreview': 'Preview', + 'integrations.skillsFilterDesignSystem': 'Design system', + 'integrations.skillsFilterAll': 'All', + 'integrations.skillsLoading': 'Loading skills...', + 'integrations.skillsLoadFailed': 'Could not load skills. Make sure the local daemon is running, then try again.', + 'integrations.skillsNoResults': 'No skills match your search.', + 'integrations.skillsNoFilterResults': 'No skills match these filters.', + 'integrations.skillsListEmpty': 'Select a skill row to inspect it.', + 'integrations.skillsTreeEmpty': 'Select a skill node to inspect it.', + 'integrations.skillsTreeGuideMode': 'MODE', + 'integrations.skillsTreeGuideScenario': 'SCENE', + 'integrations.skillsTreeGuideSkill': 'SKILL', + 'integrations.skillsTreeLegendMode': 'Mode', + 'integrations.skillsTreeLegendScenario': 'Scenario', + 'integrations.skillsTreeLegendSkill': 'Skill', + 'integrations.skillsTreePlatform': 'Platform', + 'integrations.skillsTreePreviewType': 'Preview', + 'integrations.skillsTreeDesignSystem': 'Design system', + 'integrations.skillsTreeDesignSystemRequired': 'Required', + 'integrations.skillsTreeDesignSystemOptional': 'Optional', + 'integrations.skillsTreeSource': 'Source', + 'integrations.skillsTreeExamplePrompt': 'Example prompt', 'mcpClient.title': 'External MCP servers', 'mcpClient.subtitle': 'Third-party tools for your coding agent.', 'mcpClient.addServer': 'Add server', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 115f85b82..ad0d5c861 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -684,8 +684,39 @@ export const zhCN: Dict = { 'integrations.tabHint.mcp': '外部工具', 'integrations.tabHint.connectors': '账号和 API', 'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP', - 'integrations.skillsTitle': '技能集成', - 'integrations.skillsBody': '技能级集成管理正在从另一个分支迁移过来。此标签页预留给 MCP、连接器和未来技能设置,使它们位于同一个集成路由中。', + 'integrations.skillsTitle': '技能树', + 'integrations.skillsBody': '按模式和场景浏览已安装的 Open Design 技能。选择任意节点即可查看平台、预览类型、设计系统要求和示例提示词。', + 'integrations.skillsViewMode': '技能目录视图', + 'integrations.skillsTreeView': '技能树', + 'integrations.skillsListView': '列表视图', + 'integrations.skillsSearch': '搜索技能...', + 'integrations.skillsFilters': '技能筛选', + 'integrations.skillsFilterMode': '模式', + 'integrations.skillsFilterScenario': '场景', + 'integrations.skillsFilterCategory': '分类', + 'integrations.skillsFilterPlatform': '平台', + 'integrations.skillsFilterPreview': '预览', + 'integrations.skillsFilterDesignSystem': '设计系统', + 'integrations.skillsFilterAll': '全部', + 'integrations.skillsLoading': '正在加载技能...', + 'integrations.skillsLoadFailed': '无法加载技能。请确认本地 daemon 正在运行,然后重试。', + 'integrations.skillsNoResults': '没有匹配的技能。', + 'integrations.skillsNoFilterResults': '没有匹配这些筛选条件的技能。', + 'integrations.skillsListEmpty': '选择一个技能行查看详情。', + 'integrations.skillsTreeEmpty': '选择一个技能节点查看详情。', + 'integrations.skillsTreeGuideMode': '模式', + 'integrations.skillsTreeGuideScenario': '场景', + 'integrations.skillsTreeGuideSkill': '技能', + 'integrations.skillsTreeLegendMode': '模式', + 'integrations.skillsTreeLegendScenario': '场景', + 'integrations.skillsTreeLegendSkill': '技能', + 'integrations.skillsTreePlatform': '平台', + 'integrations.skillsTreePreviewType': '预览', + 'integrations.skillsTreeDesignSystem': '设计系统', + 'integrations.skillsTreeDesignSystemRequired': '需要', + 'integrations.skillsTreeDesignSystemOptional': '可选', + 'integrations.skillsTreeSource': '来源', + 'integrations.skillsTreeExamplePrompt': '示例提示词', 'mcpClient.title': '外部 MCP 服务器', 'mcpClient.subtitle': '供编码智能体使用的第三方工具。', 'mcpClient.addServer': '添加服务器', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 644391e94..c7c7586c0 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -610,8 +610,39 @@ export const zhTW: Dict = { 'integrations.tabHint.mcp': '外部工具', 'integrations.tabHint.connectors': '帳號與 API', 'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP', - 'integrations.skillsTitle': '技能整合', - 'integrations.skillsBody': '技能層級的整合管理正從另一個分支搬移過來。此分頁預留給 MCP、連接器與未來的技能設定,讓它們集中在同一個整合路由中。', + 'integrations.skillsTitle': '技能樹', + 'integrations.skillsBody': '依模式與場景瀏覽已安裝的 Open Design 技能。選取任一節點即可查看平台、預覽類型、設計系統需求與範例提示詞。', + 'integrations.skillsViewMode': '技能目錄視圖', + 'integrations.skillsTreeView': '技能樹', + 'integrations.skillsListView': '列表視圖', + 'integrations.skillsSearch': '搜尋技能...', + 'integrations.skillsFilters': '技能篩選', + 'integrations.skillsFilterMode': '模式', + 'integrations.skillsFilterScenario': '場景', + 'integrations.skillsFilterCategory': '分類', + 'integrations.skillsFilterPlatform': '平台', + 'integrations.skillsFilterPreview': '預覽', + 'integrations.skillsFilterDesignSystem': '設計系統', + 'integrations.skillsFilterAll': '全部', + 'integrations.skillsLoading': '正在載入技能...', + 'integrations.skillsLoadFailed': '無法載入技能。請確認本機 daemon 正在執行,然後再試一次。', + 'integrations.skillsNoResults': '沒有符合的技能。', + 'integrations.skillsNoFilterResults': '沒有符合這些篩選條件的技能。', + 'integrations.skillsListEmpty': '選取一個技能列以查看詳細資訊。', + 'integrations.skillsTreeEmpty': '選取一個技能節點以查看詳細資訊。', + 'integrations.skillsTreeGuideMode': '模式', + 'integrations.skillsTreeGuideScenario': '場景', + 'integrations.skillsTreeGuideSkill': '技能', + 'integrations.skillsTreeLegendMode': '模式', + 'integrations.skillsTreeLegendScenario': '場景', + 'integrations.skillsTreeLegendSkill': '技能', + 'integrations.skillsTreePlatform': '平台', + 'integrations.skillsTreePreviewType': '預覽', + 'integrations.skillsTreeDesignSystem': '設計系統', + 'integrations.skillsTreeDesignSystemRequired': '需要', + 'integrations.skillsTreeDesignSystemOptional': '可選', + 'integrations.skillsTreeSource': '來源', + 'integrations.skillsTreeExamplePrompt': '範例提示詞', 'mcpClient.title': '外部 MCP 伺服器', 'mcpClient.subtitle': '提供給編碼智能體使用的第三方工具。', 'mcpClient.addServer': '新增伺服器', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 967c33ef3..2e3867d8f 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -998,6 +998,37 @@ export interface Dict { 'integrations.tabHint.useEverywhere': string; 'integrations.skillsTitle': string; 'integrations.skillsBody': string; + 'integrations.skillsViewMode': string; + 'integrations.skillsTreeView': string; + 'integrations.skillsListView': string; + 'integrations.skillsSearch': string; + 'integrations.skillsFilters': string; + 'integrations.skillsFilterMode': string; + 'integrations.skillsFilterScenario': string; + 'integrations.skillsFilterCategory': string; + 'integrations.skillsFilterPlatform': string; + 'integrations.skillsFilterPreview': string; + 'integrations.skillsFilterDesignSystem': string; + 'integrations.skillsFilterAll': string; + 'integrations.skillsLoading': string; + 'integrations.skillsLoadFailed': string; + 'integrations.skillsNoResults': string; + 'integrations.skillsNoFilterResults': string; + 'integrations.skillsListEmpty': string; + 'integrations.skillsTreeEmpty': string; + 'integrations.skillsTreeGuideMode': string; + 'integrations.skillsTreeGuideScenario': string; + 'integrations.skillsTreeGuideSkill': string; + 'integrations.skillsTreeLegendMode': string; + 'integrations.skillsTreeLegendScenario': string; + 'integrations.skillsTreeLegendSkill': string; + 'integrations.skillsTreePlatform': string; + 'integrations.skillsTreePreviewType': string; + 'integrations.skillsTreeDesignSystem': string; + 'integrations.skillsTreeDesignSystemRequired': string; + 'integrations.skillsTreeDesignSystemOptional': string; + 'integrations.skillsTreeSource': string; + 'integrations.skillsTreeExamplePrompt': string; 'mcpClient.title': string; 'mcpClient.subtitle': string; 'mcpClient.addServer': string; diff --git a/apps/web/src/providers/registry.ts b/apps/web/src/providers/registry.ts index cea06d135..d87d8dc49 100644 --- a/apps/web/src/providers/registry.ts +++ b/apps/web/src/providers/registry.ts @@ -13,6 +13,7 @@ import type { ImportLocalDesignSystemResponse, ReplaceProjectWorkingDirResponse, } from '@open-design/contracts'; +import { parseSkillSummaries } from '@open-design/contracts'; import type { AgentInfo, AppVersionInfo, @@ -100,13 +101,21 @@ export async function fetchAgents(options?: { throwOnError?: boolean }): Promise } } -export async function fetchSkills(): Promise { +export async function fetchSkills(options?: { throwOnError?: boolean }): Promise { try { const resp = await fetch('/api/skills'); - if (!resp.ok) return []; - const json = (await resp.json()) as { skills: SkillSummary[] }; - return json.skills ?? []; - } catch { + if (!resp.ok) { + if (options?.throwOnError) throw new Error(`skills ${resp.status}`); + return []; + } + const json = await resp.json() as { skills?: unknown }; + if (!Array.isArray(json.skills)) { + if (options?.throwOnError) throw new Error('skills response malformed'); + return []; + } + return parseSkillSummaries(json.skills); + } catch (err) { + if (options?.throwOnError) throw err; return []; } } diff --git a/apps/web/src/styles/home/integrations.css b/apps/web/src/styles/home/integrations.css index 1f1339fb4..d4eca80e0 100644 --- a/apps/web/src/styles/home/integrations.css +++ b/apps/web/src/styles/home/integrations.css @@ -186,6 +186,588 @@ color: var(--text-muted); } +.integrations-skills-tree { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-panel); + box-shadow: var(--shadow-xs); +} + +.integrations-skills-tree__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.integrations-skills-tree__head h2 { + margin: 0; + font-size: 18px; + color: var(--text-strong); +} + +.integrations-skills-tree__tools { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + min-width: min(480px, 100%); +} + +.integrations-skills-tree__view-toggle { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 3px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); +} + +.integrations-skills-tree__view-toggle button { + appearance: none; + min-height: 28px; + padding: 0 10px; + border: 0; + border-radius: calc(var(--radius-sm) - 3px); + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 12px; + font-weight: 650; + cursor: pointer; +} + +.integrations-skills-tree__view-toggle button:hover { + color: var(--text); +} + +.integrations-skills-tree__view-toggle button.is-active { + background: var(--accent-tint); + color: var(--accent-strong, var(--accent)); +} + +.integrations-skills-tree__search { + display: inline-flex; + align-items: center; + gap: 8px; + width: min(300px, 100%); + min-height: 36px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-muted); +} + +.integrations-skills-tree__search input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font: inherit; +} + +.integrations-skills-tree__filters { + display: grid; + grid-template-columns: repeat(6, minmax(120px, 1fr)); + gap: 8px; + align-items: end; +} + +.integrations-skills-tree__filter { + display: grid; + gap: 5px; + min-width: 0; +} + +.integrations-skills-tree__filter span { + color: var(--text-muted); + font-size: 11px; + font-weight: 650; +} + +.integrations-skills-tree__filter select { + width: 100%; + min-height: 34px; + min-width: 0; + padding: 0 9px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 12px; +} + +.integrations-skills-tree__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} + +.integrations-skills-tree__summary p { + margin: 0; + max-width: 760px; + font-size: 13px; + line-height: 1.55; + color: var(--text-muted); +} + +.integrations-skills-tree__summary span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 26px; + padding: 0 9px; + border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border)); + border-radius: 999px; + color: var(--accent-strong, var(--accent)); + font-size: 12px; + font-weight: 700; +} + +.integrations-skills-tree__empty { + padding: 24px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + text-align: center; +} + +.integrations-skills-tree__grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); + gap: 14px; + align-items: start; +} + +.integrations-skills-tree__canvas { + overflow: auto; + min-height: 640px; + min-width: 0; + position: relative; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 78%, transparent), transparent 42%), + var(--bg); +} + +.integrations-skills-tree__list { + display: grid; + gap: 8px; + overflow: auto; + max-height: 640px; + min-height: 420px; + min-width: 0; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); +} + +.integrations-skills-tree__list-row { + appearance: none; + display: grid; + gap: 6px; + width: 100%; + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel); + color: var(--text); + cursor: pointer; + text-align: left; + transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease; +} + +.integrations-skills-tree__list-row:hover, +.integrations-skills-tree__list-row:focus-visible { + border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); + background: color-mix(in srgb, var(--accent-tint) 24%, var(--bg-panel)); + outline: none; +} + +.integrations-skills-tree__list-row.is-active { + border-color: color-mix(in srgb, var(--accent) 56%, var(--border)); + background: color-mix(in srgb, var(--accent-tint) 42%, var(--bg-panel)); +} + +.integrations-skills-tree__list-title { + overflow: hidden; + color: var(--text-strong); + font-size: 13px; + font-weight: 760; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.integrations-skills-tree__list-description { + display: -webkit-box; + overflow: hidden; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.integrations-skills-tree__list-meta { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.integrations-skills-tree__list-meta span { + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: 22px; + padding: 0 7px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-muted); + font-size: 11px; + line-height: 1; +} + +.integrations-skills-tree__svg { + display: block; + width: 100%; +} + +.integrations-skills-tree__guide line { + stroke: color-mix(in srgb, var(--border) 78%, transparent); + stroke-dasharray: 3 8; + stroke-width: 1; +} + +.integrations-skills-tree__guide text { + fill: color-mix(in srgb, var(--text-muted) 42%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0; + writing-mode: vertical-rl; +} + +.integrations-skills-tree__edge path { + fill: none; + pointer-events: none; + stroke-linecap: round; + stroke-linejoin: round; +} + +.integrations-skills-tree__edge-base { + opacity: 0.72; + stroke: color-mix(in srgb, var(--text-muted) 34%, var(--border)); + stroke-width: 2.1; +} + +.integrations-skills-tree__edge-aura { + opacity: 0; + stroke: color-mix(in srgb, var(--accent) 28%, transparent); + stroke-width: 7; +} + +.integrations-skills-tree__edge.is-active .integrations-skills-tree__edge-aura { + opacity: 0.28; +} + +.integrations-skills-tree__edge.is-active .integrations-skills-tree__edge-base { + opacity: 1; + stroke: var(--accent); + stroke-width: 2.8; +} + +.integrations-skills-tree__svg-node { + cursor: default; + outline: none; +} + +.integrations-skills-tree__svg-node[role="button"] { + cursor: pointer; +} + +.integrations-skills-tree__svg-node.is-branch { + opacity: 0.86; +} + +.integrations-skills-tree__svg-core { + fill: color-mix(in srgb, var(--bg-panel) 86%, black); + stroke: color-mix(in srgb, var(--accent) 34%, var(--border)); + stroke-width: 1.4; + transition: fill 160ms ease, stroke 160ms ease, stroke-width 160ms ease; +} + +.integrations-skills-tree__svg-node.is-mode .integrations-skills-tree__svg-core { + fill: color-mix(in srgb, var(--bg-panel) 82%, var(--border)); + stroke: color-mix(in srgb, var(--text-muted) 30%, var(--border)); +} + +.integrations-skills-tree__svg-node.is-scenario .integrations-skills-tree__svg-core { + fill: color-mix(in srgb, var(--bg-panel) 76%, var(--border)); + stroke: color-mix(in srgb, var(--text-muted) 28%, var(--border)); +} + +.integrations-skills-tree__svg-node.is-interactive.is-active .integrations-skills-tree__svg-core, +.integrations-skills-tree__svg-node.is-interactive[role="button"]:hover .integrations-skills-tree__svg-core, +.integrations-skills-tree__svg-node.is-interactive[role="button"]:focus-visible .integrations-skills-tree__svg-core { + fill: color-mix(in srgb, var(--accent-tint) 68%, var(--bg)); + stroke: var(--accent); + stroke-width: 2.3; +} + +.integrations-skills-tree__svg-node.is-branch.is-on-path .integrations-skills-tree__svg-core { + stroke: color-mix(in srgb, var(--accent) 42%, var(--border)); + stroke-width: 1.7; +} + +.integrations-skills-tree__svg-node.is-branch .integrations-skills-tree__svg-label { + fill: color-mix(in srgb, var(--text-strong) 86%, var(--text-muted)); +} + +.integrations-skills-tree__svg-ring, +.integrations-skills-tree__svg-branch-ring, +.integrations-skills-tree__svg-affordance, +.integrations-skills-tree__svg-pulse { + fill: none; + stroke: color-mix(in srgb, var(--accent) 22%, transparent); + stroke-width: 1; +} + +.integrations-skills-tree__svg-affordance { + opacity: 0.62; + stroke: color-mix(in srgb, var(--accent) 28%, transparent); + stroke-width: 1.4; +} + +.integrations-skills-tree__svg-node.is-interactive[role="button"]:hover .integrations-skills-tree__svg-affordance, +.integrations-skills-tree__svg-node.is-interactive[role="button"]:focus-visible .integrations-skills-tree__svg-affordance, +.integrations-skills-tree__svg-node.is-interactive.is-active .integrations-skills-tree__svg-affordance { + opacity: 1; + stroke: color-mix(in srgb, var(--accent) 62%, transparent); + stroke-width: 2; +} + +.integrations-skills-tree__svg-branch-ring { + stroke: color-mix(in srgb, var(--text-muted) 22%, transparent); + stroke-dasharray: 2 5; +} + +.integrations-skills-tree__svg-pulse { + opacity: 0.42; +} + +.integrations-skills-tree__svg-pulse { + animation: integrations-skill-pulse 2.8s ease-in-out infinite; +} + +.integrations-skills-tree__svg-glow { + opacity: 0.36; + fill: color-mix(in srgb, var(--accent) 42%, transparent); + filter: url("#integrations-skill-node-glow"); + animation: integrations-skill-glow 2s ease-in-out infinite; +} + +.integrations-skills-tree__svg-label, +.integrations-skills-tree__svg-sub { + text-anchor: middle; + dominant-baseline: middle; + pointer-events: none; +} + +.integrations-skills-tree__svg-label { + fill: var(--text-strong); + font-size: 10px; + font-weight: 760; +} + +.integrations-skills-tree__svg-node.is-mode .integrations-skills-tree__svg-label { + font-size: 12px; + font-weight: 820; +} + +.integrations-skills-tree__svg-node.is-scenario .integrations-skills-tree__svg-label { + font-size: 10.5px; + font-weight: 720; +} + +.integrations-skills-tree__svg-node.is-skill .integrations-skills-tree__svg-label { + font-size: 10px; + font-weight: 760; +} + +.integrations-skills-tree__svg-sub { + fill: var(--text-muted); + font-size: 9.5px; +} + +.integrations-skills-tree__svg-node.is-mode .integrations-skills-tree__svg-sub { + font-size: 10px; + font-weight: 680; +} + +.integrations-skills-tree__svg-node.is-scenario .integrations-skills-tree__svg-sub, +.integrations-skills-tree__svg-node.is-skill .integrations-skills-tree__svg-sub { + font-size: 9px; +} + +.integrations-skills-tree__legend { + position: sticky; + bottom: 0; + display: flex; + flex-wrap: wrap; + gap: 18px; + padding: 10px 14px; + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 92%, transparent); + color: var(--text-muted); + font-size: 11px; + font-weight: 650; +} + +.integrations-skills-tree__legend span { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.integrations-skills-tree__legend i { + width: 10px; + height: 10px; + border: 1px solid var(--accent); + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 16%, transparent); +} + +.integrations-skills-tree__legend .is-mode { + width: 13px; + height: 13px; +} + +.integrations-skills-tree__legend .is-scenario { + width: 11px; + height: 11px; +} + +@keyframes integrations-skill-pulse { + 0%, 100% { + opacity: 0.42; + transform: scale(0.92); + } + 50% { + opacity: 0; + transform: scale(1.2); + } +} + +@keyframes integrations-skill-glow { + 0%, 100% { + opacity: 0.34; + transform: scale(0.94); + } + 50% { + opacity: 0.12; + transform: scale(1.1); + } +} + +.integrations-skills-tree__detail { + position: sticky; + top: 12px; + min-width: 0; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); +} + +.integrations-skills-tree__detail.is-empty { + color: var(--text-muted); +} + +.integrations-skills-tree__detail-kicker { + margin: 0 0 6px; + color: var(--accent-strong, var(--accent)); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.integrations-skills-tree__detail h3 { + margin: 0; + font-size: 17px; + color: var(--text-strong); +} + +.integrations-skills-tree__detail > p:not(.integrations-skills-tree__detail-kicker) { + margin: 8px 0 0; + color: var(--text-muted); + font-size: 13px; + line-height: 1.55; +} + +.integrations-skills-tree__detail dl { + display: grid; + gap: 8px; + margin: 14px 0 0; +} + +.integrations-skills-tree__detail dl div { + display: flex; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.integrations-skills-tree__detail dt { + color: var(--text-muted); + font-size: 11px; +} + +.integrations-skills-tree__detail dd { + margin: 0; + color: var(--text); + font-size: 11px; + text-align: right; +} + +.integrations-skills-tree__prompt { + margin-top: 14px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel); +} + +.integrations-skills-tree__prompt span { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} + +.integrations-skills-tree__prompt p { + margin: 6px 0 0; + color: var(--text); + font-size: 12px; + line-height: 1.5; +} + @media (max-width: 760px) { .integrations-view__hero { flex-direction: column; @@ -198,4 +780,42 @@ .integrations-view__coming-soon { grid-template-columns: 1fr; } + + .integrations-skills-tree__head, + .integrations-skills-tree__summary { + align-items: stretch; + flex-direction: column; + } + + .integrations-skills-tree__tools { + justify-content: stretch; + min-width: 0; + } + + .integrations-skills-tree__view-toggle, + .integrations-skills-tree__search { + width: 100%; + } + + .integrations-skills-tree__view-toggle button { + flex: 1; + } + + .integrations-skills-tree__filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .integrations-skills-tree__grid { + grid-template-columns: 1fr; + } + + .integrations-skills-tree__detail { + position: static; + } +} + +@media (max-width: 520px) { + .integrations-skills-tree__filters { + grid-template-columns: 1fr; + } } diff --git a/apps/web/tests/components/IntegrationsView.skills.test.tsx b/apps/web/tests/components/IntegrationsView.skills.test.tsx new file mode 100644 index 000000000..cac45720f --- /dev/null +++ b/apps/web/tests/components/IntegrationsView.skills.test.tsx @@ -0,0 +1,287 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { IntegrationsView } from '../../src/components/IntegrationsView'; +import { I18nProvider, type Locale } from '../../src/i18n'; +import type { AppConfig, SkillSummary } from '../../src/types'; + +const originalFetch = globalThis.fetch; + +const TEST_CONFIG: AppConfig = { + mode: 'daemon', + apiKey: '', + baseUrl: '', + model: '', + agentId: null, + skillId: null, + designSystemId: null, +}; + +function skill(overrides: Partial): SkillSummary { + return { + id: 'skill', + name: 'Skill', + description: 'A reusable skill.', + triggers: [], + mode: 'prototype', + previewType: 'html', + designSystemRequired: true, + defaultFor: [], + upstream: null, + hasBody: true, + examplePrompt: '', + aggregatesExamples: false, + source: 'built-in', + ...overrides, + }; +} + +function renderSkillsIntegration( + skills: SkillSummary[], + options: { skillsStatus?: number; skillsBody?: unknown; locale?: Locale } = {}, +) { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === '/api/skills') { + return new Response(JSON.stringify(options.skillsBody ?? { skills }), { + status: options.skillsStatus ?? 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response(JSON.stringify({}), { status: 404 }); + }) as typeof fetch; + + render( + + undefined} + /> + , + ); +} + +describe('IntegrationsView skills tree', () => { + afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('renders skills as a read-only list view by default and shows row metadata', async () => { + renderSkillsIntegration([ + skill({ + id: 'dashboard', + name: 'Dashboard', + description: 'Dense operations UI.', + scenario: 'operation', + platform: 'desktop', + previewType: 'html', + examplePrompt: 'Build an operations dashboard.', + }), + skill({ + id: 'pitch-deck', + name: 'Pitch Deck', + mode: 'deck', + scenario: 'product', + previewType: 'pptx', + designSystemRequired: false, + }), + ]); + + expect(await screen.findByTestId('integrations-skill-list-row-dashboard')).toBeTruthy(); + expect(screen.getByTestId('integrations-skill-list-row-pitch-deck')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'List view' }).getAttribute('class')).toContain('is-active'); + expect(screen.queryByTestId('integrations-skill-node-dashboard')).toBeNull(); + expect(screen.queryByTestId('integrations-skill-detail')).toBeNull(); + expect(screen.getByText('Select a skill row to inspect it.')).toBeTruthy(); + expect(screen.queryByText('Select a skill node to inspect it.')).toBeNull(); + + fireEvent.click(screen.getByTestId('integrations-skill-list-row-pitch-deck')); + + const detail = await screen.findByTestId('integrations-skill-detail'); + expect(within(detail).getByText('Pitch Deck')).toBeTruthy(); + expect(within(detail).getByText('pptx')).toBeTruthy(); + expect(within(detail).getByText('Optional')).toBeTruthy(); + }); + + it('switches to the mode/scenario tree view and reuses the detail panel', async () => { + renderSkillsIntegration([ + skill({ + id: 'dashboard', + name: 'Dashboard', + description: 'Dense operations UI.', + scenario: 'operation', + category: 'operations', + platform: 'desktop', + }), + skill({ + id: 'pitch-deck', + name: 'Pitch Deck', + mode: 'deck', + scenario: 'product', + category: 'sales', + previewType: 'pptx', + designSystemRequired: false, + }), + ]); + + await screen.findByTestId('integrations-skill-list-row-dashboard'); + + fireEvent.click(screen.getByRole('button', { name: 'Skill tree' })); + + expect(screen.getByText('Prototype')).toBeTruthy(); + expect(screen.getByText('Operation')).toBeTruthy(); + expect(screen.getByText('Deck')).toBeTruthy(); + expect(screen.queryByTestId('integrations-skill-list-row-dashboard')).toBeNull(); + expect(screen.getByText('Select a skill node to inspect it.')).toBeTruthy(); + expect(screen.getByText('Operation').closest('g')?.getAttribute('class')).toContain('is-branch'); + expect(screen.getByText('Operation').closest('g')?.getAttribute('role')).toBeNull(); + expect(screen.getByTestId('integrations-skill-node-pitch-deck').getAttribute('class')).toContain('is-interactive'); + + fireEvent.click(screen.getByTestId('integrations-skill-node-pitch-deck')); + + const detail = await screen.findByTestId('integrations-skill-detail'); + expect(within(detail).getByText('Pitch Deck')).toBeTruthy(); + expect(within(detail).getByText('pptx')).toBeTruthy(); + }); + + it('applies catalog facet filters to the same result set', async () => { + renderSkillsIntegration([ + skill({ + id: 'dashboard', + name: 'Dashboard', + scenario: 'operation', + category: 'operations', + platform: 'desktop', + }), + skill({ + id: 'poster', + name: 'Poster', + scenario: 'marketing', + category: 'marketing', + platform: 'mobile', + designSystemRequired: false, + }), + skill({ + id: 'pitch-deck', + name: 'Pitch Deck', + mode: 'deck', + scenario: 'product', + category: 'sales', + platform: 'desktop', + previewType: 'pptx', + }), + ]); + + await screen.findByTestId('integrations-skill-list-row-dashboard'); + + fireEvent.change(screen.getByLabelText('Mode'), { target: { value: 'prototype' } }); + fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'operation' } }); + fireEvent.change(screen.getByLabelText('Category'), { target: { value: 'operations' } }); + fireEvent.change(screen.getByLabelText('Platform'), { target: { value: 'desktop' } }); + fireEvent.change(screen.getByLabelText('Design system'), { target: { value: 'required' } }); + + await waitFor(() => { + expect(screen.getByTestId('integrations-skill-list-row-dashboard')).toBeTruthy(); + expect(screen.queryByTestId('integrations-skill-list-row-poster')).toBeNull(); + expect(screen.queryByTestId('integrations-skill-list-row-pitch-deck')).toBeNull(); + }); + }); + + it('clears stale selected detail when search and filters hide the selected skill', async () => { + renderSkillsIntegration([ + skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }), + skill({ id: 'poster', name: 'Poster', scenario: 'marketing', platform: 'mobile' }), + ]); + + await screen.findByTestId('integrations-skill-list-row-dashboard'); + fireEvent.click(screen.getByRole('button', { name: 'Skill tree' })); + + await screen.findByTestId('integrations-skill-node-dashboard'); + fireEvent.click(screen.getByTestId('integrations-skill-node-dashboard')); + expect(screen.getByTestId('integrations-skill-detail').textContent).toContain('Dashboard'); + + fireEvent.change(screen.getByLabelText('Platform'), { + target: { value: 'mobile' }, + }); + fireEvent.change(screen.getByPlaceholderText('Search skills...'), { + target: { value: 'poster' }, + }); + + await waitFor(() => { + expect(screen.queryByTestId('integrations-skill-node-dashboard')).toBeNull(); + }); + expect(screen.getByTestId('integrations-skill-node-poster')).toBeTruthy(); + expect(screen.queryByTestId('integrations-skill-detail')).toBeNull(); + expect(screen.getByText('Select a skill node to inspect it.')).toBeTruthy(); + + fireEvent.click(screen.getByTestId('integrations-skill-node-poster')); + expect(screen.getByTestId('integrations-skill-detail').textContent).toContain('Poster'); + }); + + it('shows an empty state when search and filters remove every skill', async () => { + renderSkillsIntegration([ + skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }), + skill({ id: 'poster', name: 'Poster', scenario: 'marketing', platform: 'mobile' }), + ]); + + await screen.findByTestId('integrations-skill-list-row-dashboard'); + + fireEvent.change(screen.getByLabelText('Platform'), { + target: { value: 'desktop' }, + }); + fireEvent.change(screen.getByPlaceholderText('Search skills...'), { + target: { value: 'poster' }, + }); + + expect(await screen.findByText('No skills match these filters.')).toBeTruthy(); + expect(screen.queryByTestId('integrations-skill-detail')).toBeNull(); + }); + + it('shows a load failure instead of an empty filter state when skills fail to load', async () => { + renderSkillsIntegration([], { + skillsStatus: 500, + skillsBody: { error: { message: 'boom' } }, + }); + + expect(await screen.findByText('Could not load skills. Make sure the local daemon is running, then try again.')).toBeTruthy(); + expect(screen.queryByText('No skills match these filters.')).toBeNull(); + }); + + it('shows a load failure when skills payload contains malformed entries', async () => { + renderSkillsIntegration([], { + skillsBody: { + skills: [ + skill({ id: 'dashboard', name: 'Dashboard' }), + { id: 'broken', mode: 'prototype' }, + ], + }, + }); + + expect(await screen.findByText('Could not load skills. Make sure the local daemon is running, then try again.')).toBeTruthy(); + expect(screen.queryByText('No skills match these filters.')).toBeNull(); + expect(screen.queryByTestId('integrations-skill-list-row-dashboard')).toBeNull(); + }); + + it('localizes tree guide and legend labels', async () => { + renderSkillsIntegration([ + skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }), + ], { locale: 'zh-CN' }); + + await screen.findByTestId('integrations-skill-list-row-dashboard'); + fireEvent.click(screen.getByRole('button', { name: '技能树' })); + + await screen.findByTestId('integrations-skill-node-dashboard'); + + expect(screen.getAllByText('模式').length).toBeGreaterThan(0); + expect(screen.getAllByText('场景').length).toBeGreaterThan(0); + expect(screen.getAllByText('技能').length).toBeGreaterThan(0); + expect(screen.queryByText('Mode')).toBeNull(); + expect(screen.queryByText('Scenario')).toBeNull(); + expect(screen.queryByText('Skill')).toBeNull(); + }); +}); diff --git a/packages/contracts/src/api/registry.ts b/packages/contracts/src/api/registry.ts index 66f740515..071d61400 100644 --- a/packages/contracts/src/api/registry.ts +++ b/packages/contracts/src/api/registry.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + export interface AgentModelOption { id: string; label: string; @@ -148,6 +150,205 @@ export interface SkillsResponse { skills: SkillSummary[]; } +export const SkillSummarySchema: z.ZodType = z.object({ + id: z.string(), + name: z.string(), + displayName: z.record(z.string()).optional(), + description: z.string(), + descriptionI18n: z.record(z.string()).optional(), + triggers: z.array(z.string()), + mode: z.enum([ + 'prototype', + 'deck', + 'template', + 'design-system', + 'image', + 'video', + 'audio', + ]), + surface: z.enum(['web', 'image', 'video', 'audio']).optional(), + platform: z.enum(['desktop', 'mobile']).nullable().optional(), + scenario: z.string().nullable().optional(), + category: z.string().nullable().optional(), + source: z.enum(['built-in', 'user']).optional(), + previewType: z.string(), + designSystemRequired: z.boolean(), + defaultFor: z.array(z.string()), + upstream: z.string().nullable(), + featured: z.number().nullable().optional(), + fidelity: z.enum(['wireframe', 'high-fidelity']).nullable().optional(), + speakerNotes: z.boolean().nullable().optional(), + animations: z.boolean().nullable().optional(), + craftRequires: z.array(z.string()).optional(), + hasBody: z.boolean(), + examplePrompt: z.string(), + examplePromptI18n: z.record(z.string()).optional(), + aggregatesExamples: z.boolean(), +}).passthrough() as z.ZodType; + +export const SkillSummaryArraySchema = z.array(SkillSummarySchema); + +export function parseSkillSummaries(value: unknown): SkillSummary[] { + return SkillSummaryArraySchema.parse(value); +} + +export function isSkillSummaries(value: unknown): value is SkillSummary[] { + return SkillSummaryArraySchema.safeParse(value).success; +} + +export interface SkillCatalogTreeSkill { + id: string; + name: string; + description: string; + mode: SkillSummary['mode']; + scenario: string; + platform: SkillSummary['platform']; + previewType: string; + designSystemRequired: boolean; + examplePrompt: string; + source: SkillSummary['source']; + featured: number | null; + defaultFor: string[]; + category: string | null; + skill: SkillSummary; +} + +export interface SkillCatalogTreeScenario { + id: string; + label: string; + count: number; + skills: SkillCatalogTreeSkill[]; +} + +export interface SkillCatalogTreeMode { + id: SkillSummary['mode']; + label: string; + count: number; + scenarios: SkillCatalogTreeScenario[]; +} + +export interface SkillCatalogTree { + total: number; + modes: SkillCatalogTreeMode[]; +} + +const SKILL_MODE_ORDER: readonly SkillSummary['mode'][] = [ + 'prototype', + 'deck', + 'template', + 'design-system', + 'image', + 'video', + 'audio', +]; + +export function buildSkillCatalogTree( + skills: readonly SkillSummary[], +): SkillCatalogTree { + const modeBuckets = new Map>(); + + for (const skill of skills) { + const scenario = normalizeTreeScenario(skill.scenario); + let scenarioBuckets = modeBuckets.get(skill.mode); + if (!scenarioBuckets) { + scenarioBuckets = new Map(); + modeBuckets.set(skill.mode, scenarioBuckets); + } + const bucket = scenarioBuckets.get(scenario) ?? []; + bucket.push({ + id: skill.id, + name: skill.name, + description: skill.description, + mode: skill.mode, + scenario, + platform: skill.platform ?? null, + previewType: skill.previewType, + designSystemRequired: skill.designSystemRequired, + examplePrompt: skill.examplePrompt, + source: skill.source, + featured: skill.featured ?? null, + defaultFor: skill.defaultFor ?? [], + category: skill.category ?? null, + skill, + }); + scenarioBuckets.set(scenario, bucket); + } + + const modes = [...modeBuckets.entries()] + .sort(([a], [b]) => compareMode(a, b)) + .map(([mode, scenarioBuckets]) => { + const scenarios = [...scenarioBuckets.entries()] + .sort(([a], [b]) => compareScenario(a, b)) + .map(([scenario, scenarioSkills]) => { + const sortedSkills = [...scenarioSkills].sort(compareTreeSkill); + return { + id: scenario, + label: labelFromSlug(scenario), + count: sortedSkills.length, + skills: sortedSkills, + }; + }); + return { + id: mode, + label: labelFromSlug(mode), + count: scenarios.reduce((total, scenario) => total + scenario.count, 0), + scenarios, + }; + }); + + return { + total: skills.length, + modes, + }; +} + +function normalizeTreeScenario(scenario: SkillSummary['scenario']): string { + const normalized = typeof scenario === 'string' ? scenario.trim() : ''; + return normalized.length > 0 ? normalized : 'general'; +} + +function compareMode(a: SkillSummary['mode'], b: SkillSummary['mode']): number { + const aIndex = SKILL_MODE_ORDER.indexOf(a); + const bIndex = SKILL_MODE_ORDER.indexOf(b); + if (aIndex !== bIndex) return aIndex - bIndex; + return a.localeCompare(b); +} + +function compareScenario(a: string, b: string): number { + if (a === 'general' && b !== 'general') return -1; + if (b === 'general' && a !== 'general') return 1; + return a.localeCompare(b); +} + +function compareTreeSkill( + a: SkillCatalogTreeSkill, + b: SkillCatalogTreeSkill, +): number { + const rankDiff = treeSkillRank(a) - treeSkillRank(b); + if (rankDiff !== 0) return rankDiff; + if (a.featured !== null || b.featured !== null) { + const featuredDiff = (a.featured ?? Number.MAX_SAFE_INTEGER) - (b.featured ?? Number.MAX_SAFE_INTEGER); + if (featuredDiff !== 0) return featuredDiff; + } + const nameDiff = a.name.localeCompare(b.name); + if (nameDiff !== 0) return nameDiff; + return a.id.localeCompare(b.id); +} + +function treeSkillRank(skill: SkillCatalogTreeSkill): number { + if (skill.defaultFor.length > 0) return 0; + if (skill.featured !== null) return 1; + return 2; +} + +function labelFromSlug(slug: string): string { + return slug + .split('-') + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + export interface SkillResponse { skill: SkillDetail; } diff --git a/packages/contracts/tests/skill-catalog-tree.test.ts b/packages/contracts/tests/skill-catalog-tree.test.ts new file mode 100644 index 000000000..71619e518 --- /dev/null +++ b/packages/contracts/tests/skill-catalog-tree.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSkillCatalogTree, + parseSkillSummaries, + type SkillSummary, +} from '../src/api/registry.js'; + +function skill(overrides: Partial): SkillSummary { + return { + id: 'skill', + name: 'Skill', + description: 'A reusable skill.', + triggers: [], + mode: 'prototype', + previewType: 'html', + designSystemRequired: true, + defaultFor: [], + upstream: null, + hasBody: true, + examplePrompt: '', + aggregatesExamples: false, + ...overrides, + }; +} + +describe('buildSkillCatalogTree', () => { + it('groups skills by mode and scenario with deterministic labels', () => { + const tree = buildSkillCatalogTree([ + skill({ id: 'deck', name: 'Deck', mode: 'deck', scenario: 'product' }), + skill({ id: 'web', name: 'Web', mode: 'prototype', scenario: 'design' }), + skill({ id: 'image', name: 'Image', mode: 'image', scenario: null }), + ]); + + expect(tree.total).toBe(3); + expect(tree.modes.map((mode) => mode.id)).toEqual([ + 'prototype', + 'deck', + 'image', + ]); + expect(tree.modes[0]?.scenarios[0]).toMatchObject({ + id: 'design', + label: 'Design', + count: 1, + }); + expect(tree.modes[2]?.scenarios[0]).toMatchObject({ + id: 'general', + label: 'General', + count: 1, + }); + }); + + it('sorts default and featured skills before regular skills stably', () => { + const tree = buildSkillCatalogTree([ + skill({ id: 'regular-b', name: 'Regular B', scenario: 'design' }), + skill({ + id: 'featured-two', + name: 'Featured two', + featured: 2, + scenario: 'design', + }), + skill({ + id: 'default', + name: 'Default', + defaultFor: ['prototype'], + scenario: 'design', + }), + skill({ + id: 'featured-one', + name: 'Featured one', + featured: 1, + scenario: 'design', + }), + skill({ id: 'regular-a', name: 'Regular A', scenario: 'design' }), + ]); + + expect( + tree.modes[0]?.scenarios[0]?.skills.map((treeSkill) => treeSkill.id), + ).toEqual([ + 'default', + 'featured-one', + 'featured-two', + 'regular-a', + 'regular-b', + ]); + }); + + it('keeps the original skill summary on leaf nodes', () => { + const source = skill({ + id: 'dashboard', + name: 'Dashboard', + scenario: 'operation', + platform: 'desktop', + previewType: 'jsx', + designSystemRequired: false, + examplePrompt: 'Build an ops dashboard.', + category: 'ops-tools', + source: 'built-in', + }); + + const tree = buildSkillCatalogTree([source]); + const leaf = tree.modes[0]?.scenarios[0]?.skills[0]; + + expect(leaf).toMatchObject({ + id: 'dashboard', + platform: 'desktop', + previewType: 'jsx', + designSystemRequired: false, + examplePrompt: 'Build an ops dashboard.', + category: 'ops-tools', + source: 'built-in', + }); + expect(leaf?.skill).toBe(source); + }); +}); + +describe('parseSkillSummaries', () => { + it('accepts valid skill summary arrays', () => { + const summaries = parseSkillSummaries([ + skill({ id: 'dashboard', name: 'Dashboard' }), + ]); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.id).toBe('dashboard'); + }); + + it('rejects malformed skill summary array elements', () => { + expect(() => parseSkillSummaries([ + skill({ id: 'dashboard', name: 'Dashboard' }), + { id: 'broken', mode: 'prototype' }, + ])).toThrow(); + }); +});