Compare commits

...

15 commits

Author SHA1 Message Date
Amy
223c047b07
Merge 91691f3e66 into 53fb175855 2026-05-31 01:23:31 -04:00
estelledc
53fb175855
fix(web): truncate long filenames in file list (no-preview state) (#3370)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 2s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 2s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
When the design files preview pane was closed, a very long filename
would expand its `<td.df-cell-name>` and push the kind / mtime / menu
columns off-screen — the auto-layout `<table>` had no width
constraint on the cell, so the existing `text-overflow: ellipsis`
on `.df-row-name` never engaged.

The `:not(.no-preview)` overrides in `routines.css` already pinned
the row's children to `width: 100%; max-width: 100%` when a preview
pane was open, but the no-preview state — the one shown in the issue
screenshot — had no equivalent guard.

CSS:
- `.df-cell-name`: add `max-width: 0; min-width: 0` so the table
  cell collapses to its column allocation in auto-layout.
- `.df-row-name-wrap`: add `max-width: 100%`.
- `.df-row-name`: add `max-width: 100%; min-width: 0` so the flex
  child clamps to the wrap and the existing ellipsis engages.

JSX (DesignFilesPanel.tsx):
- Add `title={f.name}` (and the equivalent for directory rows and
  live-artifact rows) on `.df-row-name`. The browser surfaces the
  full filename on hover even when the visible text is truncated, so
  users can read the leading characters without opening the preview
  pane. `<DfPreview>` already renders the full name with
  `word-break: break-word`.

Closes #3260

Validation:
- pnpm exec vitest run tests/components/DesignFilesPanel.long-name-truncate.test.tsx
  → 3/3 passed (1 was red on main: title attr was absent)
- pnpm --filter @open-design/web test → 2501/2501 passed (260 files)
- pnpm --filter @open-design/web typecheck → green
- pnpm guard → green

Note: jsdom does not measure layout, so the truncation itself can't
be asserted directly. The specs encode the structural contract the
CSS depends on (cell / wrap / name nesting + the `title` attr) so a
JSX shape change won't silently regress the fix.
2026-05-31 05:17:52 +00:00
BayesWang
af4a62b69a
Add configurable project locations (#2041)
* add daemon project location support

* wire project locations into web settings

* localize project location settings

* move default project location to settings

* polish project location selection cards

* fix project location i18n gaps

* fix external project validation cleanup
2026-05-31 04:47:45 +00:00
Dan Porat
3395d2c855
feat(daemon): implement fal.ai renderer for image + video generation (#1606)
* feat(daemon): implement fal.ai renderer for image + video generation

Adds renderFalImage and renderFalVideo backed by the fal queue API
(queue.fal.run). Any fal-ai/* model path can be used directly without
a catalog entry, enabling the full fal model library without code
changes. Catalogued shortcuts are mapped via FAL_ENDPOINTS to their
fal-ai/* paths; OD_FAL_MAX_POLL_MS controls the poll ceiling.

Expands the fal model catalog with flux-pro-ultra, flux-dev-fal,
flux-schnell-fal, ideogram-v3-fal, recraft-v3-fal (images) and
veo-3-fal, veo-2-fal, wan-2.1-t2v, wan-2.1-i2v, seedance-1-pro-fal,
kling-2.1-t2v-fal (video). Marks fal provider as integrated: true in
both daemon and web model registries.

* fix(daemon): address fal renderer review comments

- Correct Wan 2.1 endpoints: wan-video/v2.1/* → fal-ai/wan-t2v / fal-ai/wan-i2v
- Correct Kling 2.1 t2v endpoint: .../pro/... → .../master/text-to-video
- Add FAL_IMAGE_USES_ASPECT_RATIO: flux-pro-ultra sends aspect_ratio not image_size
- Add FAL_VIDEO_NO_DURATION: Wan models reject the duration field
- Add FAL_VIDEO_STRING_DURATION: Veo expects duration as "5s" not 5
- Fix falQueueBase() to use anchored regex replace, avoiding mangled
  custom base URLs
- Do not wrap payload under input — raw fal queue HTTP API expects flat
  body; the input wrapper is an SDK abstraction only (confirmed by 422
  validation error from fal showing prompt missing at body.prompt)

* fix(daemon): correct fal queue protocol comment (flat body, no SDK input wrapper)

* fix(daemon): clamp Veo duration to valid fal buckets (4s/6s/8s)

* fix(daemon): report effective fal Veo duration in providerNote (with snap warning)

* fix(daemon): reduce image generation latency from 4m37s to ~73s

Five layered fixes targeting the overhead that padded a ~10s fal API call
into a 4m37s user-facing wait:

1. Skip DISCOVERY_AND_PHILOSOPHY for media surfaces (image/video/audio).
   The ~3000-token HTML-artifact discovery layer is irrelevant for media
   generation and forced the agent to parse and override all its rules
   before dispatching. Removes it from the system prompt entirely for
   these surfaces; MEDIA_GENERATION_CONTRACT is the sole authority.

2. Broaden the wait-loop contract to cover ALL slow models, not just
   "Volcengine i2v / hyperframes-html". Any model whose generation
   exceeds 25s — including fal flux-pro-ultra, Veo, Sora — returns exit 2
   from od media generate. The contract now makes this universal and
   provides a python3-based bash pattern (jq is not guaranteed to be
   installed on all agent runtimes).

3. Increase od media wait polling budget from 25s to 120s. od media
   generate keeps its 25s budget for fast feedback; od media wait is
   purpose-built to sit and poll, so it can safely use the full 2-minute
   bash-tool window. Reduces re-entries for a 3-minute generation from
   ~7 to ~2.

4. First fal poll is now immediate instead of always sleeping 3s before
   the first status check. Saves 3s for all fal jobs.

5. Project metadata no longer emits "(unknown — ask)" for imageModel and
   aspectRatio when unset. Emits the actual defaults (gpt-image-2,
   aspect-ratio scene heuristic) so the agent can dispatch without
   extended reasoning about model selection. Also adds dispatch-immediately
   defaults and a brief-reply rule (2–3 sentences max after generation).

Measured end-to-end on the exact problem prompt before/after:
  Before: 4m37s (discovery form + 7x LLM re-entries + jq failure)
  After:  ~73s   (single bash loop, no question turn, image delivered)

* feat(daemon): inject media dispatch hint for non-media project surfaces

Agents running inside prototype, deck, and other non-image/video/audio
projects previously had no knowledge of `od media generate`, so when
asked to create an image with fal they would try to call provider REST
APIs directly and ask the user for API keys — even though the daemon
already holds credentials in .od/media-config.json.

Add MEDIA_DISPATCH_HINT to composeSystemPrompt for all non-media
surfaces. The hint tells the agent to always route media generation
through the daemon dispatcher, and explicitly forbids prompting for
API keys.

Verified end-to-end: a prototype project generates a 952 KB image via
flux-pro-ultra in ~52s with no key errors.

* fix(daemon): prevent agent from converting bash env vars to PowerShell syntax

MEDIA_DISPATCH_HINT now explicitly labels the shell as POSIX bash and
shows the correct $VAR form side-by-side with a warning NOT to use
PowerShell $env:VAR. Without this, claude-sonnet running on a Windows
host converts the example to PowerShell syntax (`& $env:OD_NODE_BIN`)
which then fails at the bash executor with 'syntax error near unexpected
token &'.

* fix(daemon): add generate→wait loop to MEDIA_DISPATCH_HINT for slow models

MEDIA_DISPATCH_HINT previously showed only a bare  call.
flux-pro-ultra and other slow models always exit 2 after ~25s — without
the wait loop the agent would treat exit 2 as a failure and report an
error to the user.

Replace the single-command example with the canonical generate→wait loop
(matching media-contract.ts), add an explicit note that exit 2 means
'keep polling', and reinforce the POSIX bash / no-PowerShell rule
directly inside the code block.

* fix(daemon): allow fal-ai/* passthrough in media-agent contract

The media-agent prompt instructed the agent to warn and substitute the
default model for any ID not in the catalogue. This blocked the custom
fal-ai/* passthrough path the daemon already supports, so users could
not reach uncatalogued fal models from the normal chat flow.

Carve out the fal-ai/* exception so the agent passes those IDs through
directly instead of warning or substituting.

* fix(daemon): align MEDIA_DISPATCH_HINT with exit-0 generate contract

media generate now always exits 0 (handoff included). The non-media
agent hint still checked ec==2 to decide whether to keep polling, so
slow fal models (flux-pro-ultra, veo-3-fal) would stop after printing
the handoff JSON instead of entering the wait loop.

- generate error check: drop the ec!=2 exception (exits 0 always)
- while loop: drive on taskId presence, not ec==2; stop on ec==0/5
- footer: remove --surface inference claim; CLI requires it explicitly

* fix(guard): add test-fal-webui.ts to e2e scripts allowlist

CI failed: guard flagged e2e/scripts/test-fal-webui.ts as an
unapproved package-owned entrypoint. Add it to allowedE2eScripts.

* fix(daemon): update prompt test expectations to match exit-0 handoff wording

The two stale assertions checked for the old generate-exits-2 copy which
no longer exists in the contract. Update them to match the current
always-exits-0 wording.

* fix(daemon): move skipDiscoveryBrief override before discovery block

* chore(e2e): remove ad-hoc fal webui test script

The script was a one-time developer helper used to manually validate fal
image generation through the live UI. It relied on a real fal API key and
hardcoded local port, so it cannot participate in the e2e package's
fixture/reporting/CI conventions. Removing it per reviewer feedback.

- Delete e2e/scripts/test-fal-webui.ts
- Remove its guard.ts allowlist entry
- Gitignore the file and its screenshots to prevent accidental re-addition

* chore: remove accidental local scratch files from branch

Remove bash.exe.stackdump (MSYS crash dump) and fix_loop.py (one-off
local rewrite helper) — neither is a repo-owned source artifact.

* fix(prompts): document fal-ai/* passthrough in non-media dispatch hint

Prototype/deck agents now know arbitrary fal-ai/* model ids are valid
--model values and should be forwarded as-is, mirroring the exception
already present in media-contract.ts. Adds a prompt regression test.

* fix(daemon): use renderMediaGenerationContract(mediaExecution) for media surfaces

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-31 04:44:44 +00:00
kami
333a62cda6
fix: link od bin after fresh install (#2069)
* fix: link od bin after fresh install

* test: lock root od bin shim path

* test: cover root workspace deps in postinstall scan

* chore(nix): refresh pnpm deps hash
2026-05-31 04:36:49 +00:00
kami
def2e9fd2e
fix(web): dock comment side panel outside preview (#2073)
* fix: dock comment board without clipping inspect

When the comment-side dock falls back to the stacked layout in narrow
panes, collapsing the side panel now shrinks the bottom strip to a
horizontal rail height instead of keeping the full panel-height row.
commentPreviewCanvasSize() also stops over-deducting the expanded
panel height in the stacked-collapsed path so the canvas sizing stays
in sync with what is rendered.

* fix(web): address docked comment panel review follow-ups

* Fix non-docked comment tool tablet scaling

* test(web): align comment panel tests with collapse API
2026-05-31 04:36:15 +00:00
Denis Redozubov
729ce2b0cb
feat(daemon): add run-scoped MCP tool bundles (#3244)
* feat(daemon): add run-scoped MCP tool bundles

* fix(daemon): keep sandbox runs in managed project dirs

* fix(daemon): reject malformed run tool bundles

* fix(contracts): model run-scoped mcp server inputs

* fix(daemon): reject unsupported run tool bundles

* fix(daemon): validate run tools before chat fallback

* test(daemon): expect sandbox imported folder failure

* fix(daemon): preflight sandbox project roots before run rows

* fix(daemon): preflight sandbox chat project roots

* fix(daemon): allow host editor for sandbox imports

* fix(daemon): preflight sandbox routine project reuse

* fix(daemon): reject undeliverable Claude tool bundles

* fix(daemon): single-source chat route validation
2026-05-31 03:53:04 +00:00
蓝宙
e8c179d3a6
fix: show cumulative conversation duration (#3354)
* fix: show cumulative conversation duration

* fix: include usage-only run durations

---------

Co-authored-by: Lanzhou3 <217479610+Lanzhou3@users.noreply.github.com>
2026-05-31 03:52:12 +00:00
estelledc
0b493a66c0
fix(web): prevent caret reset on tools-menu picker mousedown (#3368)
The right-side @-button tools popover (ToolsPluginsPanel,
ToolsSkillsPanel, ToolsMcpPanel) inserts text into the composer
draft using the textarea's selectionStart at click time, but the
picker rows had `onClick` without `onMouseDown={(e) =>
e.preventDefault()}`. On a real mouse, mousedown fires first, the
textarea loses focus before the click handler runs, and
selectionStart resets — so the inserted token lands at offset 0
instead of at the user's cursor.

The @-mention popover already prevents this by calling
preventDefault on mousedown for every picker row (the comment at
ChatComposer.tsx:3039-3043 explains the reason). This change
mirrors that protection on the three tools-menu pickers.

The mention popover itself was unaffected, so design-file
mentions (which only flow through the @-popover via
`replaceMentionWithText`) are not impacted by this issue. The
reporter's mention of "design files" appears to refer to picking a
file via the @-popover, where the protection was already in place.

Closes #3195

Validation:
- pnpm exec vitest run tests/components/ChatComposer.tools-menu-caret.test.tsx
  → 3/3 passed (red on main, asserts each picker calls
  preventDefault on mousedown)
- pnpm --filter @open-design/web test → 2501/2501 passed (260 files)
- pnpm --filter @open-design/web typecheck → green
- pnpm guard → green
2026-05-31 03:50:45 +00:00
mehmet turac
8448b1105c
fix: preserve OpenClaude fallback credentials (#3361)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 2s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 2s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 1s
ci / Runtime trace (push) Has been skipped
2026-05-31 03:49:25 +00:00
Jane
d66a463d62
feat(landing-page): 301 legacy /skills /systems /templates to /plugins (#3352)
The 2026-05 plugins library rebuild introduced /plugins/skills/,
/plugins/systems/, /plugins/templates/ and a unified detail route
/plugins/<manifest-slug>/, but the old /skills/, /systems/, /templates/
catalogs were left live in parallel. Two equivalent page trees split SEO
equity, and the homepage, footer, quickstart, agents, official and blog
pages all still linked to the old routes.

Retire the legacy generators and 301 every old URL to its new plugins
equivalent so inbound links and search equity are preserved:

- Remove the /skills, /systems, /templates page generators (English +
  [locale] wrappers) and the now-orphaned skill-row component, and prune
  the skills/systems/templates branches from the [locale]/[...path]
  catch-all (it now renders only craft + blog).
- Add the migration block to public/_redirects. Detail slugs differ from
  the old folder names (new slugs are manifest-name based, e.g.
  design-system-<x>, example-<x>), so systems/templates use a prefixed
  splat plus a short degrade list, and skills map the 27 with a template
  equivalent explicitly while the ~110 instruction-only skills and all
  mode/scenario/category facet pages degrade to the section landing.
  'replicate' is forced to the section to avoid colliding with the
  design-system of the same name. Locale variants (zh, zh-tw, ja, ko)
  strip to the section.
- Repoint in-site links to /plugins/* across page.tsx (footer, work,
  labs pills), info-page-i18n.ts (en + zh + sourceNames), official,
  quickstart, agents, blog and html-anything, and update the sitemap
  serialize priority list. The system-card keeps linking through
  /systems/<slug>/ so the 8 systems without a detail page ride the
  redirect's degrade rather than pointing at a missing page.

Verified with a full astro build: old routes no longer emit any HTML,
the new section pages exist, _redirects is copied verbatim, and no
in-site link targets a removed route (the remaining /systems/<slug>/
hrefs are the system cards that 301 by design). astro check passes.

Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
2026-05-31 01:04:20 +00:00
estelledc
1a6face04c
fix(web): prune draft tokens when the plugin chip strip clears (#2881) (#3356)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
nix-check / build (push) Failing after 1s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
ChatComposer tracks the `@…` tokens this surface authored via the
@-mention popover plugin-pick path. When PluginsSection's chip strip
clears, we wire its `onCleared` and prune *only* those tracked
insertions from the draft so the textarea no longer holds orphaned
styled mentions whose chips just unmounted.

Architecture summary (rounds 1–9 collapsed; round 10 detailed below):

  - `Array<{token, start, pluginId, insertionId?}>` tracking with
    start offsets reconciled across each keystroke via an LCS+LCP
    edit-range diff in
    `apps/web/src/utils/pluginInsertionTracking.ts` (round 3-4).
    `insertionId` is forwarded by `reconcileInsertions` so the
    producer can locate its own entry across reconciles
    (round 10).
  - All draft mutations route through a single `updateDraft`
    chokepoint that runs `reconcileInsertions` outside the
    `setDraft` updater so React StrictMode's double-invoke is
    harmless (round 4-5).
  - Boundaries delegate to the shared
    `inlineMentions.isMentionBoundary` /
    `inlineMentions.isMentionRightBoundary` helpers so the
    tracker can never diverge from the parser (round 5).
  - `setActivePlugin` is a chokepoint for every applyById path,
    filtering tracked entries to those matching the new active
    plugin so a replace-plugin flow can never let stale entries
    survive (round 6).
  - Picker rollback double-snapshots draft + tracker so apply-
    failure restores the tracker but only rewrites the draft
    when no user keystrokes arrived during the await
    (round 7-8).
  - `stripPluginInsertedTokens` collapses whitespace seam-local
    so user-authored multi-space spans elsewhere are preserved
    (round 8).
  - `setActivePlugin` is deferred past `await applyById` on
    every path, and `onCleared` filters by
    `pluginsSectionRef.current?.getActiveRecord()?.id` so a
    pending-window clear scopes to the actually-mounted
    plugin's tokens (round 9).

race in the picker rollback:

  Round 9 made `onCleared` mutate the tracker and the draft when
  it ran during a pending replace, and added the `getActiveRecord`
  filter so the strip targets the still-mounted plugin's entries
  only. The picker's failure-path rollback, however, still
  restored `prevEntries` / `prevActiveId` wholesale — assuming
  nothing else had touched the tracker during the await. If the
  user clicked the still-mounted original chip's × during the
  pending replace AND the deferred `applyById` then resolved
  with a 500, the wholesale restore (a) resurrected entries that
  `onCleared` had legitimately stripped (now stale offsets) and
  (b) left the optimistic `@<target>` orphaned in the draft with
  no chip ever having mounted — the original #2881 symptom
  recurring inside the failure window.

  Fix splits the failure rollback into two paths:

  1. **Detect "intervening clear" via `activePluginIdRef.current
     === null && prevActiveId !== null`.** `onCleared` always
     nulls the active id as its last action; our deferred
     `setActivePlugin` never ran in the failure branch. So the
     null-while-prev-not-null state is the smoking gun for an
     intervening clear during the await.

  2. **On detection, surgically remove only our optimistic
     entry and only its `@<target>`.** Locate the entry by
     `insertionId` (added to `TrackedInsertion` as an optional
     field, forwarded by `reconcileInsertions` so the id
     survives offset shifts) — this disambiguates the case
     where the user picked the same plugin from the @-popover
     more than once during the await window. Splice that entry
     out and run `updateDraft((d) => stripPluginInsertedTokens(
     d, [ourEntry]))` so the draft loses `@<target>` and any
     remaining tracked entries (the in-flight target would have
     no others, but a co-pending second pick could) get their
     offsets reconciled. `activePluginIdRef` stays at `null` —
     `onCleared`'s truth, since no chip is mounted.

  The "no intervening clear" branch is the round 7/8 path:
  restore `prevEntries`/`prevActiveId` wholesale and rewrite
  the draft only if `draftRef.current === postInsertDraft`
  (no user keystrokes during the await).

Regression coverage (additions):

  - `apps/web/tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx`
    — 18 integration specs total (17 prior + 1 new round-10):
    * `@-popover pick A → @-popover pick B (apply pending) →
      clear A's chip → resolve B with 500 → assert no orphan
      @<target>, no orphan @A, no chip mounted, no stale
      tracker entries`. Uses a deferred `Promise<Response>` so
      the apply stays in flight while the chip-clear is fired,
      then resolves with a 500 to drive the failure path. Pre-
      fix this would resurrect Airbnb's stale entry AND leave
      `@SecondPlugin` orphaned in the draft.

PluginsSection.tsx is unchanged. The host-local tracking +
draft-update chokepoint + parser-aligned boundaries + deferred
active-plugin scoping + transactional applyById + intervening-
clear-aware rollback + filtered `onCleared` keep the cross-
component contract identical to main — only ChatComposer touches
behavior, plus the utils module and two `inlineMentions` exports.

Validation:
  - pnpm exec vitest run tests/utils/pluginInsertionTracking.test.ts → 36/36 passed
  - pnpm exec vitest run tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx → 18/18 passed
  - pnpm exec vitest run -c vitest.config.ts (full apps/web suite, 228 files) → 2202/2202 passed
  - pnpm --filter @open-design/web typecheck → green
  - pnpm guard → green
2026-05-30 17:16:24 +00:00
Amy
91691f3e66 Tighten plugin authoring completion regressions 2026-05-29 19:05:06 +08:00
Amy
f7beb42950 Add daemon launch review regression coverage 2026-05-29 19:05:06 +08:00
Amy
a8277c678b Add main launch review E2E coverage 2026-05-29 19:05:06 +08:00
127 changed files with 8691 additions and 2473 deletions

3
.gitignore vendored
View file

@ -76,4 +76,7 @@ docs/superpowers/
# on every deploy. Should not be committed (~70MB of PNGs).
apps/landing-page/public/previews/
# Ad-hoc local e2e scripts and their screenshots
e2e/scripts/test-fal-webui.ts
e2e/scripts/fal-webui-*.png
growth/**

16
apps/daemon/bin/od.mjs Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const entryDir = dirname(fileURLToPath(import.meta.url));
const distEntry = resolve(entryDir, "../dist/cli.js");
if (!existsSync(distEntry)) {
throw new Error(
`Open Design daemon dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/daemon build" first.`,
);
}
await import(pathToFileURL(distEntry).href);

View file

@ -6,7 +6,7 @@
"main": "./dist/cli.js",
"types": "./dist/cli.d.ts",
"bin": {
"od": "./dist/cli.js"
"od": "./bin/od.mjs"
},
"exports": {
".": {
@ -20,6 +20,7 @@
}
},
"files": [
"bin",
"dist",
"package.json"
],

View file

@ -13,8 +13,9 @@
// outside this machine.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import path from 'node:path';
import { expandHomePrefix } from './home-expansion.js';
import {
readInstallationFile,
@ -85,6 +86,12 @@ export interface OrbitConfigPrefs {
templateSkillId?: string | null;
}
export interface ProjectLocationPrefs {
id: string;
name: string;
path: string;
}
export interface AppConfigPrefs {
onboardingCompleted?: boolean;
agentId?: string | null;
@ -99,6 +106,8 @@ export interface AppConfigPrefs {
privacyDecisionAt?: number | null;
orbit?: OrbitConfigPrefs;
customInstructions?: string | null;
projectLocations?: ProjectLocationPrefs[];
defaultProjectLocationId?: string | null;
}
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
@ -115,6 +124,8 @@ const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
'privacyDecisionAt',
'orbit',
'customInstructions',
'projectLocations',
'defaultProjectLocationId',
] as const);
function configFile(dataDir: string): string {
@ -245,6 +256,46 @@ function validateOrbit(raw: unknown): OrbitConfigPrefs | undefined {
return orbit;
}
function normalizeLocationId(raw: string, fallback: string): string {
const trimmed = raw.trim();
if (/^[A-Za-z0-9._-]{1,128}$/.test(trimmed) && trimmed !== 'default') {
return trimmed;
}
return fallback;
}
function autoProjectLocationId(pathKey: string): string {
return `loc_${createHash('sha256').update(pathKey).digest('base64url').slice(0, 16)}`;
}
function validateProjectLocations(raw: unknown): ProjectLocationPrefs[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw)) return undefined;
const result: ProjectLocationPrefs[] = [];
const seenIds = new Set<string>();
const seenPaths = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const obj = item as Record<string, unknown>;
if (typeof obj.path !== 'string') continue;
const expanded = expandHomePrefix(obj.path.trim());
if (!expanded || !path.isAbsolute(expanded)) continue;
const normalizedPath = path.normalize(expanded);
const pathKey = process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath;
if (seenPaths.has(pathKey)) continue;
const id = normalizeLocationId(
typeof obj.id === 'string' ? obj.id : '',
autoProjectLocationId(pathKey),
);
if (seenIds.has(id)) continue;
const rawName = typeof obj.name === 'string' ? obj.name.trim() : '';
result.push({ id, name: rawName || path.basename(normalizedPath) || normalizedPath, path: normalizedPath });
seenIds.add(id);
seenPaths.add(pathKey);
}
return result;
}
export function agentCliEnvForAgent(
prefs: AgentCliEnvPrefs | undefined,
agentId: string,
@ -330,6 +381,25 @@ function applyConfigValue(
}
return;
}
if (key === 'projectLocations') {
const validated = validateProjectLocations(value);
if (validated !== undefined) {
target[key] = validated;
} else {
delete target[key];
}
return;
}
if (key === 'defaultProjectLocationId') {
if (typeof value === 'string') {
target[key] = normalizeLocationId(value, 'default');
} else if (value === null) {
target[key] = null;
} else {
delete target[key];
}
return;
}
}
function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {

View file

@ -19,7 +19,6 @@ import { isSafeId as isSafeProjectId } from './projects.js';
import { projectKindToTracking } from '@open-design/contracts/analytics';
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
import { googleStreamGenerateContentUrl } from './google-models.js';
import { parseMediaExecutionPolicyInput } from './media-policy.js';
import { createRoleMarkerGuard } from './role-marker-guard.js';
// Allowlist for the `/feedback` route. Mirrors the
@ -45,7 +44,7 @@ export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'htt
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
const { startChatRun, submitToolResultToRun } = ctx.chat;
const { submitToolResultToRun } = ctx.chat;
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
const {
handleCritiqueArtifact,
@ -54,7 +53,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
critiqueResponseCapBytes,
critiqueRunRegistry,
} = ctx.critique;
const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false);
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
if (
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
@ -79,6 +77,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
// so any handler we wired here was shadowed and never executed. Plugin
// snapshot resolution, clientType inference, and the daemon-side
// run_created/finished analytics all live in `server.ts` now.
// POST /api/chat is likewise owned by `server.ts`; keep the chat run
// launch path single-sourced so validation changes land on the live route.
app.get('/api/runs', (req, res) => {
const { projectId, conversationId, status } = req.query;
@ -218,23 +218,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
res.status(202).json(outcome);
});
app.post('/api/chat', (req, res) => {
if (isDaemonShuttingDown()) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const body = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(
(body as { mediaExecution?: unknown }).mediaExecution,
);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const runBody = { ...body, mediaExecution: mediaExecution.policy };
const run = design.runs.create(runBody);
design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(runBody, run));
});
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
// Settings dialog uses these to verify a config works without sending a
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused

View file

@ -1,3 +1,5 @@
import path from 'node:path';
import { redactSecrets } from './redact.js';
export interface ClaudeCliDiagnosticInput {
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
stderrTail?: string | null;
stdoutTail?: string | null;
env?: Record<string, unknown> | null;
resolvedBin?: string | null;
}
export interface ClaudeCliDiagnostic {
@ -51,6 +54,15 @@ function withContext(
};
}
function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'claude' | 'openclaude' {
if (typeof input.resolvedBin !== 'string' || !input.resolvedBin.trim()) return 'claude';
const base = path
.basename(input.resolvedBin.trim().replace(/\\/g, '/'))
.replace(/\.(exe|cmd|bat)$/i, '')
.toLowerCase();
return base === 'openclaude' ? 'openclaude' : 'claude';
}
export function diagnoseClaudeCliFailure(
input: ClaudeCliDiagnosticInput,
): ClaudeCliDiagnostic | null {
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
const normalized = text.toLowerCase();
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
const runtime = selectedClaudeCompatibleRuntime(input);
const isOpenClaude = runtime === 'openclaude';
const customEndpointConnectionFailure =
hasCustomBaseUrl &&
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
);
}
if (authFailure) {
if (isOpenClaude) {
return withContext(
'OpenClaude could not authenticate with its configured endpoint.',
'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.',
input,
);
}
const configHint = hasConfigDir
? 'The configured Claude config directory may contain stale or expired auth state.'
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
@ -147,6 +168,13 @@ export function diagnoseClaudeCliFailure(
}
if (!text.trim() && input.exitCode === 1) {
if (isOpenClaude) {
return withContext(
'OpenClaude exited before producing diagnostics.',
'Check the OpenClaude API key, endpoint, and local configuration, then retry.',
input,
);
}
const message = hasConfigDir
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
: 'Claude Code exited before producing diagnostics.';

View file

@ -573,11 +573,11 @@ async function runMediaWait(rawArgs) {
const since = Number.isFinite(Number(flags.since))
? Number(flags.since)
: 0;
await pollUntilDoneOrBudget(daemonUrl, taskId, since);
await pollUntilDoneOrBudget(daemonUrl, taskId, since, { totalBudgetMs: 120_000 });
}
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {}) {
const totalBudgetMs = 25_000;
const totalBudgetMs = typeof options.totalBudgetMs === 'number' ? options.totalBudgetMs : 25_000;
const perCallTimeoutMs = 4_000;
const stillRunningExitCode =
typeof options.stillRunningExitCode === 'number'

View file

@ -1862,6 +1862,8 @@ async function testAgentConnectionInternal(
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: executableResolution.selectedPath },
);
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
@ -2026,6 +2028,7 @@ async function testAgentConnectionInternal(
stderrTail,
stdoutTail: rawStdoutTail || buffered,
env,
resolvedBin: executableResolution.selectedPath,
});
if (claudeDiagnostic) {
console.warn(

View file

@ -752,12 +752,23 @@ export function listConversations(db: SqliteDb, projectId: string) {
AND m.run_status IS NOT NULL
)
WHERE rn = 1
),
total_run_durations AS (
SELECT m.conversation_id AS conversationId,
SUM(${terminalRunDurationSql('m')}) AS totalDurationMs
FROM messages m
JOIN project_conversations c ON c.id = m.conversation_id
WHERE m.role = 'assistant'
AND m.run_status IN ('succeeded', 'failed', 'canceled')
GROUP BY m.conversation_id
)
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
lr.latestRunStatus, lr.latestRunStartedAt,
lr.latestRunEndedAt, lr.latestRunEventsJson
lr.latestRunEndedAt, lr.latestRunEventsJson,
trd.totalDurationMs
FROM project_conversations c
LEFT JOIN latest_runs lr ON lr.conversationId = c.id
LEFT JOIN total_run_durations trd ON trd.conversationId = c.id
ORDER BY c.updatedAt DESC`,
)
.all(projectId)).map(normalizeConversation);
@ -775,6 +786,7 @@ export function getConversation(db: SqliteDb, id: string) {
return {
...normalizeConversation(r),
latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
...numberProperty('totalDurationMs', totalConversationRunDurationMs(db, r.id)),
};
}
@ -791,10 +803,16 @@ function normalizeConversation(r: DbRow) {
title: r.title ?? null,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
...numberProperty('totalDurationMs', r.totalDurationMs),
latestRun: latestRun ?? undefined,
};
}
function numberProperty(key: string, value: unknown) {
const n = value == null ? undefined : Number(value);
return typeof n === 'number' && Number.isFinite(n) ? { [key]: n } : {};
}
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
const row = db
.prepare(
@ -813,6 +831,50 @@ function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
return conversationRunSummaryFromRow(row);
}
function totalConversationRunDurationMs(db: SqliteDb, conversationId: string): number | undefined {
const row = db
.prepare(
`SELECT SUM(${terminalRunDurationSql()}) AS totalDurationMs
FROM messages
WHERE conversation_id = ?
AND role = 'assistant'
AND run_status IN ('succeeded', 'failed', 'canceled')`,
)
.get(conversationId) as DbRow | undefined;
return row?.totalDurationMs == null ? undefined : Number(row.totalDurationMs);
}
function terminalRunDurationSql(alias?: string) {
const p = alias ? `${alias}.` : '';
return `CASE
WHEN ${p}started_at IS NOT NULL AND ${p}ended_at IS NOT NULL THEN
CASE
WHEN CAST(${p}ended_at AS INTEGER) >= CAST(${p}started_at AS INTEGER)
THEN CAST(${p}ended_at AS INTEGER) - CAST(${p}started_at AS INTEGER)
ELSE 0
END
ELSE (
SELECT CASE
WHEN json_extract(usage_event.value, '$.durationMs') >= 0
THEN json_extract(usage_event.value, '$.durationMs')
ELSE 0
END
FROM json_each(
CASE
WHEN json_valid(${p}events_json) AND json_type(${p}events_json) = 'array'
THEN ${p}events_json
ELSE '[]'
END
) AS usage_event
WHERE usage_event.type = 'object'
AND json_extract(usage_event.value, '$.kind') = 'usage'
AND json_type(usage_event.value, '$.durationMs') IN ('integer', 'real')
ORDER BY CAST(usage_event.key AS INTEGER) DESC
LIMIT 1
)
END`;
}
function conversationRunSummaryFromRow(row: DbRow | undefined) {
if (!row || typeof row.runStatus !== 'string') return null;
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);

View file

@ -15,6 +15,7 @@
import { spawn } from 'node:child_process';
import { access, constants as fsConstants } from 'node:fs/promises';
import path from 'node:path';
import type { Express } from 'express';
import type {
HostEditor,
@ -159,6 +160,28 @@ function applicableForPlatform(entry: CatalogueEntry, platform: Platform): boole
return true;
}
function projectHostOpenDir(
projectsRoot: string,
project: { id: string; metadata?: { baseDir?: unknown } | null },
resolveProjectDir: (
projectsRoot: string,
projectId: string,
metadata?: unknown,
opts?: { allowUnavailableSandboxImportedProject?: boolean },
) => string,
): string {
const importedBaseDir =
typeof project.metadata?.baseDir === 'string'
? path.normalize(project.metadata.baseDir)
: '';
if (importedBaseDir && path.isAbsolute(importedBaseDir)) {
return importedBaseDir;
}
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
allowUnavailableSandboxImportedProject: true,
});
}
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
const { db } = ctx;
const { sendApiError } = ctx.http;
@ -209,7 +232,11 @@ export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRout
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
const resolvedDir = projectHostOpenDir(
PROJECTS_DIR,
project,
resolveProjectDir,
);
const probe = await resolveEntry(entry);
if (!probe.available || !probe.launch) {
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);

View file

@ -40,7 +40,7 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [
{ id: 'custom-image', label: 'Custom Image API', hint: 'OpenAI-compatible images/generations + images/edits (local or cloud)', integrated: true, docsUrl: 'https://platform.openai.com/docs/api-reference/images', supportsCustomModel: true, customModelPlaceholder: 'my-image-model' },
{ id: 'comfyui', label: 'ComfyUI', hint: 'Local JSON workflow server (planned adapter)', integrated: false, defaultBaseUrl: 'http://127.0.0.1:8188', docsUrl: 'https://docs.comfy.org/development/core-concepts/workflow' },
{ id: 'bfl', label: 'Black Forest Labs', hint: 'FLUX 1.1 Pro / FLUX Pro / Dev', integrated: false, defaultBaseUrl: 'https://api.bfl.ai' },
{ id: 'fal', label: 'Fal.ai', hint: 'Sora / Seedance / Veo / FLUX', integrated: false, defaultBaseUrl: 'https://fal.run' },
{ id: 'fal', label: 'Fal.ai', hint: 'FLUX / Sora / Veo / Wan / Ideogram / Recraft and any fal-ai/* model', integrated: true, defaultBaseUrl: 'https://fal.run', supportsCustomModel: true },
{ id: 'leonardo', label: 'Leonardo.ai', hint: 'Phoenix / Kino XL / FLUX', integrated: true, credentialsRequired: true, settingsVisible: true, defaultBaseUrl: 'https://cloud.leonardo.ai/api/rest/v1' },
{ id: 'replicate', label: 'Replicate', hint: 'FLUX / SDXL / Ideogram', integrated: false, defaultBaseUrl: 'https://api.replicate.com' },
{ id: 'google', label: 'Google AI / Vertex', hint: 'Imagen 4 / Veo 3 / Lyria', integrated: false },
@ -107,7 +107,13 @@ export const IMAGE_MODELS: MediaModel[] = [
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
{ id: 'flux-pro-ultra', label: 'flux-pro-ultra', hint: 'Fal · FLUX 1.1 Pro Ultra · highest quality (~60180s)', provider: 'fal', caps: ['t2i'] },
{ id: 'flux-dev-fal', label: 'flux-dev (fal)', hint: 'Fal · FLUX Dev · balanced quality/speed (~1540s)', provider: 'fal', caps: ['t2i'] },
{ id: 'flux-schnell-fal', label: 'flux-schnell (fal)', hint: 'Fal · FLUX Schnell · fastest (~38s)', provider: 'fal', caps: ['t2i'] },
{ id: 'ideogram-v3-fal', label: 'ideogram-v3', hint: 'Fal · Ideogram v3 · typography + design (~1530s)', provider: 'fal', caps: ['t2i'] },
{ id: 'recraft-v3-fal', label: 'recraft-v3', hint: 'Fal · Recraft v3 · vector + illustration (~1530s)', provider: 'fal', caps: ['t2i'] },
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5 (~2040s)', provider: 'fal', caps: ['t2i'] },
{ id: 'leonardo-phoenix', label: 'Phoenix', hint: 'Leonardo · versatile', provider: 'leonardo', caps: ['t2i'] },
{ id: 'leonardo-kino-xl', label: 'Kino XL', hint: 'Leonardo · cinematic', provider: 'leonardo', caps: ['t2i'] },
@ -138,8 +144,14 @@ export const VIDEO_MODELS: MediaModel[] = [
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
{ id: 'sora-2', label: 'sora-2', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'veo-3-fal', label: 'veo-3 (fal)', hint: 'Fal · Google Veo 3 · sound-on', provider: 'fal', caps: ['t2v', 'audio'] },
{ id: 'veo-2-fal', label: 'veo-2 (fal)', hint: 'Fal · Google Veo 2', provider: 'fal', caps: ['t2v'] },
{ id: 'wan-2.1-t2v', label: 'wan-2.1-t2v', hint: 'Fal · Wan 2.1 text-to-video', provider: 'fal', caps: ['t2v'] },
{ id: 'wan-2.1-i2v', label: 'wan-2.1-i2v', hint: 'Fal · Wan 2.1 image-to-video', provider: 'fal', caps: ['i2v'] },
{ id: 'seedance-1-pro-fal', label: 'seedance-1-pro (fal)', hint: 'Fal · Seedance 1 Pro', provider: 'fal', caps: ['t2v', 'i2v'] },
{ id: 'kling-2.1-t2v-fal', label: 'kling-2.1 (fal)', hint: 'Fal · Kling 2.1 Pro text-to-video', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2', label: 'sora-2', hint: 'Fal · OpenAI Sora 2', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'Fal · OpenAI Sora 2 Pro', provider: 'fal', caps: ['t2v'] },
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },
{ id: 'hyperframes-html', label: 'hyperframes-html', hint: 'HyperFrames · local HTML renderer', provider: 'hyperframes', caps: ['t2v'] },

View file

@ -327,27 +327,42 @@ export async function generateMedia(args: {
`unsupported audioKind: ${audioKind}. Allowed: music | speech | sfx.`,
);
}
const def = findMediaModel(model);
// Arbitrary fal.ai model paths (e.g. "fal-ai/flux/dev") bypass the
// catalog so users can reach any model on fal without waiting for a
// catalog entry. Surface comes from the caller; no cross-surface guard
// is needed because the fal renderer reads ctx.surface directly.
let def = findMediaModel(model);
let isFalCustomPath = false;
if (!def) {
throw new Error(
`unknown model: ${model}. Pass --model from the registered list (see /api/media/models).`,
);
if (/^fal-ai\//.test(model)) {
isFalCustomPath = true;
def = {
id: model,
label: model,
hint: 'Fal.ai',
provider: 'fal',
caps: surface === 'image' ? ['t2i'] : surface === 'video' ? ['t2v'] : [],
};
} else {
throw new Error(
`unknown model: ${model}. Pass --model from the registered list (see /api/media/models), ` +
`or pass a full fal-ai/* path (e.g. fal-ai/flux/dev) for any Fal model.`,
);
}
}
// Reject cross-surface combinations (e.g. surface=image + model=seedance-2)
// here so the dispatcher never silently routes a video model id through
// the image renderer. We compare against the surface-specific list — for
// audio we further restrict to the kind-specific bucket so a `music`
// surface can't bill an `elevenlabs-v3` (speech) call.
// Reject cross-surface combinations for catalogued models.
const resolvedAudioKind =
surface === 'audio' ? audioKind || 'music' : undefined;
const allowed = modelsForSurface(surface, resolvedAudioKind);
if (!allowed.some((m) => m.id === model)) {
const ids = allowed.map((m) => m.id).join(', ');
const where =
surface === 'audio' ? `audio · ${resolvedAudioKind}` : surface;
throw new Error(
`model "${model}" is not registered for surface "${where}". Allowed: ${ids}.`,
);
if (!isFalCustomPath) {
const allowed = modelsForSurface(surface, resolvedAudioKind);
if (!allowed.some((m) => m.id === model)) {
const ids = allowed.map((m) => m.id).join(', ');
const where =
surface === 'audio' ? `audio · ${resolvedAudioKind}` : surface;
throw new Error(
`model "${model}" is not registered for surface "${where}". Allowed: ${ids}.`,
);
}
}
// Clamp registry-bound numeric inputs to their allowed buckets so a
@ -575,6 +590,16 @@ export async function generateMedia(args: {
bytes = result.bytes;
providerNote = result.providerNote;
suggestedExt = result.suggestedExt;
} else if (def.provider === 'fal' && surface === 'image') {
const result = await renderFalImage(ctx, credentials);
bytes = result.bytes;
providerNote = result.providerNote;
suggestedExt = result.suggestedExt;
} else if (def.provider === 'fal' && surface === 'video') {
const result = await renderFalVideo(ctx, credentials, args.onProgress);
bytes = result.bytes;
providerNote = result.providerNote;
suggestedExt = result.suggestedExt;
} else {
// No real renderer wired up for this (provider, surface). Gate the
// stub fallback behind OD_MEDIA_ALLOW_STUBS so release builds don't
@ -2498,6 +2523,270 @@ async function renderFishAudioTTS(ctx: MediaContext, credentials: ProviderConfig
};
}
// ---------------------------------------------------------------------------
// Provider: Fal.ai — generic queue-based renderer for image + video.
//
// Queue protocol (raw HTTP, no SDK):
// POST https://queue.fal.run/{endpoint} body: flat model input (no wrapper)
// GET {status_url}?logs=0 → { status: QUEUED|IN_PROGRESS|COMPLETED|FAILED }
// GET {response_url} → result payload
//
// Image result shape: { images: [{ url, content_type }] }
// Video result shape: { video: { url } } or { videos: [{ url }] }
//
// Endpoint resolution: FAL_ENDPOINTS maps catalogue IDs to their fal-ai/*
// path. Any model ID not in the map is used verbatim — this is what
// enables arbitrary "fal-ai/..." custom paths without catalog entries.
// ---------------------------------------------------------------------------
const FAL_ENDPOINTS: Record<string, string> = {
'sd-3.5': 'fal-ai/stable-diffusion-v35-large',
'flux-pro-ultra': 'fal-ai/flux-pro/v1.1-ultra',
'flux-dev-fal': 'fal-ai/flux/dev',
'flux-schnell-fal': 'fal-ai/flux/schnell',
'ideogram-v3-fal': 'fal-ai/ideogram/v3',
'recraft-v3-fal': 'fal-ai/recraft-v3',
'sora-2': 'fal-ai/sora',
'sora-2-pro': 'fal-ai/sora',
'veo-3-fal': 'fal-ai/veo3',
'veo-2-fal': 'fal-ai/veo2',
'wan-2.1-t2v': 'fal-ai/wan-t2v',
'wan-2.1-i2v': 'fal-ai/wan-i2v',
'seedance-1-pro-fal': 'fal-ai/bytedance/seedance-1-pro',
'kling-2.1-t2v-fal': 'fal-ai/kling-video/v2.1/master/text-to-video',
};
// Image models that expect `aspect_ratio` (e.g. "16:9") instead of the
// named `image_size` enum ("landscape_16_9") used by FLUX Dev/Schnell/SD.
const FAL_IMAGE_USES_ASPECT_RATIO = new Set([
'fal-ai/flux-pro/v1.1-ultra',
'fal-ai/flux-pro/v1.1',
]);
const FAL_IMAGE_SIZES: Record<string, string> = {
'1:1': 'square_hd',
'16:9': 'landscape_16_9',
'9:16': 'portrait_16_9',
'4:3': 'landscape_4_3',
'3:4': 'portrait_4_3',
};
// Video models that do not accept a duration field at all.
const FAL_VIDEO_NO_DURATION = new Set([
'fal-ai/wan-t2v',
'fal-ai/wan-i2v',
]);
// Video models that expect duration as a suffixed string ("4s"/"6s"/"8s") and
// only accept those specific buckets.
const FAL_VIDEO_STRING_DURATION = new Set([
'fal-ai/veo3',
'fal-ai/veo2',
]);
// Valid Veo duration buckets (seconds). Nearest-bucket clamp applied below.
const FAL_VEO_DURATION_BUCKETS = [4, 6, 8];
async function falQueueRun(
endpoint: string,
queueBase: string,
apiKey: string,
input: Record<string, unknown>,
maxMs: number,
onProgress?: ProgressFn,
modelLabel?: string,
): Promise<any> {
const authHeader = { 'authorization': `Key ${apiKey}` };
const submitResp = await fetch(`${queueBase}/${endpoint}`, {
method: 'POST',
headers: { ...authHeader, 'content-type': 'application/json' },
body: JSON.stringify(input),
});
const submitText = await submitResp.text();
if (!submitResp.ok) {
throw new Error(`fal submit ${submitResp.status}: ${truncate(submitText, 240)}`);
}
let submitData: any;
try { submitData = JSON.parse(submitText); } catch {
throw new Error(`fal submit non-JSON: ${truncate(submitText, 200)}`);
}
const requestId: string = submitData?.request_id;
if (!requestId) {
throw new Error(`fal submit missing request_id: ${truncate(submitText, 200)}`);
}
// Prefer the URLs returned by the submit response; fall back to the
// well-known model-agnostic queue paths as a safety net.
const statusUrl = submitData.status_url
?? `${queueBase}/requests/${encodeURIComponent(requestId)}/status?logs=0`;
const resultUrl = submitData.response_url
?? `${queueBase}/requests/${encodeURIComponent(requestId)}`;
const startedAt = Date.now();
let lastStatus = '';
if (onProgress) {
onProgress(`fal ${modelLabel || endpoint} task ${requestId.slice(0, 8)} accepted; polling…`);
}
let firstPoll = true;
while (Date.now() - startedAt < maxMs) {
if (!firstPoll) await sleep(3000);
firstPoll = false;
const statusResp = await fetch(statusUrl, { headers: authHeader });
const statusText = await statusResp.text();
if (!statusResp.ok) {
throw new Error(`fal poll ${statusResp.status}: ${truncate(statusText, 240)}`);
}
let statusData: any;
try { statusData = JSON.parse(statusText); } catch {
throw new Error(`fal poll non-JSON: ${truncate(statusText, 200)}`);
}
lastStatus = statusData?.status || '';
if (onProgress) {
const elapsed = Math.round((Date.now() - startedAt) / 1000);
onProgress(`fal task ${requestId.slice(0, 8)} status=${lastStatus} (${elapsed}s)`);
}
if (lastStatus === 'COMPLETED') {
const resultResp = await fetch(resultUrl, { headers: authHeader });
const resultText = await resultResp.text();
if (!resultResp.ok) {
throw new Error(`fal result ${resultResp.status}: ${truncate(resultText, 240)}`);
}
try { return JSON.parse(resultText); } catch {
throw new Error(`fal result non-JSON: ${truncate(resultText, 200)}`);
}
}
if (lastStatus === 'FAILED') {
const errRaw = statusData?.error?.message
?? (typeof statusData?.error === 'string' ? statusData.error : null)
?? 'unknown error';
throw new Error(`fal task failed: ${errRaw}`);
}
}
const elapsed = Math.round((Date.now() - startedAt) / 1000);
const ceil = Math.round(maxMs / 1000);
throw new Error(
`fal timed out after ${elapsed}s waiting for COMPLETED ` +
`(last status: ${lastStatus || 'unknown'}, ceiling ${ceil}s). ` +
`Raise OD_FAL_MAX_POLL_MS to extend the ceiling.`,
);
}
function falMaxPollMs(defaultMs: number): number {
const v = Number(process.env.OD_FAL_MAX_POLL_MS);
return Number.isFinite(v) && v >= 30_000 ? v : defaultMs;
}
function falQueueBase(baseUrl: string): string {
if (baseUrl.includes('queue.fal.run')) return baseUrl;
// Replace only the exact host to avoid mangling custom base URLs that
// happen to contain "fal.run" as a substring.
return baseUrl.replace(/^https:\/\/fal\.run/, 'https://queue.fal.run');
}
async function renderFalImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error('no Fal API key — configure it in Settings or set FAL_KEY');
}
const queueBase = falQueueBase((credentials.baseUrl || 'https://fal.run').replace(/\/$/, ''));
const endpoint = FAL_ENDPOINTS[ctx.model] ?? ctx.model;
const aspectRatio = ctx.aspect ?? '1:1';
const input: Record<string, unknown> = {
prompt: ctx.prompt || 'A high-quality image.',
num_images: 1,
};
// flux-pro-ultra and similar pro variants expect `aspect_ratio` as a
// ratio string; most other fal image models use a named `image_size`.
if (FAL_IMAGE_USES_ASPECT_RATIO.has(endpoint)) {
input.aspect_ratio = aspectRatio;
} else {
input.image_size = FAL_IMAGE_SIZES[aspectRatio] ?? 'square_hd';
}
if (ctx.imageRef?.dataUrl) {
input.image_url = ctx.imageRef.dataUrl;
}
const result = await falQueueRun(endpoint, queueBase, credentials.apiKey, input, falMaxPollMs(5 * 60 * 1000));
const imageEntry = Array.isArray(result?.images) ? result.images[0] : null;
if (!imageEntry?.url) {
throw new Error(`fal image missing images[0].url: ${truncate(JSON.stringify(result), 200)}`);
}
const dlResp = await fetch(imageEntry.url);
if (!dlResp.ok) throw new Error(`fal image download ${dlResp.status}`);
const bytes = Buffer.from(await dlResp.arrayBuffer());
const sizeLabel = FAL_IMAGE_USES_ASPECT_RATIO.has(endpoint) ? aspectRatio : (FAL_IMAGE_SIZES[aspectRatio] ?? 'square_hd');
return {
bytes,
providerNote: `fal/${endpoint} · ${sizeLabel} · ${bytes.length} bytes`,
suggestedExt: sniffImageExt(bytes),
};
}
async function renderFalVideo(ctx: MediaContext, credentials: ProviderConfig, onProgress?: ProgressFn): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error('no Fal API key — configure it in Settings or set FAL_KEY');
}
const queueBase = falQueueBase((credentials.baseUrl || 'https://fal.run').replace(/\/$/, ''));
const endpoint = FAL_ENDPOINTS[ctx.model] ?? ctx.model;
const aspectRatio = ctx.aspect ?? '16:9';
const durationSec = ctx.length ?? 5;
const input: Record<string, unknown> = {
prompt: ctx.prompt || 'A short cinematic clip.',
aspect_ratio: aspectRatio,
};
// Track the effective duration label (what we actually send upstream).
let effectiveDurationLabel: string | undefined;
let durationSnappedNote = '';
// Some models (Wan) have no duration parameter; others (Veo) require a
// suffixed string from a fixed bucket set ("4s"/"6s"/"8s").
if (!FAL_VIDEO_NO_DURATION.has(endpoint)) {
if (FAL_VIDEO_STRING_DURATION.has(endpoint)) {
const closest = FAL_VEO_DURATION_BUCKETS.reduce((a, b) =>
Math.abs(b - durationSec) < Math.abs(a - durationSec) ? b : a,
);
input.duration = `${closest}s`;
effectiveDurationLabel = `${closest}s`;
if (closest !== durationSec) {
durationSnappedNote = ` (requested ${durationSec}s → snapped to ${closest}s)`;
}
} else {
input.duration = durationSec;
effectiveDurationLabel = `${durationSec}s`;
}
}
if (ctx.imageRef?.dataUrl) {
input.image_url = ctx.imageRef.dataUrl;
}
const result = await falQueueRun(
endpoint, queueBase, credentials.apiKey, input,
falMaxPollMs(10 * 60 * 1000), onProgress, ctx.model,
);
const videoUrl: string | null =
result?.video?.url
?? (Array.isArray(result?.videos) ? result.videos[0]?.url : null)
?? null;
if (!videoUrl) {
throw new Error(`fal video missing video.url: ${truncate(JSON.stringify(result), 200)}`);
}
const dlResp = await fetch(videoUrl);
if (!dlResp.ok) throw new Error(`fal video download ${dlResp.status}`);
const bytes = Buffer.from(await dlResp.arrayBuffer());
const durationPart = effectiveDurationLabel ? ` · ${effectiveDurationLabel}${durationSnappedNote}` : '';
return {
bytes,
providerNote: `fal/${endpoint} · ${aspectRatio}${durationPart} · ${bytes.length} bytes`,
suggestedExt: '.mp4',
};
}
// ---------------------------------------------------------------------------
// Provider: HyperFrames — local HTML→MP4 renderer (heygen-com/hyperframes).
//

View file

@ -865,7 +865,13 @@ async function callLocalCli(provider, system, user, options) {
}
const env = applyAgentLaunchEnv(
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv),
spawnEnvForAgent(
def.id,
{ ...process.env, ...(def.env || {}) },
configuredAgentEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
);
const invocation = createCommandInvocation({

View file

@ -0,0 +1,130 @@
import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { ProjectLocationPrefs } from './app-config.js';
import { expandHomePrefix } from './home-expansion.js';
import { isSafeId } from './projects.js';
export const BUILT_IN_PROJECT_LOCATION_ID = 'default';
export const PROJECT_MANIFEST_RELATIVE_PATH = path.join('.open-design', 'project.json');
export interface ProjectLocation extends ProjectLocationPrefs {
builtIn?: boolean;
}
export interface ProjectManifest {
schemaVersion: 1;
id: string;
name: string;
createdAt: number;
updatedAt: number;
skillId?: string | null;
designSystemId?: string | null;
}
export function builtInProjectLocation(projectsDir: string): ProjectLocation {
return {
id: BUILT_IN_PROJECT_LOCATION_ID,
name: 'Open Design projects',
path: projectsDir,
builtIn: true,
};
}
export function allProjectLocations(projectsDir: string, external: ProjectLocationPrefs[] | undefined): ProjectLocation[] {
return [builtInProjectLocation(projectsDir), ...(external ?? [])];
}
export function locationProjectDir(location: ProjectLocation, projectId: string): string {
if (!isSafeId(projectId)) throw new Error('invalid project id');
return path.join(location.path, projectId);
}
function assertInsideLocation(locationRoot: string, projectDir: string): void {
const relative = path.relative(locationRoot, projectDir);
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('project directory escapes project location');
}
}
export async function createLocationProjectDir(location: ProjectLocation, projectId: string): Promise<string> {
const root = await realpath(location.path);
const target = locationProjectDir({ ...location, path: root }, projectId);
await mkdir(target, { recursive: false });
const info = await lstat(target);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory');
const canonical = await realpath(target);
assertInsideLocation(root, canonical);
return canonical;
}
export async function canonicalLocationChildDir(location: ProjectLocation, childName: string): Promise<string> {
const root = await realpath(location.path);
if (!isSafeId(childName)) throw new Error('invalid project directory name');
const target = path.join(root, childName);
const info = await lstat(target);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory');
const canonical = await realpath(target);
assertInsideLocation(root, canonical);
return canonical;
}
export function manifestPath(projectDir: string): string {
return path.join(projectDir, PROJECT_MANIFEST_RELATIVE_PATH);
}
export async function ensureProjectLocation(locationPath: string): Promise<string> {
const expanded = expandHomePrefix(locationPath.trim());
if (!path.isAbsolute(expanded)) throw new Error(`project location must be an absolute path: ${locationPath}`);
await mkdir(expanded, { recursive: true });
return realpath(expanded);
}
export async function writeProjectManifest(projectDir: string, manifest: ProjectManifest): Promise<void> {
const file = manifestPath(projectDir);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify(manifest, null, 2), 'utf8');
}
export async function readProjectManifest(projectDir: string): Promise<ProjectManifest | null> {
try {
const raw = await readFile(manifestPath(projectDir), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const obj = parsed as Record<string, unknown>;
if (obj.schemaVersion !== 1) return null;
if (typeof obj.id !== 'string' || !isSafeId(obj.id)) return null;
if (typeof obj.name !== 'string' || !obj.name.trim()) return null;
const createdAt = typeof obj.createdAt === 'number' && Number.isFinite(obj.createdAt) ? obj.createdAt : Date.now();
const updatedAt = typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) ? obj.updatedAt : createdAt;
return {
schemaVersion: 1,
id: obj.id,
name: obj.name.trim(),
createdAt,
updatedAt,
skillId: typeof obj.skillId === 'string' ? obj.skillId : null,
designSystemId: typeof obj.designSystemId === 'string' ? obj.designSystemId : null,
};
} catch (err: unknown) {
const e = err as { code?: string; name?: string };
if (e.code === 'ENOENT' || e.name === 'SyntaxError') return null;
throw err;
}
}
export async function scanProjectLocation(location: ProjectLocation): Promise<Array<{ dir: string; manifest: ProjectManifest }>> {
const entries = await readdir(location.path, { withFileTypes: true });
const found: Array<{ dir: string; manifest: ProjectManifest }> = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
let dir: string;
try {
dir = await canonicalLocationChildDir(location, entry.name);
} catch {
continue;
}
const manifest = await readProjectManifest(dir);
if (manifest) found.push({ dir, manifest });
}
return found;
}

View file

@ -1,4 +1,5 @@
import type { Express } from 'express';
import { rm } from 'node:fs/promises';
import path from 'node:path';
import {
defaultScenarioPluginIdForProjectMetadata,
@ -18,9 +19,18 @@ import {
import { connectorService } from './connectors/service.js';
import type { RouteDeps } from './server-context.js';
import { listSkills } from './skills.js';
import { isSafeId } from './projects.js';
import {
BUILT_IN_PROJECT_LOCATION_ID,
allProjectLocations,
createLocationProjectDir,
ensureProjectLocation,
scanProjectLocation,
writeProjectManifest,
} from './project-locations.js';
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {}
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {}
function projectDetailResolvedDir(
projectsRoot: string,
@ -145,6 +155,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths;
const { readAppConfig, writeAppConfig } = ctx.appConfig;
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
@ -202,8 +213,199 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
return Array.from(byTaskKind.values());
}
app.get('/api/projects', (_req, res) => {
async function configuredProjectLocations() {
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
const all = allProjectLocations(PROJECTS_DIR, config.projectLocations);
const valid = all[0] ? [all[0]] : [];
for (const location of all.slice(1)) {
const validated = validateLinkedDirs([location.path]);
if (validated.error) continue;
const canonical = validated.dirs[0];
if (!canonical) continue;
if (locationOverlapsDaemonData(canonical)) continue;
valid.push({ ...location, path: canonical });
}
return valid;
}
function locationOverlapsDaemonData(locationPath: string): boolean {
const runtimeDir = ctx.paths.RUNTIME_DATA_DIR_CANONICAL || ctx.paths.RUNTIME_DATA_DIR;
const projectsDir = path.join(runtimeDir, 'projects');
const relativeToRuntime = pathRelative(runtimeDir, locationPath);
const runtimeInsideLocation = pathRelative(locationPath, runtimeDir);
const relativeToProjects = pathRelative(projectsDir, locationPath);
const projectsInsideLocation = pathRelative(locationPath, projectsDir);
return isInsideOrSame(relativeToRuntime) || isInsideOrSame(runtimeInsideLocation)
|| isInsideOrSame(relativeToProjects) || isInsideOrSame(projectsInsideLocation);
}
function pathRelative(from: string, to: string): string {
return path.relative(from, to);
}
function isInsideOrSame(relative: string): boolean {
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function projectBelongsToLocation(project: any, location: { id: string; path: string }): boolean {
const metadata = project?.metadata;
if (typeof metadata?.baseDir !== 'string') return metadata?.projectLocationId === location.id;
const relative = path.relative(location.path, metadata.baseDir);
return isInsideOrSame(relative) && relative !== '';
}
function isProjectLocationProject(project: any): boolean {
const metadata = project?.metadata;
return metadata?.importedFrom === 'project-location'
|| typeof metadata?.projectLocationId === 'string';
}
function projectVisibleForLocations(
project: any,
locations: Array<{ id: string; path: string; builtIn?: boolean }>,
): boolean {
if (!isProjectLocationProject(project)) return true;
return locations.some((location) => !location.builtIn && projectBelongsToLocation(project, location));
}
async function resolveCreateProjectLocationId(explicitProjectLocationId: unknown): Promise<string> {
if (typeof explicitProjectLocationId === 'string' && explicitProjectLocationId.trim()) {
return explicitProjectLocationId.trim();
}
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
const configuredDefault = typeof config.defaultProjectLocationId === 'string'
? config.defaultProjectLocationId.trim()
: '';
if (!configuredDefault || configuredDefault === BUILT_IN_PROJECT_LOCATION_ID) {
return BUILT_IN_PROJECT_LOCATION_ID;
}
const locations = await configuredProjectLocations();
return locations.some((location) => !location.builtIn && location.id === configuredDefault)
? configuredDefault
: BUILT_IN_PROJECT_LOCATION_ID;
}
function unregisterProjectsForRemovedLocations(
previousLocations: Array<{ id: string; path: string; builtIn?: boolean }>,
nextLocations: Array<{ id?: string; path: string }>,
): string[] {
const nextIds = new Set(nextLocations.map((location) => location.id).filter(Boolean));
const nextPaths = new Set(nextLocations.map((location) => location.path));
const removed = previousLocations.filter(
(location) => !location.builtIn && !nextIds.has(location.id) && !nextPaths.has(location.path),
);
if (removed.length === 0) return [];
return listProjects(db)
.filter((project: any) => removed.some((location) => projectBelongsToLocation(project, location)))
.map((project: any) => project.id);
}
app.get('/api/project-locations', async (_req, res) => {
try {
const locations = await configuredProjectLocations();
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
const body = { locations };
res.json(body);
} catch (err: any) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
app.put('/api/project-locations', async (req, res) => {
try {
const requested = Array.isArray(req.body?.locations) ? req.body.locations : null;
if (!requested) return sendApiError(res, 400, 'BAD_REQUEST', 'locations must be an array');
const previousLocations = await configuredProjectLocations();
const prepared = [];
for (const loc of requested) {
if (!loc || typeof loc !== 'object' || typeof loc.path !== 'string') continue;
const canonicalPath = await ensureProjectLocation(loc.path);
const validated = validateLinkedDirs([canonicalPath]);
if (validated.error) return sendApiError(res, 400, 'BAD_REQUEST', validated.error);
if (locationOverlapsDaemonData(canonicalPath)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'project location cannot overlap daemon data');
}
prepared.push({
id: typeof loc.id === 'string' ? loc.id : undefined,
name: typeof loc.name === 'string' ? loc.name : undefined,
path: canonicalPath,
});
}
const config = await writeAppConfig(ctx.paths.RUNTIME_DATA_DIR, { projectLocations: prepared });
const locations = allProjectLocations(PROJECTS_DIR, config.projectLocations);
const removedProjectIds = unregisterProjectsForRemovedLocations(previousLocations, config.projectLocations ?? []);
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
const body = { locations, removedProjectIds };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.post('/api/project-locations/scan', async (_req, res) => {
try {
const locations = (await configuredProjectLocations()).filter((loc: any) => !loc.builtIn);
const imported = [];
const existing: string[] = [];
const skipped: Array<{ path: string; reason: string }> = [];
let scanned = 0;
const now = Date.now();
for (const location of locations) {
let found;
try {
found = await scanProjectLocation(location);
} catch (err: any) {
skipped.push({ path: location.path, reason: String(err?.message ?? err) });
continue;
}
scanned += found.length;
for (const entry of found) {
const { manifest } = entry;
if (getProject(db, manifest.id)) {
existing.push(manifest.id);
continue;
}
try {
const project = insertProject(db, {
id: manifest.id,
name: manifest.name,
skillId: manifest.skillId ?? null,
designSystemId: manifest.designSystemId ?? null,
pendingPrompt: null,
metadata: {
kind: 'prototype',
baseDir: entry.dir,
importedFrom: 'project-location',
projectLocationId: location.id,
},
customInstructions: null,
createdAt: manifest.createdAt,
updatedAt: manifest.updatedAt,
});
insertConversation(db, {
id: randomId(),
projectId: manifest.id,
title: null,
createdAt: now,
updatedAt: now,
});
if (project) imported.push(project);
} catch (err: any) {
skipped.push({ path: entry.dir, reason: String(err?.message ?? err) });
}
}
}
/** @type {import('@open-design/contracts').ScanProjectLocationsResponse} */
const body = { scanned, imported, existing, skipped };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/projects', async (_req, res) => {
try {
const locations = await configuredProjectLocations();
const latestRunStatuses = listLatestProjectRunStatuses(db);
const awaitingInputProjects = listProjectsAwaitingInput(db);
const activeRunStatuses = new Map();
@ -224,15 +426,17 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
}
/** @type {import('@open-design/contracts').ProjectsResponse} */
const body = {
projects: listProjects(db).map((project: any) => ({
...project,
status: composeProjectDisplayStatus(
activeRunStatuses.get(project.id) ??
latestRunStatuses.get(project.id) ?? { value: 'not_started' },
awaitingInputProjects,
project.id,
),
})),
projects: listProjects(db)
.filter((project: any) => projectVisibleForLocations(project, locations))
.map((project: any) => ({
...project,
status: composeProjectDisplayStatus(
activeRunStatuses.get(project.id) ??
latestRunStatuses.get(project.id) ?? { value: 'not_started' },
awaitingInputProjects,
project.id,
),
})),
};
res.json(body);
} catch (err: any) {
@ -250,9 +454,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
app.post('/api/projects', async (req, res) => {
try {
const { id, name, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
req.body || {};
if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) {
if (typeof id !== 'string' || !isSafeId(id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
if (typeof name !== 'string' || !name.trim()) {
@ -306,11 +510,30 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
}
const normalizedSkillId = skillValidation.id;
const selectedLocationId = await resolveCreateProjectLocationId(projectLocationId);
let externalProjectDir: string | null = null;
if (selectedLocationId !== BUILT_IN_PROJECT_LOCATION_ID) {
const location = (await configuredProjectLocations()).find((loc: any) => loc.id === selectedLocationId);
if (!location || location.builtIn) {
return sendApiError(res, 400, 'BAD_REQUEST', 'unknown project location');
}
if (getProject(db, id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'project id already exists');
}
externalProjectDir = await createLocationProjectDir(location, id);
}
const projectMetadata =
metadata && typeof metadata === 'object'
? {
...metadata,
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
...(externalProjectDir
? {
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: {}),
...(Array.isArray(metadata.linkedDirs)
? (() => {
const v = validateLinkedDirs(metadata.linkedDirs);
@ -319,23 +542,58 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
: {}),
}
: skipDiscoveryBrief === true
? { skipDiscoveryBrief: true }
: null;
? {
skipDiscoveryBrief: true,
...(externalProjectDir
? {
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: {}),
}
: externalProjectDir
? {
kind: 'prototype',
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: null;
const now = Date.now();
const project = insertProject(db, {
id,
name: name.trim(),
skillId: normalizedSkillId,
designSystemId: normalizedDesignSystemId,
pendingPrompt: pendingPrompt || null,
metadata: projectMetadata,
customInstructions:
typeof customInstructions === 'string'
? customInstructions
: null,
createdAt: now,
updatedAt: now,
});
let project;
try {
if (externalProjectDir) {
await writeProjectManifest(externalProjectDir, {
schemaVersion: 1,
id,
name: name.trim(),
createdAt: now,
updatedAt: now,
skillId: normalizedSkillId,
designSystemId: normalizedDesignSystemId,
});
}
project = insertProject(db, {
id,
name: name.trim(),
skillId: normalizedSkillId,
designSystemId: normalizedDesignSystemId,
pendingPrompt: pendingPrompt || null,
metadata: projectMetadata,
customInstructions:
typeof customInstructions === 'string'
? customInstructions
: null,
createdAt: now,
updatedAt: now,
});
} catch (err) {
if (externalProjectDir) {
await rm(externalProjectDir, { recursive: true, force: true }).catch(() => {});
}
throw err;
}
// Seed a default conversation so the UI always has somewhere to write.
const cid = randomId();
insertConversation(db, {
@ -345,7 +603,6 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
createdAt: now,
updatedAt: now,
});
const explicitPlugin =
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
? true
@ -398,7 +655,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
) {
const tpl = getTemplate(db, metadata.templateId);
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
await ensureProject(PROJECTS_DIR, id);
await ensureProject(PROJECTS_DIR, id, projectMetadata);
for (const f of tpl.files) {
if (
!f ||
@ -413,6 +670,8 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
id,
f.name,
Buffer.from(f.content, 'utf8'),
{},
projectMetadata,
);
} catch {
// Skip individual file failures — the template snapshot is
@ -435,9 +694,10 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
}
});
app.get('/api/projects/:id', (req, res) => {
app.get('/api/projects/:id', async (req, res) => {
const project = getProject(db, req.params.id);
if (!project)
const locations = await configuredProjectLocations();
if (!project || !projectVisibleForLocations(project, locations))
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
/** @type {import('@open-design/contracts').ProjectResponse} */
@ -484,6 +744,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
...(existingMeta.importedFrom === 'folder'
? { importedFrom: 'folder' }
: {}),
...(existingMeta.importedFrom === 'project-location'
? { importedFrom: 'project-location' }
: {}),
...(typeof existingMeta.projectLocationId === 'string'
? { projectLocationId: existingMeta.projectLocationId }
: {}),
...(existingMeta.fromTrustedPicker === true
? { fromTrustedPicker: true as const }
: {}),

View file

@ -243,16 +243,18 @@ reported that exact condition. One failed dispatcher call is enough to
report the error; do not fan out into alternate execution paths inside
the same turn.
### Long-running renders (Volcengine i2v, hyperframes-html): generate wait loop
### All slow renders: generate wait loop
\`media generate\` no longer blocks for the full render. It dispatches
the task daemon-side and either returns the finished \`{"file":{...}}\`
or returns a successful queued/running handoff with \`{taskId}\`. You then
drive the render to completion by calling \`media wait <taskId>\` through \`OD_NODE_BIN\` + \`OD_BIN\` in
a loop each call long-polls the daemon for up to 25s, well below your
shell tool's default 30s timeout. \`media generate\` treats the handoff as
exit \`0\` so the first dispatch does not look like a failed shell call.
The wait subcommand exits with a distinct code per outcome:
Any model whose generation takes longer than ~25s including **fal flux-pro-ultra,
fal Veo, fal Sora, Volcengine i2v, hyperframes-html, and anything else with a
multi-minute pipeline** will not complete within the initial \`media generate\` call.
\`media generate\` dispatches the task daemon-side and polls for up to ~25s. It
always exits 0 either with \`{"file":{...}}\` if the render finished within that
window, or with \`{"taskId":"..."}\` as a handoff signal. You then drive the render
to completion by calling \`media wait <taskId>\` through \`OD_NODE_BIN\` + \`OD_BIN\`
in a loop each call long-polls the daemon for up to 120s. The wait subcommand
exits with a distinct code per outcome:
- \`exit 0\` — terminal **done**. Final stdout line is \`{"file":{...}}\`.
- \`exit 5\` — terminal **failed**. Stderr carries the upstream error.
@ -262,33 +264,43 @@ The wait subcommand exits with a distinct code per outcome:
off (\`--since\` skips already-seen progress lines so you don't see the
same chatter twice).
The pattern in your shell tool:
The pattern in your shell tool (uses python3 to parse JSON do NOT use jq, it
may not be installed):
\`\`\`bash
out=$("$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model --image )
ec=$?
if [ "$ec" -ne 0 ]; then
echo "$out" >&2; exit "$ec"
out=\$("$OD_NODE_BIN" "$OD_BIN" media generate --surface image --model flux-pro-ultra --prompt "…")
ec=\$?
if [ "\$ec" -ne 0 ]; then
echo "\$out" >&2; exit "\$ec"
fi
task_id=$(printf '%s\\n' "$out" | tail -1 | jq -r '.taskId // empty')
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // 0')
while [ -n "$task_id" ]; do
out=$("$OD_NODE_BIN" "$OD_BIN" media wait "$task_id" --since "$since")
ec=$?
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // '"$since")
if [ "$ec" -eq 0 ]; then
last=\$(printf '%s\\n' "\$out" | tail -1)
task_id=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('taskId',''))" 2>/dev/null)
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',0))" 2>/dev/null)
since="\${since:-0}"
while [ -n "\$task_id" ]; do
out=\$("$OD_NODE_BIN" "$OD_BIN" media wait "\$task_id" --since "\$since")
ec=\$?
last=\$(printf '%s\\n' "\$out" | tail -1)
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',\$since))" 2>/dev/null)
since="\${since:-0}"
if [ "\$ec" -eq 0 ]; then
task_id=""
elif [ "$ec" -ne 2 ]; then
echo "$out" >&2; exit "$ec"
elif [ "\$ec" -ne 2 ]; then
echo "\$out" >&2; exit "\$ec"
fi
done
# At this point ec is 0 (done). Final result on the last stdout line of \`out\`.
# At this point ec is 0 (done) or 5 (failed). Final result on the last stdout line of \$out.
printf '%s\\n' "\$last"
\`\`\`
Each \`generate\` and \`wait\` call lasts at most ~25s, so the agent
shell tool's default ~30s cap never fires. Progress lines stream to
stderr as they arrive, so the user sees live status in chat throughout
the loop instead of waiting silently for a single multi-minute call.
Each \`generate\` call lasts at most ~25s and each \`wait\` call at most ~120s,
both well within your shell tool's timeout. Progress lines stream to stderr as
they arrive, so the user sees live status in chat throughout the loop instead of
waiting silently for a single multi-minute call.
**Always write your shell invocation as the full generate+wait loop above**, even
for image models. \`flux-pro-ultra\` routinely takes 60180s; \`sora-2\` and
\`veo-3-fal\` take longer. In the wait loop, exit 2 means "keep polling, not an error."
A note on \`fetch failed\` to \`127.0.0.1\`. The OD daemon runs on
loopback in the same machine that spawned you, so it is essentially
@ -318,10 +330,19 @@ showed it crashed).
- **audio · speech**: ${AUDIO_SPEECH_IDS}
- **audio · sfx**: ${AUDIO_SFX_IDS}
If the user requests a model that is not in this list, surface a warning
in your reply and either (a) ask them to pick a registered ID or (b)
proceed with the project metadata's default model and explain the
substitution. Do not silently fall back.
If the user requests a model that is not in this list **and** the ID does
not start with \`fal-ai/\`, surface a warning in your reply and either
(a) ask them to pick a registered ID or (b) proceed with the project
metadata's default model and explain the substitution. Do not silently
fall back.
Exception **fal-ai/\* custom paths**: any model ID that begins with
\`fal-ai/\` (e.g. \`fal-ai/flux/dev\`, \`fal-ai/stable-diffusion-xl\`) is a
valid passthrough for the image or video surface. Pass it to
\`"$OD_NODE_BIN" "$OD_BIN" media generate\` as-is via \`--model <id>\`;
the daemon routes it directly to the fal queue without a catalog entry.
Do **not** warn the user or substitute the default when a \`fal-ai/\`
path is given.
### Workflow rules
@ -344,22 +365,47 @@ substitution. Do not silently fall back.
SFX duration is capped at 30 seconds by the provider.
\`language\` enables pronunciation boost for specific languages
(e.g. \`Chinese,Yue\` for Cantonese, \`Chinese\` for Mandarin).
2. **One discovery turn before generating.** Even with metadata defaults
present, restate what you're about to make and ask one targeted
question if anything is ambiguous (subject, mood, brand, voice). The
discovery rules from the philosophy layer still apply emit a
question form on turn 1 unless the user's prompt already pins every
variable.
2. **Dispatch immediately when the brief is complete.** For image and video
projects, if the user's prompt specifies the subject, style/mood, and setting,
**dispatch without a discovery question turn**. Do not ask about model or aspect
ratio when reasonable defaults exist use them and start generating.
Default model selection (use these when \`imageModel\`/\`videoModel\` is unknown
or the user asks for "best"):
- **Image, best quality (user says "best", "highest quality", "most realistic")**:
use \`flux-pro-ultra\` — but tell the user it takes 60180s
- **Image, default / no preference stated**: use the project metadata's
\`imageModel\` if set; otherwise use \`gpt-image-2\`
- **Video, best quality**: use project metadata \`videoModel\` if set; otherwise
\`doubao-seedance-2-0-260128\`
Default aspect ratio (use when \`aspectRatio\` is unknown):
- Landscape/outdoor scenes, cinematic, widescreen \`16:9\`
- Portrait, vertical social \`9:16\`
- Product, abstract, square social \`1:1\`
- General default when no cue \`1:1\`
**Skip the discovery question when all of these are true:**
- The subject is described (what to generate)
- The style or mood is implied or stated (realistic, cinematic, illustrated, etc.)
- Any model/aspect gaps can be filled with the defaults above
**Do ask** if the output intent is genuinely ambiguous (e.g. "make something cool"
with no subject), or the user explicitly requests a model/voice the project
metadata doesn't carry.
For \`hyperframes-html\`, the discovery turn is the last turn before
you start authoring. Once the user answers, write the composition
files into \`.hyperframes-cache/\` and run \`npx hyperframes render\`
immediately do not add a second "plan" or "environment check"
message first, and do not call \`"$OD_NODE_BIN" "$OD_BIN" media generate\` (that path is
intentionally rejected for this model).
3. **Generate by shell, narrate in chat.** When you actually invoke
\`"$OD_NODE_BIN" "$OD_BIN" media generate\`, do it inside a clearly-labelled tool call. After
it returns, write a short reply: what was produced, the filename,
and any notes (model substitutions, retries, follow-up suggestions).
3. **Generate by shell, reply in one short message.** When you invoke
\`"$OD_NODE_BIN" "$OD_BIN" media generate\`, do it inside a clearly-labelled tool call.
After the command completes, reply with **one brief message** (23 sentences max):
the filename, the model used, and a single follow-up offer ("Want a different
aspect ratio?" / "Try again with more fog?"). Do not write long descriptions,
artistic analyses, or multi-paragraph commentary. Speed matters.
If it fails, quote the real stderr / exit code and stop there.
Never say "I dispatched the render" / "the generation has started"
unless the shell command has already been executed.

View file

@ -222,6 +222,62 @@ export const SKIP_DISCOVERY_BRIEF_OVERRIDE = `# Automated project mode — skip
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Treat the user's first message and project metadata as the brief, then proceed directly to planning/building under the normal artifact workflow. Ask at most one concise follow-up only if a required detail is impossible to infer safely.`;
// Injected into non-media projects so the agent knows how to dispatch
// media generation if the user asks for it mid-session (e.g. "generate an
// image with fal"). Without this, agents in prototype/deck projects try to
// call provider REST APIs directly and ask the user for keys that the daemon
// already holds in .od/media-config.json.
const MEDIA_DISPATCH_HINT = `
---
## Media generation (if asked)
If the user asks you to generate an image, video, or audio file regardless of which provider or model they mention (fal, Replicate, OpenAI, etc.) use the daemon dispatcher via your **Bash tool**. Do NOT call provider REST APIs directly.
The daemon injects these env vars into your shell (**POSIX bash not PowerShell**):
- \`OD_NODE_BIN\` — absolute path to the Node runtime
- \`OD_BIN\` — absolute path to the OD CLI script
- \`OD_PROJECT_ID\` — the active project id
**Always use the generatewait loop below.** \`media generate\` always exits 0 — either with \`{"file":{...}}\` if done within ~25s, or with \`{"taskId":"..."}\` as a handoff for slow models (flux-pro-ultra ~60180s, veo-3-fal longer). Whenever the output contains a \`taskId\`, keep polling with \`media wait\` until exit 0 (done) or exit 5 (failed).
Use **POSIX \`$VAR\` syntax** — do NOT translate to PowerShell (\`$env:VAR\`, \`&\` operator). Uses \`python3\` for JSON parsing (do NOT use \`jq\`):
\`\`\`bash
# POSIX bash do NOT convert to PowerShell
out=\$("$OD_NODE_BIN" "$OD_BIN" media generate \\
--project "$OD_PROJECT_ID" \\
--surface image \\
--model flux-pro-ultra \\
--prompt "..." \\
--aspect 16:9)
ec=\$?
if [ "\$ec" -ne 0 ]; then echo "\$out" >&2; exit "\$ec"; fi
last=\$(printf '%s\\n' "\$out" | tail -1)
task_id=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('taskId',''))" 2>/dev/null)
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',0))" 2>/dev/null)
since="\${since:-0}"
while [ -n "\$task_id" ]; do
out=\$("$OD_NODE_BIN" "$OD_BIN" media wait "\$task_id" --since "\$since")
ec=\$?
last=\$(printf '%s\\n' "\$out" | tail -1)
since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',\$since))" 2>/dev/null)
since="\${since:-0}"
if [ "\$ec" -eq 0 ]; then
task_id=""
elif [ "\$ec" -ne 2 ]; then
echo "\$out" >&2; exit "\$ec"
fi
done
printf '%s\\n' "\$last"
\`\`\`
**Never ask the user for an API key.** The daemon reads provider credentials from its config; keys are never passed through the shell. If the provider returns an auth error, tell the user to open Settings AI Providers and confirm the key is configured there.
For the best fal image model use \`--model flux-pro-ultra\`. For video use \`--model veo-3-fal\` or \`--model wan-2.1-t2v\`. Always pass \`--surface\` explicitly (\`image\`, \`video\`, or \`audio\`). Any \`fal-ai/*\` path (e.g. \`fal-ai/flux/schnell\`, \`fal-ai/wan-i2v\`) is also a valid \`--model\` value for image/video — pass it through as-is without substitution.`;
const ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE = `
---
@ -439,6 +495,21 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n');
}
// Skip the HTML-artifact discovery layer for media surfaces (image / video /
// audio). DISCOVERY_AND_PHILOSOPHY is ~3 000 tokens of rules about question
// forms, brand extraction, direction pickers, and HTML artifact checklist —
// none of which apply to media generation. Including it forces the agent to
// parse and override all of those rules before it can start, adding tokens
// and LLM inference time. The MEDIA_GENERATION_CONTRACT (pushed below) is
// the sole workflow authority for these surfaces.
const isMediaSurfaceEarly =
skillMode === 'image' ||
skillMode === 'video' ||
skillMode === 'audio' ||
metadata?.kind === 'image' ||
metadata?.kind === 'video' ||
metadata?.kind === 'audio';
if (metadata?.skipDiscoveryBrief === true) {
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
parts.push('\n\n---\n\n');
@ -450,9 +521,12 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n');
}
if (!isMediaSurfaceEarly) {
parts.push(DISCOVERY_AND_PHILOSOPHY, '\n\n---\n\n');
}
parts.push(
DISCOVERY_AND_PHILOSOPHY,
'\n\n---\n\n# Identity and workflow charter (background)\n\n',
'# Identity and workflow charter (background)\n\n',
BASE_SYSTEM_PROMPT,
);
@ -614,6 +688,11 @@ export function composeSystemPrompt({
|| resolvedExclusiveSurface === 'audio';
if (isMediaSurface) {
parts.push(renderMediaGenerationContract(mediaExecution));
} else {
// Non-media projects (prototype, deck, etc.): inject a lightweight hint
// so the agent uses `od media generate` if the user asks for an image/video
// mid-session, rather than hunting for provider API keys in the environment.
parts.push(MEDIA_DISPATCH_HINT);
}
if (includeCodexImagegenOverride && shouldAllowCodexImagegenOverride(metadata, mediaExecution)) {
@ -959,10 +1038,10 @@ function renderMetadataBlock(
}
if (metadata.kind === 'image') {
lines.push(
`- **imageModel**: ${metadata.imageModel ?? '(unknown — ask: which image model to use)'}`,
`- **imageModel**: ${metadata.imageModel ?? 'gpt-image-2 (default — override if the user asks for a specific model or provider)'}`,
);
lines.push(
`- **aspectRatio**: ${metadata.imageAspect ?? '(unknown — ask: 1:1, 16:9, 9:16, 4:3, 3:4)'}`,
`- **aspectRatio**: ${metadata.imageAspect ?? '1:1 (default — use 16:9 for landscape/outdoor scenes, 9:16 for portrait/vertical)'}`,
);
if (metadata.imageStyle) {
lines.push(`- **styleNotes**: ${metadata.imageStyle}`);

View file

@ -0,0 +1,185 @@
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp-config.js';
import type { RuntimeAgentDef } from './runtimes/types.js';
import { sanitizeMcpConfig, sanitizeMcpServer } from './mcp-config.js';
export interface RunToolBundle {
mcpServers: McpServerConfig[];
}
export interface RunToolBundleSummary {
mcpServers: Array<{
id: string;
label?: string;
templateId?: string;
transport: McpTransport;
enabled: boolean;
authMode?: McpAuthMode;
}>;
}
export interface ExternalMcpSelection {
enabledServers: McpServerConfig[];
persistedTokenServerIds: Set<string>;
}
export type RunToolBundleParseResult =
| { ok: true; bundle: RunToolBundle }
| { ok: false; message: string };
export type RunToolBundleValidationResult =
| { ok: true }
| { ok: false; message: string };
export type RunToolBundleDeliveryTarget =
| 'managed-project'
| 'external-project'
| 'none';
export interface RunToolBundleValidationOptions {
deliveryTarget?: RunToolBundleDeliveryTarget;
}
type RunToolBundleAgent = Pick<
RuntimeAgentDef,
'id' | 'name' | 'externalMcpInjection'
>;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function agentLabel(agent: RunToolBundleAgent): string {
return agent.name ? `${agent.name} (${agent.id})` : agent.id;
}
export function normalizeRunToolBundleForRun(raw: unknown): RunToolBundle {
if (!isPlainObject(raw)) return { mcpServers: [] };
return {
mcpServers: sanitizeMcpConfig({ servers: raw.mcpServers }).servers,
};
}
export function parseRunToolBundleForRequest(raw: unknown): RunToolBundleParseResult {
if (raw == null) return { ok: true, bundle: { mcpServers: [] } };
if (!isPlainObject(raw)) {
return { ok: false, message: 'toolBundle must be an object' };
}
if (raw.mcpServers == null) return { ok: true, bundle: { mcpServers: [] } };
if (!Array.isArray(raw.mcpServers)) {
return { ok: false, message: 'toolBundle.mcpServers must be an array' };
}
const seen = new Set<string>();
const servers: McpServerConfig[] = [];
for (const [index, entry] of raw.mcpServers.entries()) {
const server = sanitizeMcpServer(entry);
if (!server) {
return {
ok: false,
message: `toolBundle.mcpServers[${index}] is invalid`,
};
}
if (seen.has(server.id)) {
return {
ok: false,
message: `toolBundle.mcpServers[${index}] duplicates server id "${server.id}"`,
};
}
seen.add(server.id);
servers.push(server);
}
return { ok: true, bundle: { mcpServers: servers } };
}
export function summarizeRunToolBundle(bundle: RunToolBundle | null | undefined): RunToolBundleSummary {
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
return {
mcpServers: servers.map((server) => ({
id: server.id,
...(server.label ? { label: server.label } : {}),
...(server.templateId ? { templateId: server.templateId } : {}),
transport: server.transport,
enabled: server.enabled,
...(server.authMode ? { authMode: server.authMode } : {}),
})),
};
}
export function validateRunToolBundleForAgent(
bundle: RunToolBundle | null | undefined,
agent: RunToolBundleAgent | null | undefined,
options: RunToolBundleValidationOptions = {},
): RunToolBundleValidationResult {
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
const enabledServers = servers.filter((server) => server.enabled);
if (enabledServers.length === 0) return { ok: true };
if (!agent) {
return {
ok: false,
message: 'toolBundle requires a supported agentId',
};
}
if (agent.externalMcpInjection === 'claude-mcp-json') {
if (options.deliveryTarget && options.deliveryTarget !== 'managed-project') {
return {
ok: false,
message:
`${agentLabel(agent)} receives run-scoped MCP tool bundles through project .mcp.json, ` +
'so toolBundle requires a daemon-managed project',
};
}
return { ok: true };
}
if (agent.externalMcpInjection === 'opencode-env-content') {
return { ok: true };
}
if (agent.externalMcpInjection === 'acp-merge') {
const unsupported = servers.findIndex(
(server) => server.enabled && server.transport !== 'stdio',
);
if (unsupported === -1) return { ok: true };
return {
ok: false,
message:
`toolBundle.mcpServers[${unsupported}] uses ${servers[unsupported]?.transport} transport, ` +
`but ${agentLabel(agent)} only supports stdio run-scoped MCP servers`,
};
}
return {
ok: false,
message: `${agentLabel(agent)} does not support run-scoped MCP tool bundles`,
};
}
export function resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode,
}: {
persistedServers: McpServerConfig[];
runScopedServers: McpServerConfig[];
sandboxMode: boolean;
}): ExternalMcpSelection {
const runScopedIds = new Set(runScopedServers.map((server) => server.id));
const persistedForRun = sandboxMode ? [] : persistedServers;
const byId = new Map<string, McpServerConfig>();
for (const server of persistedForRun) byId.set(server.id, server);
for (const server of runScopedServers) byId.set(server.id, server);
const persistedTokenServerIds = new Set<string>();
for (const server of persistedForRun) {
if (!server.enabled) continue;
if (runScopedIds.has(server.id)) continue;
persistedTokenServerIds.add(server.id);
}
return {
enabledServers: Array.from(byId.values()).filter((server) => server.enabled),
persistedTokenServerIds,
};
}

View file

@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
import {
normalizeRunToolBundleForRun,
summarizeRunToolBundle,
} from './run-tool-bundle.js';
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
@ -57,6 +61,7 @@ export function createChatRunService({
pluginId:
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
toolBundle: normalizeRunToolBundleForRun(meta.toolBundle),
status: 'queued',
createdAt: now,
updatedAt: now,
@ -149,6 +154,7 @@ export function createChatRunService({
errorCode: run.errorCode ?? null,
eventsLogPath: run.eventsLogPath ?? null,
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
toolBundle: summarizeRunToolBundle(run.toolBundle),
});
const finish = (run, status, code: number | null = null, signal: string | null = null) => {

View file

@ -151,6 +151,8 @@ async function probe(
...(def.env || {}),
},
configuredEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch,
);

View file

@ -15,6 +15,9 @@ import {
} from '../sandbox-mode.js';
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
type SpawnEnvOptions = {
resolvedBin?: string | null;
};
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
path.dirname(fileURLToPath(import.meta.url)),
@ -51,6 +54,7 @@ export function spawnEnvForAgent(
baseEnv: RuntimeEnvMap,
configuredEnv: unknown = {},
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
options: SpawnEnvOptions = {},
): NodeJS.ProcessEnv {
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
const env = mergeProxyAwareEnv(
@ -75,7 +79,9 @@ export function spawnEnvForAgent(
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'claude') {
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
if (!isOpenClaudeExecutable(options.resolvedBin)) {
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
}
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'codex') {
@ -88,6 +94,15 @@ export function spawnEnvForAgent(
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
}
function isOpenClaudeExecutable(resolvedBin: string | null | undefined): boolean {
if (typeof resolvedBin !== 'string' || !resolvedBin.trim()) return false;
const base = path
.basename(resolvedBin.trim().replace(/\\/g, '/'))
.replace(/\.(exe|cmd|bat)$/i, '')
.toLowerCase();
return base === 'openclaude';
}
function sandboxRuntimeConfigForBaseEnv(
baseEnv: RuntimeEnvMap,
): SandboxRuntimeConfig | null {

View file

@ -316,6 +316,11 @@ import {
readMcpConfig,
writeMcpConfig,
} from './mcp-config.js';
import {
parseRunToolBundleForRequest,
resolveExternalMcpServersForRun,
validateRunToolBundleForAgent,
} from './run-tool-bundle.js';
import {
beginAuth,
exchangeCodeForToken,
@ -5728,6 +5733,7 @@ export async function startServer({
events: projectEventDeps,
ids: idDeps,
telemetry: { reportFinalizedMessage },
appConfig: appConfigDeps,
validation: validationDeps,
});
registerImportRoutes(app, {
@ -10775,8 +10781,8 @@ export async function startServer({
// doesn't exist yet). Without one we don't pass cwd to spawn — the
// agent then runs in whatever inherited dir, which still lets API
// mode work but loses file-tool addressability.
// For git-linked projects (metadata.baseDir), use that folder directly
// so the agent writes back to the user's original source tree.
// Project directory resolution lives in projects.ts so sandbox mode can
// consistently reject imported-folder metadata that has no managed copy.
let cwd = null;
let existingProjectFiles = [];
if (typeof projectId === 'string' && projectId) {
@ -10901,57 +10907,71 @@ export async function startServer({
// values further down at .mcp.json write time — see the spawn block
// below — instead of re-reading.
let externalMcpConfig = { servers: [] };
try {
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
} catch (err) {
console.warn(
'[mcp-config] read failed:',
err && err.message ? err.message : err,
);
if (!SANDBOX_RUNTIME.enabled) {
try {
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
} catch (err) {
console.warn(
'[mcp-config] read failed:',
err && err.message ? err.message : err,
);
}
}
const enabledExternalMcp = externalMcpConfig.servers.filter((s) => s.enabled);
const runScopedMcpServers = Array.isArray(run?.toolBundle?.mcpServers)
? run.toolBundle.mcpServers
: [];
const {
enabledServers: enabledExternalMcp,
persistedTokenServerIds,
} = resolveExternalMcpServersForRun({
persistedServers: externalMcpConfig.servers,
runScopedServers: runScopedMcpServers,
sandboxMode: SANDBOX_RUNTIME.enabled,
});
const oauthTokensForSpawn = {};
try {
const stored = await readAllTokens(RUNTIME_DATA_DIR);
for (const [serverId, tok] of Object.entries(stored)) {
if (!enabledExternalMcp.find((s) => s.id === serverId)) continue;
// Default to the persisted access token; null it out if expired so
// we never inject a stale `Authorization: Bearer …` header. The
// model treats a server with a Bearer pinned as connected and
// discourages re-auth, which is the worst possible UX when the
// token is going to 401 every call.
let access = isTokenExpired(tok) ? null : tok.accessToken;
if (isTokenExpired(tok) && tok.refreshToken) {
try {
const refreshed = await refreshAndPersistToken(
RUNTIME_DATA_DIR,
serverId,
tok,
);
if (refreshed) access = refreshed.accessToken;
} catch (err) {
if (persistedTokenServerIds.size > 0) {
try {
const stored = await readAllTokens(RUNTIME_DATA_DIR);
for (const [serverId, tok] of Object.entries(stored)) {
if (!persistedTokenServerIds.has(serverId)) continue;
// Default to the persisted access token; null it out if expired so
// we never inject a stale `Authorization: Bearer …` header. The
// model treats a server with a Bearer pinned as connected and
// discourages re-auth, which is the worst possible UX when the
// token is going to 401 every call.
let access = isTokenExpired(tok) ? null : tok.accessToken;
if (isTokenExpired(tok) && tok.refreshToken) {
try {
const refreshed = await refreshAndPersistToken(
RUNTIME_DATA_DIR,
serverId,
tok,
);
if (refreshed) access = refreshed.accessToken;
} catch (err) {
console.warn(
'[mcp-oauth] refresh failed for',
serverId,
err && err.message ? err.message : err,
);
}
}
if (access) {
oauthTokensForSpawn[serverId] = access;
} else {
console.warn(
'[mcp-oauth] refresh failed for',
'[mcp-oauth] skipping expired token for',
serverId,
err && err.message ? err.message : err,
'— reconnect required',
);
}
}
if (access) {
oauthTokensForSpawn[serverId] = access;
} else {
console.warn(
'[mcp-oauth] skipping expired token for',
serverId,
'— reconnect required',
);
}
} catch (err) {
console.warn(
'[mcp-tokens] read failed:',
err && err.message ? err.message : err,
);
}
} catch (err) {
console.warn(
'[mcp-tokens] read failed:',
err && err.message ? err.message : err,
);
}
const connectedExternalMcp = enabledExternalMcp
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
@ -11315,6 +11335,8 @@ export async function startServer({
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
),
agentLaunch,
)
@ -11697,6 +11719,8 @@ export async function startServer({
...(def.env || {}),
},
configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
);
if (def.id === 'amr') {
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
@ -12650,6 +12674,7 @@ export async function startServer({
stderrTail: agentStderrTail,
stdoutTail: agentStdoutTail,
env: spawnedAgentEnv,
resolvedBin: agentLaunch.selectedPath,
});
// A non-zero exit whose output reads as an auth / quota / upstream
// problem (typical of Claude Code, codex, …) gets the specific code
@ -12995,14 +13020,33 @@ export async function startServer({
};
});
function runToolBundleDeliveryTargetForProject(projectId, metadata) {
if (typeof projectId !== 'string' || !projectId || !isSafeId(projectId)) {
return 'none';
}
try {
const cwd = resolveProjectDir(PROJECTS_DIR, projectId, metadata, {
allowUnavailableSandboxImportedProject: true,
});
return isManagedProjectCwd(cwd, PROJECTS_DIR) ? 'managed-project' : 'external-project';
} catch {
return 'none';
}
}
app.post('/api/runs', async (req, res) => {
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
if (!toolBundle.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
}
// Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
// before the run is created. The resolver returns null when the body
// does not mention a plugin (legacy runs unchanged), an error envelope
@ -13018,7 +13062,7 @@ export async function startServer({
// bundled scenario that is not installed leaves the run plugin-less,
// which matches the legacy path.
let resolvedSnapshot = null;
if (typeof req.body?.projectId === 'string' && req.body.projectId) {
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
let registryView;
try {
registryView = await loadPluginRegistryView();
@ -13026,26 +13070,26 @@ export async function startServer({
return res.status(500).json({ error: String(err) });
}
const explicitPlugin =
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId);
let runResolveBody = req.body;
requestBody.pluginId || requestBody.appliedPluginSnapshotId;
let runResolveBody = requestBody;
if (!explicitPlugin) {
const projectRow = getProject(db, req.body.projectId);
const projectRow = getProject(db, requestBody.projectId);
const hasPin =
typeof projectRow?.appliedPluginSnapshotId === 'string'
&& projectRow.appliedPluginSnapshotId.length > 0;
if (!hasPin) {
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
runResolveBody = { ...req.body, pluginId: fallbackPluginId };
runResolveBody = { ...requestBody, pluginId: fallbackPluginId };
}
}
}
const resolved = resolvePluginSnapshot({
db,
body: runResolveBody,
projectId: req.body.projectId,
conversationId: typeof req.body.conversationId === 'string'
? req.body.conversationId
projectId: requestBody.projectId,
conversationId: typeof requestBody.conversationId === 'string'
? requestBody.conversationId
: null,
registry: registryView,
connectorProbe: buildConnectorProbe(connectorService),
@ -13053,7 +13097,7 @@ export async function startServer({
if (resolved && !resolved.ok) {
if (!explicitPlugin) {
console.warn(
`[plugins] default-scenario fallback skipped for run on project ${req.body.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
`[plugins] default-scenario fallback skipped for run on project ${requestBody.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
);
} else {
return res.status(resolved.status).json(resolved.body);
@ -13062,7 +13106,11 @@ export async function startServer({
resolvedSnapshot = resolved;
}
}
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
const meta = {
...requestBody,
mediaExecution: mediaExecution.policy,
toolBundle: toolBundle.bundle,
};
if (resolvedSnapshot?.ok) {
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
@ -13074,6 +13122,53 @@ export async function startServer({
if (renderedQuery.length > 0) meta.message = renderedQuery;
}
}
let runProject = null;
if (typeof meta.projectId === 'string' && meta.projectId) {
try {
runProject = getProject(db, meta.projectId);
assertSandboxProjectRootAvailable(runProject?.metadata);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
throw err;
}
}
// MCP / SDK callers may omit agentId. Resolve it before any run-create
// side effects so unsupported run-scoped tool bundles can fail cleanly.
if (typeof meta.agentId !== 'string' || !meta.agentId) {
try {
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
? appCfg.agentId
: null;
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const cfgAgentAvailable = cfgAgent
? agents.some((agent) => agent.id === cfgAgent && agent.available)
: false;
if (cfgAgent && cfgAgentAvailable) {
meta.agentId = cfgAgent;
} else {
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
if (firstAvailable) meta.agentId = firstAvailable;
}
} catch (err) {
console.warn('[runs] agent id fallback failed', err);
}
}
const toolBundleSupport = validateRunToolBundleForAgent(
toolBundle.bundle,
typeof meta.agentId === 'string' ? getAgentDef(meta.agentId) : null,
{
deliveryTarget: runToolBundleDeliveryTargetForProject(
meta.projectId,
runProject?.metadata,
),
},
);
if (!toolBundleSupport.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
}
// MCP / SDK callers POST /api/runs with just a projectId — no
// conversationId, no pre-created assistantMessageId — because they
// don't know about OD's chat-row lifecycle. The web flow
@ -13133,30 +13228,6 @@ export async function startServer({
console.warn('[runs] mcp conversation fallback failed', err);
}
}
// MCP / SDK callers may omit agentId. Resolve it from the saved
// app-config agent (the user's configured default) or the first
// available CLI so the run does not immediately fail with
// "unknown agent: undefined" inside startChatRun.
if (typeof meta.agentId !== 'string' || !meta.agentId) {
try {
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
? appCfg.agentId
: null;
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const cfgAgentAvailable = cfgAgent
? agents.some((agent) => agent.id === cfgAgent && agent.available)
: false;
if (cfgAgent && cfgAgentAvailable) {
meta.agentId = cfgAgent;
} else {
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
if (firstAvailable) meta.agentId = firstAvailable;
}
} catch (err) {
console.warn('[runs] agent id fallback failed', err);
}
}
const run = design.runs.create(meta);
try {
pinAssistantMessageOnRunCreate(db, run);
@ -13571,11 +13642,45 @@ export async function startServer({
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
if (!toolBundle.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
}
let chatProject = null;
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
try {
chatProject = getProject(db, requestBody.projectId);
assertSandboxProjectRootAvailable(chatProject?.metadata);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
throw err;
}
}
const toolBundleSupport = validateRunToolBundleForAgent(
toolBundle.bundle,
typeof requestBody.agentId === 'string' ? getAgentDef(requestBody.agentId) : null,
{
deliveryTarget: runToolBundleDeliveryTargetForProject(
requestBody.projectId,
chatProject?.metadata,
),
},
);
if (!toolBundleSupport.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
}
const meta = {
...requestBody,
mediaExecution: mediaExecution.policy,
toolBundle: toolBundle.bundle,
};
const run = design.runs.create(meta);
design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(meta, run));
@ -13652,6 +13757,7 @@ export async function startServer({
if (routine.target.mode === 'reuse') {
const project = getProject(db, routine.target.projectId);
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
assertSandboxProjectRootAvailable(project.metadata);
projectId = project.id;
projectName = project.name;
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;

View file

@ -1,6 +1,6 @@
import http from 'node:http';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import path from 'node:path';
import express from 'express';
import {
@ -623,6 +623,187 @@ describe('app-config telemetry prefs', () => {
});
});
describe('app-config projectLocations', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-projectLocations-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('persists valid projectLocations and reads them back', async () => {
const locs = [
{ id: 'ext-one', name: 'One', path: '/tmp/od-loc-one' },
{ id: 'ext-two', name: 'Two', path: '/tmp/od-loc-two' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toEqual(locs);
});
it('normalizes ~/ paths via expandHomePrefix', async () => {
const home = homedir();
const locs = [{ id: 'home-loc', name: 'Home', path: '~/od-projects' }];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.path).toBe(path.join(home, 'od-projects'));
expect(path.isAbsolute(first.path)).toBe(true);
});
it('drops relative paths that cannot be resolved to absolute', async () => {
const locs = [
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
{ id: 'bad-relative', name: 'Bad Rel', path: './relative/path' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.id).toBe('good');
});
it('drops entries without a string path', async () => {
const locs = [
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
{ id: 'no-path', name: 'No Path' },
];
await writeAppConfig(dataDir, { projectLocations: locs as any });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.id).toBe('good');
});
it('deduplicates paths (case-sensitive on unix)', async () => {
const locs = [
{ id: 'first', name: 'First', path: '/tmp/od-same' },
{ id: 'second', name: 'Second', path: '/tmp/od-same' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
// Single canonical entry, second deduplicated
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.path).toBe(path.normalize('/tmp/od-same'));
});
it('deduplicates by resolved path after normalization', async () => {
const locs = [
{ id: 'first', name: 'First', path: '/tmp/od-dup/../od-dup' },
{ id: 'second', name: 'Second', path: '/tmp/od-dup' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.path).toBe(path.normalize('/tmp/od-dup'));
});
it('rejects reserved id "default" and falls back to auto-generated id', async () => {
const locs = [{ id: 'default', name: 'Hijack', path: '/tmp/od-hijack' }];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
// The stored id must NOT be 'default'
const first = cfg.projectLocations![0]!;
expect(first.id).not.toBe('default');
// The auto-generated id follows the hash-backed base64url pattern
expect(first.id).toMatch(/^loc_[A-Za-z0-9_-]{1,16}$/);
expect(first.path).toBe(path.normalize('/tmp/od-hijack'));
});
it('generates distinct ids for sibling paths with long shared prefixes', async () => {
const locs = [
{ path: '/tmp/open-design-project-locations/shared-prefix-one' },
{ path: '/tmp/open-design-project-locations/shared-prefix-two' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(2);
const ids = cfg.projectLocations!.map((location) => location.id);
expect(new Set(ids).size).toBe(2);
expect(ids.every((id) => /^loc_[A-Za-z0-9_-]{1,16}$/.test(id))).toBe(true);
});
it('persists a defaultProjectLocationId preference', async () => {
await writeAppConfig(dataDir, {
projectLocations: [{ id: 'external-default', name: 'External', path: '/tmp/od-default-location' }],
defaultProjectLocationId: 'external-default',
});
const cfg = await readAppConfig(dataDir);
expect(cfg.defaultProjectLocationId).toBe('external-default');
});
it('normalizes invalid defaultProjectLocationId values', async () => {
await writeAppConfig(dataDir, { defaultProjectLocationId: '../bad' });
let cfg = await readAppConfig(dataDir);
expect(cfg.defaultProjectLocationId).toBe('default');
await writeAppConfig(dataDir, { defaultProjectLocationId: null });
cfg = await readAppConfig(dataDir);
expect(cfg.defaultProjectLocationId).toBeNull();
});
it('drops invalid scalar projectLocations (not an array)', async () => {
await writeAppConfig(dataDir, { projectLocations: 'not-array' } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toBeUndefined();
});
it('clears projectLocations when empty array is sent', async () => {
await writeAppConfig(dataDir, {
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
onboardingCompleted: true,
});
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
await writeAppConfig(dataDir, { projectLocations: [] });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toEqual([]);
expect(cfg.onboardingCompleted).toBe(true);
});
it('clears projectLocations when null is sent', async () => {
await writeAppConfig(dataDir, {
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
onboardingCompleted: true,
});
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
await writeAppConfig(dataDir, { projectLocations: null as any });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toBeUndefined();
expect(cfg.onboardingCompleted).toBe(true);
});
it('validates projectLocations on read (filters corrupted stored data)', async () => {
// Write raw JSON with invalid entries
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
projectLocations: [
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
{ id: 'bad-relative', name: 'Bad', path: 'relative' },
{ id: 'no-path', name: 'No Path' },
'not-an-object',
null,
{ id: 'good2', name: 'Dup Path', path: '/tmp/od-good' },
{ id: 'default', name: 'Reserved', path: '/tmp/od-reserved' },
],
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(2);
const ids = cfg.projectLocations!.map((l) => l.id);
expect(ids).not.toContain('default');
expect(ids).not.toContain('bad-relative');
expect(ids).not.toContain('no-path');
});
});
describe('app-config origin guard', () => {
let server: http.Server;
let port: number;

View file

@ -1,4 +1,5 @@
import type http from 'node:http';
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import {
chmodSync,
@ -27,6 +28,7 @@ import {
import { skillCwdAliasSegment } from '../src/cwd-aliases.js';
import { getAgentDef } from '../src/agents.js';
import { readMemoryConfig, writeMemoryConfig } from '../src/memory.js';
import { upsertMessage } from '../src/db.js';
import { renderCodexImagegenOverride } from '../src/prompts/system.js';
const FAKE_VELA_FIXTURE = resolve(process.cwd(), 'tests', 'fixtures', 'fake-vela.mjs');
@ -216,6 +218,87 @@ process.exit(0);
);
});
it('reuses an existing assistant message row instead of creating a duplicate when assistantMessageId is supplied', async () => {
if (!process.env.OD_DATA_DIR) {
throw new Error('OD_DATA_DIR is required for assistant message reuse tests');
}
const projectId = `proj-${randomUUID()}`;
const assistantMessageId = `assistant-${randomUUID()}`;
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: projectId, name: 'Assistant row reuse fixture' }),
});
expect(createProjectResponse.ok).toBe(true);
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
expect(conversationsResponse.ok).toBe(true);
const conversationsBody = await conversationsResponse.json() as {
conversations: Array<{ id: string }>;
};
const conversationId = conversationsBody.conversations[0]?.id;
expect(conversationId).toBeTruthy();
const dbFile = resolve(process.env.OD_DATA_DIR, 'app.sqlite');
const sqlite = new Database(dbFile);
try {
upsertMessage(sqlite as never, conversationId!, {
id: assistantMessageId,
role: 'assistant',
content: '',
runStatus: 'failed',
startedAt: Date.now() - 1_000,
endedAt: Date.now() - 500,
});
} finally {
sqlite.close();
}
await withFakeAgent(
'opencode',
`
process.stdin.resume();
process.stdin.on('end', () => {
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: 'reused-assistant-row-ok' } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
projectId,
conversationId,
assistantMessageId,
message: 'retry this turn',
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
expect(body).toContain('reused-assistant-row-ok');
},
);
const verifyDb = new Database(dbFile, { readonly: true });
try {
const rows = verifyDb
.prepare(`SELECT id, content, run_id FROM messages WHERE conversation_id = ? AND role = 'assistant'`)
.all(conversationId) as Array<{ id: string; content: string; run_id: string | null }>;
expect(rows.filter((row) => row.id === assistantMessageId)).toHaveLength(1);
expect(rows.some((row) => row.id !== assistantMessageId && row.content.includes('reused-assistant-row-ok'))).toBe(false);
const reused = rows.find((row) => row.id === assistantMessageId);
expect(reused?.content).toContain('reused-assistant-row-ok');
} finally {
verifyDb.close();
}
});
it('rewrites the OpenCode scanner overflow into a generic retry message', async () => {
const conversationId = `conv-${randomUUID()}`;
@ -311,6 +394,76 @@ child.on('exit', (code, signal) => {
}
});
it('allows plugin authoring to succeed when the requested generated-plugin artifacts exist before close', async () => {
const projectId = `proj-plugin-authoring-success-${randomUUID()}`;
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Plugin authoring artifact success fixture',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResponse.status).toBe(200);
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
expect(conversationsResponse.status).toBe(200);
const conversationsBody = await conversationsResponse.json() as {
conversations: Array<{ id: string }>;
};
const conversationId = conversationsBody.conversations[0]?.id;
expect(conversationId).toBeTruthy();
await withFakeAgent(
'opencode',
`
const fs = require('node:fs');
const path = require('node:path');
process.stdin.resume();
process.stdin.on('end', () => {
const pluginDir = path.join(process.cwd(), 'generated-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'open-design.json'), JSON.stringify({ name: 'generated-plugin' }, null, 2));
fs.writeFileSync(path.join(pluginDir, 'SKILL.md'), '# Generated plugin\\n');
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: '我来帮你创建一个通用的 Open Design 插件脚手架。先读取文档规范,再生成插件文件。' } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
projectId,
conversationId,
pluginId: 'od-plugin-authoring',
message: '请创建一个可刷新、可审计、由 API 驱动的 Open Design 插件脚手架。',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('先读取文档规范,再生成插件文件');
expect(statusBody.status).toBe('succeeded');
const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
expect(filesResponse.status).toBe(200);
const filesBody = await filesResponse.json() as { files: Array<{ name: string }> };
expect(filesBody.files.some((file) => file.name === 'generated-plugin/open-design.json')).toBe(true);
expect(filesBody.files.some((file) => file.name === 'generated-plugin/SKILL.md')).toBe(true);
},
);
});
it('does not report plugin authoring as succeeded when the agent only emits planning text without artifacts', async () => {
const projectId = `proj-plugin-authoring-${randomUUID()}`;

View file

@ -86,6 +86,42 @@ async function withFakeAgent<T>(
}
}
async function withOnlyFakeAgent<T>(
binName: string,
script: string,
run: () => Promise<T>,
): Promise<T> {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-'));
const oldPath = process.env.PATH;
const oldAgentHome = process.env.OD_AGENT_HOME;
const oldClaudeBin = process.env.CLAUDE_BIN;
try {
if (process.platform === 'win32') {
const runner = path.join(dir, `${binName}-test-runner.cjs`);
await fsp.writeFile(runner, script);
await fsp.writeFile(
path.join(dir, `${binName}.cmd`),
`@echo off\r\nnode "${runner}" %*\r\n`,
);
} else {
const bin = path.join(dir, binName);
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
await fsp.chmod(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
delete process.env.CLAUDE_BIN;
return await run();
} finally {
process.env.PATH = oldPath;
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
else process.env.OD_AGENT_HOME = oldAgentHome;
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
else process.env.CLAUDE_BIN = oldClaudeBin;
await fsp.rm(dir, { recursive: true, force: true });
}
}
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('codex', script, run);
}
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
return withFakeAgent('claude', script, run);
}
async function withOnlyFakeOpenClaude<T>(script: string, run: () => Promise<T>): Promise<T> {
return withOnlyFakeAgent('openclaude', script, run);
}
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('opencode', script, run);
}
@ -2199,6 +2239,58 @@ process.stdin.on('end', () => {
);
});
it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => {
const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`);
const previousKey = process.env.ANTHROPIC_API_KEY;
try {
process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test';
await withOnlyFakeOpenClaude(
`
const fs = require('node:fs');
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
}));
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
try {
JSON.parse(input.trim());
console.log(JSON.stringify({
type: 'assistant',
message: {
id: 'msg_1',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
},
}));
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
`,
async () => {
const result = await testAgentConnection({ agentId: 'claude' });
expect(result).toMatchObject({
ok: true,
kind: 'success',
agentName: 'Claude Code',
});
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }),
);
expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i);
},
);
} finally {
if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = previousKey;
await fsp.rm(envFile, { force: true });
}
});
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
await withFakeClaude(
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,

View file

@ -1,6 +1,6 @@
import type http from 'node:http';
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { chmod, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@ -56,26 +56,6 @@ describe('POST /api/import/folder', () => {
}
}
async function waitForRunStatus(
runId: string,
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
let lastStatus = 'unknown';
for (let attempt = 0; attempt < 200; attempt += 1) {
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
const statusBody = (await statusResponse.json()) as {
status: string;
error?: string | null;
errorCode?: string | null;
};
lastStatus = statusBody.status;
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
return statusBody;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
}
it('creates a project rooted at the submitted folder', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -105,7 +85,7 @@ describe('POST /api/import/folder', () => {
});
});
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
it('rejects sandbox runs for imported folders before creating a run', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -123,15 +103,78 @@ describe('POST /api/import/folder', () => {
message: 'Inspect the imported project.',
}),
});
expect(runResp.status).toBe(202);
const { runId } = (await runResp.json()) as { runId: string };
const status = await waitForRunStatus(runId);
expect(status.status).toBe('failed');
expect(status.errorCode).toBe('BAD_REQUEST');
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
expect(runResp.status).toBe(400);
const body = (await runResp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
it('rejects sandbox chat runs for imported folders before creating a run', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const chatResp = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: project.id,
message: 'Inspect the imported project.',
}),
});
expect(chatResp.status).toBe(400);
const body = (await chatResp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
const runsResp = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(project.id)}`);
expect(runsResp.status).toBe(200);
const runsBody = (await runsResp.json()) as { runs: unknown[] };
expect(runsBody.runs).toHaveLength(0);
});
});
it('opens imported-folder projects through host editor routes in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const binDir = makeFolder();
const cursorBin = path.join(
binDir,
process.platform === 'win32' ? 'cursor.cmd' : 'cursor',
);
await writeFile(
cursorBin,
process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
);
await chmod(cursorBin, 0o755);
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
const previousPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`;
try {
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/open-in`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ editorId: 'cursor' }),
});
expect(resp.status).toBe(200);
const body = (await resp.json()) as { path?: string };
expect(body.path).toBe(await realpath(folder));
});
} finally {
if (previousPath == null) delete process.env.PATH;
else process.env.PATH = previousPath;
}
});
it('still opens an imported-folder project record in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');

View file

@ -371,6 +371,61 @@ describe('POST /api/integrations/vela/login', () => {
}
});
it('uses the same Settings-configured AMR env for login and subsequent status reads', async () => {
const dataDir = process.env.OD_DATA_DIR as string;
const previous = await readAppConfig(dataDir);
process.env.OPEN_DESIGN_AMR_PROFILE = 'prod';
process.env.VELA_PROFILE = 'prod';
process.env.FAKE_VELA_LOGIN_USER_EMAIL = 'settings-roundtrip@example.com';
await writeAppConfig(dataDir, {
...previous,
agentCliEnv: {
...(previous.agentCliEnv ?? {}),
amr: {
...((previous.agentCliEnv?.amr as Record<string, string>) ?? {}),
VELA_BIN: FAKE_VELA,
OPEN_DESIGN_AMR_PROFILE: 'local',
},
},
});
try {
const before = await getJson<{
loggedIn: boolean;
profile: string;
user: { email?: string } | null;
}>(`${baseUrl}/api/integrations/vela/status`);
expect(before.status).toBe(200);
expect(before.body.loggedIn).toBe(false);
expect(before.body.profile).toBe('local');
const login = await postJson<{
pid: number;
profile: string;
}>(`${baseUrl}/api/integrations/vela/login`);
expect(login.status).toBe(202);
expect(login.body.profile).toBe('local');
for (let i = 0; i < 50; i += 1) {
const current = await getJson<{
loggedIn: boolean;
profile: string;
user: { email?: string } | null;
}>(`${baseUrl}/api/integrations/vela/status`);
if (current.body.loggedIn) {
expect(current.body.profile).toBe('local');
expect(current.body.user?.email).toBe('settings-roundtrip@example.com');
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('expected configured-profile AMR login to become visible via /status');
} finally {
await writeAppConfig(dataDir, previous as unknown as Record<string, unknown>);
delete process.env.FAKE_VELA_LOGIN_USER_EMAIL;
}
});
it('returns 409 when a login subprocess is already in flight', async () => {
// Use the stub's delay knob so the first login is still running when
// the second request arrives; without this the first exits before the

View file

@ -68,10 +68,14 @@ process.exit(0);
async function waitForRunStatus(
baseUrl: string,
runId: string,
): Promise<{ status: string }> {
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
for (let attempt = 0; attempt < 200; attempt += 1) {
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
const body = (await r.json()) as { status: string };
const body = (await r.json()) as {
status: string;
error?: string | null;
errorCode?: string | null;
};
if (body.status !== 'queued' && body.status !== 'running') return body;
await new Promise((resolve) => setTimeout(resolve, 25));
}
@ -82,6 +86,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
let server: http.Server;
let baseUrl: string;
const projectsToClean: string[] = [];
const tempDirs: string[] = [];
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
@ -106,9 +111,12 @@ describe('spawn writes external MCP config for Claude Code', () => {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servers: [] }),
}).catch(() => {});
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
async function createProject(): Promise<{ id: string; dir: string }> {
async function createProject(): Promise<{ id: string; dir: string; conversationId: string }> {
const id = `mcp-spawn-${randomUUID()}`;
const r = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
@ -116,6 +124,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
body: JSON.stringify({ id, name: id }),
});
expect(r.ok).toBe(true);
const body = (await r.json()) as { conversationId: string };
projectsToClean.push(id);
// The daemon owns its data dir; we discover the on-disk project path by
// having the daemon return the upload root, then composing path manually.
@ -123,7 +132,46 @@ describe('spawn writes external MCP config for Claude Code', () => {
const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', 'projects');
return { id, dir: join(projectsBase, id) };
return { id, dir: join(projectsBase, id), conversationId: body.conversationId };
}
async function importFolderProject(): Promise<{
id: string;
dir: string;
externalDir: string;
conversationId: string;
}> {
const externalDir = await fsp.mkdtemp(join(tmpdir(), 'od-mcp-import-'));
tempDirs.push(externalDir);
await fsp.writeFile(join(externalDir, 'index.html'), '<!doctype html>');
const r = await fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseDir: externalDir }),
});
expect(r.ok).toBe(true);
const body = (await r.json()) as { project: { id: string }; conversationId: string };
projectsToClean.push(body.project.id);
const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', 'projects');
return {
id: body.project.id,
dir: join(projectsBase, body.project.id),
externalDir,
conversationId: body.conversationId,
};
}
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
@ -197,6 +245,347 @@ describe('spawn writes external MCP config for Claude Code', () => {
});
}, 30_000);
it('fails sandbox runs for imported-folder projects before writing MCP config', async () => {
await withFakeClaude(async () => {
const putRes = await fetch(`${baseUrl}/api/mcp/servers`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
servers: [
{
id: 'sandbox-run',
transport: 'sse',
enabled: true,
url: 'https://mcp.example.test',
},
],
}),
});
expect(putRes.ok).toBe(true);
const { id, dir, externalDir, conversationId } = await importFolderProject();
await withSandboxMode(async () => {
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'hello sandbox mcp',
}),
});
expect(chatRes.status).toBe(400);
const body = (await chatRes.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
const managedTarget = join(dir, '.mcp.json');
expect(existsSync(managedTarget)).toBe(false);
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'hello sandbox mcp')).toBe(false);
});
}, 30_000);
it('rejects sandbox routine reuse of imported-folder projects before creating run state', async () => {
const { id } = await importFolderProject();
const conversationsBeforeRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
expect(conversationsBeforeRes.ok).toBe(true);
const conversationsBeforeBody = (await conversationsBeforeRes.json()) as {
conversations: Array<{ id: string }>;
};
const conversationIdsBefore = conversationsBeforeBody.conversations.map((conversation) => conversation.id);
let routineId: string | null = null;
try {
const createRoutineRes = await fetch(`${baseUrl}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Sandbox imported folder routine',
prompt: 'try to run inside an imported folder',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'reuse', projectId: id },
agentId: 'claude',
enabled: false,
}),
});
expect(createRoutineRes.status).toBe(201);
const createRoutineBody = (await createRoutineRes.json()) as {
routine: { id: string };
};
routineId = createRoutineBody.routine.id;
await withSandboxMode(async () => {
const runRoutineRes = await fetch(`${baseUrl}/api/routines/${routineId}/run`, {
method: 'POST',
});
expect(runRoutineRes.status).toBe(500);
const runRoutineBody = (await runRoutineRes.json()) as { error?: string };
expect(runRoutineBody.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
const routineRunsRes = await fetch(`${baseUrl}/api/routines/${routineId}/runs?limit=10`);
expect(routineRunsRes.ok).toBe(true);
const routineRunsBody = (await routineRunsRes.json()) as { runs: unknown[] };
expect(routineRunsBody.runs).toHaveLength(0);
const runsRes = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(id)}`);
expect(runsRes.ok).toBe(true);
const runsBody = (await runsRes.json()) as { runs: unknown[] };
expect(runsBody.runs).toHaveLength(0);
const conversationsAfterRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
expect(conversationsAfterRes.ok).toBe(true);
const conversationsAfterBody = (await conversationsAfterRes.json()) as {
conversations: Array<{ id: string }>;
};
expect(conversationsAfterBody.conversations.map((conversation) => conversation.id)).toEqual(
conversationIdsBefore,
);
} finally {
if (routineId) {
await fetch(`${baseUrl}/api/routines/${routineId}`, { method: 'DELETE' }).catch(() => {});
}
}
}, 30_000);
it('injects run-scoped MCP servers without saving them to the persistent registry', async () => {
await withFakeClaude(async () => {
const { id, dir } = await createProject();
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'hello run-scoped mcp',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
args: ['run-tool.js'],
env: { RUN_ONLY: '1' },
},
{
id: 'run-remote',
transport: 'http',
enabled: true,
authMode: 'none',
url: 'https://example.test/mcp',
headers: { 'X-Run': 'ok' },
},
],
},
}),
});
expect(chatRes.status).toBe(202);
const { runId } = (await chatRes.json()) as { runId: string };
const status = await waitForRunStatus(baseUrl, runId) as {
status: string;
toolBundle?: { mcpServers?: Array<{ id: string }> };
};
expect(status.status).toBe('succeeded');
expect(status.toolBundle?.mcpServers?.map((server) => server.id)).toEqual([
'run-local',
'run-remote',
]);
const target = join(dir, '.mcp.json');
expect(existsSync(target)).toBe(true);
const written = JSON.parse(await fsp.readFile(target, 'utf8'));
expect(written.mcpServers.run_local).toBeUndefined();
expect(written.mcpServers['run-local']).toMatchObject({
command: 'node',
args: ['run-tool.js'],
env: { RUN_ONLY: '1' },
});
expect(written.mcpServers['run-remote']).toMatchObject({
type: 'http',
url: 'https://example.test/mcp',
headers: { 'X-Run': 'ok' },
});
const persistedRes = await fetch(`${baseUrl}/api/mcp/servers`);
expect(persistedRes.ok).toBe(true);
const persisted = (await persistedRes.json()) as { servers: unknown[] };
expect(persisted.servers).toEqual([]);
});
}, 30_000);
it('rejects Claude run-scoped MCP bundles for imported-folder projects', async () => {
const { id, dir, externalDir, conversationId } = await importFolderProject();
const runsRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'imported run-scoped tools',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(runsRes.status).toBe(400);
const runsBody = (await runsRes.json()) as { error?: { message?: string } };
expect(runsBody.error?.message).toContain('toolBundle requires a daemon-managed project');
const chatRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'imported chat-scoped tools',
toolBundle: {
mcpServers: [
{
id: 'run-local-chat',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(chatRes.status).toBe(400);
const chatBody = (await chatRes.json()) as { error?: { message?: string } };
expect(chatBody.error?.message).toContain('toolBundle requires a daemon-managed project');
expect(existsSync(join(dir, '.mcp.json'))).toBe(false);
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'imported run-scoped tools')).toBe(false);
expect(messagesBody.messages.some((msg) => msg.content === 'imported chat-scoped tools')).toBe(false);
});
it('rejects malformed run-scoped MCP bundles before creating runs', async () => {
const { id } = await createProject();
const invalidRunsRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'bad tools',
toolBundle: {
mcpServers: [
{
id: 'missing-command',
transport: 'stdio',
},
],
},
}),
});
expect(invalidRunsRes.status).toBe(400);
const runsBody = (await invalidRunsRes.json()) as { error?: { message?: string } };
expect(runsBody.error?.message).toContain('toolBundle.mcpServers[0] is invalid');
const invalidChatRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'bad tools',
toolBundle: 'bad',
}),
});
expect(invalidChatRes.status).toBe(400);
const chatBody = (await invalidChatRes.json()) as { error?: { message?: string } };
expect(chatBody.error?.message).toContain('toolBundle must be an object');
});
it('rejects run-scoped MCP bundles the selected runtime cannot receive', async () => {
const { id, conversationId } = await createProject();
const unsupportedRuntimeRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'codex',
projectId: id,
message: 'bad tools',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(unsupportedRuntimeRes.status).toBe(400);
const unsupportedRuntimeBody = (await unsupportedRuntimeRes.json()) as {
error?: { message?: string };
};
expect(unsupportedRuntimeBody.error?.message).toContain(
'Codex CLI (codex) does not support run-scoped MCP tool bundles',
);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'bad tools')).toBe(false);
const unsupportedTransportRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'hermes',
projectId: id,
message: 'bad remote tools',
toolBundle: {
mcpServers: [
{
id: 'run-remote',
transport: 'http',
url: 'https://example.test/mcp',
},
],
},
}),
});
expect(unsupportedTransportRes.status).toBe(400);
const unsupportedTransportBody = (await unsupportedTransportRes.json()) as {
error?: { message?: string };
};
expect(unsupportedTransportBody.error?.message).toContain(
'Hermes (hermes) only supports stdio run-scoped MCP servers',
);
});
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
// session/new params instead of `.mcp.json`. The `.mcp.json` write path

View file

@ -124,6 +124,95 @@ test('conversation latest run follows assistant message position', () => {
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running');
});
test('conversation summaries expose cumulative completed run duration', () => {
const db = createDb();
insertProject(db, {
id: 'project-duration',
name: 'project-duration',
createdAt: 1,
updatedAt: 1,
});
insertConversation(db, {
id: 'project-duration-conversation',
projectId: 'project-duration',
title: 'Duration test',
createdAt: 1,
updatedAt: 4,
});
upsertMessage(db, 'project-duration-conversation', {
id: 'project-duration-first',
role: 'assistant',
content: 'first done',
runId: 'project-duration-first-run',
runStatus: 'succeeded',
startedAt: 10_000,
endedAt: 40_000,
});
upsertMessage(db, 'project-duration-conversation', {
id: 'project-duration-running',
role: 'assistant',
content: 'still running',
runId: 'project-duration-running-run',
runStatus: 'running',
startedAt: 45_000,
});
upsertMessage(db, 'project-duration-conversation', {
id: 'project-duration-second',
role: 'assistant',
content: 'second done',
runId: 'project-duration-second-run',
runStatus: 'failed',
startedAt: 50_000,
endedAt: 125_000,
});
const listed = listConversations(db, 'project-duration')[0] as { totalDurationMs?: number };
const fetched = getConversation(db, 'project-duration-conversation') as { totalDurationMs?: number } | null;
assert.equal(listed.totalDurationMs, 105_000);
assert.equal(fetched?.totalDurationMs, 105_000);
});
test('conversation summaries include usage-only terminal run durations', () => {
const db = createDb();
insertProject(db, {
id: 'project-usage-duration',
name: 'project-usage-duration',
createdAt: 1,
updatedAt: 1,
});
insertConversation(db, {
id: 'project-usage-duration-conversation',
projectId: 'project-usage-duration',
title: 'Usage duration test',
createdAt: 1,
updatedAt: 4,
});
upsertMessage(db, 'project-usage-duration-conversation', {
id: 'project-usage-duration-imported',
role: 'assistant',
content: 'imported done',
runId: 'project-usage-duration-imported-run',
runStatus: 'succeeded',
events: [{ kind: 'usage', durationMs: 22_000 }],
});
upsertMessage(db, 'project-usage-duration-conversation', {
id: 'project-usage-duration-timestamped',
role: 'assistant',
content: 'timestamped done',
runId: 'project-usage-duration-timestamped-run',
runStatus: 'succeeded',
startedAt: 30_000,
endedAt: 60_000,
});
const listed = listConversations(db, 'project-usage-duration')[0] as { totalDurationMs?: number };
const fetched = getConversation(db, 'project-usage-duration-conversation') as { totalDurationMs?: number } | null;
assert.equal(listed.totalDurationMs, 52_000);
assert.equal(fetched?.totalDurationMs, 52_000);
});
test('conversation listing batches latest run summaries for large projects', () => {
const db = createDb();
insertProject(db, {

View file

@ -13,7 +13,7 @@
*/
import type http from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { mkdir, readdir, readFile, realpath, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@ -299,6 +299,644 @@ describe('GET /api/projects/:id resolvedDir', () => {
});
});
// ---------------------------------------------------------------------------
// Project locations routes: GET, PUT, scan, and project creation under an
// external project location.
// ---------------------------------------------------------------------------
describe('project locations routes', () => {
let server: http.Server;
let baseUrl: 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;
});
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
afterAll(() => {
return new Promise<void>((resolve) => server.close(() => resolve()));
});
function makeTempDir(): string {
const d = mkdtempSync(path.join(tmpdir(), 'od-proj-loc-routes-'));
tempDirs.push(d);
return d;
}
async function putProjectLocations(
locations: Array<{ id?: string; name?: string; path: string }>,
): Promise<Response> {
return fetch(`${baseUrl}/api/project-locations`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations }),
});
}
async function putAppConfig(config: Record<string, unknown>): Promise<Response> {
return fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
}
it('GET /api/project-locations returns built-in default plus empty external', async () => {
const resp = await fetch(`${baseUrl}/api/project-locations`);
expect(resp.status).toBe(200);
const body = (await resp.json()) as { locations: Array<{ id: string; name: string; builtIn?: boolean; path: string }> };
expect(body.locations).toHaveLength(1); // only default on fresh start
const loc0 = body.locations[0]!;
expect(loc0.id).toBe('default');
expect(loc0.builtIn).toBe(true);
expect(loc0.name).toBe('Open Design projects');
});
it('PUT /api/project-locations creates external roots and GET returns them alongside default', async () => {
const extDir = makeTempDir();
const resp = await putProjectLocations([
{ id: 'ext-root', name: 'External', path: extDir },
]);
expect(resp.status).toBe(200);
const putBody = (await resp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> };
expect(putBody.locations).toHaveLength(2);
const putLoc0 = putBody.locations[0]!;
const putLoc1 = putBody.locations[1]!;
expect(putLoc0.id).toBe('default');
expect(putLoc1.id).toBe('ext-root');
expect(putLoc1.path).toBe(await realpath(extDir));
// GET returns the same
const getResp = await fetch(`${baseUrl}/api/project-locations`);
expect(getResp.status).toBe(200);
const getBody = (await getResp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> };
expect(getBody.locations).toHaveLength(2);
const getLoc0 = getBody.locations[0]!;
const getLoc1 = getBody.locations[1]!;
expect(getLoc0.id).toBe('default');
expect(getLoc1.id).toBe('ext-root');
});
it('POST /api/project-locations/scan returns empty result when no manifests found', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'empty-ext', name: 'Empty', path: extDir }]);
const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(scanResp.status).toBe(200);
const body = (await scanResp.json()) as {
scanned: number;
imported: unknown[];
existing: string[];
skipped: unknown[];
};
expect(body.scanned).toBe(0);
expect(body.imported).toEqual([]);
});
it('POST /api/project-locations/scan imports manifest-backed project and skips on re-scan', async () => {
const extDir = makeTempDir();
// Create a project directory with a valid manifest
const projectDir = path.join(extDir, 'scan-test-proj');
const odDir = path.join(projectDir, '.open-design');
await mkdir(odDir, { recursive: true });
const manifest = {
schemaVersion: 1 as const,
id: 'scan-test-proj',
name: 'Scanned Project',
createdAt: Date.now(),
updatedAt: Date.now(),
skillId: null,
designSystemId: null,
};
await writeFile(
path.join(projectDir, '.open-design', 'project.json'),
JSON.stringify(manifest, null, 2),
'utf8',
);
// Register the location
await putProjectLocations([{ id: 'scan-ext', name: 'Scan External', path: extDir }]);
// First scan: should import
const scan1 = await fetch(`${baseUrl}/api/project-locations/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(scan1.status).toBe(200);
const body1 = (await scan1.json()) as {
scanned: number;
imported: Array<{ id: string; name: string; metadata?: { baseDir?: string; importedFrom?: string } }>;
existing: string[];
skipped: unknown[];
};
expect(body1.scanned).toBeGreaterThanOrEqual(1);
expect(body1.imported).toHaveLength(1);
const imported0 = body1.imported[0]!;
expect(imported0.id).toBe('scan-test-proj');
expect(imported0.name).toBe('Scanned Project');
// The imported project should have metadata pointing at the external dir
// (ensureProjectLocation calls realpath which resolves /var -> /private/var on macOS)
expect(imported0.metadata?.baseDir).toBe(await realpath(projectDir));
expect(imported0.metadata?.importedFrom).toBe('project-location');
expect(body1.existing).toEqual([]);
// Second scan: project already exists, should be in "existing"
const scan2 = await fetch(`${baseUrl}/api/project-locations/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(scan2.status).toBe(200);
const body2 = (await scan2.json()) as {
scanned: number;
imported: unknown[];
existing: string[];
};
expect(body2.imported).toEqual([]);
expect(body2.existing).toEqual(['scan-test-proj']);
});
it('POST /api/projects with projectLocationId creates project under external root and writes .open-design/project.json', async () => {
const extDir = makeTempDir();
// Register an external location
await putProjectLocations([{ id: 'create-ext', name: 'Create External', path: extDir }]);
const projectId = `ext-proj-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'External Project',
skillId: null,
designSystemId: null,
projectLocationId: 'create-ext',
}),
});
expect(createResp.status).toBe(200);
const createBody = (await createResp.json()) as {
project: { id: string; metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } };
};
expect(createBody.project.id).toBe(projectId);
expect(createBody.project.metadata?.importedFrom).toBe('project-location');
expect(createBody.project.metadata?.projectLocationId).toBe('create-ext');
// The project should be under <extDir>/<projectId> (ensureProjectLocation realpaths)
const expectedProjectDir = await realpath(path.join(extDir, projectId));
expect(createBody.project.metadata?.baseDir).toBe(expectedProjectDir);
// Verify .open-design/project.json was written
const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json');
const manifestRaw = await import('node:fs/promises').then((m) => m.readFile(manifestPath, 'utf8'));
const manifest = JSON.parse(manifestRaw);
expect(manifest.schemaVersion).toBe(1);
expect(manifest.id).toBe(projectId);
expect(manifest.name).toBe('External Project');
// GET /api/projects/:id resolvedDir equals the external project dir
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
expect(detailResp.status).toBe(200);
const detail = (await detailResp.json()) as { resolvedDir: string };
expect(detail.resolvedDir).toBe(expectedProjectDir);
});
it('POST /api/projects uses the configured default project location when no location is supplied', async () => {
const extDir = makeTempDir();
const locationId = 'default-create-location';
await putProjectLocations([{ id: locationId, name: 'Default External', path: extDir }]);
const cfgResp = await putAppConfig({ defaultProjectLocationId: locationId });
expect(cfgResp.status).toBe(200);
const projectId = `default-location-project-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Default location project',
skillId: null,
designSystemId: null,
}),
});
expect(createResp.status).toBe(200);
const body = (await createResp.json()) as {
project: { metadata?: { baseDir?: string; projectLocationId?: string; importedFrom?: string } };
};
expect(body.project.metadata?.projectLocationId).toBe(locationId);
expect(body.project.metadata?.importedFrom).toBe('project-location');
expect(body.project.metadata?.baseDir).toBe(await realpath(path.join(extDir, projectId)));
await putAppConfig({ defaultProjectLocationId: null });
await putProjectLocations([]);
});
it('POST /api/projects falls back to built-in storage when configured default location is unavailable', async () => {
await putProjectLocations([]);
const cfgResp = await putAppConfig({ defaultProjectLocationId: 'missing-location' });
expect(cfgResp.status).toBe(200);
const projectId = `missing-default-project-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Missing default project',
skillId: null,
designSystemId: null,
}),
});
expect(createResp.status).toBe(200);
const body = (await createResp.json()) as {
project: { metadata?: { baseDir?: string; projectLocationId?: string } };
};
expect(body.project.metadata?.baseDir).toBeUndefined();
expect(body.project.metadata?.projectLocationId).toBeUndefined();
await putAppConfig({ defaultProjectLocationId: null });
});
it('PATCH /api/projects/:id preserves project-location provenance with baseDir', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'patch-ext', name: 'Patch External', path: extDir }]);
const projectId = `ext-patch-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Patch External Project',
projectLocationId: 'patch-ext',
}),
});
expect(createResp.status).toBe(200);
const createBody = (await createResp.json()) as {
project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } };
};
const patchResp = await fetch(`${baseUrl}/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: { kind: 'prototype', skipDiscoveryBrief: true } }),
});
expect(patchResp.status).toBe(200);
const patchBody = (await patchResp.json()) as {
project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string; skipDiscoveryBrief?: boolean } };
};
expect(patchBody.project.metadata?.baseDir).toBe(createBody.project.metadata?.baseDir);
expect(patchBody.project.metadata?.importedFrom).toBe('project-location');
expect(patchBody.project.metadata?.projectLocationId).toBe('patch-ext');
expect(patchBody.project.metadata?.skipDiscoveryBrief).toBe(true);
});
it('POST /api/projects with unknown projectLocationId returns 400', async () => {
const projectId = `bad-loc-${Date.now()}`;
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Bad Location Project',
projectLocationId: 'nonexistent-location-id',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/project location/i);
});
it('POST /api/projects with invalid designSystemId does not create external project directory', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'invalid-ds-ext', name: 'Invalid DS External', path: extDir }]);
const projectId = `invalid-ds-${Date.now()}`;
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Invalid design system project',
designSystemId: `missing-design-system-${Date.now()}`,
projectLocationId: 'invalid-ds-ext',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string } };
expect(body.error?.code).toBe('DESIGN_SYSTEM_NOT_FOUND');
await expect(readdir(extDir)).resolves.toEqual([]);
});
it('PUT /api/project-locations rejects non-array locations body', async () => {
const resp = await fetch(`${baseUrl}/api/project-locations`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: 'not-an-array' }),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
});
// -----------------------------------------------------------------------
// Security boundaries — see #451 (project-locations) for context.
// -----------------------------------------------------------------------
it('POST /api/projects with projectLocationId rejects unsafe id "."', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'sec-ext', name: 'Security External', path: extDir }]);
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: '.',
name: 'Dot Project',
projectLocationId: 'sec-ext',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/invalid project id/i);
});
it('POST /api/projects with projectLocationId rejects unsafe id ".."', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'sec-ext2', name: 'Security External 2', path: extDir }]);
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: '..',
name: 'DotDot Project',
projectLocationId: 'sec-ext2',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/invalid project id/i);
});
it('POST /api/projects with projectLocationId rejects when target path already exists as a symlink', async () => {
const extDir = makeTempDir();
await putProjectLocations([{ id: 'sym-ext', name: 'Symlink External', path: extDir }]);
const projectId = `symlink-proj-${Date.now()}`;
const realTargetDir = path.join(extDir, 'real-target');
await mkdir(realTargetDir, { recursive: true });
// Pre-create a symlink at <extDir>/<projectId> pointing to another directory
const symlinkPath = path.join(extDir, projectId);
await symlink(realTargetDir, symlinkPath);
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Symlink Project',
projectLocationId: 'sym-ext',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
});
it('PUT /api/project-locations rejects a root overlapping the daemon projects dir', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR required for daemon route tests');
const projectsDir = path.join(dataDir, 'projects');
const canonicalProjectsDir = await realpath(projectsDir);
const resp = await putProjectLocations([
{ id: 'overlap-projects', name: 'Overlap Projects', path: canonicalProjectsDir },
]);
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/cannot overlap|daemon data/i);
});
it('PUT /api/project-locations rejects filesystem root "/" via isBlocked check', async () => {
// isBlocked in linked-dirs.ts rejects the filesystem root.
const resp = await putProjectLocations([
{ id: 'root-loc', name: 'Root', path: '/' },
]);
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
});
it('app-config bypass: PUT /api/app-config persists invalid path but GET /api/project-locations does not expose it', async () => {
// Persist a projectLocations entry with a system-protected path ('/') via
// the generic PUT /api/app-config route, which only validates format, not
// safety. The GET /api/project-locations route must filter it out because
// configuredProjectLocations() runs validateLinkedDirs + locationOverlapsDaemonData.
const appCfgResp = await fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectLocations: [
{ id: 'bad-root', name: 'Bad Root', path: '/' },
],
}),
});
expect(appCfgResp.status).toBe(200);
// Verify the persisted config (read back) contains the entry (format validation passed)
const readCfgResp = await fetch(`${baseUrl}/api/app-config`);
expect(readCfgResp.status).toBe(200);
const cfgBody = (await readCfgResp.json()) as {
config: { projectLocations?: Array<{ id: string; path: string }> };
};
// The entry was normalized and persisted
const locs = cfgBody.config.projectLocations;
expect(locs).toBeDefined();
expect(locs!.length).toBeGreaterThanOrEqual(1);
// But GET /api/project-locations must NOT expose it
const locResp = await fetch(`${baseUrl}/api/project-locations`);
expect(locResp.status).toBe(200);
const locBody = (await locResp.json()) as {
locations: Array<{ id: string }>;
};
const ids = locBody.locations.map((l) => l.id);
expect(ids).toContain('default'); // built-in always present
// The invalid location must not appear
expect(ids).not.toContain('bad-root');
// Clean up: remove the invalid projectLocations
await fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectLocations: [] }),
});
});
it('app-config bypass: POST /api/projects with invalid persisted root id returns 400 unknown project location', async () => {
// Persist a projectLocations entry with '/' via app-config.
// The auto-generated id follows the loc_<base64url> pattern.
const appCfgResp = await fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectLocations: [
{ id: 'evil-root', name: 'Evil Root', path: '/' },
],
}),
});
expect(appCfgResp.status).toBe(200);
// Try to create a project under this location id. Since configuredProjectLocations
// filters it, the lookup returns nothing → 400 "unknown project location".
const projectId = `evil-proj-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Evil Project',
projectLocationId: 'evil-root',
}),
});
expect(createResp.status).toBe(400);
const body = (await createResp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/unknown project location/i);
// Clean up
await fetch(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectLocations: [] }),
});
});
it('removing an external location hides its projects but preserves DB history and disk files for re-scan', async () => {
const extDir = makeTempDir();
const locationId = 'unreg-loc';
await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]);
// Create a project under this external location
const projectId = `unreg-proj-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Project To Unregister',
skillId: null,
designSystemId: null,
projectLocationId: locationId,
}),
});
expect(createResp.status).toBe(200);
const createBody = (await createResp.json()) as {
project: { id: string };
conversationId: string;
};
expect(createBody.project.id).toBe(projectId);
const messageId = `msg-${Date.now()}`;
const messageResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages/${messageId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role: 'user',
content: 'restore this conversation after location re-add',
}),
});
expect(messageResp.status).toBe(200);
// Confirm the project is listed
const listBefore = await fetch(`${baseUrl}/api/projects`);
expect(listBefore.status).toBe(200);
const beforeBody = (await listBefore.json()) as { projects: Array<{ id: string }> };
expect(beforeBody.projects.some((p) => p.id === projectId)).toBe(true);
// The project directory and manifest should exist on disk
const expectedProjectDir = await realpath(path.join(extDir, projectId));
const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json');
const manifestBefore = await readFile(manifestPath, 'utf8');
expect(JSON.parse(manifestBefore).id).toBe(projectId);
// Remove the external location: PUT empty locations so the location is dropped.
// This is an unmount/hide operation, not a destructive project delete.
const removeResp = await putProjectLocations([]);
expect(removeResp.status).toBe(200);
const removeBody = (await removeResp.json()) as {
locations: Array<{ id: string }>;
removedProjectIds?: string[];
};
// The response must include removedProjectIds with our project
expect(removeBody.removedProjectIds).toBeDefined();
expect(removeBody.removedProjectIds).toContain(projectId);
// Only the built-in default location should remain
expect(removeBody.locations).toHaveLength(1);
expect(removeBody.locations[0]!.id).toBe('default');
// The project should no longer appear in GET /api/projects
const listAfter = await fetch(`${baseUrl}/api/projects`);
expect(listAfter.status).toBe(200);
const afterBody = (await listAfter.json()) as { projects: Array<{ id: string }> };
expect(afterBody.projects.some((p) => p.id === projectId)).toBe(false);
// GET /api/projects/:id should return 404 while the location is unmounted.
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
expect(detailResp.status).toBe(404);
// The on-disk project directory and manifest must still be intact
const manifestAfter = await readFile(manifestPath, 'utf8');
expect(JSON.parse(manifestAfter).id).toBe(projectId);
// Re-add the same base and scan: the existing DB row should be revealed,
// not recreated from only the manifest, so conversation history survives.
await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]);
const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, { method: 'POST' });
expect(scanResp.status).toBe(200);
const scanBody = (await scanResp.json()) as { imported: Array<{ id: string }>; existing: string[] };
expect(scanBody.imported.some((p) => p.id === projectId)).toBe(false);
expect(scanBody.existing).toContain(projectId);
const listReadded = await fetch(`${baseUrl}/api/projects`);
expect(listReadded.status).toBe(200);
const readdedBody = (await listReadded.json()) as { projects: Array<{ id: string }> };
expect(readdedBody.projects.some((p) => p.id === projectId)).toBe(true);
const messagesResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages`);
expect(messagesResp.status).toBe(200);
const messagesBody = (await messagesResp.json()) as { messages: Array<{ id: string; content: string }> };
expect(messagesBody.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: messageId,
content: 'restore this conversation after location re-add',
}),
]),
);
});
});
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';

View file

@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest';
import {
normalizeRunToolBundleForRun,
parseRunToolBundleForRequest,
resolveExternalMcpServersForRun,
summarizeRunToolBundle,
validateRunToolBundleForAgent,
} from '../src/run-tool-bundle.js';
describe('run-scoped tool bundles', () => {
it('sanitizes MCP servers onto the run and redacts spawn-only details in summaries', () => {
const bundle = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'local-tools',
label: 'Local tools',
transport: 'stdio',
command: 'node',
args: ['server.js', '--token=secret'],
env: { API_TOKEN: 'secret' },
},
{
id: 'remote-tools',
transport: 'http',
url: 'https://example.test/mcp',
headers: { Authorization: 'Bearer secret' },
},
{
id: '../bad',
transport: 'stdio',
command: 'node',
},
],
});
expect(bundle.mcpServers).toHaveLength(2);
expect(bundle.mcpServers[0]).toMatchObject({
id: 'local-tools',
command: 'node',
env: { API_TOKEN: 'secret' },
});
const summary = summarizeRunToolBundle(bundle);
expect(summary).toEqual({
mcpServers: [
{
id: 'local-tools',
label: 'Local tools',
transport: 'stdio',
enabled: true,
},
{
id: 'remote-tools',
transport: 'http',
enabled: true,
authMode: 'oauth',
},
],
});
expect(JSON.stringify(summary)).not.toContain('secret');
expect(JSON.stringify(summary)).not.toContain('server.js');
});
it('uses only run-scoped MCP servers in sandbox mode', () => {
const persistedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'persisted',
transport: 'http',
url: 'https://persisted.example.test/mcp',
},
],
}).mcpServers;
const runScopedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'run-only',
transport: 'stdio',
command: 'node',
args: ['run-tool.js'],
},
],
}).mcpServers;
const selection = resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode: true,
});
expect(selection.enabledServers.map((server) => server.id)).toEqual(['run-only']);
expect([...selection.persistedTokenServerIds]).toEqual([]);
});
it('rejects malformed run-scoped MCP server entries for request payloads', () => {
expect(parseRunToolBundleForRequest('bad')).toEqual({
ok: false,
message: 'toolBundle must be an object',
});
expect(parseRunToolBundleForRequest({ mcpServers: 'bad' })).toEqual({
ok: false,
message: 'toolBundle.mcpServers must be an array',
});
expect(parseRunToolBundleForRequest({
mcpServers: [
{
id: 'missing-command',
transport: 'stdio',
},
],
})).toEqual({
ok: false,
message: 'toolBundle.mcpServers[0] is invalid',
});
expect(parseRunToolBundleForRequest({
mcpServers: [
{
id: 'dup',
transport: 'stdio',
command: 'node',
},
{
id: 'dup',
transport: 'http',
url: 'https://example.test/mcp',
},
],
})).toEqual({
ok: false,
message: 'toolBundle.mcpServers[1] duplicates server id "dup"',
});
});
it('lets a run-scoped server override persisted config without inheriting persisted tokens', () => {
const persistedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'shared',
transport: 'http',
url: 'https://persisted.example.test/mcp',
},
{
id: 'persisted-only',
transport: 'http',
url: 'https://persisted-only.example.test/mcp',
},
],
}).mcpServers;
const runScopedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'shared',
transport: 'http',
url: 'https://run.example.test/mcp',
headers: { Authorization: 'Bearer run-token' },
},
],
}).mcpServers;
const selection = resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode: false,
});
expect(selection.enabledServers).toHaveLength(2);
expect(selection.enabledServers.find((server) => server.id === 'shared')).toMatchObject({
url: 'https://run.example.test/mcp',
});
expect([...selection.persistedTokenServerIds]).toEqual(['persisted-only']);
});
it('rejects bundles for runtimes that cannot receive the requested servers', () => {
const stdioOnly = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'local',
transport: 'stdio',
command: 'node',
},
],
});
const remote = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'remote',
transport: 'http',
url: 'https://example.test/mcp',
},
],
});
expect(validateRunToolBundleForAgent(stdioOnly, {
id: 'codex',
name: 'Codex CLI',
})).toEqual({
ok: false,
message: 'Codex CLI (codex) does not support run-scoped MCP tool bundles',
});
expect(validateRunToolBundleForAgent(remote, {
id: 'hermes',
name: 'Hermes',
externalMcpInjection: 'acp-merge',
})).toEqual({
ok: false,
message:
'toolBundle.mcpServers[0] uses http transport, but Hermes (hermes) only supports stdio run-scoped MCP servers',
});
expect(validateRunToolBundleForAgent(remote, {
id: 'claude',
name: 'Claude Code',
externalMcpInjection: 'claude-mcp-json',
})).toEqual({ ok: true });
expect(validateRunToolBundleForAgent(remote, {
id: 'claude',
name: 'Claude Code',
externalMcpInjection: 'claude-mcp-json',
}, {
deliveryTarget: 'external-project',
})).toEqual({
ok: false,
message:
'Claude Code (claude) receives run-scoped MCP tool bundles through project .mcp.json, ' +
'so toolBundle requires a daemon-managed project',
});
});
});

View file

@ -62,6 +62,27 @@ describe('chat run service shutdown', () => {
runs.list({ projectId: 'project-1', conversationId: 'conv-b', status: 'active' }),
).toEqual([runB]);
});
it('cancels a queued run immediately without waiting for child process shutdown', async () => {
const runs = createRuns();
const run = runs.create({ projectId: 'project-1', conversationId: 'conv-queued' });
const wait = runs.wait(run);
runs.cancel(run);
expect(run.status).toBe('canceled');
expect(run.cancelRequested).toBe(true);
expect(run.signal).toBe('SIGTERM');
expect(run.events.at(-1)).toMatchObject({
event: 'end',
data: { status: 'canceled', signal: 'SIGTERM' },
});
await expect(wait).resolves.toMatchObject({
status: 'canceled',
signal: 'SIGTERM',
});
});
it('stores effective media execution policy on run status bodies', () => {
const runs = createRuns();
@ -80,6 +101,45 @@ describe('chat run service shutdown', () => {
});
});
it('stores a run-scoped tool bundle and returns a redacted status summary', () => {
const runs = createRuns();
const run = runs.create({
projectId: 'project-1',
conversationId: 'conv-a',
toolBundle: {
mcpServers: [
{
id: 'run-tools',
transport: 'stdio',
command: 'node',
args: ['server.js', '--token=secret'],
env: { API_TOKEN: 'secret' },
},
],
},
}) as any;
expect(run.toolBundle.mcpServers).toHaveLength(1);
expect(run.toolBundle.mcpServers[0]).toMatchObject({
id: 'run-tools',
command: 'node',
env: { API_TOKEN: 'secret' },
});
const status = runs.statusBody(run);
expect(status.toolBundle).toEqual({
mcpServers: [
{
id: 'run-tools',
transport: 'stdio',
enabled: true,
},
],
});
expect(JSON.stringify(status)).not.toContain('secret');
expect(JSON.stringify(status)).not.toContain('server.js');
});
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
const runs = createRuns();
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });

View file

@ -1,7 +1,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { test } from 'vitest';
import {
AGENT_DEFS, aider, antigravity, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync,
AGENT_DEFS, aider, antigravity, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, grokBuild, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync,
} from './helpers/test-helpers.js';
import { writeAntigravityModelSelection } from '../../src/runtimes/defs/antigravity.js';
import type { TestAgentDef } from './helpers/test-helpers.js';
@ -756,6 +756,29 @@ test('codex buildArgs omits model_reasoning_effort when reasoning is "default"',
);
});
test('grok-build inlines the prompt as -p <value> and never falls back to stdin sentinels', () => {
const prompt = 'summarize the current page layout';
const args = grokBuild.buildArgs(
prompt,
[],
[],
{ model: 'grok-4.3', reasoning: 'high' },
{ cwd: '/tmp/od-project' },
);
assert.equal(grokBuild.promptViaStdin, false);
assert.deepEqual(args, [
'-p',
prompt,
'--model',
'grok-4.3',
'--effort',
'high',
]);
assert.equal(args.includes('-'), false);
assert.equal(args.filter((entry) => entry === '-p').length, 1);
});
test('claude flags promptViaStdin and never embeds the prompt in argv', () => {
// Long composed prompts (system prompt + design system + skill body +
// user message) routinely exceed Linux MAX_ARG_STRLEN (~128 KB) and the

View file

@ -588,6 +588,56 @@ fsTest('detectAgents marks AMR available from packaged built-in Vela with the bu
}
});
fsTest('detectAgents prefers configured AMR live models over stale fallback defaults', async () => {
const root = mkdtempSync(join(tmpdir(), 'od-detect-amr-live-models-'));
try {
return await withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], async () => {
const fakeVela = join(root, 'vela');
const fakeOpenCode = join(root, 'opencode');
writeFileSync(
fakeVela,
`#!/bin/sh
if [ "$1" = "--version" ]; then echo "vela custom-live"; exit 0; fi
if [ "$1" = "models" ]; then printf "%s\n" "public_model_deepseek_v4_flash vela" "public_model_glm_5 vela"; exit 0; fi
exit 0
`,
);
writeFileSync(fakeOpenCode, `#!/bin/sh
exit 0
`);
chmodSync(fakeVela, 0o755);
chmodSync(fakeOpenCode, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
delete process.env.OD_RESOURCE_ROOT;
delete process.env.VELA_OPENCODE_BIN;
const agents = await detectAgents({
amr: {
VELA_BIN: fakeVela,
VELA_OPENCODE_BIN: fakeOpenCode,
},
});
const amrAgent = agents.find((agent) => agent.id === 'amr');
assert.ok(amrAgent);
assert.equal(amrAgent.available, true);
assert.equal(amrAgent.path, fakeVela);
assert.equal(amrAgent.version, 'vela custom-live');
assert.equal(amrAgent.modelsSource, 'live');
assert.deepEqual(amrAgent.models, [
{ id: 'deepseek-v4-flash', label: 'deepseek-v4-flash' },
{ id: 'glm-5', label: 'glm-5' },
]);
assert.equal(amrAgent.models.some((model) => model.id === 'default'), false);
assert.equal(amrAgent.models.some((model) => model.id === 'gpt-5.4-mini'), false);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
function codexNativeTargetTriple(): string {
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';
@ -957,6 +1007,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when claude resolves to OpenClaude fallback', () => {
const env = spawnEnvForAgent(
'claude',
{
ANTHROPIC_API_KEY: 'sk-openclaude',
PATH: '/usr/bin',
},
{},
{},
{ resolvedBin: '/tools/openclaude' },
);
assert.equal(env.ANTHROPIC_API_KEY, 'sk-openclaude');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
const env = spawnEnvForAgent(agentId, {

View file

@ -200,6 +200,16 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
expect(out).not.toContain('Reference prompt template');
});
it('non-media dispatch hint includes fal-ai/* passthrough instruction', () => {
const out = composeSystemPrompt({
metadata: { kind: 'prototype' },
});
expect(out).toContain('## Media generation (if asked)');
expect(out).toContain('fal-ai/*');
expect(out).toContain('pass it through as-is without substitution');
});
it('renders without source attribution when the source field is missing', () => {
const { source: _omit, ...withoutSource } = baseSummary;
const out = composeSystemPrompt({
@ -420,8 +430,8 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
},
});
expect(out).toContain('`media generate` treats the handoff as');
expect(out).toContain('exit `0` so the first dispatch does not look like a failed shell call');
expect(out).toContain('always exits 0');
expect(out).toContain('as a handoff signal');
expect(out).toContain('`"$OD_NODE_BIN" "$OD_BIN" media generate` exits `0`');
expect(out).toContain('either `file` or `taskId`');
expect(out).toContain('`2` from `media wait` is not a failure');

View file

@ -1,62 +0,0 @@
---
/*
* Shared skill row used on `/skills/`, `/skills/mode/<slug>/`,
* `/skills/scenario/<slug>/`, and any future faceted view.
*
* Renders a `<li class="catalog-row catalog-row-skill">` with the
* canonical 5-column grid (index, thumb, body, meta, arrow). Centralizes
* the markup so all faceted views stay visually identical to the
* unfiltered index.
*/
import type { SkillRecord } from '../_lib/catalog';
import { localeFromPath, localizedHref } from '../i18n';
export interface Props {
skill: SkillRecord;
index: number;
}
const { skill, index } = Astro.props;
const locale = localeFromPath(Astro.url.pathname);
const href = (path: string) => localizedHref(path, locale);
// Catalog row thumbs are tiny (~130×80 rendered, single-format PNGs)
// so we deliberately bypass the precise IntersectionObserver pipeline.
// On long lists like /skills/instructions/ (96 rows) the observer's
// swap latency stranded mid-page rows on the SVG placeholder during
// fast scrolls. Native lazy loading (the browser's own 1250-3000px
// lookahead) keeps the upcoming rows pre-fetched without the
// observer round-trip; only the first three rows go eager so they
// paint immediately on first paint instead of waiting for the
// browser's lazy queue.
const eager = index < 3;
---
<li class="catalog-row catalog-row-skill">
<a href={href(`/skills/${skill.slug}/`)}>
<span class="row-index">{String(index + 1).padStart(3, '0')}</span>
<span class="row-thumb">
{skill.previewUrl ? (
<img
src={skill.previewUrl}
alt=""
loading={eager ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={eager ? 'high' : 'auto'}
/>
) : (
<span class="row-thumb-empty" aria-hidden="true" />
)}
</span>
<span class="row-body">
<span class="row-name">{skill.name}</span>
<span class="row-desc">{skill.description}</span>
</span>
<span class="row-meta">
{skill.modeLabel && <span class="meta-tag">{skill.modeLabel}</span>}
{skill.scenarioLabel && <span class="meta-tag muted">{skill.scenarioLabel}</span>}
{skill.platformLabel && <span class="meta-tag muted">{skill.platformLabel}</span>}
</span>
<span class="row-arrow" aria-hidden="true">→</span>
</a>
</li>

View file

@ -1,8 +1,14 @@
---
/*
* Shared system card used on `/systems/` and
* `/systems/category/<slug>/`. Displays palette swatches, name,
* category, and tagline as a clickable card.
* Shared system card used on `/plugins/systems/`. Displays palette
* swatches, name, category, and tagline as a clickable card.
*
* The card links to `/systems/<slug>/`, which `public/_redirects`
* 301s to the bundled-plugin detail (`/plugins/design-system-<slug>/`)
* for the 142 systems that have one, and degrades the 8 without a
* detail page to `/plugins/systems/`. Linking through the redirect
* (rather than hard-coding `design-system-<slug>`) keeps those 8 from
* pointing at a non-existent detail page.
*/
import type { SystemRecord } from '../_lib/catalog';
import { localeFromPath, localizedHref } from '../i18n';

View file

@ -222,9 +222,9 @@ const INFO_PAGE_COPY: Partial<Record<LandingLocaleCode, InfoPageCopy>> = {
{ label: 'Community', name: 'Discord' },
{ label: 'Documentation', name: 'GitHub README' },
{ label: 'License', name: 'Apache-2.0' },
{ label: 'Skills catalog', name: '/skills/' },
{ label: 'Systems catalog', name: '/systems/' },
{ label: 'Templates catalog', name: '/templates/' },
{ label: 'Skills catalog', name: '/plugins/skills/' },
{ label: 'Systems catalog', name: '/plugins/systems/' },
{ label: 'Templates catalog', name: '/plugins/templates/' },
],
aliasesTitle: 'Naming & aliases',
aliasesLead:
@ -538,9 +538,9 @@ INFO_PAGE_COPY.zh = {
{ label: '社区', name: 'Discord' },
{ label: '文档', name: 'GitHub README' },
{ label: '许可证', name: 'Apache-2.0' },
{ label: 'Skill 目录', name: '/skills/' },
{ label: '系统目录', name: '/systems/' },
{ label: '模板目录', name: '/templates/' },
{ label: 'Skill 目录', name: '/plugins/skills/' },
{ label: '系统目录', name: '/plugins/systems/' },
{ label: '模板目录', name: '/plugins/templates/' },
],
aliasesTitle: '命名与别名',
aliasesLead: '不同工具、受众和语言环境里,这个项目会以几种方式被搜索和书写:',
@ -1027,9 +1027,9 @@ const sourceNames = [
'Discord',
'GitHub README',
'Apache-2.0',
'/skills/',
'/systems/',
'/templates/',
'/plugins/skills/',
'/plugins/systems/',
'/plugins/templates/',
] as const;
const aliasLabels = [

View file

@ -730,23 +730,23 @@ export default function Page({
</h2>
</div>
<div className='pills' data-reveal='right'>
<a className='pill active' href={href('/skills/')}>
<a className='pill active' href={href('/plugins/skills/')}>
{home.labs.pills.all}
<span className='count'>{skills}</span>
</a>
<a className='pill' href={href('/skills/mode/prototype/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.prototype}
<span className='count'>{prototypeCount}</span>
</a>
<a className='pill' href={href('/skills/mode/deck/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.deck}
<span className='count'>{deckCount}</span>
</a>
<a className='pill' href={href('/skills/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.mobile}
<span className='count'>{mobileCount}</span>
</a>
<a className='pill' href={href('/skills/')}>
<a className='pill' href={href('/plugins/templates/')}>
{home.labs.pills.office}
<span className='count'></span>
</a>
@ -839,7 +839,7 @@ export default function Page({
{home.labs.foot(skills)}
{NBSP}·{NBSP}
<a
href={href('/skills/')}
href={href('/plugins/skills/')}
className='library-link'
style={{ color: 'var(--coral)' }}
>
@ -953,7 +953,7 @@ export default function Page({
{home.work.titleSuffix}
<span className='dot'>.</span>
</h2>
<a className='work-link' href={href('/skills/')}>
<a className='work-link' href={href('/plugins/skills/')}>
{home.work.viewAll(skills)}
</a>
</div>
@ -1325,17 +1325,17 @@ export default function Page({
<h5>{home.footer.columns.library}</h5>
<ul>
<li>
<a href={href('/skills/')}>
<a href={href('/plugins/skills/')}>
{home.footer.libraryLinks.skills(skills)}
</a>
</li>
<li>
<a href={href('/systems/')}>
<a href={href('/plugins/systems/')}>
{home.footer.libraryLinks.systems(systems)}
</a>
</li>
<li>
<a href={href('/templates/')}>
<a href={href('/plugins/templates/')}>
{home.footer.libraryLinks.templates}
</a>
</li>

View file

@ -2,17 +2,7 @@
import { getCollection } from 'astro:content';
import Layout from '../../_components/sub-page-layout.astro';
import type { HeaderProps } from '../../_components/header';
import LazyImg from '../../_components/lazy-img.astro';
import {
getCraftRecords,
getSkillModeIndex,
getSkillRecords,
getSkillScenarioIndex,
getSystemCategoryIndex,
getSystemRecords,
getTemplateRecords,
tally,
} from '../../_lib/catalog';
import { getCraftRecords } from '../../_lib/catalog';
import {
PREFIXED_LOCALES,
getCopy,
@ -23,31 +13,17 @@ import {
import '../../globals.css';
import '../../sub-pages.css';
// Localized routing only generates listing/index pages. Detail pages
// (individual skills, posts, templates, …) stay at canonical English
// URLs to keep the static build bounded; the localized chrome links
// straight to those canonical detail URLs.
// Localized routing only generates the `craft` and `blog` listing pages.
// Detail pages (individual posts, craft items, …) stay at canonical
// English URLs to keep the static build bounded; the localized chrome
// links straight to those canonical detail URLs.
export async function getStaticPaths() {
const skillModes = await getSkillModeIndex();
const skillScenarios = await getSkillScenarioIndex();
const systemCategories = await getSystemCategoryIndex();
const paths = [
'skills',
'systems',
'craft',
'templates',
'blog',
// Plugins library is generated via short-code wrappers under
// `app/pages/[locale]/plugins/` (mirroring the `[locale]/skills/`,
// `[locale]/systems/`, etc. pattern), so it does NOT participate
// in this long-code catch-all. Both surfaces co-exist in `out/`
// because `_redirects` maps `/zh-CN/*` → `/zh/*` for the long-form
// routes; plugins lives under the short-form path only.
...skillModes.map((item) => `skills/mode/${item.slug}`),
...skillScenarios.map((item) => `skills/scenario/${item.slug}`),
...systemCategories.map((item) => `systems/category/${item.slug}`),
];
// The skills / systems / templates catalogs moved under `/plugins/*`.
// Their old localized listings are now 301'd by `public/_redirects`,
// so this catch-all only renders the localized `craft` and `blog`
// listings. Plugins itself is generated via short-code wrappers under
// `app/pages/[locale]/plugins/`, so it does NOT participate here.
const paths = ['craft', 'blog'];
return PREFIXED_LOCALES.flatMap((locale) =>
paths.map((path) => ({
@ -62,36 +38,20 @@ const copy = getCopy(locale);
const pathParam = Astro.params.path ?? '';
const segments = pathParam.split('/').filter(Boolean);
const [skills, systems, craft, templates, posts] = await Promise.all([
getSkillRecords(),
getSystemRecords(),
const [craft, posts] = await Promise.all([
getCraftRecords(),
getTemplateRecords(),
getCollection('blog'),
]);
// All cross-locale subpage links resolve to canonical (English) URLs.
const href = (path: string) => path;
const titleSuffix = 'Open Design';
const routeRoot = segments[0] ?? '';
const routeSecond = segments[1] ?? '';
const routeThird = segments[2] ?? '';
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const modeTags = await getSkillModeIndex();
const scenarioTags = await getSkillScenarioIndex();
const systemCategories = await getSystemCategoryIndex();
const platformTally = tally(skills.map((skill) => skill.platform).filter((item): item is string => Boolean(item)));
const pageTitle = routeRoot === 'skills'
? `${copy.skillsTitle} — ${skills.length} | ${titleSuffix}`
: routeRoot === 'systems'
? `${copy.systemsTitle} — ${systems.length} | ${titleSuffix}`
: routeRoot === 'templates'
? `${copy.templatesTitle} — ${templates.length} | ${titleSuffix}`
: routeRoot === 'craft'
? `${copy.craftTitle} — ${craft.length} | ${titleSuffix}`
: `${copy.blog} — ${titleSuffix}`;
const pageTitle = routeRoot === 'craft'
? `${copy.craftTitle} — ${craft.length} | ${titleSuffix}`
: `${copy.blog} — ${titleSuffix}`;
const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
---
@ -123,61 +83,6 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
</>
)}
{routeRoot === 'skills' && (
<>
<header class='catalog-head'>
<span class='label'>{copy.catalog} · Nº 01</span>
<h1 class='display'><em>{copy.skillsTitle}</em> — {skills.length} composable design capabilities<span class='dot'>.</span></h1>
<p class='lead'>Each skill is a folder with one <code>SKILL.md</code>. Drop it in, restart the daemon, and the picker shows it.</p>
</header>
{routeSecond === '' && (
<section class='filter-strip' aria-label='Skill filters'>
<div class='filter-group'>
<span class='filter-label'>{copy.mode}</span>
<ul>{modeTags.map((tag) => <li><a class='chip chip-link' href={href(`/skills/mode/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
</div>
<div class='filter-group'>
<span class='filter-label'>{copy.scenario}</span>
<ul>{scenarioTags.slice(0, 12).map((tag) => <li><a class='chip chip-link' href={href(`/skills/scenario/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
</div>
<div class='filter-group'>
<span class='filter-label'>{copy.platform}</span>
<ul>{platformTally.map(([key, count]) => <li><span class='chip'>{key}<span class='chip-num'>{count}</span></span></li>)}</ul>
</div>
</section>
)}
<section class='catalog-grid catalog-grid-skills'>
<ol>
{skills
.filter((skill) => routeSecond === 'mode' ? skill.mode === routeThird : routeSecond === 'scenario' ? skill.scenario === routeThird : true)
.map((skill, index) => (
<li class='catalog-row'>
<a href={href(`/skills/${skill.slug}/`)}>
<span class='row-index'>{String(index + 1).padStart(2, '0')}</span>
<span class='row-body'><span class='row-name'>{skill.name}</span><span class='row-desc'>{skill.description}</span></span>
{skill.mode && <span class='meta-tag'>{skill.mode}</span>}
</a>
</li>
))}
</ol>
</section>
</>
)}
{routeRoot === 'systems' && (
<>
<header class='catalog-head'>
<span class='label'>{copy.catalog} · Nº 02</span>
<h1 class='display'><em>{copy.systemsTitle}</em> — {systems.length} portable visual systems<span class='dot'>.</span></h1>
<p class='lead'>Each system is a single <code>DESIGN.md</code> token spec that keeps colors, type, spacing, and components consistent.</p>
</header>
{routeSecond === '' && <section class='filter-strip'><div class='filter-group'><span class='filter-label'>{copy.category}</span><ul>{systemCategories.map((tag) => <li><a class='chip chip-link' href={href(`/systems/category/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul></div></section>}
<section class='catalog-grid systems-grid'>
<ul>{systems.filter((system) => routeSecond === 'category' ? system.category === routeThird : true).map((system) => <li class='system-card'><a href={href(`/systems/${system.slug}/`)}><span class='system-name'>{system.name}</span><p>{system.tagline}</p><span class='meta-tag'>{system.category}</span></a></li>)}</ul>
</section>
</>
)}
{routeRoot === 'craft' && (
<>
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 03</span><h1 class='display'><em>{copy.craftTitle}</em> — {craft.length} rendering principles<span class='dot'>.</span></h1><p class='lead'>Quality rules for accessibility, motion, color, type, and state coverage.</p></header>
@ -185,11 +90,4 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
</>
)}
{routeRoot === 'templates' && (
<>
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 04</span><h1 class='display'><em>{copy.templatesTitle}</em> — {templates.length} ready-to-fork artifacts<span class='dot'>.</span></h1><p class='lead'>Pre-wired artifact bundles with examples, visual language, and agent instructions.</p></header>
<section class='template-grid'><ul>{templates.map((template, index) => <li class='template-card'><a href={href(template.detailHref)}>{template.previewUrl && <span class='template-thumb'><LazyImg src={template.previewUrl} alt='' loading={index < 4 ? 'eager' : 'precise'} /></span>}<span class='template-name'>{template.name}</span><p class='template-summary'>{template.summary}</p></a></li>)}</ul></section>
</>
)}
</Layout>

View file

@ -1,19 +0,0 @@
---
import SkillPage, {
getStaticPaths as getSkillStaticPaths,
} from '../../skills/[slug]/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSkillStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SkillPage {...Astro.props} />

View file

@ -1,12 +0,0 @@
---
import SkillsPage from '../../skills/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export function getStaticPaths() {
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
(locale) => ({ params: { locale: locale.code } }),
);
}
---
<SkillsPage />

View file

@ -1,19 +0,0 @@
---
import SkillModePage, {
getStaticPaths as getSkillModeStaticPaths,
} from '../../../skills/mode/[mode].astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSkillModeStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SkillModePage {...Astro.props} />

View file

@ -1,19 +0,0 @@
---
import SkillScenarioPage, {
getStaticPaths as getSkillScenarioStaticPaths,
} from '../../../skills/scenario/[scenario].astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSkillScenarioStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SkillScenarioPage {...Astro.props} />

View file

@ -1,19 +0,0 @@
---
import SystemPage, {
getStaticPaths as getSystemStaticPaths,
} from '../../systems/[slug].astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSystemStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SystemPage {...Astro.props} />

View file

@ -1,19 +0,0 @@
---
import SystemCategoryPage, {
getStaticPaths as getSystemCategoryStaticPaths,
} from '../../../systems/category/[category].astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
export async function getStaticPaths() {
const basePaths = await getSystemCategoryStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<SystemCategoryPage {...Astro.props} />

View file

@ -1,12 +0,0 @@
---
import SystemsPage from '../../systems/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export function getStaticPaths() {
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
(locale) => ({ params: { locale: locale.code } }),
);
}
---
<SystemsPage />

View file

@ -1,19 +0,0 @@
---
import TemplatePage, {
getStaticPaths as getTemplateStaticPaths,
} from '../../templates/[slug]/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export async function getStaticPaths() {
const basePaths = await getTemplateStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<TemplatePage {...Astro.props} />

View file

@ -1,12 +0,0 @@
---
import TemplatesPage from '../../templates/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export function getStaticPaths() {
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
(locale) => ({ params: { locale: locale.code } }),
);
}
---
<TemplatesPage />

View file

@ -207,8 +207,8 @@ const jsonLd = [
<h2>{page.nextTitle}</h2>
<ul>
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
</ul>
</section>

View file

@ -81,7 +81,7 @@ const bottomCta =
? {
title: ui.blog.cta.skillsTitle,
body: ui.blog.cta.skillsBody,
href: '/skills/',
href: '/plugins/skills/',
label: ui.blog.cta.skillsLabel,
external: false,
}

View file

@ -1058,7 +1058,7 @@ pnpm -F @html-anything/next dev
</p>
<p>
<a class="ha-btn" href={href('/')}>{copy.visitOpenDesign}</a>
<a class="ha-btn" href={href('/skills/')} rel="noopener">{copy.browseSkills}</a>
<a class="ha-btn" href={href('/plugins/skills/')} rel="noopener">{copy.browseSkills}</a>
<a class="ha-btn" href={HA_URL} rel="noopener">{copy.githubLink}</a>
</p>
</section>

View file

@ -45,9 +45,9 @@ const sources = [
{ ...page.sources[4], href: DISCORD },
{ ...page.sources[5], href: DOCS },
{ ...page.sources[6], href: REPO_LICENSE },
{ ...page.sources[7], href: href('/skills/') },
{ ...page.sources[8], href: href('/systems/') },
{ ...page.sources[9], href: href('/templates/') },
{ ...page.sources[7], href: href('/plugins/skills/') },
{ ...page.sources[8], href: href('/plugins/systems/') },
{ ...page.sources[9], href: href('/plugins/templates/') },
];
const jsonLd = [
@ -140,8 +140,8 @@ const jsonLd = [
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/agents/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
</ul>
</section>
</article>

View file

@ -12,10 +12,7 @@
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SystemCard from '../../../_components/system-card.astro';
import {
getSystemRecords,
getSystemCategoryIndex,
} from '../../../_lib/catalog';
import { getSystemRecords } from '../../../_lib/catalog';
import { getPluginsCopy } from '../../../_lib/plugins-i18n';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
@ -24,7 +21,6 @@ const ui = getLandingUiCopy(locale);
const pcopy = getPluginsCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const systems = await getSystemRecords(locale);
const categoryTags = await getSystemCategoryIndex(locale);
const title = `${pcopy.tileSystems} · ${systems.length} · Open Design`;
const description = pcopy.systemsLead;
@ -54,21 +50,6 @@ const jsonLd = {
<p class="lead">{pcopy.systemsLead}</p>
</header>
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
<div class="filter-group">
<span class="filter-label">{ui.catalog.systems.category}</span>
<ul>
{categoryTags.map((tag) => (
<li>
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
</section>
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
<ul>
{systems.map((s) => <SystemCard system={s} />)}

View file

@ -142,8 +142,8 @@ const jsonLd = [
<section class="info-section" id="next">
<h2>{page.nextTitle}</h2>
<ul>
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
<li><a class="inline-link" href={href('/compare/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
<li><a class="inline-link" href={REPO_RELEASES} target="_blank" rel="noreferrer noopener">{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
</ul>

View file

@ -1,472 +0,0 @@
---
/*
* /skills/<slug>/ — a detail page per skill.
*
* Two flavours render slightly differently:
* - `template` skills get a click-to-expand iframe of their
* `example.html` demo and stay deliberately brief — the demo is the
* content, the README is one click away on GitHub.
* - `instruction` skills (no runnable demo) instead render the full
* SKILL.md body inline, so the page reads like a brief: what the
* skill does, when it triggers, how to use it. Otherwise the page
* would be a one-line description and a row of CTAs.
*/
import { getEntry, render } from 'astro:content';
import Layout from '../../../_components/sub-page-layout.astro';
import LazyImg from '../../../_components/lazy-img.astro';
import { getSkillRecords, type SkillRecord } from '../../../_lib/catalog';
import {
getLandingUiCopy,
localeFromPath,
localizedHref,
type LandingLocaleCode,
} from '../../../i18n';
/*
* Localized share-copy template, keyed by landing locale. The brand
* keyword "open-source Claude Design alternative" stays in English
* because that's the canonical search query Google associates with
* the domain — translating it would split the entity claim. The
* surrounding sentence ("I'm using X from @opendesignai") translates
* per locale so the message reads as one coherent voice instead of
* mixing two scripts in a single share post.
*
* `{name}` and `{description}` are interpolated at render time.
* `{url}` is replaced with the canonical detail-page URL.
*/
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
en: ({ name, description, url }) => `🎨 Just discovered ${name} on @opendesignai — the open-source Claude Design alternative.
✨ Local-first · BYOK · your agent does the design.
→ ${url}`,
zh: ({ name, description, url }) => `🎨 安利一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
✨ 本地优先 · 自带模型 · 让你自己的 agent 做设计。
→ ${url}`,
'zh-tw': ({ name, description, url }) => `🎨 推薦一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
✨ 本地優先 · 自帶模型 · 讓你自己的 agent 做設計。
→ ${url}`,
ja: ({ name, description, url }) => `🎨 @opendesignai で ${name} を発見 —— オープンソースの Claude Design 代替。
✨ ローカル優先 · BYOK · あなたのエージェントが設計する。
→ ${url}`,
ko: ({ name, description, url }) => `🎨 @opendesignai에서 ${name} 발견 —— 오픈 소스 Claude Design 대안.
✨ 로컬 우선 · BYOK · 에이전트가 디자인합니다.
→ ${url}`,
de: ({ name, description, url }) => `🎨 Gerade entdeckt: ${name} auf @opendesignai — die Open-Source-Alternative zu Claude Design.
✨ Local-first · BYOK · dein Agent designt.
→ ${url}`,
fr: ({ name, description, url }) => `🎨 Découvert : ${name} sur @opendesignai — l'alternative open-source à Claude Design.
✨ Local-first · BYOK · votre agent fait le design.
→ ${url}`,
ru: ({ name, description, url }) => `🎨 Нашёл ${name} на @opendesignai — open-source альтернативу Claude Design.
✨ Локально · BYOK · агент сам делает дизайн.
→ ${url}`,
es: ({ name, description, url }) => `🎨 Acabo de descubrir ${name} en @opendesignai — la alternativa open-source a Claude Design.
✨ Local-first · BYOK · tu agente diseña.
→ ${url}`,
'pt-br': ({ name, description, url }) => `🎨 Acabei de descobrir ${name} no @opendesignai — a alternativa open-source ao Claude Design.
✨ Local-first · BYOK · seu agente faz o design.
→ ${url}`,
it: ({ name, description, url }) => `🎨 Ho appena scoperto ${name} su @opendesignai — l'alternativa open-source a Claude Design.
✨ Local-first · BYOK · il tuo agente progetta.
→ ${url}`,
vi: ({ name, description, url }) => `🎨 Vừa khám phá ${name} trên @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
✨ Ưu tiên local · BYOK · agent của bạn thiết kế.
→ ${url}`,
pl: ({ name, description, url }) => `🎨 Właśnie odkryłem ${name} na @opendesignai — open-source'ową alternatywę dla Claude Design.
✨ Local-first · BYOK · twój agent projektuje.
→ ${url}`,
id: ({ name, description, url }) => `🎨 Baru nemu ${name} di @opendesignai — alternatif open-source untuk Claude Design.
✨ Local-first · BYOK · agent kamu yang nge-desain.
→ ${url}`,
nl: ({ name, description, url }) => `🎨 Net ontdekt: ${name} op @opendesignai — het open-source alternatief voor Claude Design.
✨ Local-first · BYOK · jouw agent ontwerpt.
→ ${url}`,
ar: ({ name, description, url }) => `🎨 اكتشفت للتو ${name} على @opendesignai — البديل مفتوح المصدر لـ Claude Design.
✨ محلي أولًا · BYOK · وكيلك يصمّم.
→ ${url}`,
tr: ({ name, description, url }) => `🎨 Yeni keşfettim: ${name} (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
✨ Local-first · BYOK · ajanın tasarlıyor.
→ ${url}`,
uk: ({ name, description, url }) => `🎨 Щойно знайшов ${name} на @opendesignai — open-source альтернативу Claude Design.
✨ Local-first · BYOK · ваш агент робить дизайн.
→ ${url}`,
};
export async function getStaticPaths() {
const skills = await getSkillRecords();
return skills.map((skill) => ({
params: { slug: skill.slug },
props: { skill, all: skills },
}));
}
interface Props {
skill: SkillRecord;
all: ReadonlyArray<SkillRecord>;
}
const { skill: routeSkill, all: routeAll } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const all = locale === 'en' ? routeAll : await getSkillRecords(locale);
const skill = all.find((item) => item.slug === routeSkill.slug) ?? routeSkill;
const title = ui.catalog.skills.detailTitle(skill.name);
const description = skill.description.length > 0
? skill.description
: ui.catalog.skills.detailFallbackDescription(skill.name);
const skillUrl = `https://open-design.ai/skills/${skill.slug}/`;
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
name: skill.name,
description,
url: skillUrl,
});
// Share-dialog UI strings localized inline. Keeping them next to the
// page that uses them avoids growing the global UI bundle for what's
// effectively four short labels per locale.
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
en: { title: 'Share this skill', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
zh: { title: '分享这个 skill', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
'zh-tw': { title: '分享這個 skill', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
ja: { title: 'この skill を共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
ko: { title: '이 skill 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
de: { title: 'Diesen Skill teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
fr: { title: 'Partager ce skill', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
ru: { title: 'Поделиться скиллом', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
es: { title: 'Compartir este skill', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
'pt-br': { title: 'Compartilhar skill', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
it: { title: 'Condividi lo skill', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
vi: { title: 'Chia sẻ skill', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
pl: { title: 'Udostępnij ten skill', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
id: { title: 'Bagikan skill ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
nl: { title: 'Deel deze skill', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
ar: { title: 'شارك هذه المهارة', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
tr: { title: 'Bu skilli paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
uk: { title: 'Поділитись скілом', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
};
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
const related = all
.filter((s) => s.slug !== skill.slug)
.filter((s) => s.mode === skill.mode || s.scenario === skill.scenario)
.slice(0, 4);
/*
* Instruction skills don't have a runnable demo to iframe — to avoid
* a near-empty detail page, render the SKILL.md prose inline so the
* page reads like a brief. Template skills keep the page deliberately
* brief because their demo is the content; their full SKILL.md is one
* "Find on GitHub" click away.
*
* Astro 6 exposes the markdown pipeline through a top-level
* `render(entry)` helper rather than the legacy `entry.render()`
* method. The output (heading anchors, smart-typography, GFM
* tables) styles cleanly with the existing `.detail-md` rules.
*/
const skillEntry =
skill.kind === 'instruction' ? await getEntry('skills', `${skill.slug}/SKILL`) : null;
const SkillBody = skillEntry ? (await render(skillEntry)).Content : null;
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: ui.catalog.skills.detailLabel, item: new URL('/skills/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: skill.name, item: new URL(`/skills/${skill.slug}/`, Astro.site).toString() },
],
},
{
'@context': 'https://schema.org',
'@type': 'SoftwareSourceCode',
name: skill.name,
description,
codeRepository: skill.source,
programmingLanguage: 'Markdown',
keywords: skill.triggers.join(', '),
license: 'https://www.apache.org/licenses/LICENSE-2.0',
},
];
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span>/</span>
<span aria-current="page">{skill.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.skills.detailLabel}
{typeof skill.featured === 'number' && (
<span class="ix">{ui.catalog.skills.featuredNumber(String(skill.featured).padStart(2, '0'))}</span>
)}
</span>
<h1 class="display">{skill.name}<span class="dot">.</span></h1>
<p class="lead">{description}</p>
<div class="detail-actions">
{/*
Two primary CTAs. "Use this skill" v1 sends users to the OD
desktop release page — install the app first, then run the
skill. Routing here rather than to /quickstart/ keeps the
flow concrete (download a binary now) instead of asking
users to read an install doc. Once the desktop client
exposes a registered URL scheme, this anchor flips to a
JS-driven `od://skill/<slug>` try + fallback without
changing the page surface.
*/}
<a
class="btn btn-primary"
href="https://github.com/nexu-io/open-design/releases"
target="_blank"
rel="noopener"
>
Use this skill →
</a>
<a
class="btn btn-ghost"
href={skill.source}
target="_blank"
rel="noopener"
>
Find on GitHub →
</a>
{skill.upstream && (
<a class="btn btn-ghost" href={skill.upstream} target="_blank" rel="noopener">
{ui.catalog.skills.upstream}
</a>
)}
<button
type="button"
class="btn btn-ghost detail-share-trigger"
data-share-open={`skill:${skill.slug}`}
>
{shareUi.openLabel}
</button>
</div>
</header>
{skill.kind === 'template' && skill.previewUrl && (
<figure class="detail-preview">
{/*
Click-to-expand interactive preview. Only template-kind skills
ship a runnable example.html, so this block is gated on kind
rather than just `previewUrl` — instruction skills now have a
synthesized cover thumbnail too, but no iframe target. The
thumb is the summary of a `<details>` element: clicking opens
the live iframe, replacing the thumb with the canonical
`<slug>/example.html` rendered inside a sandboxed frame.
*/}
<details class="detail-preview-live">
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${skill.name}`}>
<LazyImg
src={skill.previewUrl}
alt={`${skill.name} example output`}
loading="priority"
/>
<span class="detail-preview-thumb-overlay" aria-hidden="true">
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
</span>
</summary>
<div class="detail-preview-frame-wrap">
<iframe
src={`/skills/${skill.slug}/example.html`}
title={`${skill.name} interactive preview`}
loading="lazy"
sandbox="allow-scripts allow-same-origin"
class="detail-preview-frame"
/>
<a
class="detail-preview-popout"
href={`/skills/${skill.slug}/example.html`}
target="_blank"
rel="noopener"
aria-label="Open preview in new tab"
>
Open in new tab ↗
</a>
</div>
</details>
<figcaption>
{ui.catalog.skills.previewCaption(skill.slug)}
</figcaption>
</figure>
)}
{/*
Share modal — opens a `<dialog>` containing the canonical share
copy (with the brand keyword "open-source Claude Design
alternative" baked in), a one-click "Copy" button, and a row of
platform jump buttons. Each platform button just opens the
vendor's compose URL — the user pastes the already-copied text.
This works around a real cross-platform pain point: LinkedIn /
Facebook ignore pre-fill `text` params, X has length limits that
truncate Chinese content unpredictably, and Reddit's title param
survives but title-only is a weak signal. Copy-then-paste is
uniformly reliable.
The trigger sits inside `.detail-actions` instead of as a
separate row below `.detail-meta` so it has visual weight equal
to the primary CTAs. Joey called this out specifically.
*/}
<dialog
class="detail-share-dialog"
data-share-dialog={`skill:${skill.slug}`}
>
<form method="dialog" class="detail-share-dialog-form">
<header class="detail-share-dialog-head">
<h2>{shareUi.title}</h2>
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
</header>
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
<textarea
class="detail-share-dialog-text"
readonly
rows="6"
data-share-text
>{shareCopy}</textarea>
<div class="detail-share-dialog-actions">
<button
type="button"
class="btn btn-primary detail-share-dialog-copy"
data-share-copy
>
{shareUi.copyText}
</button>
<button
type="button"
class="btn btn-ghost detail-share-dialog-copy-link"
data-copy-link={skillUrl}
>
{shareUi.copyLink}
</button>
</div>
{/*
Platform jump buttons — official brand logos rendered as
inline SVG (no third-party icon font, no client JS). Each
opens the vendor's compose surface in a new tab; the user
pastes the already-copied text. Email channel was dropped
per Joey's revision; the four channels here cover the
highest-value SEO + virality surfaces.
*/}
<div class="detail-share-dialog-platforms">
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
<span class="sr-only">X</span>
</a>
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
<span class="sr-only">LinkedIn</span>
</a>
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
<span class="sr-only">Reddit</span>
</a>
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<span class="sr-only">Facebook</span>
</a>
</div>
</form>
</dialog>
<dl class="detail-meta">
{skill.mode && (
<Fragment>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{skill.modeLabel ?? skill.mode}</dd>
</Fragment>
)}
{skill.scenario && (
<Fragment>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{skill.scenarioLabel ?? skill.scenario}</dd>
</Fragment>
)}
{skill.platform && (
<Fragment>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{skill.platformLabel ?? skill.platform}</dd>
</Fragment>
)}
{skill.category && (
<Fragment>
<dt>{ui.catalog.systems.category}</dt>
<dd>{skill.categoryLabel ?? skill.category}</dd>
</Fragment>
)}
</dl>
{skill.triggers.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.triggers}</h2>
<p class="block-lead">
{ui.catalog.skills.triggersLead}
</p>
<ul class="trigger-list">
{skill.triggers.map((t) => <li><code>{t}</code></li>)}
</ul>
</section>
)}
{skill.examplePrompt && (
<section class="detail-block">
<h2>{ui.catalog.skills.examplePrompt}</h2>
<pre class="example-prompt">{skill.examplePrompt}</pre>
</section>
)}
{SkillBody && (
<section class="detail-block detail-md">
<h2>About this skill</h2>
<SkillBody />
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.related}</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={href(`/skills/${r.slug}/`)}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.description}</span>
<span class="related-meta">
{r.modeLabel && <span class="meta-tag">{r.modeLabel}</span>}
{r.scenarioLabel && <span class="meta-tag muted">{r.scenarioLabel}</span>}
</span>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -1,133 +0,0 @@
---
/*
* /skills/ — index of every shippable skill in the repo.
*
* Pulls live data from `skills/<slug>/SKILL.md` via Astro Content
* Collections so adding a skill anywhere in the monorepo
* automatically surfaces here on the next build.
*/
import Layout from '../../_components/sub-page-layout.astro';
import LazyImg from '../../_components/lazy-img.astro';
import SkillRow from '../../_components/skill-row.astro';
import {
getSkillRecords,
getSkillModeIndex,
getSkillScenarioIndex,
tally,
} from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const skills = await getSkillRecords(locale);
const modeTags = await getSkillModeIndex(locale);
const scenarioTags = await getSkillScenarioIndex(locale);
const platformTally = tally(
skills.map((s) => s.platformLabel).filter((p): p is string => Boolean(p)),
);
const featured = skills.filter((s) => typeof s.featured === 'number').slice(0, 6);
const title = ui.catalog.skills.title(skills.length);
const description = ui.catalog.skills.description;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: new URL('/skills/', Astro.site).toString(),
isPartOf: {
'@type': 'WebSite',
name: 'Open Design',
url: Astro.site?.toString(),
},
numberOfItems: skills.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">{ui.catalog.skills.label}</span>
<h1 class="display">
{ui.catalog.skills.heading(skills.length)}
</h1>
<p class="lead">
{ui.catalog.skills.lead}
</p>
</header>
<section class="filter-strip" aria-label={ui.catalog.skills.allAria}>
<div class="filter-group">
<span class="filter-label">{ui.catalog.skills.mode}</span>
<ul>
{modeTags.map((tag) => (
<li>
<a class="chip chip-link" href={href(`/skills/mode/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
<div class="filter-group">
<span class="filter-label">{ui.catalog.skills.scenario}</span>
<ul>
{scenarioTags.slice(0, 12).map((tag) => (
<li>
<a class="chip chip-link" href={href(`/skills/scenario/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
{platformTally.length > 0 && (
<div class="filter-group">
<span class="filter-label">{ui.catalog.skills.platform}</span>
<ul>
{platformTally.map(([key, count]) => (
<li>
<span class="chip">
{key}<span class="chip-num">{count}</span>
</span>
</li>
))}
</ul>
</div>
)}
</section>
{featured.length > 0 && (
<section class="featured-strip" aria-labelledby="featured-skills">
<h2 id="featured-skills" class="strip-title">{ui.catalog.skills.featured}</h2>
<ul class="featured-grid">
{featured.map((s, i) => (
<li class="featured-card">
<a href={href(`/skills/${s.slug}/`)}>
{s.previewUrl ? (
<span class="featured-thumb">
<LazyImg src={s.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
</span>
) : (
<span class="featured-thumb featured-thumb-empty" aria-hidden="true" />
)}
<span class="featured-num">Nº {String(s.featured).padStart(2, '0')}</span>
<span class="featured-name">{s.name}</span>
<p>{s.description}</p>
{s.modeLabel && <span class="meta-tag">{s.modeLabel}</span>}
</a>
</li>
))}
</ul>
</section>
)}
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
<ol>
{skills.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -1,78 +0,0 @@
---
/*
* /skills/mode/<slug>/ — every skill that emits a given artifact mode
* (deck, prototype, template, image, video, audio, design-system, utility).
*
* One static page per distinct `od.mode` value. Mode is the strongest
* mental-model facet ("I want a deck-builder") so this is the primary
* faceted view; scenario/category live alongside.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SkillRow from '../../../_components/skill-row.astro';
import {
getSkillModeIndex,
getSkillsForMode,
type TagDescriptor,
} from '../../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
export async function getStaticPaths() {
const tags = await getSkillModeIndex();
return tags.map((tag) => ({
params: { mode: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const { records, label } = await getSkillsForMode(tag.slug, locale);
const heading = label ?? tag.label;
const title = ui.catalog.skills.filterTitle(heading, records.length);
const description = ui.catalog.skills.modeDescription(heading, records.length);
const url = new URL(`/skills/mode/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span aria-hidden="true">/</span>
<span>{ui.catalog.skills.mode}</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">{ui.catalog.skills.label}</span>
<h1 class="display">
{ui.catalog.skills.modeHeading(heading, records.length)}
</h1>
<p class="lead">
{ui.catalog.skills.modeLead(label ?? tag.label)}
</p>
<p class="filter-clear">
<a href={href('/skills/')}>{ui.catalog.skills.allSkills(tag.count)}</a>
</p>
</header>
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
<ol>
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -1,77 +0,0 @@
---
/*
* /skills/scenario/<slug>/ — every skill targeting a given use-case
* scenario (marketing, engineering, design, research, ...).
*
* Mirrors the mode page but facets on `od.scenario`. One page per
* distinct scenario value found across all SKILL.md files.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SkillRow from '../../../_components/skill-row.astro';
import {
getSkillScenarioIndex,
getSkillsForScenario,
type TagDescriptor,
} from '../../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
export async function getStaticPaths() {
const tags = await getSkillScenarioIndex();
return tags.map((tag) => ({
params: { scenario: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const { records, label } = await getSkillsForScenario(tag.slug, locale);
const heading = label ?? tag.label;
const title = ui.catalog.skills.filterTitle(heading, records.length);
const description = ui.catalog.skills.scenarioDescription(heading, records.length);
const url = new URL(`/skills/scenario/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span aria-hidden="true">/</span>
<span>{ui.catalog.skills.scenario}</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">{ui.catalog.skills.label}</span>
<h1 class="display">
{ui.catalog.skills.scenarioHeading(heading, records.length)}
</h1>
<p class="lead">
{ui.catalog.skills.scenarioLead(label ?? tag.label)}
</p>
<p class="filter-clear">
<a href={href('/skills/')}>{ui.catalog.skills.allSkills()}</a>
</p>
</header>
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
<ol>
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -1,126 +0,0 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import { getSystemRecords, type SystemRecord } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
export async function getStaticPaths() {
const systems = await getSystemRecords();
return systems.map((system) => ({
params: { slug: system.slug },
props: { system, all: systems },
}));
}
interface Props {
system: SystemRecord;
all: ReadonlyArray<SystemRecord>;
}
const { system: routeSystem, all: routeAll } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const all = locale === 'en' ? routeAll : await getSystemRecords(locale);
const system = all.find((item) => item.slug === routeSystem.slug) ?? routeSystem;
const title = ui.catalog.systems.detailTitle(system.name);
const description = system.tagline
? `${system.name} (${system.categoryLabel}) — ${system.tagline}`
: ui.catalog.systems.detailFallbackDescription(system.name, system.categoryLabel);
const related = all
.filter((s) => s.slug !== system.slug && s.category === system.category)
.slice(0, 4);
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: ui.catalog.systems.detailLabel, item: new URL('/systems/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: system.name, item: new URL(`/systems/${system.slug}/`, Astro.site).toString() },
],
},
{
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: system.name,
description,
url: new URL(`/systems/${system.slug}/`, Astro.site).toString(),
license: 'https://www.apache.org/licenses/LICENSE-2.0',
genre: system.categoryLabel,
},
];
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
<span>/</span>
<span aria-current="page">{system.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.systems.detailLabel}
<span class="ix">· {system.categoryLabel}</span>
</span>
<h1 class="display">{system.name}<span class="dot">.</span></h1>
{system.tagline && <p class="lead">{system.tagline}</p>}
<div class="detail-actions">
<a class="btn btn-primary" href={system.source} target="_blank" rel="noopener">
{ui.catalog.systems.viewOnGithub}
</a>
</div>
</header>
{system.palette.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.systems.paletteSample}</h2>
<p class="block-lead">
{ui.catalog.systems.paletteLead(system.palette.length)}
</p>
<div class="palette-row">
{system.palette.map((hex) => (
<div class="palette-cell">
<span class="swatch" style={`background:${hex}`} />
<code>{hex}</code>
</div>
))}
</div>
</section>
)}
{system.atmosphere && (
<section class="detail-block">
<h2>{ui.catalog.systems.visualTheme}</h2>
<p class="atmosphere">{system.atmosphere}</p>
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.systems.related(system.categoryLabel)}</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={href(`/systems/${r.slug}/`)}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.tagline}</span>
<div class="system-swatches" aria-hidden="true">
{r.palette.slice(0, 4).map((hex) => (
<span class="swatch" style={`background:${hex}`} />
))}
</div>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -1,74 +0,0 @@
---
/*
* /systems/category/<slug>/ — every design system grouped by category
* (AI & LLM, Productivity & SaaS, Editorial, Brand, ...).
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SystemCard from '../../../_components/system-card.astro';
import {
getSystemCategoryIndex,
getSystemsForCategory,
type TagDescriptor,
} from '../../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
export async function getStaticPaths() {
const tags = await getSystemCategoryIndex();
return tags.map((tag) => ({
params: { category: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const { records, label } = await getSystemsForCategory(tag.slug, locale);
const heading = label ?? tag.label;
const title = ui.catalog.systems.categoryHeading(heading, records.length);
const description = ui.catalog.systems.categoryDescription(heading, records.length);
const url = new URL(`/systems/category/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
<span aria-hidden="true">/</span>
<span>{ui.catalog.systems.category}</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">{ui.catalog.systems.label}</span>
<h1 class="display">
{ui.catalog.systems.categoryHeading(heading, records.length)}
</h1>
<p class="lead">
{ui.catalog.systems.categoryLead(label ?? tag.label)}
</p>
<p class="filter-clear">
<a href={href('/systems/')}>{ui.catalog.systems.allSystems}</a>
</p>
</header>
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
<ul>
{records.map((s) => <SystemCard system={s} />)}
</ul>
</section>
</Layout>

View file

@ -1,61 +0,0 @@
---
/*
* /systems/ — index of every portable design system in the repo.
*/
import Layout from '../../_components/sub-page-layout.astro';
import SystemCard from '../../_components/system-card.astro';
import { getSystemRecords, getSystemCategoryIndex } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const systems = await getSystemRecords(locale);
const categoryTags = await getSystemCategoryIndex(locale);
const title = ui.catalog.systems.title(systems.length);
const description = ui.catalog.systems.description;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: new URL('/systems/', Astro.site).toString(),
numberOfItems: systems.length,
};
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">{ui.catalog.systems.label}</span>
<h1 class="display">
{ui.catalog.systems.heading(systems.length)}
</h1>
<p class="lead">
{ui.catalog.systems.lead}
</p>
</header>
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
<div class="filter-group">
<span class="filter-label">{ui.catalog.systems.category}</span>
<ul>
{categoryTags.map((tag) => (
<li>
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
</section>
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
<ul>
{systems.map((s) => <SystemCard system={s} />)}
</ul>
</section>
</Layout>

View file

@ -1,356 +0,0 @@
---
/*
* /templates/<slug>/ — detail page for renderable design templates and
* legacy Live Artifact template bundles.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import LazyImg from '../../../_components/lazy-img.astro';
import { getTemplateRecords, type TemplateRecord } from '../../../_lib/catalog';
import {
getLandingUiCopy,
localeFromPath,
localizedHref,
type LandingLocaleCode,
} from '../../../i18n';
/* See pages/skills/[slug]/index.astro for the rationale on why these
* tables live inline rather than in the global UI bundle. Same shape,
* just keyed for the templates surface. */
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
en: ({ name, description, url }) => `🎨 Just forked ${name} from @opendesignai — the open-source Claude Design alternative.
✨ Templates as files, not vendor docs. Fork → swap → ship.
→ ${url}`,
zh: ({ name, description, url }) => `🎨 fork 了一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
✨ 模板就是文件,不是 vendor 数据。Fork → 换数据 → 发。
→ ${url}`,
'zh-tw': ({ name, description, url }) => `🎨 fork 了一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
✨ 模板就是檔案,不是 vendor 資料。Fork → 換資料 → 發佈。
→ ${url}`,
ja: ({ name, description, url }) => `🎨 @opendesignai の ${name} を fork —— オープンソースの Claude Design 代替。
✨ テンプレートはファイル、ベンダー DB じゃない。Fork → 差し替え → 出荷。
→ ${url}`,
ko: ({ name, description, url }) => `🎨 @opendesignai의 ${name} fork —— 오픈 소스 Claude Design 대안.
✨ 템플릿은 파일, 벤더 DB가 아닙니다. Fork → 교체 → 출시.
→ ${url}`,
de: ({ name, description, url }) => `🎨 Gerade ${name} von @opendesignai geforkt — die Open-Source-Alternative zu Claude Design.
✨ Vorlagen als Dateien, nicht als Vendor-DB. Fork → swap → ship.
→ ${url}`,
fr: ({ name, description, url }) => `🎨 Je viens de forker ${name} de @opendesignai — l'alternative open-source à Claude Design.
✨ Modèles = fichiers, pas une base vendeur. Fork → swap → ship.
→ ${url}`,
ru: ({ name, description, url }) => `🎨 Форкнул ${name} с @opendesignai — open-source альтернативу Claude Design.
✨ Шаблоны — это файлы, не vendor-DB. Fork → swap → ship.
→ ${url}`,
es: ({ name, description, url }) => `🎨 Acabo de hacer fork de ${name} en @opendesignai — la alternativa open-source a Claude Design.
✨ Plantillas como archivos, no como vendor DB. Fork → swap → ship.
→ ${url}`,
'pt-br': ({ name, description, url }) => `🎨 Acabei de dar fork em ${name} do @opendesignai — a alternativa open-source ao Claude Design.
✨ Templates como arquivos, não como vendor DB. Fork → swap → ship.
→ ${url}`,
it: ({ name, description, url }) => `🎨 Ho appena forkato ${name} da @opendesignai — l'alternativa open-source a Claude Design.
✨ Template come file, non come DB vendor. Fork → swap → ship.
→ ${url}`,
vi: ({ name, description, url }) => `🎨 Vừa fork ${name} từ @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
✨ Template là file, không phải DB của vendor. Fork → đổi data → ship.
→ ${url}`,
pl: ({ name, description, url }) => `🎨 Właśnie sforkowałem ${name} z @opendesignai — open-source'ową alternatywę dla Claude Design.
✨ Szablony jako pliki, nie vendor DB. Fork → swap → ship.
→ ${url}`,
id: ({ name, description, url }) => `🎨 Baru fork ${name} dari @opendesignai — alternatif open-source untuk Claude Design.
✨ Template itu file, bukan vendor DB. Fork → tukar data → ship.
→ ${url}`,
nl: ({ name, description, url }) => `🎨 Net ${name} geforkt van @opendesignai — het open-source alternatief voor Claude Design.
✨ Templates als bestanden, niet als vendor-DB. Fork → swap → ship.
→ ${url}`,
ar: ({ name, description, url }) => `🎨 fork للتو ${name} من @opendesignai — البديل مفتوح المصدر لـ Claude Design.
✨ القوالب ملفات، ليست قاعدة بيانات للمزوّد. Fork → swap → ship.
→ ${url}`,
tr: ({ name, description, url }) => `🎨 ${name} fork'ladım (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
✨ Şablonlar dosya, vendor DB değil. Fork → swap → ship.
→ ${url}`,
uk: ({ name, description, url }) => `🎨 Форкнув ${name} з @opendesignai — open-source альтернативу Claude Design.
✨ Шаблони — це файли, а не vendor-DB. Fork → swap → ship.
→ ${url}`,
};
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
en: { title: 'Share this template', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
zh: { title: '分享这个模板', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
'zh-tw': { title: '分享這個模板', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
ja: { title: 'このテンプレートを共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
ko: { title: '이 템플릿 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
de: { title: 'Diese Vorlage teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
fr: { title: 'Partager ce modèle', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
ru: { title: 'Поделиться шаблоном', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
es: { title: 'Compartir plantilla', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
'pt-br': { title: 'Compartilhar template', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
it: { title: 'Condividi il modello', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
vi: { title: 'Chia sẻ template', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
pl: { title: 'Udostępnij szablon', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
id: { title: 'Bagikan template ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
nl: { title: 'Deel deze template', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
ar: { title: 'شارك هذا القالب', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
tr: { title: 'Bu şablonu paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
uk: { title: 'Поділитись шаблоном', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
};
export async function getStaticPaths() {
const records = await getTemplateRecords();
return records.map((template) => ({
params: { slug: template.slug },
props: { template },
}));
}
interface Props {
template: TemplateRecord;
}
const { template: routeTemplate } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const localizedTemplates = locale === 'en' ? [] : await getTemplateRecords(locale);
const template =
localizedTemplates.find((item) => item.slug === routeTemplate.slug) ?? routeTemplate;
const title = ui.catalog.templates.detailTitle(template.name);
const description = template.summary;
const templateUrl = `https://open-design.ai/templates/${template.slug}/`;
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
name: template.name,
description: template.summary,
url: templateUrl,
});
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
const originLabel =
template.origin === 'live-artifact'
? ui.catalog.templates.liveArtifact
: ui.catalog.templates.skillTemplate;
const files =
template.origin === 'live-artifact'
? [
['template.html', ui.catalog.templates.renderer],
['data.json', ui.catalog.templates.seedData],
['README.md', ui.catalog.templates.readme],
]
: [
['SKILL.md', ui.catalog.skills.detailLabel],
['example.html', ui.catalog.templates.previewCaption],
['assets/', ui.catalog.templates.detailLabel],
['references/', ui.catalog.craft.detailLabel],
];
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: ui.catalog.templates.detailLabel, item: new URL('/templates/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: template.name, item: new URL(template.detailHref, Astro.site).toString() },
],
};
---
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/templates/')}>{ui.catalog.templates.detailLabel}</a>
<span>/</span>
<span aria-current="page">{template.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.templates.detailLabel}
<span class="ix">· {originLabel}</span>
</span>
<h1 class="display">{template.name}<span class="dot">.</span></h1>
<p class="lead">{template.summary}</p>
{(template.mode || template.platform || template.scenario) && (
<dl class="detail-meta">
{template.mode && (
<>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{template.modeLabel ?? template.mode}</dd>
</>
)}
{template.platform && (
<>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{template.platformLabel ?? template.platform}</dd>
</>
)}
{template.scenario && (
<>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{template.scenarioLabel ?? template.scenario}</dd>
</>
)}
</dl>
)}
<div class="detail-actions">
{/* Two CTAs matching skills/[slug]: "Use this template" sends
users to the OD desktop release page (install first, then
use the template); "Find on GitHub" deep-links to the
source folder. See skills/[slug].astro for the broader
rationale on the release-page pivot. */}
<a
class="btn btn-primary"
href="https://github.com/nexu-io/open-design/releases"
target="_blank"
rel="noopener"
>
Use this template →
</a>
<a class="btn btn-ghost" href={template.source} target="_blank" rel="noopener">
Find on GitHub →
</a>
<button
type="button"
class="btn btn-ghost detail-share-trigger"
data-share-open={`template:${template.slug}`}
>
{shareUi.openLabel}
</button>
</div>
</header>
{template.previewUrl && (
<figure class="detail-preview">
{/* Click-to-expand: thumb is the summary; clicking opens the
live iframe rendering the canonical artifact. Skill-template
origin → /skills/<slug>/example.html; live-artifact origin
→ /templates/<slug>/preview.html. */}
<details class="detail-preview-live">
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${template.name}`}>
<LazyImg
src={template.previewUrl}
alt={`${template.name} preview`}
loading="priority"
/>
<span class="detail-preview-thumb-overlay" aria-hidden="true">
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
</span>
</summary>
<div class="detail-preview-frame-wrap">
<iframe
src={
template.origin === 'live-artifact'
? `/templates/${template.slug}/preview.html`
: `/skills/${template.slug}/example.html`
}
title={`${template.name} interactive preview`}
loading="lazy"
sandbox="allow-scripts allow-same-origin"
class="detail-preview-frame"
/>
<a
class="detail-preview-popout"
href={
template.origin === 'live-artifact'
? `/templates/${template.slug}/preview.html`
: `/skills/${template.slug}/example.html`
}
target="_blank"
rel="noopener"
aria-label="Open preview in new tab"
>
Open in new tab ↗
</a>
</div>
</details>
<figcaption>{ui.catalog.templates.previewCaption}</figcaption>
</figure>
)}
{/* Share modal — same shape as skills/[slug]; see that file for the
copy-then-paste rationale and SEO keyword choice. */}
<dialog
class="detail-share-dialog"
data-share-dialog={`template:${template.slug}`}
>
<form method="dialog" class="detail-share-dialog-form">
<header class="detail-share-dialog-head">
<h2>{shareUi.title}</h2>
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
</header>
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
<textarea
class="detail-share-dialog-text"
readonly
rows="6"
data-share-text
>{shareCopy}</textarea>
<div class="detail-share-dialog-actions">
<button
type="button"
class="btn btn-primary detail-share-dialog-copy"
data-share-copy
>
{shareUi.copyText}
</button>
<button
type="button"
class="btn btn-ghost detail-share-dialog-copy-link"
data-copy-link={templateUrl}
>
{shareUi.copyLink}
</button>
</div>
<div class="detail-share-dialog-platforms">
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
<span class="sr-only">X</span>
</a>
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
<span class="sr-only">LinkedIn</span>
</a>
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
<span class="sr-only">Reddit</span>
</a>
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<span class="sr-only">Facebook</span>
</a>
</div>
</form>
</dialog>
<section class="detail-block">
<h2>{ui.catalog.templates.whatsInside}</h2>
<p class="block-lead">
{ui.catalog.templates.whatsInsideLead}
</p>
<ul class="trigger-list">
{files.map(([name, copy]) => (
<li><code>{name}</code> — {copy}</li>
))}
</ul>
</section>
</article>
</Layout>

View file

@ -1,63 +0,0 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import LazyImg from '../../_components/lazy-img.astro';
import { getTemplateRecords } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const templates = await getTemplateRecords(locale);
const title = ui.catalog.templates.title(templates.length);
const description = ui.catalog.templates.description;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: new URL('/templates/', Astro.site).toString(),
numberOfItems: templates.length,
};
---
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">{ui.catalog.templates.label}</span>
<h1 class="display">
{ui.catalog.templates.heading(templates.length)}
</h1>
<p class="lead">
{ui.catalog.templates.lead}
</p>
</header>
<section class="template-grid" aria-label={ui.catalog.templates.allAria}>
<ul>
{templates.map((t, i) => (
<li class="template-card">
<a href={href(t.detailHref)}>
{t.previewUrl ? (
<span class="template-thumb">
<LazyImg src={t.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
</span>
) : (
<span class="template-thumb template-thumb-empty" aria-hidden="true" />
)}
<span class={`meta-tag ${t.origin === 'live-artifact' ? 'coral' : ''}`}>
{t.origin === 'live-artifact' ? ui.catalog.templates.liveArtifact : (t.modeLabel ?? ui.catalog.templates.skillTemplate)}
</span>
<span class="template-name">{t.name}</span>
<p class="template-summary">{t.summary}</p>
{(t.platform || t.scenario) && (
<span class="template-meta-line">
{[t.platformLabel ?? t.platform, t.scenarioLabel ?? t.scenario].filter(Boolean).join(' · ')}
</span>
)}
</a>
</li>
))}
</ul>
</section>
</Layout>

View file

@ -257,11 +257,11 @@ export default defineConfig({
item.priority = 0.9;
item.changefreq = changefreq.weekly;
} else if (
path === '/skills/' ||
path === '/systems/' ||
path === '/templates/' ||
path === '/craft/' ||
path === '/plugins/'
path === '/plugins/' ||
path === '/plugins/skills/' ||
path === '/plugins/systems/' ||
path === '/plugins/templates/'
) {
item.priority = 0.7;
item.changefreq = changefreq.weekly;

View file

@ -34,3 +34,85 @@
/fa/plugins/* /plugins/:splat 301
/hu/plugins/* /plugins/:splat 301
/th/plugins/* /plugins/:splat 301
# ─────────────────────────────────────────────────────────────────────
# Catalog migration: legacy /skills /systems /templates -> /plugins/*
# The old Astro generators were removed; these 301s preserve inbound
# links and SEO equity. Cloudflare matches first rule wins, so order is:
# faceted/specific -> detail prefixes -> bare index -> locale variants.
# trailingSlash:'always', so every source and target ends in '/'.
# ─────────────────────────────────────────────────────────────────────
# Faceted pages have no new equivalent -> degrade to the section landing.
/skills/mode/* /plugins/skills/ 301
/skills/scenario/* /plugins/skills/ 301
/systems/category/* /plugins/systems/ 301
# Systems detail: design-system-<folder> is the uniform new slug.
# These 8 folders have no new detail page -> degrade (must precede splat).
/systems/cisco/ /plugins/systems/ 301
/systems/hud/ /plugins/systems/ 301
/systems/loom/ /plugins/systems/ 301
/systems/perplexity/ /plugins/systems/ 301
/systems/slack/ /plugins/systems/ 301
/systems/trading-terminal/ /plugins/systems/ 301
/systems/webex/ /plugins/systems/ 301
/systems/wechat/ /plugins/systems/ 301
/systems/* /plugins/design-system-:splat 301
# Templates detail: example-<folder> is the uniform new slug.
/templates/live-otd-operations-brief/ /plugins/templates/ 301
/templates/* /plugins/example-:splat 301
# Skills detail: only these 27 have a new artifact-template equivalent.
# 'replicate' collides with design-system-replicate -> force the section.
/skills/replicate/ /plugins/skills/ 301
/skills/article-magazine/ /plugins/example-article-magazine/ 301
/skills/card-twitter/ /plugins/example-card-twitter/ 301
/skills/card-xiaohongshu/ /plugins/example-card-xiaohongshu/ 301
/skills/data-report/ /plugins/example-data-report/ 301
/skills/deck-guizang-editorial/ /plugins/example-deck-guizang-editorial/ 301
/skills/deck-open-slide-canvas/ /plugins/example-deck-open-slide-canvas/ 301
/skills/deck-swiss-international/ /plugins/example-deck-swiss-international/ 301
/skills/design-brief/ /plugins/example-design-brief/ 301
/skills/doc-kami-parchment/ /plugins/example-doc-kami-parchment/ 301
/skills/frame-data-chart-nyt/ /plugins/example-frame-data-chart-nyt/ 301
/skills/frame-flowchart-sticky/ /plugins/example-frame-flowchart-sticky/ 301
/skills/frame-glitch-title/ /plugins/example-frame-glitch-title/ 301
/skills/frame-light-leak-cinema/ /plugins/example-frame-light-leak-cinema/ 301
/skills/frame-liquid-bg-hero/ /plugins/example-frame-liquid-bg-hero/ 301
/skills/frame-logo-outro/ /plugins/example-frame-logo-outro/ 301
/skills/frame-macos-notification/ /plugins/example-frame-macos-notification/ 301
/skills/hatch-pet/ /plugins/example-hatch-pet/ 301
/skills/mockup-device-3d/ /plugins/example-mockup-device-3d/ 301
/skills/poster-hero/ /plugins/example-poster-hero/ 301
/skills/ppt-keynote/ /plugins/example-ppt-keynote/ 301
/skills/pptx-html-fidelity-audit/ /plugins/example-pptx-html-fidelity-audit/ 301
/skills/resume-modern/ /plugins/example-resume-modern/ 301
/skills/social-reddit-card/ /plugins/example-social-reddit-card/ 301
/skills/social-spotify-card/ /plugins/example-social-spotify-card/ 301
/skills/social-x-post-card/ /plugins/example-social-x-post-card/ 301
/skills/vfx-text-cursor/ /plugins/example-vfx-text-cursor/ 301
/skills/video-hyperframes/ /plugins/example-video-hyperframes/ 301
# Remaining ~110 instruction-only skills have no detail page -> section.
/skills/* /plugins/skills/ 301
# Bare catalog index pages (least specific -> last).
/skills/ /plugins/skills/ 301
/systems/ /plugins/systems/ 301
/templates/ /plugins/templates/ 301
# Locale-prefixed variants (active LANDING_LOCALES minus en: zh zh-tw ja ko).
# Non-en pages are sitemap-excluded; degrade to the section (no detail precision).
/zh/skills/* /zh/plugins/skills/ 301
/zh/systems/* /zh/plugins/systems/ 301
/zh/templates/* /zh/plugins/templates/ 301
/zh-tw/skills/* /zh-tw/plugins/skills/ 301
/zh-tw/systems/* /zh-tw/plugins/systems/ 301
/zh-tw/templates/* /zh-tw/plugins/templates/ 301
/ja/skills/* /ja/plugins/skills/ 301
/ja/systems/* /ja/plugins/systems/ 301
/ja/templates/* /ja/plugins/templates/ 301
/ko/skills/* /ko/plugins/skills/ 301
/ko/systems/* /ko/plugins/systems/ 301
/ko/templates/* /ko/plugins/templates/ 301

View file

@ -1622,6 +1622,7 @@ function AppInner() {
daemonMediaProvidersFetchState={daemonMediaProvidersFetchState}
mediaProvidersNotice={mediaProvidersNotice}
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
onProjectsRefresh={refreshProjects}
onSkillsChanged={handleSkillsChanged}
onDesignSystemsChanged={handleDesignSystemsChanged}
providerModelsCache={providerModelsCache}

View file

@ -47,6 +47,11 @@ import {
type InlineMentionEntity,
} from '../utils/inlineMentions';
import { isImeComposing } from '../utils/imeComposing';
import {
reconcileInsertions,
stripPluginInsertedTokens,
type TrackedInsertion,
} from '../utils/pluginInsertionTracking';
import { ANNOTATION_EVENT, type AnnotationEventDetail } from "./PreviewDrawOverlay";
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
@ -224,7 +229,23 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
) {
const t = useT();
const analytics = useAnalytics();
const [draft, setDraft] = useState(() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "");
const [draft, setDraft] = useState(
() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
);
// Synchronous mirror of the latest committed draft value.
// `updateDraft` reads this as `prev` instead of relying on the
// closure `draft` (which only updates after re-render) or
// `setDraft((prev) => …)` (whose updater is double-invoked
// under React StrictMode and would mutate
// `pluginInsertedTokensRef` twice). The ref is updated
// synchronously by `updateDraft` before `setDraft`, so the
// next call sees a fresh `prev` even when React batches
// multiple updates within one tick. Initialized from the same
// source as the React state to keep the two in lockstep on
// first render. See `updateDraft` below and #2929 round 5.
const draftRef = useRef<string>(
initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
);
// chat_panel page_view fires from ProjectView (which outlives
// conversation switches) so the event measures real chat-panel
@ -271,6 +292,77 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// or from the tools-menu "Details" affordance.
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
const pluginsSectionRef = useRef<PluginsSectionHandle | null>(null);
// Instance-aware tracking for `@<token>` mentions this surface
// inserted into the draft via the @-mention popover plugin-pick
// path (`insertPluginMention`). Each entry pins the precise
// start offset of `@`, so two `@Airbnb` mentions in the same
// draft (one composer-inserted, one user-authored) are
// distinguishable — the chip-clear strip removes only tracked
// instances (#2929 round 3). See utils/pluginInsertionTracking.ts
// for the diff/reconcile/strip primitives.
//
// Lifecycle invariants:
// - add: `insertPluginMention` pushes { token, start } using the
// `insertStart` returned by `replaceMentionWithText`
// - reconcile: `handleChange` runs LCP/LCS diff on each
// keystroke and shifts/drops entries whose offsets crossed
// the edit, plus revalidates surviving entries against the
// mention boundary so `@Airbnbify`-style corruption prunes
// - clear: `reset()` empties the array on send; `onCleared`
// strips by range and empties the array
//
// Tools-menu / details-modal applies route through
// `pluginsSectionRef.current.applyById` without writing to the
// draft, so the array stays empty for those surfaces and the
// post-clear strip is a no-op. Every draft mutation in this
// component goes through the `updateDraft` chokepoint, which
// runs `reconcileInsertions` against the prev → next diff. That
// includes typing, slash-command pick, file/MCP/connector
// insertion, skill chip remove, annotation append, imperative
// handle, post-send reset, and the on-cleared strip itself —
// so a tracked offset can never go stale relative to the draft
// and re-introduce the original #2881 orphan-mention symptom
// (#2929 round 4).
//
// Each entry carries the `pluginId` of the apply that produced
// it. When the active plugin changes (e.g. tools-menu `applyById`
// replaces plugin A with plugin B without writing to the draft),
// entries for the previous active plugin are dropped via
// `setActivePlugin`. Without that, clearing B's chip would still
// strip A's `@A` from the draft — silent user-text deletion in a
// supported replace-plugin flow (#2929 round 6).
const pluginInsertedTokensRef = useRef<TrackedInsertion[]>([]);
// The plugin id whose chip is currently mounted in PluginsSection's
// chip strip, or `null` after the strip clears or before any apply
// succeeds. Updated via `setActivePlugin`, which also drops any
// tracked entries whose `pluginId` does not match the new active
// — a no-op for `insertPluginMention` (the new entry it just
// pushed matches), critical for tools-menu / details-modal
// applies that arrive without an accompanying draft insertion.
const activePluginIdRef = useRef<string | null>(null);
// Monotonic counter that hands out unique `insertionId` strings to
// entries pushed by `insertPluginMention`. The id survives
// `reconcileInsertions` (utils/pluginInsertionTracking.ts forwards
// the field) so the in-flight handler's failure path can locate
// its own tracked entry even after intervening reconciles or
// `onCleared` mutations of the array (#2929 round 10 codex
// review). Plain ref counter is enough — the id only needs to be
// unique within a single composer instance and is never persisted.
const insertionIdSeqRef = useRef(0);
// Single chokepoint for setting the active plugin. Routes every
// `applyById` call so the tracker stays in lockstep with the
// chip strip's currently-mounted plugin.
function setActivePlugin(pluginId: string | null): void {
if (activePluginIdRef.current === pluginId) return;
if (pluginInsertedTokensRef.current.length > 0) {
pluginInsertedTokensRef.current =
pluginInsertedTokensRef.current.filter(
(entry) => entry.pluginId === pluginId,
);
}
activePluginIdRef.current = pluginId;
}
// Consolidated "tools" popover — a single dropdown anchored to the
// leading sliders icon that hosts MCP / Import / Pet quick actions and
// a shortcut to open the full Settings dialog. Replaces the previous
@ -299,7 +391,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
useEffect(() => {
if (seededRef.current) return;
if (initialDraft && initialDraft !== draft) {
setDraft(initialDraft);
updateDraft(initialDraft);
seededRef.current = true;
} else if (initialDraft === undefined) {
seededRef.current = true;
@ -614,7 +706,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// command's canonical insertion text.
const replaced = before.replace(/\/[^\s/]*$/, cmd.insert);
const next = replaced + after;
setDraft(next);
updateDraft(next);
setSlash(null);
requestAnimationFrame(() => {
ta.focus();
@ -658,7 +750,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const trimmed = draft.trim();
if (!/^\/mcp\s*$/i.test(trimmed)) return false;
onOpenMcpSettings();
setDraft('');
updateDraft('');
return true;
}
@ -724,7 +816,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
return false;
}
}
setDraft('');
updateDraft('');
return true;
}
@ -732,7 +824,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
ref,
() => ({
setDraft: (text: string) => {
setDraft(text);
updateDraft(text);
seededRef.current = true;
requestAnimationFrame(() => {
const ta = textareaRef.current;
@ -743,7 +835,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
});
},
restoreDraft: ({ text, attachments = [], commentAttachments = [] }) => {
setDraft(text);
updateDraft(text);
setStaged(attachments);
setStagedVisualComments(commentAttachments);
setStagedSkills([]);
@ -768,8 +860,39 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
[]
);
// Single chokepoint for every draft mutation. Reconciles the
// tracked plugin-mention offsets against the prev → next diff so
// any setDraft path — typing, slash command, file/MCP/connector
// insertion, skill chip removal, annotation append, imperative
// handle, post-send reset, on-cleared strip — keeps
// `pluginInsertedTokensRef` in lockstep with the draft.
//
// Implementation note (#2929 round 5): the reconcile and the
// ref mutation happen *outside* the `setDraft` updater, using
// the synchronous `draftRef` mirror as `prev`. Putting them
// inside `setDraft((prev) => …)` would not be safe under
// React StrictMode, which double-invokes setState updaters in
// development to detect impurity — the second invocation
// would re-shift or re-drop already-reconciled entries,
// bringing back the #2881 orphan-mention symptom for every
// user keystroke in the dev build.
function updateDraft(next: string | ((prev: string) => string)): void {
const prev = draftRef.current;
const value = typeof next === 'function' ? next(prev) : next;
if (prev === value) return;
if (pluginInsertedTokensRef.current.length > 0) {
pluginInsertedTokensRef.current = reconcileInsertions(
pluginInsertedTokensRef.current,
prev,
value,
);
}
draftRef.current = value;
setDraft(value);
}
function reset() {
setDraft("");
updateDraft("");
setStaged([]);
setStagedVisualComments([]);
setStagedSkills([]);
@ -778,6 +901,14 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
setUploadError(null);
setMention(null);
setSlash(null);
// Drop tracked plugin-mention insertions when the draft is wiped
// — otherwise a later chip clear would prune user-authored text
// that happened to share a label with a previously-applied
// plugin (#2929 round 2/3). Also clear the active-plugin id
// so the next applyById is treated as a fresh activation
// rather than a "same plugin re-apply" (#2929 round 6).
pluginInsertedTokensRef.current = [];
activePluginIdRef.current = null;
}
function currentCommentAttachments(extra: ChatCommentAttachment[] = []): ChatCommentAttachment[] {
@ -829,7 +960,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// 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) =>
updateDraft((d) =>
d
.replace(new RegExp(`(^|\\s)@${escapeRegExp(id)}(\\s|$)`, 'g'), '$1$2')
.replace(/\s{2,}/g, ' '),
@ -1001,7 +1132,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}),
]);
}
if (detail.note) setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
if (detail.note) updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
setStreamingAnnotationSendPending(true);
textareaRef.current?.focus();
ack({ ok: true });
@ -1022,7 +1153,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
if (detail.note) {
setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
textareaRef.current?.focus();
}
ack({ ok: true });
@ -1118,7 +1249,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const value = e.target.value;
const cursor = e.target.selectionStart;
setDraft(value);
// Goes through the `updateDraft` chokepoint so the
// plugin-mention offset reconcile runs on every keystroke,
// matching every other setDraft path for free.
updateDraft(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
@ -1165,7 +1299,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const after = draft.slice(cursor);
const replaced = before.replace(/@([^\s@]*)$/, `@${filePath} `);
const next = replaced + after;
setDraft(next);
updateDraft(next);
setMention(null);
if (!staged.some((s) => s.path === filePath)) {
setStaged((s) => [
@ -1185,28 +1319,175 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
async function insertPluginMention(record: InstalledPluginRecord) {
const inserted = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
if (!inserted) return;
await pluginsSectionRef.current?.applyById(record.id, record);
// Snapshot tracker AND draft state before any mutation so we
// can roll back if `applyById` fails (#2929 round 7). Without
// this, an `/apply` 5xx leaves the draft holding a freshly
// inserted `@<token>` whose chip never mounted — a user
// clearing the previously-active plugin's chip would then
// strip the user-visible `@<token>` they just picked, even
// though that text is the only signal they have that
// anything happened.
const prevDraftValue = draftRef.current;
const prevEntries = pluginInsertedTokensRef.current;
const prevActiveId = activePluginIdRef.current;
const result = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
if (!result) return;
// Capture the post-insert draft *snapshot* — the value the
// composer is in immediately after our optimistic write.
// Used as a sentinel during the rollback below: if the
// textarea is still in this state when `applyById` fails
// (no user keystrokes during the await), we can fully
// restore `prevDraftValue`. If the user typed during the
// await, the draft has moved past the snapshot and we MUST
// NOT clobber those edits with the stale `prevDraftValue`
// (#2929 round 8 — the textarea stays interactive while
// `/apply` is in flight, so this is a real prompt-data-loss
// path).
const postInsertDraft = draftRef.current;
// Track the precise start offset of the inserted `@` so the
// post-clear strip can excise exactly this instance, leaving
// any user-authored `@<sameLabel>` elsewhere in the draft
// untouched (#2929 round 3). Entry carries `pluginId` so a
// later replace-plugin flow can drop it cleanly (#2929 round 6),
// and an `insertionId` so this handler's failure path can
// locate the entry it pushed even after `reconcileInsertions`
// shifted offsets or `onCleared` mutated the array
// (#2929 round 10).
//
// Push the new entry but DO NOT yet drop entries from the
// previously-active plugin — that filter is committed only
// after `applyById` resolves successfully (#2929 round 9
// codex review). During the await, the chip strip still
// shows the previously-mounted plugin and the textarea is
// interactive: a user click on that chip's × must strip its
// tracked entries (not the optimistic `@<target>` we just
// pushed). `onCleared` filters by
// `pluginsSectionRef.current?.getActiveRecord()?.id` so a
// pending-window clear scopes to the actually-mounted
// plugin's tracked tokens.
const ourInsertionId = `i${++insertionIdSeqRef.current}`;
pluginInsertedTokensRef.current = [
...pluginInsertedTokensRef.current,
{
token: record.title,
start: result.insertStart,
pluginId: record.id,
insertionId: ourInsertionId,
},
];
const applyResult = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (!applyResult) {
// Two failure modes to disambiguate (#2929 round 10):
//
// (a) "no intervening clear" — the user neither cleared
// the previously-mounted chip nor anything else
// mutated the tracker beyond our push + reconciles
// from user keystrokes. `prevEntries` and
// `prevActiveId` are still the truth. We restore the
// tracker wholesale and restore the draft only if
// the user did not type during the await
// (round 7/8 path).
//
// (b) "intervening clear" — `onCleared` ran during the
// await for the previously-mounted chip, stripped
// its tokens from the draft, and nulled
// `activePluginIdRef`. Restoring `prevEntries`
// wholesale here would resurrect already-stripped
// entries with stale offsets, AND leave our
// optimistic `@<target>` orphaned in the draft (the
// original #2881 symptom recurring inside the
// failure window). Instead we surgically remove ONLY
// our own optimistic entry by `insertionId`, strip
// its `@<target>` from the draft, and leave
// everything `onCleared` did intact.
//
// Detection: `onCleared` always nulls
// `activePluginIdRef.current`; our deferred
// `setActivePlugin` never ran (we are in the failure
// branch). So `activePluginIdRef.current === null` while
// `prevActiveId !== null` is the smoking gun for an
// intervening clear. (If `prevActiveId` was already null,
// there was no chip to clear — no race possible.)
const intervenedClear =
activePluginIdRef.current === null && prevActiveId !== null;
if (intervenedClear) {
const cur = pluginInsertedTokensRef.current;
const idx = cur.findIndex(
(e) => e.insertionId === ourInsertionId,
);
if (idx >= 0) {
const ourEntry = cur[idx]!;
// Splice our entry out first so `updateDraft`'s
// internal `reconcileInsertions` operates on a tracker
// that already excludes it (the strip range overlaps
// the entry, which would drop it anyway, but splicing
// first keeps the invariant explicit and avoids
// depending on the reconcile drop edge case).
pluginInsertedTokensRef.current = [
...cur.slice(0, idx),
...cur.slice(idx + 1),
];
updateDraft((d) => stripPluginInsertedTokens(d, [ourEntry]));
}
// Don't touch `activePluginIdRef` — `onCleared` set it
// to null and that is the truth (no chip is mounted).
return;
}
// (a) round 7/8 path: no intervening clear.
pluginInsertedTokensRef.current = prevEntries;
activePluginIdRef.current = prevActiveId;
// Restore the draft only if no user keystrokes arrived
// during the await — overwriting newer edits with the
// stale pre-pick snapshot would be a worse bug than the
// leftover `@<token>` styled mention this branch leaves
// behind. The orphan stays as a styled mention but no
// future chip clear will touch it (tracker is empty for
// it now), and the user can edit it manually
// (#2929 round 8).
if (draftRef.current === postInsertDraft) {
setDraft(prevDraftValue);
draftRef.current = prevDraftValue;
}
return;
}
// Apply succeeded. Now commit the active-plugin switch —
// this drops any entries from the previously-active plugin
// (a no-op for the entry we just pushed since it matches
// `record.id`) and updates `activePluginIdRef`. Deferring
// until after the await means an `onCleared` triggered
// during the in-flight window saw the still-mounted plugin
// as the active one and stripped only that plugin's tokens
// (#2929 round 9).
setActivePlugin(record.id);
}
function replaceMentionWithText(text: string): boolean {
if (!mention) return false;
function replaceMentionWithText(
text: string,
): { insertStart: number } | null {
if (!mention) return null;
const ta = textareaRef.current;
const cursor = mention.cursor;
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const replaced = before.replace(/(^|\s)@([^\s@]*)$/, `$1${text}`);
const next = replaced + after;
setDraft(next);
updateDraft(next);
setMention(null);
// The inserted text was appended onto `replaced`, so its first
// char (the `@`) sits at `replaced.length - text.length`.
const insertStart = replaced.length - text.length;
requestAnimationFrame(() => {
if (!ta) return;
ta.focus();
const pos = replaced.length;
ta.setSelectionRange(pos, pos);
});
return true;
return { insertStart };
}
function insertMcpMention(server: McpServerConfig) {
@ -1234,7 +1515,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
function removeStaged(p: string) {
setStaged((s) => s.filter((a) => a.path !== p));
setStagedVisualComments((current) => current.filter((attachment) => attachment.screenshotPath !== p));
setDraft((current) => stripInlineMentionToken(current, p));
updateDraft((current) => stripInlineMentionToken(current, p));
}
function removeCommentAttachment(id: string) {
@ -1473,12 +1754,73 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
showRail={false}
onApplied={(brief) => {
// Use functional setState so stale closures from the @-mention
// flow (which awaits applyById after setDraft) still see the
// latest draft value before deciding whether to seed.
// flow (which awaits applyById after updateDraft) still see
// the latest draft value before deciding whether to seed.
if (typeof brief === 'string' && brief.length > 0) {
setDraft((cur) => (cur.trim().length === 0 ? brief : cur));
updateDraft((cur) => (cur.trim().length === 0 ? brief : cur));
}
}}
onCleared={() => {
// Removing the chip strip must drop the `@…` tokens
// this surface authored, otherwise the textarea is
// left holding orphaned mentions whose chips just
// unmounted (#2881). We strip *only* the tracked
// insertions (by precise start offset) so
// user-authored text that happens to share a label
// with a chip is preserved (#2929 round 3).
//
// The chip strip can clear while an `applyById` for
// a *different* plugin is mid-await — the @-popover
// optimistically writes `@<target>` and pushes a
// tracked entry synchronously, then awaits the
// apply (#2929 round 9 codex review). During that
// window the ref carries entries for both the
// still-mounted plugin (the chip the user is
// removing) and the in-flight target. Trusting the
// ref wholesale here would strip the optimistic
// `@<target>` and leave the unmounting plugin's
// `@<token>` orphaned — a recurrence of #2881 in a
// pending-apply window.
//
// PluginsSection only flips `activeRecord` after
// `applyPlugin` resolves successfully (see
// `PluginsSection.tsx`), so `getActiveRecord()` at
// the moment `onCleared` fires reports the plugin
// whose chip is currently being unmounted — exactly
// the one whose tracked entries we should strip.
// Filter to that id; entries for any in-flight
// replace target are left in place (the in-flight
// handler's success path will commit
// `setActivePlugin(target)` and drop them; its
// failure path will roll the tracker back).
const unmountingId =
pluginsSectionRef.current?.getActiveRecord()?.id ?? null;
const entries = pluginInsertedTokensRef.current;
if (entries.length > 0) {
const toStrip = unmountingId
? entries.filter((e) => e.pluginId === unmountingId)
: entries;
if (toStrip.length > 0) {
// `updateDraft` runs `reconcileInsertions`
// against the prev → next diff inside the
// chokepoint, so any in-flight target's entries
// get their offsets shifted to track the
// post-strip draft. We must re-read the ref
// *after* `updateDraft` returns instead of
// filtering the pre-strip `entries` snapshot,
// otherwise we would clobber the reconciled
// offsets and a later clear of the in-flight
// chip would no-op via `isInsertionStillValid`.
updateDraft((d) => stripPluginInsertedTokens(d, toStrip));
}
pluginInsertedTokensRef.current = unmountingId
? pluginInsertedTokensRef.current.filter(
(e) => e.pluginId !== unmountingId,
)
: [];
}
activePluginIdRef.current = null;
}}
onChipDetails={(item: ContextItem) => {
if (item.kind !== 'plugin') return;
const record = installedPlugins.find((p) => p.id === item.id);
@ -1700,11 +2042,32 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
plugins={pluginsForComposer}
activePluginId={pinnedPluginId}
onApply={async (record) => {
// Tools-menu apply: no draft write, so the
// tracked-insertion array gets no new
// entry. The active-plugin switch (which
// drops previously-tracked entries from a
// prior @-popover pick of a different
// plugin, #2929 round 6) is deferred until
// `applyById` resolves successfully so
// that an `onCleared` triggered during the
// in-flight window still sees the
// still-mounted plugin's entries and
// strips them correctly via the
// `getActiveRecord()` filter in
// `onCleared` (#2929 round 9).
//
// No synchronous mutation in this branch
// means no rollback snapshot is needed:
// the failure path is just an early return
// (#2929 round 7's snapshot was needed
// because `setActivePlugin` was eager).
const result = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (result) setToolsOpen(false);
if (!result) return;
setActivePlugin(record.id);
setToolsOpen(false);
}}
onShowDetails={(record) => {
setDetailsRecord(record);
@ -1726,7 +2089,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const before = currentDraft.slice(0, cursor);
const after = currentDraft.slice(cursor);
const next = before + insert + after;
setDraft(next);
updateDraft(next);
setToolsOpen(false);
requestAnimationFrame(() => {
const el = textareaRef.current;
@ -1750,7 +2113,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const next = before + insert + after;
setDraft(next);
updateDraft(next);
setToolsOpen(false);
requestAnimationFrame(() => {
const el = textareaRef.current;
@ -1905,7 +2268,24 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
record={detailsRecord}
onClose={() => setDetailsRecord(null)}
onUse={async (record) => {
await pluginsSectionRef.current?.applyById(record.id, record);
// Details-modal apply: same shape as tools-menu apply
// (no draft write). The active-plugin switch is
// deferred until `applyById` resolves successfully so
// that an `onCleared` triggered during the in-flight
// window still sees the still-mounted plugin's
// entries and strips them correctly (#2929 round 9).
//
// Modal closes regardless of apply outcome so the
// user is not stuck on the details view if `/apply`
// 5xx'd. Failure is a no-op: no synchronous mutation
// happened, so nothing to roll back (#2929 round 7's
// snapshot was needed because `setActivePlugin` was
// eager — round 9 made it lazy).
const result = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (result) setActivePlugin(record.id);
setDetailsRecord(null);
}}
/>
@ -2278,6 +2658,11 @@ function ToolsPluginsPanel({
<button
type="button"
className="composer-tools-row-main"
// Match the @-mention popover: prevent the textarea from
// losing focus before the click handler runs so
// selectionStart isn't reset to 0 and the inserted token
// lands at the user's actual cursor position (#3195).
onMouseDown={(e) => e.preventDefault()}
onClick={async () => {
setPendingId(p.id);
try {
@ -2369,6 +2754,10 @@ function ToolsMcpPanel({
type="button"
role="menuitem"
className="composer-tools-row"
// Match the @-mention popover: prevent the textarea from
// losing focus before the click handler runs so
// selectionStart isn't reset to 0 (#3195).
onMouseDown={(e) => e.preventDefault()}
onClick={() => onInsert(s.id)}
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
>
@ -2459,6 +2848,10 @@ function ToolsSkillsPanel({
type="button"
role="menuitem"
className={`composer-tools-row${active ? ' active' : ''}`}
// Match the @-mention popover: prevent the textarea from
// losing focus before the click handler runs so
// selectionStart isn't reset to 0 (#3195).
onMouseDown={(e) => e.preventDefault()}
onClick={async () => {
setPendingId(skill.id);
try {

View file

@ -2014,6 +2014,16 @@ export function conversationMetaLabel(
t: TranslateFn,
): string {
const latestRun = conversation.latestRun;
if (
latestRun &&
(latestRun.status === 'succeeded' ||
latestRun.status === 'failed' ||
latestRun.status === 'canceled') &&
typeof conversation.totalDurationMs === 'number' &&
Number.isFinite(conversation.totalDurationMs)
) {
return formatDurationShort(conversation.totalDurationMs);
}
if (
latestRun &&
(latestRun.status === 'succeeded' ||

View file

@ -766,7 +766,12 @@ export function DesignFilesPanel({
}}
>
<span className="df-row-name-wrap">
<span className="df-row-name">{currentDir === '' ? f.name : f.name.slice(currentDir.length + 1)}</span>
<span
className="df-row-name"
title={currentDir === '' ? f.name : f.name.slice(currentDir.length + 1)}
>
{currentDir === '' ? f.name : f.name.slice(currentDir.length + 1)}
</span>
<span className="df-row-sub">{humanBytes(f.size)}</span>
</span>
</button>
@ -828,7 +833,7 @@ export function DesignFilesPanel({
<td className="df-cell-name df-cell-openable" onClick={() => setCurrentDir(fullPath)}>
<button type="button" className="df-row-name-btn" onClick={() => setCurrentDir(fullPath)}>
<span className="df-row-name-wrap">
<span className="df-row-name">{dirName}</span>
<span className="df-row-name" title={dirName}>{dirName}</span>
<span className="df-row-sub">{t('designFiles.folderCount', { n: count })}</span>
</span>
</button>
@ -1240,7 +1245,9 @@ export function DesignFilesPanel({
</span>
<span className="df-row-name-wrap">
<span className="df-row-name">{artifact.title}</span>
<span className="df-row-name" title={artifact.title}>
{artifact.title}
</span>
<span className="df-row-sub">
<span>{t('designFiles.kindLiveArtifact')}</span>
<LiveArtifactBadges

View file

@ -307,6 +307,7 @@ interface Props {
| 'appearance'
| 'notifications'
| 'pet'
| 'projectLocations'
| 'library'
| 'about'
| 'memory'

View file

@ -130,7 +130,7 @@ interface Props {
onOpenDesignSystem?: (id: string) => void;
onDesignSystemsRefresh?: () => Promise<void> | void;
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'library' | 'about' | 'memory' | 'designSystems') => void;
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'projectLocations' | 'library' | 'about' | 'memory' | 'designSystems') => void;
onCompleteOnboarding: () => void;
}

View file

@ -143,6 +143,14 @@ export type ManualEditPendingStyleSave = {
};
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
type PreviewCanvasSize = { width: number; height: number };
type CommentPreviewCanvasOptions = {
boardMode: boolean;
sidePanelCollapsed: boolean;
viewport?: PreviewViewportId;
};
type PreviewScaleOptions = {
canvasPadding?: number;
};
type PreviewViewportPreset = {
id: PreviewViewportId;
width: number | null;
@ -214,6 +222,18 @@ const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
},
];
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:';
const COMMENT_SIDE_DOCK_WIDTH = 320;
const COMMENT_SIDE_DOCK_RAIL_WIDTH = 42;
const COMMENT_SIDE_DOCK_GAP = 12;
const COMMENT_SIDE_DOCK_PADDING = 8;
const COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING = 24;
const COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH = 280;
const COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT = 220;
const COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT = 48;
const COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION =
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT;
const COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION =
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT;
// The five basic style facets the inspect panel exposes. Kept narrow on
// purpose — open-slide's design tokens panel only edits global tokens, so
@ -500,10 +520,11 @@ function previewViewportStyle(
viewport: PreviewViewportId,
previewScale = 1,
canvasSize?: PreviewCanvasSize,
options?: PreviewScaleOptions,
): CSSProperties & Record<string, string | number> {
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
if (!preset.width) return {};
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize);
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize, options);
return {
'--preview-viewport-width': `${preset.width}px`,
'--preview-viewport-height': `${preset.height}px`,
@ -512,15 +533,54 @@ function previewViewportStyle(
};
}
export function commentPreviewCanvasSize(
canvasSize: PreviewCanvasSize | undefined,
options: CommentPreviewCanvasOptions,
): PreviewCanvasSize | undefined {
if (!canvasSize || !options.boardMode) return canvasSize;
const dockPadding = options.viewport && options.viewport !== 'desktop'
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
: COMMENT_SIDE_DOCK_PADDING;
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
if (usesStackedCommentSideDock(canvasSize, options)) {
const stackedHeightDeduction = options.sidePanelCollapsed
? COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION
: COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION;
return {
width: Math.max(1, canvasSize.width - (COMMENT_SIDE_DOCK_PADDING * 2)),
height: Math.max(1, canvasSize.height - stackedHeightDeduction),
};
}
return {
width: Math.max(1, dockedWidth),
height: Math.max(1, canvasSize.height - (dockPadding * 2)),
};
}
function usesStackedCommentSideDock(
canvasSize: PreviewCanvasSize | undefined,
options: CommentPreviewCanvasOptions,
) {
if (!canvasSize || !options.boardMode) return false;
const dockPadding = options.viewport && options.viewport !== 'desktop'
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
: COMMENT_SIDE_DOCK_PADDING;
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
return dockedWidth < COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH;
}
export function effectivePreviewScale(
viewport: PreviewViewportId,
previewScale: number,
canvasSize?: PreviewCanvasSize,
options?: PreviewScaleOptions,
) {
if (viewport === 'desktop') return previewScale;
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
const canvasPadding = 48;
const canvasPadding = options?.canvasPadding ?? 48;
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
@ -2086,7 +2146,6 @@ export function CommentSidePanel({
activeCommentId,
collapsed,
onCollapsedChange,
onClose,
onToggleSelect,
onSelectAll,
onClearSelection,
@ -2102,7 +2161,6 @@ export function CommentSidePanel({
activeCommentId: string | null;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onClose: () => void;
onToggleSelect: (commentId: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
@ -2119,21 +2177,48 @@ export function CommentSidePanel({
const selectedCount = visibleSelectedIds.size;
const allSelected = comments.length > 0 && selectedCount === comments.length;
const commentsLabel = t('chat.tabComments');
const collapsedRailRef = useRef<HTMLButtonElement | null>(null);
const expandedToggleRef = useRef<HTMLButtonElement | null>(null);
const pendingToggleFocusRef = useRef<'collapsed' | 'expanded' | null>(null);
const panelId = useId();
const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
const submitNewComment = async () => {
if (!onCreateComment || !newCommentDraft.trim()) return;
const saved = await onCreateComment(newCommentDraft.trim());
if (saved) setNewCommentDraft('');
};
useEffect(() => {
const target =
pendingToggleFocusRef.current === 'collapsed'
? collapsedRailRef.current
: pendingToggleFocusRef.current === 'expanded'
? expandedToggleRef.current
: null;
if (!target) return;
pendingToggleFocusRef.current = null;
target.focus();
}, [collapsed]);
const handleCollapsedChange = (
nextCollapsed: boolean,
nextFocusTarget: 'collapsed' | 'expanded',
) => {
pendingToggleFocusRef.current = nextFocusTarget;
onCollapsedChange(nextCollapsed);
};
if (collapsed) {
return (
<button
ref={collapsedRailRef}
type="button"
className="comment-side-rail"
data-testid="comment-side-collapsed-rail"
aria-label={t('preview.showSidebar', { label: commentsLabel })}
aria-expanded={false}
title={t('preview.showSidebar', { label: commentsLabel })}
onClick={() => onCollapsedChange(false)}
onClick={() => handleCollapsedChange(false, 'expanded')}
>
<RemixIcon name="message-3-line" size={15} />
<span>{commentsLabel}</span>
@ -2143,7 +2228,7 @@ export function CommentSidePanel({
}
return (
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
<aside id={panelId} className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
<div className="comment-side-header">
<div className="comment-side-title">
<RemixIcon name="message-3-line" size={15} />
@ -2160,15 +2245,18 @@ export function CommentSidePanel({
{t('chat.comments.selectAll')}
</button>
) : null}
<button
type="button"
className="comment-side-close"
aria-label={t('common.close')}
title={t('common.close')}
onClick={onClose}
>
<Icon name="close" size={12} />
</button>
<button
ref={expandedToggleRef}
type="button"
className="comment-side-collapse"
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
aria-controls={panelId}
aria-expanded={true}
title={t('preview.hideSidebar', { label: commentsLabel })}
onClick={() => handleCollapsedChange(true, 'collapsed')}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
</div>
<div className="comment-side-list">
@ -2299,6 +2387,62 @@ export function CommentSidePanel({
);
}
function CommentSideDock({
comments,
selectedIds,
activeCommentId,
collapsed,
onCollapsedChange,
onToggleSelect,
onSelectAll,
onClearSelection,
onReply,
onSendSelected,
onCreateComment,
sending,
t,
composer,
}: {
comments: PreviewComment[];
selectedIds: Set<string>;
activeCommentId: string | null;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onToggleSelect: (commentId: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
onReply: (comment: PreviewComment) => void;
onSendSelected: () => void | Promise<void>;
onCreateComment?: (note: string) => boolean | Promise<boolean>;
sending: boolean;
t: TranslateFn;
composer?: ReactNode;
}) {
return (
<div
className={`comment-side-dock${collapsed ? ' collapsed' : ''}`}
data-testid="comment-side-dock"
>
<CommentSidePanel
comments={comments}
selectedIds={selectedIds}
activeCommentId={activeCommentId}
collapsed={collapsed}
onCollapsedChange={onCollapsedChange}
onToggleSelect={onToggleSelect}
onSelectAll={onSelectAll}
onClearSelection={onClearSelection}
onReply={onReply}
onSendSelected={onSendSelected}
onCreateComment={onCreateComment}
sending={sending}
t={t}
composer={composer}
/>
</div>
);
}
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
@ -4309,6 +4453,17 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100;
const localCommentSideDockActive = commentPanelOpen && !commentPortalHost;
const boardPreviewCanvasSize = commentPreviewCanvasSize(previewBodySize, {
boardMode: localCommentSideDockActive,
sidePanelCollapsed: commentSidePanelCollapsed,
viewport: previewViewport,
});
const boardSideDockStacked = usesStackedCommentSideDock(previewBodySize, {
boardMode: localCommentSideDockActive,
sidePanelCollapsed: commentSidePanelCollapsed,
viewport: previewViewport,
});
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
@ -4432,8 +4587,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const [slideState, setSlideState] = useState<SlideState | null>(
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
);
const overlayPreviewTransform = previewOverlayTransform(previewViewport, previewScale, previewBodySize);
const overlayPreviewScale = overlayPreviewTransform.scale;
const boardPreviewScaleOptions = localCommentSideDockActive ? { canvasPadding: 0 } : undefined;
const overlayPreviewScale = effectivePreviewScale(
previewViewport,
previewScale,
boardPreviewCanvasSize,
boardPreviewScaleOptions,
);
const overlayPreviewTransform: PreviewOverlayTransform = {
scale: overlayPreviewScale,
offsetX: 0,
offsetY: 0,
};
const shareRef = useRef<HTMLDivElement | null>(null);
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
useEffect(() => {
@ -6479,6 +6644,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
};
const boardAvailable = mode === 'preview' && source !== null;
const showPreviewToolbarControls = mode === 'preview';
const commentPreviewLayoutClass = [
'comment-preview-layer',
localCommentSideDockActive ? 'comment-preview-layer-with-side-dock' : '',
localCommentSideDockActive && commentSidePanelCollapsed ? 'comment-preview-layer-dock-collapsed' : '',
boardSideDockStacked ? 'comment-preview-layer-side-dock-stacked' : '',
].filter(Boolean).join(' ');
const manualEditPanel = manualEditMode ? (
<ManualEditPanel
targets={manualEditTargets}
@ -6588,19 +6759,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
/>
) : null;
const commentSidePanel = commentPanelOpen ? (
<CommentSidePanel
<CommentSideDock
comments={visibleSideComments}
selectedIds={selectedSideCommentIds}
activeCommentId={activeSideCommentId}
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
onCollapsedChange={setCommentSidePanelCollapsed}
onClose={() => {
setCommentPanelOpen(false);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(false);
setBoardMode(false);
clearBoardComposer();
}}
onToggleSelect={(commentId) => {
setSelectedSideCommentIds((current) => {
const next = new Set(current);
@ -7102,201 +7266,258 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'preview' ? (
<div
className={manualEditMode
? `manual-edit-workspace preview-viewport preview-viewport-${previewViewport}`
: [
'comment-preview-layer',
`preview-viewport preview-viewport-${previewViewport}`,
].filter(Boolean).join(' ')}
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
className={`${manualEditMode ? 'manual-edit-workspace' : commentPreviewLayoutClass} preview-viewport preview-viewport-${previewViewport}`}
data-testid={manualEditMode ? undefined : 'comment-preview-layout'}
style={previewViewportStyle(previewViewport, previewScale, boardPreviewCanvasSize, boardPreviewScaleOptions)}
>
{manualEditPanel}
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
<div
style={
manualEditMode
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
: previewScaleShellStyle(previewViewport, previewScale)
}
>
<PreviewDrawOverlay
active={drawOverlayOpen}
onActiveChange={setDrawOverlayOpen}
captureTarget={null}
filePath={file.name}
sendDisabled={streaming}
sendDisabledReason={t('chat.annotationSendDisabledReason')}
<div
className={manualEditMode ? 'manual-edit-canvas' : 'comment-preview-canvas'}
data-testid={manualEditMode ? undefined : 'comment-preview-canvas'}
>
<div className={manualEditMode ? undefined : 'comment-frame-clip'}>
<div
style={
manualEditMode
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
: previewScaleShellStyle(previewViewport, previewScale)
}
>
<div className="artifact-preview-transport-stack">
{OD_PREVIEW_KEEP_ALIVE ? (
<PooledIframe
ref={urlPreviewIframeRef}
cacheKey={urlPreviewKeepAliveKey}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
) : (
<PreviewDrawOverlay
active={drawOverlayOpen}
onActiveChange={setDrawOverlayOpen}
captureTarget={null}
filePath={file.name}
sendDisabled={streaming}
sendDisabledReason={t('chat.annotationSendDisabledReason')}
>
<div className="artifact-preview-transport-stack">
{OD_PREVIEW_KEEP_ALIVE ? (
<PooledIframe
ref={urlPreviewIframeRef}
cacheKey={urlPreviewKeepAliveKey}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
) : (
<iframe
ref={urlPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
)}
<iframe
ref={urlPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
key={srcDocTransportResetKey}
ref={srcDocPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
data-od-render-mode="srcdoc"
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
aria-hidden={useUrlLoadPreview ? true : undefined}
tabIndex={useUrlLoadPreview ? -1 : 0}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
srcDoc={srcDocTransportContent}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
const frame = srcDocPreviewIframeRef.current;
if (!useUrlLoadPreview) iframeRef.current = frame;
// Reset the activation dedupe exactly ONCE per
// freshly mounted iframe DOM node, never on the
// subsequent load events that the same node
// emits during normal srcDoc rendering.
//
// The iframe's load event fires twice for one
// successful activation: once when the lazy
// transport shell HTML loads, and again when
// our own document.open/write/close inside the
// shell finishes. PR #2699 reset the dedupe on
// every load so that switching
// preview -> source -> preview (which remounts
// this iframe as a fresh DOM node) would
// re-activate the new shell. But resetting on
// every load also re-activated on the SECOND
// load of a non-remounted frame, which
// re-triggered document.open/write/close, which
// re-fired the load event, ad infinitum. The
// dedupe ref oscillated between null and the
// current srcDoc thousands of times per render
// and each iteration restarted every CSS
// animation from its `from` keyframe. Designs
// using `animation-fill-mode: both` with
// `from { opacity: 0 }` stayed at opacity 0
// forever and the preview read as blank.
// That is issue #2361.
//
// Tracking the last frame we reset for lets us
// keep PR #2699's "remount after Source toggle"
// fix while breaking the loop on plain renders.
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
srcDocFrameDedupeResetForRef.current = frame;
activatedSrcDocTransportHtmlRef.current = null;
}
if (useLazySrcDocTransport) setSrcDocShellReady(true);
activateLoadedSrcDocTransport(frame);
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
replayInspectOverridesToIframe(frame);
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
if (!useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
)}
<iframe
key={srcDocTransportResetKey}
ref={srcDocPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
data-od-render-mode="srcdoc"
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
aria-hidden={useUrlLoadPreview ? true : undefined}
tabIndex={useUrlLoadPreview ? -1 : 0}
title={file.name}
sandbox="allow-scripts allow-downloads"
srcDoc={srcDocTransportContent}
onLoad={() => {
const frame = srcDocPreviewIframeRef.current;
if (!useUrlLoadPreview) iframeRef.current = frame;
// Reset the activation dedupe exactly ONCE per
// freshly mounted iframe DOM node, never on the
// subsequent load events that the same node
// emits during normal srcDoc rendering.
//
// The iframe's load event fires twice for one
// successful activation: once when the lazy
// transport shell HTML loads, and again when
// our own document.open/write/close inside the
// shell finishes. PR #2699 reset the dedupe on
// every load so that switching
// preview -> source -> preview (which remounts
// this iframe as a fresh DOM node) would
// re-activate the new shell. But resetting on
// every load also re-activated on the SECOND
// load of a non-remounted frame, which
// re-triggered document.open/write/close, which
// re-fired the load event, ad infinitum. The
// dedupe ref oscillated between null and the
// current srcDoc thousands of times per render
// and each iteration restarted every CSS
// animation from its `from` keyframe. Designs
// using `animation-fill-mode: both` with
// `from { opacity: 0 }` stayed at opacity 0
// forever and the preview read as blank.
// That is issue #2361.
//
// Tracking the last frame we reset for lets us
// keep PR #2699's "remount after Source toggle"
// fix while breaking the loop on plain renders.
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
srcDocFrameDedupeResetForRef.current = frame;
activatedSrcDocTransportHtmlRef.current = null;
}
if (useLazySrcDocTransport) setSrcDocShellReady(true);
activateLoadedSrcDocTransport(frame);
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
replayInspectOverridesToIframe(frame);
syncBridgeModes(frame);
if (!useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
</div>
</PreviewDrawOverlay>
</div>
</PreviewDrawOverlay>
</div>
</div>
{boardMode ? (
<CommentPreviewOverlays
comments={commentCreateMode ? visibleSideComments : []}
liveTargets={liveCommentTargets}
hoveredTarget={hoveredCommentTarget}
hoveredPodMemberId={hoveredPodMemberId}
activeTarget={activeCommentTarget}
boardTool={boardTool}
showActivePin={commentCreateMode}
scale={overlayPreviewScale}
offsetX={overlayPreviewTransform.offsetX}
offsetY={overlayPreviewTransform.offsetY}
strokePoints={strokePoints}
onOpenComment={(comment, snapshot) => {
setCommentPanelOpen(true);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(true);
setBoardMode(true);
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setActivePreviewCommentId(comment.id);
setCommentDraft(comment.note);
setQueuedBoardNotes([]);
}}
/>
) : null}
{exportToast ? (
<div className="comment-toast-anchor">
<Toast
message={exportToast}
ttlMs={2200}
onDismiss={() => setExportToast(null)}
/>
</div>
) : null}
{commentSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={commentSavedToast}
ttlMs={2200}
onDismiss={() => setCommentSavedToast(null)}
/>
</div>
) : null}
{templateSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={templateSavedToast}
ttlMs={2200}
onDismiss={() => setTemplateSavedToast(null)}
/>
</div>
) : null}
{commentComposer}
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
) : null}
{/*
Hint banner for Inspect / Picker modes. The bridge in
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
with every element annotated with `data-od-id` /
`data-screen-label`, so `liveCommentTargets.size` is the
authoritative annotation count for the current artifact.
Two states:
- "has targets": the existing copy ("Click any element with
`data-od-id` to tune its style.") for users who just don't
see the crosshair cursor.
- "no targets" (issue #890): a freeform-generated artifact
(e.g. PRD HTML through a Claude-Code-compatible CLI
without a skill) ships zero `data-od-id` annotations. The
bridge's click handler walks up to <html>, finds nothing,
and bails clicks no-op silently. The static copy made
this look broken; the empty-state copy explains what's
missing and how to fix it. Mirrored across Inspect and
element-pick annotation mode because the failure surface is identical.
*/}
{inspectMode
&& openHintBox
&& !activeInspectTarget
&& !activeCommentTarget ? (
<div
className="inspect-empty-hint-container"
data-testid="inspect-empty-hint-container"
>
{liveCommentTargets.size === 0 ? (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint-no-targets"
>
{inspectMode
? t('chat.inspect.noEditableTargets')
: t('chat.inspect.noCommentTargets')}
</div>
) : (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint"
>
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
</div>
)}
<button
type="button"
title="Close Inspect Hint"
aria-label="Close Inspect Hint"
onClick={() => setOpenHintBox(false)}
className="orbit-artifact-ghost"
>
<Icon className="" name="close" size={12} />
</button>
</div>
) : null}
</div>
{boardMode ? (
<CommentPreviewOverlays
comments={commentCreateMode ? visibleSideComments : []}
liveTargets={liveCommentTargets}
hoveredTarget={hoveredCommentTarget}
hoveredPodMemberId={hoveredPodMemberId}
activeTarget={activeCommentTarget}
boardTool={boardTool}
showActivePin={commentCreateMode}
scale={overlayPreviewScale}
offsetX={overlayPreviewTransform.offsetX}
offsetY={overlayPreviewTransform.offsetY}
strokePoints={strokePoints}
onOpenComment={(comment, snapshot) => {
setCommentPanelOpen(true);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(true);
setBoardMode(true);
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setActivePreviewCommentId(comment.id);
setCommentDraft(comment.note);
setQueuedBoardNotes([]);
}}
/>
) : null}
{exportToast ? (
<div className="comment-toast-anchor">
<Toast
message={exportToast}
ttlMs={2200}
onDismiss={() => setExportToast(null)}
/>
</div>
) : null}
{commentSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={commentSavedToast}
ttlMs={2200}
onDismiss={() => setCommentSavedToast(null)}
/>
</div>
) : null}
{templateSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={templateSavedToast}
ttlMs={2200}
onDismiss={() => setTemplateSavedToast(null)}
/>
</div>
) : null}
{commentComposer}
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
) : null}
{commentPortalHost && commentSidePanel
? createPortal(commentSidePanel, commentPortalHost)
: commentPortalId
@ -7339,64 +7560,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
error={inspectError}
/>
) : null}
{/*
Hint banner for Inspect / Picker modes. The bridge in
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
with every element annotated with `data-od-id` /
`data-screen-label`, so `liveCommentTargets.size` is the
authoritative annotation count for the current artifact.
Two states:
- "has targets": the existing copy ("Click any element with
`data-od-id` to tune its style.") for users who just don't
see the crosshair cursor.
- "no targets" (issue #890): a freeform-generated artifact
(e.g. PRD HTML through a Claude-Code-compatible CLI
without a skill) ships zero `data-od-id` annotations. The
bridge's click handler walks up to <html>, finds nothing,
and bails clicks no-op silently. The static copy made
this look broken; the empty-state copy explains what's
missing and how to fix it. Mirrored across Inspect and
element-pick annotation mode because the failure surface is identical.
*/}
{inspectMode
&& openHintBox
&& !activeInspectTarget
&& !activeCommentTarget ? (
<div
className={`inspect-empty-hint-container${
commentPanelOpen && !commentSidePanelCollapsed ? ' comment-side-panel-open' : ''
}`}
data-testid="inspect-empty-hint-container"
>
{liveCommentTargets.size === 0 ? (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint-no-targets"
>
{inspectMode
? t('chat.inspect.noEditableTargets')
: t('chat.inspect.noCommentTargets')}
</div>
) : (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint"
>
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
</div>
)}
<button
type="button"
title="Close Inspect Hint"
aria-label="Close Inspect Hint"
onClick={() => setOpenHintBox(false)}
className="orbit-artifact-ghost"
>
<Icon className="" name="close" size={12} />
</button>
</div>
) : null}
</div>
) : (
<pre className="viewer-source">{source}</pre>

View file

@ -847,13 +847,15 @@ export function NewProjectPanel({
) : null}
</h3>
<input
className="newproj-name"
data-testid="new-project-name"
placeholder={t('newproj.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="newproj-name-row">
<input
className="newproj-name"
data-testid="new-project-name"
placeholder={t('newproj.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{showDesignSystemPicker ? (
<DesignSystemPicker

View file

@ -0,0 +1,239 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { ProjectLocation } from '@open-design/contracts';
import type { AppConfig } from '../types';
import {
fetchProjectLocations,
openProjectLocationFolderDialog,
scanProjectLocations,
updateProjectLocations,
} from '../state/project-locations';
import { useI18n } from '../i18n';
import { Icon } from './Icon';
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
onProjectsRefresh?: () => Promise<void> | void;
}
interface DraftLocation {
id?: string;
path: string;
}
function locationLabel(locationPath: string): string {
return locationPath.split(/[\\/]/).filter(Boolean).pop() || locationPath;
}
function externalLocations(locations: ProjectLocation[]): DraftLocation[] {
return locations
.filter((location) => !location.builtIn)
.map((location) => ({ id: location.id, path: location.path }));
}
function toConfigLocations(locations: ProjectLocation[]): NonNullable<AppConfig['projectLocations']> {
return locations
.filter((location) => !location.builtIn)
.map((location) => ({ id: location.id, name: location.name, path: location.path }));
}
export function ProjectLocationsSection({ cfg, setCfg, onProjectsRefresh }: Props) {
const { t } = useI18n();
const [locations, setLocations] = useState<ProjectLocation[]>([]);
const [drafts, setDrafts] = useState<DraftLocation[]>(cfg.projectLocations ?? []);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const draftsRef = useRef<DraftLocation[]>(drafts);
useEffect(() => {
draftsRef.current = drafts;
}, [drafts]);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchProjectLocations()
.then((next) => {
if (cancelled) return;
setLocations(next);
setDrafts(externalLocations(next));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [setCfg]);
const builtIn = useMemo(
() => locations.find((location) => location.builtIn),
[locations],
);
const effectiveDefaultLocationId = useMemo(() => {
const configured = cfg.defaultProjectLocationId ?? 'default';
return locations.some((location) => location.id === configured) ? configured : 'default';
}, [cfg.defaultProjectLocationId, locations]);
function defaultControlLabel(locationId: string): string {
return effectiveDefaultLocationId === locationId
? t('settings.projectLocationsDefaultBadge')
: t('settings.projectLocationsMakeDefault');
}
function handleDefaultLocationChange(locationId: string) {
setError(null);
setStatus(t('settings.projectLocationsDefaultSaved'));
setCfg((current) => ({ ...current, defaultProjectLocationId: locationId }));
}
async function save(nextDrafts: DraftLocation[]) {
setSaving(true);
setError(null);
setStatus(null);
try {
const saved = await updateProjectLocations(
nextDrafts.filter((location) => location.path.trim()),
);
if (!saved) {
setError(t('settings.projectLocationsSaveError'));
return null;
}
setLocations(saved);
const external = externalLocations(saved);
setDrafts(external);
setCfg((current) => {
const configuredDefault = current.defaultProjectLocationId ?? 'default';
const nextDefault = saved.some((location) => location.id === configuredDefault)
? configuredDefault
: 'default';
return {
...current,
projectLocations: toConfigLocations(saved),
defaultProjectLocationId: nextDefault,
};
});
setStatus(t('settings.projectLocationsSaved'));
void onProjectsRefresh?.();
return external;
} finally {
setSaving(false);
}
}
async function runScan() {
const result = await scanProjectLocations();
if (!result) {
setError(t('settings.projectLocationsScanError'));
return null;
}
setStatus(t('settings.projectLocationsScanComplete', {
imported: result.imported.length,
existing: result.existing.length,
}));
void onProjectsRefresh?.();
return result;
}
async function handleAddFolder() {
setError(null);
setStatus(null);
const selected = await openProjectLocationFolderDialog();
if (!selected) {
setStatus(t('settings.projectLocationsNoFolderSelected'));
return;
}
if (draftsRef.current.some((draft) => draft.path === selected)) {
setStatus(t('settings.projectLocationsDuplicate'));
return;
}
const previous = draftsRef.current;
const next = [...previous, { path: selected }];
setDrafts(next);
const saved = await save(next);
if (!saved) setDrafts(previous);
else await runScan();
}
async function removeDraft(index: number) {
const previous = draftsRef.current;
const next = previous.filter((_, i) => i !== index);
setDrafts(next);
const saved = await save(next);
if (!saved) setDrafts(previous);
}
return (
<section className="settings-section settings-section-card project-locations-section">
<div className="section-head">
<div>
<h3>{t('settings.projectLocations')}</h3>
<p className="hint">{t('settings.projectLocationsDescription')}</p>
</div>
</div>
{builtIn ? (
<div className={`project-location-card is-built-in${effectiveDefaultLocationId === builtIn.id ? ' is-default' : ''}`}>
<div>
<strong>{t('newproj.locationDefault')}</strong>
<code>{builtIn.path}</code>
</div>
<label className="project-location-default-control">
<input
type="radio"
name="project-location-default"
checked={effectiveDefaultLocationId === builtIn.id}
onChange={() => handleDefaultLocationChange(builtIn.id)}
/>
<span>{defaultControlLabel(builtIn.id)}</span>
</label>
</div>
) : null}
<div className="project-location-list">
{drafts.map((draft, index) => (
<div
className={`project-location-edit${draft.id && effectiveDefaultLocationId === draft.id ? ' is-default' : ''}`}
key={`${draft.id ?? 'new'}-${index}`}
>
<div className="project-location-edit-main">
<strong>{locationLabel(draft.path)}</strong>
<code>{draft.path}</code>
<small>{t('settings.projectLocationsWorkBaseMeta')}</small>
</div>
{draft.id ? (
<label className="project-location-default-control">
<input
type="radio"
name="project-location-default"
checked={effectiveDefaultLocationId === draft.id}
onChange={() => handleDefaultLocationChange(draft.id!)}
/>
<span>{defaultControlLabel(draft.id)}</span>
</label>
) : null}
<button type="button" className="icon-btn danger" onClick={() => removeDraft(index)} disabled={saving}>
{t('common.delete')}
</button>
</div>
))}
</div>
<button
type="button"
className="icon-btn project-location-add"
onClick={handleAddFolder}
disabled={loading || saving}
>
<Icon name="plus" size={12} />
{t('settings.projectLocationsAddFolder')}
</button>
{status ? <p className="settings-rescan-status">{status}</p> : null}
{error ? <p className="settings-rescan-status error">{error}</p> : null}
</section>
);
}

View file

@ -94,6 +94,7 @@ import { McpClientSection } from './McpClientSection';
import { SkillsSection } from './SkillsSection';
import { DesignSystemsSection } from './DesignSystemsSection';
import { PrivacySection } from './PrivacySection';
import { ProjectLocationsSection } from './ProjectLocationsSection';
import { RoutinesSection } from './RoutinesSection';
import { ConnectorsBrowser } from './ConnectorsBrowser';
import { MemoryModelInline } from './MemoryModelInline';
@ -135,6 +136,7 @@ export type SettingsSection =
| 'pet'
| 'skills'
| 'designSystems'
| 'projectLocations'
| 'memory'
| 'privacy'
// 'library' is consumed by the EntryShell library route — App opens it
@ -194,6 +196,7 @@ interface Props {
daemonMediaProvidersFetchState?: 'idle' | 'ok' | 'error';
mediaProvidersNotice?: string | null;
onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>;
onProjectsRefresh?: () => Promise<void> | void;
/**
* Notified by Settings Skills after a successful skill registry
* mutation (create / edit / delete). App.tsx uses this to drop preview
@ -835,6 +838,7 @@ export function SettingsDialog({
daemonMediaProvidersFetchState = 'idle',
mediaProvidersNotice,
onReloadMediaProviders,
onProjectsRefresh,
onSkillsChanged,
onDesignSystemsChanged,
providerModelsCache: sharedProviderModelsCache,
@ -2034,6 +2038,10 @@ export function SettingsDialog({
title: t('settings.designSystems'),
subtitle: t('settings.designSystemsHint'),
},
projectLocations: {
title: t('settings.projectLocations'),
subtitle: t('settings.projectLocationsHint'),
},
memory: { title: t('settings.memory'), subtitle: t('settings.memoryHint') },
// 'library' is opened via EntryShell route — SettingsDialog doesn't
// render it but SettingsSection must accept the token (see type def).
@ -2465,6 +2473,17 @@ export function SettingsDialog({
<small>{t('settings.designSystemsHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'projectLocations' ? ' active' : ''}`}
onClick={() => setActiveSection('projectLocations')}
>
<Icon name="folder" size={18} />
<span>
<strong>{t('settings.projectLocations')}</strong>
<small>{t('settings.projectLocationsHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'privacy' ? ' active' : ''}`}
@ -3664,6 +3683,10 @@ export function SettingsDialog({
/>
) : null}
{activeSection === 'projectLocations' ? (
<ProjectLocationsSection cfg={cfg} setCfg={setCfg} onProjectsRefresh={onProjectsRefresh} />
) : null}
{activeSection === 'instructions' ? (
<section className="settings-section settings-section-card instructions-rules-section">
<div className="memory-field-block instructions-rules-card">

View file

@ -566,6 +566,9 @@ export const ar: Dict = {
'newproj.fileSingular': 'ملف',
'newproj.filePlural': 'ملفات',
'newproj.create': 'إنشاء',
'newproj.locationLabel': 'حفظ في',
'newproj.locationDefault': 'مشاريع Open Design',
'newproj.locationExternalBase': 'قاعدة خارجية',
'newproj.createFromTemplate': 'إنشاء من قالب',
'newproj.createDisabledTitle':
'احفظ مشروعاً كقالب أولاً (قائمة المشاركة داخل أي مشروع).',
@ -1552,6 +1555,20 @@ export const ar: Dict = {
'settings.designSystemsCategory': 'الفئة',
'settings.designSystemsAllCategories': 'كل الفئات',
'settings.designSystemsShowInHomeGallery': 'إظهار في معرض الصفحة الرئيسية',
'settings.projectLocations': 'مواقع المشاريع',
'settings.projectLocationsHint': 'جذور تخزين مساحات العمل',
'settings.projectLocationsDescription': 'أضف قواعد عمل يمكن أن تحتوي على عدة مجلدات مشاريع Open Design. تُحفظ المشاريع الجديدة كمجلد داخل القاعدة المحددة.',
'settings.projectLocationsSaveError': 'تعذّر حفظ مواقع المشاريع. تحقق من أن كل مسار مجلد يمكن الوصول إليه.',
'settings.projectLocationsSaved': 'تم حفظ مواقع المشاريع.',
'settings.projectLocationsScanError': 'تعذّر فحص مواقع المشاريع.',
'settings.projectLocationsScanComplete': 'اكتمل الفحص: تم استيراد {imported}، و{existing} مسجلة مسبقًا.',
'settings.projectLocationsNoFolderSelected': 'لم يتم اختيار مجلد.',
'settings.projectLocationsDuplicate': 'تمت إضافة قاعدة العمل هذه بالفعل.',
'settings.projectLocationsWorkBaseMeta': 'قاعدة عمل · يتم إنشاء المشاريع هنا كمجلدات فرعية',
'settings.projectLocationsAddFolder': 'إضافة مجلد…',
'settings.projectLocationsDefaultBadge': 'الموقع الافتراضي',
'settings.projectLocationsMakeDefault': 'تعيين كافتراضي',
'settings.projectLocationsDefaultSaved': 'تم تحديث موقع المشروع الافتراضي.',
'settings.librarySkills': 'المهارات',
'settings.libraryDesignSystems': 'أنظمة التصميم',
'settings.librarySearch': 'بحث...',

View file

@ -463,6 +463,9 @@ export const de: Dict = {
'newproj.fileSingular': 'Datei',
'newproj.filePlural': 'Dateien',
'newproj.create': 'Erstellen',
'newproj.locationLabel': 'Speichern unter',
'newproj.locationDefault': 'Open Design-Projekte',
'newproj.locationExternalBase': 'Externe Basis',
'newproj.createFromTemplate': 'Aus Template erstellen',
'newproj.createDisabledTitle':
'Speichern Sie zuerst ein Projekt als Template (Teilen-Menü in einem beliebigen Projekt).',
@ -1490,6 +1493,20 @@ export const de: Dict = {
'settings.designSystemsCategory': 'Kategorie',
'settings.designSystemsAllCategories': 'Alle Kategorien',
'settings.designSystemsShowInHomeGallery': 'In Home-Galerie anzeigen',
'settings.projectLocations': 'Projektorte',
'settings.projectLocationsHint': 'Workspace-Speicherorte',
'settings.projectLocationsDescription': 'Füge Arbeitsbasen hinzu, die mehrere Open Design-Projektordner enthalten können. Neue Projekte werden als Ordner in der ausgewählten Basis gespeichert.',
'settings.projectLocationsSaveError': 'Projektorte konnten nicht gespeichert werden. Prüfe, ob jeder Pfad ein zugänglicher Ordner ist.',
'settings.projectLocationsSaved': 'Projektorte gespeichert.',
'settings.projectLocationsScanError': 'Projektorte konnten nicht gescannt werden.',
'settings.projectLocationsScanComplete': 'Scan abgeschlossen: {imported} importiert, {existing} bereits registriert.',
'settings.projectLocationsNoFolderSelected': 'Kein Ordner ausgewählt.',
'settings.projectLocationsDuplicate': 'Diese Arbeitsbasis wurde bereits hinzugefügt.',
'settings.projectLocationsWorkBaseMeta': 'Arbeitsbasis · Projekte werden hier als Unterordner erstellt',
'settings.projectLocationsAddFolder': 'Ordner hinzufügen…',
'settings.projectLocationsDefaultBadge': 'Standardort',
'settings.projectLocationsMakeDefault': 'Als Standard festlegen',
'settings.projectLocationsDefaultSaved': 'Standard-Projektort aktualisiert.',
'settings.librarySkills': 'Fähigkeiten',
'settings.libraryDesignSystems': 'Designsysteme',
'settings.librarySearch': 'Suchen...',

View file

@ -1157,6 +1157,9 @@ export const en: Dict = {
'newproj.fileSingular': 'file',
'newproj.filePlural': 'files',
'newproj.create': 'Create',
'newproj.locationLabel': 'Save to',
'newproj.locationDefault': 'Open Design projects',
'newproj.locationExternalBase': 'External base',
'newproj.createLiveArtifact': 'Create live artifact',
'newproj.createFromTemplate': 'Create from template',
'newproj.createDisabledTitle':
@ -2349,6 +2352,20 @@ export const en: Dict = {
'settings.designSystemsCategory': 'Category',
'settings.designSystemsAllCategories': 'All categories',
'settings.designSystemsShowInHomeGallery': 'Show in home gallery',
'settings.projectLocations': 'Project locations',
'settings.projectLocationsHint': 'Workspace storage roots',
'settings.projectLocationsDescription': 'Add work bases that can contain multiple Open Design project folders. New projects are saved as one folder inside the selected base.',
'settings.projectLocationsSaveError': 'Could not save project locations. Check that each path is an accessible folder.',
'settings.projectLocationsSaved': 'Project locations saved.',
'settings.projectLocationsScanError': 'Could not scan project locations.',
'settings.projectLocationsScanComplete': 'Scan complete: {imported} imported, {existing} already registered.',
'settings.projectLocationsNoFolderSelected': 'No folder selected.',
'settings.projectLocationsDuplicate': 'That work base is already added.',
'settings.projectLocationsWorkBaseMeta': 'Work base · projects are created as subfolders here',
'settings.projectLocationsAddFolder': 'Add folder…',
'settings.projectLocationsDefaultBadge': 'Default location',
'settings.projectLocationsMakeDefault': 'Make default',
'settings.projectLocationsDefaultSaved': 'Default project location updated.',
'settings.librarySkills': 'Skills',
'settings.libraryDesignSystems': 'Design Systems',
'settings.librarySearch': 'Search...',

View file

@ -464,6 +464,9 @@ export const esES: Dict = {
'newproj.fileSingular': 'archivo',
'newproj.filePlural': 'archivos',
'newproj.create': 'Crear',
'newproj.locationLabel': 'Guardar en',
'newproj.locationDefault': 'Proyectos de Open Design',
'newproj.locationExternalBase': 'Base externa',
'newproj.createFromTemplate': 'Crear desde plantilla',
'newproj.createDisabledTitle':
'Guarda primero un proyecto como plantilla (menú Compartir dentro de cualquier proyecto).',
@ -1441,6 +1444,20 @@ export const esES: Dict = {
'settings.designSystemsCategory': 'Categoría',
'settings.designSystemsAllCategories': 'Todas las categorías',
'settings.designSystemsShowInHomeGallery': 'Mostrar en la galería de inicio',
'settings.projectLocations': 'Ubicaciones de proyectos',
'settings.projectLocationsHint': 'Raíces de almacenamiento del espacio de trabajo',
'settings.projectLocationsDescription': 'Añade bases de trabajo que pueden contener varias carpetas de proyectos de Open Design. Los proyectos nuevos se guardan como una carpeta dentro de la base seleccionada.',
'settings.projectLocationsSaveError': 'No se pudieron guardar las ubicaciones de proyectos. Comprueba que cada ruta sea una carpeta accesible.',
'settings.projectLocationsSaved': 'Ubicaciones de proyectos guardadas.',
'settings.projectLocationsScanError': 'No se pudieron escanear las ubicaciones de proyectos.',
'settings.projectLocationsScanComplete': 'Escaneo completado: {imported} importados, {existing} ya registrados.',
'settings.projectLocationsNoFolderSelected': 'No se seleccionó ninguna carpeta.',
'settings.projectLocationsDuplicate': 'Esa base de trabajo ya está añadida.',
'settings.projectLocationsWorkBaseMeta': 'Base de trabajo · los proyectos se crean aquí como subcarpetas',
'settings.projectLocationsAddFolder': 'Añadir carpeta…',
'settings.projectLocationsDefaultBadge': 'Ubicación predeterminada',
'settings.projectLocationsMakeDefault': 'Hacer predeterminada',
'settings.projectLocationsDefaultSaved': 'Ubicación de proyecto predeterminada actualizada.',
'settings.librarySkills': 'Habilidades',
'settings.libraryDesignSystems': 'Sistemas de diseño',
'settings.librarySearch': 'Buscar...',

View file

@ -578,6 +578,9 @@ export const fa: Dict = {
'newproj.fileSingular': 'فایل',
'newproj.filePlural': 'فایل',
'newproj.create': 'ایجاد',
'newproj.locationLabel': 'ذخیره در',
'newproj.locationDefault': 'پروژه‌های Open Design',
'newproj.locationExternalBase': 'پایهٔ خارجی',
'newproj.createLiveArtifact': 'ایجاد مصنوع زنده',
'newproj.createFromTemplate': 'ایجاد از قالب',
'newproj.createDisabledTitle':
@ -1595,6 +1598,20 @@ export const fa: Dict = {
'settings.designSystemsCategory': 'دسته‌بندی',
'settings.designSystemsAllCategories': 'همه دسته‌بندی‌ها',
'settings.designSystemsShowInHomeGallery': 'نمایش در گالری خانه',
'settings.projectLocations': 'مکان‌های پروژه',
'settings.projectLocationsHint': 'ریشه‌های ذخیره‌سازی فضای کاری',
'settings.projectLocationsDescription': 'پایه‌های کاری اضافه کنید که می‌توانند چند پوشهٔ پروژهٔ Open Design را در خود داشته باشند. پروژه‌های جدید به‌صورت یک پوشه داخل پایهٔ انتخاب‌شده ذخیره می‌شوند.',
'settings.projectLocationsSaveError': 'ذخیرهٔ مکان‌های پروژه ممکن نشد. بررسی کنید هر مسیر یک پوشهٔ قابل دسترسی باشد.',
'settings.projectLocationsSaved': 'مکان‌های پروژه ذخیره شد.',
'settings.projectLocationsScanError': 'اسکن مکان‌های پروژه ممکن نشد.',
'settings.projectLocationsScanComplete': 'اسکن کامل شد: {imported} وارد شد، {existing} از قبل ثبت شده بود.',
'settings.projectLocationsNoFolderSelected': 'پوشه‌ای انتخاب نشد.',
'settings.projectLocationsDuplicate': 'این پایهٔ کاری قبلاً اضافه شده است.',
'settings.projectLocationsWorkBaseMeta': 'پایهٔ کاری · پروژه‌ها اینجا به‌صورت زیرپوشه ساخته می‌شوند',
'settings.projectLocationsAddFolder': 'افزودن پوشه…',
'settings.projectLocationsDefaultBadge': 'مکان پیش‌فرض',
'settings.projectLocationsMakeDefault': 'تنظیم به‌عنوان پیش‌فرض',
'settings.projectLocationsDefaultSaved': 'مکان پیش‌فرض پروژه به‌روزرسانی شد.',
'settings.librarySkills': 'مهارت‌ها',
'settings.libraryDesignSystems': 'سیستم‌های طراحی',
'settings.librarySearch': 'جستجو...',

View file

@ -1101,6 +1101,9 @@ export const fr: Dict = {
'newproj.fileSingular': 'fichier',
'newproj.filePlural': 'fichiers',
'newproj.create': 'Créer',
'newproj.locationLabel': 'Enregistrer dans',
'newproj.locationDefault': 'Projets Open Design',
'newproj.locationExternalBase': 'Base externe',
'newproj.createLiveArtifact': 'Créer un artefact dynamique',
'newproj.createFromTemplate': 'Créer depuis le modèle',
'newproj.createDisabledTitle': 'Enregistrez d\'abord un projet comme modèle (menu Partager dans un projet).',
@ -2214,6 +2217,20 @@ export const fr: Dict = {
'settings.designSystemsCategory': 'Catégorie',
'settings.designSystemsAllCategories': 'Toutes les catégories',
'settings.designSystemsShowInHomeGallery': 'Afficher dans la galerie daccueil',
'settings.projectLocations': 'Emplacements de projets',
'settings.projectLocationsHint': 'Racines de stockage des espaces de travail',
'settings.projectLocationsDescription': 'Ajoutez des bases de travail pouvant contenir plusieurs dossiers de projets Open Design. Les nouveaux projets sont enregistrés comme un dossier dans la base sélectionnée.',
'settings.projectLocationsSaveError': 'Impossible denregistrer les emplacements de projets. Vérifiez que chaque chemin est un dossier accessible.',
'settings.projectLocationsSaved': 'Emplacements de projets enregistrés.',
'settings.projectLocationsScanError': 'Impossible danalyser les emplacements de projets.',
'settings.projectLocationsScanComplete': 'Analyse terminée : {imported} importé(s), {existing} déjà enregistré(s).',
'settings.projectLocationsNoFolderSelected': 'Aucun dossier sélectionné.',
'settings.projectLocationsDuplicate': 'Cette base de travail est déjà ajoutée.',
'settings.projectLocationsWorkBaseMeta': 'Base de travail · les projets sont créés ici comme sous-dossiers',
'settings.projectLocationsAddFolder': 'Ajouter un dossier…',
'settings.projectLocationsDefaultBadge': 'Emplacement par défaut',
'settings.projectLocationsMakeDefault': 'Définir par défaut',
'settings.projectLocationsDefaultSaved': 'Emplacement de projet par défaut mis à jour.',
'settings.librarySkills': 'Compétences',
'settings.libraryDesignSystems': 'Systèmes de design',
'settings.librarySearch': 'Rechercher...',

View file

@ -566,6 +566,9 @@ export const hu: Dict = {
'newproj.fileSingular': 'fájl',
'newproj.filePlural': 'fájl',
'newproj.create': 'Létrehozás',
'newproj.locationLabel': 'Mentés ide',
'newproj.locationDefault': 'Open Design projektek',
'newproj.locationExternalBase': 'Külső bázis',
'newproj.createFromTemplate': 'Létrehozás sablonból',
'newproj.createDisabledTitle':
'Először ments el egy projektet sablonként (bármely projekt Megosztás menüjéből).',
@ -1562,6 +1565,20 @@ export const hu: Dict = {
'settings.designSystemsCategory': 'Kategória',
'settings.designSystemsAllCategories': 'Minden kategória',
'settings.designSystemsShowInHomeGallery': 'Megjelenítés a kezdő galériában',
'settings.projectLocations': 'Projekt helyek',
'settings.projectLocationsHint': 'Munkaterület tárolási gyökerek',
'settings.projectLocationsDescription': 'Adj hozzá munkabázisokat, amelyek több Open Design projektmappát is tartalmazhatnak. Az új projektek mappaként jönnek létre a kiválasztott bázison belül.',
'settings.projectLocationsSaveError': 'Nem sikerült menteni a projekt helyeket. Ellenőrizd, hogy minden útvonal elérhető mappa-e.',
'settings.projectLocationsSaved': 'Projekt helyek mentve.',
'settings.projectLocationsScanError': 'Nem sikerült beolvasni a projekt helyeket.',
'settings.projectLocationsScanComplete': 'Beolvasás kész: {imported} importálva, {existing} már regisztrálva.',
'settings.projectLocationsNoFolderSelected': 'Nincs kiválasztott mappa.',
'settings.projectLocationsDuplicate': 'Ez a munkabázis már hozzá van adva.',
'settings.projectLocationsWorkBaseMeta': 'Munkabázis · a projektek itt almappaként jönnek létre',
'settings.projectLocationsAddFolder': 'Mappa hozzáadása…',
'settings.projectLocationsDefaultBadge': 'Alapértelmezett hely',
'settings.projectLocationsMakeDefault': 'Legyen alapértelmezett',
'settings.projectLocationsDefaultSaved': 'Az alapértelmezett projekt hely frissítve.',
'settings.librarySkills': 'Készségek',
'settings.libraryDesignSystems': 'Tervezőrendszerek',
'settings.librarySearch': 'Keresés...',

View file

@ -672,6 +672,9 @@ export const id: Dict = {
'newproj.fileSingular': 'berkas',
'newproj.filePlural': 'berkas',
'newproj.create': 'Buat',
'newproj.locationLabel': 'Simpan ke',
'newproj.locationDefault': 'Proyek Open Design',
'newproj.locationExternalBase': 'Basis eksternal',
'newproj.createLiveArtifact': 'Buat live artifact',
'newproj.createFromTemplate': 'Buat dari templat',
'newproj.createDisabledTitle': 'Simpan proyek sebagai templat dulu.',
@ -1700,6 +1703,20 @@ export const id: Dict = {
'settings.designSystemsCategory': 'Kategori',
'settings.designSystemsAllCategories': 'Semua kategori',
'settings.designSystemsShowInHomeGallery': 'Tampilkan di galeri beranda',
'settings.projectLocations': 'Lokasi proyek',
'settings.projectLocationsHint': 'Root penyimpanan workspace',
'settings.projectLocationsDescription': 'Tambahkan basis kerja yang dapat berisi beberapa folder proyek Open Design. Proyek baru disimpan sebagai folder di dalam basis yang dipilih.',
'settings.projectLocationsSaveError': 'Tidak dapat menyimpan lokasi proyek. Pastikan setiap path adalah folder yang dapat diakses.',
'settings.projectLocationsSaved': 'Lokasi proyek disimpan.',
'settings.projectLocationsScanError': 'Tidak dapat memindai lokasi proyek.',
'settings.projectLocationsScanComplete': 'Pemindaian selesai: {imported} diimpor, {existing} sudah terdaftar.',
'settings.projectLocationsNoFolderSelected': 'Tidak ada folder yang dipilih.',
'settings.projectLocationsDuplicate': 'Basis kerja itu sudah ditambahkan.',
'settings.projectLocationsWorkBaseMeta': 'Basis kerja · proyek dibuat di sini sebagai subfolder',
'settings.projectLocationsAddFolder': 'Tambah folder…',
'settings.projectLocationsDefaultBadge': 'Lokasi default',
'settings.projectLocationsMakeDefault': 'Jadikan default',
'settings.projectLocationsDefaultSaved': 'Lokasi proyek default diperbarui.',
'settings.librarySkills': 'Skill',
'settings.libraryDesignSystems': 'Sistem desain',
'settings.librarySearch': 'Cari...',

View file

@ -539,6 +539,9 @@ export const it: Dict = {
'newproj.fileSingular': 'file',
'newproj.filePlural': 'file',
'newproj.create': 'Crea',
'newproj.locationLabel': 'Salva in',
'newproj.locationDefault': 'Progetti Open Design',
'newproj.locationExternalBase': 'Base esterna',
'newproj.createFromTemplate': 'Crea dal modello',
'newproj.createDisabledTitle':
'Salva prima un progetto come modello (menu Condividi in un progetto).',
@ -1432,6 +1435,20 @@ export const it: Dict = {
'settings.designSystemsCategory': 'Categoria',
'settings.designSystemsAllCategories': 'Tutte le categorie',
'settings.designSystemsShowInHomeGallery': 'Mostra nella galleria iniziale',
'settings.projectLocations': 'Posizioni dei progetti',
'settings.projectLocationsHint': 'Radici di archiviazione workspace',
'settings.projectLocationsDescription': 'Aggiungi basi di lavoro che possono contenere più cartelle di progetti Open Design. I nuovi progetti vengono salvati come una cartella nella base selezionata.',
'settings.projectLocationsSaveError': 'Impossibile salvare le posizioni dei progetti. Verifica che ogni percorso sia una cartella accessibile.',
'settings.projectLocationsSaved': 'Posizioni dei progetti salvate.',
'settings.projectLocationsScanError': 'Impossibile scansionare le posizioni dei progetti.',
'settings.projectLocationsScanComplete': 'Scansione completata: {imported} importati, {existing} già registrati.',
'settings.projectLocationsNoFolderSelected': 'Nessuna cartella selezionata.',
'settings.projectLocationsDuplicate': 'Questa base di lavoro è già stata aggiunta.',
'settings.projectLocationsWorkBaseMeta': 'Base di lavoro · i progetti vengono creati qui come sottocartelle',
'settings.projectLocationsAddFolder': 'Aggiungi cartella…',
'settings.projectLocationsDefaultBadge': 'Posizione predefinita',
'settings.projectLocationsMakeDefault': 'Imposta come predefinita',
'settings.projectLocationsDefaultSaved': 'Posizione progetto predefinita aggiornata.',
'settings.librarySkills': 'Competenze',
'settings.libraryDesignSystems': 'Sistemi di design',
'settings.librarySearch': 'Cerca...',

View file

@ -463,6 +463,9 @@ export const ja: Dict = {
'newproj.fileSingular': 'ファイル',
'newproj.filePlural': 'ファイル',
'newproj.create': '作成',
'newproj.locationLabel': '保存先',
'newproj.locationDefault': 'Open Design プロジェクト',
'newproj.locationExternalBase': '外部ベース',
'newproj.createFromTemplate': 'テンプレートから作成',
'newproj.createDisabledTitle':
'最初にプロジェクトをテンプレートとして保存してください(プロジェクト内の共有メニュー)。',
@ -1489,6 +1492,20 @@ export const ja: Dict = {
'settings.designSystemsCategory': 'カテゴリー',
'settings.designSystemsAllCategories': 'すべてのカテゴリー',
'settings.designSystemsShowInHomeGallery': 'ホームギャラリーに表示',
'settings.projectLocations': 'プロジェクトの場所',
'settings.projectLocationsHint': 'ワークスペース保存ルート',
'settings.projectLocationsDescription': '複数の Open Design プロジェクトフォルダを含められる作業ベースを追加します。新しいプロジェクトは選択したベース内の 1 つのフォルダとして保存されます。',
'settings.projectLocationsSaveError': 'プロジェクトの場所を保存できませんでした。各パスがアクセス可能なフォルダであることを確認してください。',
'settings.projectLocationsSaved': 'プロジェクトの場所を保存しました。',
'settings.projectLocationsScanError': 'プロジェクトの場所をスキャンできませんでした。',
'settings.projectLocationsScanComplete': 'スキャン完了: {imported} 件をインポート、{existing} 件は登録済みです。',
'settings.projectLocationsNoFolderSelected': 'フォルダが選択されていません。',
'settings.projectLocationsDuplicate': 'その作業ベースはすでに追加されています。',
'settings.projectLocationsWorkBaseMeta': '作業ベース · プロジェクトはここにサブフォルダとして作成されます',
'settings.projectLocationsAddFolder': 'フォルダを追加…',
'settings.projectLocationsDefaultBadge': 'デフォルトの場所',
'settings.projectLocationsMakeDefault': 'デフォルトにする',
'settings.projectLocationsDefaultSaved': 'デフォルトのプロジェクト場所を更新しました。',
'settings.librarySkills': 'スキル',
'settings.libraryDesignSystems': 'デザインシステム',
'settings.librarySearch': '検索...',

View file

@ -566,6 +566,9 @@ export const ko: Dict = {
'newproj.fileSingular': '파일',
'newproj.filePlural': '파일들',
'newproj.create': '생성',
'newproj.locationLabel': '저장 위치',
'newproj.locationDefault': 'Open Design 프로젝트',
'newproj.locationExternalBase': '외부 베이스',
'newproj.createFromTemplate': '템플릿으로 생성',
'newproj.createDisabledTitle':
'먼저 프로젝트를 템플릿으로 저장하세요 (프로젝트 내 공유 메뉴 이용).',
@ -1602,6 +1605,20 @@ export const ko: Dict = {
'settings.designSystemsCategory': '카테고리',
'settings.designSystemsAllCategories': '모든 카테고리',
'settings.designSystemsShowInHomeGallery': '홈 갤러리에 표시',
'settings.projectLocations': '프로젝트 위치',
'settings.projectLocationsHint': '워크스페이스 저장 루트',
'settings.projectLocationsDescription': '여러 Open Design 프로젝트 폴더를 포함할 수 있는 작업 베이스를 추가합니다. 새 프로젝트는 선택한 베이스 안의 폴더로 저장됩니다.',
'settings.projectLocationsSaveError': '프로젝트 위치를 저장할 수 없습니다. 각 경로가 접근 가능한 폴더인지 확인하세요.',
'settings.projectLocationsSaved': '프로젝트 위치가 저장되었습니다.',
'settings.projectLocationsScanError': '프로젝트 위치를 스캔할 수 없습니다.',
'settings.projectLocationsScanComplete': '스캔 완료: {imported}개 가져옴, {existing}개는 이미 등록됨.',
'settings.projectLocationsNoFolderSelected': '선택한 폴더가 없습니다.',
'settings.projectLocationsDuplicate': '해당 작업 베이스는 이미 추가되었습니다.',
'settings.projectLocationsWorkBaseMeta': '작업 베이스 · 프로젝트는 여기에 하위 폴더로 생성됩니다',
'settings.projectLocationsAddFolder': '폴더 추가…',
'settings.projectLocationsDefaultBadge': '기본 위치',
'settings.projectLocationsMakeDefault': '기본값으로 설정',
'settings.projectLocationsDefaultSaved': '기본 프로젝트 위치가 업데이트되었습니다.',
'settings.librarySkills': '스킬',
'settings.libraryDesignSystems': '디자인 시스템',
'settings.librarySearch': '검색...',

View file

@ -566,6 +566,9 @@ export const pl: Dict = {
'newproj.fileSingular': 'plik',
'newproj.filePlural': 'pliki',
'newproj.create': 'Utwórz',
'newproj.locationLabel': 'Zapisz w',
'newproj.locationDefault': 'Projekty Open Design',
'newproj.locationExternalBase': 'Zewnętrzna baza',
'newproj.createFromTemplate': 'Utwórz z szablonu',
'newproj.createDisabledTitle':
'Najpierw zapisz projekt jako szablon (menu Udostępnij wewnątrz projektu).',
@ -1552,6 +1555,20 @@ export const pl: Dict = {
'settings.designSystemsCategory': 'Kategoria',
'settings.designSystemsAllCategories': 'Wszystkie kategorie',
'settings.designSystemsShowInHomeGallery': 'Pokaż w galerii głównej',
'settings.projectLocations': 'Lokalizacje projektów',
'settings.projectLocationsHint': 'Katalogi główne workspace',
'settings.projectLocationsDescription': 'Dodaj bazy robocze, które mogą zawierać wiele folderów projektów Open Design. Nowe projekty są zapisywane jako folder w wybranej bazie.',
'settings.projectLocationsSaveError': 'Nie udało się zapisać lokalizacji projektów. Sprawdź, czy każda ścieżka jest dostępnym folderem.',
'settings.projectLocationsSaved': 'Lokalizacje projektów zapisane.',
'settings.projectLocationsScanError': 'Nie udało się przeskanować lokalizacji projektów.',
'settings.projectLocationsScanComplete': 'Skanowanie zakończone: zaimportowano {imported}, już zarejestrowano {existing}.',
'settings.projectLocationsNoFolderSelected': 'Nie wybrano folderu.',
'settings.projectLocationsDuplicate': 'Ta baza robocza jest już dodana.',
'settings.projectLocationsWorkBaseMeta': 'Baza robocza · projekty są tworzone tutaj jako podfoldery',
'settings.projectLocationsAddFolder': 'Dodaj folder…',
'settings.projectLocationsDefaultBadge': 'Lokalizacja domyślna',
'settings.projectLocationsMakeDefault': 'Ustaw jako domyślną',
'settings.projectLocationsDefaultSaved': 'Domyślna lokalizacja projektu zaktualizowana.',
'settings.librarySkills': 'Umiejętności',
'settings.libraryDesignSystems': 'Systemy projektowe',
'settings.librarySearch': 'Szukaj...',

View file

@ -576,6 +576,9 @@ export const ptBR: Dict = {
'newproj.fileSingular': 'arquivo',
'newproj.filePlural': 'arquivos',
'newproj.create': 'Criar',
'newproj.locationLabel': 'Salvar em',
'newproj.locationDefault': 'Projetos Open Design',
'newproj.locationExternalBase': 'Base externa',
'newproj.createLiveArtifact': 'Criar artefato live',
'newproj.createFromTemplate': 'Criar a partir do template',
'newproj.createDisabledTitle':
@ -1593,6 +1596,20 @@ export const ptBR: Dict = {
'settings.designSystemsCategory': 'Categoria',
'settings.designSystemsAllCategories': 'Todas as categorias',
'settings.designSystemsShowInHomeGallery': 'Mostrar na galeria inicial',
'settings.projectLocations': 'Locais de projetos',
'settings.projectLocationsHint': 'Raízes de armazenamento do workspace',
'settings.projectLocationsDescription': 'Adicione bases de trabalho que podem conter várias pastas de projetos do Open Design. Novos projetos são salvos como uma pasta dentro da base selecionada.',
'settings.projectLocationsSaveError': 'Não foi possível salvar os locais de projetos. Verifique se cada caminho é uma pasta acessível.',
'settings.projectLocationsSaved': 'Locais de projetos salvos.',
'settings.projectLocationsScanError': 'Não foi possível escanear os locais de projetos.',
'settings.projectLocationsScanComplete': 'Escaneamento concluído: {imported} importados, {existing} já registrados.',
'settings.projectLocationsNoFolderSelected': 'Nenhuma pasta selecionada.',
'settings.projectLocationsDuplicate': 'Essa base de trabalho já foi adicionada.',
'settings.projectLocationsWorkBaseMeta': 'Base de trabalho · projetos são criados aqui como subpastas',
'settings.projectLocationsAddFolder': 'Adicionar pasta…',
'settings.projectLocationsDefaultBadge': 'Local padrão',
'settings.projectLocationsMakeDefault': 'Tornar padrão',
'settings.projectLocationsDefaultSaved': 'Local padrão do projeto atualizado.',
'settings.librarySkills': 'Habilidades',
'settings.libraryDesignSystems': 'Sistemas de design',
'settings.librarySearch': 'Pesquisar...',

View file

@ -576,6 +576,9 @@ export const ru: Dict = {
'newproj.fileSingular': 'файл',
'newproj.filePlural': 'файлов',
'newproj.create': 'Создать',
'newproj.locationLabel': 'Сохранить в',
'newproj.locationDefault': 'Проекты Open Design',
'newproj.locationExternalBase': 'Внешняя база',
'newproj.createLiveArtifact': 'Создать live-артефакт',
'newproj.createFromTemplate': 'Создать из шаблона',
'newproj.createDisabledTitle':
@ -1593,6 +1596,20 @@ export const ru: Dict = {
'settings.designSystemsCategory': 'Категория',
'settings.designSystemsAllCategories': 'Все категории',
'settings.designSystemsShowInHomeGallery': 'Показывать в домашней галерее',
'settings.projectLocations': 'Расположения проектов',
'settings.projectLocationsHint': 'Корни хранения рабочих пространств',
'settings.projectLocationsDescription': 'Добавьте рабочие базы, которые могут содержать несколько папок проектов Open Design. Новые проекты сохраняются как папка внутри выбранной базы.',
'settings.projectLocationsSaveError': 'Не удалось сохранить расположения проектов. Проверьте, что каждый путь является доступной папкой.',
'settings.projectLocationsSaved': 'Расположения проектов сохранены.',
'settings.projectLocationsScanError': 'Не удалось просканировать расположения проектов.',
'settings.projectLocationsScanComplete': 'Сканирование завершено: импортировано {imported}, уже зарегистрировано {existing}.',
'settings.projectLocationsNoFolderSelected': 'Папка не выбрана.',
'settings.projectLocationsDuplicate': 'Эта рабочая база уже добавлена.',
'settings.projectLocationsWorkBaseMeta': 'Рабочая база · проекты создаются здесь как подпапки',
'settings.projectLocationsAddFolder': 'Добавить папку…',
'settings.projectLocationsDefaultBadge': 'Расположение по умолчанию',
'settings.projectLocationsMakeDefault': 'Сделать по умолчанию',
'settings.projectLocationsDefaultSaved': 'Расположение проекта по умолчанию обновлено.',
'settings.librarySkills': 'Навыки',
'settings.libraryDesignSystems': 'Системы дизайна',
'settings.librarySearch': 'Поиск...',

View file

@ -535,6 +535,9 @@ export const th: Dict = {
'newproj.fileSingular': 'ไฟล์',
'newproj.filePlural': 'ไฟล์',
'newproj.create': 'สร้าง',
'newproj.locationLabel': 'บันทึกไปยัง',
'newproj.locationDefault': 'โปรเจกต์ Open Design',
'newproj.locationExternalBase': 'ฐานภายนอก',
'newproj.createLiveArtifact': 'สร้าง live artifact',
'newproj.createFromTemplate': 'สร้างจากเทมเพลต',
'newproj.createDisabledTitle': 'คุณต้องบันทึกโปรเจกต์เป็นเทมเพลตก่อน',
@ -1469,6 +1472,20 @@ export const th: Dict = {
'settings.notifySoundBuzz': 'เป็นจังหวะกระตุ้นอารมณ์สั่นเลย',
'settings.notifySoundTwoToneDown': 'โทนดังลดถอย 2 จังหวะ',
'settings.notifySoundThud': 'เสียงหนักเน้นโครมให้ระวัง',
'settings.projectLocations': 'ตำแหน่งโปรเจกต์',
'settings.projectLocationsHint': 'รากที่เก็บเวิร์กสเปซ',
'settings.projectLocationsDescription': 'เพิ่มฐานงานที่สามารถเก็บโฟลเดอร์โปรเจกต์ Open Design ได้หลายรายการ โปรเจกต์ใหม่จะถูกบันทึกเป็นหนึ่งโฟลเดอร์ภายในฐานที่เลือก',
'settings.projectLocationsSaveError': 'ไม่สามารถบันทึกตำแหน่งโปรเจกต์ได้ ตรวจสอบว่าแต่ละพาธเป็นโฟลเดอร์ที่เข้าถึงได้',
'settings.projectLocationsSaved': 'บันทึกตำแหน่งโปรเจกต์แล้ว',
'settings.projectLocationsScanError': 'ไม่สามารถสแกนตำแหน่งโปรเจกต์ได้',
'settings.projectLocationsScanComplete': 'สแกนเสร็จแล้ว: นำเข้า {imported} รายการ, ลงทะเบียนไว้แล้ว {existing} รายการ',
'settings.projectLocationsNoFolderSelected': 'ไม่ได้เลือกโฟลเดอร์',
'settings.projectLocationsDuplicate': 'เพิ่มฐานงานนี้ไว้แล้ว',
'settings.projectLocationsWorkBaseMeta': 'ฐานงาน · โปรเจกต์จะถูกสร้างเป็นโฟลเดอร์ย่อยที่นี่',
'settings.projectLocationsAddFolder': 'เพิ่มโฟลเดอร์…',
'settings.projectLocationsDefaultBadge': 'ตำแหน่งเริ่มต้น',
'settings.projectLocationsMakeDefault': 'ตั้งเป็นค่าเริ่มต้น',
'settings.projectLocationsDefaultSaved': 'อัปเดตตำแหน่งโปรเจกต์เริ่มต้นแล้ว',
'settings.librarySkills': 'พวก Skills',
'settings.libraryDesignSystems': 'ตัวของระบบแบบ Design Systems',
'settings.librarySearch': 'ต้องการหาสิ่งใด…',

View file

@ -556,6 +556,9 @@ export const tr: Dict = {
'newproj.fileSingular': 'dosya',
'newproj.filePlural': 'dosyalar',
'newproj.create': 'Oluştur',
'newproj.locationLabel': 'Şuraya kaydet',
'newproj.locationDefault': 'Open Design projeleri',
'newproj.locationExternalBase': 'Harici taban',
'newproj.createFromTemplate': 'Şablondan oluştur',
'newproj.createDisabledTitle':
'Önce bir projeyi şablon olarak kaydedin (herhangi bir projenin içinde Paylaş menüsünden).',
@ -1539,6 +1542,20 @@ export const tr: Dict = {
'settings.designSystemsCategory': 'Kategori',
'settings.designSystemsAllCategories': 'Tüm kategoriler',
'settings.designSystemsShowInHomeGallery': 'Ana galeride göster',
'settings.projectLocations': 'Proje konumları',
'settings.projectLocationsHint': 'Çalışma alanı depolama kökleri',
'settings.projectLocationsDescription': 'Birden fazla Open Design proje klasörü içerebilen çalışma tabanları ekleyin. Yeni projeler seçilen tabanın içinde bir klasör olarak kaydedilir.',
'settings.projectLocationsSaveError': 'Proje konumları kaydedilemedi. Her yolun erişilebilir bir klasör olduğunu kontrol edin.',
'settings.projectLocationsSaved': 'Proje konumları kaydedildi.',
'settings.projectLocationsScanError': 'Proje konumları taranamadı.',
'settings.projectLocationsScanComplete': 'Tarama tamamlandı: {imported} içe aktarıldı, {existing} zaten kayıtlı.',
'settings.projectLocationsNoFolderSelected': 'Klasör seçilmedi.',
'settings.projectLocationsDuplicate': 'Bu çalışma tabanı zaten eklendi.',
'settings.projectLocationsWorkBaseMeta': 'Çalışma tabanı · projeler burada alt klasörler olarak oluşturulur',
'settings.projectLocationsAddFolder': 'Klasör ekle…',
'settings.projectLocationsDefaultBadge': 'Varsayılan konum',
'settings.projectLocationsMakeDefault': 'Varsayılan yap',
'settings.projectLocationsDefaultSaved': 'Varsayılan proje konumu güncellendi.',
'settings.librarySkills': 'Beceriler',
'settings.libraryDesignSystems': 'Tasarım sistemleri',
'settings.librarySearch': 'Ara...',

View file

@ -578,6 +578,9 @@ export const uk: Dict = {
'newproj.fileSingular': 'файл',
'newproj.filePlural': 'файли',
'newproj.create': 'Створити',
'newproj.locationLabel': 'Зберегти в',
'newproj.locationDefault': 'Проєкти Open Design',
'newproj.locationExternalBase': 'Зовнішня база',
'newproj.createLiveArtifact': 'Створити live-артефакт',
'newproj.createFromTemplate': 'Створити з шаблону',
'newproj.createDisabledTitle':
@ -1594,6 +1597,20 @@ export const uk: Dict = {
'settings.designSystemsCategory': 'Категорія',
'settings.designSystemsAllCategories': 'Усі категорії',
'settings.designSystemsShowInHomeGallery': 'Показувати в домашній галереї',
'settings.projectLocations': 'Розташування проєктів',
'settings.projectLocationsHint': 'Корені зберігання робочих просторів',
'settings.projectLocationsDescription': 'Додайте робочі бази, які можуть містити кілька тек проєктів Open Design. Нові проєкти зберігаються як тека всередині вибраної бази.',
'settings.projectLocationsSaveError': 'Не вдалося зберегти розташування проєктів. Перевірте, що кожен шлях є доступною текою.',
'settings.projectLocationsSaved': 'Розташування проєктів збережено.',
'settings.projectLocationsScanError': 'Не вдалося просканувати розташування проєктів.',
'settings.projectLocationsScanComplete': 'Сканування завершено: імпортовано {imported}, уже зареєстровано {existing}.',
'settings.projectLocationsNoFolderSelected': 'Теку не вибрано.',
'settings.projectLocationsDuplicate': 'Цю робочу базу вже додано.',
'settings.projectLocationsWorkBaseMeta': 'Робоча база · проєкти створюються тут як підтеки',
'settings.projectLocationsAddFolder': 'Додати теку…',
'settings.projectLocationsDefaultBadge': 'Типове розташування',
'settings.projectLocationsMakeDefault': 'Зробити типовим',
'settings.projectLocationsDefaultSaved': 'Типове розташування проєкту оновлено.',
'settings.librarySkills': 'Навички',
'settings.libraryDesignSystems': 'Системи дизайну',
'settings.librarySearch': 'Пошук...',

View file

@ -1152,6 +1152,9 @@ export const zhCN: Dict = {
'newproj.fileSingular': '个文件',
'newproj.filePlural': '个文件',
'newproj.create': '创建',
'newproj.locationLabel': '保存到',
'newproj.locationDefault': 'Open Design 项目',
'newproj.locationExternalBase': '外部基目录',
'newproj.createLiveArtifact': '创建实时制品',
'newproj.createFromTemplate': '基于模板创建',
'newproj.createDisabledTitle': '请先在任意项目内通过「分享」菜单将其保存为模板。',
@ -2299,6 +2302,20 @@ export const zhCN: Dict = {
'settings.designSystemsCategory': '分类',
'settings.designSystemsAllCategories': '所有分类',
'settings.designSystemsShowInHomeGallery': '在首页 Gallery 中显示',
'settings.projectLocations': '项目位置',
'settings.projectLocationsHint': '工作区存储根目录',
'settings.projectLocationsDescription': '添加可包含多个 Open Design 项目文件夹的工作基目录。新项目会作为所选基目录内的一个文件夹保存。',
'settings.projectLocationsSaveError': '无法保存项目位置。请检查每个路径都是可访问的文件夹。',
'settings.projectLocationsSaved': '项目位置已保存。',
'settings.projectLocationsScanError': '无法扫描项目位置。',
'settings.projectLocationsScanComplete': '扫描完成:已导入 {imported} 个,已有 {existing} 个。',
'settings.projectLocationsNoFolderSelected': '未选择文件夹。',
'settings.projectLocationsDuplicate': '这个工作基目录已添加。',
'settings.projectLocationsWorkBaseMeta': '工作基目录 · 项目会作为子文件夹创建在这里',
'settings.projectLocationsAddFolder': '添加文件夹…',
'settings.projectLocationsDefaultBadge': '默认位置',
'settings.projectLocationsMakeDefault': '设为默认',
'settings.projectLocationsDefaultSaved': '默认项目位置已更新。',
'settings.librarySkills': '技能',
'settings.libraryDesignSystems': '设计系统',
'settings.librarySearch': '搜索...',

View file

@ -754,6 +754,9 @@ export const zhTW: Dict = {
'newproj.fileSingular': '個檔案',
'newproj.filePlural': '個檔案',
'newproj.create': '建立',
'newproj.locationLabel': '儲存到',
'newproj.locationDefault': 'Open Design 專案',
'newproj.locationExternalBase': '外部基目錄',
'newproj.createLiveArtifact': '建立即時成品',
'newproj.createFromTemplate': '基於範本建立',
'newproj.createDisabledTitle': '請先在任意專案內透過「分享」選單將其儲存為範本。',
@ -1851,6 +1854,20 @@ export const zhTW: Dict = {
'settings.designSystemsCategory': '分類',
'settings.designSystemsAllCategories': '所有分類',
'settings.designSystemsShowInHomeGallery': '在首頁 Gallery 中顯示',
'settings.projectLocations': '專案位置',
'settings.projectLocationsHint': '工作區儲存根目錄',
'settings.projectLocationsDescription': '新增可包含多個 Open Design 專案資料夾的工作基目錄。新專案會儲存為所選基目錄中的一個資料夾。',
'settings.projectLocationsSaveError': '無法儲存專案位置。請確認每個路徑都是可存取的資料夾。',
'settings.projectLocationsSaved': '專案位置已儲存。',
'settings.projectLocationsScanError': '無法掃描專案位置。',
'settings.projectLocationsScanComplete': '掃描完成:已匯入 {imported} 個,已有 {existing} 個。',
'settings.projectLocationsNoFolderSelected': '未選取資料夾。',
'settings.projectLocationsDuplicate': '這個工作基目錄已新增。',
'settings.projectLocationsWorkBaseMeta': '工作基目錄 · 專案會在這裡建立為子資料夾',
'settings.projectLocationsAddFolder': '新增資料夾…',
'settings.projectLocationsDefaultBadge': '預設位置',
'settings.projectLocationsMakeDefault': '設為預設',
'settings.projectLocationsDefaultSaved': '預設專案位置已更新。',
'settings.librarySkills': '技能',
'settings.libraryDesignSystems': '設計系統',
'settings.librarySearch': '搜尋...',

View file

@ -444,6 +444,20 @@ export interface Dict {
'settings.designSystemsCategory': string;
'settings.designSystemsAllCategories': string;
'settings.designSystemsShowInHomeGallery': string;
'settings.projectLocations': string;
'settings.projectLocationsHint': string;
'settings.projectLocationsDescription': string;
'settings.projectLocationsSaveError': string;
'settings.projectLocationsSaved': string;
'settings.projectLocationsScanError': string;
'settings.projectLocationsScanComplete': string;
'settings.projectLocationsNoFolderSelected': string;
'settings.projectLocationsDuplicate': string;
'settings.projectLocationsWorkBaseMeta': string;
'settings.projectLocationsAddFolder': string;
'settings.projectLocationsDefaultBadge': string;
'settings.projectLocationsMakeDefault': string;
'settings.projectLocationsDefaultSaved': string;
'settings.librarySkills': string;
'settings.libraryDesignSystems': string;
'settings.librarySearch': string;
@ -1431,6 +1445,9 @@ export interface Dict {
'newproj.fileSingular': string;
'newproj.filePlural': string;
'newproj.create': string;
'newproj.locationLabel': string;
'newproj.locationDefault': string;
'newproj.locationExternalBase': string;
'newproj.createLiveArtifact': string;
'newproj.createFromTemplate': string;
'newproj.createDisabledTitle': string;

View file

@ -161,10 +161,11 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [
{
id: 'fal',
label: 'Fal.ai',
hint: 'Sora / Seedance / Veo / FLUX',
integrated: false,
hint: 'FLUX / Sora / Veo / Wan / Ideogram / Recraft and any fal-ai/* model',
integrated: true,
defaultBaseUrl: 'https://fal.run',
docsUrl: 'https://fal.ai/dashboard/keys',
supportsCustomModel: true,
},
{
id: 'leonardo',
@ -438,9 +439,16 @@ export const IMAGE_MODELS: MediaModel[] = [
{ id: 'imagen-3', label: 'imagen-3', hint: 'Google', provider: 'google', caps: ['t2i'] },
{ id: 'gemini-3-pro-image-preview', label: 'gemini-3-pro-image', hint: 'Google · Nano Banana Pro', provider: 'google', caps: ['t2i', 'i2i'] },
// Replicate / Fal hosted image models.
// Replicate hosted image models.
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
// Fal.ai image models — pass any fal-ai/* path as model for custom models.
{ id: 'flux-pro-ultra', label: 'flux-pro-ultra', hint: 'Fal · FLUX 1.1 Pro Ultra · highest quality', provider: 'fal', caps: ['t2i'] },
{ id: 'flux-dev-fal', label: 'flux-dev (fal)', hint: 'Fal · FLUX Dev · open weights', provider: 'fal', caps: ['t2i'] },
{ id: 'flux-schnell-fal', label: 'flux-schnell (fal)', hint: 'Fal · FLUX Schnell · fastest / cheapest', provider: 'fal', caps: ['t2i'] },
{ id: 'ideogram-v3-fal', label: 'ideogram-v3', hint: 'Fal · Ideogram v3 · typography + design', provider: 'fal', caps: ['t2i'] },
{ id: 'recraft-v3-fal', label: 'recraft-v3', hint: 'Fal · Recraft v3 · vector + illustration', provider: 'fal', caps: ['t2i'] },
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
// Leonardo.ai models
@ -538,9 +546,15 @@ export const VIDEO_MODELS: MediaModel[] = [
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
// OpenAI Sora (via Fal hosting today).
{ id: 'sora-2', label: 'sora-2', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
// Fal.ai video models — pass any fal-ai/* path as model for custom models.
{ id: 'veo-3-fal', label: 'veo-3 (fal)', hint: 'Fal · Google Veo 3 · sound-on', provider: 'fal', caps: ['t2v', 'audio'] },
{ id: 'veo-2-fal', label: 'veo-2 (fal)', hint: 'Fal · Google Veo 2', provider: 'fal', caps: ['t2v'] },
{ id: 'wan-2.1-t2v', label: 'wan-2.1-t2v', hint: 'Fal · Wan 2.1 text-to-video', provider: 'fal', caps: ['t2v'] },
{ id: 'wan-2.1-i2v', label: 'wan-2.1-i2v', hint: 'Fal · Wan 2.1 image-to-video', provider: 'fal', caps: ['i2v'] },
{ id: 'seedance-1-pro-fal', label: 'seedance-1-pro (fal)', hint: 'Fal · Seedance 1 Pro', provider: 'fal', caps: ['t2v', 'i2v'] },
{ id: 'kling-2.1-t2v-fal', label: 'kling-2.1 (fal)', hint: 'Fal · Kling 2.1 Pro text-to-video', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2', label: 'sora-2', hint: 'Fal · OpenAI Sora 2', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'Fal · OpenAI Sora 2 Pro', provider: 'fal', caps: ['t2v'] },
// MiniMax video.
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },

View file

@ -82,6 +82,8 @@ export const DEFAULT_CONFIG: AppConfig = {
pet: DEFAULT_PET,
notifications: DEFAULT_NOTIFICATIONS,
orbit: DEFAULT_ORBIT,
projectLocations: [],
defaultProjectLocationId: 'default',
// Telemetry defaults to ON so fresh-install users emit onboarding /
// ui_click events from the first frame. The disclosure modal still
// appears after `onboardingCompleted` flips, and Settings → Privacy
@ -688,6 +690,12 @@ export function mergeDaemonConfig(
if (daemonConfig.customInstructions !== undefined) {
next.customInstructions = daemonConfig.customInstructions ?? undefined;
}
if (daemonConfig.projectLocations !== undefined) {
next.projectLocations = daemonConfig.projectLocations;
}
if (daemonConfig.defaultProjectLocationId !== undefined) {
next.defaultProjectLocationId = daemonConfig.defaultProjectLocationId ?? 'default';
}
return next;
}
@ -802,6 +810,8 @@ export async function syncConfigToDaemon(
telemetry: config.telemetry,
privacyDecisionAt: config.privacyDecisionAt,
customInstructions: config.customInstructions ?? null,
projectLocations: config.projectLocations ?? [],
defaultProjectLocationId: config.defaultProjectLocationId ?? 'default',
};
try {
const response = await fetch('/api/app-config', {

View file

@ -0,0 +1,55 @@
import type {
ProjectLocation,
ProjectLocationsResponse,
ScanProjectLocationsResponse,
UpdateProjectLocationsRequest,
} from '@open-design/contracts';
export async function fetchProjectLocations(): Promise<ProjectLocation[]> {
try {
const resp = await fetch('/api/project-locations');
if (!resp.ok) return [];
const json = (await resp.json()) as ProjectLocationsResponse;
return Array.isArray(json.locations) ? json.locations : [];
} catch {
return [];
}
}
export async function updateProjectLocations(
locations: UpdateProjectLocationsRequest['locations'],
): Promise<ProjectLocation[] | null> {
try {
const resp = await fetch('/api/project-locations', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations }),
});
if (!resp.ok) return null;
const json = (await resp.json()) as ProjectLocationsResponse;
return Array.isArray(json.locations) ? json.locations : [];
} catch {
return null;
}
}
export async function scanProjectLocations(): Promise<ScanProjectLocationsResponse | null> {
try {
const resp = await fetch('/api/project-locations/scan', { method: 'POST' });
if (!resp.ok) return null;
return (await resp.json()) as ScanProjectLocationsResponse;
} catch {
return null;
}
}
export async function openProjectLocationFolderDialog(): Promise<string | null> {
try {
const resp = await fetch('/api/dialog/open-folder', { method: 'POST' });
if (!resp.ok) return null;
const json = (await resp.json()) as { path?: string | null };
return typeof json.path === 'string' && json.path.trim() ? json.path : null;
} catch {
return null;
}
}

View file

@ -53,6 +53,7 @@ export async function getProject(id: string): Promise<Project | null> {
export async function createProject(input: {
name: string;
projectLocationId?: string;
skillId: string | null;
designSystemId: string | null;
pendingPrompt?: string;

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