Merge origin/main into release/v0.8.0

Conflicts resolved by taking origin/main on both files. Root cause:
main's PR #2460 (fix(landing): align logo.webp with brand icon) changed
HomeHero.tsx's .home-hero__brand-mark to render <img src=/app-icon.svg>
instead of an inlined <HeroBrandIcon /> SVG, and bundled the matching
CSS (26px round badge with bg-panel + border + padding 2px) plus a
gap/font-size tune. The release-side visual-refresh CSS still targeted
the SVG layout (38px square, transparent, inset SVG selector). Keeping
release's CSS would leave main's <img> unstyled.

- apps/web/src/styles/home/home-hero.css  three blocks, all taken from
  main: .home-hero__brand gap 8px, .home-hero__brand-mark redesigned for
  <img> child, .home-hero__brand-name font-size 16px.
- apps/web/src/index.css  two blocks, both taken from main: workspace
  tab close column 22px and .workspace-tab__close 18x18 (paired
  tune-down of tab UI spacing).
This commit is contained in:
lefarcen 2026-05-20 22:28:38 +08:00
commit 722ddfa235
66 changed files with 4552 additions and 1001 deletions

2
.gitignore vendored
View file

@ -37,7 +37,7 @@ apps/web/playwright/
tsconfig.tsbuildinfo
.claude-sessions/*
**/.claude-sessions/*
.cursor/
.agents/

View file

@ -147,7 +147,7 @@ const LIBRARY_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
const PROJECT_STRING_FLAGS = new Set([
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
'pending-prompt', 'project', 'conversation', 'message', 'path', 'as',
'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps',
'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps', 'editor',
]);
const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']);
// `od automation …` mirrors the Automations tab. Same surface, same
@ -3685,6 +3685,11 @@ async function runProject(args) {
od project list List projects.
od project info <id> Print one project.
od project delete <id> Delete a project.
od project editors List locally-installed editors that
can open a project (hand-off targets).
od project open-in <id> --editor <slug> Open the project's working directory
in the chosen editor (cursor, zed,
vscode, finder, terminal, ).
od project handoff <id> --conversation <id> --api-key <key> --model <model>
[--base-url <url>] [--max-tokens <n>]
Synthesize a resume-conversation handoff prompt.
@ -3794,6 +3799,44 @@ Common options:
console.log(`[project] deleted ${id}`);
return;
}
case 'editors': {
const resp = await fetch(`${base}/api/editors`);
if (!resp.ok) return structuredHttpFailure(resp);
const data = await resp.json();
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
const editors = data?.editors ?? [];
for (const ed of editors) {
const status = ed.available ? 'available' : 'missing';
console.log(`${ed.id}\t${ed.label}\t${status}`);
}
return;
}
case 'open-in': {
const id = rest.find((a) => !a.startsWith('-'));
if (!id) {
console.error('Usage: od project open-in <id> --editor <slug>');
process.exit(2);
}
const editor = typeof flags.editor === 'string' ? flags.editor : '';
if (!editor) {
console.error('--editor <slug> is required. Run `od project editors` to list options.');
process.exit(2);
}
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/open-in`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ editorId: editor }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
if (flags.json) process.stdout.write(JSON.stringify(data, null, 2) + '\n');
else console.error(`POST /api/projects/${id}/open-in failed: ${resp.status} ${JSON.stringify(data)}`);
process.exit(1);
}
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
console.log(`[project] opened ${id} in ${editor} (${data.path ?? ''})`);
return;
}
default:
console.error(`unknown subcommand: od project ${sub}`);
process.exit(2);

View file

@ -0,0 +1,243 @@
// Hand-off surface — paseo-style "open project in <local app>".
//
// The daemon owns the editor catalogue, probes each entry's CLI shim on
// $PATH at request time, and on POST spawns the chosen app with the
// project's resolvedDir as its single argument. This is the same shape
// paseo uses (see getpaseo/paseo packages/server/src/server/editor-
// targets.ts) — declarative catalogue + `which` probe + detached spawn.
//
// Why not `shell.openPath`? The desktop bridge can already open the OS
// file manager at a project's resolvedDir, but it cannot pick a specific
// editor — `shell.openPath` is whatever the OS associates with the path.
// For "open in Cursor specifically" we have to invoke a CLI shim
// directly, which means the daemon (not the renderer) is the layer with
// access to spawn + $PATH probing.
import { spawn } from 'node:child_process';
import { access, constants as fsConstants } from 'node:fs/promises';
import type { Express } from 'express';
import type {
HostEditor,
HostEditorId,
HostEditorsResponse,
OpenProjectInEditorResponse,
} from '@open-design/contracts';
import type { RouteDeps } from './server-context.js';
export interface RegisterHostToolsRoutesDeps
extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'projectFiles'> {}
type RealPlatform = 'darwin' | 'win32' | 'linux';
type Platform = RealPlatform | 'unknown';
interface CatalogueEntry {
id: HostEditorId;
label: string;
icon: string;
// CLI shim name to probe on $PATH. Mutually exclusive with `macOpenBundle`.
command?: string;
// macOS-only fallback: when the CLI shim is missing, look for an app
// bundle by name and launch it via `open -a "<name>"`. Lets us list
// Xcode / Qoder / Antigravity / Warp / IntelliJ without forcing users
// to also install their CLI shim.
macOpenBundle?: string;
platforms?: RealPlatform[];
excludedPlatforms?: RealPlatform[];
}
// The catalogue covers the apps shown in the user's reference screenshot
// (image 4): Qoder, Cursor, Zed, Windsurf, Antigravity, Finder, Terminal,
// Warp, Xcode, IntelliJ IDEA — plus a few cross-platform staples.
const CATALOGUE: ReadonlyArray<CatalogueEntry> = [
{ id: 'cursor', label: 'Cursor', icon: 'sparkles', command: 'cursor', macOpenBundle: 'Cursor' },
{ id: 'vscode', label: 'VS Code', icon: 'file-code', command: 'code', macOpenBundle: 'Visual Studio Code' },
{ id: 'windsurf', label: 'Windsurf', icon: 'sparkles', command: 'windsurf', macOpenBundle: 'Windsurf' },
{ id: 'zed', label: 'Zed', icon: 'edit', command: 'zed', macOpenBundle: 'Zed' },
{ id: 'qoder', label: 'Qoder', icon: 'sparkles', command: 'qoder', macOpenBundle: 'Qoder' },
{ id: 'antigravity', label: 'Antigravity', icon: 'orbit', command: 'antigravity', macOpenBundle: 'Antigravity' },
{ id: 'webstorm', label: 'WebStorm', icon: 'edit', command: 'webstorm', macOpenBundle: 'WebStorm' },
{ id: 'idea', label: 'IntelliJ IDEA', icon: 'edit', command: 'idea', macOpenBundle: 'IntelliJ IDEA' },
{ id: 'xcode', label: 'Xcode', icon: 'file-code', command: 'xed', macOpenBundle: 'Xcode', platforms: ['darwin'] },
{ id: 'finder', label: 'Finder', icon: 'folder', command: 'open', platforms: ['darwin'] },
{ id: 'explorer', label: 'Explorer', icon: 'folder', command: 'explorer', platforms: ['win32'] },
{ id: 'file-manager', label: 'File Manager', icon: 'folder', command: 'xdg-open', platforms: ['linux'] },
{ id: 'terminal', label: 'Terminal', icon: 'sliders', macOpenBundle: 'Terminal', platforms: ['darwin'] },
{ id: 'warp', label: 'Warp', icon: 'sliders', command: 'warp-cli', macOpenBundle: 'Warp' },
];
function currentPlatform(): Platform {
switch (process.platform) {
case 'darwin':
return 'darwin';
case 'win32':
return 'win32';
case 'linux':
return 'linux';
default:
return 'unknown';
}
}
function pathDirs(): string[] {
const raw = process.env.PATH ?? '';
const sep = process.platform === 'win32' ? ';' : ':';
// macOS GUI apps inherit a very thin PATH (no /usr/local/bin, no
// /opt/homebrew/bin), so add the common locations the user's shell
// would have on first login. Without this, Cursor / Zed / VS Code
// shims installed via "Install '...' command" are invisible to the
// daemon launched by `open Open Design.app`.
const extras = process.platform === 'darwin'
? ['/usr/local/bin', '/opt/homebrew/bin', `${process.env.HOME ?? ''}/.local/bin`]
: process.platform === 'linux'
? ['/usr/local/bin', `${process.env.HOME ?? ''}/.local/bin`]
: [];
return [...raw.split(sep), ...extras].filter(Boolean);
}
async function probeCommandOnPath(command: string): Promise<string | null> {
const dirs = pathDirs();
const suffixes = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
for (const dir of dirs) {
for (const suffix of suffixes) {
const candidate = `${dir}/${command}${suffix}`;
try {
await access(candidate, fsConstants.X_OK);
return candidate;
} catch {
// not here
}
}
}
return null;
}
async function probeMacBundle(name: string): Promise<string | null> {
if (process.platform !== 'darwin') return null;
const candidates = [
`/Applications/${name}.app`,
`${process.env.HOME ?? ''}/Applications/${name}.app`,
];
for (const path of candidates) {
try {
await access(path, fsConstants.R_OK);
return path;
} catch {
// not here
}
}
return null;
}
async function resolveEntry(entry: CatalogueEntry): Promise<{
available: boolean;
resolvedPath?: string;
launch?: { command: string; args: string[] };
}> {
if (entry.command) {
const resolved = await probeCommandOnPath(entry.command);
if (resolved) {
return { available: true, resolvedPath: resolved, launch: { command: resolved, args: [] } };
}
}
if (entry.macOpenBundle && process.platform === 'darwin') {
const bundle = await probeMacBundle(entry.macOpenBundle);
if (bundle) {
return {
available: true,
resolvedPath: bundle,
launch: { command: 'open', args: ['-a', entry.macOpenBundle] },
};
}
}
return { available: false };
}
function applicableForPlatform(entry: CatalogueEntry, platform: Platform): boolean {
if (platform === 'unknown') return false;
if (entry.platforms && !entry.platforms.includes(platform)) return false;
if (entry.excludedPlatforms && entry.excludedPlatforms.includes(platform)) return false;
return true;
}
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
const { db } = ctx;
const { sendApiError } = ctx.http;
const { PROJECTS_DIR } = ctx.paths;
const { getProject } = ctx.projectStore;
const { resolveProjectDir } = ctx.projectFiles;
app.get('/api/editors', async (_req, res) => {
try {
const platform = currentPlatform();
const filtered = CATALOGUE.filter((entry) => applicableForPlatform(entry, platform));
const editors: HostEditor[] = await Promise.all(
filtered.map(async (entry) => {
const probe = await resolveEntry(entry);
return {
id: entry.id,
label: entry.label,
icon: entry.icon,
available: probe.available,
...(probe.resolvedPath ? { resolvedPath: probe.resolvedPath } : {}),
...(entry.platforms ? { platforms: entry.platforms } : {}),
};
}),
);
const body: HostEditorsResponse = { editors, platform };
res.json(body);
} catch (err) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
app.post('/api/projects/:id/open-in', async (req, res) => {
try {
const projectId = req.params.id;
const editorId = (req.body?.editorId ?? '') as HostEditorId;
if (!editorId) {
return sendApiError(res, 400, 'BAD_REQUEST', 'editorId is required');
}
const entry = CATALOGUE.find((c) => c.id === editorId);
if (!entry) {
return sendApiError(res, 400, 'BAD_REQUEST', `unknown editor: ${editorId}`);
}
const platform = currentPlatform();
if (!applicableForPlatform(entry, platform)) {
return sendApiError(res, 400, 'BAD_REQUEST', `${entry.label} is not available on ${platform}`);
}
const project = getProject(db, projectId);
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
const probe = await resolveEntry(entry);
if (!probe.available || !probe.launch) {
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);
}
// Detached spawn so the daemon doesn't keep the child alive; same
// shape paseo uses. We append the project's resolved directory as
// the last positional argument — every entry in the catalogue
// accepts "<exe> <dir>" semantics (open -a Foo /path, cursor
// /path, code /path, explorer C:\path, xdg-open /path).
const child = spawn(probe.launch.command, [...probe.launch.args, resolvedDir], {
detached: true,
stdio: 'ignore',
shell: process.platform === 'win32',
});
child.on('error', () => {
// Swallow — best-effort; the client will see ok:true but the OS
// might still have refused (e.g. quarantine). Real diagnostic
// path is `od project open-in --debug`.
});
child.unref();
const body: OpenProjectInEditorResponse = {
ok: true,
editorId,
path: resolvedDir,
};
res.json(body);
} catch (err) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
}

View file

@ -1,6 +1,6 @@
import type { Express } from 'express';
import {
defaultScenarioPluginIdForKind,
defaultScenarioPluginIdForProjectMetadata,
type PluginManifest,
} from '@open-design/contracts';
import { createProjectArtifactFile } from './artifact-create.js';
@ -227,9 +227,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
let resolveBody =
explicitPlugin ? (req.body as Record<string, unknown>) : null;
if (!resolveBody) {
const fallbackPluginId = defaultScenarioPluginIdForKind(
projectMetadata?.kind,
);
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectMetadata);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
resolveBody = { ...(req.body || {}), pluginId: fallbackPluginId };
}

View file

@ -0,0 +1,49 @@
// Daemon-side helpers that turn a `runs.statusBody(run)` value into the
// `result` + `error_code` shape that v2 `run_finished` analytics events
// expect.
//
// Extracted from `server.ts` so the invariant — `result === 'failed'`
// MUST emit a non-empty `error_code` — can be exercised by unit tests
// without spinning up the full Express app. Several failure paths in the
// child-process lifecycle call `finish('failed', ...)` directly without
// first emitting an SSE `error` event (ACP fatal, agentStreamError fall
// through, child error with no diagnostic, etc.), leaving `run.errorCode
// === null` in the status body. The fallback chain below derives an
// `AGENT_SIGNAL_*` / `AGENT_EXIT_*` / `AGENT_TERMINATED_UNKNOWN` value
// for those cases so the wire emission always carries an `error_code`
// when result=failed; dashboards keyed on it never see a blank cell.
export type RunResult = 'success' | 'failed' | 'cancelled';
export interface RunStatusForAnalytics {
status: string;
errorCode?: string | null;
exitCode?: number | null;
signal?: string | null;
}
export function runResultFromStatus(status: string | undefined): RunResult {
if (status === 'succeeded') return 'success';
if (status === 'canceled') return 'cancelled';
return 'failed';
}
export function deriveRunErrorCode(
status: RunStatusForAnalytics,
): string | undefined {
const result = runResultFromStatus(status.status);
if (result === 'success') return undefined;
// Cancellation usually carries no error; only forward an explicit one
// when the daemon stamped it (e.g. cancel during error recovery).
if (result === 'cancelled') return status.errorCode ?? undefined;
// Failure path: prefer the structured code stamped on the run via
// `extractErrorDetails`. When the run reached `failed` without going
// through `emit('error', ...)`, derive the best signal we have.
const explicit = status.errorCode;
if (explicit) return explicit;
if (status.signal) return `AGENT_SIGNAL_${status.signal}`;
if (typeof status.exitCode === 'number' && status.exitCode !== 0) {
return `AGENT_EXIT_${status.exitCode}`;
}
return 'AGENT_TERMINATED_UNKNOWN';
}

View file

@ -12,7 +12,7 @@ import fs from 'node:fs';
import os from 'node:os';
import net from 'node:net';
import {
defaultScenarioPluginIdForKind,
defaultScenarioPluginIdForProjectMetadata,
PLUGIN_SHARE_ACTION_PLUGIN_IDS,
} from '@open-design/contracts';
import {
@ -183,6 +183,7 @@ import { subscribe as subscribeFileEvents } from './project-watchers.js';
import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { createChatRunService } from './runs.js';
import { deriveRunErrorCode, runResultFromStatus } from './run-result.js';
import { reportRunCompletedFromDaemon } from './langfuse-bridge.js';
import {
createAnalyticsService,
@ -359,6 +360,7 @@ import { LiveArtifactRefreshUnavailableError, refreshLiveArtifact } from './live
import { LiveArtifactRefreshAbortError } from './live-artifacts/refresh.js';
import { registerConnectorRoutes } from './connectors/routes.js';
import { registerActiveContextRoutes } from './active-context-routes.js';
import { registerHostToolsRoutes } from './host-tools-routes.js';
import { registerMcpRoutes } from './mcp-routes.js';
import { registerXaiRoutes } from './xai-routes.js';
import { registerLiveArtifactRoutes } from './live-artifact-routes.js';
@ -4415,6 +4417,13 @@ export async function startServer({
http: httpDeps,
projectStore: projectStoreDeps,
});
registerHostToolsRoutes(app, {
db,
http: httpDeps,
paths: pathDeps,
projectStore: projectStoreDeps,
projectFiles: projectFileDeps,
});
registerProjectRoutes(app, {
db,
design,
@ -10571,11 +10580,11 @@ export async function startServer({
//
// Stage A of plugin-driven-flow-plan: when neither the body nor the
// project carries plugin info we fall back to the bundled scenario
// plugin for the project's `metadata.kind` so direct callers (CLI /
// SDK / agent-headless runs) get the same auto-binding the web
// create flow already produces. The fallback is silent — a bundled
// scenario that is not installed leaves the run plugin-less, which
// matches the legacy path.
// plugin for the project's metadata kind/intent so direct callers
// (CLI / SDK / agent-headless runs) get the same auto-binding the
// web create flow already produces. The fallback is silent — a
// bundled scenario that is not installed leaves the run plugin-less,
// which matches the legacy path.
let resolvedSnapshot = null;
if (typeof req.body?.projectId === 'string' && req.body.projectId) {
let registryView;
@ -10593,9 +10602,7 @@ export async function startServer({
typeof projectRow?.appliedPluginSnapshotId === 'string'
&& projectRow.appliedPluginSnapshotId.length > 0;
if (!hasPin) {
const fallbackPluginId = defaultScenarioPluginIdForKind(
projectRow?.metadata?.kind,
);
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
runResolveBody = { ...req.body, pluginId: fallbackPluginId };
}
@ -10809,26 +10816,13 @@ export async function startServer({
exitCode?: number | null;
signal?: string | null;
}) => {
const result =
status.status === 'succeeded'
? 'success'
: status.status === 'canceled'
? 'cancelled'
: 'failed';
let errorCode: string | undefined;
if (result === 'failed') {
errorCode = status.errorCode ?? undefined;
if (!errorCode) {
if (status.signal) errorCode = `AGENT_SIGNAL_${status.signal}`;
else if (typeof status.exitCode === 'number' && status.exitCode !== 0) {
errorCode = `AGENT_EXIT_${status.exitCode}`;
} else {
errorCode = 'AGENT_TERMINATED_UNKNOWN';
}
}
} else if (result === 'cancelled') {
errorCode = status.errorCode ?? undefined;
}
// `deriveRunErrorCode` is the invariant: when `result === 'failed'`
// it always returns a non-empty string so dashboards keyed on
// `error_code` never see a blank cell. Live in `run-result.ts`
// with unit coverage for the fall-through cases (ACP fatal,
// child close without error event, etc.).
const result = runResultFromStatus(status.status);
const errorCode = deriveRunErrorCode(status);
let inputTokens: number | undefined;
let outputTokens: number | undefined;
for (let i = run.events.length - 1; i >= 0; i -= 1) {

View file

@ -0,0 +1,142 @@
// Unit coverage for `deriveRunErrorCode`. The v2 analytics doc requires
// `run_finished` events with `result === 'failed'` to carry a non-empty
// `error_code` so dashboards keyed on it can break failures down by
// reason. Several daemon failure paths reach `runs.finish('failed',
// ...)` WITHOUT first emitting an SSE `error` event (ACP fatal,
// agentStreamError fall-through, child close with no diagnostic). When
// they do, the run status body's `errorCode` is `null`. The fallback
// chain in `run-result.ts` turns those signal/exitCode hints into a
// best-effort code so the wire emission never blanks out.
//
// This pins each failure shape's expected output. A future refactor
// that loses the fallback re-introduces the original symptom: PostHog
// shows `result=failed` events with no `error_code`.
import { describe, expect, it } from 'vitest';
import {
deriveRunErrorCode,
runResultFromStatus,
} from '../src/run-result.js';
describe('runResultFromStatus', () => {
it('maps succeeded -> success', () => {
expect(runResultFromStatus('succeeded')).toBe('success');
});
it('maps canceled -> cancelled (with the analytics doc spelling)', () => {
expect(runResultFromStatus('canceled')).toBe('cancelled');
});
it('maps failed -> failed', () => {
expect(runResultFromStatus('failed')).toBe('failed');
});
it('maps unknown / partial states to failed (defensive)', () => {
expect(runResultFromStatus('running')).toBe('failed');
expect(runResultFromStatus(undefined)).toBe('failed');
});
});
describe('deriveRunErrorCode', () => {
it('returns undefined for a successful run', () => {
expect(
deriveRunErrorCode({
status: 'succeeded',
errorCode: null,
exitCode: 0,
signal: null,
}),
).toBeUndefined();
});
it('forwards an explicit errorCode set via runs.emit(error, …)', () => {
expect(
deriveRunErrorCode({
status: 'failed',
errorCode: 'AGENT_EXECUTION_FAILED',
exitCode: 1,
signal: null,
}),
).toBe('AGENT_EXECUTION_FAILED');
});
it('derives AGENT_SIGNAL_* when the child died from a signal', () => {
expect(
deriveRunErrorCode({
status: 'failed',
errorCode: null,
exitCode: null,
signal: 'SIGSEGV',
}),
).toBe('AGENT_SIGNAL_SIGSEGV');
});
it('derives AGENT_EXIT_<code> when the child exited non-zero with no signal', () => {
expect(
deriveRunErrorCode({
status: 'failed',
errorCode: null,
exitCode: 1,
signal: null,
}),
).toBe('AGENT_EXIT_1');
expect(
deriveRunErrorCode({
status: 'failed',
errorCode: null,
exitCode: 137,
signal: null,
}),
).toBe('AGENT_EXIT_137');
});
it('falls back to AGENT_TERMINATED_UNKNOWN when neither signal nor non-zero exit is available', () => {
// This is the shape produced when `acpSession.hasFatalError()` is
// true even though the child exited cleanly (code=0, no signal) —
// see `child.on('close', ...)` in server.ts. Without this fallback,
// those failures would still blank out on PostHog.
expect(
deriveRunErrorCode({
status: 'failed',
errorCode: null,
exitCode: 0,
signal: null,
}),
).toBe('AGENT_TERMINATED_UNKNOWN');
});
it('returns undefined for a cancelled run with no stamped code', () => {
// Cancellation is the user's choice; not a diagnosable failure.
expect(
deriveRunErrorCode({
status: 'canceled',
errorCode: null,
exitCode: null,
signal: 'SIGTERM',
}),
).toBeUndefined();
});
it('forwards an explicit errorCode on a cancelled run (cancel during error recovery)', () => {
expect(
deriveRunErrorCode({
status: 'canceled',
errorCode: 'AGENT_AUTH_REQUIRED',
exitCode: 1,
signal: null,
}),
).toBe('AGENT_AUTH_REQUIRED');
});
it('treats empty-string errorCode as "missing" so the fallback chain still kicks in', () => {
// Defensive — `readString` in runs.ts trims and null-checks, but
// any future caller passing an empty string would otherwise emit a
// blank `error_code` field and reintroduce the original symptom.
expect(
deriveRunErrorCode({
status: 'failed',
errorCode: '',
exitCode: 1,
signal: null,
}),
).toBe('AGENT_EXIT_1');
});
});

View file

@ -28,6 +28,7 @@ export interface HeaderProps {
| 'systems'
| 'templates'
| 'craft'
| 'tutorials'
| 'blog';
/**
* Live counts from the Markdown catalogs. Required so we can never

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

@ -6,7 +6,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
@ -88,10 +87,13 @@ function isSameOriginApiCall(url: unknown): boolean {
// App version is read from a runtime endpoint rather than at build time so
// the same web bundle reports the daemon-pinned version even when running
// against a newer/older daemon during dev. Falls back to '0.0.0' until the
// fetch resolves; analytics events fired before resolution simply have a
// stale version string and are not re-emitted.
function useAppVersion(): string {
const versionRef = useRef('0.0.0');
// fetch resolves, then the resolved value flows through state so every
// downstream effect that depends on `appVersion` re-runs and re-registers
// the PostHog super-property with the real version. Earlier `useRef` shape
// silently broke this: ref writes don't trigger re-renders, so every event
// shipped with `app_version='0.0.0'`.
export function useAppVersion(): string {
const [version, setVersion] = useState('0.0.0');
useEffect(() => {
let cancelled = false;
void (async () => {
@ -100,7 +102,8 @@ function useAppVersion(): string {
if (!res.ok) return;
const body = (await res.json()) as { version?: { version?: string } };
if (cancelled) return;
if (body?.version?.version) versionRef.current = body.version.version;
const next = body?.version?.version;
if (next) setVersion(next);
} catch {
// Best-effort.
}
@ -109,7 +112,7 @@ function useAppVersion(): string {
cancelled = true;
};
}, []);
return versionRef.current;
return version;
}
export function AnalyticsProvider({ children }: { children: ReactNode }) {

View file

@ -0,0 +1,247 @@
// Per-editor icon for the hand-off menu. Renders a small rounded-square
// badge with a brand-tinted background and a distinctive glyph — mirrors
// the macOS dock affordance where each app has its own colored tile,
// rather than a single abstract folder/handoff glyph that hides which
// target the user is about to launch.
//
// Glyphs are stylized (Feather/Lucide-style) representations — not the
// official trademarked logos — so we keep visual identification without
// shipping brand assets we don't have a license for.
import type { HostEditorId } from '@open-design/contracts';
interface Props {
editorId: HostEditorId | string;
size?: number;
}
interface EditorVisual {
// Tile background — chosen to match the editor's primary brand color
// closely enough to read at a glance in the hand-off menu.
bg: string;
// Foreground stroke / glyph color. White on dark tiles, dark on light.
fg: string;
glyph: (size: number) => JSX.Element;
}
function angleBrackets(size: number) {
const s = size * 0.62;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 7-5 5 5 5" />
<path d="m15 7 5 5-5 5" />
</svg>
);
}
function cursorPointer(size: number) {
const s = size * 0.6;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor">
<path d="M5 3l5 15 3-6 6-3z" />
</svg>
);
}
function lightningZ(size: number) {
const s = size * 0.62;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor">
<path d="M6 4h12L9 13h9l-13 7 5-9H4z" />
</svg>
);
}
function wave(size: number) {
const s = size * 0.66;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
>
<path d="M3 12c2 -3 4 -3 6 0s4 3 6 0 4 -3 6 0" />
<path d="M3 17c2 -3 4 -3 6 0s4 3 6 0 4 -3 6 0" />
</svg>
);
}
function macFace(size: number) {
const s = size * 0.7;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" fill="currentColor" opacity="0.95" />
<circle cx="9" cy="10" r="1.2" fill="#fff" />
<circle cx="15" cy="10" r="1.2" fill="#fff" />
<path
d="M8.5 14.5c1 1 2.2 1.5 3.5 1.5s2.5 -.5 3.5 -1.5"
stroke="#fff"
strokeWidth={1.4}
strokeLinecap="round"
fill="none"
/>
</svg>
);
}
function terminalPrompt(size: number) {
const s = size * 0.62;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 4 3 -4 3" />
<path d="M12 17h6" />
</svg>
);
}
function warpTriangle(size: number) {
const s = size * 0.66;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4 22 20H2z" />
</svg>
);
}
function hammer(size: number) {
const s = size * 0.66;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 12-8.5 8.5a2.12 2.12 0 0 1-3-3L12 9" />
<path d="m17.64 15 3.36-3.36a2.83 2.83 0 0 0 0-4l-2.64-2.64a2.83 2.83 0 0 0-4 0L11 8.36" />
</svg>
);
}
function diamond(size: number) {
const s = size * 0.66;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3 22 12l-10 9L2 12z" />
</svg>
);
}
function orbit(size: number) {
const s = size * 0.7;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
>
<circle cx="12" cy="12" r="3" fill="currentColor" />
<ellipse cx="12" cy="12" rx="9" ry="4" transform="rotate(-30 12 12)" />
</svg>
);
}
function letter(ch: string, size: number) {
const s = size * 0.7;
return (
<svg width={s} height={s} viewBox="0 0 24 24">
<text
x="12"
y="17"
textAnchor="middle"
fontSize="15"
fontWeight="800"
fontFamily="'Inter', system-ui, -apple-system, sans-serif"
fill="currentColor"
>
{ch}
</text>
</svg>
);
}
const EDITORS: Record<string, EditorVisual> = {
vscode: { bg: '#0078d4', fg: '#ffffff', glyph: angleBrackets },
cursor: { bg: '#0a0a0a', fg: '#ffffff', glyph: cursorPointer },
windsurf: { bg: '#0c8a55', fg: '#ffffff', glyph: wave },
zed: { bg: '#1a1a1a', fg: '#d0d0d0', glyph: lightningZ },
qoder: { bg: '#f5a623', fg: '#1a1a1a', glyph: diamond },
antigravity: { bg: '#7c4dff', fg: '#ffffff', glyph: orbit },
webstorm: { bg: '#f97316', fg: '#ffffff', glyph: (s) => letter('W', s) },
idea: { bg: '#e91e63', fg: '#ffffff', glyph: (s) => letter('I', s) },
xcode: { bg: '#1d76d6', fg: '#ffffff', glyph: hammer },
finder: { bg: '#3097f6', fg: '#ffffff', glyph: macFace },
explorer: { bg: '#fbbf24', fg: '#1a1a1a', glyph: (s) => letter('E', s) },
'file-manager': { bg: '#6b7280', fg: '#ffffff', glyph: (s) => letter('F', s) },
terminal: { bg: '#111111', fg: '#9be37a', glyph: terminalPrompt },
warp: { bg: '#ff5c1c', fg: '#ffffff', glyph: warpTriangle },
};
export function EditorIcon({ editorId, size = 16 }: Props) {
const visual = EDITORS[editorId];
if (!visual) {
// Fallback — match a neutral folder tile rather than the abstract
// global handoff glyph the previous design used.
return (
<span
className="editor-icon"
style={{
width: size,
height: size,
background: '#9ca3af',
color: '#ffffff',
}}
>
<svg
width={size * 0.6}
height={size * 0.6}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</span>
);
}
return (
<span
className="editor-icon"
style={{ width: size, height: size, background: visual.bg, color: visual.fg }}
>
{visual.glyph(size)}
</span>
);
}

View file

@ -10,7 +10,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
defaultScenarioPluginIdForKind,
defaultScenarioPluginIdForProjectMetadata,
type ConnectorDetail,
type InstalledPluginRecord,
} from '@open-design/contracts';
@ -98,13 +98,13 @@ import { fetchProviderModels } from '../providers/provider-models';
// markup — both surfaces are always present, and CSS toggles
// `display` based on `--compact-topbar` breakpoint (900px).
// Default scenario plugin for each project kind. The mapping lives in
// `@open-design/contracts` so the daemon's `/api/projects` and
// `/api/runs` fallbacks resolve to the same plugin id when no
// Default scenario plugin for each project kind/intent. The mapping
// lives in `@open-design/contracts` so the daemon's `/api/projects`
// and `/api/runs` fallbacks resolve to the same plugin id when no
// `pluginId` is on the request body — plan §3.3 of
// `specs/current/plugin-driven-flow-plan.md`.
function defaultPluginIdForKind(metadata: ProjectMetadata): string | null {
return defaultScenarioPluginIdForKind(metadata.kind);
function defaultPluginIdForMetadata(metadata: ProjectMetadata): string | null {
return defaultScenarioPluginIdForProjectMetadata(metadata);
}
function defaultPluginInputsForCreate(
@ -419,7 +419,7 @@ export function EntryShell({
// is intentionally explicit so future kind-specific scenarios
// (e.g. a deck- or image-specialized pipeline) can take over a
// single row without touching the form.
const pluginId = defaultPluginIdForKind(input.metadata);
const pluginId = defaultPluginIdForMetadata(input.metadata);
const pluginInputs = defaultPluginInputsForCreate(input, pluginId);
return onCreateProject({
...input,

View file

@ -0,0 +1,247 @@
// Hand-off menu in the ChatPane header — "open the design project
// folder in <local app>". Mirrors paseo's WorkspaceOpenInEditorButton:
// a single split-style button that remembers the user's last pick
// (LocalStorage) and a dropdown listing the rest. Detection runs on
// the daemon; this component just renders.
import { useEffect, useRef, useState } from 'react';
import type {
HostEditor,
HostEditorId,
HostEditorsResponse,
} from '@open-design/contracts';
import { fetchHostEditors, openProjectInEditor } from '../providers/registry';
import { Icon } from './Icon';
import { EditorIcon } from './EditorIcon';
const PREFERRED_EDITOR_KEY = 'open-design:preferred-editor';
interface Props {
projectId: string;
// Optional fallback "always open in OS file manager" — falls back to the
// existing shell.openPath bridge in case the daemon catalogue is empty
// (highly unlikely on macOS / Win / Linux but harmless to support).
onRequestRevealInFinder?: () => void;
}
function readPreferred(): HostEditorId | null {
try {
const v = window.localStorage.getItem(PREFERRED_EDITOR_KEY);
return (v as HostEditorId) || null;
} catch {
return null;
}
}
function writePreferred(id: HostEditorId): void {
try {
window.localStorage.setItem(PREFERRED_EDITOR_KEY, id);
} catch {
// ignore — quota or sandboxed
}
}
export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
const [editors, setEditors] = useState<HostEditor[]>([]);
const [platform, setPlatform] = useState<HostEditorsResponse['platform']>('unknown');
const [loaded, setLoaded] = useState(false);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState<HostEditorId | null>(null);
const [error, setError] = useState<string | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
fetchHostEditors()
.then((resp) => {
if (cancelled) return;
setEditors(resp.editors);
setPlatform(resp.platform);
setLoaded(true);
})
.catch(() => {
if (cancelled) return;
setEditors([]);
setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!open) return;
function onPointer(e: MouseEvent) {
if (wrapRef.current?.contains(e.target as Node)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const available = editors.filter((e) => e.available);
const unavailable = editors.filter((e) => !e.available);
const preferred = readPreferred();
const primary =
available.find((e) => e.id === preferred) ?? available[0] ?? null;
async function launch(editor: HostEditor) {
if (!editor.available) {
// Still try — the user might have an unprobed path (e.g. macOS
// bundle in /Applications). The daemon will return 409 if it
// genuinely can't find it.
}
setError(null);
setBusy(editor.id);
setOpen(false);
writePreferred(editor.id);
try {
await openProjectInEditor(projectId, editor.id);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
// Fallback: if Finder is the user's pick and the daemon spawn
// failed, try the renderer-side reveal-in-finder bridge.
if (editor.id === 'finder' && onRequestRevealInFinder) {
try {
onRequestRevealInFinder();
} catch {
// ignore
}
}
} finally {
setBusy(null);
}
}
if (!loaded || (available.length === 0 && unavailable.length === 0)) {
return null;
}
// No detected editors at all — render a Finder/Explorer/File-Manager
// single-button fallback so the surface is never blank.
if (available.length === 0) {
const fallbackLabel = platform === 'win32' ? 'Explorer' : platform === 'linux' ? 'File Manager' : 'Finder';
const fallbackId: HostEditorId =
platform === 'win32' ? 'explorer' : platform === 'linux' ? 'file-manager' : 'finder';
return (
<button
type="button"
className="handoff-trigger handoff-trigger--solo"
title={`No editors found on $PATH — opens in ${fallbackLabel}`}
onClick={() => onRequestRevealInFinder?.()}
>
<EditorIcon editorId={fallbackId} size={20} />
<span className="handoff-trigger-label">{fallbackLabel}</span>
</button>
);
}
return (
<div
className={`handoff-wrap${open ? ' open' : ''}`}
ref={wrapRef}
data-testid="handoff-wrap"
>
{/* Split control: the labeled left side launches the preferred
editor, the right caret opens the picker. Sibling buttons
(instead of a nested caret) so the caret has its own real
tap target and so we don't render an invalid button-in-button. */}
<div className="handoff-split">
<button
type="button"
className="handoff-trigger"
data-testid="handoff-trigger"
title={primary ? `交付给 ${primary.label}` : '交付'}
onClick={() => {
if (primary && busy !== primary.id) {
void launch(primary);
} else {
setOpen((v) => !v);
}
}}
disabled={busy !== null}
>
{primary ? (
<>
<EditorIcon editorId={primary.id} size={20} />
<span className="handoff-trigger-label">
{primary.label}
</span>
</>
) : (
<>
<EditorIcon editorId="finder" size={20} />
<span className="handoff-trigger-label"></span>
</>
)}
</button>
<button
type="button"
className="handoff-caret"
aria-label="Choose hand-off target"
data-testid="handoff-caret"
onClick={() => setOpen((v) => !v)}
disabled={busy !== null}
>
<Icon name="chevron-down" size={14} />
</button>
</div>
{open ? (
<div className="handoff-menu" role="menu" data-testid="handoff-menu">
{available.map((editor) => (
<button
key={editor.id}
type="button"
className={`handoff-menu-item${editor.id === preferred ? ' active' : ''}`}
role="menuitem"
data-testid={`handoff-menu-item-${editor.id}`}
onClick={() => void launch(editor)}
disabled={busy === editor.id}
>
<EditorIcon editorId={editor.id} size={20} />
<span>{editor.label}</span>
{editor.id === preferred ? (
<Icon name="check" size={12} />
) : null}
</button>
))}
{unavailable.length > 0 ? (
<>
<div className="handoff-menu-divider" />
<div className="handoff-menu-section">Not installed</div>
{unavailable.map((editor) => (
<button
key={editor.id}
type="button"
className="handoff-menu-item dim"
role="menuitem"
data-testid={`handoff-menu-item-${editor.id}`}
onClick={() => void launch(editor)}
disabled={busy === editor.id}
title={`${editor.label} — not detected on $PATH`}
>
<EditorIcon editorId={editor.id} size={20} />
<span>{editor.label}</span>
</button>
))}
</>
) : null}
{error ? (
<>
<div className="handoff-menu-divider" />
<div className="handoff-menu-error">{error}</div>
</>
) : null}
</div>
) : null}
</div>
);
}

View file

@ -22,7 +22,12 @@ import type {
ForwardedRef,
KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import type { ConnectorDetail, InputFieldSpec, InstalledPluginRecord, McpServerConfig } from '@open-design/contracts';
import type {
ConnectorDetail,
InputFieldSpec,
InstalledPluginRecord,
McpServerConfig,
} from '@open-design/contracts';
import type { SkillSummary } from '../types';
import { Icon, type IconName } from './Icon';
import { PluginInputsForm } from './PluginInputsForm';
@ -81,6 +86,19 @@ interface Props {
onPickMcp?: (server: McpServerConfig, nextPrompt: string) => void;
onPickConnector?: (connector: ConnectorDetail, nextPrompt: string) => void;
onPickChip: (chip: HomeHeroChip) => void;
// Manus-style example-prompt suggestions. Each entry carries both
// the source plugin (we still dispatch the existing
// `requestPluginContextUse(record, 'use-with-query')` path on click)
// and a pre-resolved, locale-aware preview of the prompt the
// textarea will receive, so the card body shows the actual sentence
// the user is about to send. HomeView decides the slice (matching
// the active chip), how many, and whether the panel should be
// visible (chip selected + not dismissed). The panel stays mounted
// either way so the accordion exit animation runs on close.
exampleSuggestions?: ExampleSuggestion[];
showExamples?: boolean;
onPickExample?: (record: InstalledPluginRecord) => void;
onDismissExamples?: () => void;
contextItemCount: number;
error: string | null;
}
@ -144,6 +162,10 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
onPickMcp = () => undefined,
onPickConnector = () => undefined,
onPickChip,
exampleSuggestions = [],
showExamples = false,
onPickExample = () => undefined,
onDismissExamples = () => undefined,
contextItemCount,
error,
},
@ -451,20 +473,17 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
return (
<section className="home-hero" data-testid="home-hero">
<div className="home-hero__brand" aria-hidden>
<span className="home-hero__brand-mark">
<img src="/app-icon.svg" alt="" draggable={false} />
</span>
<span className="home-hero__brand-name">Open Design</span>
</div>
<h1 className="home-hero__title">{t('homeHero.title')}</h1>
<p className="home-hero__subtitle">
{t('homeHero.subtitlePrefix')}{' '}
<kbd>Enter</kbd>.
{t('homeHero.subtitlePrefix')} <kbd>Enter</kbd>.
</p>
<TypeTabBar
activeChipId={activeChipId}
pendingChipId={pendingChipId}
pendingPluginId={pendingPluginId}
pluginsLoading={pluginsLoading}
onPickChip={onPickChip}
/>
<div
className={`home-hero__input-card${dragActive ? ' is-drag-active' : ''}`}
onDragEnter={(event) => {
@ -497,7 +516,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
title={t('homeHero.pluginTitle', { title: plugin.title })}
>
<span className="home-hero__active-dot" aria-hidden />
<span>@{plugin.title}</span>
<span>{plugin.title}</span>
</button>
<button
type="button"
@ -528,7 +547,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
title={activePluginRecord ? t('homeHero.pluginTitle', { title: activePluginRecord.title }) : undefined}
>
<span className="home-hero__active-dot" aria-hidden />
<span>{t('homeHero.pluginPrefix', { title: activePluginTitle })}</span>
<span>{activePluginTitle}</span>
</button>
<button
type="button"
@ -883,6 +902,15 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
aria-label={t('homeHero.railAria')}
data-testid="home-hero-rail"
>
<RailGroup
group="create"
activeChipId={activeChipId}
pendingChipId={pendingChipId}
pendingPluginId={pendingPluginId}
pluginsLoading={pluginsLoading}
onPickChip={onPickChip}
/>
<span className="home-hero__rail-divider" aria-hidden />
<RailGroup
group="migrate"
activeChipId={activeChipId}
@ -893,6 +921,18 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
/>
</div>
{/* Always render the panel; toggle visibility via accordion class
so the exit animation runs on dismiss/chip-change. Hidden
when no chip is picked or when the user closed it for the
current chip. */}
<ExamplePromptPanel
suggestions={exampleSuggestions}
open={showExamples}
onPick={onPickExample}
onDismiss={onDismissExamples}
disabled={pluginsLoading || pendingPluginId !== null}
/>
{error ? (
<div role="alert" className="home-hero__error">
{error}
@ -1495,47 +1535,86 @@ function getPluginQueryPreview(plugin: InstalledPluginRecord): string {
return trimmed.length > 96 ? `${trimmed.slice(0, 96)}` : trimmed;
}
interface TypeTabBarProps {
activeChipId: string | null;
pendingChipId: string | null;
pendingPluginId: string | null;
pluginsLoading: boolean;
onPickChip: (chip: HomeHeroChip) => void;
// Each suggestion carries both the source plugin (so click can route
// through the same `requestPluginContextUse(record, 'use-with-query')`
// path the plugin card menu uses) and a pre-rendered, locale-aware
// preview the card displays. HomeView resolves the preview through
// the same renderer it'd use on submit, so the card body is exactly
// the sentence the user is about to send.
export interface ExampleSuggestion {
plugin: InstalledPluginRecord;
preview: string;
}
function TypeTabBar({
activeChipId,
pendingChipId,
pendingPluginId,
pluginsLoading,
onPickChip,
}: TypeTabBarProps) {
const chips = useMemo(() => chipsForGroup('create'), []);
interface ExamplePromptPanelProps {
suggestions: ExampleSuggestion[];
open: boolean;
onPick: (record: InstalledPluginRecord) => void;
onDismiss: () => void;
disabled: boolean;
}
// Manus-style suggestion panel. Sits below the composer card + migrate
// rail and surfaces 3-4 representative `useCase.query` previews as
// content-bearing cards (not chip pills). The panel stays mounted so
// the accordion exit animation runs on dismiss; `.accordion-collapsible
// .open` toggles visibility per the shared motion contract in
// `apps/web/src/index.css`. A small close button hides the panel for
// the current chip; switching chips re-arms it (state lives in
// HomeView).
function ExamplePromptPanel({
suggestions,
open,
onPick,
onDismiss,
disabled,
}: ExamplePromptPanelProps) {
const hasContent = suggestions.length > 0;
const isOpen = open && hasContent;
return (
<div className="home-hero__type-tabs" role="tablist" aria-label="Output type">
{chips.map((chip) => {
const isActive = activeChipId === chip.id;
const isPending = pendingChipId === chip.id;
const cls = ['home-hero__type-tab'];
if (isActive) cls.push('is-active');
if (isPending) cls.push('is-pending');
return (
<button
key={chip.id}
type="button"
role="tab"
className={cls.join(' ')}
data-chip-id={chip.id}
data-testid={`home-hero-rail-${chip.id}`}
onClick={() => onPickChip(chip)}
disabled={pluginsLoading || isPending || pendingPluginId !== null}
aria-selected={isActive}
title={chip.hint ?? chip.label}
>
<span>{chip.label}</span>
</button>
);
})}
<div
className={`home-hero__examples accordion-collapsible${isOpen ? ' open' : ''}`}
aria-hidden={isOpen ? undefined : true}
data-testid="home-hero-example-prompts"
>
<div className="accordion-collapsible-inner">
<section className="home-hero__examples-panel">
<header className="home-hero__examples-head">
<span className="home-hero__examples-title">Example prompts</span>
<button
type="button"
className="home-hero__examples-close"
onClick={onDismiss}
aria-label="Dismiss example prompts"
data-testid="home-hero-example-dismiss"
disabled={disabled || !isOpen}
>
<Icon name="close" size={12} />
</button>
</header>
<div className="home-hero__examples-grid" role="list">
{suggestions.map(({ plugin, preview }) => (
<button
key={plugin.id}
type="button"
role="listitem"
className="home-hero__example-card"
data-testid={`home-hero-example-${plugin.id}`}
onClick={() => onPick(plugin)}
disabled={disabled || !isOpen}
title={preview}
>
<span className="home-hero__example-card-body">{preview}</span>
<Icon
name="arrow-up"
size={12}
className="home-hero__example-card-arrow"
/>
</button>
))}
</div>
</section>
</div>
</div>
);
}
@ -1580,6 +1659,7 @@ function RailGroup({
onClick={() => onPickChip(chip)}
disabled={pluginsLoading || isPending || pendingPluginId !== null}
aria-pressed={isActive}
aria-selected={isActive}
title={homeHeroChipTitle(chip, t)}
>
<Icon name={chip.icon} size={14} className="home-hero__rail-chip-icon" />

View file

@ -39,8 +39,8 @@ import { useI18n } from '../i18n';
import { fetchElevenLabsVoiceOptions } from '../providers/elevenlabs-voices';
import type { Project, ProjectMetadata, PromptTemplateSummary, SkillSummary } from '../types';
import { inlineMentionToken } from '../utils/inlineMentions';
import { HomeHero } from './HomeHero';
import { findChip, type HomeHeroChip } from './home-hero/chips';
import { HomeHero, type ExampleSuggestion } from './HomeHero';
import { findChip, HOME_HERO_CHIPS, type HomeHeroChip } from './home-hero/chips';
import {
buildHomeMediaComposer,
homeMediaSurfaceForChipId,
@ -58,9 +58,17 @@ import {
import { PluginDetailsModal } from './PluginDetailsModal';
import { PluginsHomeSection } from './PluginsHomeSection';
import type { PluginLoopSubmit } from './PluginLoopHome';
import {
applyFacetSelection,
isFeaturedPlugin,
type FacetSelection,
} from './plugins-home/facets';
import type { PluginUseAction } from './plugins-home/useActions';
import { sortByVisualAppeal } from './plugins-home/visualScore';
import { RecentProjectsStrip } from './RecentProjectsStrip';
const EXAMPLE_PROMPT_LIMIT = 4;
interface ActivePlugin {
record: InstalledPluginRecord;
// `result` is `null` during the optimistic window — set on chip
@ -85,6 +93,16 @@ interface ActivePlugin {
projectMetadata: ProjectMetadata | null;
editableInputNames: string[];
preserveInputFields: boolean;
// True when the active plugin was bound through a type chip.
// In that mode we never push the rendered useCase.query into the
// textarea — the user keeps full control over the prompt and the
// example-prompt panel below the composer is the explicit opt-in
// for a starter sentence. Without this flag the media composer
// effect (which fires on external list reloads like ElevenLabs
// voices) and updateActiveInputs (fires on inline form edits)
// would back-fill the textarea, defeating the suppression that
// the chip click set up.
suppressPromptSync: boolean;
}
interface SelectedPluginContext {
@ -220,6 +238,7 @@ export function HomeView({
const consumedHandoffIdRef = useRef<number | null>(null);
const pendingPromptFocusEndRef = useRef(false);
const activePluginApplyRequestRef = useRef(0);
const defaultedPrototypeRef = useRef(false);
useEffect(() => {
let cancelled = false;
@ -303,7 +322,16 @@ export function HomeView({
},
);
const nextRendered = renderPluginBriefTemplate(composer.queryTemplate, composer.inputs);
if (prompt === active.lastRenderedPrompt || prompt.trim().length === 0) {
// When the plugin was bound through a type chip the user owns the
// textarea; never back-fill from this effect even if external
// lists (ElevenLabs voices, prompt templates) reload after the
// chip click. lastRenderedPrompt stays null in that mode so we
// don't mis-detect "the user hasn't typed" via the empty-string
// branch either.
if (
!active.suppressPromptSync &&
(prompt === active.lastRenderedPrompt || prompt.trim().length === 0)
) {
setPrompt(nextRendered);
}
setActive((prev) => {
@ -316,7 +344,7 @@ export function HomeView({
editableInputNames: composer.editableFieldNames,
inputsValid: pluginInputsAreValid(composer.fields, composer.inputs),
result: inputsEqual(prev.result?.appliedPlugin?.inputs, composer.inputs) ? prev.result : null,
lastRenderedPrompt: nextRendered,
lastRenderedPrompt: prev.suppressPromptSync ? prev.lastRenderedPrompt : nextRendered,
projectMetadata: metadataForHomeMediaComposer(prev.mediaSurface, composer.inputs, promptTemplates),
};
});
@ -365,16 +393,96 @@ export function HomeView({
setPendingAuthoringChipId('create-plugin');
}, [promptHandoff]);
const activeContextItemCount = useMemo(
() =>
active
? active.result?.contextItems?.length ??
estimatePluginContextItemCount(active.record)
: 0,
[active],
);
const contextItemCount = useMemo(
() =>
(active?.result?.contextItems?.length ?? 0) +
activeContextItemCount +
selectedPluginContexts.length +
selectedMcpContexts.length +
selectedConnectorContexts.length +
stagedFiles.length,
[active, selectedConnectorContexts.length, selectedMcpContexts.length, selectedPluginContexts, stagedFiles.length],
[
activeContextItemCount,
selectedConnectorContexts.length,
selectedMcpContexts.length,
selectedPluginContexts.length,
stagedFiles.length,
],
);
// The Home chip rail and the Official starters grid share a mental
// model — "Prototype" up top is the same artifact intent as the
// `create / prototype` slice down below. When the user picks a chip,
// we drive the starters' FacetSelection from it so they get a
// pre-filtered shelf of templates for the same intent without having
// to scroll and re-pick. `pendingChipId` (set on click, before apply
// resolves) is preferred over `active?.chipId` so the filter snaps on
// the same frame as the click.
const presetStartersSelection = useMemo<FacetSelection | null>(() => {
const chipId = pendingChipId ?? active?.chipId ?? null;
if (!chipId) return null;
return facetSelectionForChip(chipId);
}, [pendingChipId, active?.chipId]);
const rankedExamplePlugins = useMemo(() => {
if (plugins.length === 0) return [];
const visible = plugins.filter(
(plugin) =>
plugin.manifest?.od?.kind !== 'atom' && Boolean(plugin.manifest?.od?.useCase?.query),
);
return sortByVisualAppeal(visible);
}, [plugins]);
// Manus-style example-prompt suggestions for the panel that appears
// below the composer after a type chip is picked. We surface the
// top-N visually-strong plugins from the matching facet slice (e.g.
// picking "Slide deck" shows four polished deck templates) and
// pre-render each plugin's useCase.query through the same renderer
// submit uses, so the card body is the actual sentence that hits
// the textarea on click. Sparse slices are topped up with featured
// picks so the row never collapses to a single dim example.
const exampleSuggestions = useMemo<ExampleSuggestion[]>(() => {
if (rankedExamplePlugins.length === 0) return [];
const sliceFor = (selection: FacetSelection | null) => {
if (!selection) return rankedExamplePlugins;
return applyFacetSelection(rankedExamplePlugins, selection);
};
const primary = sliceFor(presetStartersSelection);
const featuredBackfill = rankedExamplePlugins.filter(
(plugin) => isFeaturedPlugin(plugin) && !primary.some((p) => p.id === plugin.id),
);
const records = [...primary, ...featuredBackfill].slice(0, EXAMPLE_PROMPT_LIMIT);
return records
.map((plugin) => {
const template = resolvePluginQueryFallback(plugin.manifest?.od?.useCase?.query, locale);
if (!template) return null;
const preview = renderPluginBriefTemplate(
template,
hydratePluginInputs(plugin.manifest?.od?.inputs ?? [], undefined),
);
return { plugin, preview };
})
.filter((entry): entry is ExampleSuggestion => entry !== null);
}, [rankedExamplePlugins, presetStartersSelection, locale]);
// Per-chip dismissal: once the user closes the panel for a given
// chip, we keep it hidden until they pick a different chip (which
// makes dismissedExampleChipId stale and lets the panel open
// again). This matches Manus' close-once-then-quiet behavior.
const [dismissedExampleChipId, setDismissedExampleChipId] = useState<string | null>(null);
const currentExampleChipId = pendingChipId ?? active?.chipId ?? null;
const showExamples =
Boolean(currentExampleChipId) &&
exampleSuggestions.length > 0 &&
dismissedExampleChipId !== currentExampleChipId;
// When the active plugin was bound through a chip, the badge shows
// the chip label (e.g. "Prototype") instead of the underlying plugin
// record title (e.g. "New generation (default scenario)"). Several
@ -413,10 +521,23 @@ export function HomeView({
editableInputNames?: string[];
preserveInputFields?: boolean;
replaceWithoutConfirmation?: boolean;
// When true, applying the plugin updates the active badge +
// context items but does NOT push the rendered useCase.query
// into the textarea. The user keeps whatever they had typed
// (or empty); the example-prompt panel below the composer is
// the surfaced opt-in to seed the textarea instead. Used by
// the top type-chip rail: picking Slide deck binds the plugin
// context, leaving the user's draft alone.
suppressPromptUpdate?: boolean;
// Type chips are a mode switch, not a commitment to run. Keeping
// their apply deferred makes Prototype <-> Deck <-> Media changes
// feel instant; submit() still resolves the snapshot before sending.
deferApply?: boolean;
},
) {
const applyRequestId = activePluginApplyRequestRef.current + 1;
activePluginApplyRequestRef.current = applyRequestId;
const shouldResolveImmediately = options?.deferApply !== true;
const inputFields = options?.inputFields ?? record.manifest?.od?.inputs ?? [];
const optimisticInputs = hydratePluginInputs(inputFields, options?.inputs);
const inputsValid = pluginInputsAreValid(inputFields, optimisticInputs);
@ -432,7 +553,7 @@ export function HomeView({
: queryTemplate
? renderPluginBriefTemplate(queryTemplate, optimisticInputs)
: null;
if (options?.chipId) setPendingChipId(options.chipId);
if (options?.chipId && shouldResolveImmediately) setPendingChipId(options.chipId);
setError(null);
// Optimistic update: the chip already carries the inputs and the
// plugin record's manifest already carries the query template, so
@ -442,6 +563,7 @@ export function HomeView({
// items in the background and we reconcile in place. Without this
// the user sees a ~100-500ms freeze before the input back-fills,
// which feels like the UI is jammed.
const suppressPromptUpdate = options?.suppressPromptUpdate === true;
setActive({
record,
result: null,
@ -449,23 +571,28 @@ export function HomeView({
inputFields,
inputsValid,
queryTemplate,
lastRenderedPrompt: optimisticPrompt,
// When prompt updates are suppressed we leave lastRenderedPrompt
// null so the inline pattern-extraction in handlePromptChange
// doesn't claim ownership of the user's typed text.
lastRenderedPrompt: suppressPromptUpdate ? null : optimisticPrompt,
projectKind: options?.projectKind ?? null,
chipId: options?.chipId ?? null,
mediaSurface: options?.mediaSurface ?? null,
projectMetadata: options?.projectMetadata ?? null,
editableInputNames: options?.editableInputNames ?? [],
preserveInputFields: options?.preserveInputFields === true,
suppressPromptSync: suppressPromptUpdate,
});
setFallbackProjectKind(null);
setDetailsRecord(null);
if (optimisticPrompt !== null) setPrompt(optimisticPrompt);
if (!suppressPromptUpdate && optimisticPrompt !== null) setPrompt(optimisticPrompt);
requestAnimationFrame(() => inputRef.current?.focus());
if (!inputsValid) {
setPendingChipId(null);
return;
}
if (!shouldResolveImmediately) return;
const result = await resolveActivePlugin(record, optimisticInputs, applyRequestId);
if (activePluginApplyRequestRef.current !== applyRequestId) return;
@ -505,7 +632,7 @@ export function HomeView({
// user hasn't edited the prompt in the meantime — if they have,
// current !== optimisticPrompt and the functional setter is a
// no-op so their edits survive.
if (nextPrompt === undefined || nextPrompt === null) {
if (!suppressPromptUpdate && (nextPrompt === undefined || nextPrompt === null)) {
const reconciledQuery =
options?.queryTemplate !== undefined
? options.queryTemplate
@ -552,6 +679,8 @@ export function HomeView({
editableInputNames?: string[];
preserveInputFields?: boolean;
replaceWithoutConfirmation?: boolean;
suppressPromptUpdate?: boolean;
deferApply?: boolean;
},
) {
const replacement = previewPluginReplacement(record, nextPrompt, {
@ -745,6 +874,7 @@ export function HomeView({
? renderPluginBriefTemplate(queryTemplate, normalized)
: active.lastRenderedPrompt;
if (
!active.suppressPromptSync &&
queryTemplate !== null &&
nextRendered !== null &&
(prompt === active.lastRenderedPrompt || prompt.trim().length === 0)
@ -760,7 +890,7 @@ export function HomeView({
editableInputNames: mediaComposer?.editableFieldNames ?? active.editableInputNames,
inputsValid,
result: inputsEqual(active.result?.appliedPlugin?.inputs, normalized) ? active.result : null,
lastRenderedPrompt: nextRendered,
lastRenderedPrompt: active.suppressPromptSync ? active.lastRenderedPrompt : nextRendered,
});
}
@ -897,7 +1027,11 @@ export function HomeView({
projectMetadata: metadataForHomeMediaComposer(mediaSurface, composer.inputs, promptTemplates),
editableInputNames: composer.editableFieldNames,
preserveInputFields: true,
replaceWithoutConfirmation: Boolean(active?.mediaSurface),
// Media chips are an editable generation form: the prompt
// slots are where users adjust model, duration, ratio, and
// audio text before running. Keep this path eager so the
// inline options and required plugin inputs stay visible.
replaceWithoutConfirmation: true,
});
return;
}
@ -905,14 +1039,21 @@ export function HomeView({
projectKind: chip.action.projectKind,
chipId: chip.id,
inputs: chip.action.inputs,
projectMetadata: chip.action.projectMetadata ?? null,
};
// Output-type tabs (create group) are mode-selection gestures:
// switching between them should never prompt for confirmation,
// even when the input already has template text from a previous
// tab. Migrate-group chips (From Figma, etc.) still go through
// the replacement guard because they carry a meaningful prompt.
// and they should NOT pre-fill the textarea with the rendered
// useCase.query — the example-prompt panel below the composer
// is the explicit opt-in for that. Migrate-group chips (From
// Figma, etc.) still carry a meaningful prompt the user wants
// dropped in, so they keep the historical behavior.
if (chip.group === 'create') {
void usePlugin(record, undefined, pluginOptions);
void usePlugin(record, undefined, {
...pluginOptions,
suppressPromptUpdate: true,
deferApply: true,
});
} else {
requestActivePlugin(record, undefined, pluginOptions);
}
@ -941,6 +1082,25 @@ export function HomeView({
}
}
// Default-select the Prototype tab on first mount so the active
// tab + composer always read as one joined surface instead of a
// naked composer under a row of unselected tabs. Runs once after
// plugins finish loading; skips if the user already has a chip
// bound (handoff, restored session, manual pick).
useEffect(() => {
if (pluginsLoading) return;
if (defaultedPrototypeRef.current) return;
if (active?.chipId || pendingChipId) {
defaultedPrototypeRef.current = true;
return;
}
const prototypeChip = HOME_HERO_CHIPS.find((c) => c.id === 'prototype');
if (!prototypeChip) return;
defaultedPrototypeRef.current = true;
pickChip(prototypeChip);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginsLoading, active?.chipId, pendingChipId]);
async function submit() {
const trimmed = prompt.trim();
if (!trimmed && stagedFiles.length === 0) return;
@ -1060,6 +1220,14 @@ export function HomeView({
onPickMcp={useMcpServer}
onPickConnector={useConnector}
onPickChip={pickChip}
exampleSuggestions={exampleSuggestions}
showExamples={showExamples}
onPickExample={(record) => requestPluginContextUse(record, 'use-with-query')}
onDismissExamples={() => {
if (currentExampleChipId) {
setDismissedExampleChipId(currentExampleChipId);
}
}}
contextItemCount={contextItemCount}
error={error}
/>
@ -1101,6 +1269,7 @@ export function HomeView({
onOpenDetails={setDetailsRecord}
onCreatePlugin={(goal) => queuePluginAuthoring(null, goal)}
onBrowseRegistry={onBrowseRegistry}
presetSelection={presetStartersSelection}
/>
{detailsRecord ? (
@ -1203,6 +1372,30 @@ function projectKindForSkill(skill: SkillSummary | null): ProjectKind | null {
return 'other';
}
// Maps a Home hero chip id to the Official starters facet slice the
// user most likely wants to browse next. The chip rail is intent
// ("I want to design a slide deck"); the starters grid is the catalog
// for that intent, so pinning the same `create / deck` slice lets the
// user keep scanning examples without re-picking the same artifact
// kind in a different control. The list mirrors the `apply-scenario`
// and `apply-figma-migration` chip ids in `home-hero/chips.ts`; any
// new chip there should add a row here too.
function facetSelectionForChip(chipId: string): FacetSelection | null {
switch (chipId) {
case 'prototype': return { category: 'create', subcategory: 'prototype' };
case 'live-artifact': return { category: 'create', subcategory: 'live-artifact' };
case 'deck': return { category: 'create', subcategory: 'deck' };
case 'image': return { category: 'create', subcategory: 'image' };
case 'video': return { category: 'create', subcategory: 'video' };
case 'hyperframes': return { category: 'create', subcategory: 'hyperframes' };
case 'audio': return { category: 'create', subcategory: 'audio' };
case 'figma': return { category: 'import', subcategory: 'from-figma' };
case 'folder': return { category: 'import', subcategory: 'from-code' };
case 'create-plugin': return { category: 'extend', subcategory: 'plugin-authoring' };
default: return null;
}
}
function homeHeroChipLabelForId(chipId: string, t: ReturnType<typeof useI18n>['t']): string {
switch (chipId) {
case 'prototype': return t('homeHero.chip.prototype');
@ -1220,6 +1413,19 @@ function homeHeroChipLabelForId(chipId: string, t: ReturnType<typeof useI18n>['t
}
}
function estimatePluginContextItemCount(
record: InstalledPluginRecord,
): number {
const context = record.manifest?.od?.context;
if (!context) return 0;
const assetCount = context.assets?.length ?? 0;
const mcpCount = context.mcp?.length ?? 0;
const claudePluginCount = context.claudePlugins?.length ?? 0;
const atomCount = context.atoms?.length ?? 0;
const craftCount = context.craft?.length ?? 0;
return assetCount + mcpCount + claudePluginCount + atomCount + craftCount;
}
function hydratePluginInputs(
fields: InputFieldSpec[],
provided: Record<string, unknown> | undefined,

View file

@ -20,8 +20,11 @@ import { useT } from '../i18n';
import type { PluginShareAction } from '../state/projects';
import { Icon } from './Icon';
import { PluginCard } from './plugins-home/PluginCard';
import { usePluginFacets } from './plugins-home/usePluginFacets';
import type { FacetOption } from './plugins-home/facets';
import {
usePluginFacets,
type FilterMode,
} from './plugins-home/usePluginFacets';
import type { FacetOption, FacetSelection } from './plugins-home/facets';
import type { PluginUseAction } from './plugins-home/useActions';
interface Props {
@ -39,6 +42,12 @@ interface Props {
onCreatePlugin?: (goal?: string) => void;
onBrowseRegistry?: () => void;
preferDefaultFacet?: boolean;
// Optional external selection. When the Home chip rail picks
// "Slide deck", HomeView passes { category: 'create', subcategory:
// 'deck' } so the Official starters grid scrolls to the matching
// slice instead of staying on its default. The hook only re-applies
// when this identity changes, so manual facet clicks still win.
presetSelection?: FacetSelection | null;
title?: string;
subtitle?: string;
emptyMessage?: string;
@ -58,6 +67,7 @@ export function PluginsHomeSection({
onCreatePlugin,
onBrowseRegistry,
preferDefaultFacet = true,
presetSelection = null,
title,
subtitle,
emptyMessage,
@ -77,7 +87,7 @@ export function PluginsHomeSection({
query,
setQuery,
totalVisible,
} = usePluginFacets({ plugins, preferDefaultFacet });
} = usePluginFacets({ plugins, preferDefaultFacet, presetSelection });
const contributionTarget = onCreatePlugin
? resolveContributionTarget(catalog, selection)
: null;

View file

@ -0,0 +1,407 @@
// Project-page design-system picker — small dropdown rendered in the
// project chrome header next to the title. It binds to an existing
// project: changing the selection PATCHes
// `project.designSystemId` so the next chat run carries the new
// design-system metadata into the agent's system prompt (the daemon
// already threads `designSystemId` from project state through
// `/api/runs` — see providers/daemon.ts).
//
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { DesignSystemSummary } from '@open-design/contracts';
import { useI18n } from '../i18n';
import {
localizeDesignSystemCategory,
localizeDesignSystemSummary,
} from '../i18n/content';
import { fetchDesignSystemPreview } from '../providers/registry';
import { Icon } from './Icon';
interface PopoverAnchor {
top: number;
left: number;
width: number;
}
interface Props {
designSystems: DesignSystemSummary[];
selectedId: string | null;
loading?: boolean;
onChange: (id: string | null) => void;
}
export function ProjectDesignSystemPicker({
designSystems,
selectedId,
loading,
onChange,
}: Props) {
const { locale, t } = useI18n();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [anchor, setAnchor] = useState<PopoverAnchor | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [hovered, setHovered] = useState<DesignSystemSummary | null>(null);
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [fullscreenPreview, setFullscreenPreview] = useState(false);
const selected = useMemo(
() => designSystems.find((d) => d.id === selectedId) ?? null,
[designSystems, selectedId],
);
useEffect(() => {
if (!open) return;
function onPointer(e: MouseEvent) {
if (fullscreenPreview) return;
const target = e.target as Node;
if (wrapRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (fullscreenPreview) return;
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [fullscreenPreview, open]);
useLayoutEffect(() => {
if (!open || !triggerRef.current) return undefined;
function updateAnchor() {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const popoverWidth = Math.min(640, Math.max(300, window.innerWidth * 0.86));
const viewport = window.innerWidth;
const left = Math.max(8, Math.min(viewport - popoverWidth - 8, rect.left));
setAnchor({ top: rect.bottom + 6, left, width: popoverWidth });
}
updateAnchor();
window.addEventListener('resize', updateAnchor);
window.addEventListener('scroll', updateAnchor, true);
return () => {
window.removeEventListener('resize', updateAnchor);
window.removeEventListener('scroll', updateAnchor, true);
};
}, [open]);
useEffect(() => {
if (open) {
window.setTimeout(() => inputRef.current?.focus(), 0);
} else {
setQuery('');
setHovered(null);
setFullscreenPreview(false);
}
}, [open]);
useEffect(() => {
if (!fullscreenPreview) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setFullscreenPreview(false);
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [fullscreenPreview]);
const previewTarget = open ? hovered ?? selected : null;
useEffect(() => {
if (!previewTarget) {
setPreviewHtml(null);
setPreviewLoading(false);
return;
}
let cancelled = false;
setPreviewLoading(true);
void fetchDesignSystemPreview(previewTarget.id)
.then((html) => {
if (cancelled) return;
setPreviewHtml(html);
})
.catch(() => {
if (cancelled) return;
setPreviewHtml(null);
})
.finally(() => {
if (cancelled) return;
setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [previewTarget?.id]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (q.length === 0) return designSystems;
return designSystems.filter((d) => {
const localizedSummary = localizeDesignSystemSummary(locale, d);
const localizedCategory = localizeDesignSystemCategory(locale, d.category);
const haystack = `${d.title} ${d.category} ${d.summary} ${localizedCategory} ${localizedSummary}`.toLowerCase();
return haystack.includes(q);
});
}, [query, designSystems, locale]);
return (
<div
ref={wrapRef}
className={`project-ds-picker${open ? ' open' : ''}`}
data-testid="project-ds-picker"
>
<button
ref={triggerRef}
type="button"
className={`project-ds-picker-trigger${selected ? ' picked' : ''}`}
data-testid="project-ds-picker-trigger"
onClick={() => setOpen((v) => !v)}
disabled={loading}
title={selected?.title ?? t('designSystemPicker.select')}
>
{selected && selected.swatches && selected.swatches.length > 0 ? (
<span className="project-ds-picker-swatches" aria-hidden>
{selected.swatches.slice(0, 3).map((sw, i) => (
<span
key={`pdsp-sw-${i}`}
className="project-ds-picker-swatch"
style={{ background: sw }}
/>
))}
</span>
) : (
<Icon name="palette" size={13} />
)}
<span className="project-ds-picker-label">
{loading
? t('designSystemPicker.loading')
: selected?.title ?? t('designSystemPicker.select')}
</span>
<Icon name="chevron-down" size={11} />
</button>
{open && anchor && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="project-ds-picker-popover"
data-testid="project-ds-picker-popover"
style={{ top: anchor.top, left: anchor.left, width: anchor.width }}
>
<div className="project-ds-picker-search">
<Icon name="search" size={12} />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('designSystemPicker.searchCompactPlaceholder')}
data-testid="project-ds-picker-search"
/>
</div>
<div className="project-ds-picker-body">
<div className="project-ds-picker-list" role="listbox">
<button
type="button"
className={`project-ds-picker-option${selectedId == null ? ' active' : ''}`}
role="option"
aria-selected={selectedId == null}
onMouseEnter={() => setHovered(null)}
onFocus={() => setHovered(null)}
onClick={() => {
onChange(null);
setOpen(false);
}}
>
<div className="project-ds-picker-option-head">
<span className="project-ds-picker-option-title">{t('designSystemPicker.noneTitle')}</span>
{selectedId == null ? (
<span
className="project-ds-picker-option-check"
data-testid="project-ds-picker-option-none-check"
>
<Icon name="check" size={13} strokeWidth={2} />
</span>
) : null}
</div>
<span className="project-ds-picker-option-summary">
{t('designSystemPicker.noneSummary')}
</span>
</button>
{filtered.map((d) => {
const active = d.id === selectedId;
const localizedCategory = localizeDesignSystemCategory(locale, d.category);
const localizedSummary = localizeDesignSystemSummary(locale, d);
return (
<button
key={d.id}
type="button"
className={`project-ds-picker-option${active ? ' active' : ''}`}
role="option"
aria-selected={active}
onMouseEnter={() => setHovered(d)}
onFocus={() => setHovered(d)}
onClick={() => {
onChange(d.id);
setOpen(false);
}}
data-testid={`project-ds-picker-option-${d.id}`}
>
<div className="project-ds-picker-option-head">
<span className="project-ds-picker-option-title">{d.title}</span>
{d.category ? (
<span className="project-ds-picker-option-cat">{localizedCategory}</span>
) : null}
{active ? (
<span
className="project-ds-picker-option-check"
data-testid={`project-ds-picker-option-${d.id}-check`}
>
<Icon name="check" size={13} strokeWidth={2} />
</span>
) : null}
</div>
{d.swatches && d.swatches.length > 0 ? (
<div className="project-ds-picker-option-swatches">
{d.swatches.slice(0, 6).map((sw, i) => (
<span
key={`${d.id}-sw-${i}`}
className="project-ds-picker-option-swatch"
style={{ background: sw }}
/>
))}
</div>
) : null}
{localizedSummary ? (
<span className="project-ds-picker-option-summary">{localizedSummary}</span>
) : null}
</button>
);
})}
{filtered.length === 0 ? (
<div className="project-ds-picker-empty">{t('designSystemPicker.empty')}</div>
) : null}
</div>
<div className="project-ds-picker-preview" data-testid="project-ds-picker-preview">
{previewTarget ? (
<>
<div className="project-ds-picker-preview-head">
<strong>{previewTarget.title}</strong>
{previewTarget.category ? (
<span className="project-ds-picker-preview-cat">
{localizeDesignSystemCategory(locale, previewTarget.category)}
</span>
) : null}
{previewHtml ? (
<button
type="button"
className="project-ds-picker-preview-expand"
data-testid="project-ds-picker-preview-expand"
onClick={() => setFullscreenPreview(true)}
title={t('designSystemPicker.openPreview')}
aria-label={t('designSystemPicker.openPreview')}
>
<Icon name="eye" size={16} strokeWidth={1.9} />
</button>
) : null}
</div>
{previewTarget.summary ? (
<p className="project-ds-picker-preview-summary">
{localizeDesignSystemSummary(locale, previewTarget)}
</p>
) : null}
{previewTarget.swatches && previewTarget.swatches.length > 0 ? (
<div className="project-ds-picker-preview-swatches">
{previewTarget.swatches.slice(0, 12).map((sw, i) => (
<span
key={`${previewTarget.id}-pv-sw-${i}`}
className="project-ds-picker-preview-swatch"
style={{ background: sw }}
title={sw}
/>
))}
</div>
) : null}
{previewLoading ? (
<div className="project-ds-picker-preview-loading">
{t('designSystemPicker.loadingPreview')}
</div>
) : previewHtml ? (
<iframe
className="project-ds-picker-preview-frame"
data-testid="project-ds-picker-preview-frame"
srcDoc={previewHtml}
sandbox="allow-same-origin"
title={t('designSystemPicker.previewFrameTitle', { title: previewTarget.title })}
/>
) : (
<div className="project-ds-picker-preview-empty">
{t('designSystemPicker.noPreview')}
</div>
)}
</>
) : (
<div className="project-ds-picker-preview-empty">
{t('designSystemPicker.previewHint')}
</div>
)}
</div>
</div>
</div>,
document.body,
)
: null}
{fullscreenPreview && previewTarget && previewHtml && typeof document !== 'undefined'
? createPortal(
<div
className="project-ds-picker-fullscreen"
role="dialog"
aria-label={t('designSystemPicker.fullscreenAria', { title: previewTarget.title })}
onClick={(event) => {
if (event.target === event.currentTarget) {
setFullscreenPreview(false);
}
}}
>
<div className="project-ds-picker-fullscreen-frame">
<div className="project-ds-picker-fullscreen-head">
<div className="project-ds-picker-fullscreen-title">
<strong>{previewTarget.title}</strong>
{previewTarget.category ? (
<span className="project-ds-picker-preview-cat">
{localizeDesignSystemCategory(locale, previewTarget.category)}
</span>
) : null}
</div>
<button
type="button"
className="project-ds-picker-fullscreen-close"
onClick={() => setFullscreenPreview(false)}
aria-label={t('designSystemPicker.closeFullscreen')}
title={t('designSystemPicker.closeEsc')}
>
<Icon name="close" size={18} strokeWidth={2.1} />
</button>
</div>
<iframe
className="project-ds-picker-fullscreen-iframe"
srcDoc={previewHtml}
sandbox="allow-same-origin"
title={t('designSystemPicker.fullscreenFrameTitle', { title: previewTarget.title })}
/>
</div>
</div>,
document.body,
)
: null}
</div>
);
}

View file

@ -118,6 +118,8 @@ import {
} from '../comments';
import { AppChromeHeader } from './AppChromeHeader';
import { AvatarMenu } from './AvatarMenu';
import { HandoffButton } from './HandoffButton';
import { ProjectDesignSystemPicker } from './ProjectDesignSystemPicker';
import { ChatPane } from './ChatPane';
import type { ChatSendMeta } from './ChatComposer';
import {
@ -3023,6 +3025,20 @@ export function ProjectView({
[project, onProjectChange],
);
const handleChangeDesignSystemId = useCallback(
(nextId: string | null) => {
if ((project.designSystemId ?? null) === nextId) return;
const updated: Project = {
...project,
designSystemId: nextId,
updatedAt: Date.now(),
};
onProjectChange(updated);
void patchProject(project.id, { designSystemId: nextId });
},
[project, onProjectChange],
);
const handleSaveInstructions = useCallback(async () => {
const value = instructionsDraft.trim() || undefined;
// After a save, land on the review panel so the saved value is read
@ -3041,13 +3057,15 @@ export function ProjectView({
}, [project, onProjectChange, instructionsDraft]);
const projectMeta = useMemo(() => {
// Design system is rendered by the adjacent picker chip — keep the
// bare meta string focused on skill / mode so the two surfaces
// don't show the same label twice.
const summary =
skills.find((s) => s.id === project.skillId) ??
designTemplates.find((s) => s.id === project.skillId);
const skill = summary?.name;
const ds = designSystems.find((d) => d.id === project.designSystemId)?.title;
return [skill, ds].filter(Boolean).join(' · ') || t('project.metaFreeform');
}, [skills, designTemplates, designSystems, project.skillId, project.designSystemId, t]);
return skill ?? t('project.metaFreeform');
}, [skills, designTemplates, project.skillId, t]);
const designSystemProject = useMemo(() => {
if (project.metadata?.importedFrom !== 'design-system') return null;
@ -3530,17 +3548,20 @@ export function ProjectView({
onBack={onBack}
backLabel={t('project.backToProjects')}
actions={(
<AvatarMenu
config={config}
agents={agents}
daemonLive={daemonLive}
onModeChange={onModeChange}
onAgentChange={onAgentChange}
onAgentModelChange={onAgentModelChange}
onOpenSettings={onOpenSettings}
onRefreshAgents={onRefreshAgents}
onBack={onBack}
/>
<>
<HandoffButton projectId={project.id} />
<AvatarMenu
config={config}
agents={agents}
daemonLive={daemonLive}
onModeChange={onModeChange}
onAgentChange={onAgentChange}
onAgentModelChange={onAgentModelChange}
onOpenSettings={onOpenSettings}
onRefreshAgents={onRefreshAgents}
onBack={onBack}
/>
</>
)}
>
<div className="app-project-title">
@ -3563,6 +3584,11 @@ export function ProjectView({
{project.name}
</span>
<span className="meta" data-testid="project-meta">{projectMeta}</span>
<ProjectDesignSystemPicker
designSystems={designSystems}
selectedId={project.designSystemId ?? null}
onChange={handleChangeDesignSystemId}
/>
{(project.customInstructions ?? '').trim() ? (
<button
type="button"

View file

@ -6,9 +6,10 @@
// onOpen / onViewAll) so the strip can be reused later by other
// surfaces (e.g. an in-project quick-switcher pane).
import type { Project } from '../types';
import { Icon } from './Icon';
import { useT } from '../i18n';
import type { Project, ProjectDisplayStatus } from '../types';
import { Icon } from './Icon';
import { STATUS_LABEL_KEYS } from './DesignsTab';
interface Props {
projects: Project[];
@ -55,29 +56,43 @@ export function RecentProjectsStrip({
</button>
</header>
<div className="recent-projects__row" role="list">
{recent.map((project) => (
<button
key={project.id}
type="button"
role="listitem"
className="recent-projects__card"
onClick={() => onOpen(project.id)}
title={project.name}
data-project-id={project.id}
>
<div className="recent-projects__card-thumb" aria-hidden>
<span className="recent-projects__card-glyph">
{projectGlyph(project.name)}
</span>
</div>
<div className="recent-projects__card-meta">
<div className="recent-projects__card-name">{project.name}</div>
<div className="recent-projects__card-time">
{relativeTime(project.updatedAt, t)}
{recent.map((project) => {
const status: ProjectDisplayStatus = project.status?.value ?? 'not_started';
const isActive =
status === 'running' || status === 'queued' || status === 'awaiting_input';
return (
<button
key={project.id}
type="button"
role="listitem"
className="recent-projects__card"
onClick={() => onOpen(project.id)}
title={project.name}
data-project-id={project.id}
>
<div className="recent-projects__card-thumb" aria-hidden>
<span className="recent-projects__card-glyph">
{projectGlyph(project.name)}
</span>
</div>
</div>
</button>
))}
<div className="recent-projects__card-meta">
<div className="recent-projects__card-name">{project.name}</div>
<div className="recent-projects__card-time">
<span
className={`recent-projects__card-status recent-projects__card-status-${status}`}
>
{isActive ? (
<span className="recent-projects__card-status-dot" aria-hidden />
) : null}
{statusLabel(status, t)}
</span>
<span className="recent-projects__card-sep" aria-hidden>·</span>
{relativeTime(project.updatedAt, t)}
</div>
</div>
</button>
);
})}
</div>
</section>
);
@ -91,6 +106,13 @@ function projectGlyph(name: string): string {
return String.fromCodePoint(codePoint).toUpperCase();
}
function statusLabel(
status: ProjectDisplayStatus,
t: ReturnType<typeof useT>,
): string {
return t(STATUS_LABEL_KEYS[status]);
}
function relativeTime(ts: number, t: ReturnType<typeof useT>): string {
const diff = Date.now() - ts;
const min = 60_000;

View file

@ -18,6 +18,7 @@ import {
trackSettingsLanguageClick,
trackSettingsLocalCliClick,
trackSettingsExecutionModeTabClick,
trackSettingsMediaProvidersClick,
trackSettingsNotificationsClick,
trackSettingsPrivacyClick,
trackSettingsView,
@ -4802,6 +4803,7 @@ function MediaProvidersSection({
onChange: () => void;
}) {
const { t } = useI18n();
const analytics = useAnalytics();
const [reloadRunning, setReloadRunning] = useState(false);
const [reloadNotice, setReloadNotice] = useState<{ kind: 'error' | 'success'; message: string } | null>(null);
const [visibleApiKeys, setVisibleApiKeys] = useState<ReadonlySet<string>>(
@ -4933,7 +4935,14 @@ function MediaProvidersSection({
className={`ghost media-provider-reload-btn${
reloadNotice?.kind === 'success' ? ' is-success-flash' : ''
}`}
onClick={() => void handleReload()}
onClick={() => {
trackSettingsMediaProvidersClick(analytics.track, {
page_name: 'settings',
area: 'media_providers',
element: 'reload',
});
void handleReload();
}}
disabled={reloadRunning}
aria-live="polite"
>
@ -5014,6 +5023,15 @@ function MediaProvidersSection({
placeholder={isSavedState ? t('settings.connectorsReplaceKeyPlaceholder') : t('settings.mediaProviderPlaceholder')}
aria-label={`${provider.label} ${t('settings.mediaProviderApiKey')}`}
disabled={disabled}
onFocus={() => {
trackSettingsMediaProvidersClick(analytics.track, {
page_name: 'settings',
area: 'media_providers',
element: 'key_input',
providers_id: provider.id,
is_configured: clearable,
});
}}
onChange={(e) => updateProvider(provider, { apiKey: e.target.value })}
/>
<button
@ -5036,6 +5054,15 @@ function MediaProvidersSection({
placeholder={provider.defaultBaseUrl || t('settings.mediaProviderBaseUrlPlaceholder')}
aria-label={`${provider.label} ${t('settings.mediaProviderBaseUrl')}`}
disabled={disabled}
onFocus={() => {
trackSettingsMediaProvidersClick(analytics.track, {
page_name: 'settings',
area: 'media_providers',
element: 'url_input',
providers_id: provider.id,
is_configured: clearable,
});
}}
onChange={(e) => updateProvider(provider, { baseUrl: e.target.value })}
/>
{supportsCustomModel ? (
@ -5052,6 +5079,17 @@ function MediaProvidersSection({
className="ghost"
disabled={!clearable}
onClick={() => {
trackSettingsMediaProvidersClick(analytics.track, {
page_name: 'settings',
area: 'media_providers',
element: 'clear',
providers_id: provider.id,
// The click reports the state at the moment the
// user pressed Clear; the actual clear only lands
// after they confirm the dialog below, but the
// dashboard cares about the intent signal.
is_configured: clearable,
});
// Match the existing window.confirm guard the rest of
// the app uses for destructive actions (conversation
// delete, design delete, file delete in FileWorkspace).

View file

@ -831,6 +831,72 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
) : null}
</section>
{proposals.length > 0 ? (
<section className="automations-saved" aria-label="Automation evolution proposals">
<div className="automations-section-head">
<div>
<h2 className="automations-section__label">Evolution proposals</h2>
<p className="automations-section__sub">
Review automation output before it changes memory, skills, or design systems.
</p>
</div>
<span className="automations-section__meta">{proposals.length} pending</span>
</div>
<ul className="automations-saved__list">
{proposals.map((proposal) => {
const isBusy = proposalBusyId === proposal.id;
return (
<li key={proposal.id} className="automation-row">
<div className="automation-row__main">
<span className="automation-row__icon">
<Icon
name={proposal.targetKind === 'design-system' ? 'sliders' : 'sparkles'}
size={15}
/>
</span>
<span className="automation-row__content">
<span className="automation-row__title">{proposal.title}</span>
<span className="automation-row__meta">
<span>{proposalTargetLabel(proposal.targetKind)}</span>
<span aria-hidden="true">·</span>
<span>{proposalActionLabel(proposal.action)}</span>
<span aria-hidden="true">·</span>
<span>{proposal.reviewPolicy}</span>
</span>
<span className="automation-row__prompt">{proposal.summary}</span>
{proposal.patch.diffSummary ? (
<span className="automation-row__last-run">
{proposal.patch.diffSummary}
</span>
) : null}
</span>
</div>
<div className="automation-row__actions">
<button
type="button"
className="automation-row__btn"
onClick={() => reviewProposal(proposal.id, 'apply')}
disabled={isBusy}
>
<Icon name="check" size={12} />
<span>Apply</span>
</button>
<button
type="button"
className="automation-row__btn automation-row__btn--danger"
onClick={() => reviewProposal(proposal.id, 'reject')}
disabled={isBusy}
>
Reject
</button>
</div>
</li>
);
})}
</ul>
</section>
) : null}
<section className="automations-ingest" aria-label="Source ingestion">
<div className="automations-section-head">
<div>
@ -963,96 +1029,53 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
</div>
</section>
{proposals.length > 0 ? (
<section className="automations-saved" aria-label="Automation evolution proposals">
<div className="automations-section-head">
<div>
<h2 className="automations-section__label">Evolution proposals</h2>
<p className="automations-section__sub">
Review automation output before it changes memory, skills, or design systems.
</p>
</div>
<span className="automations-section__meta">{proposals.length} pending</span>
</div>
<ul className="automations-saved__list">
{proposals.map((proposal) => {
const isBusy = proposalBusyId === proposal.id;
return (
<li key={proposal.id} className="automation-row">
<div className="automation-row__main">
<span className="automation-row__icon">
<Icon
name={proposal.targetKind === 'design-system' ? 'sliders' : 'sparkles'}
size={15}
/>
</span>
<span className="automation-row__content">
<span className="automation-row__title">{proposal.title}</span>
<span className="automation-row__meta">
<span>{proposalTargetLabel(proposal.targetKind)}</span>
<span aria-hidden="true">·</span>
<span>{proposalActionLabel(proposal.action)}</span>
<span aria-hidden="true">·</span>
<span>{proposal.reviewPolicy}</span>
</span>
<span className="automation-row__prompt">{proposal.summary}</span>
{proposal.patch.diffSummary ? (
<span className="automation-row__last-run">
{proposal.patch.diffSummary}
</span>
) : null}
</span>
</div>
<div className="automation-row__actions">
<button
type="button"
className="automation-row__btn"
onClick={() => reviewProposal(proposal.id, 'apply')}
disabled={isBusy}
>
<Icon name="check" size={12} />
<span>Apply</span>
</button>
<button
type="button"
className="automation-row__btn automation-row__btn--danger"
onClick={() => reviewProposal(proposal.id, 'reject')}
disabled={isBusy}
>
Reject
</button>
</div>
</li>
);
})}
</ul>
</section>
) : null}
<section className="automations-templates" aria-label="Automation templates">
<div className="automations-section-head">
<div>
<div className="automations-templates__head">
<div className="automations-templates__head-copy">
<h2 className="automations-section__label">Templates</h2>
<p className="automations-section__sub">
Orbit and live artifacts are templates inside the same automation flow.
</p>
</div>
<div className="automations-template-tabs" role="tablist" aria-label="Template filters">
{TEMPLATE_FILTERS.map((filter) => (
<span className="automations-section__meta">
{filteredTemplates.length} of {templates.length}
</span>
</div>
<div
className="automations-template-tabs"
role="tablist"
aria-label="Template filters"
>
{TEMPLATE_FILTERS.map((filter) => {
const count = filterTemplates(templates, filter.id).length;
const isActive = templateFilter === filter.id;
return (
<button
key={filter.id}
type="button"
role="tab"
aria-selected={templateFilter === filter.id}
className={`automations-template-tab${templateFilter === filter.id ? ' is-active' : ''}`}
aria-selected={isActive}
className={`automations-template-tab${isActive ? ' is-active' : ''}`}
onClick={() => setTemplateFilter(filter.id)}
>
{filter.label}
<span className="automations-template-tab__label">{filter.label}</span>
<span className="automations-template-tab__count">{count}</span>
</button>
))}
</div>
);
})}
</div>
{filteredTemplates.length === 0 ? (
<div className="automations-templates__empty" role="status">
<span className="automations-templates__empty-icon" aria-hidden="true">
<Icon name="sparkles" size={16} />
</span>
<div>
<strong>No templates in this category yet.</strong>
<p>Try a different filter, or start from a blank automation.</p>
</div>
</div>
) : null}
<div className="automations-templates__grid">
{filteredTemplates.map((template) => (
<button

View file

@ -50,10 +50,7 @@ interface Props {
const STORAGE_KEY = 'open-design:workspace-tabs:v1';
const OPEN_WORKSPACE_TAB_EVENT = 'open-design:workspace-tabs:open';
const MAX_VISIBLE_CHROME_TABS = 16;
const MAX_SEARCH_RESULTS = 80;
const TAB_STRIP_CONTROL_WIDTH = 112;
const MIN_VISIBLE_TAB_WIDTH = 76;
export function openWorkspaceTab(route: Route): void {
window.dispatchEvent(
@ -238,32 +235,57 @@ function syncStateToRoute(state: WorkspaceTabsState, route: Route): WorkspaceTab
return normalizeTabsState({ tabs: nextTabs, activeTabId: replacement.id });
}
function visibleChromeTabs(
tabs: WorkspaceChromeTab[],
activeTabId: string,
maxVisibleTabs: number,
): WorkspaceChromeTab[] {
if (tabs.length <= maxVisibleTabs) return tabs;
const activeIndex = Math.max(0, tabs.findIndex((tab) => tab.id === activeTabId));
const half = Math.floor(maxVisibleTabs / 2);
const start = Math.max(0, Math.min(activeIndex - half, tabs.length - maxVisibleTabs));
return tabs.slice(start, start + maxVisibleTabs);
}
function normalizeSearch(value: string): string {
return value.trim().toLocaleLowerCase();
}
interface HoverPreviewState {
tabId: string;
anchorLeft: number;
anchorRight: number;
anchorBottom: number;
}
const HOVER_PREVIEW_DELAY_MS = 380;
export function WorkspaceTabsBar({ route, projects }: Props) {
const t = useT();
const [state, setState] = useState<WorkspaceTabsState>(() => initialTabsState(route));
const [tabsMenuOpen, setTabsMenuOpen] = useState(false);
const [query, setQuery] = useState('');
const [maxVisibleTabs, setMaxVisibleTabs] = useState(MAX_VISIBLE_CHROME_TABS);
const [hoverPreview, setHoverPreview] = useState<HoverPreviewState | null>(null);
const stripRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const hoverTimerRef = useRef<number | null>(null);
function clearHoverTimer() {
if (hoverTimerRef.current !== null) {
window.clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
}
function scheduleHoverPreview(tabId: string, element: HTMLElement) {
clearHoverTimer();
hoverTimerRef.current = window.setTimeout(() => {
const rect = element.getBoundingClientRect();
setHoverPreview({
tabId,
anchorLeft: rect.left,
anchorRight: rect.right,
anchorBottom: rect.bottom,
});
}, HOVER_PREVIEW_DELAY_MS);
}
function dismissHoverPreview() {
clearHoverTimer();
setHoverPreview(null);
}
useEffect(() => () => clearHoverTimer(), []);
const projectById = useMemo(
() => new Map(projects.map((project) => [project.id, project])),
@ -278,11 +300,6 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
() => new Map(displayTabs.map((tab) => [tab.id, tab])),
[displayTabs],
);
const visibleTabs = useMemo(
() => visibleChromeTabs(state.tabs, state.activeTabId, maxVisibleTabs),
[state.tabs, state.activeTabId, maxVisibleTabs],
);
const hiddenTabCount = Math.max(0, state.tabs.length - visibleTabs.length);
const filteredTabs = useMemo(() => {
const needle = normalizeSearch(query);
const source = needle
@ -301,6 +318,11 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
setState((current) => syncStateToRoute(current, route));
}, [route]);
// Scroll the active tab into view when it changes. The strip itself
// is native-scrollable horizontally (see CSS), so we just nudge the
// browser's scroll position whenever the active id flips — keeps the
// current tab visible after a route change even if the user had
// scrolled the strip elsewhere.
useEffect(() => {
function onOpenWorkspaceTab(event: Event) {
const detail = (event as CustomEvent<{ route?: Route }>).detail;
@ -323,29 +345,13 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
useEffect(() => {
const stripElement = stripRef.current;
if (!stripElement) return undefined;
const measuredStrip: HTMLDivElement = stripElement;
function updateVisibleCapacity() {
if (measuredStrip.clientWidth === 0) {
setMaxVisibleTabs(MAX_VISIBLE_CHROME_TABS);
return;
}
const available = Math.max(0, measuredStrip.clientWidth - TAB_STRIP_CONTROL_WIDTH);
const next = Math.max(
1,
Math.min(MAX_VISIBLE_CHROME_TABS, Math.floor(available / MIN_VISIBLE_TAB_WIDTH)),
);
setMaxVisibleTabs((current) => (current === next ? current : next));
if (!stripElement) return;
const activeEl = stripElement.querySelector<HTMLElement>('.workspace-tab.is-active');
if (!activeEl) return;
if (typeof activeEl.scrollIntoView === 'function') {
activeEl.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
updateVisibleCapacity();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateVisibleCapacity);
return () => window.removeEventListener('resize', updateVisibleCapacity);
}
const observer = new ResizeObserver(updateVisibleCapacity);
observer.observe(measuredStrip);
return () => observer.disconnect();
}, []);
}, [state.activeTabId, state.tabs.length]);
useEffect(() => {
try {
@ -400,6 +406,7 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
activeTabId: tab.id,
}));
setTabsMenuOpen(false);
dismissHoverPreview();
navigate(routeForTab(tab));
}
@ -414,6 +421,7 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
}
function closeTab(tabId: string) {
dismissHoverPreview();
const normalized = normalizeTabsState(state);
const closingIndex = normalized.tabs.findIndex((tab) => tab.id === tabId);
if (closingIndex < 0) return;
@ -444,7 +452,14 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
aria-label="Open workspaces"
ref={stripRef}
>
{visibleTabs.map((tab) => {
{/* Render every open tab the strip itself scrolls horizontally
when the tabs exceed the available chrome width. Previous
behaviour sliced to `visibleChromeTabs(...)` and squeezed
the rest behind a "+N more" chip, which squished the entire
chrome horizontally. The search-tabs popover still acts as
a keyboard surface for finding a tab that's scrolled out of
view. */}
{state.tabs.map((tab) => {
const display = displayTabById.get(tab.id) ?? displayTabFor(tab, projectById, t);
const active = tab.id === state.activeTabId;
return (
@ -453,13 +468,16 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
className={`workspace-tab${active ? ' is-active' : ''}`}
role="tab"
aria-selected={active}
title={display.title}
aria-describedby={hoverPreview?.tabId === tab.id ? 'workspace-tab-preview' : undefined}
onMouseEnter={(event) => scheduleHoverPreview(tab.id, event.currentTarget)}
onMouseLeave={dismissHoverPreview}
>
<button
type="button"
className="workspace-tab__main"
onClick={() => openTab(tab)}
title={display.title}
onFocus={(event) => scheduleHoverPreview(tab.id, event.currentTarget.parentElement ?? event.currentTarget)}
onBlur={dismissHoverPreview}
>
<span className="workspace-tab__icon" aria-hidden>
<Icon name={display.icon} size={14} />
@ -470,7 +488,6 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
type="button"
className="workspace-tab__close"
aria-label={t('common.close')}
title={t('common.close')}
onClick={() => closeTab(tab.id)}
>
<Icon name="close" size={10} />
@ -478,110 +495,156 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
</div>
);
})}
{hiddenTabCount > 0 ? (
<button
type="button"
className="workspace-tab workspace-tab--overflow"
onClick={() => setTabsMenuOpen(true)}
title="Show hidden tabs"
>
{hiddenTabCount} more
</button>
) : null}
<div className="workspace-tabs-actions" ref={menuRef}>
<button
type="button"
className="workspace-tabs-new-btn"
onClick={createNewTab}
title="New tab"
aria-label="New tab"
>
<Icon name="plus" size={14} />
</button>
<button
type="button"
className={`workspace-tabs-icon-btn${tabsMenuOpen ? ' is-active' : ''}`}
onClick={() => setTabsMenuOpen((open) => !open)}
title="Search tabs"
aria-label="Search tabs"
aria-haspopup="dialog"
aria-expanded={tabsMenuOpen}
>
<Icon name="search" size={15} />
</button>
{tabsMenuOpen && typeof document !== 'undefined'
? createPortal(
<div
className="workspace-tabs-popover"
role="dialog"
aria-label="Search tabs"
ref={popoverRef}
>
<div className="workspace-tabs-search">
<Icon name="search" size={14} />
<input
ref={searchInputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search tabs"
aria-label="Search tabs"
/>
</div>
<div className="workspace-tabs-popover__section">
<span>Open tabs</span>
<span>{state.tabs.length}</span>
</div>
<div className="workspace-tabs-list" role="listbox" aria-label="Open tabs">
{filteredTabs.length > 0 ? (
filteredTabs.map((display) => {
const active = display.id === state.activeTabId;
return (
<div
key={display.id}
className={`workspace-tabs-list__item${active ? ' is-active' : ''}`}
role="option"
aria-selected={active}
>
<button
type="button"
className="workspace-tabs-list__main"
onClick={() => openTab(display.tab)}
>
<span className="workspace-tabs-list__icon" aria-hidden>
<Icon name={display.icon} size={15} />
</span>
<span className="workspace-tabs-list__text">
<span className="workspace-tabs-list__title">{display.title}</span>
<span className="workspace-tabs-list__meta">{display.meta}</span>
</span>
</button>
<button
type="button"
className="workspace-tabs-list__close"
onClick={() => closeTab(display.id)}
title={t('common.close')}
aria-label={t('common.close')}
>
<Icon name="close" size={11} />
</button>
</div>
);
})
) : (
<div className="workspace-tabs-empty">No tabs found</div>
)}
</div>
</div>,
document.body,
)
: null}
</div>
</div>
<div className="workspace-tabs-drag" aria-hidden />
<div className="workspace-tabs-actions" ref={menuRef}>
<button
type="button"
className="workspace-tabs-new-btn"
onClick={createNewTab}
title="New tab"
aria-label="New tab"
>
<Icon name="plus" size={14} />
</button>
<button
type="button"
className={`workspace-tabs-icon-btn${tabsMenuOpen ? ' is-active' : ''}`}
onClick={() => setTabsMenuOpen((open) => !open)}
title="Search tabs"
aria-label="Search tabs"
aria-haspopup="dialog"
aria-expanded={tabsMenuOpen}
>
<Icon name="search" size={15} />
</button>
{tabsMenuOpen && typeof document !== 'undefined'
? createPortal(
<div
className="workspace-tabs-popover"
role="dialog"
aria-label="Search tabs"
ref={popoverRef}
>
<div className="workspace-tabs-search">
<Icon name="search" size={14} />
<input
ref={searchInputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search tabs"
aria-label="Search tabs"
/>
</div>
<div className="workspace-tabs-popover__section">
<span>Open tabs</span>
<span>{state.tabs.length}</span>
</div>
<div className="workspace-tabs-list" role="listbox" aria-label="Open tabs">
{filteredTabs.length > 0 ? (
filteredTabs.map((display) => {
const active = display.id === state.activeTabId;
return (
<div
key={display.id}
className={`workspace-tabs-list__item${active ? ' is-active' : ''}`}
role="option"
aria-selected={active}
>
<button
type="button"
className="workspace-tabs-list__main"
onClick={() => openTab(display.tab)}
>
<span className="workspace-tabs-list__icon" aria-hidden>
<Icon name={display.icon} size={15} />
</span>
<span className="workspace-tabs-list__text">
<span className="workspace-tabs-list__title">{display.title}</span>
<span className="workspace-tabs-list__meta">{display.meta}</span>
</span>
</button>
<button
type="button"
className="workspace-tabs-list__close"
onClick={() => closeTab(display.id)}
title={t('common.close')}
aria-label={t('common.close')}
>
<Icon name="close" size={11} />
</button>
</div>
);
})
) : (
<div className="workspace-tabs-empty">No tabs found</div>
)}
</div>
</div>,
document.body,
)
: null}
</div>
{hoverPreview && typeof document !== 'undefined' && !tabsMenuOpen
? createPortal(
(() => {
const previewTab = state.tabs.find((tab) => tab.id === hoverPreview.tabId);
if (!previewTab) return null;
const previewDisplay = displayTabById.get(previewTab.id)
?? displayTabFor(previewTab, projectById, t);
const previewDetail = describePreviewDetail(previewTab, projectById);
const previewWidth = 240;
const anchorCenter = (hoverPreview.anchorLeft + hoverPreview.anchorRight) / 2;
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1024;
const left = Math.max(
10,
Math.min(viewportWidth - previewWidth - 10, anchorCenter - previewWidth / 2),
);
return (
<div
id="workspace-tab-preview"
className="workspace-tab-preview"
role="tooltip"
style={{ left, top: hoverPreview.anchorBottom + 6, width: previewWidth }}
>
<div className="workspace-tab-preview__icon" aria-hidden>
<Icon name={previewDisplay.icon} size={16} />
</div>
<div className="workspace-tab-preview__text">
<div className="workspace-tab-preview__title">{previewDisplay.title}</div>
<div className="workspace-tab-preview__meta">{previewDisplay.meta}</div>
{previewDetail ? (
<div className="workspace-tab-preview__detail">{previewDetail}</div>
) : null}
</div>
</div>
);
})(),
document.body,
)
: null}
</header>
);
}
function describePreviewDetail(
tab: WorkspaceChromeTab,
projectById: Map<string, Project>,
): string | null {
if (tab.kind === 'project') {
if (tab.fileName) return tab.fileName;
const project = projectById.get(tab.projectId);
const brief = project?.pendingPrompt?.trim() || project?.customInstructions?.trim();
if (brief) {
return brief.length > 120 ? `${brief.slice(0, 117)}` : brief;
}
return null;
}
if (tab.kind === 'marketplace') {
return tab.pluginId ? tab.pluginId : null;
}
return null;
}
function displayTabFor(
tab: WorkspaceChromeTab,
projectById: Map<string, Project>,

View file

@ -19,7 +19,7 @@
// - `action` — discriminated union the HomeView dispatcher matches
// on. The rail component itself stays presentational.
import type { ProjectKind } from '@open-design/contracts';
import type { ProjectKind, ProjectMetadata } from '@open-design/contracts';
import type { DefaultScenarioPluginId } from '@open-design/contracts';
import type { IconName } from '../Icon';
@ -41,12 +41,14 @@ export type ChipAction =
pluginId: ChipScenarioPluginId;
projectKind: ProjectKind;
inputs?: Record<string, unknown>;
projectMetadata?: ProjectMetadata;
}
| {
kind: 'apply-figma-migration';
pluginId: 'od-figma-migration';
projectKind: ProjectKind;
inputs?: Record<string, unknown>;
projectMetadata?: ProjectMetadata;
}
| { kind: 'create-plugin' }
| { kind: 'import-folder' }
@ -89,6 +91,23 @@ export const HOME_HERO_CHIPS: ReadonlyArray<HomeHeroChip> = [
projectKind: 'prototype',
},
},
{
id: 'live-artifact',
label: 'Live artifact',
icon: 'refresh',
group: 'create',
hint: 'Build a refreshable artifact backed by connector or local data.',
action: {
kind: 'apply-scenario',
pluginId: 'example-live-artifact',
projectKind: 'prototype',
projectMetadata: {
kind: 'prototype',
intent: 'live-artifact',
fidelity: 'high-fidelity',
},
},
},
{
id: 'deck',
label: 'Slide deck',

View file

@ -370,6 +370,13 @@ const SUBCATEGORIES: readonly SubcategoryDef[] = [
starterPrompt: 'Create an Open Design plugin that generates a polished slide deck from a narrative brief.',
test: byMode('deck'),
},
{
parent: 'create',
slug: 'live-artifact',
label: 'Live artifact',
starterPrompt: 'Create an Open Design plugin that generates a refreshable dashboard, report, or data-backed artifact.',
test: byAnySlug('live-artifact', 'live-dashboard', 'refreshable-dashboard', 'live-report', 'refreshable-report'),
},
{
parent: 'create',
slug: 'design-system',

View file

@ -11,7 +11,7 @@
// override rather than AND-compose so a featured pick is never
// accidentally hidden behind a still-selected category pill.
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
applyFacetSelection,
@ -29,6 +29,13 @@ export type FilterMode = 'all' | 'featured';
interface UsePluginFacetsArgs {
plugins: InstalledPluginRecord[];
preferDefaultFacet?: boolean;
// External selection driven by the Home hero chip rail. When this
// value changes to a new (non-null) selection, the hook applies it,
// overriding both the user's manual pick and the default-facet
// bootstrap. We track the last-applied identity so the user can
// still click a different category afterwards without the effect
// snapping back on every re-render.
presetSelection?: FacetSelection | null;
}
export interface UsePluginFacetsResult {
@ -56,6 +63,7 @@ const EMPTY_SELECTION: FacetSelection = {
export function usePluginFacets({
plugins,
preferDefaultFacet = true,
presetSelection = null,
}: UsePluginFacetsArgs): UsePluginFacetsResult {
const [mode, setMode] = useState<FilterMode>('all');
const [selection, setSelection] = useState<FacetSelection>(EMPTY_SELECTION);
@ -65,6 +73,7 @@ export function usePluginFacets({
// initializer) handles the realistic case where `args.plugins` is
// empty at first paint and arrives a tick later.
const [bootstrapped, setBootstrapped] = useState(false);
const lastAppliedPresetKeyRef = useRef<string | null>(null);
// Atoms are infrastructure pieces (`code-import`, `patch-edit`) that
// are not user-facing on the home grid; the original section already
@ -102,6 +111,22 @@ export function usePluginFacets({
setBootstrapped(true);
}, [bootstrapped, preferDefaultFacet, visiblePlugins.length, catalog]);
// Sync an externally-driven selection (the Home chip rail) into the
// facet state. We only apply a preset once per identity so the user
// can still click a different facet pill afterwards without the
// effect snapping back. Setting `bootstrapped` here also prevents
// the default-facet effect above from overriding the preset on the
// first non-empty render.
useEffect(() => {
if (!presetSelection) return;
const key = `${presetSelection.category ?? ''}::${presetSelection.subcategory ?? ''}`;
if (lastAppliedPresetKeyRef.current === key) return;
lastAppliedPresetKeyRef.current = key;
setSelection(presetSelection);
setMode((current) => (current === 'featured' ? 'all' : current));
setBootstrapped(true);
}, [presetSelection]);
// The visual-appeal sort is applied at `visiblePlugins` derivation
// (above), so any downstream `applyFacetSelection` slice preserves
// the ranking. We do not re-sort here because filter + featured

View file

@ -319,6 +319,7 @@ export const FR_DESIGN_SYSTEM_CATEGORIES: Record<string, string> = {
'Media & Consumer': 'Médias & grand public',
'Social & Messaging': 'Réseaux sociaux & messageries',
Automotive: 'Automobile',
Product: 'Produit',
'Editorial & Print': 'Éditorial & print',
'Editorial · Studio': 'Éditorial · Studio',
'Retro & Nostalgic': 'Rétro & nostalgique',

View file

@ -454,6 +454,22 @@ export const en: Dict = {
'homeHero.chip.figmaHint': 'Migrate a Figma frame into the active design system.',
'homeHero.chip.folderHint': 'Import an existing local folder and continue editing.',
'homeHero.chip.templateHint': 'Start from a bundled template.',
'designSystemPicker.select': 'Choose design system',
'designSystemPicker.loading': 'Loading design systems…',
'designSystemPicker.searchPlaceholder': 'Search design systems (title / category / summary)',
'designSystemPicker.searchCompactPlaceholder': 'Search design systems',
'designSystemPicker.noneTitle': 'No design system',
'designSystemPicker.noneSummary': 'Let the model choose freely',
'designSystemPicker.empty': 'No matching design systems',
'designSystemPicker.openPreview': 'Open preview',
'designSystemPicker.loadingPreview': 'Loading preview…',
'designSystemPicker.noPreview': 'No preview page. Open Design Systems to view the full preview.',
'designSystemPicker.previewHint': 'Hover a design system to preview it',
'designSystemPicker.fullscreenAria': '{title} full-screen preview',
'designSystemPicker.closeFullscreen': 'Close full-screen preview',
'designSystemPicker.closeEsc': 'Close (Esc)',
'designSystemPicker.previewFrameTitle': '{title} preview',
'designSystemPicker.fullscreenFrameTitle': '{title} full-screen preview',
'recentProjects.title': 'Recent projects',
'recentProjects.viewAll': 'View all',
'recentProjects.empty': 'No projects yet — type a prompt to start one.',

View file

@ -317,6 +317,22 @@ export const fr: Dict = {
'entry.navHome': 'Accueil',
'entry.navProjects': 'Projets',
'entry.navDesignSystems': 'Systèmes de design',
'designSystemPicker.select': 'Choisir un design system',
'designSystemPicker.loading': 'Chargement des design systems…',
'designSystemPicker.searchPlaceholder': 'Rechercher des design systems (titre / catégorie / résumé)',
'designSystemPicker.searchCompactPlaceholder': 'Rechercher des design systems',
'designSystemPicker.noneTitle': 'Aucun design system',
'designSystemPicker.noneSummary': 'Laisser le modèle choisir librement',
'designSystemPicker.empty': 'Aucun design system correspondant',
'designSystemPicker.openPreview': 'Ouvrir laperçu',
'designSystemPicker.loadingPreview': 'Chargement de laperçu…',
'designSystemPicker.noPreview': 'Aucune page daperçu. Ouvrez Design Systems pour voir laperçu complet.',
'designSystemPicker.previewHint': 'Survolez un design system pour le prévisualiser',
'designSystemPicker.fullscreenAria': 'Aperçu plein écran de {title}',
'designSystemPicker.closeFullscreen': 'Fermer laperçu plein écran',
'designSystemPicker.closeEsc': 'Fermer (Esc)',
'designSystemPicker.previewFrameTitle': 'Aperçu de {title}',
'designSystemPicker.fullscreenFrameTitle': 'Aperçu plein écran de {title}',
'entry.helpAria': 'Aide',
'entry.helpMenuAria': 'Menu d\'aide',
'entry.helpGetHelp': 'Obtenir de l\'aide sur GitHub',

View file

@ -454,6 +454,22 @@ export const zhCN: Dict = {
'homeHero.chip.figmaHint': '将 Figma 画框迁移到当前设计体系。',
'homeHero.chip.folderHint': '导入本地文件夹并继续编辑。',
'homeHero.chip.templateHint': '从内置模板开始。',
'designSystemPicker.select': '选择设计系统',
'designSystemPicker.loading': '正在加载设计系统…',
'designSystemPicker.searchPlaceholder': '搜索设计系统(标题 / 分类 / 摘要)',
'designSystemPicker.searchCompactPlaceholder': '搜索设计系统',
'designSystemPicker.noneTitle': '不指定设计系统',
'designSystemPicker.noneSummary': '让模型自由发挥',
'designSystemPicker.empty': '没有匹配的设计系统',
'designSystemPicker.openPreview': '打开预览',
'designSystemPicker.loadingPreview': '正在加载预览…',
'designSystemPicker.noPreview': '无预览页面 — 在「设计系统」中查看完整预览',
'designSystemPicker.previewHint': '将鼠标悬停在左侧条目上查看预览',
'designSystemPicker.fullscreenAria': '{title} 全屏预览',
'designSystemPicker.closeFullscreen': '关闭全屏预览',
'designSystemPicker.closeEsc': '关闭 (Esc)',
'designSystemPicker.previewFrameTitle': '{title} 预览',
'designSystemPicker.fullscreenFrameTitle': '{title} 全屏预览',
'recentProjects.title': '最近项目',
'recentProjects.viewAll': '查看全部',
'recentProjects.empty': '还没有项目 — 输入 Prompt 即可开始。',

View file

@ -362,6 +362,22 @@ export const zhTW: Dict = {
'entry.navHome': '主頁',
'entry.navProjects': '專案',
'entry.navDesignSystems': '設計體系',
'designSystemPicker.select': '選擇設計系統',
'designSystemPicker.loading': '正在載入設計系統…',
'designSystemPicker.searchPlaceholder': '搜尋設計系統(標題 / 分類 / 摘要)',
'designSystemPicker.searchCompactPlaceholder': '搜尋設計系統',
'designSystemPicker.noneTitle': '不指定設計系統',
'designSystemPicker.noneSummary': '讓模型自由發揮',
'designSystemPicker.empty': '沒有符合的設計系統',
'designSystemPicker.openPreview': '開啟預覽',
'designSystemPicker.loadingPreview': '正在載入預覽…',
'designSystemPicker.noPreview': '沒有預覽頁面 — 請在「設計系統」中查看完整預覽',
'designSystemPicker.previewHint': '將滑鼠懸停在左側項目上查看預覽',
'designSystemPicker.fullscreenAria': '{title} 全螢幕預覽',
'designSystemPicker.closeFullscreen': '關閉全螢幕預覽',
'designSystemPicker.closeEsc': '關閉 (Esc)',
'designSystemPicker.previewFrameTitle': '{title} 預覽',
'designSystemPicker.fullscreenFrameTitle': '{title} 全螢幕預覽',
'entry.helpAria': '說明',
'entry.helpMenuAria': '說明選單',
'entry.helpGetHelp': '在 GitHub 取得協助',

View file

@ -737,6 +737,22 @@ export interface Dict {
'homeHero.chip.figmaHint': string;
'homeHero.chip.folderHint': string;
'homeHero.chip.templateHint': string;
'designSystemPicker.select': string;
'designSystemPicker.loading': string;
'designSystemPicker.searchPlaceholder': string;
'designSystemPicker.searchCompactPlaceholder': string;
'designSystemPicker.noneTitle': string;
'designSystemPicker.noneSummary': string;
'designSystemPicker.empty': string;
'designSystemPicker.openPreview': string;
'designSystemPicker.loadingPreview': string;
'designSystemPicker.noPreview': string;
'designSystemPicker.previewHint': string;
'designSystemPicker.fullscreenAria': string;
'designSystemPicker.closeFullscreen': string;
'designSystemPicker.closeEsc': string;
'designSystemPicker.previewFrameTitle': string;
'designSystemPicker.fullscreenFrameTitle': string;
'recentProjects.title': string;
'recentProjects.viewAll': string;
'recentProjects.empty': string;

File diff suppressed because it is too large Load diff

View file

@ -1741,6 +1741,36 @@ export async function openFolderDialog(): Promise<string | null> {
}
}
// Hand-off (open project in local app). The daemon enumerates installed
// editors on demand (PATH probe + macOS bundle scan), and the POST
// endpoint spawns the chosen app with the project's resolvedDir.
export async function fetchHostEditors(): Promise<
import('@open-design/contracts').HostEditorsResponse
> {
const resp = await fetch('/api/editors');
if (!resp.ok) throw new Error(`GET /api/editors failed: ${resp.status}`);
return (await resp.json()) as import('@open-design/contracts').HostEditorsResponse;
}
export async function openProjectInEditor(
projectId: string,
editorId: import('@open-design/contracts').HostEditorId,
): Promise<import('@open-design/contracts').OpenProjectInEditorResponse> {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/open-in`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ editorId }),
},
);
if (!resp.ok) {
const body = await readApiErrorBody(resp);
throw new Error(body.message);
}
return (await resp.json()) as import('@open-design/contracts').OpenProjectInEditorResponse;
}
export async function fetchDesignSystemPreview(id: string): Promise<string | null> {
try {
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}/preview`);

View file

@ -17,6 +17,7 @@
/* Hero — large centered prompt input */
.home-hero {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
@ -26,42 +27,33 @@
.home-hero__brand {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
}
.home-hero__brand-mark {
position: relative;
width: 38px;
height: 38px;
border-radius: 34%;
background: transparent;
border: 0;
color: #202020;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--bg-panel);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 2px;
}
[data-theme="dark"] .home-hero__brand-mark {
color: #ffffff;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) .home-hero__brand-mark {
color: #ffffff;
}
}
.home-hero__brand-mark svg {
position: absolute;
inset: -1px;
width: calc(100% + 2px);
height: calc(100% + 2px);
display: block;
.home-hero__brand-mark img {
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
.home-hero__brand-name {
font-family: var(--serif);
font-weight: 600;
font-size: 18px;
font-size: 16px;
color: var(--text-strong);
}
.home-hero__title {
@ -74,10 +66,7 @@
text-align: center;
}
.home-hero__subtitle {
/* Pair tightly with the title above; the larger separation
belongs between this subtitle and the type tab bar / chat
box that follow. */
margin: -8px 0 0;
margin: 0;
color: var(--text-muted);
font-size: 13.5px;
text-align: center;
@ -92,87 +81,128 @@
border-radius: 3px;
background: var(--bg-subtle);
}
/* ---------- Output-type tab bar ----------
Sits above the input card and lets the user pick a creation
mode (Prototype, Slide deck, Image, Video, etc.) before typing.
Folder-tab visual: the bar itself has no baseline border; the
input card's top edge serves as the baseline. The active tab
borrows the card's panel color and border so it reads as a
continuous "flap" attached to the card top this gives the
strong "tabs belong to this chat box" cue that a free-floating
underline cannot. Labels are text-only; icons were removed
because the seven labels (Prototype, Live artifact, Slide deck,
Image, Video, HyperFrames, Audio) already disambiguate at the
sizes used here and the icons added visual noise without an
information gain. */
.home-hero__type-tabs {
/* Example prompt panel below the Home chip rail. */
.home-hero__examples {
position: relative;
z-index: 0;
width: 100%;
max-width: 720px;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 2px;
position: relative;
z-index: 1;
/* The parent .home-hero uses `gap: 14px`; pulling the bottom
margin back by 14px collapses that gap so the active tab can
sit flush against the card top (with its own -1px margin
covering the card's top border). The top margin restores the
breathing room above the bar after the title/subtitle pair. */
margin: 14px 0 -14px;
padding: 0 12px;
margin-top: 4px;
}
.home-hero__type-tab {
.home-hero__examples-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 6px;
}
.home-hero__examples-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.home-hero__examples-title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.01em;
}
.home-hero__examples-close {
appearance: none;
display: inline-flex;
align-items: center;
padding: 8px 14px;
justify-content: center;
width: 22px;
height: 22px;
border: 1px solid transparent;
border-radius: 8px 8px 0 0;
/* Reach 1px past the bar baseline so an active tab's bottom edge
overlays the input card's top border, hiding it for the width
of the tab and producing the folder-tab merge. */
margin-bottom: -1px;
border-radius: 999px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
line-height: 1.3;
letter-spacing: -0.005em;
cursor: pointer;
transition: color 120ms ease, background-color 120ms ease, border-color 120ms ease;
transition: color 140ms cubic-bezier(0.23, 1, 0.32, 1),
background-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.home-hero__type-tab:hover:not(:disabled) {
color: var(--text);
background: color-mix(in srgb, var(--bg-subtle) 70%, transparent);
}
.home-hero__type-tab.is-active {
background: var(--bg-panel);
border-color: var(--border);
/* Paint the bottom border with the panel color so it visually
erases the input card's own top border for the width of this
tab, completing the folder-tab merge. */
border-bottom-color: var(--bg-panel);
.home-hero__examples-close:hover:not(:disabled) {
color: var(--text-strong);
font-weight: 600;
background: var(--bg-subtle);
border-color: var(--border);
}
.home-hero__type-tab.is-active:hover:not(:disabled) {
background: var(--bg-panel);
}
.home-hero__type-tab.is-pending {
opacity: 0.65;
}
.home-hero__type-tab:disabled {
.home-hero__examples-close:disabled {
cursor: not-allowed;
opacity: 0.45;
opacity: 0;
pointer-events: none;
}
.home-hero__type-tab:focus-visible {
.home-hero__examples-close:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--accent-tint);
}
.home-hero__examples-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
}
.home-hero__example-card {
appearance: none;
position: relative;
display: flex;
align-items: flex-start;
text-align: left;
padding: 10px 28px 10px 12px;
min-height: 64px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-panel);
color: var(--text);
font-size: 12px;
line-height: 1.45;
cursor: pointer;
transition: color 200ms cubic-bezier(0.23, 1, 0.32, 1),
background-color 200ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 200ms cubic-bezier(0.23, 1, 0.32, 1),
transform 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.home-hero__example-card:hover:not(:disabled) {
border-color: var(--border-strong, var(--border));
background: color-mix(in srgb, var(--bg-panel) 92%, var(--bg-subtle));
}
.home-hero__example-card:active:not(:disabled) {
transform: translateY(1px);
}
.home-hero__example-card:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.home-hero__example-card:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--accent-tint);
}
.home-hero__example-card-body {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--text);
font-weight: 500;
}
.home-hero__example-card-arrow {
position: absolute;
right: 10px;
bottom: 10px;
color: var(--text-muted);
opacity: 0.7;
transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1),
color 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.home-hero__example-card:hover:not(:disabled) .home-hero__example-card-arrow {
color: var(--text-strong);
opacity: 1;
}
.home-hero__input-card {
position: relative;
z-index: 2;
width: 100%;
max-width: 720px;
background: var(--bg-panel);
@ -183,10 +213,6 @@
display: flex;
flex-direction: column;
gap: 10px;
/* Card top sits exactly at the tab bar bottom (tab bar margin
cancels the parent flex gap) so the active folder-tab can
overlap the card border by 1px and read as one continuous
surface with the chat box. */
margin-top: 0;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
@ -839,7 +865,7 @@
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-panel);
color: var(--text-muted);
color: var(--text);
cursor: pointer;
display: inline-flex;
align-items: center;
@ -853,12 +879,6 @@
color: var(--accent);
transform: translateY(-0.5px);
}
.home-hero__attach > svg,
.home-hero__submit > svg {
width: 20px;
height: 20px;
flex: 0 0 20px;
}
.home-hero__hint {
font-size: 11.5px;
color: var(--text-soft);
@ -983,6 +1003,8 @@
intents recede without losing affordance.
------------------------------------------------------------ */
.home-hero__rail {
position: relative;
z-index: 1;
width: 100%;
max-width: 720px;
display: flex;

View file

@ -103,4 +103,48 @@
.recent-projects__card-time {
font-size: 11.5px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.recent-projects__card-sep {
color: var(--text-muted);
}
.recent-projects__card-status {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.recent-projects__card-status-running {
color: var(--accent);
}
.recent-projects__card-status-awaiting_input {
color: var(--amber);
}
.recent-projects__card-status-queued,
.recent-projects__card-status-not_started,
.recent-projects__card-status-canceled {
color: var(--text-muted);
}
.recent-projects__card-status-succeeded {
color: var(--green);
}
.recent-projects__card-status-failed {
color: var(--red);
}
.recent-projects__card-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.recent-projects__card-status-running .recent-projects__card-status-dot {
animation: recent-projects-pulse 1.4s ease-in-out infinite;
}
@keyframes recent-projects-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}

View file

@ -5,10 +5,10 @@
.automations-view {
display: flex;
flex-direction: column;
gap: 22px;
gap: 28px;
width: min(1080px, 100%);
margin: 0 auto;
padding: 10px 4px 80px;
padding: 16px 4px 96px;
}
.automations-hero {
@ -16,7 +16,7 @@
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: end;
padding: 4px 0 8px;
padding: 4px 0 4px;
}
.automations-hero__copy {
@ -24,35 +24,42 @@
}
.automations-hero__eyebrow {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
display: inline-flex;
align-items: center;
height: 22px;
margin-bottom: 12px;
padding: 0 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent, #79a8ff) 12%, transparent);
color: color-mix(in srgb, var(--accent, #79a8ff) 78%, var(--text));
font-size: 10.5px;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--accent, #79a8ff) 72%, var(--text-muted));
}
.automations-hero__title {
margin: 0;
font-size: 34px;
line-height: 1.05;
font-weight: 680;
font-size: 36px;
line-height: 1.04;
font-weight: 700;
letter-spacing: -0.012em;
color: var(--text-strong);
}
.automations-hero__lede {
margin: 8px 0 0;
margin: 10px 0 0;
max-width: 620px;
font-size: 13.5px;
font-size: 14px;
line-height: 1.55;
color: var(--text-muted);
}
.automations-hero__actions {
display: flex;
align-items: center;
gap: 12px;
align-items: stretch;
gap: 8px;
height: 34px;
}
.automations-metrics {
@ -64,56 +71,69 @@
border-radius: 8px;
background: var(--border);
box-shadow: var(--shadow-xs);
height: 100%;
}
.automations-metric {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 68px;
padding: 8px 10px;
align-items: center;
gap: 5px;
min-width: 58px;
padding: 0 10px;
background: var(--bg-panel);
line-height: 1;
}
.automations-metric__value {
color: var(--text-strong);
font-size: 15px;
font-weight: 700;
font-size: 13px;
font-weight: 720;
line-height: 1;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.automations-metric__label {
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
font-size: 10px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.automations-view__new {
appearance: none;
display: inline-flex;
align-items: center;
gap: 7px;
height: 36px;
padding: 0 14px;
border: 1px solid color-mix(in srgb, var(--accent, #79a8ff) 38%, var(--border));
gap: 6px;
height: 34px;
padding: 0 13px;
border: 1px solid color-mix(in srgb, var(--accent, #79a8ff) 48%, var(--border));
border-radius: 8px;
background: color-mix(in srgb, var(--accent, #79a8ff) 14%, var(--bg-panel));
background: color-mix(in srgb, var(--accent, #79a8ff) 16%, var(--bg-panel));
color: var(--text-strong);
font-size: 13px;
font-weight: 650;
font-size: 12.5px;
font-weight: 680;
letter-spacing: -0.005em;
cursor: pointer;
box-shadow: var(--shadow-xs);
box-shadow:
0 1px 0 color-mix(in srgb, #fff 70%, transparent) inset,
var(--shadow-xs);
transition:
background-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
transform 140ms cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 140ms cubic-bezier(0.23, 1, 0.32, 1);
white-space: nowrap;
}
.automations-view__new:hover {
background: color-mix(in srgb, var(--accent, #79a8ff) 20%, var(--bg-panel));
border-color: color-mix(in srgb, var(--accent, #79a8ff) 55%, var(--border));
background: color-mix(in srgb, var(--accent, #79a8ff) 22%, var(--bg-panel));
border-color: color-mix(in srgb, var(--accent, #79a8ff) 60%, var(--border));
box-shadow:
0 1px 0 color-mix(in srgb, #fff 70%, transparent) inset,
0 6px 16px color-mix(in srgb, var(--accent, #79a8ff) 18%, transparent);
}
.automations-view__new:active {
@ -136,28 +156,32 @@
.automations-section-head {
display: flex;
align-items: center;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.automations-section__label {
margin: 0;
font-size: 14px;
font-weight: 680;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--text-strong);
}
.automations-section__sub {
margin: 4px 0 0;
font-size: 12.5px;
line-height: 1.45;
line-height: 1.5;
color: var(--text-muted);
}
.automations-section__meta {
color: var(--text-muted);
font-size: 12px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
/* ---------- Saved automations ---------- */
@ -180,71 +204,83 @@
.automation-empty {
appearance: none;
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 12px;
grid-template-columns: 40px minmax(0, 1fr);
gap: 14px;
align-items: center;
width: 100%;
min-height: 74px;
padding: 14px 16px;
border: 1px dashed color-mix(in srgb, var(--border-strong, var(--border)) 82%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-panel) 76%, transparent);
min-height: 84px;
padding: 18px 20px;
border: 1px dashed color-mix(in srgb, var(--border-strong, var(--border)) 72%, transparent);
border-radius: 12px;
background: color-mix(in srgb, var(--bg-panel) 70%, transparent);
color: var(--text);
text-align: left;
cursor: pointer;
transition:
background-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.automation-empty:hover {
border-style: solid;
border-color: color-mix(in srgb, var(--accent, #79a8ff) 38%, var(--border-strong, var(--border)));
background: var(--bg-panel);
box-shadow: var(--shadow-xs);
}
.automation-empty__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent, #79a8ff) 14%, var(--bg-subtle));
color: var(--text-strong);
width: 38px;
height: 38px;
border-radius: 10px;
background: color-mix(in srgb, var(--accent, #79a8ff) 16%, var(--bg-subtle));
color: color-mix(in srgb, var(--accent, #79a8ff) 76%, var(--text-strong));
}
.automation-empty__body {
display: flex;
flex-direction: column;
gap: 3px;
gap: 4px;
min-width: 0;
}
.automation-empty__body strong {
color: var(--text-strong);
font-size: 13px;
font-size: 13.5px;
font-weight: 680;
}
.automation-empty__body span {
color: var(--text-muted);
font-size: 12px;
font-size: 12.5px;
line-height: 1.45;
}
.automation-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
gap: 14px;
align-items: center;
padding: 12px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 10px;
background: var(--bg-panel);
box-shadow: var(--shadow-xs);
transition:
background-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1);
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.automation-row:hover {
background: color-mix(in srgb, var(--bg-panel) 86%, var(--bg-subtle));
background: color-mix(in srgb, var(--bg-panel) 88%, var(--bg-subtle));
border-color: var(--border-strong);
box-shadow:
0 1px 2px color-mix(in srgb, #000 3%, transparent),
0 6px 18px color-mix(in srgb, #000 4%, transparent);
}
.automation-row.is-paused {
@ -669,30 +705,48 @@
.automations-templates {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
}
.automations-templates__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.automations-templates__head-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.automations-template-tabs {
display: inline-flex;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 3px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
gap: 4px 18px;
padding: 4px 0 6px;
border-bottom: 1px solid var(--border-soft, var(--border));
}
.automations-template-tab {
appearance: none;
height: 26px;
padding: 0 9px;
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 2px;
border: 0;
border-radius: 6px;
border-radius: 0;
background: transparent;
color: var(--text-muted);
font-size: 11.5px;
font-weight: 650;
font-size: 13px;
font-weight: 600;
letter-spacing: -0.005em;
cursor: pointer;
transition:
color 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.automations-template-tab:hover {
@ -700,42 +754,106 @@
}
.automations-template-tab.is-active {
background: var(--bg-subtle);
color: var(--text-strong);
box-shadow: var(--shadow-xs);
font-weight: 700;
}
.automations-template-tab__label {
white-space: nowrap;
}
.automations-template-tab__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-subtle) 90%, transparent);
color: var(--text-muted);
font-size: 10.5px;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.automations-template-tab.is-active .automations-template-tab__count {
background: color-mix(in srgb, var(--accent, #d77757) 18%, var(--bg-subtle));
color: color-mix(in srgb, var(--accent, #d77757) 70%, var(--text-strong));
}
.automations-templates__empty {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 14px 16px;
border: 1px dashed color-mix(in srgb, var(--border-strong, var(--border)) 70%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-panel) 76%, transparent);
}
.automations-templates__empty-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent, #79a8ff) 14%, var(--bg-subtle));
color: color-mix(in srgb, var(--accent, #79a8ff) 70%, var(--text-strong));
}
.automations-templates__empty strong {
display: block;
color: var(--text-strong);
font-size: 13px;
font-weight: 680;
}
.automations-templates__empty p {
margin: 2px 0 0;
color: var(--text-muted);
font-size: 12px;
}
.automations-templates__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
gap: 12px;
}
.automation-template-card {
appearance: none;
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 12px;
grid-template-columns: 40px minmax(0, 1fr);
gap: 14px;
align-items: start;
min-height: 164px;
min-height: 168px;
width: 100%;
padding: 14px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 12px;
background: var(--bg-panel);
color: var(--text);
text-align: left;
cursor: pointer;
box-shadow: var(--shadow-xs);
transition:
background-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
background-color 160ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 160ms cubic-bezier(0.23, 1, 0.32, 1),
transform 160ms cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.automation-template-card:hover {
background: color-mix(in srgb, var(--bg-panel) 84%, var(--bg-subtle));
background: color-mix(in srgb, var(--bg-panel) 86%, var(--bg-subtle));
border-color: var(--border-strong);
transform: translateY(-1px);
transform: translateY(-2px);
box-shadow:
0 1px 2px color-mix(in srgb, #000 3%, transparent),
0 10px 24px color-mix(in srgb, #000 5%, transparent);
}
.automation-template-card:active {
@ -1587,16 +1705,16 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.automations-templates__grid {
grid-template-columns: 1fr;
}
.automation-ingest-controls,
.automation-ingest-fields,
.automation-ingest-footer {
grid-template-columns: 1fr;
}
.automations-templates__grid {
grid-template-columns: 1fr;
}
.automation-row {
grid-template-columns: 1fr;
}

View file

@ -0,0 +1,75 @@
// @vitest-environment jsdom
//
// Regression test for "every PostHog event ships with app_version='0.0.0'".
//
// `useAppVersion()` reads /api/version at runtime so the same web bundle
// reports the daemon-pinned version even when running against a newer or
// older daemon during dev. The previous implementation stored the fetched
// version in a `useRef`, which silently broke the contract: ref writes do
// NOT trigger a re-render, so the hook kept returning '0.0.0' forever and
// every downstream useEffect that depended on `appVersion`
// (`client.register({ app_version })` in particular) never re-ran with
// the real version. PostHog dashboards then showed `app_version=0.0.0`
// on every event.
//
// This test goes red on the `useRef` version and green on the `useState`
// version: after the mocked /api/version resolves, the hook must return
// the fetched value, not the boot placeholder.
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppVersion } from '../src/analytics/provider';
describe('useAppVersion', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith('/api/version')) {
return new Response(
JSON.stringify({
version: {
version: '1.2.3',
channel: 'development',
packaged: false,
platform: 'darwin',
arch: 'arm64',
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response('not found', { status: 404 });
}) as unknown as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it('boots to the 0.0.0 placeholder before the fetch resolves', () => {
const { result } = renderHook(() => useAppVersion());
expect(result.current).toBe('0.0.0');
});
it('updates to the fetched version once /api/version resolves', async () => {
const { result } = renderHook(() => useAppVersion());
await waitFor(() => {
expect(result.current).toBe('1.2.3');
});
});
it('keeps the 0.0.0 placeholder when the fetch fails', async () => {
globalThis.fetch = vi.fn(async () => new Response('boom', { status: 500 })) as unknown as typeof fetch;
const { result } = renderHook(() => useAppVersion());
// Let any pending microtasks settle so a buggy implementation has the
// same opportunity to "succeed" with stale data as the happy path.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(result.current).toBe('0.0.0');
});
});

View file

@ -8,6 +8,11 @@ import { ChatPane } from '../../src/components/ChatPane';
import type { Conversation, ProjectMetadata } from '../../src/types';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));

View file

@ -9,6 +9,11 @@ import { DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX } from '../../src/design-system-a
import type { ChatMessage, Conversation, ProjectMetadata } from '../../src/types';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));

View file

@ -393,5 +393,8 @@ describe('HomeHero plugin picker', () => {
fireEvent.click(screen.getByTitle('Plugin: Prototype Plugin'));
expect(onOpenPluginDetails).toHaveBeenCalledWith(active);
const activeChipText = screen.getByTestId('home-hero-active-plugin').textContent;
expect(activeChipText).toContain('Prototype');
expect(activeChipText).not.toContain('Plugin');
});
});

View file

@ -133,6 +133,15 @@ describe('HomeHero intent rail', () => {
pluginId: 'example-hyperframes',
projectKind: 'video',
});
expect(findChip('live-artifact')).toBeUndefined();
expect(findChip('live-artifact')?.action).toMatchObject({
kind: 'apply-scenario',
pluginId: 'example-live-artifact',
projectKind: 'prototype',
projectMetadata: {
kind: 'prototype',
intent: 'live-artifact',
fidelity: 'high-fidelity',
},
});
});
});

View file

@ -79,11 +79,11 @@ const HIDDEN_DEFAULT_PLUGIN = {
},
};
// The Prototype / Live-artifact chips now bind to the bundled
// `example-web-prototype` plugin (which ships its own seed +
// layouts + checklist) instead of the generic od-new-generation
// router. Mirror that here so the chip-applies test can find a
// matching plugin record and the apply call resolves to the new id.
// The Prototype chip binds to the bundled `example-web-prototype`
// plugin (which ships its own seed + layouts + checklist) instead of
// the generic od-new-generation router. Mirror that here so the
// chip-applies test can find a matching plugin record and the apply
// call resolves to the new id.
const WEB_PROTOTYPE_PLUGIN = {
...DEFAULT_PLUGIN,
id: 'example-web-prototype',
@ -141,6 +141,35 @@ const WEB_PROTOTYPE_PLUGIN = {
},
};
const LIVE_ARTIFACT_PLUGIN = {
...DEFAULT_PLUGIN,
id: 'example-live-artifact',
title: 'Live Artifact',
source: '/tmp/live-artifact',
fsPath: '/tmp/live-artifact',
manifest: {
...DEFAULT_PLUGIN.manifest,
name: 'example-live-artifact',
title: 'Live Artifact',
description: 'Create refreshable, auditable Open Design artifacts.',
od: {
kind: 'scenario',
taskKind: 'new-generation',
mode: 'prototype',
scenario: 'live',
useCase: {
query: 'Create refreshable, auditable Open Design artifacts backed by connector or local data.',
},
context: {
skills: [{ path: './SKILL.md' }],
},
pipeline: {
stages: [{ id: 'generate', atoms: ['file-write', 'live-artifact'] }],
},
},
},
};
const AUTHORING_DEFAULT_SCENARIO_INPUTS = {
artifactKind: 'Open Design plugin',
audience: 'Open Design plugin authors',
@ -205,6 +234,21 @@ const WEB_PROTOTYPE_APPLY_RESULT = {
},
};
const LIVE_ARTIFACT_APPLY_RESULT = {
...AUTHORING_APPLY_RESULT,
query: LIVE_ARTIFACT_PLUGIN.manifest.od.useCase.query,
inputs: [],
appliedPlugin: {
...AUTHORING_APPLY_RESULT.appliedPlugin,
snapshotId: 'snap-live-artifact',
pluginId: 'example-live-artifact',
inputs: {},
},
projectMetadata: {
skillId: 'live-artifact',
},
};
describe('HomeView prompt handoff', () => {
afterEach(() => {
vi.unstubAllGlobals();
@ -423,7 +467,7 @@ describe('HomeView prompt handoff', () => {
}));
});
it('applies Home rail Prototype chip against the bundled web-prototype scenario plugin', async () => {
it('binds the Home rail Prototype chip locally and applies it on submit', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
@ -444,11 +488,12 @@ describe('HomeView prompt handoff', () => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
@ -456,6 +501,17 @@ describe('HomeView prompt handoff', () => {
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-plugin').textContent).toContain('Prototype');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
))).toBe(false);
fireEvent.change(screen.getByTestId('home-hero-input'), {
target: { value: 'Build a pricing-page prototype.' },
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
expect.anything(),
@ -472,17 +528,84 @@ describe('HomeView prompt handoff', () => {
template: 'the bundled web prototype seed',
},
});
expect(screen.getByTestId('home-hero-prompt-slot-fidelity')).toBeTruthy();
expect(screen.getByTestId('home-hero-prompt-slot-artifactKind')).toBeTruthy();
expect(screen.getByTestId('home-hero-prompt-slot-designSystem')).toBeTruthy();
expect(screen.getByTestId('home-hero-prompt-slot-template')).toBeTruthy();
// Template-backed inputs are represented inline in the prompt, so
// the structured form below should not duplicate the same fields.
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-web-prototype',
projectKind: 'prototype',
prompt: 'Build a pricing-page prototype.',
})));
expect(screen.queryByRole('alert')).toBeNull();
});
it('applies output-type chips immediately when replacing an existing prompt', async () => {
it('binds the Home rail Live artifact chip with live-artifact metadata and applies it on submit', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN, LIVE_ARTIFACT_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply')) {
return new Response(JSON.stringify(LIVE_ARTIFACT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
fireEvent.click(await screen.findByTestId('home-hero-rail-live-artifact'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-plugin').textContent).toContain('Live artifact');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply')
))).toBe(false);
fireEvent.change(screen.getByTestId('home-hero-input'), {
target: { value: 'Build a refreshable Stripe revenue dashboard.' },
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-live-artifact/apply',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply')
));
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
inputs: {},
});
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-live-artifact',
appliedPluginSnapshotId: 'snap-live-artifact',
projectKind: 'prototype',
projectMetadata: expect.objectContaining({
kind: 'prototype',
intent: 'live-artifact',
fidelity: 'high-fidelity',
}),
prompt: 'Build a refreshable Stripe revenue dashboard.',
})));
expect(screen.queryByRole('alert')).toBeNull();
});
it('switches output-type chips without replacing an existing prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
@ -517,10 +640,13 @@ describe('HomeView prompt handoff', () => {
fireEvent.change(input, { target: { value: 'Keep my current brief' } });
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
expect.anything(),
));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-plugin').textContent).toContain('Prototype');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
))).toBe(false);
expect((input as HTMLTextAreaElement).value).toBe('Keep my current brief');
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
});

View file

@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DesignSystemSummary } from '../../src/types';
vi.mock('../../src/providers/registry', () => ({
fetchDesignSystemPreview: vi.fn(),
}));
import { ProjectDesignSystemPicker } from '../../src/components/ProjectDesignSystemPicker';
import { I18nProvider, type Locale } from '../../src/i18n';
import { fetchDesignSystemPreview } from '../../src/providers/registry';
const fetchDesignSystemPreviewMock = vi.mocked(fetchDesignSystemPreview);
const designSystems: DesignSystemSummary[] = [
{
id: 'clay',
title: 'Clay',
summary: 'Friendly tactile product UI.',
category: 'Product',
swatches: ['#f4efe7', '#25211d'],
},
{
id: 'noir',
title: 'Editorial Noir',
summary: 'High-contrast editorial system.',
category: 'Editorial',
swatches: ['#111111', '#f7f0e8'],
},
];
beforeEach(() => {
fetchDesignSystemPreviewMock.mockResolvedValue('<html><body><h1>Preview</h1></body></html>');
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ProjectDesignSystemPicker', () => {
function renderPicker(
props: Partial<ComponentProps<typeof ProjectDesignSystemPicker>> = {},
locale: Locale = 'zh-CN',
) {
return render(
<I18nProvider initial={locale}>
<ProjectDesignSystemPicker
designSystems={designSystems}
selectedId="noir"
onChange={vi.fn()}
{...props}
/>
</I18nProvider>,
);
}
it('checks the active project design system and previews it by default', async () => {
renderPicker();
fireEvent.click(screen.getByTestId('project-ds-picker-trigger'));
const activeOption = await screen.findByTestId('project-ds-picker-option-noir');
expect(activeOption.getAttribute('aria-selected')).toBe('true');
expect(screen.getByTestId('project-ds-picker-option-noir-check')).toBeTruthy();
await waitFor(() => {
expect(fetchDesignSystemPreviewMock).toHaveBeenCalledWith('noir');
});
expect(await screen.findByTestId('project-ds-picker-preview-frame')).toBeTruthy();
});
it('updates the preview target on hover and opens the fullscreen preview', async () => {
renderPicker();
fireEvent.click(screen.getByTestId('project-ds-picker-trigger'));
await screen.findByTestId('project-ds-picker-preview-frame');
fireEvent.mouseEnter(screen.getByTestId('project-ds-picker-option-clay'));
await waitFor(() => {
expect(fetchDesignSystemPreviewMock).toHaveBeenCalledWith('clay');
});
fireEvent.click(await screen.findByTestId('project-ds-picker-preview-expand'));
expect(screen.getByRole('dialog')).toBeTruthy();
expect(screen.getAllByText('Clay').length).toBeGreaterThan(0);
fireEvent.click(screen.getByLabelText('关闭全屏预览'));
expect(screen.queryByRole('dialog')).toBeNull();
});
it('uses localized picker copy and design-system category labels', async () => {
renderPicker({}, 'fr');
fireEvent.click(screen.getByTestId('project-ds-picker-trigger'));
expect(screen.getByPlaceholderText('Rechercher des design systems')).toBeTruthy();
expect(await screen.findByText('Produit')).toBeTruthy();
expect(screen.getByText('Aucun design system')).toBeTruthy();
});
});

View file

@ -32,6 +32,11 @@ const saveTabs = vi.fn();
const chatPaneProps: { onDeleteConversation?: (id: string) => Promise<void> | void } = {};
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => ((value: string) => value),
}));

View file

@ -21,6 +21,11 @@ import {
import { fetchPreviewComments } from '../../src/providers/registry';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));

View file

@ -24,6 +24,11 @@ import {
import { fetchPreviewComments } from '../../src/providers/registry';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));

View file

@ -22,6 +22,11 @@ import {
import { fetchPreviewComments } from '../../src/providers/registry';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));

View file

@ -65,6 +65,11 @@ function artifactProjectFile(name: string, mtime: number): ProjectFile {
}
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => ((value: string) => value),
}));
@ -638,12 +643,16 @@ describe('ProjectView daemon cleanup', () => {
}],
warnings: [],
});
let streamCallCount = 0;
streamViaDaemon.mockImplementation(async (options: {
handlers: { onDone: () => void };
onRunCreated?: (runId: string) => void;
}) => {
options.onRunCreated?.('run-ds-1');
options.handlers.onDone();
streamCallCount += 1;
options.onRunCreated?.(`run-ds-${streamCallCount}`);
if (streamCallCount === 1) {
options.handlers.onDone();
}
});
chatPaneSpy.mockClear();

View file

@ -31,6 +31,11 @@ const playSound = vi.fn();
const showCompletionNotification = vi.fn();
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));

View file

@ -23,6 +23,11 @@ import {
import { fetchPreviewComments } from '../../src/providers/registry';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));

View file

@ -11,6 +11,11 @@ import { navigate, type Route } from '../../src/router';
import type { Project } from '../../src/types';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => {
const labels: Record<string, string> = {
'app.brand': 'Open Design',

View file

@ -181,6 +181,7 @@ describe('buildFacetCatalog', () => {
expect((catalog.subcategory.create ?? []).map((o) => o.slug)).toEqual([
'prototype',
'deck',
'live-artifact',
'design-system',
'hyperframes',
'image',

View file

@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import postcss, { type Rule } from 'postcss';
import { describe, expect, it } from 'vitest';
describe('plugin info preview pane styles', () => {
it('keeps plugin preview sidebar content away from the pane edges', () => {
const css = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
const root = postcss.parse(css, { from: 'src/index.css' });
const topLevelRules = root.nodes.filter(
(node): node is Rule => node.type === 'rule',
);
const topLevelSelectors = topLevelRules.map((rule) => rule.selector);
expect(css).toContain('.ds-modal-sidebar');
expect(css).toContain('scrollbar-gutter: stable;');
expect(topLevelSelectors).toContain('.plugin-info-pane');
expect(css).toContain('padding: 22px 28px 28px 32px;');
expect(topLevelSelectors).toContain('.plugin-design-sidebar__spec');
expect(css).toContain('padding: 18px 28px 28px 32px;');
expect(topLevelSelectors).not.toContain(
'.plugin-details-modal__stage-num .plugin-info-pane',
);
});
});

View file

@ -94,6 +94,9 @@ describe('dialog artifact consistency', () => {
const page = await context.newPage();
await page.goto('/', { waitUntil: 'domcontentloaded' });
await playwrightExpect(
page.getByRole('heading', { name: 'What do you want to design?' }),
).toBeVisible();
await page.evaluate(({ projectId, conversationId }) => {
const target = `/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}`;
window.history.pushState(null, '', target);

View file

@ -171,7 +171,7 @@ test('entry top navigation matches the current home tab structure', async ({ pag
await expect(page.getByTestId('entry-nav-integrations')).toBeVisible();
await expect(page.getByTestId('home-hero-rail')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-prototype')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-live-artifact')).toHaveCount(0);
await expect(page.getByTestId('home-hero-rail-live-artifact')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-deck')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-image')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-video')).toBeVisible();

View file

@ -36,21 +36,5 @@
"engines": {
"node": "~24",
"pnpm": ">=10.33.2 <11"
},
"pnpm": {
"overrides": {
"brace-expansion": "5.0.6",
"devalue": "5.8.1",
"fast-uri": "3.1.2",
"hono": "4.12.19",
"ip-address": "10.2.0",
"postcss": "8.5.14",
"yaml": "2.9.0"
},
"onlyBuiltDependencies": [
"better-sqlite3",
"electron",
"esbuild"
]
}
}

View file

@ -0,0 +1,59 @@
// Hand-off surface: the daemon enumerates local apps the user can open
// their design project folder in (Cursor, Zed, VS Code, Finder, etc.),
// then spawns the chosen app with the project's resolvedDir as its
// argument. The detection model is borrowed from paseo's
// `editor-targets.ts` — a declarative catalogue filtered by what's
// actually on $PATH at request time. The daemon never opens binary
// paths the user did not pick from this list.
export type HostEditorId =
| 'cursor'
| 'vscode'
| 'windsurf'
| 'zed'
| 'qoder'
| 'antigravity'
| 'webstorm'
| 'idea'
| 'xcode'
| 'finder'
| 'explorer'
| 'file-manager'
| 'terminal'
| 'warp';
export interface HostEditor {
id: HostEditorId;
label: string;
// Optional bundled icon name from the web's Icon registry — purely
// presentational, daemon does not consume.
icon?: string;
// The CLI shim or `open -a` argument the daemon actually invokes.
// Omitted from API responses by default — exposed only for diagnostics.
command?: string;
// True when the daemon successfully probed the executable on $PATH
// (or on macOS, found the bundle via `mdfind`). Clients hide entries
// where `available === false` unless explicitly showing the full list.
available: boolean;
// Where the executable was resolved from — for debugging.
resolvedPath?: string;
// Platforms this entry can ever match. Calculated by the daemon at
// request time; included so the UI can branch by host (e.g. show
// Finder only on macOS).
platforms?: Array<'darwin' | 'win32' | 'linux'>;
}
export interface HostEditorsResponse {
editors: HostEditor[];
platform: 'darwin' | 'win32' | 'linux' | 'unknown';
}
export interface OpenProjectInEditorRequest {
editorId: HostEditorId;
}
export interface OpenProjectInEditorResponse {
ok: true;
editorId: HostEditorId;
path: string;
}

View file

@ -10,6 +10,7 @@ export * from './api/connectors.js';
export * from './api/comments.js';
export * from './api/connectionTest.js';
export * from './api/files.js';
export * from './api/host-tools.js';
export * from './api/finalize.js';
export * from './api/handoff.js';
export * from './api/live-artifacts.js';

View file

@ -21,7 +21,7 @@
// surface-specific seed. Media kinds keep od-media-generation, which
// dispatches through the media contract instead of emitting HTML.
import type { ProjectKind } from '../api/projects.js';
import type { ProjectKind, ProjectMetadata } from '../api/projects.js';
import type { AppliedPluginSnapshot } from './apply.js';
export type TaskKind = AppliedPluginSnapshot['taskKind'];
@ -44,6 +44,7 @@ export type DefaultScenarioPluginId =
| 'od-figma-migration'
| 'od-code-migration'
| 'od-tune-collab'
| 'example-live-artifact'
| 'example-simple-deck'
| 'example-web-prototype';
@ -80,6 +81,13 @@ export function defaultScenarioPluginIdForKind(
return DEFAULT_SCENARIO_PLUGIN_BY_KIND[kind] ?? null;
}
export function defaultScenarioPluginIdForProjectMetadata(
metadata: Pick<ProjectMetadata, 'kind' | 'intent'> | null | undefined,
): DefaultScenarioPluginId | null {
if (metadata?.intent === 'live-artifact') return 'example-live-artifact';
return defaultScenarioPluginIdForKind(metadata?.kind);
}
export function defaultScenarioPluginIdForTaskKind(
taskKind: TaskKind | undefined,
): DefaultScenarioPluginId | null {

View file

@ -9,6 +9,7 @@ import {
DEFAULT_SCENARIO_PLUGIN_BY_TASK_KIND,
DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
defaultScenarioPluginIdForKind,
defaultScenarioPluginIdForProjectMetadata,
defaultScenarioPluginIdForTaskKind,
} from '../src/plugins/scenario-defaults.js';
@ -36,6 +37,16 @@ describe('defaultScenarioPluginIdForKind', () => {
expect(defaultScenarioPluginIdForKind(undefined)).toBeNull();
});
it('routes live-artifact intent to the dedicated bundled live artifact scenario', () => {
expect(defaultScenarioPluginIdForProjectMetadata({
kind: 'prototype',
intent: 'live-artifact',
})).toBe('example-live-artifact');
expect(defaultScenarioPluginIdForProjectMetadata({ kind: 'prototype' }))
.toBe('example-web-prototype');
expect(defaultScenarioPluginIdForProjectMetadata(undefined)).toBeNull();
});
it('exposes the hidden free-form Home fallback plugin separately from kind defaults', () => {
expect(DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID).toBe('od-default');
expect(DEFAULT_SCENARIO_PLUGIN_BY_KIND.other).toBe('od-new-generation');

View file

@ -240,7 +240,9 @@ export function isOpenDesignHostBridge(value: unknown): value is OpenDesignHostB
if (!isRecord(shell) || !hasFunction(shell, "openExternal") || !hasFunction(shell, "openPath")) return false;
const project = value.project;
if (!isRecord(project) || !hasFunction(project, "pickAndImport")) return false;
if (!isRecord(project) || !hasFunction(project, "pickAndImport")) {
return false;
}
const pdf = value.pdf;
if (!isRecord(pdf) || !hasFunction(pdf, "print")) return false;

View file

@ -3,3 +3,17 @@ packages:
- apps/*
- tools/*
- e2e
overrides:
brace-expansion: 5.0.6
devalue: 5.8.1
fast-uri: 3.1.2
hono: 4.12.19
ip-address: 10.2.0
postcss: 8.5.14
yaml: 2.9.0
onlyBuiltDependencies:
- better-sqlite3
- electron
- esbuild

View file

@ -0,0 +1,444 @@
/*
* scripts/normalize-plugin-scenarios.ts
*
* Phase 3 dry-run: propose a `od.scenario` value for every visible plugin
* manifest under `plugins/_official/**`, mapped onto the 7 scenario lanes
* derived from the user-query analysis report.
*
* business-system | / /
* presentation | 稿 / /
* app-prototype | App /
* landing | / Landing /
* brand-visual | / Logo /
* dev-tool | /
* media-asset | / /
* general |
*
* The script is intentionally read-only. It emits:
* - stdout: a markdown report grouped by proposed scenario so a human
* can scan whether assignments look right.
* - .tmp/plugin-scenario-mapping.json: machine-readable mapping the
* follow-up writer step will consume.
*
* Run: `pnpm exec tsx scripts/normalize-plugin-scenarios.ts`
* */
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const officialRoot = path.join(repoRoot, 'plugins', '_official');
const outDir = path.join(repoRoot, '.tmp');
const outFile = path.join(outDir, 'plugin-scenario-mapping.json');
type Scenario =
| 'business-system'
| 'presentation'
| 'app-prototype'
| 'landing'
| 'brand-visual'
| 'dev-tool'
| 'media-asset'
| 'general';
const SCENARIO_LABEL: Record<Scenario, string> = {
'business-system': '业务系统 / 后台 / 数据看板',
presentation: '演示文稿 / 报告 / 课程',
'app-prototype': 'App / 多屏产品原型',
landing: '官网 / Landing / 营销页',
'brand-visual': '品牌视觉 / Logo / 设计系统',
'dev-tool': '开发者工具 / 工程协作',
'media-asset': '图片 / 视频 / 展示素材',
general: '兜底(待人工复核)',
};
interface Manifest {
name?: string;
title?: string;
description?: string;
tags?: string[];
od?: {
kind?: string;
taskKind?: string;
mode?: string;
scenario?: string;
surface?: string;
platform?: string;
};
}
interface Candidate {
manifestPath: string;
relPath: string;
id: string;
title: string;
currentScenario: string;
currentMode: string;
currentTaskKind: string;
tags: string[];
proposedScenario: Scenario;
reason: string;
}
async function listManifests(): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full);
} else if (entry.isFile() && entry.name === 'open-design.json') {
out.push(full);
}
}
}
await walk(officialRoot);
return out.sort();
}
function toTagSet(tags: string[] | undefined): Set<string> {
return new Set((tags ?? []).map((t) => String(t).toLowerCase().trim()).filter(Boolean));
}
const BUSINESS_SYSTEM_TAGS = [
'dashboard',
'admin-panel',
'admin',
'analytics',
'control-panel',
'crm',
'erp',
'operations',
'reporting',
'workspace',
'kanban',
'inventory',
'logistics',
'fleet',
'finance-admin',
];
const PRESENTATION_TAGS = [
'slides',
'deck',
'presentation',
'pitch',
'pitch-deck',
'keynote',
'course',
'training',
'lecture',
'lesson',
'report-deck',
'editorial-deck',
];
const APP_TAGS = [
'mobile',
'ios',
'android',
'wechat',
'miniapp',
'mini-program',
'tablet',
'app',
'mobile-app',
'multi-screen',
'screen-flow',
'onboarding',
];
const LANDING_TAGS = [
'landing',
'landing-page',
'saas',
'marketing',
'marketing-site',
'hero',
'cta',
'pricing',
'b2b',
'product-site',
'homepage',
'portfolio',
'agency',
'studio',
'consulting',
];
const BRAND_VISUAL_TAGS = [
'design-system',
'brand',
'brand-visual',
'logo',
'typography',
'color-system',
'visual-language',
'identity',
'guideline',
'style-guide',
];
const DEV_TOOL_TAGS = [
'developer',
'developer-tool',
'cli',
'ide',
'agent',
'mcp',
'connector',
'plugin-authoring',
'automation',
'devops',
'runbook',
'figma-migration',
'code-migration',
'export',
'handoff',
];
const MEDIA_ASSET_TAGS = [
'image-asset',
'poster',
'screenshot',
'app-store-screenshot',
'render',
'thumbnail',
'storyboard',
'banner',
];
function anyTag(tags: Set<string>, candidates: readonly string[]): string | null {
for (const candidate of candidates) {
if (tags.has(candidate)) return candidate;
}
return null;
}
function classify(manifest: Manifest, relPath: string): { scenario: Scenario; reason: string } {
const od = manifest.od ?? {};
const mode = String(od.mode ?? '').toLowerCase();
const taskKind = String(od.taskKind ?? '').toLowerCase();
const scenario = String(od.scenario ?? '').toLowerCase();
const tags = toTagSet(manifest.tags);
const id = (manifest.name ?? '').toLowerCase();
const description = (manifest.description ?? '').toLowerCase();
// 1. Hard infrastructure scenarios go straight to dev-tool. These are
// the platform's own routing scenarios (figma migration, code migration,
// exports, plugin authoring, refine/tune), not artifact builders.
if (
taskKind === 'figma-migration' ||
taskKind === 'code-migration' ||
taskKind === 'tune-collab' ||
id === 'od-plugin-authoring' ||
id === 'od-design-refine' ||
id.endsWith('-export') ||
id.startsWith('od-')
&& (id.includes('export') || id.includes('migration') || id.includes('tune') || id.includes('refine'))
) {
return { scenario: 'dev-tool', reason: `infra:${taskKind || id}` };
}
// 2. Mode-based fast paths.
if (mode === 'design-system') {
return { scenario: 'brand-visual', reason: 'mode:design-system' };
}
if (mode === 'image' || mode === 'video' || mode === 'audio') {
return { scenario: 'media-asset', reason: `mode:${mode}` };
}
if (mode === 'deck') {
return { scenario: 'presentation', reason: 'mode:deck' };
}
// 3. Tag-based classification for the remaining prototype/utility plugins.
const businessHit = anyTag(tags, BUSINESS_SYSTEM_TAGS);
if (businessHit) return { scenario: 'business-system', reason: `tag:${businessHit}` };
const presentationHit = anyTag(tags, PRESENTATION_TAGS);
if (presentationHit) return { scenario: 'presentation', reason: `tag:${presentationHit}` };
const brandHit = anyTag(tags, BRAND_VISUAL_TAGS);
if (brandHit) return { scenario: 'brand-visual', reason: `tag:${brandHit}` };
const devHit = anyTag(tags, DEV_TOOL_TAGS);
if (devHit) return { scenario: 'dev-tool', reason: `tag:${devHit}` };
const landingHit = anyTag(tags, LANDING_TAGS);
if (landingHit) return { scenario: 'landing', reason: `tag:${landingHit}` };
const appHit = anyTag(tags, APP_TAGS);
if (appHit) return { scenario: 'app-prototype', reason: `tag:${appHit}` };
const mediaAssetHit = anyTag(tags, MEDIA_ASSET_TAGS);
if (mediaAssetHit) return { scenario: 'media-asset', reason: `tag:${mediaAssetHit}` };
// 4. Path-based defaults for directories that are uniformly one scenario.
if (relPath.includes('/image-templates/')) {
return { scenario: 'media-asset', reason: 'path:image-templates' };
}
if (relPath.includes('/video-templates/')) {
return { scenario: 'media-asset', reason: 'path:video-templates' };
}
if (relPath.includes('/design-systems/')) {
return { scenario: 'brand-visual', reason: 'path:design-systems' };
}
// 5. Description heuristics for prototype-mode plugins that didn't tag
// themselves. A dashboard-shaped description should still land in
// business-system even when the author forgot to tag it.
if (mode === 'prototype') {
if (/dashboard|admin|console|analytics|kpi|control panel/.test(description)) {
return { scenario: 'business-system', reason: 'desc:dashboard-ish' };
}
if (/landing|hero|cta|pricing|marketing/.test(description)) {
return { scenario: 'landing', reason: 'desc:landing-ish' };
}
if (/mobile|app screen|onboarding|sign[- ]?up flow/.test(description)) {
return { scenario: 'app-prototype', reason: 'desc:mobile-ish' };
}
if (/slide|deck|presentation|pitch/.test(description)) {
return { scenario: 'presentation', reason: 'desc:deck-ish' };
}
// The legacy "scenario": "operations" is a strong signal but we
// already caught it via the BUSINESS_SYSTEM_TAGS set when tags carry
// it. As a final hint, treat the literal scenario field if present.
if (scenario === 'operations') {
return { scenario: 'business-system', reason: 'scenario-field:operations' };
}
// Generic prototype with no other signal — most likely an app shell
// since the curated default for HomeHero's `Prototype` chip is
// example-web-prototype, which is closer to a web app/landing than a
// dashboard. We mark it as `general` so the human review step can
// re-route precisely instead of guessing.
return { scenario: 'general', reason: 'mode:prototype no-signal' };
}
return { scenario: 'general', reason: 'no-signal' };
}
async function main() {
const manifests = await listManifests();
const candidates: Candidate[] = [];
for (const manifestPath of manifests) {
const relPath = path.relative(repoRoot, manifestPath).split(path.sep).join('/');
let raw: string;
try {
raw = await readFile(manifestPath, 'utf8');
} catch (err) {
console.error(`[skip] ${relPath}: ${(err as Error).message}`);
continue;
}
let manifest: Manifest;
try {
manifest = JSON.parse(raw) as Manifest;
} catch (err) {
console.error(`[skip] ${relPath}: ${(err as Error).message}`);
continue;
}
// Skip atoms — they don't show up on the home grid and don't need a
// scenario assignment. The current facets.ts excludes them via
// `od.kind !== 'atom'` so this matches the visible-plugin contract.
if (manifest.od?.kind === 'atom') continue;
const { scenario, reason } = classify(manifest, relPath);
candidates.push({
manifestPath,
relPath,
id: manifest.name ?? '(unknown)',
title: manifest.title ?? manifest.name ?? '(untitled)',
currentScenario: String(manifest.od?.scenario ?? ''),
currentMode: String(manifest.od?.mode ?? ''),
currentTaskKind: String(manifest.od?.taskKind ?? ''),
tags: manifest.tags ?? [],
proposedScenario: scenario,
reason,
});
}
// Group by proposed scenario for the human report.
const byScenario = new Map<Scenario, Candidate[]>();
for (const candidate of candidates) {
const list = byScenario.get(candidate.proposedScenario) ?? [];
list.push(candidate);
byScenario.set(candidate.proposedScenario, list);
}
const scenarioOrder: Scenario[] = [
'business-system',
'presentation',
'app-prototype',
'landing',
'brand-visual',
'dev-tool',
'media-asset',
'general',
];
const total = candidates.length;
console.log(`# Plugin scenario mapping preview\n`);
console.log(`Scanned ${manifests.length} manifests, classified ${total} visible plugins (atoms skipped).\n`);
console.log(`## Distribution\n`);
console.log(`| Scenario | Count | Share |`);
console.log(`|---|---:|---:|`);
for (const scenario of scenarioOrder) {
const count = byScenario.get(scenario)?.length ?? 0;
const share = total > 0 ? ((count / total) * 100).toFixed(1) : '0.0';
console.log(`| ${SCENARIO_LABEL[scenario]} (\`${scenario}\`) | ${count} | ${share}% |`);
}
console.log('');
for (const scenario of scenarioOrder) {
const list = byScenario.get(scenario) ?? [];
if (list.length === 0) continue;
console.log(`## ${SCENARIO_LABEL[scenario]} (\`${scenario}\`) — ${list.length}\n`);
console.log(`| id | title | mode | tags (first 4) | reason |`);
console.log(`|---|---|---|---|---|`);
for (const candidate of list) {
const tagsPreview = candidate.tags.slice(0, 4).join(', ');
console.log(
`| \`${candidate.id}\` | ${candidate.title} | ${candidate.currentMode || '—'} | ${tagsPreview || '—'} | ${candidate.reason} |`,
);
}
console.log('');
}
await mkdir(outDir, { recursive: true });
await writeFile(
outFile,
JSON.stringify(
{
generatedAt: new Date().toISOString(),
total,
distribution: Object.fromEntries(
scenarioOrder.map((scenario) => [scenario, byScenario.get(scenario)?.length ?? 0]),
),
mappings: candidates.map((c) => ({
id: c.id,
path: c.relPath,
title: c.title,
currentScenario: c.currentScenario,
currentMode: c.currentMode,
currentTaskKind: c.currentTaskKind,
tags: c.tags,
proposedScenario: c.proposedScenario,
reason: c.reason,
})),
},
null,
2,
),
'utf8',
);
console.error(`\nMapping JSON written to ${path.relative(repoRoot, outFile)}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -9,23 +9,85 @@ export type StartupLogDiagnostics = {
lines: string[];
};
export type NodeRuntimeDiagnosticInput = {
nodeModuleVersion: string;
nodeVersion: string;
};
const SUPPORTED_NODE_MAJOR = 24;
const SUPPORTED_NODE_RANGE = "Node ~24";
const NATIVE_ADDON_ABI_MISMATCH_PATTERN = /was compiled against a different Node\.js version[\s\S]*?NODE_MODULE_VERSION\s+\d+[\s\S]*?requires\s+NODE_MODULE_VERSION\s+\d+/i;
const NODE_MODULE_VERSION_PATTERN = /NODE_MODULE_VERSION\s+\d+[\s\S]*?NODE_MODULE_VERSION\s+\d+/i;
const NEXT_PACKAGE_RESOLUTION_PATTERN = /couldn't find the Next\.js package.*from the project directory:/i;
export function detectLogDiagnostics(lines: readonly string[]): LogDiagnostic[] {
function currentNodeRuntime(): NodeRuntimeDiagnosticInput {
return {
nodeModuleVersion: process.versions.modules,
nodeVersion: process.version,
};
}
function parseNodeMajor(nodeVersion: string): number | null {
const match = /^v?(\d+)\./.exec(nodeVersion);
if (match == null) return null;
return Number(match[1]);
}
export function isSupportedNodeRuntime(nodeVersion = process.version): boolean {
return parseNodeMajor(nodeVersion) === SUPPORTED_NODE_MAJOR;
}
export function formatUnsupportedNodeRuntimeMessage(runtime: NodeRuntimeDiagnosticInput = currentNodeRuntime()): string {
return [
`tools-dev must run with ${SUPPORTED_NODE_RANGE} before starting Open Design.`,
`Current runtime: Node ${runtime.nodeVersion} (NODE_MODULE_VERSION ${runtime.nodeModuleVersion}).`,
"Switch to Node 24 first, then refresh native dependencies if this worktree was installed under another Node:",
" nvm use 24",
" corepack pnpm install --frozen-lockfile",
" corepack pnpm --filter @open-design/daemon rebuild better-sqlite3 --pending",
].join("\n");
}
export function createUnsupportedNodeRuntimeError(runtime?: NodeRuntimeDiagnosticInput): Error {
return new Error(formatUnsupportedNodeRuntimeMessage(runtime));
}
function formatNativeAddonAbiMismatchRecommendation(runtime: NodeRuntimeDiagnosticInput): string {
const base = [
"Open Design's dev stack must run with Node ~24.",
`Current tools-dev runtime: Node ${runtime.nodeVersion} (NODE_MODULE_VERSION ${runtime.nodeModuleVersion}).`,
];
if (!isSupportedNodeRuntime(runtime.nodeVersion)) {
return [
...base,
"Switch to Node 24 first, then refresh native daemon dependencies:",
" nvm use 24",
" corepack pnpm install --frozen-lockfile",
" corepack pnpm --filter @open-design/daemon rebuild better-sqlite3 --pending",
].join("\n");
}
return [
...base,
"Refresh native daemon dependencies for the active Node 24 runtime:",
" corepack pnpm --filter @open-design/daemon rebuild better-sqlite3 --pending",
"or refresh the workspace install:",
" corepack pnpm install --frozen-lockfile",
].join("\n");
}
export function detectLogDiagnostics(
lines: readonly string[],
runtime: NodeRuntimeDiagnosticInput = currentNodeRuntime(),
): LogDiagnostic[] {
const logText = lines.join("\n");
const diagnostics: LogDiagnostic[] = [];
if (NATIVE_ADDON_ABI_MISMATCH_PATTERN.test(logText) || NODE_MODULE_VERSION_PATTERN.test(logText)) {
diagnostics.push({
message: "Detected a native Node addon ABI mismatch in the daemon log.",
recommendation: [
"Rebuild native daemon dependencies for the active Node version:",
" pnpm --filter @open-design/daemon rebuild better-sqlite3 --pending",
"or refresh the workspace install:",
" pnpm install",
].join("\n"),
recommendation: formatNativeAddonAbiMismatchRecommendation(runtime),
});
}

View file

@ -48,9 +48,11 @@ import {
} from "./config.js";
import {
appendStartupLogDiagnostics,
createUnsupportedNodeRuntimeError,
createStartupLogDiagnostics,
detectLogDiagnostics,
formatLogDiagnostics,
isSupportedNodeRuntime,
type LogDiagnostic,
} from "./diagnostics.js";
import {
@ -98,6 +100,10 @@ function output(payload: unknown, options: CliOptions = {}): void {
printJson(payload);
}
function assertSupportedNodeRuntimeForStart(): void {
if (!isSupportedNodeRuntime()) throw createUnsupportedNodeRuntimeError();
}
function normalizeDisplayUrl(url: string): string {
return url.endsWith("/") ? url : `${url}/`;
}
@ -1049,6 +1055,7 @@ function addPortOptions(command: ReturnType<typeof cli.command>) {
addPortOptions(addSharedOptions(cli.command("start [app]", "Start daemon, web, desktop, or all when app is omitted"))).action(
async (appName: string | undefined, options: CliOptions) => {
assertSupportedNodeRuntimeForStart();
const config = resolveToolDevConfig(options);
const targets = resolveStartApps(appName);
const result = await runSequential(targets, (target) => startApp(config, target, options, { targets }));
@ -1058,6 +1065,7 @@ addPortOptions(addSharedOptions(cli.command("start [app]", "Start daemon, web, d
addPortOptions(addSharedOptions(cli.command("run [app]", "Start apps and keep this command alive until interrupted"))).action(
async (appName: string | undefined, options: CliOptions) => {
assertSupportedNodeRuntimeForStart();
await runForeground(resolveToolDevConfig(options), appName, options);
},
);
@ -1079,6 +1087,7 @@ addSharedOptions(cli.command("stop [app]", "Stop daemon, web, desktop, or all wh
addPortOptions(addSharedOptions(cli.command("restart [app]", "Restart daemon, web, desktop, or all when app is omitted"))).action(
async (appName: string | undefined, options: CliOptions) => {
assertSupportedNodeRuntimeForStart();
printRestartResult(await restartTargets(resolveToolDevConfig(options), appName, options), options);
},
);

View file

@ -5,21 +5,53 @@ import {
appendStartupLogDiagnostics,
createStartupLogDiagnostics,
detectLogDiagnostics,
formatUnsupportedNodeRuntimeMessage,
isSupportedNodeRuntime,
} from "../src/diagnostics.js";
describe("tools-dev diagnostics", () => {
it("detects native addon ABI mismatches", () => {
const diagnostics = detectLogDiagnostics([
"Error: The module '/repo/node_modules/better-sqlite3/build/Release/better_sqlite3.node'",
"was compiled against a different Node.js version using",
"NODE_MODULE_VERSION 127. This version of Node.js requires",
"NODE_MODULE_VERSION 137. Please try re-compiling or re-installing",
]);
const diagnostics = detectLogDiagnostics(
[
"Error: The module '/repo/node_modules/better-sqlite3/build/Release/better_sqlite3.node'",
"was compiled against a different Node.js version using",
"NODE_MODULE_VERSION 127. This version of Node.js requires",
"NODE_MODULE_VERSION 137. Please try re-compiling or re-installing",
],
{ nodeModuleVersion: "137", nodeVersion: "v24.15.0" },
);
assert.equal(diagnostics.length, 1);
assert.match(diagnostics[0].message, /native Node addon ABI mismatch/);
assert.match(diagnostics[0].recommendation, /rebuild better-sqlite3 --pending/);
assert.match(diagnostics[0].recommendation, /pnpm install/);
assert.match(diagnostics[0].recommendation, /active Node 24 runtime/);
assert.match(diagnostics[0].recommendation, /corepack pnpm --filter @open-design\/daemon rebuild better-sqlite3 --pending/);
assert.match(diagnostics[0].recommendation, /corepack pnpm install --frozen-lockfile/);
});
it("points ABI mismatches under unsupported Node at Node 24 first", () => {
const diagnostics = detectLogDiagnostics(
[
"Error: better_sqlite3.node was compiled against a different Node.js version using",
"NODE_MODULE_VERSION 137. This version of Node.js requires",
"NODE_MODULE_VERSION 127.",
],
{ nodeModuleVersion: "127", nodeVersion: "v22.22.3" },
);
assert.equal(diagnostics.length, 1);
assert.match(diagnostics[0].recommendation, /Current tools-dev runtime: Node v22\.22\.3/);
assert.match(diagnostics[0].recommendation, /Switch to Node 24 first/);
assert.match(diagnostics[0].recommendation, /nvm use 24/);
});
it("formats unsupported Node runtime errors before startup", () => {
assert.equal(isSupportedNodeRuntime("v24.15.0"), true);
assert.equal(isSupportedNodeRuntime("v22.22.3"), false);
const message = formatUnsupportedNodeRuntimeMessage({ nodeModuleVersion: "127", nodeVersion: "v22.22.3" });
assert.match(message, /tools-dev must run with Node ~24/);
assert.match(message, /Current runtime: Node v22\.22\.3 \(NODE_MODULE_VERSION 127\)/);
assert.match(message, /corepack pnpm install --frozen-lockfile/);
});
it("detects missing Next.js package resolution during web startup", () => {
@ -62,6 +94,6 @@ describe("tools-dev diagnostics", () => {
assert.match(error.message, /daemon did not expose status in time/);
assert.match(error.message, /daemon log tail \(\/tmp\/daemon\.log\)/);
assert.match(error.message, /better_sqlite3\.node/);
assert.match(error.message, /pnpm --filter @open-design\/daemon rebuild better-sqlite3 --pending/);
assert.match(error.message, /corepack pnpm --filter @open-design\/daemon rebuild better-sqlite3 --pending/);
});
});