Compare commits

...

21 commits

Author SHA1 Message Date
Denis Redozubov
0c3dff0231
Merge 67396350c8 into af4a62b69a 2026-05-31 05:06:40 +00:00
Denis Redozubov
67396350c8 fix(daemon): tolerate coarse artifact mtimes 2026-05-31 09:04:38 +04:00
Denis Redozubov
755d8173df feat(daemon): add contained project preview URLs 2026-05-31 09:04:38 +04:00
Denis Redozubov
0bffe6ba40 fix(daemon): preserve export refs across directory moves 2026-05-31 09:03:13 +04:00
Denis Redozubov
f2e04df500 fix(daemon): update artifact refs during rename 2026-05-31 09:03:12 +04:00
Denis Redozubov
328d893b4f fix(daemon): update renamed artifact manifest entries 2026-05-31 09:03:12 +04:00
Denis Redozubov
646fa370d2 fix(daemon): honor artifact manifest entry refs 2026-05-31 09:03:12 +04:00
Denis Redozubov
393bfd0c6e test(daemon): reject sandbox imported export manifests 2026-05-31 09:03:12 +04:00
Denis Redozubov
de6c0d498e test(e2e): retry tools-dev namespace startup collisions 2026-05-31 09:03:12 +04:00
Denis Redozubov
a5f09334a2 fix(daemon): bind headless runs to default conversation 2026-05-31 09:03:12 +04:00
Denis Redozubov
34ecd800ae fix(daemon): honor export manifest primary refs 2026-05-31 09:03:12 +04:00
Denis Redozubov
871a393917 feat(daemon): add project export manifest 2026-05-31 09:03:12 +04: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
122 changed files with 6693 additions and 2499 deletions

5
.gitignore vendored
View file

@ -76,4 +76,7 @@ docs/superpowers/
# on every deploy. Should not be committed (~70MB of PNGs). # on every deploy. Should not be committed (~70MB of PNGs).
apps/landing-page/public/previews/ apps/landing-page/public/previews/
growth/** # 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", "main": "./dist/cli.js",
"types": "./dist/cli.d.ts", "types": "./dist/cli.d.ts",
"bin": { "bin": {
"od": "./dist/cli.js" "od": "./bin/od.mjs"
}, },
"exports": { "exports": {
".": { ".": {
@ -20,6 +20,7 @@
} }
}, },
"files": [ "files": [
"bin",
"dist", "dist",
"package.json" "package.json"
], ],

View file

@ -13,8 +13,9 @@
// outside this machine. // outside this machine.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; 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 path from 'node:path';
import { expandHomePrefix } from './home-expansion.js';
import { import {
readInstallationFile, readInstallationFile,
@ -85,6 +86,12 @@ export interface OrbitConfigPrefs {
templateSkillId?: string | null; templateSkillId?: string | null;
} }
export interface ProjectLocationPrefs {
id: string;
name: string;
path: string;
}
export interface AppConfigPrefs { export interface AppConfigPrefs {
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
agentId?: string | null; agentId?: string | null;
@ -99,6 +106,8 @@ export interface AppConfigPrefs {
privacyDecisionAt?: number | null; privacyDecisionAt?: number | null;
orbit?: OrbitConfigPrefs; orbit?: OrbitConfigPrefs;
customInstructions?: string | null; customInstructions?: string | null;
projectLocations?: ProjectLocationPrefs[];
defaultProjectLocationId?: string | null;
} }
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([ const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
@ -115,6 +124,8 @@ const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
'privacyDecisionAt', 'privacyDecisionAt',
'orbit', 'orbit',
'customInstructions', 'customInstructions',
'projectLocations',
'defaultProjectLocationId',
] as const); ] as const);
function configFile(dataDir: string): string { function configFile(dataDir: string): string {
@ -245,6 +256,46 @@ function validateOrbit(raw: unknown): OrbitConfigPrefs | undefined {
return orbit; 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( export function agentCliEnvForAgent(
prefs: AgentCliEnvPrefs | undefined, prefs: AgentCliEnvPrefs | undefined,
agentId: string, agentId: string,
@ -330,6 +381,25 @@ function applyConfigValue(
} }
return; 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 { function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {

View file

@ -201,10 +201,15 @@ export function validateArtifactManifestInput(
} }
} }
const safeEntry = typeof entry === 'string' ? entry : ''; const manifestEntry =
if (!safeEntry || safeEntry.length > MAX_ENTRY_LENGTH) { typeof manifest.entry === 'string' && manifest.entry.trim()
return { ok: false, error: `artifact entry exceeds max length (${MAX_ENTRY_LENGTH})` }; ? manifest.entry.trim()
: entry;
const entryErr = validateSupportingPath(manifestEntry);
if (entryErr) {
return { ok: false, error: `artifactManifest.entry ${entryErr}` };
} }
const safeEntry = (manifestEntry as string).replace(/\\/g, '/');
return { ok: true, value: sanitizeManifest(manifest, safeEntry, options) }; return { ok: true, value: sanitizeManifest(manifest, safeEntry, options) };
} }

View file

@ -19,7 +19,6 @@ import { isSafeId as isSafeProjectId } from './projects.js';
import { projectKindToTracking } from '@open-design/contracts/analytics'; import { projectKindToTracking } from '@open-design/contracts/analytics';
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js'; import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
import { googleStreamGenerateContentUrl } from './google-models.js'; import { googleStreamGenerateContentUrl } from './google-models.js';
import { parseMediaExecutionPolicyInput } from './media-policy.js';
import { createRoleMarkerGuard } from './role-marker-guard.js'; import { createRoleMarkerGuard } from './role-marker-guard.js';
// Allowlist for the `/feedback` route. Mirrors the // 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) { export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const { db, design } = ctx; const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http; const { sendApiError, createSseResponse } = ctx.http;
const { startChatRun, submitToolResultToRun } = ctx.chat; const { submitToolResultToRun } = ctx.chat;
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents; const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
const { const {
handleCritiqueArtifact, handleCritiqueArtifact,
@ -54,7 +53,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
critiqueResponseCapBytes, critiqueResponseCapBytes,
critiqueRunRegistry, critiqueRunRegistry,
} = ctx.critique; } = ctx.critique;
const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false);
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => { const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
if ( if (
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) || (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 // so any handler we wired here was shadowed and never executed. Plugin
// snapshot resolution, clientType inference, and the daemon-side // snapshot resolution, clientType inference, and the daemon-side
// run_created/finished analytics all live in `server.ts` now. // 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) => { app.get('/api/runs', (req, res) => {
const { projectId, conversationId, status } = req.query; const { projectId, conversationId, status } = req.query;
@ -218,23 +218,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
res.status(202).json(outcome); 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) ------------------------ // ---- Connection tests (single-shot JSON; no SSE) ------------------------
// Settings dialog uses these to verify a config works without sending a // Settings dialog uses these to verify a config works without sending a
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused // 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'; import { redactSecrets } from './redact.js';
export interface ClaudeCliDiagnosticInput { export interface ClaudeCliDiagnosticInput {
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
stderrTail?: string | null; stderrTail?: string | null;
stdoutTail?: string | null; stdoutTail?: string | null;
env?: Record<string, unknown> | null; env?: Record<string, unknown> | null;
resolvedBin?: string | null;
} }
export interface ClaudeCliDiagnostic { 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( export function diagnoseClaudeCliFailure(
input: ClaudeCliDiagnosticInput, input: ClaudeCliDiagnosticInput,
): ClaudeCliDiagnostic | null { ): ClaudeCliDiagnostic | null {
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
const normalized = text.toLowerCase(); const normalized = text.toLowerCase();
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null; const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null; const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
const runtime = selectedClaudeCompatibleRuntime(input);
const isOpenClaude = runtime === 'openclaude';
const customEndpointConnectionFailure = const customEndpointConnectionFailure =
hasCustomBaseUrl && hasCustomBaseUrl &&
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
); );
} }
if (authFailure) { 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 const configHint = hasConfigDir
? 'The configured Claude config directory may contain stale or expired auth state.' ? '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.'; : '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 (!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 const message = hasConfigDir
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.' ? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
: 'Claude Code exited before producing diagnostics.'; : 'Claude Code exited before producing diagnostics.';

View file

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

View file

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

View file

@ -752,12 +752,23 @@ export function listConversations(db: SqliteDb, projectId: string) {
AND m.run_status IS NOT NULL AND m.run_status IS NOT NULL
) )
WHERE rn = 1 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, SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
lr.latestRunStatus, lr.latestRunStartedAt, lr.latestRunStatus, lr.latestRunStartedAt,
lr.latestRunEndedAt, lr.latestRunEventsJson lr.latestRunEndedAt, lr.latestRunEventsJson,
trd.totalDurationMs
FROM project_conversations c FROM project_conversations c
LEFT JOIN latest_runs lr ON lr.conversationId = c.id 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`, ORDER BY c.updatedAt DESC`,
) )
.all(projectId)).map(normalizeConversation); .all(projectId)).map(normalizeConversation);
@ -775,6 +786,7 @@ export function getConversation(db: SqliteDb, id: string) {
return { return {
...normalizeConversation(r), ...normalizeConversation(r),
latestRun: latestConversationRunSummary(db, r.id) ?? undefined, latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
...numberProperty('totalDurationMs', totalConversationRunDurationMs(db, r.id)),
}; };
} }
@ -791,10 +803,16 @@ function normalizeConversation(r: DbRow) {
title: r.title ?? null, title: r.title ?? null,
createdAt: Number(r.createdAt), createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt), updatedAt: Number(r.updatedAt),
...numberProperty('totalDurationMs', r.totalDurationMs),
latestRun: latestRun ?? undefined, 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) { function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
const row = db const row = db
.prepare( .prepare(
@ -813,6 +831,50 @@ function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
return conversationRunSummaryFromRow(row); 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) { function conversationRunSummaryFromRow(row: DbRow | undefined) {
if (!row || typeof row.runStatus !== 'string') return null; if (!row || typeof row.runStatus !== 'string') return null;
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt); const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);

View file

@ -15,6 +15,7 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { access, constants as fsConstants } from 'node:fs/promises'; import { access, constants as fsConstants } from 'node:fs/promises';
import path from 'node:path';
import type { Express } from 'express'; import type { Express } from 'express';
import type { import type {
HostEditor, HostEditor,
@ -159,6 +160,28 @@ function applicableForPlatform(entry: CatalogueEntry, platform: Platform): boole
return true; 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) { export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
const { db } = ctx; const { db } = ctx;
const { sendApiError } = ctx.http; const { sendApiError } = ctx.http;
@ -209,7 +232,11 @@ export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRout
if (!project) { if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); 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); const probe = await resolveEntry(entry);
if (!probe.available || !probe.launch) { if (!probe.available || !probe.launch) {
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`); return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);

View file

@ -1,4 +1,5 @@
import type { Express } from 'express'; import type { Express } from 'express';
import nodePath from 'node:path';
import type { RouteDeps } from './server-context.js'; import type { RouteDeps } from './server-context.js';
import { import {
InlineAssetsLimitError, InlineAssetsLimitError,
@ -358,7 +359,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
const { sendApiError } = ctx.http; const { sendApiError } = ctx.http;
const { PROJECTS_DIR } = ctx.paths; const { PROJECTS_DIR } = ctx.paths;
const { getProject } = ctx.projectStore; const { getProject } = ctx.projectStore;
const { readProjectFile, resolveProjectFilePath } = ctx.projectFiles; const { listFiles, readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
const { isSafeId } = ctx.validation; const { isSafeId } = ctx.validation;
const { const {
buildProjectArchive, buildProjectArchive,
@ -447,6 +448,30 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
} }
}); });
app.get('/api/projects/:id/export/manifest', async (req, res) => {
try {
if (!isSafeId(req.params.id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
const project = getProject(db, req.params.id);
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const files = await listFiles(PROJECTS_DIR, req.params.id, {
metadata: project.metadata,
});
/** @type {import('@open-design/contracts').ProjectExportManifestResponse} */
const body = buildProjectExportManifestResponse({
project,
projectId: req.params.id,
files,
});
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
}
});
app.post('/api/projects/:id/export/pdf', async (req, res) => { app.post('/api/projects/:id/export/pdf', async (req, res) => {
if (typeof desktopPdfExporter !== 'function') { if (typeof desktopPdfExporter !== 'function') {
return sendApiError( return sendApiError(
@ -656,6 +681,177 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
} }
function buildProjectExportManifestResponse({
project,
projectId,
files,
}: {
project: any;
projectId: string;
files: any[];
}) {
const sortedFiles = [...files].sort((a, b) => String(a.name).localeCompare(String(b.name)));
const filesByName = new Map(sortedFiles.map((file) => [file.name, file]));
const reasons = new Map<string, Set<string>>();
const supportingNames = new Set<string>();
const artifactNames = new Set<string>();
const artifacts = [];
const note = (name: unknown, reason: string) => {
if (typeof name !== 'string' || !filesByName.has(name)) return;
if (!reasons.has(name)) reasons.set(name, new Set());
reasons.get(name)?.add(reason);
};
for (const file of sortedFiles) {
const manifest = file.artifactManifest && typeof file.artifactManifest === 'object'
? file.artifactManifest
: null;
if (!manifest) continue;
if (isInferredArtifactManifest(manifest)) continue;
artifactNames.add(file.name);
note(file.name, 'artifact-manifest');
const artifactSupporting = new Set<string>();
const addManifestRef = (
ref: unknown,
reason: string,
options: { allowProjectRootFallback?: boolean; preferProjectRoot?: boolean } = {},
) => {
const ownerRelative = normalizeManifestProjectRef(ref, file.name);
const projectRoot = normalizeManifestProjectRootRef(ref);
const candidates = options.preferProjectRoot
? [projectRoot, ownerRelative]
: [
ownerRelative,
...(options.allowProjectRootFallback ? [projectRoot] : []),
];
const normalized = candidates.find((candidate) => candidate && filesByName.has(candidate));
if (!normalized) return;
if (normalized === file.name) return;
supportingNames.add(normalized);
artifactSupporting.add(normalized);
note(normalized, reason);
};
addManifestRef(manifest.entry, 'artifact-entry', { preferProjectRoot: true });
if (typeof manifest.primary === 'string') {
addManifestRef(manifest.primary, 'artifact-primary', { preferProjectRoot: true });
}
if (Array.isArray(manifest.supportingFiles)) {
for (const ref of manifest.supportingFiles) {
addManifestRef(ref, 'artifact-supporting-file', { allowProjectRootFallback: true });
}
}
artifacts.push({
file: file.name,
title: typeof manifest.title === 'string' && manifest.title.trim()
? manifest.title
: file.name,
kind: typeof manifest.kind === 'string' ? manifest.kind : (file.artifactKind ?? null),
renderer: typeof manifest.renderer === 'string' ? manifest.renderer : null,
status: typeof manifest.status === 'string' ? manifest.status : null,
exports: Array.isArray(manifest.exports)
? manifest.exports.filter((value: unknown): value is string => typeof value === 'string')
: [],
supportingFiles: Array.from(artifactSupporting).sort((a, b) => a.localeCompare(b)),
updatedAt: typeof manifest.updatedAt === 'string' ? manifest.updatedAt : null,
});
}
const entryFile = chooseExportManifestEntryFile(project, sortedFiles, filesByName);
note(entryFile, 'project-entry-file');
return {
schema: 'open-design.project-export-manifest.v1',
projectId,
projectName: typeof project?.name === 'string' ? project.name : null,
generatedAt: new Date().toISOString(),
entryFile,
files: sortedFiles.map((file) => ({
...file,
included: true,
role: roleForExportManifestFile(file, {
entryFile,
artifactNames,
supportingNames,
}),
reasons: Array.from(reasons.get(file.name) ?? ['visible-project-file']).sort((a, b) => a.localeCompare(b)),
})),
artifacts,
};
}
function isInferredArtifactManifest(manifest: any): boolean {
return manifest?.metadata &&
typeof manifest.metadata === 'object' &&
manifest.metadata.inferred === true;
}
function chooseExportManifestEntryFile(
project: any,
files: any[],
filesByName: Map<string, any>,
): string | null {
const metadataEntry = typeof project?.metadata?.entryFile === 'string'
? project.metadata.entryFile
: null;
if (metadataEntry && filesByName.has(metadataEntry)) return metadataEntry;
for (const file of files) {
const manifest = file.artifactManifest;
if (!manifest || typeof manifest !== 'object') continue;
if (isInferredArtifactManifest(manifest)) continue;
if (manifest.primary === true) return file.name;
if (typeof manifest.primary === 'string') {
const rootPrimary = normalizeManifestProjectRootRef(manifest.primary);
if (rootPrimary && filesByName.has(rootPrimary)) return rootPrimary;
const ownerRelativePrimary = normalizeManifestProjectRef(manifest.primary, file.name);
if (ownerRelativePrimary && filesByName.has(ownerRelativePrimary)) return ownerRelativePrimary;
}
const rootEntry = normalizeManifestProjectRootRef(manifest.entry);
if (rootEntry && filesByName.has(rootEntry)) return rootEntry;
const ownerRelativeEntry = normalizeManifestProjectRef(manifest.entry, file.name);
if (ownerRelativeEntry && filesByName.has(ownerRelativeEntry)) return ownerRelativeEntry;
}
return files.find((file) => /(^|\/)index\.html?$/i.test(file.name))?.name
?? files.find((file) => file.kind === 'html')?.name
?? files[0]?.name
?? null;
}
function normalizeManifestProjectRootRef(ref: unknown): string | null {
return normalizeManifestProjectRef(ref, '');
}
function normalizeManifestProjectRef(ref: unknown, ownerFile: string): string | null {
if (typeof ref !== 'string' || !ref.trim()) return null;
const value = ref.trim();
if (value.includes('\0') || value.startsWith('/')) return null;
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
const ownerDir = nodePath.posix.dirname(ownerFile);
const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`;
const normalized = nodePath.posix.normalize(joined).replace(/^\.\//, '');
if (!normalized || normalized === '.' || normalized.startsWith('../')) return null;
if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null;
return normalized;
}
function roleForExportManifestFile(
file: any,
refs: {
entryFile: string | null;
artifactNames: Set<string>;
supportingNames: Set<string>;
},
) {
if (file.name === refs.entryFile) return 'entry';
if (refs.artifactNames.has(file.name)) return 'artifact';
if (refs.supportingNames.has(file.name)) return 'supporting';
if (file.kind === 'image' || file.kind === 'video' || file.kind === 'audio') return 'asset';
if (file.kind === 'code' || file.kind === 'text') return 'source';
return 'other';
}
export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {} export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {}
export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) { export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) {

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: '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: '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: '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: '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: '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 }, { 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: '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: '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-phoenix', label: 'Phoenix', hint: 'Leonardo · versatile', provider: 'leonardo', caps: ['t2i'] },
{ id: 'leonardo-kino-xl', label: 'Kino XL', hint: 'Leonardo · cinematic', 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-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: '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: 'veo-3-fal', label: 'veo-3 (fal)', hint: 'Fal · Google Veo 3 · sound-on', provider: 'fal', caps: ['t2v', 'audio'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] }, { 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: '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'] }, { 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.`, `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) { if (!def) {
throw new Error( if (/^fal-ai\//.test(model)) {
`unknown model: ${model}. Pass --model from the registered list (see /api/media/models).`, 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) // Reject cross-surface combinations for catalogued models.
// 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.
const resolvedAudioKind = const resolvedAudioKind =
surface === 'audio' ? audioKind || 'music' : undefined; surface === 'audio' ? audioKind || 'music' : undefined;
const allowed = modelsForSurface(surface, resolvedAudioKind); if (!isFalCustomPath) {
if (!allowed.some((m) => m.id === model)) { const allowed = modelsForSurface(surface, resolvedAudioKind);
const ids = allowed.map((m) => m.id).join(', '); if (!allowed.some((m) => m.id === model)) {
const where = const ids = allowed.map((m) => m.id).join(', ');
surface === 'audio' ? `audio · ${resolvedAudioKind}` : surface; const where =
throw new Error( surface === 'audio' ? `audio · ${resolvedAudioKind}` : surface;
`model "${model}" is not registered for surface "${where}". Allowed: ${ids}.`, throw new Error(
); `model "${model}" is not registered for surface "${where}". Allowed: ${ids}.`,
);
}
} }
// Clamp registry-bound numeric inputs to their allowed buckets so a // Clamp registry-bound numeric inputs to their allowed buckets so a
@ -575,6 +590,16 @@ export async function generateMedia(args: {
bytes = result.bytes; bytes = result.bytes;
providerNote = result.providerNote; providerNote = result.providerNote;
suggestedExt = result.suggestedExt; 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 { } else {
// No real renderer wired up for this (provider, surface). Gate the // No real renderer wired up for this (provider, surface). Gate the
// stub fallback behind OD_MEDIA_ALLOW_STUBS so release builds don't // 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). // 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( const env = applyAgentLaunchEnv(
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv), spawnEnvForAgent(
def.id,
{ ...process.env, ...(def.env || {}) },
configuredAgentEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch, launch,
); );
const invocation = createCommandInvocation({ 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,5 +1,7 @@
import type { Express } from 'express'; import { randomUUID } from 'node:crypto';
import { rm } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import type { Express, Response } from 'express';
import { import {
defaultScenarioPluginIdForProjectMetadata, defaultScenarioPluginIdForProjectMetadata,
type PluginManifest, type PluginManifest,
@ -18,9 +20,18 @@ import {
import { connectorService } from './connectors/service.js'; import { connectorService } from './connectors/service.js';
import type { RouteDeps } from './server-context.js'; import type { RouteDeps } from './server-context.js';
import { listSkills } from './skills.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'; 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( function projectDetailResolvedDir(
projectsRoot: string, projectsRoot: string,
@ -145,6 +156,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
const { db, design } = ctx; const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http; const { sendApiError, createSseResponse } = ctx.http;
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths; 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 { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles; const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations; const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
@ -202,8 +214,199 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
return Array.from(byTaskKind.values()); 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 { 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 latestRunStatuses = listLatestProjectRunStatuses(db);
const awaitingInputProjects = listProjectsAwaitingInput(db); const awaitingInputProjects = listProjectsAwaitingInput(db);
const activeRunStatuses = new Map(); const activeRunStatuses = new Map();
@ -224,15 +427,17 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
} }
/** @type {import('@open-design/contracts').ProjectsResponse} */ /** @type {import('@open-design/contracts').ProjectsResponse} */
const body = { const body = {
projects: listProjects(db).map((project: any) => ({ projects: listProjects(db)
...project, .filter((project: any) => projectVisibleForLocations(project, locations))
status: composeProjectDisplayStatus( .map((project: any) => ({
activeRunStatuses.get(project.id) ?? ...project,
latestRunStatuses.get(project.id) ?? { value: 'not_started' }, status: composeProjectDisplayStatus(
awaitingInputProjects, activeRunStatuses.get(project.id) ??
project.id, latestRunStatuses.get(project.id) ?? { value: 'not_started' },
), awaitingInputProjects,
})), project.id,
),
})),
}; };
res.json(body); res.json(body);
} catch (err: any) { } catch (err: any) {
@ -250,9 +455,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
app.post('/api/projects', async (req, res) => { app.post('/api/projects', async (req, res) => {
try { try {
const { id, name, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } = const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
req.body || {}; 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'); return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
} }
if (typeof name !== 'string' || !name.trim()) { if (typeof name !== 'string' || !name.trim()) {
@ -306,11 +511,30 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
return sendApiError(res, 400, skillValidation.code, skillValidation.message); return sendApiError(res, 400, skillValidation.code, skillValidation.message);
} }
const normalizedSkillId = skillValidation.id; 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 = const projectMetadata =
metadata && typeof metadata === 'object' metadata && typeof metadata === 'object'
? { ? {
...metadata, ...metadata,
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}), ...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
...(externalProjectDir
? {
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: {}),
...(Array.isArray(metadata.linkedDirs) ...(Array.isArray(metadata.linkedDirs)
? (() => { ? (() => {
const v = validateLinkedDirs(metadata.linkedDirs); const v = validateLinkedDirs(metadata.linkedDirs);
@ -319,23 +543,58 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
: {}), : {}),
} }
: skipDiscoveryBrief === true : 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 now = Date.now();
const project = insertProject(db, { let project;
id, try {
name: name.trim(), if (externalProjectDir) {
skillId: normalizedSkillId, await writeProjectManifest(externalProjectDir, {
designSystemId: normalizedDesignSystemId, schemaVersion: 1,
pendingPrompt: pendingPrompt || null, id,
metadata: projectMetadata, name: name.trim(),
customInstructions: createdAt: now,
typeof customInstructions === 'string' updatedAt: now,
? customInstructions skillId: normalizedSkillId,
: null, designSystemId: normalizedDesignSystemId,
createdAt: now, });
updatedAt: now, }
}); 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. // Seed a default conversation so the UI always has somewhere to write.
const cid = randomId(); const cid = randomId();
insertConversation(db, { insertConversation(db, {
@ -345,7 +604,6 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
const explicitPlugin = const explicitPlugin =
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0 typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
? true ? true
@ -398,7 +656,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
) { ) {
const tpl = getTemplate(db, metadata.templateId); const tpl = getTemplate(db, metadata.templateId);
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) { 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) { for (const f of tpl.files) {
if ( if (
!f || !f ||
@ -413,6 +671,8 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
id, id,
f.name, f.name,
Buffer.from(f.content, 'utf8'), Buffer.from(f.content, 'utf8'),
{},
projectMetadata,
); );
} catch { } catch {
// Skip individual file failures — the template snapshot is // Skip individual file failures — the template snapshot is
@ -435,9 +695,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); 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'); return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir); const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
/** @type {import('@open-design/contracts').ProjectResponse} */ /** @type {import('@open-design/contracts').ProjectResponse} */
@ -484,6 +745,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
...(existingMeta.importedFrom === 'folder' ...(existingMeta.importedFrom === 'folder'
? { importedFrom: 'folder' } ? { importedFrom: 'folder' }
: {}), : {}),
...(existingMeta.importedFrom === 'project-location'
? { importedFrom: 'project-location' }
: {}),
...(typeof existingMeta.projectLocationId === 'string'
? { projectLocationId: existingMeta.projectLocationId }
: {}),
...(existingMeta.fromTrustedPicker === true ...(existingMeta.fromTrustedPicker === true
? { fromTrustedPicker: true as const } ? { fromTrustedPicker: true as const }
: {}), : {}),
@ -942,6 +1209,104 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles; const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles;
const { buildDocumentPreview } = ctx.documents; const { buildDocumentPreview } = ctx.documents;
const { validateArtifactManifestInput } = ctx.artifacts; const { validateArtifactManifestInput } = ctx.artifacts;
const projectPreviewIframeSandbox = 'allow-scripts allow-forms';
const projectPreviewCsp = [
`sandbox ${projectPreviewIframeSandbox}`,
"default-src 'self' data: blob:",
"img-src 'self' data: blob:",
"media-src 'self' data: blob:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"connect-src 'none'",
"form-action 'none'",
"base-uri 'none'",
"object-src 'none'",
].join('; ');
const previewScopeRe = /^[A-Za-z0-9_-]{8,128}$/u;
function setProjectPreviewHeaders(res: Response) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', projectPreviewCsp);
}
async function sendProjectFile(
req: any,
res: Response,
projectId: string,
relPath: string,
metadata?: unknown,
beforeSend?: (mime: string) => void,
transformFile?: (file: { mime: string; buffer: Buffer }) => Buffer | string,
) {
const meta = await resolveProjectFilePath(
PROJECTS_DIR,
projectId,
relPath,
metadata,
);
beforeSend?.(meta.mime);
if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) {
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Type', meta.mime);
if (meta.size === 0) {
res.setHeader('Content-Length', '0');
return res.status(200).end();
}
const range = parseByteRange(req.headers.range, meta.size);
if (range === 'unsatisfiable') {
res.setHeader('Content-Range', `bytes */${meta.size}`);
return res.status(416).end();
}
let start;
let end;
let statusCode;
if (range) {
({ start, end } = range);
statusCode = 206;
res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`);
res.setHeader('Content-Length', String(end - start + 1));
} else {
start = 0;
end = meta.size - 1;
statusCode = 200;
res.setHeader('Content-Length', String(meta.size));
}
res.status(statusCode);
const stream = fs.createReadStream(meta.filePath, { start, end });
stream.on('error', (streamErr: any) => {
if (!res.headersSent) {
sendApiError(res, 500, 'STREAM_ERROR', String(streamErr));
} else {
res.destroy(streamErr);
}
});
stream.pipe(res);
return;
}
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, metadata);
res.type(file.mime).send(transformFile ? transformFile(file) : file.buffer);
}
function previewFilePathForProject(project: any, queryFile: unknown): string {
if (typeof queryFile === 'string' && queryFile.trim().length > 0) {
return queryFile;
}
const entryFile = project?.metadata?.entryFile;
return typeof entryFile === 'string' && entryFile.length > 0 ? entryFile : 'index.html';
}
function encodeProjectPathForUrl(filePath: string): string {
return filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/');
}
// Project files. Each project owns a flat folder under .od/projects/<id>/ // Project files. Each project owns a flat folder under .od/projects/<id>/
// containing every file the user has uploaded, pasted, sketched, or that // containing every file the user has uploaded, pasted, sketched, or that
@ -1000,6 +1365,79 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
} }
}); });
app.get('/api/projects/:id/preview-url', async (req, res) => {
try {
const project = getProject(db, req.params.id);
if (!project) {
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
return;
}
const requestedPath = previewFilePathForProject(project, req.query.file);
const meta = await resolveProjectFilePath(
PROJECTS_DIR,
project.id,
requestedPath,
project.metadata,
);
const scope = randomUUID();
/** @type {import('@open-design/contracts').ProjectPreviewUrlResponse} */
const body = {
url: `/api/projects/${encodeURIComponent(project.id)}/preview/${scope}/${encodeProjectPathForUrl(meta.name)}`,
file: meta.name,
csp: projectPreviewCsp,
iframeSandbox: projectPreviewIframeSandbox,
opaqueOrigin: true,
};
res.setHeader('Cache-Control', 'no-store');
res.json(body);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
});
app.get(/^\/api\/projects\/([^/]+)\/preview\/([^/]+)\/(.+)$/u, async (req, res) => {
try {
const params = req.params as unknown as { 0?: string; 1?: string; 2?: string };
const projectId = String(params[0] ?? '');
const scope = String(params[1] ?? '');
const relPath = String(params[2] ?? '');
if (!previewScopeRe.test(scope)) {
sendApiError(res, 400, 'BAD_REQUEST', 'invalid preview scope');
return;
}
const project = getProject(db, projectId);
if (!project) {
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
return;
}
if (req.headers.origin === 'null') {
res.header('Access-Control-Allow-Origin', '*');
}
await sendProjectFile(
req,
res,
project.id,
relPath,
project.metadata,
() => setProjectPreviewHeaders(res),
);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
});
// Preflight for the raw file route. Current artifact fetches are simple GETs // Preflight for the raw file route. Current artifact fetches are simple GETs
// (no preflight needed), but an explicit handler future-proofs the route if // (no preflight needed), but an explicit handler future-proofs the route if
@ -1027,66 +1465,23 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Origin', '*');
} }
const meta = await resolveProjectFilePath( await sendProjectFile(
PROJECTS_DIR, req,
res,
projectId, projectId,
relPath, relPath,
project?.metadata, project?.metadata,
); undefined,
(file) => {
if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) { if (
res.setHeader('Accept-Ranges', 'bytes'); wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
res.setHeader('Content-Type', meta.mime); /^text\/html(?:;|$)/i.test(file.mime)
) {
if (meta.size === 0) { return injectUrlPreviewScrollBridge(file.buffer.toString('utf8'));
res.setHeader('Content-Length', '0');
return res.status(200).end();
}
const range = parseByteRange(req.headers.range, meta.size);
if (range === 'unsatisfiable') {
res.setHeader('Content-Range', `bytes */${meta.size}`);
return res.status(416).end();
}
let start;
let end;
let statusCode;
if (range) {
({ start, end } = range);
statusCode = 206;
res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`);
res.setHeader('Content-Length', String(end - start + 1));
} else {
start = 0;
end = meta.size - 1;
statusCode = 200;
res.setHeader('Content-Length', String(meta.size));
}
res.status(statusCode);
const stream = fs.createReadStream(meta.filePath, { start, end });
stream.on('error', (streamErr: any) => {
if (!res.headersSent) {
sendApiError(res, 500, 'STREAM_ERROR', String(streamErr));
} else {
res.destroy(streamErr);
} }
}); return file.buffer;
stream.pipe(res); },
return; );
}
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
if (
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
/^text\/html(?:;|$)/i.test(file.mime)
) {
res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8')));
return;
}
res.type(file.mime).send(file.buffer);
} catch (err: any) { } catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400; const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError( sendApiError(

View file

@ -32,10 +32,16 @@ const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']); const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md'; const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md';
const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json'; const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json';
export const RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS = 1000;
export const projectFileRenameTestHooks = { export const projectFileRenameTestHooks = {
beforeCommit: null as null | ((paths: { source: string; target: string }) => Promise<void> | void), beforeCommit: null as null | ((paths: { source: string; target: string }) => Promise<void> | void),
}; };
export function isRunTouchedProjectFile(fileMtimeMs, runStartTimeMs) {
if (!Number.isFinite(fileMtimeMs) || !Number.isFinite(runStartTimeMs)) return false;
return fileMtimeMs + RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS >= runStartTimeMs;
}
export function projectDir(projectsRoot, projectId) { export function projectDir(projectsRoot, projectId) {
if (!isSafeId(projectId)) throw new Error('invalid project id'); if (!isSafeId(projectId)) throw new Error('invalid project id');
return path.join(projectsRoot, projectId); return path.join(projectsRoot, projectId);
@ -626,7 +632,8 @@ export async function resolveProjectFilePath(projectsRoot, projectId, name, meta
const dir = resolveProjectDir(projectsRoot, projectId, metadata); const dir = resolveProjectDir(projectsRoot, projectId, metadata);
const file = await resolveSafeReal(dir, name); const file = await resolveSafeReal(dir, name);
const st = await stat(file); const st = await stat(file);
const rel = toProjectPath(path.relative(dir, file)); const rootReal = await realpath(dir).catch(() => dir);
const rel = toProjectPath(path.relative(rootReal, file));
return { return {
filePath: file, filePath: file,
name: rel, name: rel,
@ -880,6 +887,7 @@ export async function renameProjectFile(projectsRoot, projectId, fromName, toNam
await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath }); await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath });
await renameFilePath(source, targetPath, { noOverwrite: true }); await renameFilePath(source, targetPath, { noOverwrite: true });
await commitArtifactManifestRename(manifestRename, newName); await commitArtifactManifestRename(manifestRename, newName);
await updateArtifactManifestRefsForRename(dir, oldName, newName);
const st = await stat(targetPath); const st = await stat(targetPath);
const manifest = await readManifestForPath(dir, newName); const manifest = await readManifestForPath(dir, newName);
@ -974,16 +982,22 @@ async function prepareArtifactManifestRename(dir, oldName, newName) {
} }
} }
return { oldManifestPath, newManifestPath: targetManifestPath, raw }; return { oldManifestPath, newManifestPath: targetManifestPath, raw, oldName };
} }
async function commitArtifactManifestRename(manifestRename, newName) { async function commitArtifactManifestRename(manifestRename, newName) {
if (!manifestRename) return; if (!manifestRename) return;
const { oldManifestPath, newManifestPath, raw } = manifestRename; const { oldManifestPath, newManifestPath, raw, oldName } = manifestRename;
await mkdir(path.dirname(newManifestPath), { recursive: true }); await mkdir(path.dirname(newManifestPath), { recursive: true });
const parsed = parseManifest(raw); const parsed = parseManifest(raw);
if (parsed) { if (parsed) {
const validated = validateArtifactManifestInput(parsed, newName); const parsedEntry = typeof parsed.entry === 'string'
? parsed.entry.replace(/\\/g, '/')
: '';
const renamedManifest = parsedEntry === oldName
? { ...parsed, entry: newName }
: parsed;
const validated = validateArtifactManifestInput(renamedManifest, newName);
if (validated.ok && validated.value) { if (validated.ok && validated.value) {
await writeFile(oldManifestPath, JSON.stringify(validated.value, null, 2)); await writeFile(oldManifestPath, JSON.stringify(validated.value, null, 2));
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true }); await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
@ -993,6 +1007,153 @@ async function commitArtifactManifestRename(manifestRename, newName) {
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true }); await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
} }
async function updateArtifactManifestRefsForRename(dir, oldName, newName) {
const manifests = [];
await collectArtifactManifestFiles(dir, '', manifests);
for (const manifestFile of manifests) {
const ownerName = ownerNameForArtifactManifest(manifestFile.relPath);
if (!ownerName) continue;
let raw;
try {
raw = await readFile(manifestFile.fullPath, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') continue;
throw err;
}
const parsed = parseManifest(raw);
if (!parsed) continue;
const updated = rewriteArtifactManifestRenameRefs(parsed, {
ownerName,
oldName,
newName,
});
if (!updated.changed) continue;
const validated = validateArtifactManifestInput(updated.manifest, ownerName);
if (!validated.ok || !validated.value) continue;
await writeFile(manifestFile.fullPath, JSON.stringify(validated.value, null, 2));
}
}
async function collectArtifactManifestFiles(dir, relDir, out) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err && err.code === 'ENOENT') return;
throw err;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await collectArtifactManifestFiles(fullPath, relPath, out);
continue;
}
if (entry.isFile() && entry.name.endsWith('.artifact.json')) {
out.push({ relPath, fullPath });
}
}
}
function ownerNameForArtifactManifest(manifestName) {
const suffix = '.artifact.json';
if (!manifestName.endsWith(suffix)) return null;
return manifestName.slice(0, -suffix.length);
}
function rewriteArtifactManifestRenameRefs(manifest, { ownerName, oldName, newName }) {
let changed = false;
const next = { ...manifest };
const entry = rewriteManifestRefForRename(next.entry, ownerName, oldName, newName, {
preferProjectRoot: true,
});
if (entry.changed) {
next.entry = entry.value;
changed = true;
}
if (typeof next.primary === 'string') {
const primary = rewriteManifestRefForRename(next.primary, ownerName, oldName, newName, {
preferProjectRoot: true,
});
if (primary.changed) {
next.primary = primary.value;
changed = true;
}
}
if (Array.isArray(next.supportingFiles)) {
const supportingFiles = next.supportingFiles.map((ref) => {
const updated = rewriteManifestRefForRename(ref, ownerName, oldName, newName);
if (updated.changed) changed = true;
return updated.value;
});
if (changed) next.supportingFiles = supportingFiles;
}
return { changed, manifest: next };
}
function rewriteManifestRefForRename(
ref,
ownerName,
oldName,
newName,
options = {},
) {
if (typeof ref !== 'string') return { changed: false, value: ref };
const normalized = ref.replace(/\\/g, '/').trim();
if (!normalized) return { changed: false, value: ref };
if (options.preferProjectRoot && normalizeManifestProjectRootRef(normalized) === oldName) {
return { changed: true, value: newName };
}
if (normalizeManifestProjectRef(normalized, ownerName) === oldName) {
return {
changed: true,
value: relativeManifestRefForOwner(ownerName, newName),
};
}
if (normalized === oldName) {
return { changed: true, value: newName };
}
return { changed: false, value: ref };
}
function relativeManifestRefForOwner(ownerName, targetName) {
const ownerDir = path.posix.dirname(ownerName);
if (ownerDir === '.') return targetName;
const relative = path.posix.relative(ownerDir, targetName);
if (!relative || relative === '.' || relative.startsWith('../') || relative.includes('/../')) {
return targetName;
}
return relative;
}
function normalizeManifestProjectRootRef(ref) {
return normalizeManifestProjectRef(ref, '');
}
function normalizeManifestProjectRef(ref, ownerName) {
if (typeof ref !== 'string' || !ref.trim()) return null;
const value = ref.trim().replace(/\\/g, '/');
if (value.includes('\0') || value.startsWith('/')) return null;
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
const ownerDir = path.posix.dirname(ownerName);
const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`;
const normalized = path.posix.normalize(joined).replace(/^\.\//, '');
if (!normalized || normalized === '.' || normalized.startsWith('../')) return null;
if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null;
return normalized;
}
export async function removeProjectDir(projectsRoot, projectId) { export async function removeProjectDir(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId); const dir = projectDir(projectsRoot, projectId);
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });

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 report the error; do not fan out into alternate execution paths inside
the same turn. 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 Any model whose generation takes longer than ~25s including **fal flux-pro-ultra,
the task daemon-side and either returns the finished \`{"file":{...}}\` fal Veo, fal Sora, Volcengine i2v, hyperframes-html, and anything else with a
or returns a successful queued/running handoff with \`{taskId}\`. You then multi-minute pipeline** will not complete within the initial \`media generate\` call.
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 \`media generate\` dispatches the task daemon-side and polls for up to ~25s. It
shell tool's default 30s timeout. \`media generate\` treats the handoff as always exits 0 either with \`{"file":{...}}\` if the render finished within that
exit \`0\` so the first dispatch does not look like a failed shell call. window, or with \`{"taskId":"..."}\` as a handoff signal. You then drive the render
The wait subcommand exits with a distinct code per outcome: 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 0\` — terminal **done**. Final stdout line is \`{"file":{...}}\`.
- \`exit 5\` — terminal **failed**. Stderr carries the upstream error. - \`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 off (\`--since\` skips already-seen progress lines so you don't see the
same chatter twice). 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 \`\`\`bash
out=$("$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model --image ) out=\$("$OD_NODE_BIN" "$OD_BIN" media generate --surface image --model flux-pro-ultra --prompt "…")
ec=$? ec=\$?
if [ "$ec" -ne 0 ]; then if [ "\$ec" -ne 0 ]; then
echo "$out" >&2; exit "$ec" echo "\$out" >&2; exit "\$ec"
fi fi
task_id=$(printf '%s\\n' "$out" | tail -1 | jq -r '.taskId // empty') last=\$(printf '%s\\n' "\$out" | tail -1)
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // 0') task_id=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('taskId',''))" 2>/dev/null)
while [ -n "$task_id" ]; do since=\$(printf '%s\\n' "\$last" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('nextSince',0))" 2>/dev/null)
out=$("$OD_NODE_BIN" "$OD_BIN" media wait "$task_id" --since "$since") since="\${since:-0}"
ec=$? while [ -n "\$task_id" ]; do
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // '"$since") out=\$("$OD_NODE_BIN" "$OD_BIN" media wait "\$task_id" --since "\$since")
if [ "$ec" -eq 0 ]; then 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="" task_id=""
elif [ "$ec" -ne 2 ]; then elif [ "\$ec" -ne 2 ]; then
echo "$out" >&2; exit "$ec" echo "\$out" >&2; exit "\$ec"
fi fi
done 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 Each \`generate\` call lasts at most ~25s and each \`wait\` call at most ~120s,
shell tool's default ~30s cap never fires. Progress lines stream to both well within your shell tool's timeout. Progress lines stream to stderr as
stderr as they arrive, so the user sees live status in chat throughout they arrive, so the user sees live status in chat throughout the loop instead of
the loop instead of waiting silently for a single multi-minute call. 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 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 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 · speech**: ${AUDIO_SPEECH_IDS}
- **audio · sfx**: ${AUDIO_SFX_IDS} - **audio · sfx**: ${AUDIO_SFX_IDS}
If the user requests a model that is not in this list, surface a warning If the user requests a model that is not in this list **and** the ID does
in your reply and either (a) ask them to pick a registered ID or (b) not start with \`fal-ai/\`, surface a warning in your reply and either
proceed with the project metadata's default model and explain the (a) ask them to pick a registered ID or (b) proceed with the project
substitution. Do not silently fall back. 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 ### Workflow rules
@ -344,22 +365,47 @@ substitution. Do not silently fall back.
SFX duration is capped at 30 seconds by the provider. SFX duration is capped at 30 seconds by the provider.
\`language\` enables pronunciation boost for specific languages \`language\` enables pronunciation boost for specific languages
(e.g. \`Chinese,Yue\` for Cantonese, \`Chinese\` for Mandarin). (e.g. \`Chinese,Yue\` for Cantonese, \`Chinese\` for Mandarin).
2. **One discovery turn before generating.** Even with metadata defaults 2. **Dispatch immediately when the brief is complete.** For image and video
present, restate what you're about to make and ask one targeted projects, if the user's prompt specifies the subject, style/mood, and setting,
question if anything is ambiguous (subject, mood, brand, voice). The **dispatch without a discovery question turn**. Do not ask about model or aspect
discovery rules from the philosophy layer still apply emit a ratio when reasonable defaults exist use them and start generating.
question form on turn 1 unless the user's prompt already pins every
variable. 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 For \`hyperframes-html\`, the discovery turn is the last turn before
you start authoring. Once the user answers, write the composition you start authoring. Once the user answers, write the composition
files into \`.hyperframes-cache/\` and run \`npx hyperframes render\` files into \`.hyperframes-cache/\` and run \`npx hyperframes render\`
immediately do not add a second "plan" or "environment check" 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 message first, and do not call \`"$OD_NODE_BIN" "$OD_BIN" media generate\` (that path is
intentionally rejected for this model). intentionally rejected for this model).
3. **Generate by shell, narrate in chat.** When you actually invoke 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 \`"$OD_NODE_BIN" "$OD_BIN" media generate\`, do it inside a clearly-labelled tool call.
it returns, write a short reply: what was produced, the filename, After the command completes, reply with **one brief message** (23 sentences max):
and any notes (model substitutions, retries, follow-up suggestions). 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. If it fails, quote the real stderr / exit code and stop there.
Never say "I dispatched the render" / "the generation has started" Never say "I dispatched the render" / "the generation has started"
unless the shell command has already been executed. 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.`; 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 = ` const ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE = `
--- ---
@ -439,6 +495,21 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n'); 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) { if (metadata?.skipDiscoveryBrief === true) {
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE); parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
parts.push('\n\n---\n\n'); parts.push('\n\n---\n\n');
@ -450,9 +521,12 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n'); parts.push('\n\n---\n\n');
} }
if (!isMediaSurfaceEarly) {
parts.push(DISCOVERY_AND_PHILOSOPHY, '\n\n---\n\n');
}
parts.push( parts.push(
DISCOVERY_AND_PHILOSOPHY, '# Identity and workflow charter (background)\n\n',
'\n\n---\n\n# Identity and workflow charter (background)\n\n',
BASE_SYSTEM_PROMPT, BASE_SYSTEM_PROMPT,
); );
@ -614,6 +688,11 @@ export function composeSystemPrompt({
|| resolvedExclusiveSurface === 'audio'; || resolvedExclusiveSurface === 'audio';
if (isMediaSurface) { if (isMediaSurface) {
parts.push(renderMediaGenerationContract(mediaExecution)); 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)) { if (includeCodexImagegenOverride && shouldAllowCodexImagegenOverride(metadata, mediaExecution)) {
@ -959,10 +1038,10 @@ function renderMetadataBlock(
} }
if (metadata.kind === 'image') { if (metadata.kind === 'image') {
lines.push( 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( 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) { if (metadata.imageStyle) {
lines.push(`- **styleNotes**: ${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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js'; import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
import {
normalizeRunToolBundleForRun,
summarizeRunToolBundle,
} from './run-tool-bundle.js';
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']); export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
@ -57,6 +61,7 @@ export function createChatRunService({
pluginId: pluginId:
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null, typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution), mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
toolBundle: normalizeRunToolBundleForRun(meta.toolBundle),
status: 'queued', status: 'queued',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -149,6 +154,7 @@ export function createChatRunService({
errorCode: run.errorCode ?? null, errorCode: run.errorCode ?? null,
eventsLogPath: run.eventsLogPath ?? null, eventsLogPath: run.eventsLogPath ?? null,
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null), mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
toolBundle: summarizeRunToolBundle(run.toolBundle),
}); });
const finish = (run, status, code: number | null = null, signal: string | null = null) => { const finish = (run, status, code: number | null = null, signal: string | null = null) => {

View file

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

View file

@ -15,6 +15,9 @@ import {
} from '../sandbox-mode.js'; } from '../sandbox-mode.js';
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>; type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
type SpawnEnvOptions = {
resolvedBin?: string | null;
};
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule( const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
@ -51,6 +54,7 @@ export function spawnEnvForAgent(
baseEnv: RuntimeEnvMap, baseEnv: RuntimeEnvMap,
configuredEnv: unknown = {}, configuredEnv: unknown = {},
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(), systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
options: SpawnEnvOptions = {},
): NodeJS.ProcessEnv { ): NodeJS.ProcessEnv {
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv); const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
const env = mergeProxyAwareEnv( const env = mergeProxyAwareEnv(
@ -75,7 +79,9 @@ export function spawnEnvForAgent(
return reapplySandboxRuntimeEnv(env, sandboxRuntime); return reapplySandboxRuntimeEnv(env, sandboxRuntime);
} }
if (agentId === 'claude') { 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); return reapplySandboxRuntimeEnv(env, sandboxRuntime);
} }
if (agentId === 'codex') { if (agentId === 'codex') {
@ -88,6 +94,15 @@ export function spawnEnvForAgent(
return reapplySandboxRuntimeEnv(env, sandboxRuntime); 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( function sandboxRuntimeConfigForBaseEnv(
baseEnv: RuntimeEnvMap, baseEnv: RuntimeEnvMap,
): SandboxRuntimeConfig | null { ): SandboxRuntimeConfig | null {

View file

@ -316,6 +316,11 @@ import {
readMcpConfig, readMcpConfig,
writeMcpConfig, writeMcpConfig,
} from './mcp-config.js'; } from './mcp-config.js';
import {
parseRunToolBundleForRequest,
resolveExternalMcpServersForRun,
validateRunToolBundleForAgent,
} from './run-tool-bundle.js';
import { import {
beginAuth, beginAuth,
exchangeCodeForToken, exchangeCodeForToken,
@ -348,6 +353,7 @@ import {
assertSandboxProjectRootAvailable, assertSandboxProjectRootAvailable,
detectEntryFile, detectEntryFile,
ensureProject, ensureProject,
isRunTouchedProjectFile,
isSafeId, isSafeId,
listFiles, listFiles,
mimeFor, mimeFor,
@ -4169,7 +4175,7 @@ export async function startServer({
// Routes that serve content to sandboxed iframes (Origin: null) for // Routes that serve content to sandboxed iframes (Origin: null) for
// read-only purposes. All other /api routes reject Origin: null. // read-only purposes. All other /api routes reject Origin: null.
const _NULL_ORIGIN_SAFE_GET_RE = const _NULL_ORIGIN_SAFE_GET_RE =
/^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/; /^\/projects\/[^/]+\/(?:raw|preview)\/|^\/codex-pets\/[^/]+\/spritesheet$/;
// Reject cross-origin requests to API endpoints. // Reject cross-origin requests to API endpoints.
// Health/version remain open for monitoring probes. // Health/version remain open for monitoring probes.
@ -5728,6 +5734,7 @@ export async function startServer({
events: projectEventDeps, events: projectEventDeps,
ids: idDeps, ids: idDeps,
telemetry: { reportFinalizedMessage }, telemetry: { reportFinalizedMessage },
appConfig: appConfigDeps,
validation: validationDeps, validation: validationDeps,
}); });
registerImportRoutes(app, { registerImportRoutes(app, {
@ -10775,8 +10782,8 @@ export async function startServer({
// doesn't exist yet). Without one we don't pass cwd to spawn — the // 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 // agent then runs in whatever inherited dir, which still lets API
// mode work but loses file-tool addressability. // mode work but loses file-tool addressability.
// For git-linked projects (metadata.baseDir), use that folder directly // Project directory resolution lives in projects.ts so sandbox mode can
// so the agent writes back to the user's original source tree. // consistently reject imported-folder metadata that has no managed copy.
let cwd = null; let cwd = null;
let existingProjectFiles = []; let existingProjectFiles = [];
if (typeof projectId === 'string' && projectId) { if (typeof projectId === 'string' && projectId) {
@ -10901,57 +10908,71 @@ export async function startServer({
// values further down at .mcp.json write time — see the spawn block // values further down at .mcp.json write time — see the spawn block
// below — instead of re-reading. // below — instead of re-reading.
let externalMcpConfig = { servers: [] }; let externalMcpConfig = { servers: [] };
try { if (!SANDBOX_RUNTIME.enabled) {
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR); try {
} catch (err) { externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
console.warn( } catch (err) {
'[mcp-config] read failed:', console.warn(
err && err.message ? err.message : err, '[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 = {}; const oauthTokensForSpawn = {};
try { if (persistedTokenServerIds.size > 0) {
const stored = await readAllTokens(RUNTIME_DATA_DIR); try {
for (const [serverId, tok] of Object.entries(stored)) { const stored = await readAllTokens(RUNTIME_DATA_DIR);
if (!enabledExternalMcp.find((s) => s.id === serverId)) continue; for (const [serverId, tok] of Object.entries(stored)) {
// Default to the persisted access token; null it out if expired so if (!persistedTokenServerIds.has(serverId)) continue;
// we never inject a stale `Authorization: Bearer …` header. The // Default to the persisted access token; null it out if expired so
// model treats a server with a Bearer pinned as connected and // we never inject a stale `Authorization: Bearer …` header. The
// discourages re-auth, which is the worst possible UX when the // model treats a server with a Bearer pinned as connected and
// token is going to 401 every call. // discourages re-auth, which is the worst possible UX when the
let access = isTokenExpired(tok) ? null : tok.accessToken; // token is going to 401 every call.
if (isTokenExpired(tok) && tok.refreshToken) { let access = isTokenExpired(tok) ? null : tok.accessToken;
try { if (isTokenExpired(tok) && tok.refreshToken) {
const refreshed = await refreshAndPersistToken( try {
RUNTIME_DATA_DIR, const refreshed = await refreshAndPersistToken(
serverId, RUNTIME_DATA_DIR,
tok, serverId,
); tok,
if (refreshed) access = refreshed.accessToken; );
} catch (err) { 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( console.warn(
'[mcp-oauth] refresh failed for', '[mcp-oauth] skipping expired token for',
serverId, serverId,
err && err.message ? err.message : err, '— reconnect required',
); );
} }
} }
if (access) { } catch (err) {
oauthTokensForSpawn[serverId] = access; console.warn(
} else { '[mcp-tokens] read failed:',
console.warn( err && err.message ? err.message : err,
'[mcp-oauth] skipping expired token for', );
serverId,
'— reconnect required',
);
}
} }
} catch (err) {
console.warn(
'[mcp-tokens] read failed:',
err && err.message ? err.message : err,
);
} }
const connectedExternalMcp = enabledExternalMcp const connectedExternalMcp = enabledExternalMcp
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string') .filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
@ -11315,6 +11336,8 @@ export async function startServer({
...(def.env || {}), ...(def.env || {}),
}, },
configuredAgentEnv, configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
), ),
agentLaunch, agentLaunch,
) )
@ -11697,6 +11720,8 @@ export async function startServer({
...(def.env || {}), ...(def.env || {}),
}, },
configuredAgentEnv, configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
); );
if (def.id === 'amr') { if (def.id === 'amr') {
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv); const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
@ -12650,6 +12675,7 @@ export async function startServer({
stderrTail: agentStderrTail, stderrTail: agentStderrTail,
stdoutTail: agentStdoutTail, stdoutTail: agentStdoutTail,
env: spawnedAgentEnv, env: spawnedAgentEnv,
resolvedBin: agentLaunch.selectedPath,
}); });
// A non-zero exit whose output reads as an auth / quota / upstream // A non-zero exit whose output reads as an auth / quota / upstream
// problem (typical of Claude Code, codex, …) gets the specific code // problem (typical of Claude Code, codex, …) gets the specific code
@ -12726,7 +12752,7 @@ export async function startServer({
try { try {
const filePath = path.join(dir, f.name); const filePath = path.join(dir, f.name);
const st = await fs.promises.stat(filePath); const st = await fs.promises.stat(filePath);
if (st.mtimeMs < runStartTimeMs) continue; if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue;
await reconcileHtmlArtifactManifest( await reconcileHtmlArtifactManifest(
PROJECTS_DIR, PROJECTS_DIR,
run.projectId, run.projectId,
@ -12995,14 +13021,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) => { app.post('/api/runs', async (req, res) => {
if (daemonShuttingDown) { if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down'); 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) { if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message); 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 // Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
// before the run is created. The resolver returns null when the body // before the run is created. The resolver returns null when the body
// does not mention a plugin (legacy runs unchanged), an error envelope // does not mention a plugin (legacy runs unchanged), an error envelope
@ -13018,7 +13063,7 @@ export async function startServer({
// bundled scenario that is not installed leaves the run plugin-less, // bundled scenario that is not installed leaves the run plugin-less,
// which matches the legacy path. // which matches the legacy path.
let resolvedSnapshot = null; let resolvedSnapshot = null;
if (typeof req.body?.projectId === 'string' && req.body.projectId) { if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
let registryView; let registryView;
try { try {
registryView = await loadPluginRegistryView(); registryView = await loadPluginRegistryView();
@ -13026,26 +13071,26 @@ export async function startServer({
return res.status(500).json({ error: String(err) }); return res.status(500).json({ error: String(err) });
} }
const explicitPlugin = const explicitPlugin =
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId); requestBody.pluginId || requestBody.appliedPluginSnapshotId;
let runResolveBody = req.body; let runResolveBody = requestBody;
if (!explicitPlugin) { if (!explicitPlugin) {
const projectRow = getProject(db, req.body.projectId); const projectRow = getProject(db, requestBody.projectId);
const hasPin = const hasPin =
typeof projectRow?.appliedPluginSnapshotId === 'string' typeof projectRow?.appliedPluginSnapshotId === 'string'
&& projectRow.appliedPluginSnapshotId.length > 0; && projectRow.appliedPluginSnapshotId.length > 0;
if (!hasPin) { if (!hasPin) {
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata); const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) { if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
runResolveBody = { ...req.body, pluginId: fallbackPluginId }; runResolveBody = { ...requestBody, pluginId: fallbackPluginId };
} }
} }
} }
const resolved = resolvePluginSnapshot({ const resolved = resolvePluginSnapshot({
db, db,
body: runResolveBody, body: runResolveBody,
projectId: req.body.projectId, projectId: requestBody.projectId,
conversationId: typeof req.body.conversationId === 'string' conversationId: typeof requestBody.conversationId === 'string'
? req.body.conversationId ? requestBody.conversationId
: null, : null,
registry: registryView, registry: registryView,
connectorProbe: buildConnectorProbe(connectorService), connectorProbe: buildConnectorProbe(connectorService),
@ -13053,7 +13098,7 @@ export async function startServer({
if (resolved && !resolved.ok) { if (resolved && !resolved.ok) {
if (!explicitPlugin) { if (!explicitPlugin) {
console.warn( 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 { } else {
return res.status(resolved.status).json(resolved.body); return res.status(resolved.status).json(resolved.body);
@ -13062,7 +13107,11 @@ export async function startServer({
resolvedSnapshot = resolved; resolvedSnapshot = resolved;
} }
} }
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy }; const meta = {
...requestBody,
mediaExecution: mediaExecution.policy,
toolBundle: toolBundle.bundle,
};
if (resolvedSnapshot?.ok) { if (resolvedSnapshot?.ok) {
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId; meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId; if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
@ -13074,6 +13123,53 @@ export async function startServer({
if (renderedQuery.length > 0) meta.message = renderedQuery; 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 // MCP / SDK callers POST /api/runs with just a projectId — no
// conversationId, no pre-created assistantMessageId — because they // conversationId, no pre-created assistantMessageId — because they
// don't know about OD's chat-row lifecycle. The web flow // don't know about OD's chat-row lifecycle. The web flow
@ -13133,30 +13229,6 @@ export async function startServer({
console.warn('[runs] mcp conversation fallback failed', err); 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); const run = design.runs.create(meta);
try { try {
pinAssistantMessageOnRunCreate(db, run); pinAssistantMessageOnRunCreate(db, run);
@ -13571,11 +13643,45 @@ export async function startServer({
if (daemonShuttingDown) { if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down'); 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) { if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message); 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); const run = design.runs.create(meta);
design.runs.stream(run, req, res); design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(meta, run)); design.runs.start(run, () => startChatRun(meta, run));
@ -13652,6 +13758,7 @@ export async function startServer({
if (routine.target.mode === 'reuse') { if (routine.target.mode === 'reuse') {
const project = getProject(db, routine.target.projectId); const project = getProject(db, routine.target.projectId);
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`); if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
assertSandboxProjectRootAvailable(project.metadata);
projectId = project.id; projectId = project.id;
projectName = project.name; projectName = project.name;
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null; previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;

View file

@ -1,6 +1,6 @@
import http from 'node:http'; import http from 'node:http';
import { mkdtemp, rm, writeFile } from 'node:fs/promises'; 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 path from 'node:path';
import express from 'express'; import express from 'express';
import { 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', () => { describe('app-config origin guard', () => {
let server: http.Server; let server: http.Server;
let port: number; let port: number;

View file

@ -9,7 +9,7 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { closeDatabase, insertProject, openDatabase } from '../src/db.js'; import { closeDatabase, insertProject, openDatabase } from '../src/db.js';
import { reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js'; import { isRunTouchedProjectFile, reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js';
const PROJECT_ID = 'reconcile-test'; const PROJECT_ID = 'reconcile-test';
let tempDir = null; let tempDir = null;
@ -146,6 +146,9 @@ describe('run-end artifact manifest reconciliation (#2893)', () => {
// File written during the run // File written during the run
await writeProjectFile(projectsRoot, PROJECT_ID, 'new-output.html', '<p>new</p>'); await writeProjectFile(projectsRoot, PROJECT_ID, 'new-output.html', '<p>new</p>');
const newPath = path.join(projectsRoot, PROJECT_ID, 'new-output.html');
const coarseFsTime = new Date(runStartTimeMs - 500);
fs.utimesSync(newPath, coarseFsTime, coarseFsTime);
// Simulate the close-handler reconciliation with mtime filter // Simulate the close-handler reconciliation with mtime filter
const dir = path.join(projectsRoot, PROJECT_ID); const dir = path.join(projectsRoot, PROJECT_ID);
@ -154,7 +157,7 @@ describe('run-end artifact manifest reconciliation (#2893)', () => {
const ext = path.extname(name).toLowerCase(); const ext = path.extname(name).toLowerCase();
if (ext !== '.html' && ext !== '.htm') continue; if (ext !== '.html' && ext !== '.htm') continue;
const st = fs.statSync(path.join(dir, name)); const st = fs.statSync(path.join(dir, name));
if (st.mtimeMs < runStartTimeMs) continue; if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue;
await reconcileHtmlArtifactManifest(projectsRoot, PROJECT_ID, name); await reconcileHtmlArtifactManifest(projectsRoot, PROJECT_ID, name);
} }

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> { async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('codex', script, run); return withFakeAgent('codex', script, run);
} }
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
return withFakeAgent('claude', script, run); 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> { async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('opencode', script, run); 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 () => { it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
await withFakeClaude( await withFakeClaude(
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`, `console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,

View file

@ -0,0 +1,364 @@
import type http from 'node:http';
import { randomUUID } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { writeFile as writeFsFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
describe('project export manifest route', () => {
let server: http.Server;
let baseUrl: string;
const projectsToClean: 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;
});
afterAll(async () => {
for (const id of projectsToClean.splice(0)) {
await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {});
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function makeFolder(): string {
const dir = mkdtempSync(path.join(tmpdir(), 'od-export-manifest-'));
tempDirs.push(dir);
return dir;
}
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;
}
}
async function createProject(
metadata: Record<string, unknown> = { kind: 'prototype', entryFile: 'index.html' },
): Promise<string> {
const id = `export-manifest-${randomUUID()}`;
const response = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id,
name: 'Export manifest project',
metadata,
}),
});
expect(response.ok).toBe(true);
projectsToClean.push(id);
return id;
}
async function writeFile(projectId: string, body: Record<string, unknown>): Promise<void> {
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
expect(response.ok).toBe(true);
}
async function renameFile(projectId: string, from: string, to: string): Promise<void> {
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files/rename`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ from, to }),
});
expect(response.ok).toBe(true);
}
it('lists exportable project files and artifact sidecar metadata without exposing sidecars', async () => {
const projectId = await createProject();
await writeFile(projectId, {
name: 'styles.css',
content: 'body { color: black; }',
});
await writeFile(projectId, {
name: 'assets/logo.svg',
content: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
});
await writeFile(projectId, {
name: 'index.html',
content: '<!doctype html><link rel="stylesheet" href="styles.css">',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Reviewed prototype',
entry: 'index.html',
renderer: 'html',
status: 'complete',
exports: ['html', 'zip'],
primary: true,
supportingFiles: ['styles.css', 'assets/logo.svg', 'missing.png'],
updatedAt: '2026-05-28T00:00:00.000Z',
},
});
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
expect(response.ok).toBe(true);
const body = await response.json() as {
schema: string;
projectId: string;
entryFile: string;
files: Array<{ name: string; role: string; reasons: string[]; artifactManifest?: unknown }>;
artifacts: Array<{ file: string; title: string; supportingFiles: string[] }>;
};
expect(body).toMatchObject({
schema: 'open-design.project-export-manifest.v1',
projectId,
entryFile: 'index.html',
});
expect(body.files.map((file) => file.name)).toEqual([
'assets/logo.svg',
'index.html',
'styles.css',
]);
expect(body.files.find((file) => file.name === 'index.html')).toMatchObject({
role: 'entry',
reasons: expect.arrayContaining(['artifact-manifest', 'project-entry-file']),
});
expect(body.files.find((file) => file.name === 'styles.css')).toMatchObject({
role: 'supporting',
reasons: ['artifact-supporting-file'],
});
expect(body.artifacts).toMatchObject([
{
file: 'index.html',
title: 'Reviewed prototype',
supportingFiles: ['assets/logo.svg', 'styles.css'],
},
]);
expect(body.files.some((file) => file.name.endsWith('.artifact.json'))).toBe(false);
});
it('uses artifact primary strings as project-relative entry refs', async () => {
const projectId = await createProject({ kind: 'prototype' });
await writeFile(projectId, {
name: 'reviewed.html',
content: '<!doctype html><main>reviewed</main>',
});
await writeFile(projectId, {
name: 'preview/wrapper.html',
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Review wrapper',
renderer: 'html',
status: 'complete',
exports: ['html'],
primary: 'reviewed.html',
},
});
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
expect(response.ok).toBe(true);
const body = await response.json() as {
entryFile: string;
files: Array<{ name: string; role: string; reasons: string[] }>;
};
expect(body.entryFile).toBe('reviewed.html');
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
role: 'entry',
reasons: expect.arrayContaining(['artifact-primary', 'project-entry-file']),
});
});
it('uses artifact entry strings as project-relative entry refs without primary hints', async () => {
const projectId = await createProject({ kind: 'prototype' });
await writeFile(projectId, {
name: 'index.html',
content: '<!doctype html><main>fallback</main>',
});
await writeFile(projectId, {
name: 'reviewed.html',
content: '<!doctype html><main>reviewed</main>',
});
await writeFile(projectId, {
name: 'preview/wrapper.html',
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Review wrapper',
entry: 'reviewed.html',
renderer: 'html',
status: 'complete',
exports: ['html'],
},
});
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
expect(response.ok).toBe(true);
const body = await response.json() as {
entryFile: string;
files: Array<{ name: string; role: string; reasons: string[] }>;
};
expect(body.entryFile).toBe('reviewed.html');
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
role: 'entry',
reasons: expect.arrayContaining(['artifact-entry', 'project-entry-file']),
});
});
it('keeps artifact entry refs current when a referenced file is renamed', async () => {
const projectId = await createProject({ kind: 'prototype' });
await writeFile(projectId, {
name: 'index.html',
content: '<!doctype html><main>fallback</main>',
});
await writeFile(projectId, {
name: 'reviewed.html',
content: '<!doctype html><main>reviewed</main>',
});
await writeFile(projectId, {
name: 'preview/wrapper.html',
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Review wrapper',
entry: 'reviewed.html',
renderer: 'html',
status: 'complete',
exports: ['html'],
primary: 'reviewed.html',
supportingFiles: ['reviewed.html'],
},
});
await renameFile(projectId, 'reviewed.html', 'reviewed-renamed.html');
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
expect(response.ok).toBe(true);
const body = await response.json() as {
entryFile: string;
files: Array<{ name: string; role: string; reasons: string[] }>;
};
expect(body.entryFile).toBe('reviewed-renamed.html');
expect(body.files.find((file) => file.name === 'reviewed-renamed.html')).toMatchObject({
role: 'entry',
reasons: expect.arrayContaining(['artifact-entry', 'artifact-primary', 'project-entry-file']),
});
const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
expect(filesResponse.ok).toBe(true);
const filesBody = await filesResponse.json() as {
files: Array<{
name: string;
artifactManifest?: {
entry?: string;
primary?: string | boolean;
supportingFiles?: string[];
};
}>;
};
expect(filesBody.files.find((file) => file.name === 'preview/wrapper.html')?.artifactManifest)
.toMatchObject({
entry: 'reviewed-renamed.html',
primary: 'reviewed-renamed.html',
supportingFiles: ['reviewed-renamed.html'],
});
});
it('keeps artifact entry refs current when a referenced file moves out of the wrapper directory', async () => {
const projectId = await createProject({ kind: 'prototype' });
await writeFile(projectId, {
name: 'index.html',
content: '<!doctype html><main>fallback</main>',
});
await writeFile(projectId, {
name: 'preview/reviewed.html',
content: '<!doctype html><main>reviewed</main>',
});
await writeFile(projectId, {
name: 'preview/wrapper.html',
content: '<!doctype html><iframe src="reviewed.html"></iframe>',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Review wrapper',
entry: 'reviewed.html',
renderer: 'html',
status: 'complete',
exports: ['html'],
primary: 'reviewed.html',
supportingFiles: ['reviewed.html'],
},
});
await renameFile(projectId, 'preview/reviewed.html', 'reviewed.html');
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
expect(response.ok).toBe(true);
const body = await response.json() as {
entryFile: string;
files: Array<{ name: string; role: string; reasons: string[] }>;
artifacts: Array<{ file: string; supportingFiles: string[] }>;
};
expect(body.entryFile).toBe('reviewed.html');
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
role: 'entry',
reasons: expect.arrayContaining([
'artifact-entry',
'artifact-primary',
'artifact-supporting-file',
'project-entry-file',
]),
});
expect(body.artifacts.find((artifact) => artifact.file === 'preview/wrapper.html'))
.toMatchObject({
supportingFiles: ['reviewed.html'],
});
});
it('rejects invalid project ids before listing files', async () => {
const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`);
expect(response.status).toBe(400);
});
it('rejects imported-folder projects in sandbox mode instead of returning an empty manifest', async () => {
const folder = makeFolder();
await writeFsFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResponse = await fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseDir: folder }),
});
expect(importResponse.status).toBe(200);
const importBody = (await importResponse.json()) as { project: { id: string } };
projectsToClean.push(importBody.project.id);
await withSandboxMode(async () => {
const response = await fetch(`${baseUrl}/api/projects/${importBody.project.id}/export/manifest`);
expect(response.status).toBe(400);
const body = (await response.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
});

View file

@ -1,6 +1,6 @@
import type http from 'node:http'; import type http from 'node:http';
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'; 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 { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; 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 () => { it('creates a project rooted at the submitted folder', async () => {
const folder = makeFolder(); const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>'); 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(); const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>'); await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -123,15 +103,78 @@ describe('POST /api/import/folder', () => {
message: 'Inspect the imported project.', message: 'Inspect the imported project.',
}), }),
}); });
expect(runResp.status).toBe(202); expect(runResp.status).toBe(400);
const { runId } = (await runResp.json()) as { runId: string }; const body = (await runResp.json()) as { error?: { message?: string } };
const status = await waitForRunStatus(runId); expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
expect(status.status).toBe('failed');
expect(status.errorCode).toBe('BAD_REQUEST');
expect(status.error).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 () => { it('still opens an imported-folder project record in sandbox mode', async () => {
const folder = makeFolder(); const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>'); await writeFile(path.join(folder, 'index.html'), '<!doctype html>');

View file

@ -68,10 +68,14 @@ process.exit(0);
async function waitForRunStatus( async function waitForRunStatus(
baseUrl: string, baseUrl: string,
runId: string, runId: string,
): Promise<{ status: string }> { ): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
for (let attempt = 0; attempt < 200; attempt += 1) { for (let attempt = 0; attempt < 200; attempt += 1) {
const r = await fetch(`${baseUrl}/api/runs/${runId}`); 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; if (body.status !== 'queued' && body.status !== 'running') return body;
await new Promise((resolve) => setTimeout(resolve, 25)); 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 server: http.Server;
let baseUrl: string; let baseUrl: string;
const projectsToClean: string[] = []; const projectsToClean: string[] = [];
const tempDirs: string[] = [];
beforeAll(async () => { beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as { 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' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servers: [] }), body: JSON.stringify({ servers: [] }),
}).catch(() => {}); }).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 id = `mcp-spawn-${randomUUID()}`;
const r = await fetch(`${baseUrl}/api/projects`, { const r = await fetch(`${baseUrl}/api/projects`, {
method: 'POST', method: 'POST',
@ -116,6 +124,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
body: JSON.stringify({ id, name: id }), body: JSON.stringify({ id, name: id }),
}); });
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
const body = (await r.json()) as { conversationId: string };
projectsToClean.push(id); projectsToClean.push(id);
// The daemon owns its data dir; we discover the on-disk project path by // 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. // 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 const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects') ? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', '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 () => { it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
@ -197,6 +245,414 @@ describe('spawn writes external MCP config for Claude Code', () => {
}); });
}, 30_000); }, 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('binds conversation-less runs to the seeded project conversation', async () => {
await withFakeClaude(async () => {
const { id, conversationId } = await createProject();
const recentConvRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'Recently active' }),
});
expect(recentConvRes.ok).toBe(true);
const recentConvBody = (await recentConvRes.json()) as {
conversation: { id: string };
};
const recentConversationId = recentConvBody.conversation.id;
await fetch(`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: 'Recently active',
updatedAt: Date.now() + 60_000,
}),
});
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'headless fallback prompt',
}),
});
expect(chatRes.status).toBe(202);
const { runId, conversationId: resolvedConversationId } = (await chatRes.json()) as {
runId: string;
conversationId: string;
};
expect(resolvedConversationId).toBe(conversationId);
const status = await waitForRunStatus(baseUrl, runId);
expect(status.status).toBe('succeeded');
const defaultMessagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(defaultMessagesRes.ok).toBe(true);
const defaultMessages = (await defaultMessagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(defaultMessages.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: 'user',
content: 'headless fallback prompt',
}),
]),
);
const recentMessagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}/messages`,
);
expect(recentMessagesRes.ok).toBe(true);
const recentMessages = (await recentMessagesRes.json()) as {
messages: Array<{ content: string }>;
};
expect(recentMessages.messages.some((msg) => msg.content === 'headless fallback prompt')).toBe(false);
});
}, 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 () => { 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 // ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
// session/new params instead of `.mcp.json`. The `.mcp.json` write path // session/new params instead of `.mcp.json`. The `.mcp.json` write path

View file

@ -0,0 +1,122 @@
import type http from 'node:http';
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
describe('project preview containment routes', () => {
let server: http.Server;
let baseUrl: string;
const projectsToClean: string[] = [];
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
});
afterAll(async () => {
for (const id of projectsToClean.splice(0)) {
await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {});
}
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function createProject(metadata: Record<string, unknown> = {}): Promise<string> {
const id = `preview-containment-${randomUUID()}`;
const response = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id,
name: 'Preview containment project',
metadata,
}),
});
expect(response.ok).toBe(true);
projectsToClean.push(id);
return id;
}
async function writeProjectFile(projectId: string, name: string, content: string): Promise<void> {
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, content }),
});
expect(response.ok).toBe(true);
}
it('returns a scoped preview URL with sandbox guidance and serves it with an opaque-origin CSP', async () => {
const projectId = await createProject({ entryFile: 'pages/index.html' });
await writeProjectFile(
projectId,
'pages/index.html',
'<!doctype html><title>Preview</title><link rel="stylesheet" href="../styles/app.css">',
);
await writeProjectFile(projectId, 'styles/app.css', 'body { color: black; }');
const urlResponse = await fetch(
`${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('pages/index.html')}`,
);
expect(urlResponse.ok).toBe(true);
expect(urlResponse.headers.get('cache-control')).toBe('no-store');
const body = await urlResponse.json() as {
url: string;
file: string;
csp: string;
iframeSandbox: string;
opaqueOrigin: true;
};
expect(body.file).toBe('pages/index.html');
expect(body.url).toContain(`/api/projects/${projectId}/preview/`);
expect(body.url).toMatch(/\/preview\/[A-Za-z0-9_-]{8,128}\/pages\/index\.html$/u);
expect(body.iframeSandbox).toBe('allow-scripts allow-forms');
expect(body.iframeSandbox).not.toContain('allow-same-origin');
expect(body.csp).toContain('sandbox allow-scripts allow-forms');
expect(body.csp).toContain("connect-src 'none'");
expect(body.csp).not.toContain('allow-same-origin');
expect(body.opaqueOrigin).toBe(true);
const previewResponse = await fetch(`${baseUrl}${body.url}`, {
headers: { Origin: 'null' },
});
expect(previewResponse.status).toBe(200);
expect(previewResponse.headers.get('access-control-allow-origin')).toBe('*');
expect(previewResponse.headers.get('cache-control')).toBe('no-store');
expect(previewResponse.headers.get('x-content-type-options')).toBe('nosniff');
const csp = previewResponse.headers.get('content-security-policy') ?? '';
expect(csp).toContain('sandbox allow-scripts allow-forms');
expect(csp).toContain("connect-src 'none'");
expect(csp).not.toContain('allow-same-origin');
expect(await previewResponse.text()).toContain('<title>Preview</title>');
const scope = body.url.match(/\/preview\/([^/]+)\//u)?.[1];
expect(scope).toBeTruthy();
const assetResponse = await fetch(
`${baseUrl}/api/projects/${projectId}/preview/${scope}/styles/app.css`,
{ headers: { Origin: 'null' } },
);
expect(assetResponse.status).toBe(200);
expect(assetResponse.headers.get('access-control-allow-origin')).toBe('*');
expect(assetResponse.headers.get('content-type')).toContain('text/css');
expect(await assetResponse.text()).toContain('color: black');
});
it('rejects invalid preview scopes and escaping preview-url paths', async () => {
const projectId = await createProject();
await writeProjectFile(projectId, 'index.html', '<!doctype html>');
const invalidScope = await fetch(`${baseUrl}/api/projects/${projectId}/preview/bad/index.html`);
expect(invalidScope.status).toBe(400);
const escapingPath = await fetch(
`${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('../index.html')}`,
);
expect(escapingPath.status).toBe(400);
});
});

View file

@ -124,6 +124,95 @@ test('conversation latest run follows assistant message position', () => {
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running'); 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', () => { test('conversation listing batches latest run summaries for large projects', () => {
const db = createDb(); const db = createDb();
insertProject(db, { insertProject(db, {

View file

@ -13,7 +13,7 @@
*/ */
import type http from 'node:http'; import type http from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs'; 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 { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; 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> { async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE; const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1'; 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

@ -80,6 +80,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 () => { it('cancels active runs and terminates their child process during daemon shutdown', async () => {
const runs = createRuns(); const runs = createRuns();
const child = new FakeChildProcess({ closeOn: 'SIGTERM' }); const child = new FakeChildProcess({ closeOn: 'SIGTERM' });

View file

@ -957,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
assert.equal(env.PATH, '/usr/bin'); 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', () => { test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) { for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
const env = spawnEnvForAgent(agentId, { const env = spawnEnvForAgent(agentId, {

View file

@ -200,6 +200,16 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
expect(out).not.toContain('Reference prompt template'); 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', () => { it('renders without source attribution when the source field is missing', () => {
const { source: _omit, ...withoutSource } = baseSummary; const { source: _omit, ...withoutSource } = baseSummary;
const out = composeSystemPrompt({ const out = composeSystemPrompt({
@ -420,8 +430,8 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
}, },
}); });
expect(out).toContain('`media generate` treats the handoff as'); expect(out).toContain('always exits 0');
expect(out).toContain('exit `0` so the first dispatch does not look like a failed shell call'); expect(out).toContain('as a handoff signal');
expect(out).toContain('`"$OD_NODE_BIN" "$OD_BIN" media generate` exits `0`'); expect(out).toContain('`"$OD_NODE_BIN" "$OD_BIN" media generate` exits `0`');
expect(out).toContain('either `file` or `taskId`'); expect(out).toContain('either `file` or `taskId`');
expect(out).toContain('`2` from `media wait` is not a failure'); 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 * Shared system card used on `/plugins/systems/`. Displays palette
* `/systems/category/<slug>/`. Displays palette swatches, name, * swatches, name, category, and tagline as a clickable card.
* 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 type { SystemRecord } from '../_lib/catalog';
import { localeFromPath, localizedHref } from '../i18n'; import { localeFromPath, localizedHref } from '../i18n';

View file

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

View file

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

View file

@ -2,17 +2,7 @@
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import Layout from '../../_components/sub-page-layout.astro'; import Layout from '../../_components/sub-page-layout.astro';
import type { HeaderProps } from '../../_components/header'; import type { HeaderProps } from '../../_components/header';
import LazyImg from '../../_components/lazy-img.astro'; import { getCraftRecords } from '../../_lib/catalog';
import {
getCraftRecords,
getSkillModeIndex,
getSkillRecords,
getSkillScenarioIndex,
getSystemCategoryIndex,
getSystemRecords,
getTemplateRecords,
tally,
} from '../../_lib/catalog';
import { import {
PREFIXED_LOCALES, PREFIXED_LOCALES,
getCopy, getCopy,
@ -23,31 +13,17 @@ import {
import '../../globals.css'; import '../../globals.css';
import '../../sub-pages.css'; import '../../sub-pages.css';
// Localized routing only generates listing/index pages. Detail pages // Localized routing only generates the `craft` and `blog` listing pages.
// (individual skills, posts, templates, …) stay at canonical English // Detail pages (individual posts, craft items, …) stay at canonical
// URLs to keep the static build bounded; the localized chrome links // English URLs to keep the static build bounded; the localized chrome
// straight to those canonical detail URLs. // links straight to those canonical detail URLs.
export async function getStaticPaths() { export async function getStaticPaths() {
const skillModes = await getSkillModeIndex(); // The skills / systems / templates catalogs moved under `/plugins/*`.
const skillScenarios = await getSkillScenarioIndex(); // Their old localized listings are now 301'd by `public/_redirects`,
const systemCategories = await getSystemCategoryIndex(); // so this catch-all only renders the localized `craft` and `blog`
// listings. Plugins itself is generated via short-code wrappers under
const paths = [ // `app/pages/[locale]/plugins/`, so it does NOT participate here.
'skills', const paths = ['craft', 'blog'];
'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}`),
];
return PREFIXED_LOCALES.flatMap((locale) => return PREFIXED_LOCALES.flatMap((locale) =>
paths.map((path) => ({ paths.map((path) => ({
@ -62,36 +38,20 @@ const copy = getCopy(locale);
const pathParam = Astro.params.path ?? ''; const pathParam = Astro.params.path ?? '';
const segments = pathParam.split('/').filter(Boolean); const segments = pathParam.split('/').filter(Boolean);
const [skills, systems, craft, templates, posts] = await Promise.all([ const [craft, posts] = await Promise.all([
getSkillRecords(),
getSystemRecords(),
getCraftRecords(), getCraftRecords(),
getTemplateRecords(),
getCollection('blog'), getCollection('blog'),
]); ]);
// All cross-locale subpage links resolve to canonical (English) URLs. // All cross-locale subpage links resolve to canonical (English) URLs.
const href = (path: string) => path; const href = (path: string) => path;
const titleSuffix = 'Open Design'; const titleSuffix = 'Open Design';
const routeRoot = segments[0] ?? ''; 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 sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const modeTags = await getSkillModeIndex(); const pageTitle = routeRoot === 'craft'
const scenarioTags = await getSkillScenarioIndex(); ? `${copy.craftTitle} — ${craft.length} | ${titleSuffix}`
const systemCategories = await getSystemCategoryIndex(); : `${copy.blog} — ${titleSuffix}`;
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 pageDescription = `Open Design ${routeRoot || 'landing'} page.`; 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' && ( {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> <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> </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> <h2>{page.nextTitle}</h2>
<ul> <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('/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('/plugins/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/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> <li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
</ul> </ul>
</section> </section>

View file

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

View file

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

View file

@ -45,9 +45,9 @@ const sources = [
{ ...page.sources[4], href: DISCORD }, { ...page.sources[4], href: DISCORD },
{ ...page.sources[5], href: DOCS }, { ...page.sources[5], href: DOCS },
{ ...page.sources[6], href: REPO_LICENSE }, { ...page.sources[6], href: REPO_LICENSE },
{ ...page.sources[7], href: href('/skills/') }, { ...page.sources[7], href: href('/plugins/skills/') },
{ ...page.sources[8], href: href('/systems/') }, { ...page.sources[8], href: href('/plugins/systems/') },
{ ...page.sources[9], href: href('/templates/') }, { ...page.sources[9], href: href('/plugins/templates/') },
]; ];
const jsonLd = [ 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('/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('/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('/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('/plugins/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/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
</ul> </ul>
</section> </section>
</article> </article>

View file

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

View file

@ -142,8 +142,8 @@ const jsonLd = [
<section class="info-section" id="next"> <section class="info-section" id="next">
<h2>{page.nextTitle}</h2> <h2>{page.nextTitle}</h2>
<ul> <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('/plugins/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/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={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> <li><a class="inline-link" href={REPO_RELEASES} target="_blank" rel="noreferrer noopener">{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
</ul> </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.priority = 0.9;
item.changefreq = changefreq.weekly; item.changefreq = changefreq.weekly;
} else if ( } else if (
path === '/skills/' ||
path === '/systems/' ||
path === '/templates/' ||
path === '/craft/' || path === '/craft/' ||
path === '/plugins/' path === '/plugins/' ||
path === '/plugins/skills/' ||
path === '/plugins/systems/' ||
path === '/plugins/templates/'
) { ) {
item.priority = 0.7; item.priority = 0.7;
item.changefreq = changefreq.weekly; item.changefreq = changefreq.weekly;

View file

@ -34,3 +34,85 @@
/fa/plugins/* /plugins/:splat 301 /fa/plugins/* /plugins/:splat 301
/hu/plugins/* /plugins/:splat 301 /hu/plugins/* /plugins/:splat 301
/th/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} daemonMediaProvidersFetchState={daemonMediaProvidersFetchState}
mediaProvidersNotice={mediaProvidersNotice} mediaProvidersNotice={mediaProvidersNotice}
onReloadMediaProviders={reloadMediaProvidersFromDaemon} onReloadMediaProviders={reloadMediaProvidersFromDaemon}
onProjectsRefresh={refreshProjects}
onSkillsChanged={handleSkillsChanged} onSkillsChanged={handleSkillsChanged}
onDesignSystemsChanged={handleDesignSystemsChanged} onDesignSystemsChanged={handleDesignSystemsChanged}
providerModelsCache={providerModelsCache} providerModelsCache={providerModelsCache}

View file

@ -2658,6 +2658,11 @@ function ToolsPluginsPanel({
<button <button
type="button" type="button"
className="composer-tools-row-main" 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 () => { onClick={async () => {
setPendingId(p.id); setPendingId(p.id);
try { try {
@ -2749,6 +2754,10 @@ function ToolsMcpPanel({
type="button" type="button"
role="menuitem" role="menuitem"
className="composer-tools-row" 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)} onClick={() => onInsert(s.id)}
title={`Insert a hint that nudges the model to use ${s.label || s.id}`} title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
> >
@ -2839,6 +2848,10 @@ function ToolsSkillsPanel({
type="button" type="button"
role="menuitem" role="menuitem"
className={`composer-tools-row${active ? ' active' : ''}`} 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 () => { onClick={async () => {
setPendingId(skill.id); setPendingId(skill.id);
try { try {

View file

@ -2014,6 +2014,16 @@ export function conversationMetaLabel(
t: TranslateFn, t: TranslateFn,
): string { ): string {
const latestRun = conversation.latestRun; 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 ( if (
latestRun && latestRun &&
(latestRun.status === 'succeeded' || (latestRun.status === 'succeeded' ||

View file

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

View file

@ -130,7 +130,7 @@ interface Props {
onOpenDesignSystem?: (id: string) => void; onOpenDesignSystem?: (id: string) => void;
onDesignSystemsRefresh?: () => Promise<void> | void; onDesignSystemsRefresh?: () => Promise<void> | void;
onPersistComposioKey: (composio: AppConfig['composio']) => 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; onCompleteOnboarding: () => void;
} }

View file

@ -143,6 +143,14 @@ export type ManualEditPendingStyleSave = {
}; };
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile'; type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
type PreviewCanvasSize = { width: number; height: number }; type PreviewCanvasSize = { width: number; height: number };
type CommentPreviewCanvasOptions = {
boardMode: boolean;
sidePanelCollapsed: boolean;
viewport?: PreviewViewportId;
};
type PreviewScaleOptions = {
canvasPadding?: number;
};
type PreviewViewportPreset = { type PreviewViewportPreset = {
id: PreviewViewportId; id: PreviewViewportId;
width: number | null; width: number | null;
@ -214,6 +222,18 @@ const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
}, },
]; ];
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:'; 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 // The five basic style facets the inspect panel exposes. Kept narrow on
// purpose — open-slide's design tokens panel only edits global tokens, so // purpose — open-slide's design tokens panel only edits global tokens, so
@ -500,10 +520,11 @@ function previewViewportStyle(
viewport: PreviewViewportId, viewport: PreviewViewportId,
previewScale = 1, previewScale = 1,
canvasSize?: PreviewCanvasSize, canvasSize?: PreviewCanvasSize,
options?: PreviewScaleOptions,
): CSSProperties & Record<string, string | number> { ): CSSProperties & Record<string, string | number> {
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!; const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
if (!preset.width) return {}; if (!preset.width) return {};
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize); const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize, options);
return { return {
'--preview-viewport-width': `${preset.width}px`, '--preview-viewport-width': `${preset.width}px`,
'--preview-viewport-height': `${preset.height}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( export function effectivePreviewScale(
viewport: PreviewViewportId, viewport: PreviewViewportId,
previewScale: number, previewScale: number,
canvasSize?: PreviewCanvasSize, canvasSize?: PreviewCanvasSize,
options?: PreviewScaleOptions,
) { ) {
if (viewport === 'desktop') return previewScale; if (viewport === 'desktop') return previewScale;
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport); const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale; 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 availableWidth = Math.max(1, canvasSize.width - canvasPadding);
const availableHeight = Math.max(1, canvasSize.height - canvasPadding); const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height); const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
@ -2086,7 +2146,6 @@ export function CommentSidePanel({
activeCommentId, activeCommentId,
collapsed, collapsed,
onCollapsedChange, onCollapsedChange,
onClose,
onToggleSelect, onToggleSelect,
onSelectAll, onSelectAll,
onClearSelection, onClearSelection,
@ -2102,7 +2161,6 @@ export function CommentSidePanel({
activeCommentId: string | null; activeCommentId: string | null;
collapsed: boolean; collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void; onCollapsedChange: (collapsed: boolean) => void;
onClose: () => void;
onToggleSelect: (commentId: string) => void; onToggleSelect: (commentId: string) => void;
onSelectAll: () => void; onSelectAll: () => void;
onClearSelection: () => void; onClearSelection: () => void;
@ -2119,21 +2177,48 @@ export function CommentSidePanel({
const selectedCount = visibleSelectedIds.size; const selectedCount = visibleSelectedIds.size;
const allSelected = comments.length > 0 && selectedCount === comments.length; const allSelected = comments.length > 0 && selectedCount === comments.length;
const commentsLabel = t('chat.tabComments'); 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 canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
const submitNewComment = async () => { const submitNewComment = async () => {
if (!onCreateComment || !newCommentDraft.trim()) return; if (!onCreateComment || !newCommentDraft.trim()) return;
const saved = await onCreateComment(newCommentDraft.trim()); const saved = await onCreateComment(newCommentDraft.trim());
if (saved) setNewCommentDraft(''); 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) { if (collapsed) {
return ( return (
<button <button
ref={collapsedRailRef}
type="button" type="button"
className="comment-side-rail" className="comment-side-rail"
data-testid="comment-side-collapsed-rail" data-testid="comment-side-collapsed-rail"
aria-label={t('preview.showSidebar', { label: commentsLabel })} aria-label={t('preview.showSidebar', { label: commentsLabel })}
aria-expanded={false}
title={t('preview.showSidebar', { label: commentsLabel })} title={t('preview.showSidebar', { label: commentsLabel })}
onClick={() => onCollapsedChange(false)} onClick={() => handleCollapsedChange(false, 'expanded')}
> >
<RemixIcon name="message-3-line" size={15} /> <RemixIcon name="message-3-line" size={15} />
<span>{commentsLabel}</span> <span>{commentsLabel}</span>
@ -2143,7 +2228,7 @@ export function CommentSidePanel({
} }
return ( 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-header">
<div className="comment-side-title"> <div className="comment-side-title">
<RemixIcon name="message-3-line" size={15} /> <RemixIcon name="message-3-line" size={15} />
@ -2160,15 +2245,18 @@ export function CommentSidePanel({
{t('chat.comments.selectAll')} {t('chat.comments.selectAll')}
</button> </button>
) : null} ) : null}
<button <button
type="button" ref={expandedToggleRef}
className="comment-side-close" type="button"
aria-label={t('common.close')} className="comment-side-collapse"
title={t('common.close')} aria-label={t('preview.hideSidebar', { label: commentsLabel })}
onClick={onClose} aria-controls={panelId}
> aria-expanded={true}
<Icon name="close" size={12} /> title={t('preview.hideSidebar', { label: commentsLabel })}
</button> onClick={() => handleCollapsedChange(true, 'collapsed')}
>
<Icon name="chevron-right" size={14} />
</button>
</div> </div>
</div> </div>
<div className="comment-side-list"> <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 // 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> // input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip // 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 [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`; const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100; 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[]) { function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {}; const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
@ -4432,8 +4587,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const [slideState, setSlideState] = useState<SlideState | null>( const [slideState, setSlideState] = useState<SlideState | null>(
() => htmlPreviewSlideState.get(previewStateKey) ?? null, () => htmlPreviewSlideState.get(previewStateKey) ?? null,
); );
const overlayPreviewTransform = previewOverlayTransform(previewViewport, previewScale, previewBodySize); const boardPreviewScaleOptions = localCommentSideDockActive ? { canvasPadding: 0 } : undefined;
const overlayPreviewScale = overlayPreviewTransform.scale; const overlayPreviewScale = effectivePreviewScale(
previewViewport,
previewScale,
boardPreviewCanvasSize,
boardPreviewScaleOptions,
);
const overlayPreviewTransform: PreviewOverlayTransform = {
scale: overlayPreviewScale,
offsetX: 0,
offsetY: 0,
};
const shareRef = useRef<HTMLDivElement | null>(null); const shareRef = useRef<HTMLDivElement | null>(null);
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null); const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
@ -6479,6 +6644,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
}; };
const boardAvailable = mode === 'preview' && source !== null; const boardAvailable = mode === 'preview' && source !== null;
const showPreviewToolbarControls = mode === 'preview'; 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 ? ( const manualEditPanel = manualEditMode ? (
<ManualEditPanel <ManualEditPanel
targets={manualEditTargets} targets={manualEditTargets}
@ -6588,19 +6759,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
/> />
) : null; ) : null;
const commentSidePanel = commentPanelOpen ? ( const commentSidePanel = commentPanelOpen ? (
<CommentSidePanel <CommentSideDock
comments={visibleSideComments} comments={visibleSideComments}
selectedIds={selectedSideCommentIds} selectedIds={selectedSideCommentIds}
activeCommentId={activeSideCommentId} activeCommentId={activeSideCommentId}
collapsed={commentPortalHost ? false : commentSidePanelCollapsed} collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
onCollapsedChange={setCommentSidePanelCollapsed} onCollapsedChange={setCommentSidePanelCollapsed}
onClose={() => {
setCommentPanelOpen(false);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(false);
setBoardMode(false);
clearBoardComposer();
}}
onToggleSelect={(commentId) => { onToggleSelect={(commentId) => {
setSelectedSideCommentIds((current) => { setSelectedSideCommentIds((current) => {
const next = new Set(current); const next = new Set(current);
@ -7102,201 +7266,258 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
<div className="viewer-empty">{t('fileViewer.loading')}</div> <div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'preview' ? ( ) : mode === 'preview' ? (
<div <div
className={manualEditMode className={`${manualEditMode ? 'manual-edit-workspace' : commentPreviewLayoutClass} preview-viewport preview-viewport-${previewViewport}`}
? `manual-edit-workspace preview-viewport preview-viewport-${previewViewport}` data-testid={manualEditMode ? undefined : 'comment-preview-layout'}
: [ style={previewViewportStyle(previewViewport, previewScale, boardPreviewCanvasSize, boardPreviewScaleOptions)}
'comment-preview-layer',
`preview-viewport preview-viewport-${previewViewport}`,
].filter(Boolean).join(' ')}
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
> >
{manualEditPanel} {manualEditPanel}
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}> <div
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-preview-canvas'}
style={ data-testid={manualEditMode ? undefined : 'comment-preview-canvas'}
manualEditMode >
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth) <div className={manualEditMode ? undefined : 'comment-frame-clip'}>
: previewScaleShellStyle(previewViewport, previewScale) <div
} style={
> manualEditMode
<PreviewDrawOverlay ? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
active={drawOverlayOpen} : previewScaleShellStyle(previewViewport, previewScale)
onActiveChange={setDrawOverlayOpen} }
captureTarget={null}
filePath={file.name}
sendDisabled={streaming}
sendDisabledReason={t('chat.annotationSendDisabledReason')}
> >
<div className="artifact-preview-transport-stack"> <PreviewDrawOverlay
{OD_PREVIEW_KEEP_ALIVE ? ( active={drawOverlayOpen}
<PooledIframe onActiveChange={setDrawOverlayOpen}
ref={urlPreviewIframeRef} captureTarget={null}
cacheKey={urlPreviewKeepAliveKey} filePath={file.name}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'} sendDisabled={streaming}
data-od-render-mode="url-load" sendDisabledReason={t('chat.annotationSendDisabledReason')}
data-od-active={useUrlLoadPreview ? 'true' : 'false'} >
aria-hidden={useUrlLoadPreview ? undefined : true} <div className="artifact-preview-transport-stack">
tabIndex={useUrlLoadPreview ? 0 : -1} {OD_PREVIEW_KEEP_ALIVE ? (
title={file.name} <PooledIframe
sandbox="allow-scripts allow-downloads" ref={urlPreviewIframeRef}
src={urlTransportSrc} cacheKey={urlPreviewKeepAliveKey}
onLoad={() => { data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
const frame = urlPreviewIframeRef.current; data-od-render-mode="url-load"
if (useUrlLoadPreview) iframeRef.current = frame; data-od-active={useUrlLoadPreview ? 'true' : 'false'}
dcViewportRestoreAtRef.current = Date.now(); aria-hidden={useUrlLoadPreview ? undefined : true}
frame?.contentWindow?.postMessage({ tabIndex={useUrlLoadPreview ? 0 : -1}
type: '__dc_set_viewport', title={file.name}
...dcViewportRef.current, sandbox="allow-scripts allow-downloads"
}, '*'); src={urlTransportSrc}
syncBridgeModes(frame); onLoad={() => {
if (useUrlLoadPreview) restorePreviewScrollPosition(); 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 <iframe
ref={urlPreviewIframeRef} key={srcDocTransportResetKey}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'} ref={srcDocPreviewIframeRef}
data-od-render-mode="url-load" data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
data-od-active={useUrlLoadPreview ? 'true' : 'false'} data-od-render-mode="srcdoc"
aria-hidden={useUrlLoadPreview ? undefined : true} data-od-active={useUrlLoadPreview ? 'false' : 'true'}
tabIndex={useUrlLoadPreview ? 0 : -1} aria-hidden={useUrlLoadPreview ? true : undefined}
tabIndex={useUrlLoadPreview ? -1 : 0}
title={file.name} title={file.name}
sandbox="allow-scripts allow-downloads" sandbox="allow-scripts allow-downloads"
src={urlTransportSrc} srcDoc={srcDocTransportContent}
onLoad={() => { onLoad={() => {
const frame = urlPreviewIframeRef.current; const frame = srcDocPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame; 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(); dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({ frame?.contentWindow?.postMessage({
type: '__dc_set_viewport', type: '__dc_set_viewport',
...dcViewportRef.current, ...dcViewportRef.current,
}, '*'); }, '*');
replayInspectOverridesToIframe(frame);
syncBridgeModes(frame); syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition(); if (!useUrlLoadPreview) restorePreviewScrollPosition();
}} }}
/> />
)} </div>
<iframe </PreviewDrawOverlay>
key={srcDocTransportResetKey} </div>
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> </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> </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 {commentPortalHost && commentSidePanel
? createPortal(commentSidePanel, commentPortalHost) ? createPortal(commentSidePanel, commentPortalHost)
: commentPortalId : commentPortalId
@ -7339,64 +7560,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
error={inspectError} error={inspectError}
/> />
) : null} ) : 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> </div>
) : ( ) : (
<pre className="viewer-source">{source}</pre> <pre className="viewer-source">{source}</pre>

View file

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

View file

@ -566,6 +566,9 @@ export const ar: Dict = {
'newproj.fileSingular': 'ملف', 'newproj.fileSingular': 'ملف',
'newproj.filePlural': 'ملفات', 'newproj.filePlural': 'ملفات',
'newproj.create': 'إنشاء', 'newproj.create': 'إنشاء',
'newproj.locationLabel': 'حفظ في',
'newproj.locationDefault': 'مشاريع Open Design',
'newproj.locationExternalBase': 'قاعدة خارجية',
'newproj.createFromTemplate': 'إنشاء من قالب', 'newproj.createFromTemplate': 'إنشاء من قالب',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
'احفظ مشروعاً كقالب أولاً (قائمة المشاركة داخل أي مشروع).', 'احفظ مشروعاً كقالب أولاً (قائمة المشاركة داخل أي مشروع).',
@ -1552,6 +1555,20 @@ export const ar: Dict = {
'settings.designSystemsCategory': 'الفئة', 'settings.designSystemsCategory': 'الفئة',
'settings.designSystemsAllCategories': 'كل الفئات', 'settings.designSystemsAllCategories': 'كل الفئات',
'settings.designSystemsShowInHomeGallery': 'إظهار في معرض الصفحة الرئيسية', '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.librarySkills': 'المهارات',
'settings.libraryDesignSystems': 'أنظمة التصميم', 'settings.libraryDesignSystems': 'أنظمة التصميم',
'settings.librarySearch': 'بحث...', 'settings.librarySearch': 'بحث...',

View file

@ -463,6 +463,9 @@ export const de: Dict = {
'newproj.fileSingular': 'Datei', 'newproj.fileSingular': 'Datei',
'newproj.filePlural': 'Dateien', 'newproj.filePlural': 'Dateien',
'newproj.create': 'Erstellen', 'newproj.create': 'Erstellen',
'newproj.locationLabel': 'Speichern unter',
'newproj.locationDefault': 'Open Design-Projekte',
'newproj.locationExternalBase': 'Externe Basis',
'newproj.createFromTemplate': 'Aus Template erstellen', 'newproj.createFromTemplate': 'Aus Template erstellen',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
'Speichern Sie zuerst ein Projekt als Template (Teilen-Menü in einem beliebigen Projekt).', '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.designSystemsCategory': 'Kategorie',
'settings.designSystemsAllCategories': 'Alle Kategorien', 'settings.designSystemsAllCategories': 'Alle Kategorien',
'settings.designSystemsShowInHomeGallery': 'In Home-Galerie anzeigen', '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.librarySkills': 'Fähigkeiten',
'settings.libraryDesignSystems': 'Designsysteme', 'settings.libraryDesignSystems': 'Designsysteme',
'settings.librarySearch': 'Suchen...', 'settings.librarySearch': 'Suchen...',

View file

@ -1157,6 +1157,9 @@ export const en: Dict = {
'newproj.fileSingular': 'file', 'newproj.fileSingular': 'file',
'newproj.filePlural': 'files', 'newproj.filePlural': 'files',
'newproj.create': 'Create', 'newproj.create': 'Create',
'newproj.locationLabel': 'Save to',
'newproj.locationDefault': 'Open Design projects',
'newproj.locationExternalBase': 'External base',
'newproj.createLiveArtifact': 'Create live artifact', 'newproj.createLiveArtifact': 'Create live artifact',
'newproj.createFromTemplate': 'Create from template', 'newproj.createFromTemplate': 'Create from template',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
@ -2349,6 +2352,20 @@ export const en: Dict = {
'settings.designSystemsCategory': 'Category', 'settings.designSystemsCategory': 'Category',
'settings.designSystemsAllCategories': 'All categories', 'settings.designSystemsAllCategories': 'All categories',
'settings.designSystemsShowInHomeGallery': 'Show in home gallery', '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.librarySkills': 'Skills',
'settings.libraryDesignSystems': 'Design Systems', 'settings.libraryDesignSystems': 'Design Systems',
'settings.librarySearch': 'Search...', 'settings.librarySearch': 'Search...',

View file

@ -464,6 +464,9 @@ export const esES: Dict = {
'newproj.fileSingular': 'archivo', 'newproj.fileSingular': 'archivo',
'newproj.filePlural': 'archivos', 'newproj.filePlural': 'archivos',
'newproj.create': 'Crear', 'newproj.create': 'Crear',
'newproj.locationLabel': 'Guardar en',
'newproj.locationDefault': 'Proyectos de Open Design',
'newproj.locationExternalBase': 'Base externa',
'newproj.createFromTemplate': 'Crear desde plantilla', 'newproj.createFromTemplate': 'Crear desde plantilla',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
'Guarda primero un proyecto como plantilla (menú Compartir dentro de cualquier proyecto).', '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.designSystemsCategory': 'Categoría',
'settings.designSystemsAllCategories': 'Todas las categorías', 'settings.designSystemsAllCategories': 'Todas las categorías',
'settings.designSystemsShowInHomeGallery': 'Mostrar en la galería de inicio', '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.librarySkills': 'Habilidades',
'settings.libraryDesignSystems': 'Sistemas de diseño', 'settings.libraryDesignSystems': 'Sistemas de diseño',
'settings.librarySearch': 'Buscar...', 'settings.librarySearch': 'Buscar...',

View file

@ -578,6 +578,9 @@ export const fa: Dict = {
'newproj.fileSingular': 'فایل', 'newproj.fileSingular': 'فایل',
'newproj.filePlural': 'فایل', 'newproj.filePlural': 'فایل',
'newproj.create': 'ایجاد', 'newproj.create': 'ایجاد',
'newproj.locationLabel': 'ذخیره در',
'newproj.locationDefault': 'پروژه‌های Open Design',
'newproj.locationExternalBase': 'پایهٔ خارجی',
'newproj.createLiveArtifact': 'ایجاد مصنوع زنده', 'newproj.createLiveArtifact': 'ایجاد مصنوع زنده',
'newproj.createFromTemplate': 'ایجاد از قالب', 'newproj.createFromTemplate': 'ایجاد از قالب',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
@ -1595,6 +1598,20 @@ export const fa: Dict = {
'settings.designSystemsCategory': 'دسته‌بندی', 'settings.designSystemsCategory': 'دسته‌بندی',
'settings.designSystemsAllCategories': 'همه دسته‌بندی‌ها', 'settings.designSystemsAllCategories': 'همه دسته‌بندی‌ها',
'settings.designSystemsShowInHomeGallery': 'نمایش در گالری خانه', '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.librarySkills': 'مهارت‌ها',
'settings.libraryDesignSystems': 'سیستم‌های طراحی', 'settings.libraryDesignSystems': 'سیستم‌های طراحی',
'settings.librarySearch': 'جستجو...', 'settings.librarySearch': 'جستجو...',

View file

@ -1101,6 +1101,9 @@ export const fr: Dict = {
'newproj.fileSingular': 'fichier', 'newproj.fileSingular': 'fichier',
'newproj.filePlural': 'fichiers', 'newproj.filePlural': 'fichiers',
'newproj.create': 'Créer', '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.createLiveArtifact': 'Créer un artefact dynamique',
'newproj.createFromTemplate': 'Créer depuis le modèle', 'newproj.createFromTemplate': 'Créer depuis le modèle',
'newproj.createDisabledTitle': 'Enregistrez d\'abord un projet comme modèle (menu Partager dans un projet).', '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.designSystemsCategory': 'Catégorie',
'settings.designSystemsAllCategories': 'Toutes les catégories', 'settings.designSystemsAllCategories': 'Toutes les catégories',
'settings.designSystemsShowInHomeGallery': 'Afficher dans la galerie daccueil', '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.librarySkills': 'Compétences',
'settings.libraryDesignSystems': 'Systèmes de design', 'settings.libraryDesignSystems': 'Systèmes de design',
'settings.librarySearch': 'Rechercher...', 'settings.librarySearch': 'Rechercher...',

View file

@ -566,6 +566,9 @@ export const hu: Dict = {
'newproj.fileSingular': 'fájl', 'newproj.fileSingular': 'fájl',
'newproj.filePlural': 'fájl', 'newproj.filePlural': 'fájl',
'newproj.create': 'Létrehozás', '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.createFromTemplate': 'Létrehozás sablonból',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
'Először ments el egy projektet sablonként (bármely projekt Megosztás menüjéből).', '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.designSystemsCategory': 'Kategória',
'settings.designSystemsAllCategories': 'Minden kategória', 'settings.designSystemsAllCategories': 'Minden kategória',
'settings.designSystemsShowInHomeGallery': 'Megjelenítés a kezdő galériában', '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.librarySkills': 'Készségek',
'settings.libraryDesignSystems': 'Tervezőrendszerek', 'settings.libraryDesignSystems': 'Tervezőrendszerek',
'settings.librarySearch': 'Keresés...', 'settings.librarySearch': 'Keresés...',

View file

@ -672,6 +672,9 @@ export const id: Dict = {
'newproj.fileSingular': 'berkas', 'newproj.fileSingular': 'berkas',
'newproj.filePlural': 'berkas', 'newproj.filePlural': 'berkas',
'newproj.create': 'Buat', 'newproj.create': 'Buat',
'newproj.locationLabel': 'Simpan ke',
'newproj.locationDefault': 'Proyek Open Design',
'newproj.locationExternalBase': 'Basis eksternal',
'newproj.createLiveArtifact': 'Buat live artifact', 'newproj.createLiveArtifact': 'Buat live artifact',
'newproj.createFromTemplate': 'Buat dari templat', 'newproj.createFromTemplate': 'Buat dari templat',
'newproj.createDisabledTitle': 'Simpan proyek sebagai templat dulu.', 'newproj.createDisabledTitle': 'Simpan proyek sebagai templat dulu.',
@ -1700,6 +1703,20 @@ export const id: Dict = {
'settings.designSystemsCategory': 'Kategori', 'settings.designSystemsCategory': 'Kategori',
'settings.designSystemsAllCategories': 'Semua kategori', 'settings.designSystemsAllCategories': 'Semua kategori',
'settings.designSystemsShowInHomeGallery': 'Tampilkan di galeri beranda', '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.librarySkills': 'Skill',
'settings.libraryDesignSystems': 'Sistem desain', 'settings.libraryDesignSystems': 'Sistem desain',
'settings.librarySearch': 'Cari...', 'settings.librarySearch': 'Cari...',

View file

@ -539,6 +539,9 @@ export const it: Dict = {
'newproj.fileSingular': 'file', 'newproj.fileSingular': 'file',
'newproj.filePlural': 'file', 'newproj.filePlural': 'file',
'newproj.create': 'Crea', 'newproj.create': 'Crea',
'newproj.locationLabel': 'Salva in',
'newproj.locationDefault': 'Progetti Open Design',
'newproj.locationExternalBase': 'Base esterna',
'newproj.createFromTemplate': 'Crea dal modello', 'newproj.createFromTemplate': 'Crea dal modello',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
'Salva prima un progetto come modello (menu Condividi in un progetto).', 'Salva prima un progetto come modello (menu Condividi in un progetto).',
@ -1432,6 +1435,20 @@ export const it: Dict = {
'settings.designSystemsCategory': 'Categoria', 'settings.designSystemsCategory': 'Categoria',
'settings.designSystemsAllCategories': 'Tutte le categorie', 'settings.designSystemsAllCategories': 'Tutte le categorie',
'settings.designSystemsShowInHomeGallery': 'Mostra nella galleria iniziale', '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.librarySkills': 'Competenze',
'settings.libraryDesignSystems': 'Sistemi di design', 'settings.libraryDesignSystems': 'Sistemi di design',
'settings.librarySearch': 'Cerca...', 'settings.librarySearch': 'Cerca...',

View file

@ -463,6 +463,9 @@ export const ja: Dict = {
'newproj.fileSingular': 'ファイル', 'newproj.fileSingular': 'ファイル',
'newproj.filePlural': 'ファイル', 'newproj.filePlural': 'ファイル',
'newproj.create': '作成', 'newproj.create': '作成',
'newproj.locationLabel': '保存先',
'newproj.locationDefault': 'Open Design プロジェクト',
'newproj.locationExternalBase': '外部ベース',
'newproj.createFromTemplate': 'テンプレートから作成', 'newproj.createFromTemplate': 'テンプレートから作成',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
'最初にプロジェクトをテンプレートとして保存してください(プロジェクト内の共有メニュー)。', '最初にプロジェクトをテンプレートとして保存してください(プロジェクト内の共有メニュー)。',
@ -1489,6 +1492,20 @@ export const ja: Dict = {
'settings.designSystemsCategory': 'カテゴリー', 'settings.designSystemsCategory': 'カテゴリー',
'settings.designSystemsAllCategories': 'すべてのカテゴリー', 'settings.designSystemsAllCategories': 'すべてのカテゴリー',
'settings.designSystemsShowInHomeGallery': 'ホームギャラリーに表示', '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.librarySkills': 'スキル',
'settings.libraryDesignSystems': 'デザインシステム', 'settings.libraryDesignSystems': 'デザインシステム',
'settings.librarySearch': '検索...', 'settings.librarySearch': '検索...',

View file

@ -566,6 +566,9 @@ export const ko: Dict = {
'newproj.fileSingular': '파일', 'newproj.fileSingular': '파일',
'newproj.filePlural': '파일들', 'newproj.filePlural': '파일들',
'newproj.create': '생성', 'newproj.create': '생성',
'newproj.locationLabel': '저장 위치',
'newproj.locationDefault': 'Open Design 프로젝트',
'newproj.locationExternalBase': '외부 베이스',
'newproj.createFromTemplate': '템플릿으로 생성', 'newproj.createFromTemplate': '템플릿으로 생성',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
'먼저 프로젝트를 템플릿으로 저장하세요 (프로젝트 내 공유 메뉴 이용).', '먼저 프로젝트를 템플릿으로 저장하세요 (프로젝트 내 공유 메뉴 이용).',
@ -1602,6 +1605,20 @@ export const ko: Dict = {
'settings.designSystemsCategory': '카테고리', 'settings.designSystemsCategory': '카테고리',
'settings.designSystemsAllCategories': '모든 카테고리', 'settings.designSystemsAllCategories': '모든 카테고리',
'settings.designSystemsShowInHomeGallery': '홈 갤러리에 표시', '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.librarySkills': '스킬',
'settings.libraryDesignSystems': '디자인 시스템', 'settings.libraryDesignSystems': '디자인 시스템',
'settings.librarySearch': '검색...', 'settings.librarySearch': '검색...',

View file

@ -566,6 +566,9 @@ export const pl: Dict = {
'newproj.fileSingular': 'plik', 'newproj.fileSingular': 'plik',
'newproj.filePlural': 'pliki', 'newproj.filePlural': 'pliki',
'newproj.create': 'Utwórz', '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.createFromTemplate': 'Utwórz z szablonu',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
'Najpierw zapisz projekt jako szablon (menu Udostępnij wewnątrz projektu).', 'Najpierw zapisz projekt jako szablon (menu Udostępnij wewnątrz projektu).',
@ -1552,6 +1555,20 @@ export const pl: Dict = {
'settings.designSystemsCategory': 'Kategoria', 'settings.designSystemsCategory': 'Kategoria',
'settings.designSystemsAllCategories': 'Wszystkie kategorie', 'settings.designSystemsAllCategories': 'Wszystkie kategorie',
'settings.designSystemsShowInHomeGallery': 'Pokaż w galerii głównej', '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.librarySkills': 'Umiejętności',
'settings.libraryDesignSystems': 'Systemy projektowe', 'settings.libraryDesignSystems': 'Systemy projektowe',
'settings.librarySearch': 'Szukaj...', 'settings.librarySearch': 'Szukaj...',

View file

@ -576,6 +576,9 @@ export const ptBR: Dict = {
'newproj.fileSingular': 'arquivo', 'newproj.fileSingular': 'arquivo',
'newproj.filePlural': 'arquivos', 'newproj.filePlural': 'arquivos',
'newproj.create': 'Criar', 'newproj.create': 'Criar',
'newproj.locationLabel': 'Salvar em',
'newproj.locationDefault': 'Projetos Open Design',
'newproj.locationExternalBase': 'Base externa',
'newproj.createLiveArtifact': 'Criar artefato live', 'newproj.createLiveArtifact': 'Criar artefato live',
'newproj.createFromTemplate': 'Criar a partir do template', 'newproj.createFromTemplate': 'Criar a partir do template',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
@ -1593,6 +1596,20 @@ export const ptBR: Dict = {
'settings.designSystemsCategory': 'Categoria', 'settings.designSystemsCategory': 'Categoria',
'settings.designSystemsAllCategories': 'Todas as categorias', 'settings.designSystemsAllCategories': 'Todas as categorias',
'settings.designSystemsShowInHomeGallery': 'Mostrar na galeria inicial', '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.librarySkills': 'Habilidades',
'settings.libraryDesignSystems': 'Sistemas de design', 'settings.libraryDesignSystems': 'Sistemas de design',
'settings.librarySearch': 'Pesquisar...', 'settings.librarySearch': 'Pesquisar...',

View file

@ -576,6 +576,9 @@ export const ru: Dict = {
'newproj.fileSingular': 'файл', 'newproj.fileSingular': 'файл',
'newproj.filePlural': 'файлов', 'newproj.filePlural': 'файлов',
'newproj.create': 'Создать', 'newproj.create': 'Создать',
'newproj.locationLabel': 'Сохранить в',
'newproj.locationDefault': 'Проекты Open Design',
'newproj.locationExternalBase': 'Внешняя база',
'newproj.createLiveArtifact': 'Создать live-артефакт', 'newproj.createLiveArtifact': 'Создать live-артефакт',
'newproj.createFromTemplate': 'Создать из шаблона', 'newproj.createFromTemplate': 'Создать из шаблона',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
@ -1593,6 +1596,20 @@ export const ru: Dict = {
'settings.designSystemsCategory': 'Категория', 'settings.designSystemsCategory': 'Категория',
'settings.designSystemsAllCategories': 'Все категории', 'settings.designSystemsAllCategories': 'Все категории',
'settings.designSystemsShowInHomeGallery': 'Показывать в домашней галерее', '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.librarySkills': 'Навыки',
'settings.libraryDesignSystems': 'Системы дизайна', 'settings.libraryDesignSystems': 'Системы дизайна',
'settings.librarySearch': 'Поиск...', 'settings.librarySearch': 'Поиск...',

View file

@ -535,6 +535,9 @@ export const th: Dict = {
'newproj.fileSingular': 'ไฟล์', 'newproj.fileSingular': 'ไฟล์',
'newproj.filePlural': 'ไฟล์', 'newproj.filePlural': 'ไฟล์',
'newproj.create': 'สร้าง', 'newproj.create': 'สร้าง',
'newproj.locationLabel': 'บันทึกไปยัง',
'newproj.locationDefault': 'โปรเจกต์ Open Design',
'newproj.locationExternalBase': 'ฐานภายนอก',
'newproj.createLiveArtifact': 'สร้าง live artifact', 'newproj.createLiveArtifact': 'สร้าง live artifact',
'newproj.createFromTemplate': 'สร้างจากเทมเพลต', 'newproj.createFromTemplate': 'สร้างจากเทมเพลต',
'newproj.createDisabledTitle': 'คุณต้องบันทึกโปรเจกต์เป็นเทมเพลตก่อน', 'newproj.createDisabledTitle': 'คุณต้องบันทึกโปรเจกต์เป็นเทมเพลตก่อน',
@ -1469,6 +1472,20 @@ export const th: Dict = {
'settings.notifySoundBuzz': 'เป็นจังหวะกระตุ้นอารมณ์สั่นเลย', 'settings.notifySoundBuzz': 'เป็นจังหวะกระตุ้นอารมณ์สั่นเลย',
'settings.notifySoundTwoToneDown': 'โทนดังลดถอย 2 จังหวะ', 'settings.notifySoundTwoToneDown': 'โทนดังลดถอย 2 จังหวะ',
'settings.notifySoundThud': 'เสียงหนักเน้นโครมให้ระวัง', '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.librarySkills': 'พวก Skills',
'settings.libraryDesignSystems': 'ตัวของระบบแบบ Design Systems', 'settings.libraryDesignSystems': 'ตัวของระบบแบบ Design Systems',
'settings.librarySearch': 'ต้องการหาสิ่งใด…', 'settings.librarySearch': 'ต้องการหาสิ่งใด…',

View file

@ -556,6 +556,9 @@ export const tr: Dict = {
'newproj.fileSingular': 'dosya', 'newproj.fileSingular': 'dosya',
'newproj.filePlural': 'dosyalar', 'newproj.filePlural': 'dosyalar',
'newproj.create': 'Oluştur', 'newproj.create': 'Oluştur',
'newproj.locationLabel': 'Şuraya kaydet',
'newproj.locationDefault': 'Open Design projeleri',
'newproj.locationExternalBase': 'Harici taban',
'newproj.createFromTemplate': 'Şablondan oluştur', 'newproj.createFromTemplate': 'Şablondan oluştur',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
'Önce bir projeyi şablon olarak kaydedin (herhangi bir projenin içinde Paylaş menüsünden).', 'Ö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.designSystemsCategory': 'Kategori',
'settings.designSystemsAllCategories': 'Tüm kategoriler', 'settings.designSystemsAllCategories': 'Tüm kategoriler',
'settings.designSystemsShowInHomeGallery': 'Ana galeride göster', '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.librarySkills': 'Beceriler',
'settings.libraryDesignSystems': 'Tasarım sistemleri', 'settings.libraryDesignSystems': 'Tasarım sistemleri',
'settings.librarySearch': 'Ara...', 'settings.librarySearch': 'Ara...',

View file

@ -578,6 +578,9 @@ export const uk: Dict = {
'newproj.fileSingular': 'файл', 'newproj.fileSingular': 'файл',
'newproj.filePlural': 'файли', 'newproj.filePlural': 'файли',
'newproj.create': 'Створити', 'newproj.create': 'Створити',
'newproj.locationLabel': 'Зберегти в',
'newproj.locationDefault': 'Проєкти Open Design',
'newproj.locationExternalBase': 'Зовнішня база',
'newproj.createLiveArtifact': 'Створити live-артефакт', 'newproj.createLiveArtifact': 'Створити live-артефакт',
'newproj.createFromTemplate': 'Створити з шаблону', 'newproj.createFromTemplate': 'Створити з шаблону',
'newproj.createDisabledTitle': 'newproj.createDisabledTitle':
@ -1594,6 +1597,20 @@ export const uk: Dict = {
'settings.designSystemsCategory': 'Категорія', 'settings.designSystemsCategory': 'Категорія',
'settings.designSystemsAllCategories': 'Усі категорії', 'settings.designSystemsAllCategories': 'Усі категорії',
'settings.designSystemsShowInHomeGallery': 'Показувати в домашній галереї', '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.librarySkills': 'Навички',
'settings.libraryDesignSystems': 'Системи дизайну', 'settings.libraryDesignSystems': 'Системи дизайну',
'settings.librarySearch': 'Пошук...', 'settings.librarySearch': 'Пошук...',

View file

@ -1152,6 +1152,9 @@ export const zhCN: Dict = {
'newproj.fileSingular': '个文件', 'newproj.fileSingular': '个文件',
'newproj.filePlural': '个文件', 'newproj.filePlural': '个文件',
'newproj.create': '创建', 'newproj.create': '创建',
'newproj.locationLabel': '保存到',
'newproj.locationDefault': 'Open Design 项目',
'newproj.locationExternalBase': '外部基目录',
'newproj.createLiveArtifact': '创建实时制品', 'newproj.createLiveArtifact': '创建实时制品',
'newproj.createFromTemplate': '基于模板创建', 'newproj.createFromTemplate': '基于模板创建',
'newproj.createDisabledTitle': '请先在任意项目内通过「分享」菜单将其保存为模板。', 'newproj.createDisabledTitle': '请先在任意项目内通过「分享」菜单将其保存为模板。',
@ -2299,6 +2302,20 @@ export const zhCN: Dict = {
'settings.designSystemsCategory': '分类', 'settings.designSystemsCategory': '分类',
'settings.designSystemsAllCategories': '所有分类', 'settings.designSystemsAllCategories': '所有分类',
'settings.designSystemsShowInHomeGallery': '在首页 Gallery 中显示', '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.librarySkills': '技能',
'settings.libraryDesignSystems': '设计系统', 'settings.libraryDesignSystems': '设计系统',
'settings.librarySearch': '搜索...', 'settings.librarySearch': '搜索...',

View file

@ -754,6 +754,9 @@ export const zhTW: Dict = {
'newproj.fileSingular': '個檔案', 'newproj.fileSingular': '個檔案',
'newproj.filePlural': '個檔案', 'newproj.filePlural': '個檔案',
'newproj.create': '建立', 'newproj.create': '建立',
'newproj.locationLabel': '儲存到',
'newproj.locationDefault': 'Open Design 專案',
'newproj.locationExternalBase': '外部基目錄',
'newproj.createLiveArtifact': '建立即時成品', 'newproj.createLiveArtifact': '建立即時成品',
'newproj.createFromTemplate': '基於範本建立', 'newproj.createFromTemplate': '基於範本建立',
'newproj.createDisabledTitle': '請先在任意專案內透過「分享」選單將其儲存為範本。', 'newproj.createDisabledTitle': '請先在任意專案內透過「分享」選單將其儲存為範本。',
@ -1851,6 +1854,20 @@ export const zhTW: Dict = {
'settings.designSystemsCategory': '分類', 'settings.designSystemsCategory': '分類',
'settings.designSystemsAllCategories': '所有分類', 'settings.designSystemsAllCategories': '所有分類',
'settings.designSystemsShowInHomeGallery': '在首頁 Gallery 中顯示', '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.librarySkills': '技能',
'settings.libraryDesignSystems': '設計系統', 'settings.libraryDesignSystems': '設計系統',
'settings.librarySearch': '搜尋...', 'settings.librarySearch': '搜尋...',

View file

@ -444,6 +444,20 @@ export interface Dict {
'settings.designSystemsCategory': string; 'settings.designSystemsCategory': string;
'settings.designSystemsAllCategories': string; 'settings.designSystemsAllCategories': string;
'settings.designSystemsShowInHomeGallery': 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.librarySkills': string;
'settings.libraryDesignSystems': string; 'settings.libraryDesignSystems': string;
'settings.librarySearch': string; 'settings.librarySearch': string;
@ -1431,6 +1445,9 @@ export interface Dict {
'newproj.fileSingular': string; 'newproj.fileSingular': string;
'newproj.filePlural': string; 'newproj.filePlural': string;
'newproj.create': string; 'newproj.create': string;
'newproj.locationLabel': string;
'newproj.locationDefault': string;
'newproj.locationExternalBase': string;
'newproj.createLiveArtifact': string; 'newproj.createLiveArtifact': string;
'newproj.createFromTemplate': string; 'newproj.createFromTemplate': string;
'newproj.createDisabledTitle': string; 'newproj.createDisabledTitle': string;

View file

@ -161,10 +161,11 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [
{ {
id: 'fal', id: 'fal',
label: 'Fal.ai', label: 'Fal.ai',
hint: 'Sora / Seedance / Veo / FLUX', hint: 'FLUX / Sora / Veo / Wan / Ideogram / Recraft and any fal-ai/* model',
integrated: false, integrated: true,
defaultBaseUrl: 'https://fal.run', defaultBaseUrl: 'https://fal.run',
docsUrl: 'https://fal.ai/dashboard/keys', docsUrl: 'https://fal.ai/dashboard/keys',
supportsCustomModel: true,
}, },
{ {
id: 'leonardo', id: 'leonardo',
@ -438,9 +439,16 @@ export const IMAGE_MODELS: MediaModel[] = [
{ id: 'imagen-3', label: 'imagen-3', hint: 'Google', provider: 'google', caps: ['t2i'] }, { 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'] }, { 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: '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: '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'] }, { id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
// Leonardo.ai models // 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-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: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
// OpenAI Sora (via Fal hosting today). // Fal.ai video models — pass any fal-ai/* path as model for custom models.
{ id: 'sora-2', label: 'sora-2', 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: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] }, { 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. // MiniMax video.
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] }, { 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, pet: DEFAULT_PET,
notifications: DEFAULT_NOTIFICATIONS, notifications: DEFAULT_NOTIFICATIONS,
orbit: DEFAULT_ORBIT, orbit: DEFAULT_ORBIT,
projectLocations: [],
defaultProjectLocationId: 'default',
// Telemetry defaults to ON so fresh-install users emit onboarding / // Telemetry defaults to ON so fresh-install users emit onboarding /
// ui_click events from the first frame. The disclosure modal still // ui_click events from the first frame. The disclosure modal still
// appears after `onboardingCompleted` flips, and Settings → Privacy // appears after `onboardingCompleted` flips, and Settings → Privacy
@ -688,6 +690,12 @@ export function mergeDaemonConfig(
if (daemonConfig.customInstructions !== undefined) { if (daemonConfig.customInstructions !== undefined) {
next.customInstructions = 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; return next;
} }
@ -802,6 +810,8 @@ export async function syncConfigToDaemon(
telemetry: config.telemetry, telemetry: config.telemetry,
privacyDecisionAt: config.privacyDecisionAt, privacyDecisionAt: config.privacyDecisionAt,
customInstructions: config.customInstructions ?? null, customInstructions: config.customInstructions ?? null,
projectLocations: config.projectLocations ?? [],
defaultProjectLocationId: config.defaultProjectLocationId ?? 'default',
}; };
try { try {
const response = await fetch('/api/app-config', { const response = await fetch('/api/app-config', {

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