feat: generic skills + split skills/design-templates + finalize-design API (#955)

* feat: general-purpose skills with @-mention composition and user import

Lift skills from "one mode-bound skill per project" to a generic capability
the user can compose per turn:

- Daemon: scan multiple skill roots (user-skills under runtime data, then
  the bundled `skills/`); user-imported skills can shadow built-ins by id.
- New `POST /api/skills/import` and `DELETE /api/skills/:id` endpoints,
  with CONFLICT/BAD_REQUEST/NOT_FOUND error codes and built-in delete
  protection.
- ChatRequest gains `skillIds: string[]`; the chat run concatenates each
  picked skill's body (and merges craftRequires) into the system prompt
  for that turn only — the project's persistent `skillId` is untouched.
- Web composer: `@` popover now lists skills alongside project files;
  picks render as removable chips above the textarea and ride along with
  the request as `skillIds`.
- Settings → Library: import form (name/description/triggers/body),
  per-card delete for user skills, "user" origin badge.

* chore(web): drop welcome pet teaser + add ds→prompt-template mapping util

- SettingsDialog: remove the inline pet adoption teaser from the welcome
  panel so the first-run modal stays focused on configuration.
- New `inferPromptTemplateCategoriesForDs(ds)` helper that maps a design
  system's authored metadata to prompt-template gallery categories.
  Imported by the design-system gallery wiring on a sibling branch; no
  callers in this branch yet.

* feat: split skills/design-templates and add finalize-design API

Phase 0 of the skills/design-templates refactor (specs/current/
skills-and-design-templates.md):

- Move ~104 rendering catalogue entries from skills/ to design-templates/
  and keep skills/ for the small set of functional skills that *do work*
  on user input (utilities, briefs, packagers).
- Add design-templates/AGENTS.md and skills/AGENTS.md describing the
  contract, and a brand-agnostic craft/ surface for opt-in craft rules.
- Daemon: add DESIGN_TEMPLATES_DIR / USER_DESIGN_TEMPLATES_DIR roots and
  an /api/design-templates surface mirroring /api/skills. Asset/example
  routes still span both registries so existing srcdoc URLs keep
  resolving across the rename.
- Web: split LibrarySection into SkillsSection + DesignSystemsSection,
  rename the EntryView "Examples" tab to "Templates", and update locales
  + the New-project picker accordingly.

Adds the finalize-design endpoint:

- New apps/daemon/src/finalize-design.ts and packages/contracts/src/api/
  finalize.ts — one-shot synthesis of a project's transcript + active
  design system + current artifact into <projectDir>/DESIGN.md via the
  Anthropic Messages API. Per-project .finalize.lock mirrors the
  transcript-export hygiene from PR #493; provider credentials are not
  persisted by the daemon.

Other supporting changes:

- README + AGENTS.md updates to document the new directory split and
  craft/ surface, plus i18n strings across 13 locales.
- Test refactors and new coverage (finalize-design, runs, sidecar
  server, plus refreshed daemon integration tests).
- .gitignore: scope the *.exe ignore to /OpenDesign.exe so legitimate
  vendor binaries are no longer hidden.

* fix(merge): move clinical-case-report to design-templates/

Origin/main added the clinical-case-report skill under skills/ before
the skills/design-templates split landed. Its od.mode is prototype, so
per specs/current/skills-and-design-templates.md it is a design template
and belongs alongside the other rendering catalogue entries — not under
the slimmed-down functional skills/ root. Moving it keeps the EntryView
Templates tab consistent with origin/main's intent.

* feat(skills): curated design/creative catalogue + collapsible Settings rows

Seed ~100 curated design/creative skill stubs under skills/ sourced from
awesome-claude-skills (ComposioHQ) and awesome-agent-skills (VoltAgent).
Each stub carries an od.category tag so the new filter pill row in
Settings -> Skills can group them. The seed script
(scripts/seed-curated-design-skills.ts, pnpm seed:curated-design-skills)
is idempotent: it only creates folders that don't already exist, so
hand-edited stubs are never overwritten.

- Daemon: parse and surface od.category on SkillInfo with a strict slug
  normaliser; mirror the field on SkillSummary in @open-design/contracts.
  Category is purely a UI hint — system-prompt composition is unchanged.
- Web: rewrite SkillsSection from a left-list / right-detail grid into a
  vertical stack of collapsible rows mirroring the External MCP panel
  (header always visible with name + mode/source/category pills + per-row
  enable toggle; SKILL.md preview, file tree and inline edit form expand
  on demand). Add a Category filter row above the list. Reorder Settings
  nav so Skills + External MCP sit above the Composio/MCP cluster. Update
  composer placeholder/hint across 17 locales to advertise '@ files or
  skills · / for commands'.
- Docs: extend skills/AGENTS.md with the curated catalogue rules
  (idempotency, category vocabulary, no upstream vendoring).

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(skills): teach localized-content + system-prompt tests about the skills/design-templates split

mrcfps blocking review on PR #955: the skills/design-templates split
(b5993385) moved ~110 SKILL.md entries out of `skills/` and into
`design-templates/`, but two repo-level tests still hard-coded the
single-root layout, so CI gates went red on the merged branch:

- `e2e/tests/localized-content.test.ts` only scanned `<repo>/skills`
  while the locale `skillCopy` map keeps id-keyed entries spanning
  both roots (ExamplesTab/Templates uses one lookup regardless of
  origin). Teach the helper to read both `skills/` and
  `design-templates/`, deduplicating ids so the union matches the
  localized claim.
- `apps/daemon/tests/prompts/system.test.ts` read
  `skills/live-artifact/SKILL.md`, which now lives under
  `design-templates/live-artifact/`. Update the absolute path so
  composeSystemPrompt's coverage of the live-artifact preamble is
  exercised again.

Also enroll the curated design/creative catalogue (PR #955, ~91
stubs sourced from awesome-claude-skills / awesome-agent-skills) in
the DE / FR / RU `_SKILL_IDS_WITH_EN_FALLBACK` lists. The stubs are
English-only by design (frontmatter advertises an upstream URL); the
fallback list is exactly the place to acknowledge "we know this id
exists, English copy is fine here" so the localized-content coverage
gate passes without forcing a translation task per locale.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): always quote frontmatter name so importUserSkill round-trips numeric / boolean ids

mrcfps PR #955 review: `buildSkillMarkdown` emitted `name:
${escapeYamlString(name)}` without quotes, so YAML coerced names
like `123`, `true`, `false`, or `null` into non-string scalars on
re-parse. listSkills() then read `data.name` as a number/boolean
and the import flow's follow-up `findSkillById(skills, result.id)`
missed it, falling into `/api/skills/import`'s "imported skill
could not be re-read" 500 path for those ids.

Switch the emitter to a quoted scalar (`name: "..."`) — the
double-escape already in `escapeYamlString` makes the quoted form
safe — and add a round-trip test covering `123`, `true`, `false`,
`null`, and `0` to lock in the contract.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): drop staged-skill chips when the matching @<id> token leaves the draft

mrcfps PR #955 review: `submit()` always forwarded every id in
`stagedSkills`, but that state was only mutated on picker click and
chip removal. Hand-deleting an `@<id>` token from the textarea left
the chip staged, so the request still carried `skillIds: [<id>]` and
the daemon composed a skill the prompt no longer referenced.

Sync the chips with the draft inside `handleChange()` by pruning
`stagedSkills` whenever the new value no longer contains the
`@<id>` token (using the same whitespace boundary as
`removeStagedSkill`'s strip regex). Comment explains why this
prune does not run for `staged` file attachments — users frequently
add files via the upload button without leaving an `@<path>` token,
so a symmetric prune there would erase legitimate uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(daemon): stage @-composed skills' side files alongside the active skill

codex PR #955 review: composing a per-turn `@`-picked skill into the
system prompt appended its body (with the `withSkillRootPreamble`
guidance pointing at relative paths under `<cwd>/.od-skills/<folder>/`)
but never staged the actual folder. `startChatRun` only copied
`activeSkillDir`, so when the project's primary skill was different
(or absent) the composed skill's references/, examples/, and scripts/
files lived only at their absolute repo path — agents that honour
the cwd-relative form (or that don't get `--add-dir`, e.g. Codex with
allowlisted gpt-image projects) couldn't reach them.

Thread the composed skills' dirs out of `composeDaemonSystemPrompt`
as `extraSkillDirs` and stage each one through the same
`stageActiveSkill` API used for the primary skill. Dedupe by folder
basename so a project whose primary skill is also `@`-composed isn't
copied twice. Each preamble already advertises its own folder, so the
prompt and the staged tree stay aligned without further changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): respect the Library disable toggle in the project @-mention picker

codex PR #955 review: only `EntryView` received `enabledSkills`
(filtered against `config.disabledSkills`); active projects still
got `skills={skills}` raw, so a skill the user disabled in Settings
kept appearing in the project's `@`-mention popover and could ride
along to the daemon via `skillIds`. That broke the Library toggle
for any project opened on the post-split branch.

Compute a functional-skills-only enabled subset
(`enabledFunctionalSkills`) and pass it into `<ProjectView>` instead.
Templates stay separate — design-templates are filtered through their
own `enabledDesignTemplates` memo for the Templates gallery — so
ProjectView's chat composer still only sees skills, never templates,
matching the pre-split prop surface.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): mock /api/design-templates for example-use-prompt flow

The Templates tab in EntryView fetches from /api/design-templates after
the skills/design-templates split (specs/current/skills-and-design-templates.md).
The example-use-prompt Playwright scenario only mocked /api/skills, so the
gallery card never appeared and the test timed out waiting on
example-card-warm-utility-example. Serve the same fixture summary on both
endpoints so the templates gallery renders the card the test clicks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(tools-pack): create design-templates fixture for resources test

The packaging resources copy now bundles the new design-templates tree
alongside skills (see resources.ts BUNDLED_RESOURCE_TREES). The
copyBundledResourceTrees fixture only created skills, design-systems,
craft, etc., so the recursive copy crashed with ENOENT on
design-templates before it could check the prompt-templates assertion.
Add the missing fixture directory so the test exercises the same set
of resource trees the packaged build does.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): clone built-in side files into the shadow on first edit

mrcfps PR #955 review: editing a built-in skill wrote a USER_SKILLS_DIR
shadow folder that contained only a new SKILL.md. The next listSkills()
pass surfaced the shadow as the active dir, but every side-file resolver
(/api/skills/:id/files, /example, /assets/*, the system-prompt preamble,
and the per-turn cwd staging) reads through skill.dir. With nothing but
SKILL.md in the shadow, the bundled assets/, references/, scripts/, and
examples/ disappeared the moment the user hit save — a built-in like
last30days or live-artifact would break immediately after edit instead
of just having its body overridden.

Teach updateUserSkill() to take a `sourceDir` and clone every entry
except SKILL.md / dotfiles into the shadow on the very first edit. The
shadow stays self-contained, so all the resolvers keep working without
fallback bookkeeping. Subsequent edits detect the existing shadow and
skip the clone, so user tweaks under the side tree survive a re-save.

Wire `sourceDir: skill.dir` from server.ts's PUT /api/skills/:id handler
and add two regression tests:
- 'clones built-in side files into the shadow on the first edit' walks
  the file tree after save and asserts assets/template.html, references/
  notes.md, and scripts/helper.sh all round-trip from the built-in.
- 'preserves user-edited side files on subsequent edits' edits the
  staged assets/template.html, re-saves, and confirms the user content
  is still there.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): rename home tab from Examples to Templates

The Examples tab was renamed to Templates in EntryView (b5993385's
skills/design-templates split — entry.tabExamples became entry.tabTemplates
and the tab value moved from 'examples' to 'templates'), but
entry-chrome-flows still asserted the old label and testId. Update both.

* fix(skills+web): preserve template body in API mode and dir-based skill delete

Two follow-ups from PR #955 review:

1. ProjectView only received `enabledFunctionalSkills`, but
   `composedSystemPrompt()` still resolved `project.skillId` through that
   prop and `fetchSkill()`. Projects created from the new
   `/api/design-templates` surface keep a template id in `project.skillId`,
   so opening one in API mode dropped the template body from the system
   prompt and the upstream request ran without the project's primary
   template instructions. Now ProjectView takes a separate
   `designTemplates` prop (the unfiltered template list, so a
   later-disabled template still loads for projects already created from
   it) and `composedSystemPrompt()` plus the metadata / `isDeck` lookups
   fall back to that list, with `fetchDesignTemplate()` as the body-fetch
   fallback to `fetchSkill()`. The chat composer's `@`-picker keeps
   receiving only the enabled functional skills.

2. `DELETE /api/skills/:id` used `deleteUserSkill(USER_SKILLS_DIR, skill.id)`
   which re-slugified the frontmatter id and removed
   `<userSkillsDir>/<slug>/`. That matched the import shape but missed the
   install shape — `installFromTarget` writes the folder at
   `sanitizeRepoName(url)` (GitHub) or `path.basename(realpath)` (local
   symlink), neither of which is guaranteed to equal the slugified
   frontmatter `name`. A duplicate `app.delete('/api/skills/:id', ...)`
   handler at the install routes never fired because Express resolved the
   earlier registration first, leaving the install/uninstall path without
   working teardown. The handler now removes `skill.dir` (the absolute
   path listSkills already discovered) under a USER_SKILLS_DIR safety
   check, using `lstat` + `unlinkSync` so symlinked local installs unlink
   cleanly without recursing into the user's source tree. The dead
   duplicate handler is removed; `deleteUserSkill` is dropped from the
   server.ts import set (still exported and unit-tested in skills.ts).
   Regression coverage in `apps/daemon/tests/skills-delete-route.test.ts`
   pins both shapes plus the symlink-preserves-source case.

* test(daemon): point hyperframes system-prompt test at design-templates

The merge with main brought in a hyperframes system-prompt test that
reads `skills/hyperframes/SKILL.md`, but this branch's split moved
`hyperframes` into `design-templates/` (same migration as `live-artifact`
already handled above in this file). CI was failing with ENOENT on the
old path.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tom Huang 2026-05-11 17:48:34 +08:00 committed by GitHub
parent f2db5a749c
commit b5eb8c1647
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
883 changed files with 9490 additions and 916 deletions

View file

@ -13,7 +13,7 @@ This file is the single source of truth for agents entering this repository. Rea
## Workspace directories
- Workspace packages come from `pnpm-workspace.yaml`: `apps/*`, `packages/*`, `tools/*`, and `e2e`.
- Top-level content directories: `skills/` (artifact-shape skills), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`).
- Top-level content directories: `skills/` (functional skills the agent invokes mid-task — utilities, briefs, packagers; see `skills/AGENTS.md`), `design-templates/` (rendering catalogue: decks, prototypes, image/video/audio templates; see `design-templates/AGENTS.md` and `specs/current/skills-and-design-templates.md`), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`).
- `apps/web` is the Next.js 16 App Router + React 18 web runtime; do not restore `apps/nextjs`.
- `apps/daemon` is the local privileged daemon and `od` bin. It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving.
- `apps/desktop` is the Electron shell; it discovers the web URL through sidecar IPC.

View file

@ -17,6 +17,10 @@ export interface PathDeps {
ARTIFACTS_DIR: string;
BUNDLED_PETS_DIR: string;
DESIGN_SYSTEMS_DIR: string;
// Bundled rendering catalogue (see specs/current/skills-and-design-templates.md).
// Distinct from SKILLS_DIR so the EntryView Templates surface and the
// Settings → Skills surface stay decoupled.
DESIGN_TEMPLATES_DIR: string;
OD_BIN: string;
PROJECT_ROOT: string;
PROJECTS_DIR: string;
@ -25,12 +29,23 @@ export interface PathDeps {
RUNTIME_DATA_DIR_CANONICAL: string;
SKILLS_DIR: string;
USER_DESIGN_SYSTEMS_DIR: string;
// Mirror of USER_SKILLS_DIR rooted at DESIGN_TEMPLATES_DIR so user
// imports of templates do not collide with imports of functional skills.
USER_DESIGN_TEMPLATES_DIR: string;
USER_SKILLS_DIR: string;
}
export interface ResourceDeps {
listAllDesignSystems: () => Promise<Array<DesignSystemSummary & { source?: string }>>;
listAllSkills: () => Promise<Array<SkillInfo & { source?: string }>>;
// Mirrors listAllSkills but scans DESIGN_TEMPLATE_ROOTS so the Templates
// surface only sees rendering-catalogue entries.
listAllDesignTemplates: () => Promise<Array<SkillInfo & { source?: string }>>;
// Spans both functional skills and design templates so cross-surface
// resolvers (chat run system prompt, orbit template resolver,
// /api/skills/:id/example, /api/skills/:id/assets/*) keep working when
// a stored project.skillId points at either root.
listAllSkillLikeEntries: () => Promise<Array<SkillInfo & { source?: string }>>;
mimeFor: (filePath: string) => string;
}

View file

@ -881,6 +881,15 @@ const DESIGN_SYSTEMS_DIR = resolveDaemonResourceDir(
'design-systems',
path.join(PROJECT_ROOT, 'design-systems'),
);
// Renderable templates pulled out of `skills/` by the skills/design-templates
// split (PR #955) so the EntryView Templates tab gets the large rendering
// catalogue and Settings → Skills only carries functional skills the agent
// invokes mid-task. See specs/current/skills-and-design-templates.md.
const DESIGN_TEMPLATES_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'design-templates',
path.join(PROJECT_ROOT, 'design-templates'),
);
const CRAFT_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'craft',
@ -975,8 +984,26 @@ const CRITIQUE_ARTIFACTS_DIR = path.join(RUNTIME_DATA_DIR, 'critique-artifacts')
const PROJECTS_DIR = path.join(RUNTIME_DATA_DIR, 'projects');
const USER_SKILLS_DIR = path.join(RUNTIME_DATA_DIR, 'skills');
const USER_DESIGN_SYSTEMS_DIR = path.join(RUNTIME_DATA_DIR, 'design-systems');
// User-imported design templates mirror USER_SKILLS_DIR but are scanned
// against DESIGN_TEMPLATES_DIR rather than SKILLS_DIR so the EntryView
// Templates surface and the Settings → Skills surface stay decoupled.
const USER_DESIGN_TEMPLATES_DIR = path.join(RUNTIME_DATA_DIR, 'design-templates');
// Multi-root tuples used everywhere the daemon resolves a skill / template
// id without knowing which surface it came from. SKILL_ROOTS drives
// Settings → Skills; DESIGN_TEMPLATE_ROOTS drives the EntryView Templates
// gallery; ALL_SKILL_LIKE_ROOTS spans both for chat run system-prompt
// composition and the orbit template resolver, where stored project ids
// can resolve to either root after the split.
const SKILL_ROOTS = [USER_SKILLS_DIR, SKILLS_DIR];
const DESIGN_TEMPLATE_ROOTS = [USER_DESIGN_TEMPLATES_DIR, DESIGN_TEMPLATES_DIR];
const ALL_SKILL_LIKE_ROOTS = [
USER_SKILLS_DIR,
USER_DESIGN_TEMPLATES_DIR,
SKILLS_DIR,
DESIGN_TEMPLATES_DIR,
];
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR]) {
for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR, USER_DESIGN_TEMPLATES_DIR]) {
fs.mkdirSync(dir, { recursive: true });
}
fs.mkdirSync(CRITIQUE_ARTIFACTS_DIR, { recursive: true });
@ -2044,24 +2071,26 @@ export async function startServer({
const app = express();
app.use(express.json({ limit: '4mb' }));
// Multi-directory scanning: merge built-in and user-installed skills/DS.
// Built-in items win on ID collisions (higher priority per skills-protocol.md).
// Multi-directory scanning shared by every skill / template surface. The
// helpers delegate to listSkills(roots) which walks roots in priority
// order, tags each entry with the SkillSource ('user' for the user
// root, 'built-in' for the bundled root) the contracts package
// declares, and lets a user-imported entry shadow a built-in one of
// the same id without erasing the built-in copy.
async function listAllSkills() {
const builtIn = (await listSkills(SKILLS_DIR)).map((s) => ({
...s,
source: 'built-in',
}));
let installed = [];
try {
installed = (await listSkills(USER_SKILLS_DIR)).map((s) => ({
...s,
source: 'installed',
}));
} catch {
// User directory may not exist yet or be unreadable.
}
const seen = new Set(builtIn.map((s) => s.id));
return [...builtIn, ...installed.filter((s) => !seen.has(s.id))];
return listSkills(SKILL_ROOTS);
}
async function listAllDesignTemplates() {
return listSkills(DESIGN_TEMPLATE_ROOTS);
}
// Spans both roots so chat run system-prompt composition and the orbit
// template resolver can resolve a stored project.skillId regardless of
// which surface created the project after the skills/design-templates
// split. Keep in sync with SKILL_ROOTS + DESIGN_TEMPLATE_ROOTS above.
async function listAllSkillLikeEntries() {
return listSkills(ALL_SKILL_LIKE_ROOTS);
}
async function listAllDesignSystems() {
@ -2593,6 +2622,8 @@ export async function startServer({
RUNTIME_DATA_DIR_CANONICAL,
DESIGN_SYSTEMS_DIR,
USER_DESIGN_SYSTEMS_DIR,
DESIGN_TEMPLATES_DIR,
USER_DESIGN_TEMPLATES_DIR,
SKILLS_DIR,
USER_SKILLS_DIR,
PROMPT_TEMPLATES_DIR,
@ -2802,7 +2833,13 @@ export async function startServer({
registerStaticResourceRoutes(app, {
http: httpDeps,
paths: pathDeps,
resources: { listAllSkills, listAllDesignSystems, mimeFor },
resources: {
listAllSkills,
listAllDesignTemplates,
listAllSkillLikeEntries,
listAllDesignSystems,
mimeFor,
},
});
registerProjectArtifactRoutes(app, {
http: httpDeps,
@ -2899,8 +2936,11 @@ export async function startServer({
let skillCraftRequires = [];
let activeSkillDir = null;
if (effectiveSkillId) {
// Span both functional skills and design templates so a project
// saved against either surface keeps its system prompt after the
// skills/design-templates split. See specs/current/skills-and-design-templates.md.
const skill = findSkillById(
await listAllSkills(),
await listAllSkillLikeEntries(),
effectiveSkillId,
);
if (skill) {
@ -4215,7 +4255,11 @@ export async function startServer({
});
orbitService.setTemplateResolver(async (skillId) => {
const skills = await listAllSkills();
// Orbit templates (live-artifact, etc.) live under design-templates after
// the split, but earlier projects may still point at functional-skill
// ids for the same purpose — search both roots so a stored project id
// keeps resolving through one or the other.
const skills = await listAllSkillLikeEntries();
const skill = findSkillById(skills, skillId);
if (!skill || skill.scenario !== 'orbit') return null;
return {
@ -4361,7 +4405,13 @@ export async function startServer({
nativeDialogs: nativeDialogDeps,
research: researchDeps,
mcp: { pendingAuth: mcpPendingAuth, daemonUrlRef },
resources: { listAllSkills, listAllDesignSystems, mimeFor },
resources: {
listAllSkills,
listAllDesignTemplates,
listAllSkillLikeEntries,
listAllDesignSystems,
mimeFor,
},
routines: { routineService },
validation: validationDeps,
finalize: finalizeDeps,

View file

@ -1,9 +1,13 @@
// Skill registry. Scans <projectRoot>/skills/* for SKILL.md files, parses
// Skill registry. Scans one or more on-disk roots for SKILL.md files, parses
// front-matter, returns listing. No watching in this MVP — re-scans on every
// GET /api/skills, which is fine for dozens of skills.
//
// Roots are passed in priority order: the first one wins on `id` collisions
// so user-imported skills under USER_SKILLS_DIR can shadow a built-in skill
// of the same name without erasing the built-in copy.
import type { Dirent } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises";
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { parseFrontmatter } from "./frontmatter.js";
import { SKILLS_CWD_ALIAS } from "./cwd-aliases.js";
@ -30,9 +34,20 @@ interface SkillFrontmatter extends JsonRecord {
name?: unknown;
description?: unknown;
triggers?: unknown;
od?: JsonRecord & { craft?: JsonRecord; preview?: JsonRecord; design_system?: JsonRecord };
od?: JsonRecord & {
craft?: JsonRecord;
preview?: JsonRecord;
design_system?: JsonRecord;
category?: unknown;
};
}
// Indicates whether a skill came from a user-writable root (the first root
// passed to listSkills) or from a built-in repo root (any later root). The
// UI uses this to render an origin pill and to gate destructive actions:
// only `user` skills can be deleted via /api/skills/:id.
export type SkillSource = "user" | "built-in";
export interface SkillInfo {
id: string;
name: string;
@ -40,9 +55,16 @@ export interface SkillInfo {
triggers: unknown[];
mode: SkillMode;
surface: SkillSurface;
source: SkillSource;
craftRequires: string[];
platform: SkillPlatform;
scenario: string;
// Optional human-readable category (e.g. "image-generation", "video",
// "design-systems"). Surfaced as a filter pill in Settings → Skills so a
// large pre-loaded catalogue (e.g. curated design/creative skills from the
// upstream awesome-* lists) stays scannable. Not part of system-prompt
// composition; purely a UI hint.
category: string | null;
previewType: string;
designSystemRequired: boolean;
defaultFor: string[];
@ -91,125 +113,162 @@ export function findSkillById(skills: unknown, id: unknown): SkillInfo | undefin
return (skills as SkillInfo[]).find((s) => s.id === canonical);
}
export async function listSkills(skillsRoot: string): Promise<SkillInfo[]> {
// Accept either a single root path or an array. When given multiple roots,
// the first one wins on id collisions so user-imported skills under
// USER_SKILLS_DIR can shadow a built-in skill of the same name without
// erasing the bundled copy. Each surfaced summary carries a `source`
// (`"user"` for the first root, `"built-in"` for any later root) so the
// UI can render an origin pill and gate the delete control.
export async function listSkills(
skillsRoots: string | readonly string[],
): Promise<SkillInfo[]> {
const roots = Array.isArray(skillsRoots) ? skillsRoots : [skillsRoots];
const out: SkillInfo[] = [];
let entries: Dirent[] = [];
try {
entries = await readdir(skillsRoot, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const dir = path.join(skillsRoot, entry.name);
const skillPath = path.join(dir, "SKILL.md");
const seenIds = new Set<string>();
for (let rootIdx = 0; rootIdx < roots.length; rootIdx += 1) {
const skillsRoot = roots[rootIdx];
if (!skillsRoot) continue;
const source: SkillSource = rootIdx === 0 ? "user" : "built-in";
let entries: Dirent[] = [];
try {
const stats = await stat(skillPath);
if (!stats.isFile()) continue;
const raw = await readFile(skillPath, "utf8");
const { data: parsedData, body } = parseFrontmatter(raw) as { data: unknown; body: string };
const data = asSkillFrontmatter(parsedData);
const hasAttachments = await dirHasAttachments(dir);
const mode = normalizeMode(data.od?.mode, body, data.description);
const surface = normalizeSurface(data.od?.surface, mode);
const platform = normalizePlatform(
data.od?.platform,
mode,
body,
data.description
);
const scenario = normalizeScenario(
data.od?.scenario,
body,
data.description
);
const designSystemRequired =
typeof data.od?.design_system?.requires === "boolean"
? data.od.design_system.requires
: true;
const upstream =
typeof data.od?.upstream === "string" ? data.od.upstream : null;
const previewType =
typeof data.od?.preview?.type === "string" ? data.od.preview.type : "html";
const parentId = typeof data.name === "string" && data.name ? data.name : entry.name;
const description = typeof data.description === "string" ? data.description : "";
const parentBody = hasAttachments ? withSkillRootPreamble(body, dir) : body;
// Pre-compute derived examples so the parent entry can advertise
// `aggregatesExamples` in the same push. The frontend uses that
// flag to hide the parent card from the gallery (its preview would
// duplicate one of the derived cards), while the daemon keeps the
// parent in the listing so `findSkillById` still resolves it for
// system-prompt composition and id alias lookups.
const derivedExamples = await collectDerivedExamples(dir);
const aggregatesExamples = derivedExamples.length > 0;
out.push({
id: parentId,
name: parentId,
description,
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
surface,
craftRequires: normalizeCraftRequires(data.od?.craft?.requires),
platform,
scenario,
previewType,
designSystemRequired,
defaultFor: normalizeDefaultFor(data.od?.default_for),
upstream,
featured: normalizeFeatured(data.od?.featured),
// Optional metadata hints used by 'Use this prompt' fast-create so
// the resulting project mirrors the shipped example.html. Each hint
// is only consumed when its kind matches the skill mode; missing
// hints fall back to the same defaults the new-project form uses.
fidelity: normalizeFidelity(data.od?.fidelity),
speakerNotes: normalizeBoolHint(data.od?.speaker_notes),
animations: normalizeBoolHint(data.od?.animations),
examplePrompt: derivePrompt(data),
aggregatesExamples,
body: parentBody,
dir,
});
// Surface every example sitting next to a SKILL.md as its own card so
// a single skill (e.g. live-artifact) can ship a small gallery of
// hand-crafted samples without needing one SKILL.md per sample. Each
// derived card inherits the parent's mode/platform/surface/scenario
// so existing TYPE/SURFACE filters keep working; the synthetic id
// `<parent>:<child>` lets `/api/skills/:id/example` resolve straight
// to the matching HTML on disk. We deliberately do not inherit
// `featured` so derived cards never crowd the magazine row.
for (const example of derivedExamples) {
entries = await readdir(skillsRoot, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const dir = path.join(skillsRoot, entry.name);
const skillPath = path.join(dir, "SKILL.md");
try {
const stats = await stat(skillPath);
if (!stats.isFile()) continue;
const raw = await readFile(skillPath, "utf8");
const { data: parsedData, body } = parseFrontmatter(raw) as {
data: unknown;
body: string;
};
const data = asSkillFrontmatter(parsedData);
const parentId =
typeof data.name === "string" && data.name ? data.name : entry.name;
// Skip when an earlier root already surfaced this id — the first
// root wins so user shadows built-in. Done before we read the
// rest of the frontmatter to keep the shadowed-skill path cheap.
if (seenIds.has(parentId)) continue;
seenIds.add(parentId);
const hasAttachments = await dirHasAttachments(dir);
const mode = normalizeMode(data.od?.mode, body, data.description);
const surface = normalizeSurface(data.od?.surface, mode);
const platform = normalizePlatform(
data.od?.platform,
mode,
body,
data.description,
);
const scenario = normalizeScenario(
data.od?.scenario,
body,
data.description,
);
const category = normalizeCategory(data.od?.category);
const designSystemRequired =
typeof data.od?.design_system?.requires === "boolean"
? data.od.design_system.requires
: true;
const upstream =
typeof data.od?.upstream === "string" ? data.od.upstream : null;
const previewType =
typeof data.od?.preview?.type === "string"
? data.od.preview.type
: "html";
const description =
typeof data.description === "string" ? data.description : "";
const parentBody = hasAttachments
? withSkillRootPreamble(body, dir)
: body;
// Pre-compute derived examples so the parent entry can advertise
// `aggregatesExamples` in the same push. The frontend uses that
// flag to hide the parent card from the gallery (its preview would
// duplicate one of the derived cards), while the daemon keeps the
// parent in the listing so `findSkillById` still resolves it for
// system-prompt composition and id alias lookups.
const derivedExamples = await collectDerivedExamples(dir);
const aggregatesExamples = derivedExamples.length > 0;
out.push({
id: `${parentId}:${example.key}`,
name: humanizeExampleName(example.key),
id: parentId,
name: parentId,
description,
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
surface,
craftRequires: [],
source,
craftRequires: normalizeCraftRequires(data.od?.craft?.requires),
platform,
scenario,
category,
previewType,
designSystemRequired,
defaultFor: [],
defaultFor: normalizeDefaultFor(data.od?.default_for),
upstream,
featured: null,
featured: normalizeFeatured(data.od?.featured),
// Optional metadata hints used by 'Use this prompt' fast-create
// so the resulting project mirrors the shipped example.html.
// Each hint is only consumed when its kind matches the skill
// mode; missing hints fall back to the new-project defaults.
fidelity: normalizeFidelity(data.od?.fidelity),
speakerNotes: normalizeBoolHint(data.od?.speaker_notes),
animations: normalizeBoolHint(data.od?.animations),
examplePrompt: derivePrompt(data),
aggregatesExamples: false,
// Inherit the parent's full SKILL.md body so 'Use this prompt'
// on a derived card seeds the agent with the same workflow the
// parent describes. Without this, picking a derived card would
// compose an empty system prompt and the agent would have no
// skill instructions.
aggregatesExamples,
body: parentBody,
dir,
});
// Surface every example sitting next to a SKILL.md as its own card
// so a single skill (e.g. live-artifact) can ship a small gallery
// of hand-crafted samples without needing one SKILL.md per sample.
// Each derived card inherits the parent's mode/platform/surface/
// scenario so existing TYPE/SURFACE filters keep working; the
// synthetic id `<parent>:<child>` lets `/api/skills/:id/example`
// resolve straight to the matching HTML on disk. We deliberately
// do not inherit `featured` so derived cards never crowd the
// magazine row.
for (const example of derivedExamples) {
const derivedId = `${parentId}:${example.key}`;
if (seenIds.has(derivedId)) continue;
seenIds.add(derivedId);
out.push({
id: derivedId,
name: humanizeExampleName(example.key),
description,
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
surface,
source,
craftRequires: [],
platform,
scenario,
category,
previewType,
designSystemRequired,
defaultFor: [],
upstream,
featured: null,
fidelity: normalizeFidelity(data.od?.fidelity),
speakerNotes: normalizeBoolHint(data.od?.speaker_notes),
animations: normalizeBoolHint(data.od?.animations),
examplePrompt: derivePrompt(data),
aggregatesExamples: false,
// Inherit the parent's full SKILL.md body so 'Use this prompt'
// on a derived card seeds the agent with the same workflow
// the parent describes. Without this, picking a derived card
// would compose an empty system prompt.
body: parentBody,
dir,
});
}
} catch {
// Skip unreadable entries — this is discovery, not validation.
}
} catch {
// Skip unreadable entries — this is discovery, not validation.
}
}
return out;
@ -512,6 +571,21 @@ const KNOWN_SCENARIOS = new Set([
"education",
"personal",
]);
// Normalise a free-form category tag. Limits the set of accepted characters
// to lowercase letters, digits, and dashes so the value can flow straight
// into the UI as a filter pill class without escaping. Empty / non-string
// values become null so the filter row hides instead of rendering an empty
// pill. We intentionally do not lock down a fixed vocabulary here — the
// curated catalogue under skills/ owns the canonical category set, and
// user-imported skills are free to introduce their own.
function normalizeCategory(value: unknown): string | null {
if (typeof value !== "string") return null;
const slug = value.trim().toLowerCase();
if (!slug) return null;
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null;
return slug.slice(0, 64);
}
function normalizeScenario(value: unknown, body: unknown, description: unknown): string {
if (typeof value === "string") {
const v = value.trim().toLowerCase();
@ -533,3 +607,356 @@ function normalizeScenario(value: unknown, body: unknown, description: unknown):
// Surface the vocabulary so callers (frontend filter UI) could mirror it
// later if they want to. Not exported today, kept here for documentation.
void KNOWN_SCENARIOS;
// ---------------------------------------------------------------------------
// User-skill import / delete primitives
// ---------------------------------------------------------------------------
// User-imported skills live under <runtimeData>/user-skills/<slug>/SKILL.md.
// We treat that directory as fully owned by the daemon, so import/delete are
// simple: write or rm the slug folder and let listSkills() pick the change up
// on the next /api/skills request. The slug is derived from the user-supplied
// `name` (alphanumeric + dash) and prefixed with `user-` only when an existing
// built-in skill folder shares the same id, to avoid colliding with a
// repo-shipped folder.
export type SkillImportErrorCode =
| "BAD_REQUEST"
| "CONFLICT"
| "NOT_FOUND"
| "INTERNAL_ERROR";
export class SkillImportError extends Error {
readonly code: SkillImportErrorCode;
constructor(code: SkillImportErrorCode, message: string) {
super(message);
this.code = code;
this.name = "SkillImportError";
}
}
const RESERVED_SLUGS = new Set(["", ".", ".."]);
export function slugifySkillName(name: unknown): string {
if (typeof name !== "string") return "";
const lowered = name.trim().toLowerCase();
const cleaned = lowered
.replace(/[^a-z0-9\-_]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
if (!cleaned || RESERVED_SLUGS.has(cleaned)) return "";
return cleaned.slice(0, 64);
}
function escapeYamlString(value: unknown): string {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
interface BuildSkillMarkdownInput {
name: string;
description: string;
body: string;
triggers: string[];
}
function buildSkillMarkdown({
name,
description,
body,
triggers,
}: BuildSkillMarkdownInput): string {
// Always emit `name` as a quoted scalar so YAML never coerces it to a
// number / boolean / null. Without the quotes, parseYamlSubset() would
// re-read names like '123', 'true', or 'null' as non-string literals,
// and importUserSkill()'s round-trip ("imported skill could not be
// re-read") would fail for those ids. See PR #955 review feedback.
const lines: string[] = ["---", `name: "${escapeYamlString(name)}"`];
if (description && description.trim().length > 0) {
lines.push("description: |");
for (const ln of description.trim().split(/\r?\n/)) {
lines.push(` ${ln}`);
}
}
if (triggers.length > 0) {
lines.push("triggers:");
for (const t of triggers) {
const trimmed = typeof t === "string" ? t.trim() : "";
if (!trimmed) continue;
lines.push(` - "${escapeYamlString(trimmed)}"`);
}
}
lines.push("---", "", body.trim(), "");
return lines.join("\n");
}
export interface SkillImportInput {
name?: unknown;
description?: unknown;
body?: unknown;
triggers?: unknown;
}
export interface SkillImportResult {
id: string;
slug: string;
dir: string;
}
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err) && typeof err === "object" && "code" in (err as object);
}
export async function importUserSkill(
userSkillsRoot: string,
input: SkillImportInput,
): Promise<SkillImportResult> {
const name = typeof input?.name === "string" ? input.name.trim() : "";
const description =
typeof input?.description === "string" ? input.description : "";
const body = typeof input?.body === "string" ? input.body : "";
if (!name) {
throw new SkillImportError("BAD_REQUEST", "skill name required");
}
if (!body || body.trim().length === 0) {
throw new SkillImportError("BAD_REQUEST", "skill body required");
}
const slug = slugifySkillName(name);
if (!slug) {
throw new SkillImportError(
"BAD_REQUEST",
"skill name must produce a valid slug (a-z, 0-9, dash)",
);
}
const triggersRaw = Array.isArray(input?.triggers) ? input.triggers : [];
const triggers = triggersRaw
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(Boolean);
await mkdir(userSkillsRoot, { recursive: true });
const dir = path.join(userSkillsRoot, slug);
// Refuse to overwrite an existing folder. The caller can DELETE first
// when intentionally replacing a skill.
try {
const existing = await stat(dir);
if (existing) {
throw new SkillImportError(
"CONFLICT",
`a user skill with slug "${slug}" already exists`,
);
}
} catch (err) {
if (err instanceof SkillImportError) throw err;
if (isErrnoException(err) && err.code !== "ENOENT") {
throw new SkillImportError(
"INTERNAL_ERROR",
`could not check skill dir: ${err.message ?? err}`,
);
}
}
await mkdir(dir, { recursive: true });
const md = buildSkillMarkdown({ name, description, body, triggers });
await writeFile(path.join(dir, "SKILL.md"), md, "utf8");
return { id: name, slug, dir };
}
export interface SkillUpdateInput {
name: string;
description?: unknown;
body?: unknown;
triggers?: unknown;
// Original on-disk dir for the skill being edited. When the caller is
// shadowing a built-in for the first time (i.e. `sourceDir` differs
// from the user shadow target and the shadow folder does not exist
// yet), `updateUserSkill` clones every entry except `SKILL.md` from
// `sourceDir` into the shadow so the bundled side tree (assets/,
// references/, scripts/, examples/, ...) keeps resolving through the
// /api/skills/:id/files, /example, and /assets/* routes after the
// edit. Without this, listSkills() promotes the shadow folder to the
// active dir but the resolvers see only the user-authored SKILL.md
// and the rest of the skill silently disappears (mrcfps PR #955
// review). When omitted (or pointing at the same folder) the call
// only writes SKILL.md and leaves any previously-cloned side files
// alone so subsequent edits do not clobber the user's tweaks.
sourceDir?: string;
}
// Overwrite (or create-on-demand) a user-owned SKILL.md. For built-in
// skills this writes a "shadow" copy under USER_SKILLS_DIR/<slug>/ that
// the next listSkills() pass will surface in place of the bundled copy.
// On the very first shadow-creation we also clone the built-in's side
// files (assets/, references/, scripts/, examples/, ...) so the shadow
// folder is self-contained and downstream resolvers — `/api/skills/:id/
// files`, `/example`, `/assets/*`, the system-prompt preamble, and the
// per-turn cwd staging — keep finding the bundled tree even though the
// user's `SKILL.md` is what we serve.
export async function updateUserSkill(
userSkillsRoot: string,
input: SkillUpdateInput,
): Promise<SkillImportResult> {
const name = typeof input?.name === "string" ? input.name.trim() : "";
if (!name) {
throw new SkillImportError("BAD_REQUEST", "skill name required");
}
const description =
typeof input?.description === "string" ? input.description : "";
const body = typeof input?.body === "string" ? input.body : "";
if (!body || body.trim().length === 0) {
throw new SkillImportError("BAD_REQUEST", "skill body required");
}
const slug = slugifySkillName(name);
if (!slug) {
throw new SkillImportError(
"BAD_REQUEST",
"skill name must produce a valid slug (a-z, 0-9, dash)",
);
}
const triggersRaw = Array.isArray(input?.triggers) ? input.triggers : [];
const triggers = triggersRaw
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(Boolean);
await mkdir(userSkillsRoot, { recursive: true });
const dir = path.join(userSkillsRoot, slug);
const dirExisted = await stat(dir)
.then(() => true)
.catch(() => false);
// Only clone on the very first shadow over a built-in. If `dirExisted`
// is true, we are editing an already-shadowed skill (or a pure user
// skill); re-cloning would clobber the user's tweaks under the side
// tree. If `sourceDir` is missing or already points at the shadow,
// there is nothing to clone — same dir.
const shouldCloneSideFiles =
!dirExisted &&
typeof input.sourceDir === "string" &&
input.sourceDir.length > 0 &&
path.resolve(input.sourceDir) !== path.resolve(dir);
if (shouldCloneSideFiles) {
try {
await cloneSkillSideFiles(input.sourceDir!, dir);
} catch {
// Non-fatal: SKILL.md still lands below. Side-file resolvers will
// 404 individual entries instead of erasing the whole edit, which
// matches the pre-fix behaviour for unreachable assets.
await mkdir(dir, { recursive: true });
}
} else {
await mkdir(dir, { recursive: true });
}
const md = buildSkillMarkdown({ name, description, body, triggers });
await writeFile(path.join(dir, "SKILL.md"), md, "utf8");
return { id: name, slug, dir };
}
// Copy every entry in `sourceDir` into `destDir` except `SKILL.md` and
// dotfiles. Used by `updateUserSkill` to build a self-contained shadow
// folder over a built-in skill on first edit. We dereference symlinks
// for the same reason `stageActiveSkill` does — the shadow lives under
// runtime data and must not link back into a read-only resource tree.
async function cloneSkillSideFiles(
sourceDir: string,
destDir: string,
): Promise<void> {
await mkdir(destDir, { recursive: true });
let entries: Dirent[] = [];
try {
entries = await readdir(sourceDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === "SKILL.md") continue;
if (entry.name.startsWith(".")) continue;
const src = path.join(sourceDir, entry.name);
const dst = path.join(destDir, entry.name);
await cp(src, dst, {
recursive: true,
dereference: true,
preserveTimestamps: true,
});
}
}
export interface SkillFileEntry {
// Path relative to the skill's on-disk directory. Forward-slashes only.
path: string;
// 'file' | 'directory'. We do not surface symlinks or other file types.
kind: "file" | "directory";
// Byte size for files; null for directories.
size: number | null;
}
const SKILL_FILES_MAX_ENTRIES = 500;
const SKILL_FILES_MAX_DEPTH = 6;
// Walk a skill directory and return a flat list of files/folders. Used by
// the Settings → Skills detail panel to render a small file tree next to
// the SKILL.md preview. Skips dotfiles, symlinks, and anything past
// `SKILL_FILES_MAX_DEPTH` so a pathological skill folder cannot stall the
// daemon. The cap on entries protects against large bundled assets folders.
export async function listSkillFiles(skillDir: string): Promise<SkillFileEntry[]> {
const out: SkillFileEntry[] = [];
const seen = new Set<string>();
async function walk(dir: string, depth: number): Promise<void> {
if (depth > SKILL_FILES_MAX_DEPTH) return;
if (out.length >= SKILL_FILES_MAX_ENTRIES) return;
let entries: Dirent[] = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (out.length >= SKILL_FILES_MAX_ENTRIES) return;
if (entry.name.startsWith(".")) continue;
// Refuse symlinks defensively — readdir's withFileTypes already
// returns isSymbolicLink(), but we double-check via the Dirent's
// kind methods to keep this aligned with the read paths elsewhere.
if (entry.isSymbolicLink()) continue;
const abs = path.join(dir, entry.name);
const rel = path.relative(skillDir, abs).split(path.sep).join("/");
if (seen.has(rel)) continue;
seen.add(rel);
if (entry.isDirectory()) {
out.push({ path: rel, kind: "directory", size: null });
await walk(abs, depth + 1);
} else if (entry.isFile()) {
let size: number | null = null;
try {
const s = await stat(abs);
size = s.size;
} catch {
size = null;
}
out.push({ path: rel, kind: "file", size });
}
}
}
await walk(skillDir, 0);
return out;
}
export async function deleteUserSkill(
userSkillsRoot: string,
id: string,
): Promise<void> {
const slug = slugifySkillName(id);
if (!slug) {
throw new SkillImportError("BAD_REQUEST", "invalid skill id");
}
const dir = path.join(userSkillsRoot, slug);
const root = path.resolve(userSkillsRoot);
const target = path.resolve(dir);
if (target !== dir || !target.startsWith(root + path.sep)) {
// Defence-in-depth: refuse to delete anything outside the user-skills
// root. The slugify above already strips traversal characters.
throw new SkillImportError("BAD_REQUEST", "invalid skill path");
}
try {
await stat(target);
} catch (err) {
if (isErrnoException(err) && err.code === "ENOENT") {
throw new SkillImportError("NOT_FOUND", "user skill not found");
}
throw err;
}
await rm(target, { recursive: true, force: true });
}

View file

@ -2,7 +2,15 @@ import type { Express } from 'express';
import path from 'node:path';
import fs from 'node:fs';
import { detectAgents } from './agents.js';
import { findSkillById, splitDerivedSkillId } from './skills.js';
import {
SkillImportError,
deleteUserSkill,
findSkillById,
importUserSkill,
listSkillFiles,
splitDerivedSkillId,
updateUserSkill,
} from './skills.js';
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
import { syncCommunityPets } from './community-pets-sync.js';
import { readDesignSystem } from './design-systems.js';
@ -20,12 +28,20 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
RUNTIME_DATA_DIR,
DESIGN_SYSTEMS_DIR,
USER_DESIGN_SYSTEMS_DIR,
DESIGN_TEMPLATES_DIR,
USER_DESIGN_TEMPLATES_DIR,
SKILLS_DIR,
USER_SKILLS_DIR,
PROMPT_TEMPLATES_DIR,
BUNDLED_PETS_DIR,
} = ctx.paths;
const { listAllSkills, listAllDesignSystems, mimeFor } = ctx.resources;
const {
listAllSkills,
listAllDesignTemplates,
listAllSkillLikeEntries,
listAllDesignSystems,
mimeFor,
} = ctx.resources;
const { isLocalSameOrigin, resolvedPortRef, sendApiError } = ctx.http;
const requireLocalOrigin = (req: any, res: any) => {
if (isLocalSameOrigin(req, resolvedPortRef.current)) return true;
@ -71,6 +87,128 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
}
});
// Design templates — the rendering catalogue. Same shape as /api/skills
// (so the web client can reuse SkillSummary types) but rooted at
// DESIGN_TEMPLATE_ROOTS so the listing stays focused on template-style
// entries without bleeding functional skills into the EntryView gallery.
app.get('/api/design-templates', async (_req, res) => {
try {
const templates = await listAllDesignTemplates();
res.json({
designTemplates: templates.map(({ body, dir: _dir, ...rest }) => ({
...rest,
hasBody: typeof body === 'string' && body.length > 0,
})),
});
} catch (err: any) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/design-templates/:id', async (req, res) => {
try {
const templates = await listAllDesignTemplates();
const template = findSkillById(templates, req.params.id);
if (!template) return res.status(404).json({ error: 'design template not found' });
const { dir: _dir, ...serializable } = template;
res.json(serializable);
} catch (err: any) {
res.status(500).json({ error: String(err) });
}
});
// POST /api/skills/import — write a new SKILL.md under USER_SKILLS_DIR
// from a UI-supplied body. The next /api/skills request surfaces it
// automatically because listSkills walks USER_SKILLS_DIR first.
app.post('/api/skills/import', async (req, res) => {
try {
const result = await importUserSkill(USER_SKILLS_DIR, req.body || {});
const skills = await listAllSkills();
const skill = findSkillById(skills, result.id);
if (!skill) {
return sendApiError(
res,
500,
'INTERNAL_ERROR',
'imported skill was not found in catalog',
);
}
const { dir: _dir, body: _body, ...serializable } = skill;
res.status(201).json({
skill: {
...serializable,
hasBody: typeof skill.body === 'string' && skill.body.length > 0,
},
});
} catch (err: any) {
if (err instanceof SkillImportError) {
const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'BAD_REQUEST' ? 400 : 500;
return sendApiError(res, status, err.code, err.message);
}
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
// PUT /api/skills/:id — update an existing user-managed skill's
// SKILL.md (and, when the user edits a built-in for the first time,
// clone its side files into USER_SKILLS_DIR/<slug>/ so subsequent
// /api/skills/:id/{files,example,assets/*} requests keep resolving
// the bundled assets/references/scripts/examples). See PR #955 review.
app.put('/api/skills/:id', async (req, res) => {
try {
const skills = await listAllSkills();
const skill = findSkillById(skills, req.params.id);
if (!skill) {
return sendApiError(res, 404, 'NOT_FOUND', 'skill not found');
}
const result = await updateUserSkill(USER_SKILLS_DIR, {
...(req.body || {}),
id: skill.id,
sourceDir: skill.dir,
});
const next = await listAllSkills();
const updated = findSkillById(next, result.id);
if (!updated) {
return sendApiError(
res,
500,
'INTERNAL_ERROR',
'updated skill was not found in catalog',
);
}
const { dir: _dir, body: _body, ...serializable } = updated;
res.json({
skill: {
...serializable,
hasBody: typeof updated.body === 'string' && updated.body.length > 0,
},
});
} catch (err: any) {
if (err instanceof SkillImportError) {
const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'BAD_REQUEST' ? 400 : 500;
return sendApiError(res, status, err.code, err.message);
}
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
// GET /api/skills/:id/files — flat listing of the files that ship with
// a skill. Used by the Settings → Skills detail panel to render the
// file tree (capped server-side to keep payload bounded).
app.get('/api/skills/:id/files', async (req, res) => {
try {
const skills = await listAllSkills();
const skill = findSkillById(skills, req.params.id);
if (!skill) {
return sendApiError(res, 404, 'NOT_FOUND', 'skill not found');
}
const files = await listSkillFiles(skill.dir);
res.json({ files });
} catch (err: any) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
// Codex hatch-pet registry — pets packaged by the upstream `hatch-pet`
// skill under `${CODEX_HOME:-$HOME/.codex}/pets/`. Surfaced so the web
// pet settings can offer one-click adoption of recently-hatched pets.
@ -259,7 +397,11 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
// a real preview on its parent card instead of returning 404.
app.get('/api/skills/:id/example', async (req, res) => {
try {
const skills = await listAllSkills();
// Span both functional skills and design templates: rendered example
// HTML rewrites assets to /api/skills/<id>/... and we want those URLs
// to keep resolving regardless of which root owns the backing folder
// after the skills/design-templates split.
const skills = await listAllSkillLikeEntries();
// 1. Derived `<parent>:<child>` id — resolve straight to the matching
// file under <parentDir>/examples/. Done before findSkillById so the
@ -381,7 +523,9 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
// contributors can preview `example.html` straight from disk.
app.get('/api/skills/:id/assets/*', async (req, res) => {
try {
const skills = await listAllSkills();
// Same rationale as /example above — assets need to resolve whether
// the owning skill folder lives under skills/ or design-templates/.
const skills = await listAllSkillLikeEntries();
const skill = findSkillById(skills, req.params.id);
if (!skill) {
return res.status(404).type('text/plain').send('skill not found');

View file

@ -9,8 +9,14 @@ import { composeSystemPrompt } from '../../src/prompts/system.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../../..');
const liveArtifactRoot = path.join(repoRoot, 'skills/live-artifact');
const liveArtifactSkillPath = path.join(repoRoot, 'skills/live-artifact/SKILL.md');
// `live-artifact` moved from skills/ to design-templates/ in PR #955 as
// part of the skills/design-templates split (see specs/current/
// skills-and-design-templates.md). The root path now points there.
const liveArtifactRoot = path.join(repoRoot, 'design-templates/live-artifact');
const liveArtifactSkillPath = path.join(
repoRoot,
'design-templates/live-artifact/SKILL.md',
);
const liveArtifactSkillMarkdown = readFileSync(liveArtifactSkillPath, 'utf8');
const liveArtifactSkillBody = [
`> **Skill root (absolute):** \`${liveArtifactRoot}\``,
@ -25,8 +31,13 @@ const liveArtifactSkillBody = [
liveArtifactSkillMarkdown.replace(/^---[\s\S]*?---\n\n/, '').trim(),
].join('\n');
const hyperframesRoot = path.join(repoRoot, 'skills/hyperframes');
const hyperframesSkillPath = path.join(repoRoot, 'skills/hyperframes/SKILL.md');
// `hyperframes` also moved to design-templates/ in PR #955 — same split
// as `live-artifact` above.
const hyperframesRoot = path.join(repoRoot, 'design-templates/hyperframes');
const hyperframesSkillPath = path.join(
repoRoot,
'design-templates/hyperframes/SKILL.md',
);
const hyperframesSkillMarkdown = readFileSync(hyperframesSkillPath, 'utf8');
const hyperframesSkillBody = [
`> **Skill root (absolute):** \`${hyperframesRoot}\``,

View file

@ -0,0 +1,131 @@
/**
* Coverage for `DELETE /api/skills/:id`. After review feedback on PR #955,
* the route resolves the on-disk folder by `skill.dir` rather than by
* re-slugifying the frontmatter id, so it has to handle both shapes that
* land under USER_SKILLS_DIR:
*
* 1. Import shape `<userSkillsDir>/<slugifySkillName(name)>/SKILL.md`
* (the daemon picks the folder name from the frontmatter `name`).
* 2. Install shape `<userSkillsDir>/<sanitizeRepoName(url)>/` or
* `<userSkillsDir>/<basename(realpath)>/`, often a symlink to the
* user's source tree, where the folder name is independent of the
* frontmatter `name`.
*
* The earlier handler matched only shape 1 and silently 404'd shape 2,
* leaving installed folders behind. These tests pin both shapes plus a
* sibling-traversal guard so the regression cannot return.
*/
import type http from 'node:http';
import { mkdirSync, rmSync, symlinkSync, writeFileSync, existsSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
describe('DELETE /api/skills/:id', () => {
let server: http.Server;
let baseUrl: string;
let userSkillsDir: string;
const tempDirs: string[] = [];
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
userSkillsDir = path.join(dataDir, 'skills');
mkdirSync(userSkillsDir, { recursive: true });
});
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
afterAll(() => {
return new Promise<void>((resolve) => server.close(() => resolve()));
});
function seedSkill(folderName: string, frontmatterName: string): string {
const dir = path.join(userSkillsDir, folderName);
mkdirSync(dir, { recursive: true });
writeFileSync(
path.join(dir, 'SKILL.md'),
`---\nname: ${frontmatterName}\ndescription: fixture for delete route\n---\nbody`,
);
return dir;
}
it('removes an import-shaped skill (folder named after the slugified frontmatter id)', async () => {
const dir = seedSkill('blog-helper', 'blog-helper');
expect(existsSync(dir)).toBe(true);
const resp = await fetch(`${baseUrl}/api/skills/blog-helper`, { method: 'DELETE' });
expect(resp.status).toBe(200);
expect(existsSync(dir)).toBe(false);
});
it('removes an install-shaped skill where the folder name does not match the frontmatter id', async () => {
// GitHub install: `installFromTarget` writes the clone under the
// sanitized repo name, even though the SKILL.md inside advertises a
// different `name`. Re-slugifying the id (the previous handler) would
// miss this directory entirely.
const dir = seedSkill('awesome-skill-pack', 'totally-different-id');
expect(existsSync(dir)).toBe(true);
const resp = await fetch(`${baseUrl}/api/skills/totally-different-id`, { method: 'DELETE' });
expect(resp.status).toBe(200);
expect(existsSync(dir)).toBe(false);
});
it('removes a symlinked local install without following the link to the source tree', async () => {
// Local-path install: `installFromTarget` symlinks the user's source
// directory into USER_SKILLS_DIR. Deleting must unlink the symlink,
// not recurse into and wipe the user's own files.
const sourceTree = mkdtempSync(path.join(tmpdir(), 'od-skill-source-'));
tempDirs.push(sourceTree);
writeFileSync(
path.join(sourceTree, 'SKILL.md'),
`---\nname: linked-skill\ndescription: fixture\n---\nbody`,
);
writeFileSync(path.join(sourceTree, 'guard.txt'), 'must survive delete');
const linkPath = path.join(userSkillsDir, 'linked-skill');
symlinkSync(sourceTree, linkPath);
const resp = await fetch(`${baseUrl}/api/skills/linked-skill`, { method: 'DELETE' });
expect(resp.status).toBe(200);
expect(existsSync(linkPath)).toBe(false);
// The symlink target must remain untouched — unlinkSync, never rm -rf.
expect(existsSync(path.join(sourceTree, 'guard.txt'))).toBe(true);
});
it('refuses to delete a built-in skill', async () => {
// `live-artifact` ships under the repo's design-templates root and is
// surfaced with `source: 'built-in'`; the handler must reject it
// regardless of the resolution path.
const resp = await fetch(`${baseUrl}/api/skills/live-artifact`, { method: 'DELETE' });
// The id may not even appear under the user/built-in skill roots
// (functional-only) — in that case the route returns 404 without
// falling through to a built-in deletion. uninstallById returns 403
// ("Cannot uninstall built-in items") when the id IS present in the
// bundled skills root. Both shapes are safe; what we forbid is a 200
// that would have removed the bundled folder.
expect([400, 403, 404]).toContain(resp.status);
expect(resp.status).not.toBe(200);
});
it('returns 404 for an unknown id', async () => {
const resp = await fetch(`${baseUrl}/api/skills/does-not-exist-${Date.now()}`, {
method: 'DELETE',
});
expect(resp.status).toBe(404);
});
});

View file

@ -5,14 +5,30 @@ import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { rmSync } from 'node:fs';
import { SKILLS_CWD_ALIAS } from '../src/cwd-aliases.js';
import { listSkills } from '../src/skills.js';
import { readFileSync } from 'node:fs';
import {
deleteUserSkill,
importUserSkill,
listSkillFiles,
listSkills,
slugifySkillName,
updateUserSkill,
} from '../src/skills.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../..');
const skillsRoot = path.join(repoRoot, 'skills');
const liveArtifactRoot = path.join(skillsRoot, 'live-artifact');
// `live-artifact`, `dcf-valuation`, `x-research`, and `last30days` were
// reclassified as design templates under the Phase 0 split (see
// specs/current/skills-and-design-templates.md). The body/preamble
// expectations below still apply, but they now read from the design
// templates root rather than skills/.
const designTemplatesRoot = path.join(repoRoot, 'design-templates');
const liveArtifactRoot = path.join(designTemplatesRoot, 'live-artifact');
type SkillCatalogEntry = {
id: string;
@ -60,7 +76,7 @@ function writeSkill(
describe('listSkills', () => {
it('includes the built-in live-artifact skill catalog entry', async () => {
const skills = await listSkills(skillsRoot);
const skills = await listSkills(designTemplatesRoot);
const skill = skills.find((entry: { id: string }) => entry.id === 'live-artifact');
if (!skill) throw new Error('live-artifact skill not found');
@ -87,7 +103,7 @@ describe('listSkills', () => {
});
it('includes the DCF valuation, X research, and Last30Days research skills', async () => {
const skills = await listSkills(skillsRoot);
const skills = await listSkills(designTemplatesRoot);
const byId = new Map(
(skills as SkillCatalogEntry[]).map((skill) => [skill.id, skill]),
);
@ -231,3 +247,349 @@ describe('listSkills preamble', () => {
expect(skill.body).toContain('Body without external files.');
});
});
describe('listSkills multi-root + source tagging', () => {
it('tags entries from the first root as "user" and the second as "built-in"', async () => {
const userRoot = fresh();
const builtInRoot = fresh();
writeSkill(userRoot, 'web-search', {
description: 'User-imported web search.',
});
writeSkill(builtInRoot, 'audio-jingle', {
description: 'Built-in jingle skill.',
});
const skills = await listSkills([userRoot, builtInRoot]);
expect(skills).toHaveLength(2);
const byId = new Map<string, { id: string; source: string }>(
skills.map((s: { id: string; source: string }) => [s.id, s]),
);
expect(byId.get('web-search')?.source).toBe('user');
expect(byId.get('audio-jingle')?.source).toBe('built-in');
rmSync(userRoot, { recursive: true, force: true });
rmSync(builtInRoot, { recursive: true, force: true });
});
it('lets a user skill shadow a built-in skill of the same id', async () => {
const userRoot = fresh();
const builtInRoot = fresh();
writeSkill(userRoot, 'shared-id', {
description: 'User override.',
body: '# Override body',
});
writeSkill(builtInRoot, 'shared-id', {
description: 'Original built-in.',
body: '# Built-in body',
});
const skills = await listSkills([userRoot, builtInRoot]);
expect(skills).toHaveLength(1);
const shadowed = skills[0]!;
expect(shadowed.source).toBe('user');
expect(shadowed.body).toContain('Override body');
rmSync(userRoot, { recursive: true, force: true });
rmSync(builtInRoot, { recursive: true, force: true });
});
});
describe('slugifySkillName', () => {
it('lowercases, normalises spaces, and strips reserved slugs', () => {
expect(slugifySkillName('Web Search')).toBe('web-search');
expect(slugifySkillName(' Multi Word Skill ')).toBe('multi-word-skill');
expect(slugifySkillName(' ')).toBe('');
expect(slugifySkillName('..')).toBe('');
expect(slugifySkillName('a/../b')).toBe('a-b');
});
});
describe('importUserSkill / deleteUserSkill', () => {
it('writes a SKILL.md and round-trips through listSkills', async () => {
const root = fresh();
try {
const result = await importUserSkill(root, {
name: 'Code Review',
description: 'Review the latest diff.',
body: '# Review\n\n1. Read.\n2. Comment.',
triggers: ['code review', 'review my diff'],
});
expect(result.id).toBe('Code Review');
expect(result.slug).toBe('code-review');
expect(result.dir).toBe(path.join(root, 'code-review'));
const skills = await listSkills(root);
expect(skills).toHaveLength(1);
const imported = skills[0]!;
expect(imported.id).toBe('Code Review');
expect(imported.triggers).toEqual(['code review', 'review my diff']);
// First (and only) root is treated as the user root.
expect(imported.source).toBe('user');
// Importing the same name again surfaces a CONFLICT error.
await expect(
importUserSkill(root, {
name: 'Code Review',
body: '# Different body',
}),
).rejects.toMatchObject({ code: 'CONFLICT' });
await deleteUserSkill(root, 'Code Review');
const after = await listSkills(root);
expect(after).toHaveLength(0);
// Deleting an already-deleted skill returns NOT_FOUND.
await expect(deleteUserSkill(root, 'Code Review')).rejects.toMatchObject({
code: 'NOT_FOUND',
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('rejects empty bodies and impossibly-named skills', async () => {
const root = fresh();
try {
await expect(
importUserSkill(root, { name: 'foo', body: ' ' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(
importUserSkill(root, { name: '..', body: '# body' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
// Names like '123', 'true', or 'null' are valid skill ids but YAML coerces
// unquoted scalars to non-strings, which broke the importUserSkill ->
// listSkills round-trip prior to PR #955 review feedback. The frontmatter
// emitter now always quotes `name`, so listSkills should round-trip the
// exact string id we wrote.
it('round-trips numeric- and boolean-shaped names through listSkills', async () => {
const cases = ['123', 'true', 'false', 'null', '0'];
for (const name of cases) {
const root = fresh();
try {
const result = await importUserSkill(root, {
name,
body: `# ${name} body`,
});
expect(result.id).toBe(name);
const skills = await listSkills(root);
expect(skills).toHaveLength(1);
expect(skills[0]?.id).toBe(name);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
});
});
describe('updateUserSkill', () => {
it('writes a SKILL.md and shadows a built-in entry on next listSkills', async () => {
const userRoot = fresh();
const builtInRoot = fresh();
try {
writeSkill(builtInRoot, 'shared-id', {
description: 'Original built-in.',
body: '# Original',
});
const result = await updateUserSkill(userRoot, {
name: 'shared-id',
description: 'User override.',
body: '# Override',
triggers: ['shared trigger'],
});
expect(result.slug).toBe('shared-id');
expect(result.dir).toBe(path.join(userRoot, 'shared-id'));
const skills = await listSkills([userRoot, builtInRoot]);
expect(skills).toHaveLength(1);
const shadowed = skills[0]!;
expect(shadowed.source).toBe('user');
expect(shadowed.body).toContain('Override');
expect(shadowed.triggers).toEqual(['shared trigger']);
} finally {
rmSync(userRoot, { recursive: true, force: true });
rmSync(builtInRoot, { recursive: true, force: true });
}
});
it('rejects empty bodies and impossibly-named skills', async () => {
const root = fresh();
try {
await expect(
updateUserSkill(root, { name: 'demo', body: ' ' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(
updateUserSkill(root, { name: '..', body: '# body' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
// Regression for mrcfps' PR #955 blocker: editing a built-in skill
// wrote a shadow folder that contained only a new SKILL.md. The next
// listSkills() pass surfaced the shadow as the active dir, but
// /api/skills/:id/files, /example, /assets/* and the system-prompt
// preamble all resolve through skill.dir, so the bundled assets/,
// references/, scripts/, and examples/ silently disappeared after
// save. The fix clones the built-in side tree into the shadow on
// first edit; subsequent edits leave the user's tweaks alone.
it('clones built-in side files into the shadow on the first edit', async () => {
const userRoot = fresh();
const builtInRoot = fresh();
try {
writeSkill(builtInRoot, 'shadow-me', {
body: '# Original built-in',
withAttachments: true,
});
mkdirSync(path.join(builtInRoot, 'shadow-me', 'references'), {
recursive: true,
});
writeFileSync(
path.join(builtInRoot, 'shadow-me', 'references', 'notes.md'),
'# bundled notes',
);
mkdirSync(path.join(builtInRoot, 'shadow-me', 'scripts'), {
recursive: true,
});
writeFileSync(
path.join(builtInRoot, 'shadow-me', 'scripts', 'helper.sh'),
'#!/bin/sh\necho built-in\n',
);
const before = await listSkills([userRoot, builtInRoot]);
expect(before).toHaveLength(1);
expect(before[0]!.source).toBe('built-in');
const result = await updateUserSkill(userRoot, {
name: 'shadow-me',
body: '# User override',
sourceDir: before[0]!.dir,
});
expect(result.dir).toBe(path.join(userRoot, 'shadow-me'));
const after = await listSkills([userRoot, builtInRoot]);
expect(after).toHaveLength(1);
const shadowed = after[0]!;
expect(shadowed.source).toBe('user');
expect(shadowed.body).toContain('User override');
const files = await listSkillFiles(shadowed.dir);
const paths = files.map((entry) => entry.path).sort();
expect(paths).toContain('SKILL.md');
expect(paths).toContain('assets');
expect(paths).toContain('assets/template.html');
expect(paths).toContain('references');
expect(paths).toContain('references/notes.md');
expect(paths).toContain('scripts');
expect(paths).toContain('scripts/helper.sh');
const noteContent = readFileSync(
path.join(shadowed.dir, 'references', 'notes.md'),
'utf8',
);
expect(noteContent).toContain('bundled notes');
} finally {
rmSync(userRoot, { recursive: true, force: true });
rmSync(builtInRoot, { recursive: true, force: true });
}
});
it('preserves user-edited side files on subsequent edits', async () => {
const userRoot = fresh();
const builtInRoot = fresh();
try {
writeSkill(builtInRoot, 'edit-twice', {
body: '# Original',
withAttachments: true,
});
const initial = await listSkills([userRoot, builtInRoot]);
await updateUserSkill(userRoot, {
name: 'edit-twice',
body: '# First override',
sourceDir: initial[0]!.dir,
});
const tweakedAsset = path.join(
userRoot,
'edit-twice',
'assets',
'template.html',
);
writeFileSync(tweakedAsset, '<html><body>user-tweaked</body></html>');
const next = await listSkills([userRoot, builtInRoot]);
expect(next[0]!.source).toBe('user');
await updateUserSkill(userRoot, {
name: 'edit-twice',
body: '# Second override',
sourceDir: next[0]!.dir,
});
const tweaked = readFileSync(tweakedAsset, 'utf8');
expect(tweaked).toContain('user-tweaked');
const final = await listSkills([userRoot, builtInRoot]);
expect(final[0]!.body).toContain('Second override');
} finally {
rmSync(userRoot, { recursive: true, force: true });
rmSync(builtInRoot, { recursive: true, force: true });
}
});
});
describe('listSkillFiles', () => {
it('returns a flat sorted file/directory list with byte sizes', async () => {
const root = fresh();
try {
writeSkill(root, 'demo-files', { withAttachments: true });
mkdirSync(path.join(root, 'demo-files', 'references'), { recursive: true });
writeFileSync(
path.join(root, 'demo-files', 'references', 'notes.md'),
'# notes',
);
const entries = await listSkillFiles(path.join(root, 'demo-files'));
const byPath = new Map(entries.map((entry) => [entry.path, entry]));
const skillMd = byPath.get('SKILL.md');
const assetsDir = byPath.get('assets');
const templateHtml = byPath.get('assets/template.html');
const referencesDir = byPath.get('references');
const notesMd = byPath.get('references/notes.md');
if (!skillMd || !assetsDir || !templateHtml || !referencesDir || !notesMd) {
throw new Error('expected file tree to include SKILL.md + assets + references');
}
expect(skillMd.kind).toBe('file');
expect(skillMd.size).toBeGreaterThan(0);
expect(assetsDir.kind).toBe('directory');
expect(assetsDir.size).toBeNull();
expect(templateHtml.kind).toBe('file');
expect(templateHtml.size).toBeGreaterThan(0);
expect(referencesDir.kind).toBe('directory');
expect(notesMd.kind).toBe('file');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('skips dotfiles and returns an empty list for a missing directory', async () => {
const root = fresh();
try {
writeSkill(root, 'with-dotfile');
writeFileSync(path.join(root, 'with-dotfile', '.DS_Store'), 'x');
const entries = await listSkillFiles(path.join(root, 'with-dotfile'));
expect(entries.find((entry) => entry.path === '.DS_Store')).toBeUndefined();
const missing = await listSkillFiles(path.join(root, 'no-such-skill'));
expect(missing).toEqual([]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});

View file

@ -40,6 +40,7 @@ describe('static resource mutation routes', () => {
ARTIFACTS_DIR: path.join(tempRoot, 'artifacts'),
BUNDLED_PETS_DIR: path.join(tempRoot, 'pets'),
DESIGN_SYSTEMS_DIR: path.join(tempRoot, 'design-systems'),
DESIGN_TEMPLATES_DIR: path.join(tempRoot, 'design-templates'),
OD_BIN: path.join(tempRoot, 'od'),
PROJECT_ROOT: tempRoot,
PROJECTS_DIR: path.join(tempRoot, 'projects'),
@ -48,6 +49,7 @@ describe('static resource mutation routes', () => {
RUNTIME_DATA_DIR_CANONICAL: path.join(tempRoot, 'data'),
SKILLS_DIR: path.join(tempRoot, 'skills'),
USER_DESIGN_SYSTEMS_DIR: path.join(tempRoot, 'user-design-systems'),
USER_DESIGN_TEMPLATES_DIR: path.join(tempRoot, 'user-design-templates'),
USER_SKILLS_DIR: path.join(tempRoot, 'user-skills'),
},
resources: {
@ -59,6 +61,8 @@ describe('static resource mutation routes', () => {
catalogReadCount += 1;
return [];
},
listAllDesignTemplates: async () => [],
listAllSkillLikeEntries: async () => [],
mimeFor: () => 'application/octet-stream',
},
});

View file

@ -15,6 +15,7 @@ import {
fetchAppVersionInfo,
fetchAgents,
fetchDesignSystems,
fetchDesignTemplates,
fetchPromptTemplates,
fetchSkills,
} from './providers/registry';
@ -125,7 +126,13 @@ export function App() {
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSection>('execution');
const [daemonLive, setDaemonLive] = useState(false);
const [agents, setAgents] = useState<AgentInfo[]>([]);
// Functional skills (capabilities the agent invokes mid-task) — stays
// small and lives under the Settings → Skills surface.
const [skills, setSkills] = useState<SkillSummary[]>([]);
// Design templates (rendering catalogue: decks, prototypes, image/video/
// audio templates) — sourced from /api/design-templates and shown in the
// EntryView Templates tab. See specs/current/skills-and-design-templates.md.
const [designTemplates, setDesignTemplates] = useState<SkillSummary[]>([]);
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
@ -234,10 +241,27 @@ export function App() {
setAgentsLoading(false);
});
// Functional skills + design templates land independently. Both
// gate `skillsLoading` together so the EntryView stops rendering
// its loader once both registries respond — neither tab would have
// a complete picture if we cleared the flag on the first reply.
let functionalReady = false;
let templatesReady = false;
const maybeClearLoading = () => {
if (functionalReady && templatesReady) setSkillsLoading(false);
};
void fetchSkills().then((list) => {
if (cancelled) return;
setSkills(list);
setSkillsLoading(false);
functionalReady = true;
maybeClearLoading();
});
void fetchDesignTemplates().then((list) => {
if (cancelled) return;
setDesignTemplates(list);
templatesReady = true;
maybeClearLoading();
});
void fetchDesignSystems().then((list) => {
@ -790,10 +814,43 @@ export function App() {
void refreshTemplates();
}, [route.kind, refreshTemplates]);
// Existing card grids (DesignsTab, ProjectView), pickers (NewProjectPanel,
// ChatComposer mention) all look skills up by id without caring whether
// the id resolves to a functional skill or a design template. Pass them
// the union so the post-split refactor stays invisible to those callers.
const allSkillSummaries = useMemo(
() => [...skills, ...designTemplates],
[skills, designTemplates],
);
const enabledSkills = useMemo(
() => skills.filter((s) => !(config.disabledSkills ?? []).includes(s.id)),
() =>
allSkillSummaries.filter(
(s) => !(config.disabledSkills ?? []).includes(s.id),
),
[allSkillSummaries, config.disabledSkills],
);
// Functional-skills-only enabled subset — what ProjectView's chat
// composer @-picker should see. Without this, a skill the user has
// disabled in Settings still appears in an existing project's @-mention
// popover and can ride along to the daemon via skillIds, breaking the
// Library toggle for projects opened on the post-split branch.
const enabledFunctionalSkills = useMemo(
() =>
skills.filter(
(s) => !(config.disabledSkills ?? []).includes(s.id),
),
[skills, config.disabledSkills],
);
// Templates-only enabled subset — what the EntryView Templates gallery
// actually renders. Filtering in App keeps the EntryView prop surface
// narrow ("here are the templates the user has not disabled").
const enabledDesignTemplates = useMemo(
() =>
designTemplates.filter(
(s) => !(config.disabledSkills ?? []).includes(s.id),
),
[designTemplates, config.disabledSkills],
);
const enabledDS = useMemo(
() =>
designSystems.filter(
@ -811,7 +868,8 @@ export function App() {
routeFileName={route.kind === 'project' ? route.fileName : null}
config={config}
agents={agents}
skills={skills}
skills={enabledFunctionalSkills}
designTemplates={designTemplates}
designSystems={designSystems}
daemonLive={daemonLive}
onModeChange={handleModeChange}
@ -832,6 +890,7 @@ export function App() {
) : (
<EntryView
skills={enabledSkills}
designTemplates={enabledDesignTemplates}
designSystems={enabledDS}
projects={projects}
templates={templates}

View file

@ -12,7 +12,7 @@ import { projectRawUrl, uploadProjectFiles, openFolderDialog } from "../provider
import { patchProject } from "../state/projects";
import { fetchMcpServers } from "../state/mcp";
import type { McpServerConfig } from "../state/mcp";
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile, ProjectMetadata } from "../types";
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile, ProjectMetadata, SkillSummary } from "../types";
import type { ResearchOptions } from '@open-design/contracts';
import { Icon } from "./Icon";
import { BUILT_IN_PETS, CUSTOM_PET_ID, resolveActivePet } from "./pet/pets";
@ -47,7 +47,18 @@ interface Props {
onEnsureProject: () => Promise<string | null>;
commentAttachments?: ChatCommentAttachment[];
onRemoveCommentAttachment?: (id: string) => void;
onSend: (prompt: string, attachments: ChatAttachment[], commentAttachments: ChatCommentAttachment[], meta?: ChatSendMeta) => void;
// Available skills the user can compose into a turn via @<skill>. The
// chat layer already filters out disabled skills before passing them in
// here, so the picker can render the list as-is. Keep this optional so
// the composer still works on surfaces that don't show a skills picker
// (e.g. tests, screenshot harnesses).
skills?: SkillSummary[];
onSend: (
prompt: string,
attachments: ChatAttachment[],
commentAttachments: ChatCommentAttachment[],
meta?: ChatSendMeta,
) => void;
onStop: () => void;
// Opens the global settings dialog (CLI / model / agent picker). The
// composer's leading gear icon routes here so users can switch models
@ -78,6 +89,11 @@ export interface ChatComposerHandle {
export interface ChatSendMeta {
research?: ResearchOptions;
// Per-turn skill ids picked via the @-mention popover. The chat layer
// forwards these to the daemon's `skillIds` field so the system prompt
// for this run only is composed with the extra skill bodies, without
// touching the project's persistent `skillId`.
skillIds?: string[];
}
/**
@ -99,6 +115,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
onEnsureProject,
commentAttachments = [],
onRemoveCommentAttachment,
skills = [],
onSend,
onStop,
onOpenSettings,
@ -116,6 +133,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const t = useT();
const [draft, setDraft] = useState(initialDraft ?? "");
const [staged, setStaged] = useState<ChatAttachment[]>([]);
// Skills the user has @-mentioned for this turn. We dedupe on id and
// strip the chip when the user removes the corresponding `@<skill>`
// token from the draft, keeping draft and chips in sync.
const [stagedSkills, setStagedSkills] = useState<SkillSummary[]>([]);
const [dragActive, setDragActive] = useState(false);
const [mention, setMention] = useState<{
q: string;
@ -461,11 +482,48 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
function reset() {
setDraft("");
setStaged([]);
setStagedSkills([]);
setUploadError(null);
setMention(null);
setSlash(null);
}
function insertSkillMention(skill: SkillSummary) {
if (!mention) return;
const ta = textareaRef.current;
if (!ta) return;
const cursor = mention.cursor;
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
// Use the same `@<token>` prefix as file mentions so the visual
// grammar is consistent. The id is stable across renames; the
// displayed name is derived in render from stagedSkills.
const replaced = before.replace(/@([^\s@]*)$/, `@${skill.id} `);
const next = replaced + after;
setDraft(next);
setMention(null);
setStagedSkills((prev) =>
prev.some((s) => s.id === skill.id) ? prev : [...prev, skill],
);
requestAnimationFrame(() => {
ta.focus();
const pos = replaced.length;
ta.setSelectionRange(pos, pos);
});
}
function removeStagedSkill(id: string) {
setStagedSkills((prev) => prev.filter((s) => s.id !== id));
// Also strip the matching `@<id>` token from the draft so the chip
// and the textarea stay in sync. We allow trailing whitespace to be
// collapsed too.
setDraft((d) =>
d
.replace(new RegExp(`(^|\\s)@${escapeRegExp(id)}(\\s|$)`, 'g'), '$1$2')
.replace(/\s{2,}/g, ' '),
);
}
async function ensureProject(): Promise<string | null> {
if (projectId) return projectId;
return onEnsureProject();
@ -545,6 +603,20 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const value = e.target.value;
const cursor = e.target.selectionStart;
setDraft(value);
// Keep the staged-skill chips in sync with the draft. If the user
// hand-deletes an `@<id>` token from the textarea, the chip must
// disappear too — otherwise submit() would still forward that id in
// skillIds and the daemon would compose a skill the prompt no
// longer references. Mirror the removeStagedSkill() boundary
// (whitespace or string edge) so partial matches don't keep a chip
// alive accidentally. We do not run the same prune for `staged`
// file attachments because users frequently attach files via the
// upload button without leaving an `@<path>` token in the draft.
setStagedSkills((prev) =>
prev.filter((s) =>
new RegExp(`(^|\\s)@${escapeRegExp(s.id)}(\\s|$)`).test(value),
),
);
// Detect a fresh @ at start or after whitespace; capture the typed
// query up to the cursor.
const before = value.slice(0, cursor);
@ -606,10 +678,12 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// prompt and *is* sent to the agent — the agent runs the skill,
// packages a Codex pet under `~/.codex/pets/`, and the user
// adopts it from "Recently hatched" in pet settings afterwards.
const skillIds = stagedSkills.map((s) => s.id);
const skillMeta = skillIds.length > 0 ? { skillIds } : undefined;
const hatched = expandHatchCommand(prompt);
if (hatched) {
if (streaming) return;
onSend(hatched, staged, commentAttachments);
onSend(hatched, staged, commentAttachments, skillMeta);
reset();
return;
}
@ -617,13 +691,14 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
if (search) {
if (streaming) return;
onSend(search.prompt, staged, commentAttachments, {
...skillMeta,
research: { enabled: true, query: search.query },
});
reset();
return;
}
if ((!prompt && commentAttachments.length === 0) || streaming) return;
onSend(prompt, staged, commentAttachments);
onSend(prompt, staged, commentAttachments, skillMeta);
reset();
}
@ -640,6 +715,28 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
})
.slice(0, 12)
: [];
// Skills appear in the same @-popover so the user has one entry point
// for everything they want to attach to a turn. Already-staged skills
// drop out of the suggestion list so the popover keeps moving forward.
const stagedSkillIds = useMemo(
() => new Set(stagedSkills.map((s) => s.id)),
[stagedSkills],
);
const filteredSkills = useMemo(() => {
if (!mention) return [] as SkillSummary[];
const q = mention.q.toLowerCase();
return skills
.filter((s) => !stagedSkillIds.has(s.id))
.filter((s) => {
if (!q) return true;
return (
s.id.toLowerCase().includes(q) ||
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q)
);
})
.slice(0, 8);
}, [mention, skills, stagedSkillIds]);
return (
<div
@ -653,6 +750,13 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
onDrop={handleDrop}
>
<div className="composer-shell">
{stagedSkills.length > 0 ? (
<StagedSkills
skills={stagedSkills}
onRemove={removeStagedSkill}
t={t}
/>
) : null}
{staged.length > 0 ? (
<StagedAttachments
attachments={staged}
@ -732,8 +836,13 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
}}
/>
{mention && filteredFiles.length > 0 ? (
<MentionPopover files={filteredFiles} onPick={insertMention} />
{mention && (filteredFiles.length > 0 || filteredSkills.length > 0) ? (
<MentionPopover
files={filteredFiles}
skills={filteredSkills}
onPickFile={insertMention}
onPickSkill={insertSkillMention}
/>
) : null}
{slash && filteredSlash.length > 0 ? (
<SlashPopover
@ -976,6 +1085,45 @@ function StagedAttachments({
);
}
function StagedSkills({
skills,
onRemove,
t,
}: {
skills: SkillSummary[];
onRemove: (id: string) => void;
t: TranslateFn;
}) {
return (
<div
className="staged-row staged-skills-row"
data-testid="staged-skills"
>
{skills.map((s) => (
<div
key={s.id}
className={`staged-chip staged-skill staged-skill-${s.source ?? 'built-in'}`}
>
<span className="staged-icon" aria-hidden>
<Icon name="sparkles" size={12} />
</span>
<span className="staged-name" title={s.description || s.name}>
@{s.id}
</span>
<button
className="staged-remove"
onClick={() => onRemove(s.id)}
title={t('common.delete')}
aria-label={`Remove skill ${s.id}`}
>
<Icon name="close" size={11} />
</button>
</div>
))}
</div>
);
}
function StagedCommentAttachments({
attachments,
onRemove,
@ -1236,36 +1384,77 @@ function SlashPopover({
function MentionPopover({
files,
onPick,
skills,
onPickFile,
onPickSkill,
}: {
files: ProjectFile[];
onPick: (path: string) => void;
skills: SkillSummary[];
onPickFile: (path: string) => void;
onPickSkill: (skill: SkillSummary) => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = 0;
}, [files]);
}, [files, skills]);
return (
<div className="mention-popover" data-testid="mention-popover" ref={ref}>
{files.map((f) => {
const key = f.path ?? f.name;
return (
<button
key={key}
className="mention-item"
onClick={() => onPick(key)}
>
<code>{key}</code>
{f.size != null ? (
<span className="mention-meta">{prettySize(f.size)}</span>
) : null}
</button>
);
})}
{skills.length > 0 ? (
<>
<div className="mention-section-head">Skills</div>
{skills.map((s) => (
<button
key={`skill-${s.id}`}
className="mention-item mention-skill-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPickSkill(s)}
data-testid={`mention-skill-${s.id}`}
>
<span className="mention-skill-row">
<Icon name="sparkles" size={12} />
<code>@{s.id}</code>
{s.source === 'user' ? (
<span className="mention-skill-badge">user</span>
) : null}
</span>
{s.description ? (
<span className="mention-skill-desc">{s.description}</span>
) : null}
</button>
))}
</>
) : null}
{files.length > 0 ? (
<>
{skills.length > 0 ? (
<div className="mention-section-head">Files</div>
) : null}
{files.map((f) => {
const key = f.path ?? f.name;
return (
<button
key={`file-${key}`}
className="mention-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPickFile(key)}
>
<code>{key}</code>
{f.size != null ? (
<span className="mention-meta">{prettySize(f.size)}</span>
) : null}
</button>
);
})}
</>
) : null}
</div>
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function looksLikeImage(name: string): boolean {
return /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i.test(name);
}

View file

@ -3,7 +3,7 @@ import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { projectRawUrl } from '../providers/registry';
import type { TodoItem } from '../runtime/todos';
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile, ProjectMetadata } from '../types';
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile, ProjectMetadata, SkillSummary } from '../types';
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
import { commentsToAttachments, simplePositionLabel } from '../comments';
import { AssistantMessage } from './AssistantMessage';
@ -208,8 +208,16 @@ interface Props {
onAttachComment?: (comment: PreviewComment) => void;
onDetachComment?: (commentId: string) => void;
onDeleteComment?: (commentId: string) => void;
onSend: (prompt: string, attachments: ChatAttachment[], commentAttachments: ChatCommentAttachment[], meta?: ChatSendMeta) => void;
onSend: (
prompt: string,
attachments: ChatAttachment[],
commentAttachments: ChatCommentAttachment[],
meta?: ChatSendMeta,
) => void;
onStop: () => void;
// Skills available for @-mention assembly. ProjectView filters out the
// user's disabled set before passing them in here.
skills?: SkillSummary[];
// Click-to-open chain: passes a basename up to ProjectView, which sets
// FileWorkspace's openRequest. Tool cards, attachment chips, and
// produced-file chips all call this.
@ -282,6 +290,7 @@ export function ChatPane({
onOpenPetSettings,
projectMetadata,
onProjectMetadataChange,
skills = [],
researchAvailable,
onCollapse,
}: Props) {
@ -721,6 +730,7 @@ export function ChatPane({
ref={composerRef}
projectId={projectId}
projectFiles={projectFiles}
skills={skills}
streaming={streaming || hasActiveRunMessage}
initialDraft={initialDraft}
onEnsureProject={onEnsureProject}

View file

@ -0,0 +1,215 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useT } from '../i18n';
import type { AppConfig } from '../types';
import type { DesignSystemSummary } from '@open-design/contracts';
import {
fetchDesignSystem,
fetchDesignSystems,
} from '../providers/registry';
// Sibling Settings section that hosts the design-systems registry.
// Lifted out of the previous LibrarySection so each surface (functional
// skills vs. design systems) gets its own dedicated nav entry instead of
// sharing a sub-tab toggle. See specs/current/skills-and-design-templates.md.
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
}
export function DesignSystemsSection({ cfg, setCfg }: Props) {
const t = useT();
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('All');
const [previewId, setPreviewId] = useState<string | null>(null);
const [previewBody, setPreviewBody] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
useEffect(() => {
fetchDesignSystems().then(setDesignSystems);
}, []);
const disabledDS = useMemo(
() => new Set(cfg.disabledDesignSystems ?? []),
[cfg.disabledDesignSystems],
);
const categories = useMemo(() => {
const cats = new Set(designSystems.map((d) => d.category));
return ['All', ...Array.from(cats).sort()];
}, [designSystems]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return designSystems.filter((d) => {
if (categoryFilter !== 'All' && d.category !== categoryFilter) return false;
if (
q &&
!d.title.toLowerCase().includes(q) &&
!d.summary.toLowerCase().includes(q)
)
return false;
return true;
});
}, [designSystems, categoryFilter, search]);
const grouped = useMemo(() => {
const groups = new Map<string, DesignSystemSummary[]>();
for (const d of filtered) {
const list = groups.get(d.category) ?? [];
list.push(d);
groups.set(d.category, list);
}
return groups;
}, [filtered]);
const openPreview = useCallback(
async (id: string) => {
if (previewId === id) {
setPreviewId(null);
setPreviewBody(null);
return;
}
setPreviewId(id);
setPreviewBody(null);
setPreviewLoading(true);
try {
const detail = await fetchDesignSystem(id);
setPreviewId((cur) => {
if (cur === id) setPreviewBody(detail?.body ?? null);
return cur;
});
} catch {
setPreviewId((cur) => {
if (cur === id) setPreviewBody(null);
return cur;
});
} finally {
setPreviewId((cur) => {
if (cur === id) setPreviewLoading(false);
return cur;
});
}
},
[previewId],
);
function toggleDSDisabled(id: string, enabled: boolean) {
setCfg((c) => {
const set = new Set(c.disabledDesignSystems ?? []);
if (enabled) set.delete(id);
else set.add(id);
return { ...c, disabledDesignSystems: [...set] };
});
}
return (
<section className="settings-section settings-design-systems">
<div className="section-head">
<div>
<h3>{t('settings.designSystems')}</h3>
<p className="hint">{t('settings.designSystemsHint')}</p>
</div>
</div>
<div className="library-toolbar">
<input
type="search"
className="library-search"
placeholder={t('settings.librarySearch')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="library-filters">
{categories.map((cat) => {
const count =
cat === 'All'
? designSystems.length
: designSystems.filter((d) => d.category === cat).length;
return (
<button
key={cat}
type="button"
className={`filter-pill${categoryFilter === cat ? ' active' : ''}`}
onClick={() => setCategoryFilter(cat)}
>
{cat}
<span className="filter-pill-count">{count}</span>
</button>
);
})}
</div>
</div>
<div className="library-content">
{filtered.length === 0 ? (
<p className="library-empty">{t('settings.libraryNoResults')}</p>
) : (
<>
{Array.from(grouped.entries()).map(([category, items]) => (
<div key={category} className="library-group">
<h4 className="library-group-title">
{category}{' '}
<span className="library-group-count">{items.length}</span>
</h4>
<div className="ds-grid">
{items.map((ds) => (
<div
key={ds.id}
className={`library-ds-card${
disabledDS.has(ds.id) ? ' disabled' : ''
}`}
>
<div
className="library-ds-card-content"
onClick={() => openPreview(ds.id)}
>
{ds.swatches && ds.swatches.length > 0 && (
<div className="library-ds-swatches">
{ds.swatches.slice(0, 4).map((c, i) => (
<span
key={i}
className="library-ds-swatch"
style={{ backgroundColor: c }}
/>
))}
</div>
)}
<div className="library-ds-title">{ds.title}</div>
<div className="library-ds-summary">{ds.summary}</div>
</div>
<label
className="toggle-switch toggle-switch-sm"
title={t('settings.libraryToggleLabel')}
>
<input
type="checkbox"
checked={!disabledDS.has(ds.id)}
onChange={(e) =>
toggleDSDisabled(ds.id, e.target.checked)
}
/>
<span className="toggle-slider" />
</label>
</div>
))}
</div>
</div>
))}
{previewId && filtered.some((d) => d.id === previewId) && (
<div className="library-preview">
{previewLoading ? (
<p>{t('settings.libraryLoading')}</p>
) : previewBody ? (
<pre className="library-preview-body">{previewBody}</pre>
) : null}
</div>
)}
</>
)}
</div>
</section>
);
}

View file

@ -36,10 +36,17 @@ import { PromptTemplatesTab } from './PromptTemplatesTab';
import { apiProtocolLabel } from '../utils/apiProtocol';
import { isMacPlatform } from '../utils/platform';
type TopTab = 'designs' | 'examples' | 'design-systems' | 'image-templates' | 'video-templates';
type TopTab = 'designs' | 'templates' | 'design-systems' | 'image-templates' | 'video-templates';
interface Props {
// Union of functional skills + design templates — used for id-based
// lookups (DesignsTab project chips, NewProjectPanel skill picker).
// The Templates gallery itself reads `designTemplates` instead so it
// doesn't accidentally show functional skills as renderable cards.
skills: SkillSummary[];
// Design templates only. Sourced from /api/design-templates. See
// specs/current/skills-and-design-templates.md.
designTemplates: SkillSummary[];
designSystems: DesignSystemSummary[];
projects: Project[];
templates: ProjectTemplate[];
@ -216,6 +223,7 @@ function loadPetRailHidden(): boolean {
export function EntryView({
skills,
designTemplates,
designSystems,
projects,
templates,
@ -561,7 +569,7 @@ export function EntryView({
<div className="entry-header">
<div className="entry-tabs" role="tablist">
<TopTabButton current={topTab} value="designs" label={t('entry.tabDesigns')} onClick={setTopTab} />
<TopTabButton current={topTab} value="examples" label={t('entry.tabExamples')} onClick={setTopTab} />
<TopTabButton current={topTab} value="templates" label={t('entry.tabTemplates')} onClick={setTopTab} />
<TopTabButton
current={topTab}
value="design-systems"
@ -601,11 +609,14 @@ export function EntryView({
/>
)
) : null}
{topTab === 'examples' ? (
{topTab === 'templates' ? (
skillsLoading ? (
<CenteredLoader label={t('common.loading')} />
) : (
<ExamplesTab skills={skills} onUsePrompt={usePromptFromSkill} />
<ExamplesTab
skills={designTemplates}
onUsePrompt={usePromptFromSkill}
/>
)
) : null}
{topTab === 'design-systems' ? (

View file

@ -1,524 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useT } from '../i18n';
import { Icon } from './Icon';
import type { AppConfig, InstallInput } from '../types';
import type { SkillSummary, DesignSystemSummary } from '@open-design/contracts';
import {
fetchSkills,
fetchDesignSystems,
fetchSkill,
fetchDesignSystem,
installSkill,
uninstallSkill,
installDesignSystem,
uninstallDesignSystem,
} from '../providers/registry';
type Tab = 'skills' | 'design-systems';
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
}
const MODES = [
'prototype',
'deck',
'template',
'design-system',
'image',
'video',
'audio',
] as const;
export function LibrarySection({ cfg, setCfg }: Props) {
const t = useT();
const [tab, setTab] = useState<Tab>('skills');
const [search, setSearch] = useState('');
const [modeFilter, setModeFilter] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('All');
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [previewId, setPreviewId] = useState<string | null>(null);
const [previewBody, setPreviewBody] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
// Install state
const [installOpen, setInstallOpen] = useState(false);
const [installTab, setInstallTab] = useState<'github' | 'local'>('github');
const [installUrl, setInstallUrl] = useState('');
const [installPath, setInstallPath] = useState('');
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const reloadData = useCallback(() => {
fetchSkills().then(setSkills);
fetchDesignSystems().then(setDesignSystems);
}, []);
useEffect(() => {
reloadData();
}, [reloadData]);
const categories = useMemo(() => {
const cats = new Set(designSystems.map((d) => d.category));
return ['All', ...Array.from(cats).sort()];
}, [designSystems]);
const disabledSkills = useMemo(
() => new Set(cfg.disabledSkills ?? []),
[cfg.disabledSkills],
);
const disabledDS = useMemo(
() => new Set(cfg.disabledDesignSystems ?? []),
[cfg.disabledDesignSystems],
);
const filteredSkills = useMemo(() => {
const q = search.toLowerCase();
return skills.filter((s) => {
if (modeFilter !== 'all' && s.mode !== modeFilter) return false;
if (q && !s.name.toLowerCase().includes(q) && !s.description.toLowerCase().includes(q))
return false;
return true;
});
}, [skills, modeFilter, search]);
const filteredDS = useMemo(() => {
const q = search.toLowerCase();
return designSystems.filter((d) => {
if (categoryFilter !== 'All' && d.category !== categoryFilter) return false;
if (q && !d.title.toLowerCase().includes(q) && !d.summary.toLowerCase().includes(q))
return false;
return true;
});
}, [designSystems, categoryFilter, search]);
const groupedSkills = useMemo(() => {
const groups = new Map<string, SkillSummary[]>();
for (const s of filteredSkills) {
const list = groups.get(s.mode) ?? [];
list.push(s);
groups.set(s.mode, list);
}
return groups;
}, [filteredSkills]);
const groupedDS = useMemo(() => {
const groups = new Map<string, DesignSystemSummary[]>();
for (const d of filteredDS) {
const list = groups.get(d.category) ?? [];
list.push(d);
groups.set(d.category, list);
}
return groups;
}, [filteredDS]);
const openPreview = useCallback(
async (id: string) => {
if (previewId === id) {
setPreviewId(null);
setPreviewBody(null);
return;
}
setPreviewId(id);
setPreviewBody(null);
setPreviewLoading(true);
try {
const detail =
tab === 'skills'
? await fetchSkill(id)
: await fetchDesignSystem(id);
setPreviewId((cur) => {
if (cur === id) setPreviewBody(detail?.body ?? null);
return cur;
});
} catch {
setPreviewId((cur) => {
if (cur === id) setPreviewBody(null);
return cur;
});
} finally {
setPreviewId((cur) => {
if (cur === id) setPreviewLoading(false);
return cur;
});
}
},
[previewId, tab],
);
function toggleSkillDisabled(id: string, disabled: boolean) {
setCfg((c) => {
const set = new Set(c.disabledSkills ?? []);
if (disabled) set.add(id);
else set.delete(id);
return { ...c, disabledSkills: [...set] };
});
}
function toggleDSDisabled(id: string, disabled: boolean) {
setCfg((c) => {
const set = new Set(c.disabledDesignSystems ?? []);
if (disabled) set.add(id);
else set.delete(id);
return { ...c, disabledDesignSystems: [...set] };
});
}
async function handleInstall() {
setInstallError(null);
setInstalling(true);
const input: InstallInput =
installTab === 'github'
? { source: 'github', url: installUrl.trim() }
: { source: 'local', path: installPath.trim() };
const result =
tab === 'skills'
? await installSkill(input)
: await installDesignSystem(input);
setInstalling(false);
if ('error' in result) {
setInstallError(result.error);
return;
}
setInstallOpen(false);
setInstallUrl('');
setInstallPath('');
setInstallError(null);
reloadData();
}
async function handleUninstallSkill(id: string) {
const result = await uninstallSkill(id);
if ('error' in result) return;
reloadData();
}
async function handleUninstallDS(id: string) {
const result = await uninstallDesignSystem(id);
if ('error' in result) return;
reloadData();
}
return (
<section className="settings-section">
<div className="section-head">
<div>
<h3>{t('settings.library')}</h3>
<p className="hint">{t('settings.libraryHint')}</p>
</div>
</div>
<div className="seg-control" role="tablist">
<button
type="button"
role="tab"
className={`seg-btn${tab === 'skills' ? ' active' : ''}`}
onClick={() => {
setTab('skills');
setModeFilter('all');
setCategoryFilter('All');
setSearch('');
setPreviewId(null);
}}
>
<span className="seg-title">
{t('settings.librarySkills')}
<span className="seg-meta">{skills.length}</span>
</span>
</button>
<button
type="button"
role="tab"
className={`seg-btn${tab === 'design-systems' ? ' active' : ''}`}
onClick={() => {
setTab('design-systems');
setModeFilter('all');
setCategoryFilter('All');
setSearch('');
setPreviewId(null);
}}
>
<span className="seg-title">
{t('settings.libraryDesignSystems')}
<span className="seg-meta">{designSystems.length}</span>
</span>
</button>
</div>
<div className="library-toolbar">
<div className="library-toolbar-row">
<input
type="search"
className="library-search"
placeholder={t('settings.librarySearch')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button
type="button"
className="library-install-btn"
onClick={() => setInstallOpen((v) => !v)}
>
<Icon name="plus" size={14} />
{t('settings.libraryInstall')}
</button>
</div>
{installOpen && (
<div className="library-install-form">
<div className="seg-control" role="tablist">
<button
type="button"
role="tab"
className={`seg-btn${installTab === 'github' ? ' active' : ''}`}
onClick={() => setInstallTab('github')}
>
{t('settings.libraryInstallGithub')}
</button>
<button
type="button"
role="tab"
className={`seg-btn${installTab === 'local' ? ' active' : ''}`}
onClick={() => setInstallTab('local')}
>
{t('settings.libraryInstallLocal')}
</button>
</div>
<div className="library-install-row">
{installTab === 'github' ? (
<input
type="url"
className="library-search"
placeholder={t('settings.libraryInstallUrl')}
value={installUrl}
onChange={(e) => setInstallUrl(e.target.value)}
/>
) : (
<input
type="text"
className="library-search"
placeholder={t('settings.libraryInstallPath')}
value={installPath}
onChange={(e) => setInstallPath(e.target.value)}
/>
)}
<button
type="button"
className="library-install-submit"
disabled={installing}
onClick={handleInstall}
>
{installing ? t('settings.libraryLoading') : t('settings.libraryInstallButton')}
</button>
</div>
{installError && (
<p className="library-install-error">{installError}</p>
)}
</div>
)}
{tab === 'skills' ? (
<div className="library-filters">
<button
type="button"
className={`filter-pill${modeFilter === 'all' ? ' active' : ''}`}
onClick={() => setModeFilter('all')}
>
{t('settings.libraryAll')}
</button>
{MODES.map((mode) => {
const count = skills.filter((s) => s.mode === mode).length;
if (count === 0) return null;
return (
<button
key={mode}
type="button"
className={`filter-pill${modeFilter === mode ? ' active' : ''}`}
onClick={() => setModeFilter(mode)}
>
{mode}
<span className="filter-pill-count">{count}</span>
</button>
);
})}
</div>
) : (
<div className="library-filters">
{categories.map((cat) => {
const count =
cat === 'All'
? designSystems.length
: designSystems.filter((d) => d.category === cat).length;
return (
<button
key={cat}
type="button"
className={`filter-pill${categoryFilter === cat ? ' active' : ''}`}
onClick={() => setCategoryFilter(cat)}
>
{cat}
<span className="filter-pill-count">{count}</span>
</button>
);
})}
</div>
)}
</div>
<div className="library-content">
{tab === 'skills' ? (
filteredSkills.length === 0 ? (
<p className="library-empty">{t('settings.libraryNoResults')}</p>
) : (
MODES.filter((m) => groupedSkills.has(m)).map((mode) => (
<div key={mode} className="library-group">
<h4 className="library-group-title">
{mode}{' '}
<span className="library-group-count">{groupedSkills.get(mode)!.length}</span>
</h4>
{groupedSkills.get(mode)!.map((skill) => (
<div
key={skill.id}
className={`library-card${disabledSkills.has(skill.id) ? ' disabled' : ''}`}
>
<div className="library-card-info">
<div className="library-card-title-row">
<span className="library-card-name">{skill.name}</span>
<span className="library-card-badge">{skill.previewType}</span>
<span
className={`library-source-badge${skill.source === 'installed' ? ' installed' : ''}`}
>
{skill.source === 'installed'
? t('settings.libraryInstalled')
: t('settings.libraryBuiltIn')}
</span>
</div>
<div className="library-card-desc">{skill.description}</div>
</div>
<button
type="button"
className="library-card-expand"
onClick={() => openPreview(skill.id)}
title={t('settings.libraryPreview')}
>
<Icon
name={previewId === skill.id ? 'close' : 'chevron-right'}
size={14}
/>
</button>
{skill.source === 'installed' && (
<button
type="button"
className="library-uninstall-btn"
title={t('settings.libraryUninstall')}
onClick={() => handleUninstallSkill(skill.id)}
>
<Icon name="trash" size={14} />
</button>
)}
<label className="toggle-switch" title={t('settings.libraryToggleLabel')}>
<input
type="checkbox"
checked={!disabledSkills.has(skill.id)}
onChange={(e) => toggleSkillDisabled(skill.id, !e.target.checked)}
/>
<span className="toggle-slider" />
</label>
{previewId === skill.id && (
<div className="library-preview">
{previewLoading ? (
<p>{t('settings.libraryLoading')}</p>
) : previewBody ? (
<pre className="library-preview-body">{previewBody}</pre>
) : null}
</div>
)}
</div>
))}
</div>
))
)
) : filteredDS.length === 0 ? (
<p className="library-empty">{t('settings.libraryNoResults')}</p>
) : (
<>
{Array.from(groupedDS.entries()).map(([category, items]) => (
<div key={category} className="library-group">
<h4 className="library-group-title">
{category} <span className="library-group-count">{items.length}</span>
</h4>
<div className="ds-grid">
{items.map((ds) => (
<div
key={ds.id}
className={`library-ds-card${disabledDS.has(ds.id) ? ' disabled' : ''}`}
>
<div className="library-ds-card-content" onClick={() => openPreview(ds.id)}>
{ds.swatches && ds.swatches.length > 0 && (
<div className="library-ds-swatches">
{ds.swatches.slice(0, 4).map((c, i) => (
<span
key={i}
className="library-ds-swatch"
style={{ backgroundColor: c }}
/>
))}
</div>
)}
<div className="library-ds-title-row">
<span className="library-ds-title">{ds.title}</span>
<span
className={`library-source-badge${ds.source === 'installed' ? ' installed' : ''}`}
>
{ds.source === 'installed'
? t('settings.libraryInstalled')
: t('settings.libraryBuiltIn')}
</span>
</div>
<div className="library-ds-summary">{ds.summary}</div>
</div>
<div className="library-ds-card-actions">
{ds.source === 'installed' && (
<button
type="button"
className="library-uninstall-btn"
title={t('settings.libraryUninstall')}
onClick={() => handleUninstallDS(ds.id)}
>
<Icon name="trash" size={14} />
</button>
)}
<label className="toggle-switch toggle-switch-sm" title={t('settings.libraryToggleLabel')}>
<input
type="checkbox"
checked={!disabledDS.has(ds.id)}
onChange={(e) => toggleDSDisabled(ds.id, !e.target.checked)}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
))}
</div>
</div>
))}
{previewId && filteredDS.some((d) => d.id === previewId) && (
<div className="library-preview">
{previewLoading ? (
<p>{t('settings.libraryLoading')}</p>
) : previewBody ? (
<pre className="library-preview-body">{previewBody}</pre>
) : null}
</div>
)}
</>
)}
</div>
</section>
);
}

View file

@ -23,6 +23,7 @@ import {
deletePreviewComment,
fetchPreviewComments,
fetchDesignSystem,
fetchDesignTemplate,
fetchLiveArtifacts,
fetchProjectFiles,
fetchSkill,
@ -109,7 +110,17 @@ interface Props {
routeFileName: string | null;
config: AppConfig;
agents: AgentInfo[];
// Mentionable functional skills — already filtered by config.disabledSkills
// upstream, so this drives only the chat composer's @-picker scope. For
// resolving an existing project's `skillId` (which can also point at a
// design template after the skills/design-templates split) use
// `designTemplates` as a fallback in composedSystemPrompt() and in the
// skill-name / skill-mode lookups below.
skills: SkillSummary[];
// All known design templates (unfiltered). Required so projects created
// from the Templates surface keep composing the template body in API
// mode even when the user later disables the template in Settings.
designTemplates: SkillSummary[];
designSystems: DesignSystemSummary[];
daemonLive: boolean;
onModeChange: (mode: AppConfig['mode']) => void;
@ -234,6 +245,7 @@ export function ProjectView({
config,
agents,
skills,
designTemplates,
designSystems,
daemonLive,
onModeChange,
@ -654,14 +666,21 @@ export function ProjectView({
let designSystemTitle: string | undefined;
if (project.skillId) {
const summary = skills.find((s) => s.id === project.skillId);
// project.skillId can resolve to either root after the
// skills/design-templates split; check both lists so a template-backed
// project keeps composing its template body when running in API mode.
const summary =
skills.find((s) => s.id === project.skillId) ??
designTemplates.find((s) => s.id === project.skillId);
skillName = summary?.name;
skillMode = summary?.mode;
const cached = skillCache.current.get(project.skillId);
if (cached !== undefined) {
skillBody = cached;
} else {
const detail = await fetchSkill(project.skillId);
const detail =
(await fetchSkill(project.skillId)) ??
(await fetchDesignTemplate(project.skillId));
if (detail) {
skillBody = detail.body;
skillCache.current.set(project.skillId, detail.body);
@ -730,6 +749,7 @@ export function ProjectView({
project.designSystemId,
project.metadata,
skills,
designTemplates,
designSystems,
config.mode,
]);
@ -1075,7 +1095,7 @@ export function ProjectView({
prompt: string,
attachments: ChatAttachment[],
commentAttachments: ChatCommentAttachment[] = commentsToAttachments(attachedComments),
meta?: { research?: ResearchOptions },
meta?: { research?: ResearchOptions; skillIds?: string[] },
) => {
if (!activeConversationId) return;
if (streaming) return;
@ -1396,6 +1416,7 @@ export function ProjectView({
assistantMessageId: assistantId,
clientRequestId: randomUUID(),
skillId: project.skillId ?? null,
skillIds: Array.isArray(meta?.skillIds) ? meta.skillIds : [],
designSystemId: project.designSystemId ?? null,
attachments: attachments.map((a) => a.path),
commentAttachments,
@ -1786,14 +1807,19 @@ export function ProjectView({
);
const projectMeta = useMemo(() => {
const skill = skills.find((s) => s.id === project.skillId)?.name;
const summary =
skills.find((s) => s.id === project.skillId) ??
designTemplates.find((s) => s.id === project.skillId);
const skill = summary?.name;
const ds = designSystems.find((d) => d.id === project.designSystemId)?.title;
return [skill, ds].filter(Boolean).join(' · ') || t('project.metaFreeform');
}, [skills, designSystems, project.skillId, project.designSystemId, t]);
}, [skills, designTemplates, designSystems, project.skillId, project.designSystemId, t]);
const isDeck = useMemo(
() => skills.find((s) => s.id === project.skillId)?.mode === 'deck',
[skills, project.skillId],
() =>
(skills.find((s) => s.id === project.skillId) ??
designTemplates.find((s) => s.id === project.skillId))?.mode === 'deck',
[skills, designTemplates, project.skillId],
);
const chatResizeLabel = t('project.resizeChatPanel');
const workspacePanelTrack =
@ -2171,6 +2197,7 @@ export function ProjectView({
projectId={project.id}
projectFiles={projectFiles}
projectFileNames={projectFileNames}
skills={skills}
onEnsureProject={handleEnsureProject}
previewComments={previewComments}
attachedComments={attachedComments}

View file

@ -57,7 +57,8 @@ import { MEDIA_PROVIDERS } from '../media/models';
import type { MediaProvider } from '../media/models';
import { PetSettings } from './pet/PetSettings';
import { McpClientSection } from './McpClientSection';
import { LibrarySection } from './LibrarySection';
import { SkillsSection } from './SkillsSection';
import { DesignSystemsSection } from './DesignSystemsSection';
import { PrivacySection } from './PrivacySection';
import { RoutinesSection } from './RoutinesSection';
import { ConnectorsBrowser } from './ConnectorsBrowser';
@ -88,8 +89,9 @@ export type SettingsSection =
| 'appearance'
| 'notifications'
| 'pet'
| 'skills'
| 'designSystems'
| 'memory'
| 'library'
| 'privacy'
| 'about';
@ -1304,8 +1306,12 @@ export function SettingsDialog({
notifications: { title: t('settings.notifications'), subtitle: t('settings.notificationsHint') },
privacy: { title: t('settings.privacy'), subtitle: t('settings.privacyHint') },
pet: { title: t('pet.title'), subtitle: t('pet.subtitle') },
skills: { title: t('settings.skills'), subtitle: t('settings.skillsHint') },
designSystems: {
title: t('settings.designSystems'),
subtitle: t('settings.designSystemsHint'),
},
memory: { title: t('settings.memory'), subtitle: t('settings.memoryHint') },
library: { title: t('settings.library'), subtitle: t('settings.libraryHint') },
about: { title: t('settings.about'), subtitle: t('settings.aboutHint') },
};
const activeHeader = sectionHeader[activeSection];
@ -1373,26 +1379,6 @@ export function SettingsDialog({
<span className="kicker">{t('settings.welcomeKicker')}</span>
<h2>{t('settings.welcomeTitle')}</h2>
<p className="subtitle">{t('settings.welcomeSubtitle')}</p>
{/* First-run users see a mini pet teaser inside the welcome
modal so adoption is part of the warm intro rather than
hidden behind another nav click. The chip nudges them
toward Pets without forcing them to leave the rest of
the welcome flow. */}
<button
type="button"
className="welcome-pet-teaser"
onClick={() => setActiveSection('pet')}
>
<span className="welcome-pet-glyph" aria-hidden>🐾</span>
<span className="welcome-pet-copy">
<strong>{t('pet.welcomeTeaserTitle')}</strong>
<span>{t('pet.welcomeTeaserBody')}</span>
</span>
<span className="welcome-pet-cta">
{t('pet.welcomeTeaserCta')}
<Icon name="chevron-right" size={12} />
</span>
</button>
</>
) : (
<>
@ -1427,17 +1413,6 @@ export function SettingsDialog({
<small>{t('settings.memoryHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'library' ? ' active' : ''}`}
onClick={() => setActiveSection('library')}
>
<Icon name="grid" size={18} />
<span>
<strong>{t('settings.library')}</strong>
<small>{t('settings.libraryHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'media' ? ' active' : ''}`}
@ -1449,6 +1424,28 @@ export function SettingsDialog({
<small>Image / video / audio</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'skills' ? ' active' : ''}`}
onClick={() => setActiveSection('skills')}
>
<Icon name="grid" size={18} />
<span>
<strong>{t('settings.skills')}</strong>
<small>{t('settings.skillsHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'mcpClient' ? ' active' : ''}`}
onClick={() => setActiveSection('mcpClient')}
>
<Icon name="sparkles" size={18} />
<span>
<strong>{t('settings.externalMcpTitle')}</strong>
<small>{t('settings.externalMcpHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'composio' ? ' active' : ''}`}
@ -1493,17 +1490,6 @@ export function SettingsDialog({
<small>{t('settings.mcpServerHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'mcpClient' ? ' active' : ''}`}
onClick={() => setActiveSection('mcpClient')}
>
<Icon name="sparkles" size={18} />
<span>
<strong>{t('settings.externalMcpTitle')}</strong>
<small>{t('settings.externalMcpHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'language' ? ' active' : ''}`}
@ -1548,6 +1534,17 @@ export function SettingsDialog({
<small>{t('pet.navHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'designSystems' ? ' active' : ''}`}
onClick={() => setActiveSection('designSystems')}
>
<Icon name="draw" size={18} />
<span>
<strong>{t('settings.designSystems')}</strong>
<small>{t('settings.designSystemsHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'privacy' ? ' active' : ''}`}
@ -2425,12 +2422,16 @@ export function SettingsDialog({
<PetSettings cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'memory' ? <MemorySection /> : null}
{activeSection === 'library' ? (
<LibrarySection cfg={cfg} setCfg={setCfg} />
{activeSection === 'skills' ? (
<SkillsSection cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'designSystems' ? (
<DesignSystemsSection cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'memory' ? <MemorySection /> : null}
{activeSection === 'privacy' ? (
<PrivacySection cfg={cfg} setCfg={setCfg} />
) : null}

View file

@ -0,0 +1,836 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useT } from '../i18n';
import { Icon } from './Icon';
import type { AppConfig } from '../types';
import type { SkillSummary } from '@open-design/contracts';
import {
deleteSkill,
fetchSkill,
fetchSkillFiles,
fetchSkills,
importSkill,
updateSkill,
type SkillFileEntry,
} from '../providers/registry';
// Functional skills only — design templates render in EntryView's
// Templates tab and are managed under their own daemon registry. See
// specs/current/skills-and-design-templates.md.
//
// Layout mirrors the External MCP servers panel: a single vertical
// stack of collapsible rows. Each row is a skill — the header is
// always visible (enable toggle, name, mode badge, source badge,
// actions); the body (SKILL.md preview, file tree, inline edit form)
// is revealed only when the row is expanded. Replaces the previous
// left-list / right-detail two-column workspace, which felt cramped
// inside the settings dialog content column and left a wasteful empty
// detail panel whenever no skill was selected.
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
}
type SourceFilter = 'all' | 'user' | 'built-in';
interface DraftState {
name: string;
description: string;
triggers: string;
body: string;
}
const EMPTY_DRAFT: DraftState = {
name: '',
description: '',
triggers: '',
body: '',
};
function summaryToDraft(skill: SkillSummary, body: string): DraftState {
return {
name: skill.name,
description: skill.description,
triggers: Array.isArray(skill.triggers) ? skill.triggers.join(', ') : '',
body,
};
}
function parseTriggers(raw: string): string[] {
return raw
.split(/[,\n]/)
.map((t) => t.trim())
.filter(Boolean);
}
export function SkillsSection({ cfg, setCfg }: Props) {
const t = useT();
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all');
const [modeFilter, setModeFilter] = useState<string>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
// Body for the currently-expanded skill — fetched lazily so the
// initial list payload stays small. `undefined` means 'not yet
// fetched'; `''` means 'fetched but empty'.
const [bodyById, setBodyById] = useState<Record<string, string>>({});
const [bodyLoadingId, setBodyLoadingId] = useState<string | null>(null);
// File tree, cached the same way as bodies so re-expanding the same
// row is instant after the first fetch.
const [filesById, setFilesById] = useState<Record<string, SkillFileEntry[]>>({});
const [filesLoadingId, setFilesLoadingId] = useState<string | null>(null);
// One row expanded at a time — keeps the section scannable. `null`
// means every row is collapsed.
const [expandedId, setExpandedId] = useState<string | null>(null);
// Editing happens inline inside an expanded row. Holds the id of the
// skill currently being edited, or `null` when no edit is in flight.
const [editingId, setEditingId] = useState<string | null>(null);
// Top-of-list create form. Toggled by the header 'New skill' button.
const [creating, setCreating] = useState(false);
// Editing draft + status. The draft is held in local state so the
// user can collapse a row and come back without losing progress
// (we drop it only on Save / Cancel).
const [draft, setDraft] = useState<DraftState>(EMPTY_DRAFT);
const [draftError, setDraftError] = useState<string | null>(null);
const [draftSaving, setDraftSaving] = useState(false);
// Inline delete confirmation — replaces the old window.confirm() call.
// Only one skill can be in the 'confirm pending' state at a time; the
// user clicks once to arm, twice to commit.
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const refresh = useCallback(async () => {
const list = await fetchSkills();
setSkills(list);
return list;
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const disabledSkills = useMemo(
() => new Set(cfg.disabledSkills ?? []),
[cfg.disabledSkills],
);
const modeOptions = useMemo(() => {
const counts = new Map<string, number>();
for (const s of skills) {
counts.set(s.mode, (counts.get(s.mode) ?? 0) + 1);
}
return Array.from(counts.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [skills]);
// Categories are optional per-skill metadata (`od.category` in the
// SKILL.md frontmatter). The pill row only renders when at least one
// skill in the listing carries one, so a project that ships only the
// baseline functional skills doesn't see an empty filter row.
const categoryOptions = useMemo(() => {
const counts = new Map<string, number>();
for (const s of skills) {
const cat = s.category;
if (typeof cat !== 'string' || !cat) continue;
counts.set(cat, (counts.get(cat) ?? 0) + 1);
}
return Array.from(counts.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [skills]);
const filteredSkills = useMemo(() => {
const q = search.toLowerCase().trim();
return skills.filter((s) => {
if (modeFilter !== 'all' && s.mode !== modeFilter) return false;
if (sourceFilter !== 'all' && s.source !== sourceFilter) return false;
if (categoryFilter !== 'all' && s.category !== categoryFilter)
return false;
if (!q) return true;
const hay = `${s.name}\n${s.description}\n${(s.triggers ?? []).join(
' ',
)}\n${s.category ?? ''}`;
return hay.toLowerCase().includes(q);
});
}, [skills, modeFilter, sourceFilter, categoryFilter, search]);
const ensureBody = useCallback(
async (id: string) => {
if (bodyById[id] !== undefined) return bodyById[id];
setBodyLoadingId(id);
try {
const detail = await fetchSkill(id);
const body = detail?.body ?? '';
setBodyById((cur) => ({ ...cur, [id]: body }));
return body;
} finally {
setBodyLoadingId((cur) => (cur === id ? null : cur));
}
},
[bodyById],
);
const ensureFiles = useCallback(
async (id: string) => {
if (filesById[id]) return filesById[id]!;
setFilesLoadingId(id);
try {
const files = await fetchSkillFiles(id);
setFilesById((cur) => ({ ...cur, [id]: files }));
return files;
} finally {
setFilesLoadingId((cur) => (cur === id ? null : cur));
}
},
[filesById],
);
const toggleExpanded = useCallback(
(id: string) => {
setExpandedId((cur) => {
if (cur === id) return null;
void ensureBody(id);
void ensureFiles(id);
return id;
});
// Switching rows aborts any in-flight edit on the previous row.
setEditingId((cur) => (cur === id ? cur : null));
setConfirmDeleteId(null);
},
[ensureBody, ensureFiles],
);
const startCreate = useCallback(() => {
setCreating(true);
setDraft(EMPTY_DRAFT);
setDraftError(null);
setEditingId(null);
setConfirmDeleteId(null);
}, []);
const startEdit = useCallback(
async (skill: SkillSummary) => {
const body = await ensureBody(skill.id);
setDraft(summaryToDraft(skill, body ?? ''));
setDraftError(null);
setEditingId(skill.id);
setExpandedId(skill.id);
setCreating(false);
setConfirmDeleteId(null);
},
[ensureBody],
);
const cancelDraft = useCallback(() => {
setDraft(EMPTY_DRAFT);
setDraftError(null);
setEditingId(null);
setCreating(false);
}, []);
const submitDraft = useCallback(async () => {
if (draftSaving) return;
const name = draft.name.trim();
const body = draft.body.trim();
if (!name) {
setDraftError('Skill name is required.');
return;
}
if (!body) {
setDraftError('Skill body is required.');
return;
}
const triggers = parseTriggers(draft.triggers);
const payload = {
name,
description: draft.description.trim() || undefined,
body,
triggers,
};
setDraftSaving(true);
setDraftError(null);
const result =
editingId
? await updateSkill(editingId, payload)
: await importSkill(payload);
setDraftSaving(false);
if ('error' in result) {
setDraftError(result.error.message);
return;
}
const updated = result.skill;
await refresh();
setBodyById((cur) => ({ ...cur, [updated.id]: body }));
// Drop the cached file tree for this id so the next expand
// re-walks the on-disk folder; SKILL.md may have been the only
// file before, but the user might have meant to add more.
setFilesById((cur) => {
const next = { ...cur };
delete next[updated.id];
return next;
});
setExpandedId(updated.id);
setEditingId(null);
setCreating(false);
setDraft(EMPTY_DRAFT);
}, [draft, draftSaving, editingId, refresh]);
const armDelete = useCallback((id: string) => {
setConfirmDeleteId(id);
}, []);
const cancelDelete = useCallback(() => {
setConfirmDeleteId(null);
}, []);
const commitDelete = useCallback(
async (id: string) => {
const result = await deleteSkill(id);
if ('error' in result) {
setDraftError(result.error.message);
return;
}
setConfirmDeleteId(null);
await refresh();
setBodyById((cur) => {
const next = { ...cur };
delete next[id];
return next;
});
setFilesById((cur) => {
const next = { ...cur };
delete next[id];
return next;
});
// Clear the disabled-skill flag so deleting a skill that was
// toggled off doesn't leave dangling preferences behind.
setCfg((c) => {
const set = new Set(c.disabledSkills ?? []);
set.delete(id);
return { ...c, disabledSkills: [...set] };
});
if (expandedId === id) setExpandedId(null);
if (editingId === id) {
setEditingId(null);
setDraft(EMPTY_DRAFT);
}
},
[editingId, expandedId, refresh, setCfg],
);
const toggleEnabled = useCallback(
(id: string, enabled: boolean) => {
setCfg((c) => {
const set = new Set(c.disabledSkills ?? []);
if (enabled) set.delete(id);
else set.add(id);
return { ...c, disabledSkills: [...set] };
});
},
[setCfg],
);
return (
<section className="settings-section settings-skills">
<div className="section-head">
<div>
<h3>{t('settings.skills')}</h3>
<p className="hint">{t('settings.skillsHint')}</p>
</div>
<button
type="button"
className="primary skills-add-btn"
onClick={startCreate}
data-testid="skills-new"
>
<Icon name="plus" size={13} />
<span>{t('settings.skillsNew')}</span>
</button>
</div>
<div className="library-toolbar">
<input
type="search"
className="library-search"
placeholder={t('settings.librarySearch')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="library-filters">
{(['all', 'user', 'built-in'] as const).map((s) => {
const count =
s === 'all'
? skills.length
: skills.filter((skill) => skill.source === s).length;
return (
<button
key={s}
type="button"
className={`filter-pill${sourceFilter === s ? ' active' : ''}`}
onClick={() => setSourceFilter(s)}
>
{s === 'all' ? t('settings.libraryAll') : s}
<span className="filter-pill-count">{count}</span>
</button>
);
})}
</div>
<div className="library-filters">
<button
type="button"
className={`filter-pill${modeFilter === 'all' ? ' active' : ''}`}
onClick={() => setModeFilter('all')}
>
{t('settings.libraryAll')}
</button>
{modeOptions.map(([mode, count]) => (
<button
key={mode}
type="button"
className={`filter-pill${modeFilter === mode ? ' active' : ''}`}
onClick={() => setModeFilter(mode)}
>
{mode}
<span className="filter-pill-count">{count}</span>
</button>
))}
</div>
{categoryOptions.length > 0 ? (
<div className="library-filters" data-testid="skills-category-filters">
<button
type="button"
className={`filter-pill${categoryFilter === 'all' ? ' active' : ''}`}
onClick={() => setCategoryFilter('all')}
>
{t('settings.libraryAll')}
</button>
{categoryOptions.map(([cat, count]) => (
<button
key={cat}
type="button"
className={`filter-pill${categoryFilter === cat ? ' active' : ''}`}
onClick={() => setCategoryFilter(cat)}
>
{humanizeCategory(cat)}
<span className="filter-pill-count">{count}</span>
</button>
))}
</div>
) : null}
</div>
{creating ? (
<SkillDraftForm
heading={t('settings.skillsNew')}
subheading={null}
draft={draft}
setDraft={setDraft}
error={draftError}
saving={draftSaving}
isEdit={false}
onCancel={cancelDraft}
onSubmit={() => void submitDraft()}
/>
) : null}
{filteredSkills.length === 0 ? (
<div className="empty-card">
<strong>{t('settings.libraryNoResults')}</strong>
</div>
) : (
<div className="skills-rows" data-testid="skills-list">
{filteredSkills.map((skill) => {
const enabled = !disabledSkills.has(skill.id);
const isExpanded = expandedId === skill.id;
const isEditing = editingId === skill.id;
return (
<SkillRow
key={skill.id}
skill={skill}
enabled={enabled}
expanded={isExpanded}
editing={isEditing}
body={bodyById[skill.id]}
bodyLoading={bodyLoadingId === skill.id}
files={filesById[skill.id] ?? null}
filesLoading={filesLoadingId === skill.id}
confirmDelete={confirmDeleteId === skill.id}
draft={isEditing ? draft : null}
draftError={isEditing ? draftError : null}
draftSaving={isEditing && draftSaving}
setDraft={setDraft}
onToggleExpanded={() => toggleExpanded(skill.id)}
onToggleEnabled={(e) => toggleEnabled(skill.id, e)}
onStartEdit={() => void startEdit(skill)}
onArmDelete={() => armDelete(skill.id)}
onCancelDelete={cancelDelete}
onCommitDelete={() => void commitDelete(skill.id)}
onCancelEdit={cancelDraft}
onSubmitEdit={() => void submitDraft()}
/>
);
})}
</div>
)}
</section>
);
}
interface SkillRowProps {
skill: SkillSummary;
enabled: boolean;
expanded: boolean;
editing: boolean;
body: string | undefined;
bodyLoading: boolean;
files: SkillFileEntry[] | null;
filesLoading: boolean;
confirmDelete: boolean;
draft: DraftState | null;
draftError: string | null;
draftSaving: boolean;
setDraft: Dispatch<SetStateAction<DraftState>>;
onToggleExpanded: () => void;
onToggleEnabled: (enabled: boolean) => void;
onStartEdit: () => void;
onArmDelete: () => void;
onCancelDelete: () => void;
onCommitDelete: () => void;
onCancelEdit: () => void;
onSubmitEdit: () => void;
}
function SkillRow({
skill,
enabled,
expanded,
editing,
body,
bodyLoading,
files,
filesLoading,
confirmDelete,
draft,
draftError,
draftSaving,
setDraft,
onToggleExpanded,
onToggleEnabled,
onStartEdit,
onArmDelete,
onCancelDelete,
onCommitDelete,
onCancelEdit,
onSubmitEdit,
}: SkillRowProps) {
const t = useT();
const summaryName = skill.name || skill.id;
return (
<div
className={`skills-row${enabled ? '' : ' skills-row-disabled'}${
expanded ? ' skills-row-expanded' : ''
}${editing ? ' skills-row-editing' : ''}`}
data-testid={`skill-row-${skill.id}`}
>
<div className="skills-row-head">
<button
type="button"
className="skills-row-summary-btn"
onClick={onToggleExpanded}
aria-expanded={expanded}
title={expanded ? 'Collapse' : 'Expand'}
>
<span className="skills-row-icon" aria-hidden>
<Icon name="grid" size={14} />
</span>
<span className="skills-row-summary">
<span className="skills-row-summary-line">
<span className="skills-row-summary-name">{summaryName}</span>
<span className="skills-row-summary-mode">{skill.mode}</span>
{skill.category ? (
<span
className="skills-row-summary-category"
title={`Category: ${humanizeCategory(skill.category)}`}
>
{humanizeCategory(skill.category)}
</span>
) : null}
{skill.source === 'user' ? (
<span
className="skills-row-summary-source"
title="User-imported skill"
>
user
</span>
) : null}
</span>
{skill.description ? (
<span className="skills-row-summary-desc">{skill.description}</span>
) : null}
</span>
<span className="skills-row-chevron" aria-hidden>
<Icon name="chevron-down" size={14} />
</span>
</button>
<div className="skills-row-actions">
{confirmDelete ? (
<span className="skills-delete-confirm" role="group">
<button
type="button"
className="btn danger"
onClick={onCommitDelete}
data-testid="skills-delete-confirm"
>
{t('settings.skillsDeleteConfirm')}
</button>
<button
type="button"
className="btn ghost"
onClick={onCancelDelete}
>
{t('common.cancel')}
</button>
</span>
) : (
<>
<button
type="button"
className="icon-btn"
onClick={onStartEdit}
title={t('settings.skillsEdit')}
data-testid="skills-edit"
>
<Icon name="edit" size={13} />
</button>
<button
type="button"
className="icon-btn"
onClick={onArmDelete}
title={t('settings.skillsDelete')}
data-testid="skills-delete"
>
<Icon name="close" size={13} />
</button>
</>
)}
<label
className="toggle-switch toggle-switch-sm skills-row-enable"
title={t('settings.libraryToggleLabel')}
>
<input
type="checkbox"
checked={enabled}
onChange={(e) => onToggleEnabled(e.target.checked)}
aria-label={t('settings.libraryToggleLabel')}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
{expanded && !editing ? (
<div className="skills-row-detail">
<div className="skills-row-section">
<h5>SKILL.md</h5>
{bodyLoading ? (
<p className="library-empty">{t('settings.libraryLoading')}</p>
) : (
<pre className="library-preview-body">{body ?? ''}</pre>
)}
</div>
<div className="skills-row-section">
<h5>{t('settings.skillsFiles')}</h5>
{filesLoading ? (
<p className="library-empty">{t('settings.libraryLoading')}</p>
) : !files || files.length === 0 ? (
<p className="library-empty">{t('settings.skillsNoFiles')}</p>
) : (
<ul className="skills-file-tree">
{files.map((entry) => (
<li
key={entry.path}
className={`skills-file-entry skills-file-entry-${entry.kind}`}
style={{ paddingLeft: depthIndent(entry.path) }}
>
<Icon
name={entry.kind === 'directory' ? 'folder' : 'file'}
size={12}
/>
<span>{leafName(entry.path)}</span>
{entry.kind === 'file' && typeof entry.size === 'number' ? (
<span className="skills-file-size">
{formatSize(entry.size)}
</span>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
) : null}
{editing && draft ? (
<SkillDraftForm
heading={t('settings.skillsEdit')}
subheading={skill.id}
draft={draft}
setDraft={setDraft}
error={draftError}
saving={draftSaving}
isEdit
onCancel={onCancelEdit}
onSubmit={onSubmitEdit}
/>
) : null}
</div>
);
}
interface SkillDraftFormProps {
heading: string;
subheading: string | null;
draft: DraftState;
setDraft: Dispatch<SetStateAction<DraftState>>;
error: string | null;
saving: boolean;
isEdit: boolean;
onCancel: () => void;
onSubmit: () => void;
}
function SkillDraftForm({
heading,
subheading,
draft,
setDraft,
error,
saving,
isEdit,
onCancel,
onSubmit,
}: SkillDraftFormProps) {
const t = useT();
return (
<div
className="skills-draft library-import-form"
data-testid={isEdit ? 'skills-edit-form' : 'skills-create-form'}
>
<header className="skills-draft-head">
<div>
<h4>{heading}</h4>
{subheading ? <p className="skills-draft-sub">{subheading}</p> : null}
</div>
</header>
<div className="library-import-row">
<label>
<span>{t('settings.skillsName')}</span>
<input
type="text"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="my-skill"
disabled={isEdit}
/>
</label>
<label>
<span>{t('settings.skillsTriggers')}</span>
<input
type="text"
value={draft.triggers}
onChange={(e) =>
setDraft((d) => ({ ...d, triggers: e.target.value }))
}
placeholder="search the web, summarize"
/>
</label>
</div>
<label className="library-import-block">
<span>{t('settings.skillsDescription')}</span>
<textarea
rows={2}
value={draft.description}
onChange={(e) =>
setDraft((d) => ({ ...d, description: e.target.value }))
}
placeholder="What does this skill do? When should the agent reach for it?"
/>
</label>
<label className="library-import-block">
<span>{t('settings.skillsBody')}</span>
<textarea
rows={14}
value={draft.body}
onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
placeholder={'# My skill\n\n1. Explain the workflow.\n2. Describe the inputs and outputs.'}
/>
</label>
{error ? (
<div className="library-import-error" role="alert">
{error}
</div>
) : null}
<div className="library-import-actions">
<button
type="button"
className="btn ghost"
onClick={onCancel}
disabled={saving}
>
{t('common.cancel')}
</button>
<button
type="button"
className="btn primary"
onClick={onSubmit}
disabled={saving}
data-testid="skills-save"
>
{saving
? t('settings.skillsSaving')
: isEdit
? t('settings.skillsSave')
: t('settings.skillsCreate')}
</button>
</div>
</div>
);
}
// Each `/`-separated segment indents by 12px so a small assets/ tree
// reads as a tree without us building a nested list. Capped at 4 levels
// so bundles with deep folder hierarchies don't push the file label
// past the panel.
function depthIndent(p: string): number {
const depth = Math.min(4, p.split('/').length - 1);
return depth * 12;
}
function leafName(p: string): string {
const idx = p.lastIndexOf('/');
return idx >= 0 ? p.slice(idx + 1) : p;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Frontmatter-style category slugs come in as kebab-case
// ("image-generation"). Render them as Title Case in the filter pill so
// the row reads as a category list rather than a raw enum dump.
function humanizeCategory(slug: string): string {
if (!slug) return slug;
return slug
.split('-')
.map((word) =>
word.length === 0
? word
: word.charAt(0).toUpperCase() + word.slice(1),
)
.join(' ');
}

View file

@ -387,6 +387,103 @@ export const FR_SKILL_IDS_WITH_EN_FALLBACK = [
'swiss-creative-mode-template',
'github-dashboard',
'after-hours-editorial-template',
// Curated design/creative skill catalogue (PR #955) — lightweight stubs
// pointing at upstream awesome-claude-skills / awesome-agent-skills
// entries; English-only by design. The localized-content coverage test
// treats these as English-fallback so the stubs land in Settings →
// Skills without forcing a localization task per locale.
'ad-creative',
'ai-music-album',
'algorithmic-art',
'apple-hig',
'artifacts-builder',
'brainstorming',
'brand-guidelines',
'canvas-design',
'color-expert',
'competitive-ads-extractor',
'copywriting',
'creative-director',
'd3-visualization',
'design-consultation',
'design-md',
'design-review',
'doc',
'docx',
'domain-name-brainstormer',
'enhance-prompt',
'fal-3d',
'fal-generate',
'fal-image-edit',
'fal-kling-o3',
'fal-lip-sync',
'fal-realtime',
'fal-restore',
'fal-train',
'fal-tryon',
'fal-upscale',
'fal-video-edit',
'fal-vision',
'figma-code-connect-components',
'figma-create-design-system-rules',
'figma-create-new-file',
'figma-generate-design',
'figma-generate-library',
'figma-implement-design',
'figma-use',
'flutter-animating-apps',
'frontend-design',
'frontend-dev',
'frontend-skill',
'frontend-slides',
'full-page-screenshot',
'gif-sticker-maker',
'gsap-core',
'gsap-react',
'gsap-scrolltrigger',
'gsap-timeline',
'hand-drawn-diagrams',
'image-enhancer',
'imagegen',
'imagen',
'marketing-psychology',
'minimax-docx',
'minimax-pdf',
'nanobanana-ppt',
'paywall-upgrade-cro',
'pdf',
'pixelbin-media',
'plan-design-review',
'platform-design',
'pptx',
'pptx-generator',
'remotion',
'replicate',
'screenshot',
'screenshots-marketing',
'shadcn-ui',
'shader-dev',
'slack-gif-creator',
'slides',
'sora',
'speech',
'stitch-loop',
'swiftui-design',
'taste-skill',
'theme-factory',
'threejs',
'ui-skills',
'ui-ux-pro-max',
'venice-audio-music',
'venice-audio-speech',
'venice-image-edit',
'venice-image-generate',
'venice-video',
'video-downloader',
'web-artifacts-builder',
'web-design-guidelines',
'wpds',
'youtube-clipper',
] as const;
export const FR_DESIGN_SYSTEM_IDS_WITH_EN_FALLBACK = [

View file

@ -387,6 +387,103 @@ export const RU_SKILL_IDS_WITH_EN_FALLBACK = [
'swiss-creative-mode-template',
'github-dashboard',
'after-hours-editorial-template',
// Curated design/creative skill catalogue (PR #955) — lightweight stubs
// pointing at upstream awesome-claude-skills / awesome-agent-skills
// entries; English-only by design. The localized-content coverage test
// treats these as English-fallback so the stubs land in Settings →
// Skills without forcing a localization task per locale.
'ad-creative',
'ai-music-album',
'algorithmic-art',
'apple-hig',
'artifacts-builder',
'brainstorming',
'brand-guidelines',
'canvas-design',
'color-expert',
'competitive-ads-extractor',
'copywriting',
'creative-director',
'd3-visualization',
'design-consultation',
'design-md',
'design-review',
'doc',
'docx',
'domain-name-brainstormer',
'enhance-prompt',
'fal-3d',
'fal-generate',
'fal-image-edit',
'fal-kling-o3',
'fal-lip-sync',
'fal-realtime',
'fal-restore',
'fal-train',
'fal-tryon',
'fal-upscale',
'fal-video-edit',
'fal-vision',
'figma-code-connect-components',
'figma-create-design-system-rules',
'figma-create-new-file',
'figma-generate-design',
'figma-generate-library',
'figma-implement-design',
'figma-use',
'flutter-animating-apps',
'frontend-design',
'frontend-dev',
'frontend-skill',
'frontend-slides',
'full-page-screenshot',
'gif-sticker-maker',
'gsap-core',
'gsap-react',
'gsap-scrolltrigger',
'gsap-timeline',
'hand-drawn-diagrams',
'image-enhancer',
'imagegen',
'imagen',
'marketing-psychology',
'minimax-docx',
'minimax-pdf',
'nanobanana-ppt',
'paywall-upgrade-cro',
'pdf',
'pixelbin-media',
'plan-design-review',
'platform-design',
'pptx',
'pptx-generator',
'remotion',
'replicate',
'screenshot',
'screenshots-marketing',
'shadcn-ui',
'shader-dev',
'slack-gif-creator',
'slides',
'sora',
'speech',
'stitch-loop',
'swiftui-design',
'taste-skill',
'theme-factory',
'threejs',
'ui-skills',
'ui-ux-pro-max',
'venice-audio-music',
'venice-audio-speech',
'venice-image-edit',
'venice-image-generate',
'venice-video',
'video-downloader',
'web-artifacts-builder',
'web-design-guidelines',
'wpds',
'youtube-clipper',
] as const;
export const RU_DESIGN_SYSTEM_IDS_WITH_EN_FALLBACK = [

View file

@ -434,6 +434,104 @@ const DE_SKILL_IDS_WITH_EN_FALLBACK = [
'swiss-creative-mode-template',
'github-dashboard',
'after-hours-editorial-template',
// Curated design/creative skill catalogue (PR #955) — lightweight stubs
// that point at upstream awesome-claude-skills / awesome-agent-skills
// entries. The frontmatter description is English-only by design; the
// localized-content coverage test treats these as English-fallback so
// the stubs land in Settings → Skills without forcing a localization
// task per locale.
'ad-creative',
'ai-music-album',
'algorithmic-art',
'apple-hig',
'artifacts-builder',
'brainstorming',
'brand-guidelines',
'canvas-design',
'color-expert',
'competitive-ads-extractor',
'copywriting',
'creative-director',
'd3-visualization',
'design-consultation',
'design-md',
'design-review',
'doc',
'docx',
'domain-name-brainstormer',
'enhance-prompt',
'fal-3d',
'fal-generate',
'fal-image-edit',
'fal-kling-o3',
'fal-lip-sync',
'fal-realtime',
'fal-restore',
'fal-train',
'fal-tryon',
'fal-upscale',
'fal-video-edit',
'fal-vision',
'figma-code-connect-components',
'figma-create-design-system-rules',
'figma-create-new-file',
'figma-generate-design',
'figma-generate-library',
'figma-implement-design',
'figma-use',
'flutter-animating-apps',
'frontend-design',
'frontend-dev',
'frontend-skill',
'frontend-slides',
'full-page-screenshot',
'gif-sticker-maker',
'gsap-core',
'gsap-react',
'gsap-scrolltrigger',
'gsap-timeline',
'hand-drawn-diagrams',
'image-enhancer',
'imagegen',
'imagen',
'marketing-psychology',
'minimax-docx',
'minimax-pdf',
'nanobanana-ppt',
'paywall-upgrade-cro',
'pdf',
'pixelbin-media',
'plan-design-review',
'platform-design',
'pptx',
'pptx-generator',
'remotion',
'replicate',
'screenshot',
'screenshots-marketing',
'shadcn-ui',
'shader-dev',
'slack-gif-creator',
'slides',
'sora',
'speech',
'stitch-loop',
'swiftui-design',
'taste-skill',
'theme-factory',
'threejs',
'ui-skills',
'ui-ux-pro-max',
'venice-audio-music',
'venice-audio-speech',
'venice-image-edit',
'venice-image-generate',
'venice-video',
'video-downloader',
'web-artifacts-builder',
'web-design-guidelines',
'wpds',
'youtube-clipper',
] as const;
const DE_DESIGN_SYSTEM_IDS_WITH_EN_FALLBACK = [

View file

@ -191,7 +191,7 @@ export const ar: Dict = {
'settings.versionUnavailable': 'تفاصيل النسخة غير متوفرة بينما البرنامج الخفي غير متصل.',
'entry.tabDesigns': 'التصاميم',
'entry.tabExamples': 'أمثلة',
'entry.tabTemplates': 'قوالب',
'entry.tabDesignSystems': 'أنظمة التصميم',
'entry.tabConnectors': 'الموصلات',
'entry.openSettingsTitle': 'الإعدادات',
@ -560,9 +560,9 @@ export const ar: Dict = {
'chat.you': 'أنت',
'chat.openFile': 'فتح {name}',
'chat.composerPlaceholder':
'صف التصميم الذي تريده - الصق أو اسحب الصور، أو @ لملف...',
'صف التصميم الذي تريده - الصق أو اسحب الصور، أو @ لملف أو مهارة...',
'chat.composerHint':
'⌘/Ctrl + Enter للإرسال · الصق الصور · @ للإشارة لملفات',
'⌘/Ctrl + Enter للإرسال · الصق الصور · @ للملفات أو المهارات · / للأوامر',
'chat.cliSettingsTitle': 'إعدادات CLI والنموذج',
'chat.cliSettingsAria': 'فتح إعدادات CLI والنموذج',
'chat.attachTitle': 'إرفاق ملفات (أو الصق / اسحب)',
@ -1118,8 +1118,24 @@ export const ar: Dict = {
'settings.notifySoundBuzz': 'طنين',
'settings.notifySoundTwoToneDown': 'نغمتان هابطتان',
'settings.notifySoundThud': 'دمدمة',
'settings.library': 'المهارات وأنظمة التصميم',
'settings.libraryHint': 'تصفح ومعاينة وتفعيل/تعطيل مكتبة المحتوى الخاصة بك',
'settings.skills': 'المهارات',
'settings.skillsHint': 'المهارات الوظيفية التي يمكن للوكيل استدعاؤها أثناء المهمة',
'settings.skillsNew': 'مهارة جديدة',
'settings.skillsEmpty': 'حدد مهارة من اليسار أو أنشئ مهارة جديدة.',
'settings.skillsEdit': 'تحرير',
'settings.skillsDelete': 'حذف',
'settings.skillsDeleteConfirm': 'تأكيد الحذف',
'settings.skillsName': 'الاسم',
'settings.skillsTriggers': 'محفزات (مفصولة بفواصل أو أسطر جديدة)',
'settings.skillsDescription': 'الوصف',
'settings.skillsBody': 'محتوى SKILL.md',
'settings.skillsCreate': 'إنشاء',
'settings.skillsSave': 'حفظ',
'settings.skillsSaving': 'جاري الحفظ…',
'settings.skillsFiles': 'الملفات',
'settings.skillsNoFiles': 'لا توجد ملفات في مجلد هذه المهارة.',
'settings.designSystems': 'أنظمة التصميم',
'settings.designSystemsHint': 'تصفح وتفعيل أنظمة التصميم المتاحة للوكيل',
'settings.librarySkills': 'المهارات',
'settings.libraryDesignSystems': 'أنظمة التصميم',
'settings.librarySearch': 'بحث...',

View file

@ -191,7 +191,7 @@ export const de: Dict = {
'settings.versionUnavailable': 'Versionsdetails sind nicht verfügbar, solange der Daemon offline ist.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Beispiele',
'entry.tabTemplates': 'Vorlagen',
'entry.tabDesignSystems': 'Designsysteme',
'entry.openSettingsTitle': 'Einstellungen',
'entry.openSettingsAria': 'Einstellungen öffnen',
@ -448,9 +448,9 @@ export const de: Dict = {
'chat.you': 'Sie',
'chat.openFile': '{name} öffnen',
'chat.composerPlaceholder':
'Beschreiben Sie das gewünschte Design — Bilder einfügen/ablegen oder mit @ eine Datei referenzieren…',
'Beschreiben Sie das gewünschte Design — Bilder einfügen/ablegen oder mit @ eine Datei oder einen Skill referenzieren…',
'chat.composerHint':
'⌘/Ctrl + Enter zum Senden · Bilder einfügen · @ für Dateireferenzen',
'⌘/Ctrl + Enter zum Senden · Bilder einfügen · @ für Dateien oder Skills · / für Befehle',
'chat.cliSettingsTitle': 'CLI- & Modelleinstellungen',
'chat.cliSettingsAria': 'CLI- und Modelleinstellungen öffnen',
'chat.attachTitle': 'Dateien anhängen (oder einfügen / ablegen)',
@ -1006,8 +1006,24 @@ export const de: Dict = {
'settings.notifySoundBuzz': 'Summen',
'settings.notifySoundTwoToneDown': 'Zweiton abwärts',
'settings.notifySoundThud': 'Dumpfer Schlag',
'settings.library': 'Fähigkeiten & Designsysteme',
'settings.libraryHint': 'Inhaltsbibliothek durchsuchen, vorschauen und umschalten',
'settings.skills': 'Skills',
'settings.skillsHint': 'Funktionale Skills, die der Agent während einer Aufgabe aufrufen kann',
'settings.skillsNew': 'Neuer Skill',
'settings.skillsEmpty': 'Wähle links einen Skill aus oder erstelle einen neuen.',
'settings.skillsEdit': 'Bearbeiten',
'settings.skillsDelete': 'Löschen',
'settings.skillsDeleteConfirm': 'Löschen bestätigen',
'settings.skillsName': 'Name',
'settings.skillsTriggers': 'Trigger (Komma- oder zeilengetrennt)',
'settings.skillsDescription': 'Beschreibung',
'settings.skillsBody': 'SKILL.md-Inhalt',
'settings.skillsCreate': 'Erstellen',
'settings.skillsSave': 'Speichern',
'settings.skillsSaving': 'Speichern…',
'settings.skillsFiles': 'Dateien',
'settings.skillsNoFiles': 'Keine Dateien in diesem Skill-Ordner.',
'settings.designSystems': 'Design-Systeme',
'settings.designSystemsHint': 'Verfügbare Design-Systeme durchsuchen und umschalten',
'settings.librarySkills': 'Fähigkeiten',
'settings.libraryDesignSystems': 'Designsysteme',
'settings.librarySearch': 'Suchen...',

View file

@ -245,7 +245,7 @@ export const en: Dict = {
'Open Design must be running for MCP tool calls to succeed. If you started your coding agent before opening Open Design, restart the agent so it can reach the live daemon.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Examples',
'entry.tabTemplates': 'Templates',
'entry.tabDesignSystems': 'Design systems',
'entry.tabConnectors': 'Connectors',
'entry.openSettingsTitle': 'Settings',
@ -627,9 +627,9 @@ export const en: Dict = {
'chat.you': 'You',
'chat.openFile': 'Open {name}',
'chat.composerPlaceholder':
'Describe the design you want — paste or drop images, or @ a file…',
'Describe the design you want — paste or drop images, or @ a file or skill…',
'chat.composerHint':
'⌘/Ctrl + Enter to send · paste images · @ to reference files',
'⌘/Ctrl + Enter to send · paste images · @ files or skills · / for commands',
'chat.cliSettingsTitle': 'CLI & model settings',
'chat.cliSettingsAria': 'Open CLI and model settings',
'chat.attachTitle': 'Attach files (or paste / drop)',
@ -1220,8 +1220,24 @@ export const en: Dict = {
'settings.notifySoundBuzz': 'Buzz',
'settings.notifySoundTwoToneDown': 'Two-tone down',
'settings.notifySoundThud': 'Thud',
'settings.library': 'Skills & Design Systems',
'settings.libraryHint': 'Browse, preview, and toggle your content library',
'settings.skills': 'Skills',
'settings.skillsHint': 'Functional skills the agent can invoke mid-task',
'settings.skillsNew': 'New skill',
'settings.skillsEmpty': 'Select a skill on the left, or create a new one.',
'settings.skillsEdit': 'Edit',
'settings.skillsDelete': 'Delete',
'settings.skillsDeleteConfirm': 'Confirm delete',
'settings.skillsName': 'Name',
'settings.skillsTriggers': 'Triggers (comma- or newline-separated)',
'settings.skillsDescription': 'Description',
'settings.skillsBody': 'SKILL.md body',
'settings.skillsCreate': 'Create',
'settings.skillsSave': 'Save',
'settings.skillsSaving': 'Saving…',
'settings.skillsFiles': 'Files',
'settings.skillsNoFiles': 'No files in this skill folder.',
'settings.designSystems': 'Design Systems',
'settings.designSystemsHint': 'Browse and toggle the design systems your agent can use',
'settings.librarySkills': 'Skills',
'settings.libraryDesignSystems': 'Design Systems',
'settings.librarySearch': 'Search...',

View file

@ -191,7 +191,7 @@ export const esES: Dict = {
'settings.versionUnavailable': 'Los detalles de versión no están disponibles mientras el daemon está offline.',
'entry.tabDesigns': 'Diseños',
'entry.tabExamples': 'Ejemplos',
'entry.tabTemplates': 'Plantillas',
'entry.tabDesignSystems': 'Sistemas de diseño',
'entry.openSettingsTitle': 'Ajustes',
'entry.openSettingsAria': 'Abrir ajustes',
@ -449,9 +449,9 @@ export const esES: Dict = {
'chat.you': 'Tú',
'chat.openFile': 'Abrir {name}',
'chat.composerPlaceholder':
'Describe el diseño que quieres: pega o suelta imágenes, o usa @ para referenciar un archivo…',
'Describe el diseño que quieres: pega o suelta imágenes, o usa @ para referenciar un archivo o skill…',
'chat.composerHint':
'⌘/Ctrl + Intro para enviar · pega imágenes · @ para referenciar archivos',
'⌘/Ctrl + Intro para enviar · pega imágenes · @ para archivos o skills · / para comandos',
'chat.cliSettingsTitle': 'Ajustes de CLI y modelo',
'chat.cliSettingsAria': 'Abrir ajustes de CLI y modelo',
'chat.attachTitle': 'Adjuntar archivos (o pegar / soltar)',
@ -1007,8 +1007,24 @@ export const esES: Dict = {
'settings.notifySoundBuzz': 'Zumbido',
'settings.notifySoundTwoToneDown': 'Dos tonos descendente',
'settings.notifySoundThud': 'Golpe',
'settings.library': 'Habilidades y sistemas de diseño',
'settings.libraryHint': 'Explorar, previsualizar y activar/desactivar tu biblioteca de contenidos',
'settings.skills': 'Habilidades',
'settings.skillsHint': 'Habilidades funcionales que el agente puede invocar durante la tarea',
'settings.skillsNew': 'Nueva habilidad',
'settings.skillsEmpty': 'Selecciona una habilidad a la izquierda o crea una nueva.',
'settings.skillsEdit': 'Editar',
'settings.skillsDelete': 'Eliminar',
'settings.skillsDeleteConfirm': 'Confirmar eliminación',
'settings.skillsName': 'Nombre',
'settings.skillsTriggers': 'Disparadores (separados por comas o saltos de línea)',
'settings.skillsDescription': 'Descripción',
'settings.skillsBody': 'Cuerpo de SKILL.md',
'settings.skillsCreate': 'Crear',
'settings.skillsSave': 'Guardar',
'settings.skillsSaving': 'Guardando…',
'settings.skillsFiles': 'Archivos',
'settings.skillsNoFiles': 'No hay archivos en esta carpeta de habilidad.',
'settings.designSystems': 'Sistemas de diseño',
'settings.designSystemsHint': 'Explora y activa los sistemas de diseño disponibles',
'settings.librarySkills': 'Habilidades',
'settings.libraryDesignSystems': 'Sistemas de diseño',
'settings.librarySearch': 'Buscar...',

View file

@ -191,7 +191,7 @@ export const fa: Dict = {
'settings.versionUnavailable': 'تا وقتی daemon آفلاین است جزئیات نسخه در دسترس نیست.',
'entry.tabDesigns': 'طرح‌ها',
'entry.tabExamples': 'نمونهها',
'entry.tabTemplates': 'قالبها',
'entry.tabDesignSystems': 'سیستم‌های طراحی',
'entry.tabConnectors': 'اتصال‌دهنده‌ها',
'entry.tabImageTemplates': 'قالب‌های تصویر',
@ -573,9 +573,9 @@ export const fa: Dict = {
'chat.you': 'شما',
'chat.openFile': 'باز کردن {name}',
'chat.composerPlaceholder':
'طرح مورد نظر خود را توصیف کنید — تصاویر را بچسبانید یا رها کنید، یا @ برای مرجع فایل…',
'طرح مورد نظر خود را توصیف کنید — تصاویر را بچسبانید یا رها کنید، یا @ برای مرجع فایل یا مهارت…',
'chat.composerHint':
'⌘/Ctrl + Enter برای ارسال · چسباندن تصاویر · @ برای مرجع فایل‌ها',
'⌘/Ctrl + Enter برای ارسال · چسباندن تصاویر · @ برای فایل‌ها یا مهارت‌ها · / برای دستورها',
'chat.cliSettingsTitle': 'تنظیمات CLI و مدل',
'chat.cliSettingsAria': 'باز کردن تنظیمات CLI و مدل',
'chat.attachTitle': 'ضمیمه کردن فایل‌ها (یا چسباندن / رها کردن)',
@ -1152,8 +1152,24 @@ export const fa: Dict = {
'settings.notifySoundBuzz': 'وزوز',
'settings.notifySoundTwoToneDown': 'دو نوای پایین‌رونده',
'settings.notifySoundThud': 'تالاپ',
'settings.library': 'مهارت‌ها و سیستم‌های طراحی',
'settings.libraryHint': 'مرور، پیش‌نمایش و فعال/غیرفعال‌سازی کتابخانه محتوای شما',
'settings.skills': 'مهارت‌ها',
'settings.skillsHint': 'مهارت‌های کاربردی که عامل می‌تواند در حین یک وظیفه فراخوانی کند',
'settings.skillsNew': 'مهارت جدید',
'settings.skillsEmpty': 'یک مهارت را از سمت چپ انتخاب کنید یا یکی بسازید.',
'settings.skillsEdit': 'ویرایش',
'settings.skillsDelete': 'حذف',
'settings.skillsDeleteConfirm': 'تأیید حذف',
'settings.skillsName': 'نام',
'settings.skillsTriggers': 'محرک‌ها (با کاما یا خط جدید جدا شوند)',
'settings.skillsDescription': 'توضیحات',
'settings.skillsBody': 'متن SKILL.md',
'settings.skillsCreate': 'ایجاد',
'settings.skillsSave': 'ذخیره',
'settings.skillsSaving': 'در حال ذخیره…',
'settings.skillsFiles': 'فایل‌ها',
'settings.skillsNoFiles': 'هیچ فایلی در این پوشه مهارت نیست.',
'settings.designSystems': 'سیستم‌های طراحی',
'settings.designSystemsHint': 'سیستم‌های طراحی موجود را مرور و فعال کنید',
'settings.librarySkills': 'مهارت‌ها',
'settings.libraryDesignSystems': 'سیستم‌های طراحی',
'settings.librarySearch': 'جستجو...',

View file

@ -191,7 +191,7 @@ export const fr: Dict = {
'settings.versionUnavailable': 'Les informations de version sont indisponibles lorsque le daemon est hors ligne.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Exemples',
'entry.tabTemplates': 'Modèles',
'entry.tabDesignSystems': 'Design systems',
'entry.tabConnectors': 'Connecteurs',
'entry.openSettingsTitle': 'Paramètres',
@ -560,9 +560,9 @@ export const fr: Dict = {
'chat.you': 'Vous',
'chat.openFile': 'Ouvrir {name}',
'chat.composerPlaceholder':
'Décrivez le design souhaité — collez ou déposez des images, ou @ un fichier…',
'Décrivez le design souhaité — collez ou déposez des images, ou @ un fichier ou un skill…',
'chat.composerHint':
'⌘/Ctrl + Entrée pour envoyer · coller des images · @ pour référencer des fichiers',
'⌘/Ctrl + Entrée pour envoyer · coller des images · @ pour fichiers ou skills · / pour les commandes',
'chat.cliSettingsTitle': 'Paramètres CLI et modèle',
'chat.cliSettingsAria': 'Ouvrir les paramètres CLI et modèle',
'chat.attachTitle': 'Attacher des fichiers (ou coller / déposer)',
@ -1118,8 +1118,24 @@ export const fr: Dict = {
'settings.notifySoundBuzz': 'Buzz',
'settings.notifySoundTwoToneDown': 'Bitonale descendante',
'settings.notifySoundThud': 'Sourd',
'settings.library': 'Compétences et systèmes de design',
'settings.libraryHint': 'Parcourir, prévisualiser et activer/désactiver votre bibliothèque de contenus',
'settings.skills': 'Compétences',
'settings.skillsHint': 'Compétences que lagent peut invoquer en cours de tâche',
'settings.skillsNew': 'Nouvelle compétence',
'settings.skillsEmpty': 'Sélectionnez une compétence à gauche, ou créez-en une.',
'settings.skillsEdit': 'Modifier',
'settings.skillsDelete': 'Supprimer',
'settings.skillsDeleteConfirm': 'Confirmer la suppression',
'settings.skillsName': 'Nom',
'settings.skillsTriggers': 'Déclencheurs (séparés par virgules ou retours à la ligne)',
'settings.skillsDescription': 'Description',
'settings.skillsBody': 'Corps SKILL.md',
'settings.skillsCreate': 'Créer',
'settings.skillsSave': 'Enregistrer',
'settings.skillsSaving': 'Enregistrement…',
'settings.skillsFiles': 'Fichiers',
'settings.skillsNoFiles': 'Aucun fichier dans ce dossier de compétence.',
'settings.designSystems': 'Design systems',
'settings.designSystemsHint': 'Parcourez et activez les design systems disponibles',
'settings.librarySkills': 'Compétences',
'settings.libraryDesignSystems': 'Systèmes de design',
'settings.librarySearch': 'Rechercher...',

View file

@ -191,7 +191,7 @@ export const hu: Dict = {
'settings.versionUnavailable': 'A verzió adatai nem érhetők el, amíg a daemon offline.',
'entry.tabDesigns': 'Tervek',
'entry.tabExamples': 'Példák',
'entry.tabTemplates': 'Sablonok',
'entry.tabDesignSystems': 'Designrendszerek',
'entry.tabConnectors': 'Kapcsolók',
'entry.openSettingsTitle': 'Beállítások',
@ -560,9 +560,9 @@ export const hu: Dict = {
'chat.you': 'Te',
'chat.openFile': '{name} megnyitása',
'chat.composerPlaceholder':
'Írd le a kívánt designt — illessz be vagy húzz képeket, vagy @-tel hivatkozz fájlra…',
'Írd le a kívánt designt — illessz be vagy húzz képeket, vagy @-tel hivatkozz fájlra vagy skillre…',
'chat.composerHint':
'⌘/Ctrl + Enter: küldés · képek beillesztése · @ fájlra hivatkozás',
'⌘/Ctrl + Enter: küldés · képek beillesztése · @ fájl vagy skill · / parancsok',
'chat.cliSettingsTitle': 'CLI- és modellbeállítások',
'chat.cliSettingsAria': 'CLI- és modellbeállítások megnyitása',
'chat.attachTitle': 'Fájlok csatolása (vagy beillesztés / húzás)',
@ -1128,8 +1128,24 @@ export const hu: Dict = {
'settings.notifySoundBuzz': 'Zümmögés',
'settings.notifySoundTwoToneDown': 'Kétszólamú ereszkedő',
'settings.notifySoundThud': 'Tompa puffanás',
'settings.library': 'Készségek és tervezőrendszerek',
'settings.libraryHint': 'Tartalomkönyvtár böngészése, előnézete és be-/kikapcsolása',
'settings.skills': 'Készségek',
'settings.skillsHint': 'Funkcionális készségek, amelyeket az ügynök egy feladat közben hívhat',
'settings.skillsNew': 'Új készség',
'settings.skillsEmpty': 'Válassz egy készséget balra, vagy hozz létre újat.',
'settings.skillsEdit': 'Szerkesztés',
'settings.skillsDelete': 'Törlés',
'settings.skillsDeleteConfirm': 'Törlés megerősítése',
'settings.skillsName': 'Név',
'settings.skillsTriggers': 'Indítók (vesszővel vagy új sorral elválasztva)',
'settings.skillsDescription': 'Leírás',
'settings.skillsBody': 'SKILL.md tartalom',
'settings.skillsCreate': 'Létrehozás',
'settings.skillsSave': 'Mentés',
'settings.skillsSaving': 'Mentés…',
'settings.skillsFiles': 'Fájlok',
'settings.skillsNoFiles': 'Nincs fájl ebben a készségmappában.',
'settings.designSystems': 'Designrendszerek',
'settings.designSystemsHint': 'Böngészd és kapcsold be az elérhető designrendszereket',
'settings.librarySkills': 'Készségek',
'settings.libraryDesignSystems': 'Tervezőrendszerek',
'settings.librarySearch': 'Keresés...',

View file

@ -285,7 +285,7 @@ export const id: Dict = {
'settings.orbit.sourceMarkdown': 'Markdown sumber',
'entry.tabDesigns': 'Desain',
'entry.tabExamples': 'Contoh',
'entry.tabTemplates': 'Templat',
'entry.tabDesignSystems': 'Sistem desain',
'entry.tabConnectors': 'Konektor',
'entry.openSettingsTitle': 'Pengaturan',
@ -664,8 +664,10 @@ export const id: Dict = {
'chat.scrollToLatest': 'Scroll ke terbaru',
'chat.you': 'Kamu',
'chat.openFile': 'Buka {name}',
'chat.composerPlaceholder': 'Jelaskan desain, perubahan, atau artifact yang kamu inginkan...',
'chat.composerHint': 'Tekan Enter untuk kirim, Shift+Enter untuk baris baru.',
'chat.composerPlaceholder':
'Jelaskan desain yang kamu inginkan — tempel atau jatuhkan gambar, atau @ file atau skill…',
'chat.composerHint':
'⌘/Ctrl + Enter untuk kirim · tempel gambar · @ untuk file atau skill · / untuk perintah',
'chat.cliSettingsTitle': 'Pengaturan CLI',
'chat.cliSettingsAria': 'Buka pengaturan CLI',
'chat.attachTitle': 'Lampirkan',
@ -1256,8 +1258,24 @@ export const id: Dict = {
'settings.notifySoundBuzz': 'Buzz',
'settings.notifySoundTwoToneDown': 'Dua nada turun',
'settings.notifySoundThud': 'Thud',
'settings.library': 'Skill & Sistem Desain',
'settings.libraryHint': 'Jelajahi, pratinjau, dan aktifkan/nonaktifkan library kontenmu',
'settings.skills': 'Keterampilan',
'settings.skillsHint': 'Keterampilan fungsional yang dapat dipanggil agen saat tugas',
'settings.skillsNew': 'Keterampilan baru',
'settings.skillsEmpty': 'Pilih keterampilan di kiri, atau buat yang baru.',
'settings.skillsEdit': 'Edit',
'settings.skillsDelete': 'Hapus',
'settings.skillsDeleteConfirm': 'Konfirmasi hapus',
'settings.skillsName': 'Nama',
'settings.skillsTriggers': 'Pemicu (dipisahkan koma atau baris baru)',
'settings.skillsDescription': 'Deskripsi',
'settings.skillsBody': 'Isi SKILL.md',
'settings.skillsCreate': 'Buat',
'settings.skillsSave': 'Simpan',
'settings.skillsSaving': 'Menyimpan…',
'settings.skillsFiles': 'Berkas',
'settings.skillsNoFiles': 'Tidak ada berkas dalam folder keterampilan ini.',
'settings.designSystems': 'Sistem desain',
'settings.designSystemsHint': 'Telusuri dan aktifkan sistem desain yang tersedia',
'settings.librarySkills': 'Skill',
'settings.libraryDesignSystems': 'Sistem desain',
'settings.librarySearch': 'Cari...',

View file

@ -191,7 +191,7 @@ export const ja: Dict = {
'settings.versionUnavailable': 'daemon がオフラインの間はバージョン詳細を取得できません。',
'entry.tabDesigns': 'デザイン',
'entry.tabExamples': 'サンプル',
'entry.tabTemplates': 'テンプレート',
'entry.tabDesignSystems': 'デザインシステム',
'entry.openSettingsTitle': '設定',
'entry.openSettingsAria': '設定を開く',
@ -447,9 +447,9 @@ export const ja: Dict = {
'chat.you': 'あなた',
'chat.openFile': '{name} を開く',
'chat.composerPlaceholder':
'欲しいデザインを説明してください — 画像を貼り付けるかドロップ、または @ でファイルを参照…',
'欲しいデザインを説明してください — 画像を貼り付けるかドロップ、または @ でファイルやスキルを参照…',
'chat.composerHint':
'⌘/Ctrl + Enter で送信 · 画像を貼り付け · @ でファイルを参照',
'⌘/Ctrl + Enter で送信 · 画像を貼り付け · @ でファイル/スキルを参照 · / でコマンド',
'chat.cliSettingsTitle': 'CLI とモデルの設定',
'chat.cliSettingsAria': 'CLI とモデルの設定を開く',
'chat.attachTitle': 'ファイルを添付(または貼り付け / ドロップ)',
@ -1005,8 +1005,24 @@ export const ja: Dict = {
'settings.notifySoundBuzz': 'ブザー',
'settings.notifySoundTwoToneDown': '下降2音',
'settings.notifySoundThud': 'ドスン',
'settings.library': 'スキルとデザインシステム',
'settings.libraryHint': 'コンテンツライブラリの閲覧、プレビュー、切り替え',
'settings.skills': 'スキル',
'settings.skillsHint': 'エージェントがタスク中に呼び出せる機能スキル',
'settings.skillsNew': '新規スキル',
'settings.skillsEmpty': '左からスキルを選ぶか、新規作成してください。',
'settings.skillsEdit': '編集',
'settings.skillsDelete': '削除',
'settings.skillsDeleteConfirm': '削除を確定',
'settings.skillsName': '名前',
'settings.skillsTriggers': 'トリガー(カンマ/改行区切り)',
'settings.skillsDescription': '説明',
'settings.skillsBody': 'SKILL.md 本文',
'settings.skillsCreate': '作成',
'settings.skillsSave': '保存',
'settings.skillsSaving': '保存中…',
'settings.skillsFiles': 'ファイル',
'settings.skillsNoFiles': 'このスキルフォルダーにファイルはありません。',
'settings.designSystems': 'デザインシステム',
'settings.designSystemsHint': 'エージェントが利用できるデザインシステムを管理',
'settings.librarySkills': 'スキル',
'settings.libraryDesignSystems': 'デザインシステム',
'settings.librarySearch': '検索...',

View file

@ -191,7 +191,7 @@ export const ko: Dict = {
'settings.versionUnavailable': '데몬이 오프라인 상태일 때는 버전 세부 정보를 확인할 수 없습니다.',
'entry.tabDesigns': '디자인',
'entry.tabExamples': '예제',
'entry.tabTemplates': '템플릿',
'entry.tabDesignSystems': '디자인 시스템',
'entry.tabConnectors': '커넥터',
'entry.openSettingsTitle': '설정',
@ -560,9 +560,9 @@ export const ko: Dict = {
'chat.you': '나',
'chat.openFile': '{name} 열기',
'chat.composerPlaceholder':
'원하는 디자인을 설명하세요 — 이미지 붙여넣기/끌어놓기 가능, @로 파일 참조…',
'원하는 디자인을 설명하세요 — 이미지 붙여넣기/끌어놓기 가능, @로 파일이나 스킬 참조…',
'chat.composerHint':
'⌘/Ctrl + Enter 로 전송 · 이미지 붙여넣기 · @로 파일 참조',
'⌘/Ctrl + Enter 로 전송 · 이미지 붙여넣기 · @로 파일/스킬 참조 · /로 명령어',
'chat.cliSettingsTitle': 'CLI 및 모델 설정',
'chat.cliSettingsAria': 'CLI 및 모델 설정 열기',
'chat.attachTitle': '파일 첨부 (또는 붙여넣기 / 끌어놓기)',
@ -1118,8 +1118,24 @@ export const ko: Dict = {
'settings.notifySoundBuzz': '버즈',
'settings.notifySoundTwoToneDown': '하강 2음',
'settings.notifySoundThud': '쿵',
'settings.library': '스킬 및 디자인 시스템',
'settings.libraryHint': '콘텐츠 라이브러리 찾아보기, 미리보기 및 전환',
'settings.skills': '스킬',
'settings.skillsHint': '에이전트가 작업 중 호출할 수 있는 기능 스킬',
'settings.skillsNew': '새 스킬',
'settings.skillsEmpty': '왼쪽에서 스킬을 선택하거나 새로 만드세요.',
'settings.skillsEdit': '편집',
'settings.skillsDelete': '삭제',
'settings.skillsDeleteConfirm': '삭제 확인',
'settings.skillsName': '이름',
'settings.skillsTriggers': '트리거 (쉼표 또는 줄바꿈으로 구분)',
'settings.skillsDescription': '설명',
'settings.skillsBody': 'SKILL.md 본문',
'settings.skillsCreate': '만들기',
'settings.skillsSave': '저장',
'settings.skillsSaving': '저장 중…',
'settings.skillsFiles': '파일',
'settings.skillsNoFiles': '이 스킬 폴더에 파일이 없습니다.',
'settings.designSystems': '디자인 시스템',
'settings.designSystemsHint': '사용 가능한 디자인 시스템을 탐색하고 전환하세요',
'settings.librarySkills': '스킬',
'settings.libraryDesignSystems': '디자인 시스템',
'settings.librarySearch': '검색...',

View file

@ -191,7 +191,7 @@ export const pl: Dict = {
'settings.versionUnavailable': 'Szczegóły wersji są niedostępne, gdy daemon jest offline.',
'entry.tabDesigns': 'Projekty',
'entry.tabExamples': 'Przykłady',
'entry.tabTemplates': 'Szablony',
'entry.tabDesignSystems': 'Systemy projektowania',
'entry.tabConnectors': 'Konektory',
'entry.openSettingsTitle': 'Ustawienia',
@ -560,9 +560,9 @@ export const pl: Dict = {
'chat.you': 'Ty',
'chat.openFile': 'Otwórz {name}',
'chat.composerPlaceholder':
'Opisz projekt, który chcesz stworzyć — wklej obrazy lub użyj @, aby wskazać plik…',
'Opisz projekt, który chcesz stworzyć — wklej obrazy lub użyj @, aby wskazać plik lub skill…',
'chat.composerHint':
'⌘/Ctrl + Enter aby wysłać · wklej obrazy · @ aby wskazać pliki',
'⌘/Ctrl + Enter aby wysłać · wklej obrazy · @ pliki lub skille · / komendy',
'chat.cliSettingsTitle': 'Ustawienia CLI i modelu',
'chat.cliSettingsAria': 'Otwórz ustawienia CLI i modelu',
'chat.attachTitle': 'Załącz pliki (lub wklej / przeciągnij)',
@ -1118,8 +1118,24 @@ export const pl: Dict = {
'settings.notifySoundBuzz': 'Brzęczenie',
'settings.notifySoundTwoToneDown': 'Dwuton malejący',
'settings.notifySoundThud': 'Łomot',
'settings.library': 'Umiejętności i systemy projektowe',
'settings.libraryHint': 'Przeglądaj, podglądaj i włączaj/wyłączaj bibliotekę treści',
'settings.skills': 'Umiejętności',
'settings.skillsHint': 'Umiejętności funkcyjne, które agent może wywołać w trakcie zadania',
'settings.skillsNew': 'Nowa umiejętność',
'settings.skillsEmpty': 'Wybierz umiejętność po lewej lub utwórz nową.',
'settings.skillsEdit': 'Edytuj',
'settings.skillsDelete': 'Usuń',
'settings.skillsDeleteConfirm': 'Potwierdź usunięcie',
'settings.skillsName': 'Nazwa',
'settings.skillsTriggers': 'Wyzwalacze (rozdzielone przecinkami lub nowymi liniami)',
'settings.skillsDescription': 'Opis',
'settings.skillsBody': 'Treść SKILL.md',
'settings.skillsCreate': 'Utwórz',
'settings.skillsSave': 'Zapisz',
'settings.skillsSaving': 'Zapisywanie…',
'settings.skillsFiles': 'Pliki',
'settings.skillsNoFiles': 'Brak plików w tym folderze umiejętności.',
'settings.designSystems': 'Systemy projektowe',
'settings.designSystemsHint': 'Przeglądaj i przełączaj dostępne systemy projektowe',
'settings.librarySkills': 'Umiejętności',
'settings.libraryDesignSystems': 'Systemy projektowe',
'settings.librarySearch': 'Szukaj...',

View file

@ -190,7 +190,7 @@ export const ptBR: Dict = {
'settings.versionUnavailable': 'Os detalhes de versão ficam indisponíveis enquanto o daemon está offline.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Exemplos',
'entry.tabTemplates': 'Modelos',
'entry.tabDesignSystems': 'Sistemas de design',
'entry.tabConnectors': 'Conectores',
'entry.openSettingsTitle': 'Configurações',
@ -572,9 +572,9 @@ export const ptBR: Dict = {
'chat.you': 'Você',
'chat.openFile': 'Abrir {name}',
'chat.composerPlaceholder':
'Descreva o design que você quer — cole ou arraste imagens, ou use @ para referenciar um arquivo…',
'Descreva o design que você quer — cole ou arraste imagens, ou use @ para referenciar um arquivo ou skill…',
'chat.composerHint':
'⌘/Ctrl + Enter para enviar · cole imagens · @ para referenciar arquivos',
'⌘/Ctrl + Enter para enviar · cole imagens · @ para arquivos ou skills · / para comandos',
'chat.cliSettingsTitle': 'Configurações de CLI e modelo',
'chat.cliSettingsAria': 'Abrir configurações de CLI e modelo',
'chat.attachTitle': 'Anexar arquivos (ou colar / arrastar)',
@ -1150,8 +1150,24 @@ export const ptBR: Dict = {
'settings.notifySoundBuzz': 'Zumbido',
'settings.notifySoundTwoToneDown': 'Dois tons descendente',
'settings.notifySoundThud': 'Baque',
'settings.library': 'Habilidades e sistemas de design',
'settings.libraryHint': 'Navegar, visualizar e ativar/desativar sua biblioteca de conteúdos',
'settings.skills': 'Habilidades',
'settings.skillsHint': 'Habilidades funcionais que o agente pode invocar durante a tarefa',
'settings.skillsNew': 'Nova habilidade',
'settings.skillsEmpty': 'Selecione uma habilidade à esquerda ou crie uma nova.',
'settings.skillsEdit': 'Editar',
'settings.skillsDelete': 'Excluir',
'settings.skillsDeleteConfirm': 'Confirmar exclusão',
'settings.skillsName': 'Nome',
'settings.skillsTriggers': 'Gatilhos (separados por vírgulas ou novas linhas)',
'settings.skillsDescription': 'Descrição',
'settings.skillsBody': 'Corpo do SKILL.md',
'settings.skillsCreate': 'Criar',
'settings.skillsSave': 'Salvar',
'settings.skillsSaving': 'Salvando…',
'settings.skillsFiles': 'Arquivos',
'settings.skillsNoFiles': 'Nenhum arquivo nesta pasta de habilidade.',
'settings.designSystems': 'Design systems',
'settings.designSystemsHint': 'Explore e ative os design systems disponíveis',
'settings.librarySkills': 'Habilidades',
'settings.libraryDesignSystems': 'Sistemas de design',
'settings.librarySearch': 'Pesquisar...',

View file

@ -190,7 +190,7 @@ export const ru: Dict = {
'settings.versionUnavailable': 'Сведения о версии недоступны, пока daemon не запущен.',
'entry.tabDesigns': 'Дизайны',
'entry.tabExamples': 'Примеры',
'entry.tabTemplates': 'Шаблоны',
'entry.tabDesignSystems': 'Дизайн-системы',
'entry.tabConnectors': 'Коннекторы',
'entry.openSettingsTitle': 'Настройки',
@ -572,9 +572,9 @@ export const ru: Dict = {
'chat.you': 'Вы',
'chat.openFile': 'Открыть {name}',
'chat.composerPlaceholder':
'Опишите дизайн, который вы хотите — вставьте или добавьте изображения, или @ файл…',
'Опишите дизайн, который вы хотите — вставьте или добавьте изображения, или @ файл либо навык…',
'chat.composerHint':
'⌘/Ctrl + Enter для отправки · вставьте изображения · @ для ссылки на файлы',
'⌘/Ctrl + Enter для отправки · вставьте изображения · @ для файлов и навыков · / для команд',
'chat.cliSettingsTitle': 'Настройки CLI и модели',
'chat.cliSettingsAria': 'Открыть настройки CLI и модели',
'chat.attachTitle': 'Прикрепить файлы (или вставить / перетащить)',
@ -1150,8 +1150,24 @@ export const ru: Dict = {
'settings.notifySoundBuzz': 'Жужжание',
'settings.notifySoundTwoToneDown': 'Двухтон вниз',
'settings.notifySoundThud': 'Глухой удар',
'settings.library': 'Навыки и системы дизайна',
'settings.libraryHint': 'Просмотр, предпросмотр и управление библиотекой контента',
'settings.skills': 'Навыки',
'settings.skillsHint': 'Функциональные навыки, которые агент может вызывать во время задачи',
'settings.skillsNew': 'Новый навык',
'settings.skillsEmpty': 'Выберите навык слева или создайте новый.',
'settings.skillsEdit': 'Изменить',
'settings.skillsDelete': 'Удалить',
'settings.skillsDeleteConfirm': 'Подтвердить удаление',
'settings.skillsName': 'Имя',
'settings.skillsTriggers': 'Триггеры (через запятую или с новой строки)',
'settings.skillsDescription': 'Описание',
'settings.skillsBody': 'Содержимое SKILL.md',
'settings.skillsCreate': 'Создать',
'settings.skillsSave': 'Сохранить',
'settings.skillsSaving': 'Сохранение…',
'settings.skillsFiles': 'Файлы',
'settings.skillsNoFiles': 'В папке этого навыка нет файлов.',
'settings.designSystems': 'Дизайн-системы',
'settings.designSystemsHint': 'Просматривайте и переключайте доступные дизайн-системы',
'settings.librarySkills': 'Навыки',
'settings.libraryDesignSystems': 'Системы дизайна',
'settings.librarySearch': 'Поиск...',

View file

@ -178,7 +178,7 @@ export const th: Dict = {
'settings.versionUnavailable': 'ข้อมูลเวอร์ชันไม่พร้อมใช้งานขณะที่ daemon ออฟไลน์',
'entry.tabDesigns': 'ดีไซน์',
'entry.tabExamples': 'ตัวอย่าง',
'entry.tabTemplates': 'ตัวอย่าง',
'entry.tabDesignSystems': 'ระบบการออกแบบ',
'entry.tabConnectors': 'ตัวเชื่อมต่อ',
'entry.openSettingsTitle': 'การตั้งค่า',
@ -1119,8 +1119,6 @@ export const th: Dict = {
'settings.notifySoundBuzz': 'เป็นจังหวะกระตุ้นอารมณ์สั่นเลย',
'settings.notifySoundTwoToneDown': 'โทนดังลดถอย 2 จังหวะ',
'settings.notifySoundThud': 'เสียงหนักเน้นโครมให้ระวัง',
'settings.library': 'ชุดของด้านความสามารถทั้ง Skills และตัว Design Systems มีคลังห้องข้อมูล',
'settings.libraryHint': 'ให้สามารถตามมอง สลับการแสดงของในของหน้าห้องข้อมูลที่ตั้งใจ',
'settings.librarySkills': 'พวก Skills',
'settings.libraryDesignSystems': 'ตัวของระบบแบบ Design Systems',
'settings.librarySearch': 'ต้องการหาสิ่งใด…',

View file

@ -185,7 +185,7 @@ export const tr: Dict = {
'settings.versionUnavailable': 'Arka plan servisi devre dışıyken sürüm detayları mevcut değildir.',
'entry.tabDesigns': 'Tasarımlar',
'entry.tabExamples': 'Örnekler',
'entry.tabTemplates': 'Şablonlar',
'entry.tabDesignSystems': 'Tasarım sistemleri',
'entry.tabConnectors': 'Bağlayıcılar',
'entry.openSettingsTitle': 'Ayarlar',
@ -553,9 +553,9 @@ export const tr: Dict = {
'chat.you': 'Sen',
'chat.openFile': '{name}ı aç',
'chat.composerPlaceholder':
'İstediğiniz tasarımııklayın — görsel yapıştırın veya sürükleyin, veya bir dosyayı @leyin…',
'İstediğiniz tasarımııklayın — görsel yapıştırın veya sürükleyin, veya @ ile bir dosya ya da skill seçin…',
'chat.composerHint':
'Görselleri göndermek · yapıştırmak için ⌘/Ctrl + Enter · dosyaları referans almak için @',
'Göndermek için ⌘/Ctrl + Enter · görsel yapıştır · @ ile dosya veya skill · / ile komut',
'chat.cliSettingsTitle': 'CLI & model ayarları',
'chat.cliSettingsAria': 'CLI ve model ayarlarını aç',
'chat.attachTitle': 'Dosyaları iliştirin (veya yapıştırın / sürükleyin)',
@ -1109,8 +1109,24 @@ export const tr: Dict = {
'settings.notifySoundBuzz': 'Vızıltı',
'settings.notifySoundTwoToneDown': 'Alçalan iki ton',
'settings.notifySoundThud': 'Boğuk vuruş',
'settings.library': 'Beceriler ve tasarım sistemleri',
'settings.libraryHint': 'İçerik kitaplığınıza göz atın, önizleyin ve açıp kapatın',
'settings.skills': 'Yetenekler',
'settings.skillsHint': 'Aracının görev sırasında çağırabileceği işlevsel yetenekler',
'settings.skillsNew': 'Yeni yetenek',
'settings.skillsEmpty': 'Soldan bir yetenek seçin veya yeni bir yetenek oluşturun.',
'settings.skillsEdit': 'Düzenle',
'settings.skillsDelete': 'Sil',
'settings.skillsDeleteConfirm': 'Silmeyi onayla',
'settings.skillsName': 'Ad',
'settings.skillsTriggers': 'Tetikleyiciler (virgül veya yeni satır ile ayrılmış)',
'settings.skillsDescription': 'Açıklama',
'settings.skillsBody': 'SKILL.md gövdesi',
'settings.skillsCreate': 'Oluştur',
'settings.skillsSave': 'Kaydet',
'settings.skillsSaving': 'Kaydediliyor…',
'settings.skillsFiles': 'Dosyalar',
'settings.skillsNoFiles': 'Bu yetenek klasöründe dosya yok.',
'settings.designSystems': 'Tasarım sistemleri',
'settings.designSystemsHint': 'Mevcut tasarım sistemlerini görün ve etkinleştirin',
'settings.librarySkills': 'Beceriler',
'settings.libraryDesignSystems': 'Tasarım sistemleri',
'settings.librarySearch': 'Ara...',

View file

@ -192,7 +192,7 @@ export const uk: Dict = {
'settings.versionUnavailable': 'Деталі версії недоступні, поки фоновий процес перебуває в офлайні.',
'entry.tabDesigns': 'Дизайни',
'entry.tabExamples': 'Приклади',
'entry.tabTemplates': 'Шаблони',
'entry.tabDesignSystems': 'Системи дизайну',
'entry.tabConnectors': 'Конектори',
'entry.openSettingsTitle': 'Налаштування',
@ -573,9 +573,9 @@ export const uk: Dict = {
'chat.you': 'Ви',
'chat.openFile': 'Відкрити {name}',
'chat.composerPlaceholder':
'Опишіть дизайн, який ви хочете — вставте або перенесіть зображення, або скористайтеся @ для посилання на файл…',
'Опишіть дизайн, який ви хочете — вставте або перенесіть зображення, або скористайтеся @ для посилання на файл чи навичку…',
'chat.composerHint':
'⌘/Ctrl + Enter для надіслання · вставляння зображень · @ для посилання на файли',
'⌘/Ctrl + Enter для надіслання · вставляння зображень · @ для файлів або навичок · / для команд',
'chat.cliSettingsTitle': 'Налаштування CLI та моделі',
'chat.cliSettingsAria': 'Відкрити налаштування CLI та моделі',
'chat.attachTitle': 'Прикріпити файли (або вставити / перенести)',
@ -1151,8 +1151,24 @@ export const uk: Dict = {
'settings.notifySoundBuzz': 'Гудіння',
'settings.notifySoundTwoToneDown': 'Два тони вниз',
'settings.notifySoundThud': 'Глухий звук',
'settings.library': 'Навички та системи дизайну',
'settings.libraryHint': 'Перегляд, попередній перегляд та керування бібліотекою вмісту',
'settings.skills': 'Навички',
'settings.skillsHint': 'Функціональні навички, які агент може викликати під час задачі',
'settings.skillsNew': 'Нова навичка',
'settings.skillsEmpty': 'Виберіть навичку зліва або створіть нову.',
'settings.skillsEdit': 'Редагувати',
'settings.skillsDelete': 'Видалити',
'settings.skillsDeleteConfirm': 'Підтвердити видалення',
'settings.skillsName': 'Назва',
'settings.skillsTriggers': 'Тригери (через кому або з нового рядка)',
'settings.skillsDescription': 'Опис',
'settings.skillsBody': 'Вміст SKILL.md',
'settings.skillsCreate': 'Створити',
'settings.skillsSave': 'Зберегти',
'settings.skillsSaving': 'Збереження…',
'settings.skillsFiles': 'Файли',
'settings.skillsNoFiles': 'У теці цієї навички немає файлів.',
'settings.designSystems': 'Дизайн-системи',
'settings.designSystemsHint': 'Переглядайте та вмикайте доступні дизайн-системи',
'settings.librarySkills': 'Навички',
'settings.libraryDesignSystems': 'Системи дизайну',
'settings.librarySearch': 'Пошук...',

View file

@ -243,7 +243,7 @@ export const zhCN: Dict = {
'Open Design 必须处于运行状态MCP 工具调用才能成功。如果你在打开 Open Design 之前启动了编码助手,请重启助手以便它能连接到正在运行的守护进程。',
'entry.tabDesigns': '我的设计',
'entry.tabExamples': '示例',
'entry.tabTemplates': '模板',
'entry.tabDesignSystems': '设计体系',
'entry.tabConnectors': '连接器',
'entry.openSettingsTitle': '设置',
@ -619,8 +619,8 @@ export const zhCN: Dict = {
'chat.scrollToLatest': '滚动到最新',
'chat.you': '你',
'chat.openFile': '打开 {name}',
'chat.composerPlaceholder': '描述你想要的设计 — 可粘贴/拖入图片,或用 @ 引用文件…',
'chat.composerHint': '⌘/Ctrl + Enter 发送 · 可粘贴图片 · @ 引用文件',
'chat.composerPlaceholder': '描述你想要的设计 — 可粘贴/拖入图片,或用 @ 引用文件或技能…',
'chat.composerHint': '⌘/Ctrl + Enter 发送 · 可粘贴图片 · @ 引用文件或技能 · / 调出命令',
'chat.cliSettingsTitle': 'CLI 与模型设置',
'chat.cliSettingsAria': '打开 CLI 与模型设置',
'chat.attachTitle': '附加文件(也可以粘贴/拖入)',
@ -1190,8 +1190,24 @@ export const zhCN: Dict = {
'settings.notifySoundBuzz': '蜂鸣',
'settings.notifySoundTwoToneDown': '下行双音',
'settings.notifySoundThud': '低响',
'settings.library': '技能与设计系统',
'settings.libraryHint': '浏览、预览和管理您的内容库',
'settings.skills': '技能',
'settings.skillsHint': '智能体在任务中可以调用的功能技能',
'settings.skillsNew': '新建技能',
'settings.skillsEmpty': '请在左侧选择一个技能,或新建一个。',
'settings.skillsEdit': '编辑',
'settings.skillsDelete': '删除',
'settings.skillsDeleteConfirm': '确认删除',
'settings.skillsName': '名称',
'settings.skillsTriggers': '触发词(逗号或换行分隔)',
'settings.skillsDescription': '描述',
'settings.skillsBody': 'SKILL.md 内容',
'settings.skillsCreate': '创建',
'settings.skillsSave': '保存',
'settings.skillsSaving': '保存中…',
'settings.skillsFiles': '文件',
'settings.skillsNoFiles': '该技能目录下暂无文件。',
'settings.designSystems': '设计系统',
'settings.designSystemsHint': '浏览并启用智能体可使用的设计系统',
'settings.librarySkills': '技能',
'settings.libraryDesignSystems': '设计系统',
'settings.librarySearch': '搜索...',

View file

@ -236,7 +236,7 @@ export const zhTW: Dict = {
'Open Design 必須正在執行MCP 工具呼叫才能成功。如果您在開啟 Open Design 之前就已啟動 coding agent請重新啟動 agent使其能夠連線到正在執行的守護行程。',
'entry.tabDesigns': '我的設計',
'entry.tabExamples': '範例',
'entry.tabTemplates': '範本',
'entry.tabDesignSystems': '設計系統',
'entry.tabConnectors': '連接器',
'entry.openSettingsTitle': '設定',
@ -612,8 +612,8 @@ export const zhTW: Dict = {
'chat.scrollToLatest': '捲動到最新',
'chat.you': '你',
'chat.openFile': '開啟 {name}',
'chat.composerPlaceholder': '描述你想要的設計 — 可貼上/拖入圖片,或用 @ 引用檔案…',
'chat.composerHint': '⌘/Ctrl + Enter 傳送 · 可貼上圖片 · @ 引用檔案',
'chat.composerPlaceholder': '描述你想要的設計 — 可貼上/拖入圖片,或用 @ 引用檔案或技能…',
'chat.composerHint': '⌘/Ctrl + Enter 傳送 · 可貼上圖片 · @ 引用檔案或技能 · / 叫出指令',
'chat.cliSettingsTitle': 'CLI 與模型設定',
'chat.cliSettingsAria': '開啟 CLI 與模型設定',
'chat.attachTitle': '附加檔案(也可以貼上/拖入)',
@ -1183,8 +1183,24 @@ export const zhTW: Dict = {
'settings.notifySoundBuzz': '蜂鳴',
'settings.notifySoundTwoToneDown': '下行雙音',
'settings.notifySoundThud': '低響',
'settings.library': '技能與設計系統',
'settings.libraryHint': '瀏覽、預覽和管理您的內容庫',
'settings.skills': '技能',
'settings.skillsHint': '代理可在任務中呼叫的功能技能',
'settings.skillsNew': '新增技能',
'settings.skillsEmpty': '請於左側選擇一個技能,或新增一個。',
'settings.skillsEdit': '編輯',
'settings.skillsDelete': '刪除',
'settings.skillsDeleteConfirm': '確認刪除',
'settings.skillsName': '名稱',
'settings.skillsTriggers': '觸發詞(逗號或換行分隔)',
'settings.skillsDescription': '描述',
'settings.skillsBody': 'SKILL.md 內容',
'settings.skillsCreate': '建立',
'settings.skillsSave': '儲存',
'settings.skillsSaving': '儲存中…',
'settings.skillsFiles': '檔案',
'settings.skillsNoFiles': '此技能資料夾沒有檔案。',
'settings.designSystems': '設計系統',
'settings.designSystemsHint': '瀏覽並啟用代理可使用的設計系統',
'settings.librarySkills': '技能',
'settings.libraryDesignSystems': '設計系統',
'settings.librarySearch': '搜尋...',

View file

@ -215,8 +215,24 @@ export interface Dict {
'settings.runtimePackaged': string;
'settings.runtimeDevelopment': string;
'settings.versionUnavailable': string;
'settings.library': string;
'settings.libraryHint': string;
'settings.skills': string;
'settings.skillsHint': string;
'settings.skillsNew': string;
'settings.skillsEmpty': string;
'settings.skillsEdit': string;
'settings.skillsDelete': string;
'settings.skillsDeleteConfirm': string;
'settings.skillsName': string;
'settings.skillsTriggers': string;
'settings.skillsDescription': string;
'settings.skillsBody': string;
'settings.skillsCreate': string;
'settings.skillsSave': string;
'settings.skillsSaving': string;
'settings.skillsFiles': string;
'settings.skillsNoFiles': string;
'settings.designSystems': string;
'settings.designSystemsHint': string;
'settings.librarySkills': string;
'settings.libraryDesignSystems': string;
'settings.librarySearch': string;
@ -481,7 +497,7 @@ export interface Dict {
// Entry view / tabs
'entry.tabDesigns': string;
'entry.tabExamples': string;
'entry.tabTemplates': string;
'entry.tabDesignSystems': string;
'entry.tabConnectors': string;
'entry.tabImageTemplates': string;

View file

@ -1261,6 +1261,56 @@ code {
}
.mention-meta { color: var(--text-muted); font-size: 10px; flex-shrink: 0; }
/* Section header inside the @-popover when both skills and files appear. */
.mention-section-head {
padding: 6px 10px 2px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.mention-skill-item { flex-direction: column; align-items: flex-start; gap: 2px; }
.mention-skill-row {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
}
.mention-skill-row code { flex: none; font-size: 11px; }
.mention-skill-badge {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 6px;
border-radius: 999px;
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mention-skill-desc {
color: var(--text-muted);
font-size: 11px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Staged skill chips above the textarea. Reuse the staged-chip baseline
so they line up with attachment chips. */
.staged-skills-row { gap: 6px; flex-wrap: wrap; }
.staged-chip.staged-skill {
background: color-mix(in oklab, var(--accent) 8%, var(--bg-subtle));
border-color: color-mix(in oklab, var(--accent) 28%, var(--border));
}
.staged-chip.staged-skill .staged-icon { color: var(--accent); }
.staged-chip.staged-skill-user {
background: color-mix(in oklab, var(--accent) 14%, var(--bg-subtle));
}
/* ===========================================================
Modal / Settings
=========================================================== */
@ -14304,6 +14354,78 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
color: var(--text);
}
.library-card-badge-user {
background: color-mix(in oklab, var(--accent) 16%, var(--bg-subtle));
color: var(--text);
}
.library-card-delete {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-muted);
border-radius: 4px;
}
.library-card-delete:hover {
background: color-mix(in oklab, #ef4444 18%, transparent);
color: #ef4444;
}
.library-import-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
}
.library-import-form {
margin: 12px 0;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-subtle);
display: grid;
gap: 10px;
}
.library-import-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.library-import-row label,
.library-import-block {
display: grid;
gap: 4px;
font-size: 11px;
color: var(--text-muted);
}
.library-import-row input,
.library-import-block input,
.library-import-block textarea {
font-size: 13px;
color: var(--text);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 9px;
font-family: inherit;
resize: vertical;
}
.library-import-block textarea {
min-height: 60px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.library-import-error {
color: #ef4444;
font-size: 12px;
}
.library-import-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.library-ds-card {
position: relative;
display: flex;
@ -15769,6 +15891,322 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
text-decoration-thickness: 2px;
}
/* Settings Skills (functional) row stack.
Mirrors the External MCP servers panel: a vertical list of
collapsible rows. Each row's header is always visible; SKILL.md
preview, file tree and inline edit form expand only on demand. The
single-column layout avoids the cramped left/right split the
previous version produced inside the narrow settings content
column. */
.settings-skills .skills-add-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
border-radius: 8px;
}
.settings-skills .skills-rows {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.settings-skills .skills-row {
position: relative;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
display: flex;
flex-direction: column;
min-width: 0;
transition: border-color 140ms ease, box-shadow 140ms ease,
background-color 140ms ease;
}
.settings-skills .skills-row::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 0 3px 3px 0;
background: transparent;
transition: background-color 140ms ease;
pointer-events: none;
}
.settings-skills .skills-row:hover {
border-color: var(--border-strong, var(--border));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.settings-skills .skills-row-expanded {
background: var(--bg-subtle);
border-color: var(--border-strong, var(--border));
}
.settings-skills .skills-row-expanded::before {
background: var(--accent);
}
.settings-skills .skills-row-editing {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.settings-skills .skills-row-disabled { opacity: 0.55; }
.settings-skills .skills-row-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px 8px 14px;
min-width: 0;
}
.settings-skills .skills-row-summary-btn {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px;
background: transparent;
border: none;
border-radius: 6px;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
}
.settings-skills .skills-row-summary-btn:hover .skills-row-summary-name {
color: var(--accent);
}
.settings-skills .skills-row-summary-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.settings-skills .skills-row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--bg-subtle);
color: var(--text-muted);
flex-shrink: 0;
}
.settings-skills .skills-row-expanded .skills-row-icon {
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
}
.settings-skills .skills-row-summary {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-skills .skills-row-summary-line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.settings-skills .skills-row-summary-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
color: var(--text);
transition: color 120ms ease;
}
.settings-skills .skills-row-summary-mode,
.settings-skills .skills-row-summary-source,
.settings-skills .skills-row-summary-category {
flex-shrink: 0;
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 1px 7px;
font-weight: 500;
}
.settings-skills .skills-row-summary-source {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.settings-skills .skills-row-summary-category {
/* Soft tinted pill so the category is visually distinct from the
* mode pill but still subordinate to the skill name. Falls back to a
* neutral border when --accent-2 is not defined by the active theme. */
text-transform: none;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--accent) 80%, var(--text-muted));
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.settings-skills .skills-row-summary-desc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.settings-skills .skills-row-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: var(--text-faint);
transition: transform 160ms ease, color 120ms ease;
flex-shrink: 0;
}
.settings-skills .skills-row-summary-btn:hover .skills-row-chevron {
color: var(--text-muted);
}
.settings-skills .skills-row-expanded .skills-row-chevron {
transform: rotate(180deg);
color: var(--text-muted);
}
.settings-skills .skills-row-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.settings-skills .skills-row-actions .icon-btn {
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-faint);
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease,
color 120ms ease;
}
.settings-skills .skills-row-actions .icon-btn:hover {
background: var(--bg-panel);
border-color: var(--border);
color: var(--text);
}
.settings-skills .skills-row-enable {
margin-left: 4px;
}
.settings-skills .skills-delete-confirm {
display: inline-flex;
gap: 6px;
}
.settings-skills .skills-delete-confirm .btn {
padding: 4px 10px;
font-size: 12px;
border-radius: 6px;
}
.settings-skills .skills-row-detail {
display: flex;
flex-direction: column;
gap: 14px;
padding: 4px 14px 14px;
border-top: 1px dashed var(--border);
margin: 0 4px;
}
.settings-skills .skills-row-section {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.settings-skills .skills-row-section h5 {
margin: 0;
font-size: 10.5px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: 600;
}
.settings-skills .skills-row-section .library-preview-body {
margin: 0;
max-height: 320px;
overflow: auto;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-size: 12.5px;
line-height: 1.55;
}
.settings-skills .skills-file-tree {
list-style: none;
margin: 0;
padding: 0;
font-size: 12px;
color: var(--text-muted);
overflow-y: auto;
max-height: 240px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
}
.settings-skills .skills-file-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0;
}
.settings-skills .skills-file-entry-directory {
color: var(--text);
font-weight: 500;
}
.settings-skills .skills-file-size {
margin-left: auto;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
font-size: 11px;
}
.settings-skills > .skills-draft {
margin: 0;
}
.settings-skills .skills-row > .skills-draft {
border: none;
border-top: 1px dashed var(--border);
border-radius: 0 0 10px 10px;
margin: 0;
background: transparent;
}
.settings-skills .skills-draft .skills-draft-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
}
.settings-skills .skills-draft .skills-draft-head h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.settings-skills .skills-draft .skills-draft-sub {
margin: 0;
font-size: 11px;
color: var(--text-faint);
font-family: var(--mono);
}
.mcp-json-helper {
margin-top: 10px;

View file

@ -72,6 +72,10 @@ export interface DaemonStreamOptions {
assistantMessageId?: string | null;
clientRequestId?: string | null;
skillId?: string | null;
// Per-turn skill ids picked via the composer's @-mention popover. These
// are layered onto the system prompt for this run only and do not
// change the project's persistent `skillId`.
skillIds?: string[];
designSystemId?: string | null;
// Project-relative paths the user has staged for this turn. The
// daemon resolves them inside the project folder, validates they
@ -111,6 +115,7 @@ export async function streamViaDaemon({
assistantMessageId,
clientRequestId,
skillId,
skillIds,
designSystemId,
attachments,
commentAttachments,
@ -137,6 +142,7 @@ export async function streamViaDaemon({
assistantMessageId: assistantMessageId ?? null,
clientRequestId: clientRequestId ?? null,
skillId: skillId ?? null,
skillIds: Array.isArray(skillIds) ? skillIds : [],
designSystemId: designSystemId ?? null,
attachments: attachments ?? [],
commentAttachments: commentAttachments ?? [],

View file

@ -94,6 +94,32 @@ export async function fetchSkills(): Promise<SkillSummary[]> {
}
}
// Design templates — the rendering catalogue (decks, prototypes, image/
// video/audio templates). Same SkillSummary shape as functional skills,
// fetched from a separate registry root so the EntryView Templates tab
// and Settings → Skills surface stay decoupled. See
// specs/current/skills-and-design-templates.md.
export async function fetchDesignTemplates(): Promise<SkillSummary[]> {
try {
const resp = await fetch('/api/design-templates');
if (!resp.ok) return [];
const json = (await resp.json()) as { designTemplates: SkillSummary[] };
return json.designTemplates ?? [];
} catch {
return [];
}
}
export async function fetchDesignTemplate(id: string): Promise<SkillDetail | null> {
try {
const resp = await fetch(`/api/design-templates/${encodeURIComponent(id)}`);
if (!resp.ok) return null;
return (await resp.json()) as SkillDetail;
} catch {
return null;
}
}
// Pets packaged by the Codex `hatch-pet` skill — surfaced so the web
// pet settings can offer one-click adoption right after the agent run
// finishes. Returns an empty list (not an error) when the registry
@ -157,6 +183,142 @@ export function codexPetSpritesheetUrl(pet: CodexPetSummary): string {
return pet.spritesheetUrl;
}
// Body for POST /api/skills/import. Mirrors the contracts type but is
// repeated here so the registry module is self-describing for callers.
export interface SkillImportInput {
name: string;
description?: string;
body: string;
triggers?: string[];
}
export interface SkillImportError {
code?: string;
message: string;
}
export async function importSkill(
input: SkillImportInput,
): Promise<{ skill: SkillSummary } | { error: SkillImportError }> {
try {
const resp = await fetch('/api/skills/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as
| { error?: SkillImportError }
| null;
return {
error: {
code: payload?.error?.code,
message: payload?.error?.message ?? `Import failed (${resp.status}).`,
},
};
}
return (await resp.json()) as { skill: SkillSummary };
} catch (err) {
return {
error: {
message: err instanceof Error ? err.message : 'Import request failed.',
},
};
}
}
// Update an existing skill's body. For built-in skills the daemon writes
// a "shadow" copy under the user-skills root; the next listSkills() pass
// surfaces it in place of the bundled copy. The id passed here must
// match the SKILL.md frontmatter `name` — the daemon refuses cross-id
// renames so callers can drop "edit" into the same surface they use for
// "edit my own draft".
export interface SkillUpdateInput {
name?: string;
description?: string;
body: string;
triggers?: string[];
}
export async function updateSkill(
id: string,
input: SkillUpdateInput,
): Promise<{ skill: SkillSummary } | { error: SkillImportError }> {
try {
const resp = await fetch(`/api/skills/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as
| { error?: SkillImportError }
| null;
return {
error: {
code: payload?.error?.code,
message:
payload?.error?.message ?? `Update failed (${resp.status}).`,
},
};
}
return (await resp.json()) as { skill: SkillSummary };
} catch (err) {
return {
error: {
message: err instanceof Error ? err.message : 'Update request failed.',
},
};
}
}
export interface SkillFileEntry {
path: string;
kind: 'file' | 'directory';
size: number | null;
}
export async function fetchSkillFiles(id: string): Promise<SkillFileEntry[]> {
try {
const resp = await fetch(
`/api/skills/${encodeURIComponent(id)}/files`,
);
if (!resp.ok) return [];
const json = (await resp.json()) as { files: SkillFileEntry[] };
return json.files ?? [];
} catch {
return [];
}
}
export async function deleteSkill(
id: string,
): Promise<{ ok: true } | { error: SkillImportError }> {
try {
const resp = await fetch(`/api/skills/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as
| { error?: SkillImportError }
| null;
return {
error: {
code: payload?.error?.code,
message: payload?.error?.message ?? `Delete failed (${resp.status}).`,
},
};
}
return { ok: true };
} catch (err) {
return {
error: {
message: err instanceof Error ? err.message : 'Delete request failed.',
},
};
}
}
export async function fetchSkill(id: string): Promise<SkillDetail | null> {
try {
const resp = await fetch(`/api/skills/${encodeURIComponent(id)}`);

View file

@ -0,0 +1,68 @@
import type { DesignSystemSummary } from '../types';
/**
* Maps a design system's authored metadata to prompt-template gallery categories.
* Used when the workspace default design system is image/video-aligned so the
* template tabs can narrow results without per-template schema coupling.
*/
export function inferPromptTemplateCategoriesForDs(
ds: DesignSystemSummary,
): string[] | null {
const blob = `${ds.category} ${ds.title} ${ds.summary}`.toLowerCase();
const out = new Set<string>();
const add = (cats: string[]) => {
for (const c of cats) out.add(c);
};
if (/anime|manga|illustration|creative|artistic|editorial/i.test(blob)) {
add([
'Anime',
'Anime / Manga',
'Illustration',
'Profile / Avatar',
'Social Media Post',
]);
}
if (/game|gaming|\bgui\b|\bui\b|interface/i.test(blob)) {
add(['Game UI', 'App / Web Design']);
}
if (/e-?commerce|retail|shopping|product|saas|marketplace|store/i.test(blob)) {
add(['Product', 'Social Media Post', 'Marketing', 'App / Web Design']);
}
if (/fintech|finance|crypto|payment|bank|stripe/i.test(blob)) {
add(['App / Web Design', 'Data', 'Marketing', 'Branding']);
}
if (/developer|tool|api|backend|data|engineering|llm|ai\b/i.test(blob)) {
add(['App / Web Design', 'Data', 'General']);
}
if (
/video|cinematic|film|motion|advertis|marketing|media|social|meme|travel|vfx|fantasy|short form/i.test(
blob,
)
) {
add([
'Cinematic',
'Motion Graphics',
'Advertising',
'Marketing',
'Social / Meme',
'Travel',
'VFX / Fantasy',
'Short Form',
]);
}
if (/automotive|car|vehicle|motor/i.test(blob)) {
add(['Product', 'Cinematic', 'Advertising']);
}
if (/\bbrand/i.test(blob)) {
add(['Branding']);
}
if (/infographic|data\s+viz|chart|diagram/i.test(blob)) {
add(['Infographic', 'Data']);
}
if (/profile|avatar|portrait/i.test(blob)) {
add(['Profile / Avatar']);
}
return out.size > 0 ? [...out] : null;
}

View file

@ -151,6 +151,7 @@ function renderProjectView(
config={config}
agents={[] as AgentInfo[]}
skills={[] as SkillSummary[]}
designTemplates={[] as SkillSummary[]}
designSystems={[] as DesignSystemSummary[]}
daemonLive
onModeChange={vi.fn()}
@ -221,6 +222,7 @@ describe('ProjectView pending prompt seeding', () => {
config={config}
agents={[]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={vi.fn()}

View file

@ -140,6 +140,7 @@ describe('ProjectView daemon cleanup', () => {
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}

View file

@ -1939,19 +1939,18 @@ describe('SettingsDialog pets interactions', () => {
});
});
describe('SettingsDialog skills and design systems interactions', () => {
describe('SettingsDialog skills section', () => {
afterEach(() => {
cleanup();
});
it('renders the skills library by default and filters by mode and search', async () => {
it('lists functional skills and filters them by mode + search', async () => {
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'library' },
{ initialSection: 'skills' },
);
await waitFor(() => {
expect(screen.getByRole('tab', { name: /Skills3/i })).toBeTruthy();
expect(screen.getByText('blog-post')).toBeTruthy();
expect(screen.getByText('sales-deck')).toBeTruthy();
});
@ -1967,17 +1966,17 @@ describe('SettingsDialog skills and design systems interactions', () => {
expect(screen.queryByText('dashboard')).toBeNull();
});
it('opens a skill preview and persists disabled skills from toggle switches', async () => {
it('opens a skill detail panel and persists disabled skills from toggle switches', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'library' },
{ initialSection: 'skills' },
);
await waitFor(() => {
expect(screen.getByText('blog-post')).toBeTruthy();
});
fireEvent.click(screen.getAllByTitle('Preview')[0] as HTMLElement);
fireEvent.click(screen.getByText('blog-post'));
await waitFor(() => {
expect(fetchSkillMock).toHaveBeenCalledWith('blog-post');
expect(screen.getByText('skill body for blog-post')).toBeTruthy();
@ -1995,17 +1994,34 @@ describe('SettingsDialog skills and design systems interactions', () => {
);
});
it('switches to design systems, previews details, and persists disabled design systems', async () => {
const { onPersist } = renderSettingsDialog(
it('shows an empty state when search matches nothing', async () => {
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'library' },
{ initialSection: 'skills' },
);
await waitFor(() => {
expect(screen.getByRole('tab', { name: /Design Systems2/i })).toBeTruthy();
expect(screen.getByText('blog-post')).toBeTruthy();
});
fireEvent.click(screen.getByRole('tab', { name: /Design Systems2/i }));
fireEvent.change(screen.getByPlaceholderText('Search...'), {
target: { value: 'zzz-no-match' },
});
expect(screen.getByText('No items match your search.')).toBeTruthy();
});
});
describe('SettingsDialog design systems section', () => {
afterEach(() => {
cleanup();
});
it('lists design systems and persists disabled selections from toggle switches', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'designSystems' },
);
await waitFor(() => {
expect(screen.getByText('Neutral Modern')).toBeTruthy();
expect(screen.getByText('Signal Green')).toBeTruthy();
@ -2031,22 +2047,6 @@ describe('SettingsDialog skills and design systems interactions', () => {
{},
);
});
it('shows an empty state when library search returns no results', async () => {
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'library' },
);
await waitFor(() => {
expect(screen.getByText('blog-post')).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText('Search...'), {
target: { value: 'zzz-no-match' },
});
expect(screen.getByText('No items match your search.')).toBeTruthy();
});
});
describe('SettingsDialog about interactions', () => {

View file

@ -776,7 +776,8 @@ describe('shouldEnableSettingsSave', () => {
expect(shouldEnableSettingsSave(incompleteApiCfg, 'integrations', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'notifications', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'pet', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'library', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'skills', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'designSystems', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'about', [availableAgent], true)).toBe(true);
});
@ -922,7 +923,8 @@ describe('sanitizeSettingsSavePayload', () => {
'appearance',
'notifications',
'pet',
'library',
'skills',
'designSystems',
'about',
];
for (const section of sections) {

View file

@ -0,0 +1,34 @@
# design-templates
This directory holds **design templates** — packaged "shapes" the agent
renders into a project artifact (decks, prototypes, image/video/audio
templates, …). Each entry is a folder with a `SKILL.md` (same shape as
functional skills) plus rendering side files (`example.html`,
`assets/`, `references/`, …).
If the entry primarily *does work* on user input — utilities, briefs,
asset packagers, fidelity audits — it belongs under `../skills/`
instead. See `specs/current/skills-and-design-templates.md` for the
full split.
## Daemon plumbing
- Listed under `/api/design-templates`. The shape mirrors `/api/skills`
(same `SkillSummary`/`SkillDetail` types) so the web client can
reuse a single `SkillSummary[]` consumer for both surfaces.
- Asset and example routes (`/api/skills/:id/example`,
`/api/skills/:id/assets/*`) intentionally span both registries — the
example HTML rewrites to `/api/skills/<id>/...` regardless of which
root owns the folder, so URLs keep resolving after the split.
- Surfaced in the EntryView Templates tab and in the New-project panel
as the rendering catalogue.
## Adding a design template
1. Create `design-templates/<my-template>/SKILL.md` with `name`,
`description`, `triggers`, and an explicit `od.mode` (one of
`prototype`, `deck`, `template`, `image`, `video`, `audio`).
2. Ship a baked `example.html` (and any side files) so the EntryView
gallery has something to preview.
3. Optionally drop additional baked samples under `examples/<key>.html`
to surface them as derived `<parent>:<key>` cards.

Some files were not shown because too many files have changed in this diff Show more