mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
commit
722ddfa235
66 changed files with 4552 additions and 1001 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -37,7 +37,7 @@ apps/web/playwright/
|
|||
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
.claude-sessions/*
|
||||
**/.claude-sessions/*
|
||||
|
||||
.cursor/
|
||||
.agents/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
243
apps/daemon/src/host-tools-routes.ts
Normal file
243
apps/daemon/src/host-tools-routes.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
49
apps/daemon/src/run-result.ts
Normal file
49
apps/daemon/src/run-result.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
142
apps/daemon/tests/run-result.test.ts
Normal file
142
apps/daemon/tests/run-result.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 |
|
|
@ -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 }) {
|
||||
|
|
|
|||
247
apps/web/src/components/EditorIcon.tsx
Normal file
247
apps/web/src/components/EditorIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
247
apps/web/src/components/HandoffButton.tsx
Normal file
247
apps/web/src/components/HandoffButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
407
apps/web/src/components/ProjectDesignSystemPicker.tsx
Normal file
407
apps/web/src/components/ProjectDesignSystemPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 l’aperçu',
|
||||
'designSystemPicker.loadingPreview': 'Chargement de l’aperçu…',
|
||||
'designSystemPicker.noPreview': 'Aucune page d’aperçu. Ouvrez Design Systems pour voir l’aperçu complet.',
|
||||
'designSystemPicker.previewHint': 'Survolez un design system pour le prévisualiser',
|
||||
'designSystemPicker.fullscreenAria': 'Aperçu plein écran de {title}',
|
||||
'designSystemPicker.closeFullscreen': 'Fermer l’aperç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',
|
||||
|
|
|
|||
|
|
@ -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 即可开始。',
|
||||
|
|
|
|||
|
|
@ -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 取得協助',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
75
apps/web/tests/analytics-app-version.test.tsx
Normal file
75
apps/web/tests/analytics-app-version.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
105
apps/web/tests/components/ProjectDesignSystemPicker.test.tsx
Normal file
105
apps/web/tests/components/ProjectDesignSystemPicker.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ describe('buildFacetCatalog', () => {
|
|||
expect((catalog.subcategory.create ?? []).map((o) => o.slug)).toEqual([
|
||||
'prototype',
|
||||
'deck',
|
||||
'live-artifact',
|
||||
'design-system',
|
||||
'hyperframes',
|
||||
'image',
|
||||
|
|
|
|||
26
apps/web/tests/styles/plugin-info-pane.test.ts
Normal file
26
apps/web/tests/styles/plugin-info-pane.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
packages/contracts/src/api/host-tools.ts
Normal file
59
packages/contracts/src/api/host-tools.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
444
scripts/normalize-plugin-scenarios.ts
Normal file
444
scripts/normalize-plugin-scenarios.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue