This commit is contained in:
RyanCheng77 2026-05-31 01:23:28 -04:00 committed by GitHub
commit ea250069f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2430 additions and 34 deletions

View file

@ -11,6 +11,7 @@ import { parseDesignSystemRenameArgs } from './design-system-rename-args.js';
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js'; import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
import { splitResearchSubcommand } from './research/cli-args.js'; import { splitResearchSubcommand } from './research/cli-args.js';
import { resolveDaemonUrl } from './daemon-url.js'; import { resolveDaemonUrl } from './daemon-url.js';
import { buildSkillCatalogTree, parseSkillSummaries } from '@open-design/contracts';
import { requestJsonIpc } from '@open-design/sidecar'; import { requestJsonIpc } from '@open-design/sidecar';
import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto'; import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto';
@ -196,6 +197,7 @@ const RECOVERABLE_EXIT_CODES = {
'plugin-requires-daemon': 71, 'plugin-requires-daemon': 71,
'snapshot-stale': 72, 'snapshot-stale': 72,
'genui-surface-awaiting': 73, 'genui-surface-awaiting': 73,
'daemon-protocol-error': 74,
'desktop-auth-pending': 74, 'desktop-auth-pending': 74,
'desktop-import-token-rejected': 75, '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 <id> 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 runCraft(args) { return runLibraryList('craft', args); }
async function runDesignSystems(args) { async function runDesignSystems(args) {

View file

@ -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<T>(
handler: (req: IncomingMessage, res: ServerResponse) => void,
run: (baseUrl: string) => Promise<T>,
): Promise<T> {
const server = createServer(handler);
await new Promise<void>((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<void>((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');
}
},
);
});
});

View file

@ -1,9 +1,13 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { AppConfig } from '../types'; import type { AppConfig, SkillSummary } from '../types';
import {
buildSkillCatalogTree,
type SkillCatalogTree,
type SkillCatalogTreeSkill,
} from '@open-design/contracts';
import { useAnalytics } from '../analytics/provider'; import { useAnalytics } from '../analytics/provider';
import { import {
trackIntegrationsConnectorsTabClick, trackIntegrationsConnectorsTabClick,
trackIntegrationsSkillsTabClick,
trackIntegrationsTabClick, trackIntegrationsTabClick,
trackPageView, trackPageView,
trackSettingsConnectorAuthResult, trackSettingsConnectorAuthResult,
@ -12,7 +16,13 @@ import { ConnectorSection } from './SettingsDialog';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { McpClientSection } from './McpClientSection'; import { McpClientSection } from './McpClientSection';
import { UseEverywhereGuidePanel } from './UseEverywhereModal'; 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'; export type IntegrationTab = 'mcp' | 'connectors' | 'skills' | 'use-everywhere';
@ -148,7 +158,7 @@ export function IntegrationsView({
/> />
) : null} ) : null}
{activeTab === 'skills' ? <SkillsComingSoonPanel /> : null} {activeTab === 'skills' ? <SkillsCatalogTreePanel /> : null}
{activeTab === 'use-everywhere' ? ( {activeTab === 'use-everywhere' ? (
<div className="integrations-view__use-everywhere"> <div className="integrations-view__use-everywhere">
@ -163,35 +173,783 @@ export function IntegrationsView({
); );
} }
function SkillsComingSoonPanel() { function SkillsCatalogTreePanel() {
const t = useT(); const t = useT();
const analytics = useAnalytics(); const { locale } = useI18n();
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<SkillCatalogViewMode>('list');
const [filters, setFilters] = useState<SkillCatalogFilters>(DEFAULT_SKILL_CATALOG_FILTERS);
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(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 ( return (
<section <section
className="integrations-view__coming-soon" className="integrations-skills-tree"
aria-labelledby="integration-skills-title" aria-labelledby="integration-skills-title"
onClick={() =>
trackIntegrationsSkillsTabClick(analytics.track, {
page_name: 'integrations',
area: 'skills_tab',
element: 'coming_soon',
})
}
> >
<div className="integrations-view__coming-icon" aria-hidden="true"> <header className="integrations-skills-tree__head">
<Icon name="sparkles" size={22} /> <div>
</div> <p className="integrations-view__coming-kicker">{t('integrations.tabLabel.skills')}</p>
<div> <h2 id="integration-skills-title">{t('integrations.skillsTitle')}</h2>
<p className="integrations-view__coming-kicker">{t('tasks.comingSoon')}</p> </div>
<h2 id="integration-skills-title">{t('integrations.skillsTitle')}</h2> <div className="integrations-skills-tree__tools">
<div className="integrations-skills-tree__view-toggle" aria-label={t('integrations.skillsViewMode')}>
<button
type="button"
className={viewMode === 'tree' ? 'is-active' : ''}
onClick={() => setViewMode('tree')}
>
{t('integrations.skillsTreeView')}
</button>
<button
type="button"
className={viewMode === 'list' ? 'is-active' : ''}
onClick={() => setViewMode('list')}
>
{t('integrations.skillsListView')}
</button>
</div>
<label className="integrations-skills-tree__search">
<Icon name="search" size={13} />
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('integrations.skillsSearch')}
/>
</label>
</div>
</header>
<div className="integrations-skills-tree__summary">
<p> <p>
{t('integrations.skillsBody')} {t('integrations.skillsBody')}
</p> </p>
<span>{tree.total}</span>
</div> </div>
<SkillCatalogFiltersBar
filters={filters}
options={filterOptions}
onChange={setFilters}
/>
{loading ? (
<div className="integrations-skills-tree__empty">
{t('integrations.skillsLoading')}
</div>
) : loadError ? (
<div className="integrations-skills-tree__empty">
{t('integrations.skillsLoadFailed')}
</div>
) : tree.total === 0 ? (
<div className="integrations-skills-tree__empty">
{t('integrations.skillsNoFilterResults')}
</div>
) : viewMode === 'list' ? (
<SkillListView
skills={filteredSkills}
selectedSkill={selectedSkill}
selectedSkillId={selectedSkillId}
onSelectSkill={setSelectedSkillId}
/>
) : (
<SkillTreeGraph
tree={tree}
selectedSkillId={selectedSkillId}
selectedSkill={selectedSkill}
onSelectSkill={setSelectedSkillId}
/>
)}
</section> </section>
); );
} }
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 = <K extends keyof SkillCatalogFilters>(key: K, value: SkillCatalogFilters[K]) => {
onChange({ ...filters, [key]: value });
};
return (
<div className="integrations-skills-tree__filters" aria-label={t('integrations.skillsFilters')}>
<SkillCatalogSelect
label={t('integrations.skillsFilterMode')}
value={filters.mode}
options={options.modes}
onChange={(value) => update('mode', value)}
/>
<SkillCatalogSelect
label={t('integrations.skillsFilterScenario')}
value={filters.scenario}
options={options.scenarios}
onChange={(value) => update('scenario', value)}
/>
<SkillCatalogSelect
label={t('integrations.skillsFilterCategory')}
value={filters.category}
options={options.categories}
onChange={(value) => update('category', value)}
/>
<SkillCatalogSelect
label={t('integrations.skillsFilterPlatform')}
value={filters.platform}
options={options.platforms}
onChange={(value) => update('platform', value)}
/>
<SkillCatalogSelect
label={t('integrations.skillsFilterPreview')}
value={filters.previewType}
options={options.previewTypes}
onChange={(value) => update('previewType', value)}
/>
<label className="integrations-skills-tree__filter">
<span>{t('integrations.skillsFilterDesignSystem')}</span>
<select
value={filters.designSystem}
onChange={(event) => update('designSystem', event.target.value as SkillDesignSystemFilter)}
data-testid="integrations-skill-filter-design-system"
>
<option value="all">{t('integrations.skillsFilterAll')}</option>
<option value="required">{t('integrations.skillsTreeDesignSystemRequired')}</option>
<option value="optional">{t('integrations.skillsTreeDesignSystemOptional')}</option>
</select>
</label>
</div>
);
}
function SkillCatalogSelect({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: SkillCatalogFilterOption[];
onChange: (value: string) => void;
}) {
const t = useT();
return (
<label className="integrations-skills-tree__filter">
<span>{label}</span>
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="all">{t('integrations.skillsFilterAll')}</option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label} ({option.count})
</option>
))}
</select>
</label>
);
}
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 (
<div className="integrations-skills-tree__grid">
<div className="integrations-skills-tree__list" role="list">
{skills.map((skill) => {
const name = localizeSkillName(locale, skill) || skill.name || skill.id;
const description = localizeSkillDescription(locale, skill);
const isActive = skill.id === selectedSkillId;
return (
<button
key={skill.id}
type="button"
className={`integrations-skills-tree__list-row${isActive ? ' is-active' : ''}`}
onClick={() => onSelectSkill(skill.id)}
aria-pressed={isActive}
data-testid={`integrations-skill-list-row-${skill.id}`}
>
<span className="integrations-skills-tree__list-title">{name}</span>
{description ? (
<span className="integrations-skills-tree__list-description">{description}</span>
) : null}
<span className="integrations-skills-tree__list-meta">
<span>{skill.mode}</span>
<span>{skillCatalogScenario(skill)}</span>
{skill.category ? <span>{skill.category}</span> : null}
{skill.platform ? <span>{skill.platform}</span> : null}
<span>{skill.previewType}</span>
<span>
{skill.designSystemRequired
? t('integrations.skillsTreeDesignSystemRequired')
: t('integrations.skillsTreeDesignSystemOptional')}
</span>
</span>
</button>
);
})}
</div>
<SkillTreeInfoPanel skill={selectedTreeSkill} emptyLabel={t('integrations.skillsListEmpty')} />
</div>
);
}
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 (
<div className="integrations-skills-tree__grid">
<div className="integrations-skills-tree__canvas">
<svg
className="integrations-skills-tree__svg"
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="img"
aria-label={t('integrations.skillsTreeView')}
style={{ minWidth: layout.width }}
>
<defs>
<filter id="integrations-skill-node-glow" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{layout.guides.map((guide) => (
<g key={guide.id} className="integrations-skills-tree__guide">
<line x1={56} y1={guide.y} x2={layout.width - 24} y2={guide.y} />
<text x={24} y={guide.y + 4}>{guide.label}</text>
</g>
))}
{layout.edges.map((edge) => (
<g
key={edge.id}
className={`integrations-skills-tree__edge${edge.active ? ' is-active' : ''}`}
>
<path className="integrations-skills-tree__edge-aura" d={edge.d} />
<path className="integrations-skills-tree__edge-base" d={edge.d} />
</g>
))}
{layout.nodes.map((node) => {
const interactive = Boolean(node.skillId);
return (
<g
key={node.id}
className={`integrations-skills-tree__svg-node is-${node.kind}${interactive ? ' is-interactive' : ' is-branch'}${node.active ? ' is-active' : ''}${node.onPath ? ' is-on-path' : ''}`}
transform={`translate(${node.x} ${node.y})`}
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
aria-pressed={interactive ? node.active : undefined}
onClick={interactive ? () => 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}
>
<title>{node.title}</title>
{node.active ? <circle className="integrations-skills-tree__svg-glow" r={node.radius + 14} /> : null}
{interactive ? <circle className="integrations-skills-tree__svg-affordance" r={node.radius + 8} /> : null}
{interactive && !node.active ? <circle className="integrations-skills-tree__svg-pulse" r={node.radius + 6} /> : null}
<circle className="integrations-skills-tree__svg-core" r={node.radius} />
{interactive ? (
<circle className="integrations-skills-tree__svg-ring" r={node.radius - 7} />
) : (
<circle className="integrations-skills-tree__svg-branch-ring" r={node.radius - 9} />
)}
<text className="integrations-skills-tree__svg-label" y={node.kind === 'skill' ? -3 : -5}>
{node.label}
</text>
<text className="integrations-skills-tree__svg-sub" y={node.kind === 'skill' ? 12 : 11}>
{node.subLabel}
</text>
</g>
);
})}
</svg>
<div className="integrations-skills-tree__legend" aria-hidden>
<span><i className="is-mode" /> {t('integrations.skillsTreeLegendMode')}</span>
<span><i className="is-scenario" /> {t('integrations.skillsTreeLegendScenario')}</span>
<span><i className="is-skill" /> {t('integrations.skillsTreeLegendSkill')}</span>
</div>
</div>
<SkillTreeInfoPanel skill={selectedTreeSkill} emptyLabel={t('integrations.skillsTreeEmpty')} />
</div>
);
}
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<typeof useI18n>['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 (
<aside className="integrations-skills-tree__detail is-empty">
{emptyLabel}
</aside>
);
}
const name = localizeSkillName(locale, skill.skill) || skill.id;
const description = localizeSkillDescription(locale, skill.skill);
const prompt = localizeSkillPrompt(locale, skill.skill) || skill.examplePrompt;
return (
<aside className="integrations-skills-tree__detail" data-testid="integrations-skill-detail">
<p className="integrations-skills-tree__detail-kicker">
{skill.mode} / {skill.scenario}
</p>
<h3>{name}</h3>
{description ? <p>{description}</p> : null}
<dl>
<div>
<dt>{t('integrations.skillsTreePlatform')}</dt>
<dd>{skill.platform ?? '—'}</dd>
</div>
<div>
<dt>{t('integrations.skillsTreePreviewType')}</dt>
<dd>{skill.previewType}</dd>
</div>
<div>
<dt>{t('integrations.skillsTreeDesignSystem')}</dt>
<dd>
{skill.designSystemRequired
? t('integrations.skillsTreeDesignSystemRequired')
: t('integrations.skillsTreeDesignSystemOptional')}
</dd>
</div>
<div>
<dt>{t('integrations.skillsTreeSource')}</dt>
<dd>{skill.source ?? 'built-in'}</dd>
</div>
</dl>
{prompt ? (
<div className="integrations-skills-tree__prompt">
<span>{t('integrations.skillsTreeExamplePrompt')}</span>
<p>{prompt}</p>
</div>
) : null}
</aside>
);
}
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<string | null | undefined>): SkillCatalogFilterOption[] {
const counts = new Map<string, number>();
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<typeof useT>): string { function integrationTabLabel(id: IntegrationTab, t: ReturnType<typeof useT>): string {
switch (id) { switch (id) {
case 'mcp': return t('integrations.tabLabel.mcp'); case 'mcp': return t('integrations.tabLabel.mcp');
@ -205,7 +963,7 @@ function integrationTabHint(id: IntegrationTab, t: ReturnType<typeof useT>): str
switch (id) { switch (id) {
case 'mcp': return t('integrations.tabHint.mcp'); case 'mcp': return t('integrations.tabHint.mcp');
case 'connectors': return t('integrations.tabHint.connectors'); 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'); case 'use-everywhere': return t('integrations.tabHint.useEverywhere');
} }
} }

View file

@ -684,8 +684,39 @@ export const en: Dict = {
'integrations.tabHint.mcp': 'External tools', 'integrations.tabHint.mcp': 'External tools',
'integrations.tabHint.connectors': 'Accounts and APIs', 'integrations.tabHint.connectors': 'Accounts and APIs',
'integrations.tabHint.useEverywhere': 'CLI, HTTP, MCP', 'integrations.tabHint.useEverywhere': 'CLI, HTTP, MCP',
'integrations.skillsTitle': 'Skills integrations', 'integrations.skillsTitle': 'Skill tree',
'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.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.title': 'External MCP servers',
'mcpClient.subtitle': 'Third-party tools for your coding agent.', 'mcpClient.subtitle': 'Third-party tools for your coding agent.',
'mcpClient.addServer': 'Add server', 'mcpClient.addServer': 'Add server',

View file

@ -684,8 +684,39 @@ export const zhCN: Dict = {
'integrations.tabHint.mcp': '外部工具', 'integrations.tabHint.mcp': '外部工具',
'integrations.tabHint.connectors': '账号和 API', 'integrations.tabHint.connectors': '账号和 API',
'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP', 'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP',
'integrations.skillsTitle': '技能集成', 'integrations.skillsTitle': '技能树',
'integrations.skillsBody': '技能级集成管理正在从另一个分支迁移过来。此标签页预留给 MCP、连接器和未来技能设置使它们位于同一个集成路由中。', '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.title': '外部 MCP 服务器',
'mcpClient.subtitle': '供编码智能体使用的第三方工具。', 'mcpClient.subtitle': '供编码智能体使用的第三方工具。',
'mcpClient.addServer': '添加服务器', 'mcpClient.addServer': '添加服务器',

View file

@ -610,8 +610,39 @@ export const zhTW: Dict = {
'integrations.tabHint.mcp': '外部工具', 'integrations.tabHint.mcp': '外部工具',
'integrations.tabHint.connectors': '帳號與 API', 'integrations.tabHint.connectors': '帳號與 API',
'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP', 'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP',
'integrations.skillsTitle': '技能整合', 'integrations.skillsTitle': '技能樹',
'integrations.skillsBody': '技能層級的整合管理正從另一個分支搬移過來。此分頁預留給 MCP、連接器與未來的技能設定讓它們集中在同一個整合路由中。', '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.title': '外部 MCP 伺服器',
'mcpClient.subtitle': '提供給編碼智能體使用的第三方工具。', 'mcpClient.subtitle': '提供給編碼智能體使用的第三方工具。',
'mcpClient.addServer': '新增伺服器', 'mcpClient.addServer': '新增伺服器',

View file

@ -998,6 +998,37 @@ export interface Dict {
'integrations.tabHint.useEverywhere': string; 'integrations.tabHint.useEverywhere': string;
'integrations.skillsTitle': string; 'integrations.skillsTitle': string;
'integrations.skillsBody': 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.title': string;
'mcpClient.subtitle': string; 'mcpClient.subtitle': string;
'mcpClient.addServer': string; 'mcpClient.addServer': string;

View file

@ -13,6 +13,7 @@ import type {
ImportLocalDesignSystemResponse, ImportLocalDesignSystemResponse,
ReplaceProjectWorkingDirResponse, ReplaceProjectWorkingDirResponse,
} from '@open-design/contracts'; } from '@open-design/contracts';
import { parseSkillSummaries } from '@open-design/contracts';
import type { import type {
AgentInfo, AgentInfo,
AppVersionInfo, AppVersionInfo,
@ -100,13 +101,21 @@ export async function fetchAgents(options?: { throwOnError?: boolean }): Promise
} }
} }
export async function fetchSkills(): Promise<SkillSummary[]> { export async function fetchSkills(options?: { throwOnError?: boolean }): Promise<SkillSummary[]> {
try { try {
const resp = await fetch('/api/skills'); const resp = await fetch('/api/skills');
if (!resp.ok) return []; if (!resp.ok) {
const json = (await resp.json()) as { skills: SkillSummary[] }; if (options?.throwOnError) throw new Error(`skills ${resp.status}`);
return json.skills ?? []; return [];
} catch { }
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 []; return [];
} }
} }

View file

@ -186,6 +186,588 @@
color: var(--text-muted); 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) { @media (max-width: 760px) {
.integrations-view__hero { .integrations-view__hero {
flex-direction: column; flex-direction: column;
@ -198,4 +780,42 @@
.integrations-view__coming-soon { .integrations-view__coming-soon {
grid-template-columns: 1fr; 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;
}
} }

View file

@ -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>): 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(
<I18nProvider initial={options.locale ?? 'en'}>
<IntegrationsView
config={TEST_CONFIG}
initialTab="skills"
onPersistComposioKey={() => undefined}
/>
</I18nProvider>,
);
}
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();
});
});

View file

@ -1,3 +1,5 @@
import { z } from 'zod';
export interface AgentModelOption { export interface AgentModelOption {
id: string; id: string;
label: string; label: string;
@ -148,6 +150,205 @@ export interface SkillsResponse {
skills: SkillSummary[]; skills: SkillSummary[];
} }
export const SkillSummarySchema: z.ZodType<SkillSummary> = 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<SkillSummary>;
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<SkillSummary['mode'], Map<string, SkillCatalogTreeSkill[]>>();
for (const skill of skills) {
const scenario = normalizeTreeScenario(skill.scenario);
let scenarioBuckets = modeBuckets.get(skill.mode);
if (!scenarioBuckets) {
scenarioBuckets = new Map<string, SkillCatalogTreeSkill[]>();
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 { export interface SkillResponse {
skill: SkillDetail; skill: SkillDetail;
} }

View file

@ -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>): 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();
});
});