mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
[codex] Land design system GitHub intake handoff (#2187)
* Add Claude-style design system workflow * Merge design system workflow into main * Restore design system workflow UI styles * Fix design system setup scrolling * Fix design system setup connector button * Preserve connector auth link after popup block * Simplify connected GitHub setup state * Open generated design system workspace project * Summarize design system auto prompt in chat * Add bounded GitHub connector design intake * Prefer path-scoped GitHub intake tools * Restore branch GitHub design context intake * Restore design system review workspace * Restore design system manager tab * Let design system workflow routes own details * Open editable design systems as projects * Restore design system workspace coverage * Fix bounded GitHub connector intake * Hide design system review while generating * Suppress design system generation questions * Constrain GitHub design intake to bounded command * Tolerate oversized GitHub metadata during intake * Rebuild daemon CLI when sources change * Fallback when GitHub connector snapshots are rate limited * Allow GitHub intake without Composio * Use native GitHub auth for design intake * Remove design system review group heading * Improve design system extraction evidence * Align design system scaffold with Claude output * Add evidence inventory for design system intake * Add local design system evidence intake * Add design system package audit gate * Allow auditing Claude Design reference packages * Audit design system package content quality * Migrate legacy design system artifacts * Clean migrated design system artifacts * Require modular design system UI kits * Reject thin design system UI kits * Prioritize core design evidence intake * Require role-based design system UI kits * Clean stale design system manifest references * Require representative preserved design assets * Warn on generic design system visuals * Enforce design system quality warnings * Audit connected design system UI kits * Require mounted design system UI kits * Require composed design system app shells * Require runnable JSX design system kits * Require browser globals for design system components * Infer design system names from source URLs * Require source examples in design system packages * Bind preserved fonts in design system tokens * Require skill frontmatter in design system packages * Preserve build icons in design system packages * Require real assets in brand previews * Require substantive source examples * Require product overview in design system README * Require reusable UI kit README * Require reusable design system skill docs * Seed Claude-style UI kit entry contract * Preserve runtime build assets in design packages * Audit design system packages after generation * Audit design system first-run output * Audit source-backed preview cards * Align design system UI kit scaffolds * Materialize design evidence package artifacts * Show project chat during design system setup * Hand off design system setup to project chat * Auto-repair design system audit failures * Harden design system evidence preservation * Tighten design system package guidance * Add targeted design system repair guidance * Bound design system audit auto repair * Use connector statuses in design system setup * Audit design system preview manifests * Require README preview manifests for design systems * Fix design system GitHub intake handoff * Fix daemon prompt CI assertions
This commit is contained in:
parent
1d59ffacd1
commit
18b947c25f
45 changed files with 19869 additions and 236 deletions
|
|
@ -247,7 +247,7 @@ function printRootHelp() {
|
|||
od artifacts create --name <path> --input <file> [--project <id-or-name>]
|
||||
Create a normal project artifact through the local daemon.
|
||||
|
||||
od tools connectors <list|execute> [options]
|
||||
od tools connectors <list|execute|github-design-context> [options]
|
||||
Discover and execute configured connectors.
|
||||
|
||||
od mcp live-artifacts
|
||||
|
|
|
|||
352
apps/daemon/src/design-system-generation-jobs.ts
Normal file
352
apps/daemon/src/design-system-generation-jobs.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
createUserDesignSystemRevision,
|
||||
createUserDesignSystem,
|
||||
listUserDesignSystemFiles,
|
||||
readDesignSystem,
|
||||
type DesignSystemRevision,
|
||||
type DesignSystemSummary,
|
||||
type UserDesignSystemInput,
|
||||
type UserDesignSystemRevisionInput,
|
||||
} from './design-systems.js';
|
||||
import {
|
||||
collectDesignSystemSourceContext,
|
||||
mergeSourceContextIntoInput,
|
||||
type DesignSystemSourceContext,
|
||||
} from './design-system-source-context.js';
|
||||
|
||||
export type DesignSystemGenerationJobStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed';
|
||||
|
||||
export type DesignSystemGenerationStepStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed';
|
||||
|
||||
export type DesignSystemGenerationStep = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: DesignSystemGenerationStepStatus;
|
||||
message?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
|
||||
export type DesignSystemGenerationJob = {
|
||||
id: string;
|
||||
kind?: 'generation' | 'revision';
|
||||
status: DesignSystemGenerationJobStatus;
|
||||
progress: number;
|
||||
steps: DesignSystemGenerationStep[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
designSystemId?: string;
|
||||
revisionId?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type MutableJob = DesignSystemGenerationJob;
|
||||
|
||||
type StoreOptions = {
|
||||
root: string;
|
||||
createDesignSystem?: (
|
||||
root: string,
|
||||
input: UserDesignSystemInput,
|
||||
) => Promise<DesignSystemSummary>;
|
||||
readDesignSystem?: (root: string, id: string, options?: { idPrefix?: string }) => Promise<string | null>;
|
||||
createRevision?: (
|
||||
root: string,
|
||||
id: string,
|
||||
input: UserDesignSystemRevisionInput,
|
||||
) => Promise<DesignSystemRevision | null>;
|
||||
collectSourceContext?: (input: UserDesignSystemInput) => Promise<DesignSystemSourceContext>;
|
||||
listFiles?: (root: string, id: string) => Promise<Array<{ path: string }> | null>;
|
||||
delayMs?: number;
|
||||
idFactory?: () => string;
|
||||
};
|
||||
|
||||
const STEP_DEFS = [
|
||||
{ id: 'explore-resources', title: 'Explore provided resources' },
|
||||
{ id: 'create-draft', title: 'Create design system draft' },
|
||||
{ id: 'generate-files', title: 'Generate preview cards and files' },
|
||||
{ id: 'register-files', title: 'Register files for review' },
|
||||
{ id: 'prepare-review', title: 'Prepare review workspace' },
|
||||
] as const;
|
||||
|
||||
const REVISION_STEP_DEFS = [
|
||||
{ id: 'read-draft', title: 'Read current draft' },
|
||||
{ id: 'apply-feedback', title: 'Apply requested changes' },
|
||||
{ id: 'create-revision', title: 'Create pending revision' },
|
||||
{ id: 'prepare-review', title: 'Prepare updated review' },
|
||||
] as const;
|
||||
|
||||
export type DesignSystemRevisionInput = {
|
||||
designSystemId: string;
|
||||
feedback: string;
|
||||
sectionTitle?: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
export function createDesignSystemGenerationJobStore(options: StoreOptions) {
|
||||
const jobs = new Map<string, MutableJob>();
|
||||
const createDesignSystem = options.createDesignSystem ?? createUserDesignSystem;
|
||||
const readExistingDesignSystem = options.readDesignSystem ?? readDesignSystem;
|
||||
const createRevision = options.createRevision ?? createUserDesignSystemRevision;
|
||||
const collectSourceContext = options.collectSourceContext ?? collectDesignSystemSourceContext;
|
||||
const listFiles = options.listFiles ?? listUserDesignSystemFiles;
|
||||
const delayMs = options.delayMs ?? 280;
|
||||
const idFactory = options.idFactory ?? randomUUID;
|
||||
|
||||
function start(input: UserDesignSystemInput): DesignSystemGenerationJob {
|
||||
const now = new Date().toISOString();
|
||||
const job: MutableJob = {
|
||||
id: idFactory(),
|
||||
kind: 'generation',
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
steps: STEP_DEFS.map((step) => ({ ...step, status: 'pending' })),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
message: 'Queued',
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
void run(job, input);
|
||||
return snapshot(job);
|
||||
}
|
||||
|
||||
function revise(input: DesignSystemRevisionInput): DesignSystemGenerationJob {
|
||||
const now = new Date().toISOString();
|
||||
const job: MutableJob = {
|
||||
id: idFactory(),
|
||||
kind: 'revision',
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
steps: REVISION_STEP_DEFS.map((step) => ({ ...step, status: 'pending' })),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
designSystemId: input.designSystemId,
|
||||
message: 'Queued revision',
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
void runRevision(job, input);
|
||||
return snapshot(job);
|
||||
}
|
||||
|
||||
function get(id: string): DesignSystemGenerationJob | null {
|
||||
const job = jobs.get(id);
|
||||
return job ? snapshot(job) : null;
|
||||
}
|
||||
|
||||
async function run(job: MutableJob, input: UserDesignSystemInput): Promise<void> {
|
||||
try {
|
||||
markJob(job, 'running', 'Starting generation');
|
||||
let created: DesignSystemSummary | null = null;
|
||||
let enrichedInput = input;
|
||||
await runStep(job, 'explore-resources', async () => {
|
||||
await sleep(delayMs);
|
||||
const sourceContext = await safeCollectSourceContext(collectSourceContext, input);
|
||||
enrichedInput = mergeSourceContextIntoInput(input, sourceContext);
|
||||
setStepMessage(job, 'explore-resources', sourceSummary(input, sourceContext));
|
||||
});
|
||||
await runStep(job, 'create-draft', async () => {
|
||||
created = await createDesignSystem(options.root, enrichedInput);
|
||||
job.designSystemId = created.id;
|
||||
setStepMessage(job, 'create-draft', `Created ${created.title}`);
|
||||
});
|
||||
await runStep(job, 'generate-files', async () => {
|
||||
await sleep(delayMs);
|
||||
setStepMessage(job, 'generate-files', 'Generated DESIGN.md, README.md, SKILL.md, tokens, previews, and context files');
|
||||
});
|
||||
await runStep(job, 'register-files', async () => {
|
||||
const designSystemId = created?.id;
|
||||
const files = designSystemId ? await listFiles(options.root, designSystemId) : [];
|
||||
setStepMessage(job, 'register-files', `Registered ${files?.length ?? 0} files`);
|
||||
});
|
||||
await runStep(job, 'prepare-review', async () => {
|
||||
await sleep(delayMs);
|
||||
setStepMessage(job, 'prepare-review', 'Review workspace is ready');
|
||||
});
|
||||
completeJob(job, 'succeeded', 'Design system ready for review');
|
||||
} catch (err) {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function runRevision(job: MutableJob, input: DesignSystemRevisionInput): Promise<void> {
|
||||
try {
|
||||
markJob(job, 'running', 'Starting revision');
|
||||
const feedback = cleanFeedback(input.feedback);
|
||||
if (!feedback) throw new Error('Revision feedback is required');
|
||||
let body = input.body;
|
||||
let proposedBody = '';
|
||||
await runStep(job, 'read-draft', async () => {
|
||||
if (!body) {
|
||||
body = await readExistingDesignSystem(options.root, input.designSystemId, {
|
||||
idPrefix: 'user:',
|
||||
}) ?? undefined;
|
||||
}
|
||||
if (!body) throw new Error('Editable design system not found');
|
||||
setStepMessage(job, 'read-draft', `Loaded ${input.designSystemId}`);
|
||||
});
|
||||
await runStep(job, 'apply-feedback', async () => {
|
||||
await sleep(delayMs);
|
||||
proposedBody = applyRevisionToBody(body ?? '', {
|
||||
feedback,
|
||||
...(input.sectionTitle ? { sectionTitle: input.sectionTitle } : {}),
|
||||
});
|
||||
setStepMessage(job, 'apply-feedback', input.sectionTitle ? `Updated ${input.sectionTitle}` : 'Updated DESIGN.md');
|
||||
});
|
||||
await runStep(job, 'create-revision', async () => {
|
||||
if (!body) throw new Error('Editable design system not found');
|
||||
const revision = await createRevision(options.root, input.designSystemId, {
|
||||
feedback,
|
||||
baseBody: body,
|
||||
proposedBody,
|
||||
...(input.sectionTitle ? { sectionTitle: input.sectionTitle } : {}),
|
||||
jobId: job.id,
|
||||
});
|
||||
if (!revision) throw new Error('Could not create revision');
|
||||
job.revisionId = revision.id;
|
||||
setStepMessage(job, 'create-revision', `Created pending revision ${revision.id}`);
|
||||
});
|
||||
await runStep(job, 'prepare-review', async () => {
|
||||
await sleep(delayMs);
|
||||
setStepMessage(job, 'prepare-review', 'Updated review is ready');
|
||||
});
|
||||
completeJob(job, 'succeeded', 'Revision ready for review');
|
||||
} catch (err) {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return { start, revise, get };
|
||||
}
|
||||
|
||||
async function runStep(
|
||||
job: MutableJob,
|
||||
stepId: string,
|
||||
task: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const step = job.steps.find((candidate) => candidate.id === stepId);
|
||||
if (!step) throw new Error(`Unknown generation step: ${stepId}`);
|
||||
step.status = 'running';
|
||||
step.startedAt = new Date().toISOString();
|
||||
touch(job);
|
||||
try {
|
||||
await task();
|
||||
step.status = 'succeeded';
|
||||
step.completedAt = new Date().toISOString();
|
||||
job.progress = Math.round(
|
||||
(job.steps.filter((candidate) => candidate.status === 'succeeded').length / job.steps.length) * 100,
|
||||
);
|
||||
touch(job);
|
||||
} catch (err) {
|
||||
step.status = 'failed';
|
||||
step.completedAt = new Date().toISOString();
|
||||
step.message = err instanceof Error ? err.message : String(err);
|
||||
touch(job);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function markJob(
|
||||
job: MutableJob,
|
||||
status: DesignSystemGenerationJobStatus,
|
||||
message: string,
|
||||
): void {
|
||||
job.status = status;
|
||||
job.message = message;
|
||||
touch(job);
|
||||
}
|
||||
|
||||
function completeJob(
|
||||
job: MutableJob,
|
||||
status: Extract<DesignSystemGenerationJobStatus, 'succeeded'>,
|
||||
message: string,
|
||||
): void {
|
||||
job.status = status;
|
||||
job.progress = 100;
|
||||
job.message = message;
|
||||
job.completedAt = new Date().toISOString();
|
||||
touch(job);
|
||||
}
|
||||
|
||||
function failJob(job: MutableJob, message: string): void {
|
||||
job.status = 'failed';
|
||||
job.error = message;
|
||||
job.message = 'Generation failed';
|
||||
job.completedAt = new Date().toISOString();
|
||||
touch(job);
|
||||
}
|
||||
|
||||
function setStepMessage(job: MutableJob, stepId: string, message: string): void {
|
||||
const step = job.steps.find((candidate) => candidate.id === stepId);
|
||||
if (step) step.message = message;
|
||||
touch(job);
|
||||
}
|
||||
|
||||
function touch(job: MutableJob): void {
|
||||
job.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
function snapshot(job: MutableJob): DesignSystemGenerationJob {
|
||||
return {
|
||||
...job,
|
||||
steps: job.steps.map((step) => ({ ...step })),
|
||||
};
|
||||
}
|
||||
|
||||
async function safeCollectSourceContext(
|
||||
collectSourceContext: (input: UserDesignSystemInput) => Promise<DesignSystemSourceContext>,
|
||||
input: UserDesignSystemInput,
|
||||
): Promise<DesignSystemSourceContext> {
|
||||
try {
|
||||
return await collectSourceContext(input);
|
||||
} catch (err) {
|
||||
return {
|
||||
github: [],
|
||||
notes: `Source context fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function sourceSummary(input: UserDesignSystemInput, context?: DesignSystemSourceContext): string {
|
||||
const provenance = input.provenance;
|
||||
const counts = [
|
||||
provenance?.githubUrls?.length ? `${provenance.githubUrls.length} GitHub link(s)` : '',
|
||||
provenance?.localCodeFiles?.length ? `${provenance.localCodeFiles.length} local code reference(s)` : '',
|
||||
provenance?.figFiles?.length ? `${provenance.figFiles.length} Figma file(s)` : '',
|
||||
provenance?.assetFiles?.length ? `${provenance.assetFiles.length} asset(s)` : '',
|
||||
].filter(Boolean);
|
||||
const base = counts.length > 0 ? counts.join(', ') : 'Using company context and notes';
|
||||
if (!context?.github.length) return base;
|
||||
const readable = context.github.filter((repo) => !repo.error).length;
|
||||
if (readable === 0) return `${base}; GitHub context unavailable`;
|
||||
return `${base}; read ${readable} GitHub repo(s)`;
|
||||
}
|
||||
|
||||
function cleanFeedback(value: string): string {
|
||||
return value.trim().replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
function applyRevisionToBody(
|
||||
body: string,
|
||||
input: { feedback: string; sectionTitle?: string },
|
||||
): string {
|
||||
const section = input.sectionTitle?.trim();
|
||||
const title = section ? `Revision Request: ${section}` : 'Revision Request';
|
||||
const stamp = new Date().toISOString();
|
||||
return `${body.trim()}\n\n## ${title}\n\n${input.feedback}\n\n_Revision job applied at ${stamp}._\n`;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
if (ms <= 0) return Promise.resolve();
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
375
apps/daemon/src/design-system-source-context.ts
Normal file
375
apps/daemon/src/design-system-source-context.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import type { UserDesignSystemInput } from './design-systems.js';
|
||||
|
||||
export type DesignSystemSourceContext = {
|
||||
github: GitHubRepositoryContext[];
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type GitHubRepositoryContext = {
|
||||
url: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
description?: string;
|
||||
homepage?: string;
|
||||
defaultBranch?: string;
|
||||
language?: string;
|
||||
stars?: number;
|
||||
topics?: string[];
|
||||
readmeExcerpt?: string;
|
||||
packageName?: string;
|
||||
packageDescription?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type FetchLike = (
|
||||
url: string,
|
||||
init?: { headers?: Record<string, string>; signal?: AbortSignal },
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
text: () => Promise<string>;
|
||||
}>;
|
||||
|
||||
export type SourceContextOptions = {
|
||||
fetch?: FetchLike;
|
||||
maxRepos?: number;
|
||||
maxReadmeChars?: number;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type ParsedGitHubRepo = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_REPOS = 3;
|
||||
const DEFAULT_MAX_README_CHARS = 720;
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 3500;
|
||||
|
||||
export async function collectDesignSystemSourceContext(
|
||||
input: UserDesignSystemInput,
|
||||
options: SourceContextOptions = {},
|
||||
): Promise<DesignSystemSourceContext> {
|
||||
const githubUrls = input.provenance?.githubUrls ?? [];
|
||||
const repos = uniqueRepositories(githubUrls).slice(0, options.maxRepos ?? DEFAULT_MAX_REPOS);
|
||||
if (repos.length === 0) return { github: [], notes: '' };
|
||||
|
||||
const fetchFn = options.fetch ?? defaultFetch;
|
||||
const github = await Promise.all(
|
||||
repos.map((repo) => readGitHubRepositoryContext(repo, {
|
||||
fetch: fetchFn,
|
||||
maxReadmeChars: options.maxReadmeChars ?? DEFAULT_MAX_README_CHARS,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
github,
|
||||
notes: formatGithubContextNotes(github),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeSourceContextIntoInput(
|
||||
input: UserDesignSystemInput,
|
||||
context: DesignSystemSourceContext,
|
||||
): UserDesignSystemInput {
|
||||
const contextNotes = cleanMultiline(context.notes);
|
||||
if (!contextNotes) return input;
|
||||
|
||||
const topLevelSourceNotes = joinUniqueBlocks([
|
||||
input.sourceNotes,
|
||||
contextNotes,
|
||||
]);
|
||||
const provenanceSourceNotes = joinUniqueBlocks([
|
||||
provenanceOnlySourceNotes(input),
|
||||
contextNotes,
|
||||
]);
|
||||
const provenance = {
|
||||
...(input.provenance ?? {}),
|
||||
sourceNotes: provenanceSourceNotes,
|
||||
};
|
||||
|
||||
return {
|
||||
...input,
|
||||
sourceNotes: topLevelSourceNotes,
|
||||
provenance,
|
||||
};
|
||||
}
|
||||
|
||||
function provenanceOnlySourceNotes(input: UserDesignSystemInput): string {
|
||||
const provenanceSourceNotes = cleanMultiline(input.provenance?.sourceNotes);
|
||||
const topLevelSourceNotes = cleanMultiline(input.sourceNotes);
|
||||
if (!provenanceSourceNotes || provenanceSourceNotes === topLevelSourceNotes) return '';
|
||||
return provenanceSourceNotes;
|
||||
}
|
||||
|
||||
async function readGitHubRepositoryContext(
|
||||
repo: ParsedGitHubRepo,
|
||||
options: { fetch: FetchLike; maxReadmeChars: number; timeoutMs: number },
|
||||
): Promise<GitHubRepositoryContext> {
|
||||
const apiUrl = `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`;
|
||||
const api = await fetchJson(options.fetch, apiUrl, options.timeoutMs);
|
||||
if (!api.ok) {
|
||||
return {
|
||||
...repo,
|
||||
error: `GitHub repository metadata unavailable (${api.error})`,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = asRecord(api.value);
|
||||
const description = readOptionalString(payload.description);
|
||||
const homepage = readOptionalString(payload.homepage);
|
||||
const defaultBranch = readOptionalString(payload.default_branch) ?? 'main';
|
||||
const language = readOptionalString(payload.language);
|
||||
const stars = readOptionalNumber(payload.stargazers_count);
|
||||
const topics = parseTopics(payload.topics);
|
||||
const [readme, packageJson] = await Promise.all([
|
||||
readRawFile(options.fetch, repo, branchCandidates(defaultBranch), ['README.md', 'readme.md'], options.timeoutMs),
|
||||
readRawFile(options.fetch, repo, branchCandidates(defaultBranch), ['package.json'], options.timeoutMs),
|
||||
]);
|
||||
const packageInfo = parsePackageInfo(packageJson);
|
||||
|
||||
return {
|
||||
...repo,
|
||||
...(description ? { description } : {}),
|
||||
...(homepage ? { homepage } : {}),
|
||||
...(defaultBranch ? { defaultBranch } : {}),
|
||||
...(language ? { language } : {}),
|
||||
...(typeof stars === 'number' ? { stars } : {}),
|
||||
...(topics.length > 0 ? { topics } : {}),
|
||||
...(readme ? { readmeExcerpt: excerptMarkdown(readme, options.maxReadmeChars) } : {}),
|
||||
...(packageInfo.name ? { packageName: packageInfo.name } : {}),
|
||||
...(packageInfo.description ? { packageDescription: packageInfo.description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueRepositories(urls: string[]): ParsedGitHubRepo[] {
|
||||
const seen = new Set<string>();
|
||||
const repos: ParsedGitHubRepo[] = [];
|
||||
for (const url of urls) {
|
||||
const parsed = parseGitHubRepositoryUrl(url);
|
||||
if (!parsed) continue;
|
||||
const key = `${parsed.owner.toLowerCase()}/${parsed.repo.toLowerCase()}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
repos.push(parsed);
|
||||
}
|
||||
return repos;
|
||||
}
|
||||
|
||||
function parseGitHubRepositoryUrl(raw: string): ParsedGitHubRepo | null {
|
||||
const clean = raw.trim();
|
||||
const ssh = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:[#?].*)?$/.exec(clean);
|
||||
if (ssh?.[1] && ssh[2]) {
|
||||
return {
|
||||
owner: ssh[1],
|
||||
repo: stripGitSuffix(ssh[2]),
|
||||
url: `https://github.com/${ssh[1]}/${stripGitSuffix(ssh[2])}`,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(clean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname !== 'github.com' && parsed.hostname !== 'www.github.com') return null;
|
||||
const [owner, repo] = parsed.pathname.split('/').filter(Boolean);
|
||||
if (!owner || !repo) return null;
|
||||
return {
|
||||
owner,
|
||||
repo: stripGitSuffix(repo),
|
||||
url: `https://github.com/${owner}/${stripGitSuffix(repo)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(
|
||||
fetchFn: FetchLike,
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
|
||||
try {
|
||||
const response = await fetchWithTimeout(fetchFn, url, {
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'open-design-local',
|
||||
},
|
||||
}, timeoutMs);
|
||||
if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
|
||||
return { ok: true, value: await response.json() };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function readRawFile(
|
||||
fetchFn: FetchLike,
|
||||
repo: ParsedGitHubRepo,
|
||||
branches: string[],
|
||||
filePaths: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
for (const branch of branches) {
|
||||
for (const filePath of filePaths) {
|
||||
const url = rawGithubUrl(repo, branch, filePath);
|
||||
try {
|
||||
const response = await fetchWithTimeout(fetchFn, url, {
|
||||
headers: {
|
||||
accept: 'text/plain',
|
||||
'user-agent': 'open-design-local',
|
||||
},
|
||||
}, timeoutMs);
|
||||
if (response.ok) return response.text();
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
fetchFn: FetchLike,
|
||||
url: string,
|
||||
init: { headers?: Record<string, string> },
|
||||
timeoutMs: number,
|
||||
): ReturnType<FetchLike> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetchFn(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function rawGithubUrl(repo: ParsedGitHubRepo, branch: string, filePath: string): string {
|
||||
const parts = [
|
||||
encodeURIComponent(repo.owner),
|
||||
encodeURIComponent(repo.repo),
|
||||
encodeURIComponent(branch),
|
||||
...filePath.split('/').map(encodeURIComponent),
|
||||
];
|
||||
return `https://raw.githubusercontent.com/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function branchCandidates(defaultBranch: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const branch of [defaultBranch, 'main', 'master']) {
|
||||
const clean = branch.trim();
|
||||
if (clean && !out.includes(clean)) out.push(clean);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parsePackageInfo(raw: string): { name?: string; description?: string } {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const value = JSON.parse(raw) as unknown;
|
||||
const record = asRecord(value);
|
||||
const name = readOptionalString(record.name);
|
||||
const description = readOptionalString(record.description);
|
||||
return {
|
||||
...(name ? { name } : {}),
|
||||
...(description ? { description } : {}),
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function formatGithubContextNotes(repos: GitHubRepositoryContext[]): string {
|
||||
if (repos.length === 0) return '';
|
||||
const lines = ['Fetched GitHub context:'];
|
||||
for (const repo of repos) {
|
||||
const headline = repo.description || repo.error || 'No repository description found.';
|
||||
lines.push(`- ${repo.owner}/${repo.repo}: ${headline}`);
|
||||
const metadata = [
|
||||
repo.language ? `language ${repo.language}` : '',
|
||||
typeof repo.stars === 'number' ? `${repo.stars} stars` : '',
|
||||
repo.defaultBranch ? `default branch ${repo.defaultBranch}` : '',
|
||||
].filter(Boolean).join(', ');
|
||||
if (metadata) lines.push(` Metadata: ${metadata}.`);
|
||||
if (repo.homepage) lines.push(` Homepage: ${repo.homepage}`);
|
||||
if (repo.topics?.length) lines.push(` Topics: ${repo.topics.join(', ')}`);
|
||||
if (repo.packageName || repo.packageDescription) {
|
||||
lines.push(` package.json: ${[repo.packageName, repo.packageDescription].filter(Boolean).join(' - ')}`);
|
||||
}
|
||||
if (repo.readmeExcerpt) lines.push(` README excerpt: ${repo.readmeExcerpt}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function excerptMarkdown(raw: string, maxChars: number): string {
|
||||
const cleaned = raw
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, '')
|
||||
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/`{1,3}/g, '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return cleaned.length > maxChars ? `${cleaned.slice(0, Math.max(0, maxChars - 3)).trim()}...` : cleaned;
|
||||
}
|
||||
|
||||
function cleanMultiline(raw: string | undefined): string {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trim().replace(/[ \t]+/g, ' '))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function joinUniqueBlocks(blocks: Array<string | undefined>): string {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const block of blocks) {
|
||||
const clean = cleanMultiline(block);
|
||||
if (!clean || seen.has(clean)) continue;
|
||||
seen.add(clean);
|
||||
out.push(clean);
|
||||
}
|
||||
return out.join('\n\n');
|
||||
}
|
||||
|
||||
function stripGitSuffix(value: string): string {
|
||||
return value.replace(/\.git$/i, '');
|
||||
}
|
||||
|
||||
function parseTopics(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readOptionalNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function defaultFetch(url: string, init?: { headers?: Record<string, string>; signal?: AbortSignal }) {
|
||||
return fetch(url, init);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,7 @@ import {
|
|||
type InlineAssetReader,
|
||||
} from './inline-assets.js';
|
||||
|
||||
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles'> {}
|
||||
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {}
|
||||
|
||||
export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps) {
|
||||
const { db } = ctx;
|
||||
|
|
@ -27,6 +27,7 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
|
|||
const { insertProject } = ctx.projectStore;
|
||||
const { insertConversation } = ctx.conversations;
|
||||
const { setTabs } = ctx.projectFiles;
|
||||
const { validateProjectDesignSystemId } = ctx.validation;
|
||||
app.post(
|
||||
'/api/import/claude-design',
|
||||
importUpload.single('file'),
|
||||
|
|
@ -185,12 +186,21 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
|
|||
? name.trim()
|
||||
: path.basename(normalizedPath);
|
||||
const entryFile = await detectEntryFile(normalizedPath);
|
||||
const designSystemValidation = await validateProjectDesignSystemId(designSystemId);
|
||||
if (!designSystemValidation.ok) {
|
||||
return sendApiError(
|
||||
res,
|
||||
400,
|
||||
designSystemValidation.code,
|
||||
designSystemValidation.message,
|
||||
);
|
||||
}
|
||||
|
||||
const project = insertProject(db, {
|
||||
id,
|
||||
name: projectName,
|
||||
skillId: skillId ?? null,
|
||||
designSystemId: designSystemId ?? null,
|
||||
designSystemId: designSystemValidation.id,
|
||||
pendingPrompt: null,
|
||||
metadata: {
|
||||
kind: 'prototype',
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ import {
|
|||
} from './plugins/index.js';
|
||||
import type { RouteDeps } from './server-context.js';
|
||||
import { listSkills } from './skills.js';
|
||||
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
||||
|
||||
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry'> {}
|
||||
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {}
|
||||
|
||||
export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
|
||||
const { db, design } = ctx;
|
||||
|
|
@ -28,6 +29,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
const { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects } = ctx.status;
|
||||
const { subscribeFileEvents, activeProjectEventSinks } = ctx.events;
|
||||
const { randomId } = ctx.ids;
|
||||
const { validateProjectDesignSystemId } = ctx.validation;
|
||||
async function loadPluginRegistryView() {
|
||||
const [skills, designSystems] = await Promise.all([
|
||||
listSkills(SKILLS_DIR),
|
||||
|
|
@ -166,6 +168,16 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
if (skipDiscoveryBrief !== undefined && typeof skipDiscoveryBrief !== 'boolean') {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'skipDiscoveryBrief must be a boolean');
|
||||
}
|
||||
const designSystemValidation = await validateProjectDesignSystemId(designSystemId);
|
||||
if (!designSystemValidation.ok) {
|
||||
return sendApiError(
|
||||
res,
|
||||
400,
|
||||
designSystemValidation.code,
|
||||
designSystemValidation.message,
|
||||
);
|
||||
}
|
||||
const normalizedDesignSystemId = designSystemValidation.id;
|
||||
const projectMetadata =
|
||||
metadata && typeof metadata === 'object'
|
||||
? {
|
||||
|
|
@ -186,7 +198,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
id,
|
||||
name: name.trim(),
|
||||
skillId: skillId ?? null,
|
||||
designSystemId: designSystemId ?? null,
|
||||
designSystemId: normalizedDesignSystemId,
|
||||
pendingPrompt: pendingPrompt || null,
|
||||
metadata: projectMetadata,
|
||||
customInstructions:
|
||||
|
|
@ -231,8 +243,8 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
conversationId: cid,
|
||||
registry,
|
||||
activeProjectDesignSystem:
|
||||
typeof designSystemId === 'string' && designSystemId.length > 0
|
||||
? { id: designSystemId }
|
||||
typeof normalizedDesignSystemId === 'string' && normalizedDesignSystemId.length > 0
|
||||
? { id: normalizedDesignSystemId }
|
||||
: undefined,
|
||||
});
|
||||
if (resolved && !resolved.ok) {
|
||||
|
|
@ -306,7 +318,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
res.json(body);
|
||||
});
|
||||
|
||||
app.patch('/api/projects/:id', (req, res) => {
|
||||
app.patch('/api/projects/:id', async (req, res) => {
|
||||
try {
|
||||
const patch = req.body || {};
|
||||
// baseDir / folder-import state is privileged: it's set only by the
|
||||
|
|
@ -377,6 +389,18 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
if (typeof patch.customInstructions === 'string' && patch.customInstructions.length > 5000) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'designSystemId')) {
|
||||
const designSystemValidation = await validateProjectDesignSystemId(patch.designSystemId);
|
||||
if (!designSystemValidation.ok) {
|
||||
return sendApiError(
|
||||
res,
|
||||
400,
|
||||
designSystemValidation.code,
|
||||
designSystemValidation.message,
|
||||
);
|
||||
}
|
||||
patch.designSystemId = designSystemValidation.id;
|
||||
}
|
||||
const project = updateProject(db, req.params.id, patch);
|
||||
if (!project)
|
||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
||||
|
|
@ -781,7 +805,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
const { upload } = ctx.uploads;
|
||||
const { fs } = ctx.node;
|
||||
const { getProject } = ctx.projectStore;
|
||||
const { listFiles, searchProjectFiles, readProjectFile, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles;
|
||||
const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles;
|
||||
const { buildDocumentPreview } = ctx.documents;
|
||||
const { validateArtifactManifestInput } = ctx.artifacts;
|
||||
|
||||
|
|
@ -826,6 +850,22 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id/design-system-package-audit', async (req, res) => {
|
||||
try {
|
||||
const project = getProject(db, req.params.id);
|
||||
if (!project) {
|
||||
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||
return;
|
||||
}
|
||||
const projectRoot = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
|
||||
const audit = await auditDesignSystemPackage(projectRoot);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({ audit });
|
||||
} catch (err: any) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Preflight for the raw file route. Current artifact fetches are simple GETs
|
||||
// (no preflight needed), but an explicit handler future-proofs the route if
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ You are an expert designer working with the user as your manager. You produce de
|
|||
|
||||
Three hard rules govern the start of every new design task. They are not optional. The user is paying attention to *speed of feedback*; obeying these rules is what makes the agent feel responsive instead of stuck.
|
||||
|
||||
Active design system exception: if a later section in this same system prompt is titled \`## Active design system\`, the user has already selected the brand and visual direction. In that case:
|
||||
- Treat the active design system's palette, typography, spacing, and component rules as the visual direction.
|
||||
- Do not ask the user to pick a separate theme color, visual direction, palette, typography mood, or direction card.
|
||||
- Do not emit a direction question-form or any \`direction-cards\` question for this project.
|
||||
- In the turn-1 discovery form, drop brand/direction/theme-color questions unless the user explicitly asks to switch away from the active design system.
|
||||
- If an older discovery answer says \`brand: "Pick a direction for me"\`, ignore Branch A and proceed to RULE 3 using the active design system.
|
||||
|
||||
---
|
||||
|
||||
## RULE 1 — turn 1 must emit a \`<question-form id="discovery">\` (not tools, not thinking)
|
||||
|
|
|
|||
|
|
@ -63,10 +63,20 @@ import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './nati
|
|||
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
|
||||
import { syncCommunityPets } from './community-pets-sync.js';
|
||||
import {
|
||||
createUserDesignSystem,
|
||||
deleteUserDesignSystem,
|
||||
LEGACY_DESIGN_SYSTEM_ARTIFACTS,
|
||||
linkUserDesignSystemProject,
|
||||
listDesignSystems,
|
||||
listUserDesignSystemFiles,
|
||||
listUserDesignSystemRevisions,
|
||||
readDesignSystem,
|
||||
readUserDesignSystemFile,
|
||||
resolveDesignSystemAssets,
|
||||
updateUserDesignSystem,
|
||||
updateUserDesignSystemRevisionStatus,
|
||||
} from './design-systems.js';
|
||||
import { createDesignSystemGenerationJobStore } from './design-system-generation-jobs.js';
|
||||
import {
|
||||
applyDiffReviewDecisionToCwd,
|
||||
applyPlugin,
|
||||
|
|
@ -1169,6 +1179,9 @@ for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR, USER_DESIGN_TEMPLAT
|
|||
}
|
||||
fs.mkdirSync(CRITIQUE_ARTIFACTS_DIR, { recursive: true });
|
||||
const orbitService = new OrbitService(RUNTIME_DATA_DIR);
|
||||
const designSystemGenerationJobs = createDesignSystemGenerationJobStore({
|
||||
root: USER_DESIGN_SYSTEMS_DIR,
|
||||
});
|
||||
let routineService = null;
|
||||
|
||||
// In-memory OAuth state cache. Lives for the daemon process's lifetime.
|
||||
|
|
@ -2589,17 +2602,206 @@ export async function startServer({
|
|||
const builtIn = (await listDesignSystems(DESIGN_SYSTEMS_DIR)).map((s) => ({
|
||||
...s,
|
||||
source: 'built-in',
|
||||
isEditable: false,
|
||||
status: 'published',
|
||||
}));
|
||||
let installed = [];
|
||||
try {
|
||||
installed = (await listDesignSystems(USER_DESIGN_SYSTEMS_DIR)).map(
|
||||
(s) => ({ ...s, source: 'installed' }),
|
||||
);
|
||||
installed = await listDesignSystems(USER_DESIGN_SYSTEMS_DIR, {
|
||||
idPrefix: 'user:',
|
||||
source: 'user',
|
||||
isEditable: true,
|
||||
defaultStatus: 'draft',
|
||||
});
|
||||
} catch {
|
||||
// User directory may not exist yet or be unreadable.
|
||||
}
|
||||
const seen = new Set(builtIn.map((s) => s.id));
|
||||
return [...builtIn, ...installed.filter((s) => !seen.has(s.id))];
|
||||
return [
|
||||
...installed
|
||||
.filter((s) => s.source === 'user')
|
||||
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')),
|
||||
...builtIn,
|
||||
...installed.filter((s) => s.source !== 'user' && !seen.has(s.id)),
|
||||
];
|
||||
}
|
||||
|
||||
async function readAvailableDesignSystem(id) {
|
||||
if (typeof id === 'string' && id.startsWith('user:')) {
|
||||
return readDesignSystem(USER_DESIGN_SYSTEMS_DIR, id, { idPrefix: 'user:' });
|
||||
}
|
||||
return (
|
||||
(await readDesignSystem(DESIGN_SYSTEMS_DIR, id))
|
||||
?? (await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, id))
|
||||
);
|
||||
}
|
||||
|
||||
function isProjectUsableDesignSystem(summary) {
|
||||
return summary?.status !== 'draft';
|
||||
}
|
||||
|
||||
async function validateProjectDesignSystemId(id) {
|
||||
if (id === undefined || id === null || id === '') return { ok: true, id: null };
|
||||
if (typeof id !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_DESIGN_SYSTEM',
|
||||
message: 'designSystemId must be a string or null',
|
||||
};
|
||||
}
|
||||
const systems = await listAllDesignSystems();
|
||||
const summary = systems.find((system) => system.id === id);
|
||||
if (!summary) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'DESIGN_SYSTEM_NOT_FOUND',
|
||||
message: 'design system not found',
|
||||
};
|
||||
}
|
||||
if (!isProjectUsableDesignSystem(summary)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'DESIGN_SYSTEM_NOT_PUBLISHED',
|
||||
message: 'draft design systems cannot be used by projects',
|
||||
};
|
||||
}
|
||||
return { ok: true, id };
|
||||
}
|
||||
|
||||
function userDesignSystemWorkspaceProjectId(id) {
|
||||
if (typeof id !== 'string' || !id.startsWith('user:')) return null;
|
||||
const dirId = id.slice('user:'.length);
|
||||
if (!/^[A-Za-z0-9._-]{1,120}$/.test(dirId)) return null;
|
||||
return `ds-${dirId}`.slice(0, 128);
|
||||
}
|
||||
|
||||
function projectBackedDesignSystemProjectId(id, summary) {
|
||||
if (typeof summary?.projectId === 'string' && isSafeId(summary.projectId)) {
|
||||
return summary.projectId;
|
||||
}
|
||||
return userDesignSystemWorkspaceProjectId(id);
|
||||
}
|
||||
|
||||
async function ensureUserDesignSystemWorkspaceProject(db, id) {
|
||||
const systems = await listAllDesignSystems();
|
||||
const summary = systems.find((s) => s.id === id && s.source === 'user');
|
||||
if (!summary) return null;
|
||||
const projectId = projectBackedDesignSystemProjectId(id, summary);
|
||||
if (!projectId) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const metadata = {
|
||||
kind: 'other',
|
||||
importedFrom: 'design-system',
|
||||
entryFile: 'DESIGN.md',
|
||||
sourceFileName: id,
|
||||
};
|
||||
const existing = getProject(db, projectId);
|
||||
const project = existing
|
||||
? updateProject(db, projectId, {
|
||||
name: summary.title,
|
||||
designSystemId: id,
|
||||
metadata: { ...existing.metadata, ...metadata },
|
||||
updatedAt: now,
|
||||
})
|
||||
: insertProject(db, {
|
||||
id: projectId,
|
||||
name: summary.title,
|
||||
skillId: null,
|
||||
designSystemId: id,
|
||||
pendingPrompt: null,
|
||||
metadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (!project) return null;
|
||||
|
||||
const files = await listUserDesignSystemFiles(USER_DESIGN_SYSTEMS_DIR, id);
|
||||
if (!files) return null;
|
||||
for (const file of files) {
|
||||
if (file.kind === 'folder') continue;
|
||||
const detail = await readUserDesignSystemFile(USER_DESIGN_SYSTEMS_DIR, id, file.path);
|
||||
if (!detail) continue;
|
||||
if (existing) {
|
||||
try {
|
||||
const existingFile = await readProjectFile(PROJECTS_DIR, projectId, detail.path, project.metadata);
|
||||
if (!isReplaceableDesignSystemWorkspaceFile(detail.path, existingFile)) continue;
|
||||
} catch (err) {
|
||||
if (!err || err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
await writeProjectFile(
|
||||
PROJECTS_DIR,
|
||||
projectId,
|
||||
detail.path,
|
||||
Buffer.from(detail.content, 'utf8'),
|
||||
{},
|
||||
project.metadata,
|
||||
);
|
||||
}
|
||||
await removeLegacyDesignSystemWorkspaceArtifacts(project);
|
||||
await linkUserDesignSystemProject(USER_DESIGN_SYSTEMS_DIR, id, project.id);
|
||||
const projectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: project.metadata });
|
||||
return { project, files: projectFiles };
|
||||
}
|
||||
|
||||
function isReplaceableDesignSystemWorkspaceFile(filePath, file) {
|
||||
const buffer = file?.buffer;
|
||||
if (!Buffer.isBuffer(buffer)) return false;
|
||||
const text = buffer.toString('utf8');
|
||||
if (/^ui_kits\/app\/components\/.+\.(jsx|tsx|js|ts|css|html)$/u.test(filePath)) {
|
||||
return buffer.length < 700 && /od-ui-kit-[a-z-]+/u.test(text);
|
||||
}
|
||||
if (!/^(DESIGN\.md|README\.md|SKILL\.md|ui_kits\/app\/README\.md)$/u.test(filePath)) {
|
||||
return false;
|
||||
}
|
||||
return hasLegacyDesignSystemPackageReferences(text);
|
||||
}
|
||||
|
||||
function hasLegacyDesignSystemPackageReferences(text) {
|
||||
return /preview\/(colors-node-types|colors-ui-palette|typography-scale|spacing-system|logo-variants)\.html|ui_kits\/generated_interface(?:\/index\.html|\/)?/u.test(text);
|
||||
}
|
||||
|
||||
async function removeLegacyDesignSystemWorkspaceArtifacts(project) {
|
||||
if (project?.metadata?.importedFrom !== 'design-system') return;
|
||||
const dir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
|
||||
for (const artifact of LEGACY_DESIGN_SYSTEM_ARTIFACTS) {
|
||||
const replacementReady = await Promise.all(
|
||||
artifact.replacementPaths.map(async (replacementPath) => {
|
||||
try {
|
||||
const stats = await fs.promises.stat(path.join(dir, ...replacementPath.split('/')));
|
||||
return stats.isFile();
|
||||
} catch (err) {
|
||||
if (!err || (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')) throw err;
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!replacementReady.every(Boolean)) continue;
|
||||
await fs.promises.rm(path.join(dir, ...artifact.legacyPath.split('/')), {
|
||||
recursive: artifact.removeDirectory === true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readDesignSystemWorkspaceTextFile(db, summary, filePath) {
|
||||
if (!summary?.projectId || !isSafeId(summary.projectId)) return null;
|
||||
const project = getProject(db, summary.projectId);
|
||||
if (!project) return null;
|
||||
try {
|
||||
const file = await readProjectFile(
|
||||
PROJECTS_DIR,
|
||||
project.id,
|
||||
filePath,
|
||||
project.metadata,
|
||||
);
|
||||
const text = file.buffer.toString('utf8');
|
||||
if (text.includes('\0')) return null;
|
||||
return text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome may strip the port from the Origin header on same-origin GET
|
||||
|
|
@ -3632,7 +3834,7 @@ export async function startServer({
|
|||
isFinalizeProviderProtocol,
|
||||
redactSecrets,
|
||||
};
|
||||
const validationDeps = { isSafeId, validateExternalApiBaseUrl, validateBaseUrl };
|
||||
const validationDeps = { isSafeId, validateExternalApiBaseUrl, validateBaseUrl, validateProjectDesignSystemId };
|
||||
const agentDeps = {
|
||||
listProviderModels,
|
||||
testProviderConnection,
|
||||
|
|
@ -3678,6 +3880,7 @@ export async function startServer({
|
|||
events: projectEventDeps,
|
||||
ids: idDeps,
|
||||
telemetry: { reportFinalizedMessage },
|
||||
validation: validationDeps,
|
||||
});
|
||||
registerImportRoutes(app, {
|
||||
db,
|
||||
|
|
@ -3691,6 +3894,7 @@ export async function startServer({
|
|||
projectStore: projectStoreDeps,
|
||||
conversations: conversationDeps,
|
||||
projectFiles: projectFileDeps,
|
||||
validation: validationDeps,
|
||||
});
|
||||
|
||||
// Resource catalog
|
||||
|
|
@ -4191,7 +4395,7 @@ export async function startServer({
|
|||
|
||||
app.get('/api/design-systems', async (_req, res) => {
|
||||
try {
|
||||
const systems = await listDesignSystems(DESIGN_SYSTEMS_DIR);
|
||||
const systems = await listAllDesignSystems();
|
||||
res.json({
|
||||
designSystems: systems.map(({ body, ...rest }) => rest),
|
||||
});
|
||||
|
|
@ -4200,12 +4404,165 @@ export async function startServer({
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/api/design-systems', async (req, res) => {
|
||||
try {
|
||||
const created = await createUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.body || {});
|
||||
res.status(201).json({ ...created, designSystem: created });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/design-systems/generation-jobs', async (req, res) => {
|
||||
try {
|
||||
const job = designSystemGenerationJobs.start(req.body || {});
|
||||
res.status(202).json({ job });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/design-systems/generation-jobs/:jobId', async (req, res) => {
|
||||
try {
|
||||
const job = designSystemGenerationJobs.get(req.params.jobId);
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: 'design system generation job not found' });
|
||||
}
|
||||
res.json({ job });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/design-systems/:id/revision-jobs', async (req, res) => {
|
||||
try {
|
||||
const feedback = typeof req.body?.feedback === 'string' ? req.body.feedback : '';
|
||||
if (!feedback.trim()) return res.status(400).json({ error: 'feedback is required' });
|
||||
const job = designSystemGenerationJobs.revise({
|
||||
designSystemId: req.params.id,
|
||||
feedback,
|
||||
sectionTitle: typeof req.body?.sectionTitle === 'string' ? req.body.sectionTitle : undefined,
|
||||
body: typeof req.body?.body === 'string' ? req.body.body : undefined,
|
||||
});
|
||||
res.status(202).json({ job });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/design-systems/:id/revisions', async (req, res) => {
|
||||
try {
|
||||
const revisions = await listUserDesignSystemRevisions(
|
||||
USER_DESIGN_SYSTEMS_DIR,
|
||||
req.params.id,
|
||||
);
|
||||
if (!revisions) {
|
||||
return res.status(404).json({ error: 'editable design system not found' });
|
||||
}
|
||||
res.json({ revisions });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/design-systems/:id/revisions/:revisionId', async (req, res) => {
|
||||
try {
|
||||
const status = typeof req.body?.status === 'string' ? req.body.status : '';
|
||||
if (status !== 'accepted' && status !== 'rejected') {
|
||||
return res.status(400).json({ error: 'status must be accepted or rejected' });
|
||||
}
|
||||
const revision = await updateUserDesignSystemRevisionStatus(
|
||||
USER_DESIGN_SYSTEMS_DIR,
|
||||
req.params.id,
|
||||
req.params.revisionId,
|
||||
status,
|
||||
);
|
||||
if (!revision) {
|
||||
return res.status(404).json({ error: 'design system revision not found' });
|
||||
}
|
||||
res.json({ revision });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/design-systems/:id', async (req, res) => {
|
||||
try {
|
||||
const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id);
|
||||
if (body === null)
|
||||
const systems = await listAllDesignSystems();
|
||||
const summary = systems.find((s) => s.id === req.params.id);
|
||||
const projectBody = await readDesignSystemWorkspaceTextFile(db, summary, 'DESIGN.md');
|
||||
const body = projectBody ?? await readAvailableDesignSystem(req.params.id);
|
||||
if (body === null || !summary)
|
||||
return res.status(404).json({ error: 'design system not found' });
|
||||
res.json({ id: req.params.id, body });
|
||||
const detail = { ...summary, body };
|
||||
res.json({ ...detail, designSystem: detail });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/design-systems/:id/workspace', async (req, res) => {
|
||||
try {
|
||||
const workspace = await ensureUserDesignSystemWorkspaceProject(db, req.params.id);
|
||||
if (!workspace) {
|
||||
return res.status(404).json({ error: 'editable design system not found' });
|
||||
}
|
||||
res.status(201).json(workspace);
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/design-systems/:id/files', async (req, res) => {
|
||||
try {
|
||||
const files = await listUserDesignSystemFiles(USER_DESIGN_SYSTEMS_DIR, req.params.id);
|
||||
if (!files) {
|
||||
return res.status(404).json({ error: 'editable design system not found' });
|
||||
}
|
||||
res.json({ files });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/design-systems/:id/file', async (req, res) => {
|
||||
try {
|
||||
const requestedPath = typeof req.query.path === 'string' ? req.query.path : '';
|
||||
const file = await readUserDesignSystemFile(
|
||||
USER_DESIGN_SYSTEMS_DIR,
|
||||
req.params.id,
|
||||
requestedPath,
|
||||
);
|
||||
if (!file) return res.status(404).json({ error: 'design system file not found' });
|
||||
res.json({ file });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/design-systems/:id', async (req, res) => {
|
||||
try {
|
||||
const updated = await updateUserDesignSystem(
|
||||
USER_DESIGN_SYSTEMS_DIR,
|
||||
req.params.id,
|
||||
req.body || {},
|
||||
);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'editable design system not found' });
|
||||
}
|
||||
res.json({ ...updated, designSystem: updated });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/design-systems/:id', async (req, res) => {
|
||||
try {
|
||||
const ok = await deleteUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id);
|
||||
if (!ok) {
|
||||
return res.status(404).json({ error: 'editable design system not found' });
|
||||
}
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
|
|
@ -5786,7 +6143,7 @@ export async function startServer({
|
|||
// file shows up on the next view, no rebuild needed.
|
||||
app.get('/api/design-systems/:id/preview', async (req, res) => {
|
||||
try {
|
||||
const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id);
|
||||
const body = await readAvailableDesignSystem(req.params.id);
|
||||
if (body === null)
|
||||
return res.status(404).type('text/plain').send('not found');
|
||||
const html = renderDesignSystemPreview(req.params.id, body);
|
||||
|
|
@ -5801,7 +6158,7 @@ export async function startServer({
|
|||
// /preview: built at request time, no caching.
|
||||
app.get('/api/design-systems/:id/showcase', async (req, res) => {
|
||||
try {
|
||||
const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id);
|
||||
const body = await readAvailableDesignSystem(req.params.id);
|
||||
if (body === null)
|
||||
return res.status(404).type('text/plain').send('not found');
|
||||
const html = renderDesignSystemShowcase(req.params.id, body);
|
||||
|
|
@ -7735,25 +8092,34 @@ export async function startServer({
|
|||
let designSystemComponentsManifest;
|
||||
let designSystemFixtureHtml;
|
||||
if (effectiveDesignSystemId) {
|
||||
const systems = await listAllDesignSystems();
|
||||
const summary = systems.find((s) => s.id === effectiveDesignSystemId);
|
||||
let systems = await listAllDesignSystems();
|
||||
let summary = systems.find((s) => s.id === effectiveDesignSystemId);
|
||||
if (summary?.source === 'user') {
|
||||
await ensureUserDesignSystemWorkspaceProject(db, effectiveDesignSystemId);
|
||||
systems = await listAllDesignSystems();
|
||||
summary = systems.find((s) => s.id === effectiveDesignSystemId);
|
||||
}
|
||||
const editingOwnDraftDesignSystem =
|
||||
project?.metadata?.importedFrom === 'design-system'
|
||||
&& project.designSystemId === effectiveDesignSystemId;
|
||||
designSystemTitle = summary?.title;
|
||||
designSystemBody =
|
||||
(await readDesignSystem(DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)) ??
|
||||
(await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)) ??
|
||||
undefined;
|
||||
// Single seam: env gate + built-in→user-installed fallback chain
|
||||
// live together inside `resolveDesignSystemAssets` so the whole
|
||||
// server-side asset-resolution path can be tested end-to-end
|
||||
// from real disk fixtures (see `tests/design-system-assets.test.ts`).
|
||||
const assets = await resolveDesignSystemAssets(
|
||||
effectiveDesignSystemId,
|
||||
DESIGN_SYSTEMS_DIR,
|
||||
USER_DESIGN_SYSTEMS_DIR,
|
||||
);
|
||||
designSystemTokensCss = assets.tokensCss;
|
||||
designSystemComponentsManifest = assets.componentsManifest;
|
||||
designSystemFixtureHtml = assets.fixtureHtml;
|
||||
if (summary && (isProjectUsableDesignSystem(summary) || editingOwnDraftDesignSystem)) {
|
||||
const workspaceBody = await readDesignSystemWorkspaceTextFile(db, summary, 'DESIGN.md');
|
||||
const registryBody = await readAvailableDesignSystem(effectiveDesignSystemId);
|
||||
designSystemBody = (workspaceBody ?? registryBody) ?? undefined;
|
||||
// Single seam: env gate + built-in→user-installed fallback chain
|
||||
// live together inside `resolveDesignSystemAssets` so the whole
|
||||
// server-side asset-resolution path can be tested end-to-end
|
||||
// from real disk fixtures (see `tests/design-system-assets.test.ts`).
|
||||
const assets = await resolveDesignSystemAssets(
|
||||
effectiveDesignSystemId,
|
||||
DESIGN_SYSTEMS_DIR,
|
||||
USER_DESIGN_SYSTEMS_DIR,
|
||||
);
|
||||
designSystemTokensCss = assets.tokensCss;
|
||||
designSystemComponentsManifest = assets.componentsManifest;
|
||||
designSystemFixtureHtml = assets.fixtureHtml;
|
||||
}
|
||||
}
|
||||
|
||||
const template =
|
||||
|
|
|
|||
|
|
@ -301,17 +301,11 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/api/design-systems/:id', async (req, res) => {
|
||||
try {
|
||||
const body =
|
||||
(await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id)) ??
|
||||
(await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id));
|
||||
if (body === null)
|
||||
return res.status(404).json({ error: 'design system not found' });
|
||||
res.json({ id: req.params.id, body });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
app.get('/api/design-systems/:id', (_req, _res, next) => {
|
||||
// The design-system workflow owns the detail shape now because user-created
|
||||
// systems may be backed by a review workspace project. Let the richer route
|
||||
// registered in server.ts answer this request.
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/api/prompt-templates', async (_req, res) => {
|
||||
|
|
@ -344,35 +338,15 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
// samples, sample components, and the full DESIGN.md rendered as prose.
|
||||
// Built at request time from the on-disk DESIGN.md so any update to the
|
||||
// file shows up on the next view, no rebuild needed.
|
||||
app.get('/api/design-systems/:id/preview', async (req, res) => {
|
||||
try {
|
||||
const body =
|
||||
(await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id)) ??
|
||||
(await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id));
|
||||
if (body === null)
|
||||
return res.status(404).type('text/plain').send('not found');
|
||||
const html = renderDesignSystemPreview(req.params.id, body);
|
||||
res.type('text/html').send(html);
|
||||
} catch (err: any) {
|
||||
res.status(500).type('text/plain').send(String(err));
|
||||
}
|
||||
app.get('/api/design-systems/:id/preview', (_req, _res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Marketing-style showcase derived from the same DESIGN.md — full landing
|
||||
// page parameterised by the system's tokens. Same lazy-render strategy as
|
||||
// /preview: built at request time, no caching.
|
||||
app.get('/api/design-systems/:id/showcase', async (req, res) => {
|
||||
try {
|
||||
const body =
|
||||
(await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id)) ??
|
||||
(await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id));
|
||||
if (body === null)
|
||||
return res.status(404).type('text/plain').send('not found');
|
||||
const html = renderDesignSystemShowcase(req.params.id, body);
|
||||
res.type('text/html').send(html);
|
||||
} catch (err: any) {
|
||||
res.status(500).type('text/plain').send(String(err));
|
||||
}
|
||||
app.get('/api/design-systems/:id/showcase', (_req, _res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Pre-built example HTML for a skill — what a typical artifact from this
|
||||
|
|
@ -719,8 +693,11 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
}
|
||||
});
|
||||
|
||||
app.delete('/api/design-systems/:id', async (req, res) => {
|
||||
app.delete('/api/design-systems/:id', async (req, res, next) => {
|
||||
if (!requireLocalOrigin(req, res)) return;
|
||||
if (req.params.id.startsWith('user:')) {
|
||||
return next();
|
||||
}
|
||||
try {
|
||||
const result = await uninstallById(
|
||||
req.params.id,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
203
apps/daemon/tests/design-system-generation-jobs.test.ts
Normal file
203
apps/daemon/tests/design-system-generation-jobs.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createDesignSystemGenerationJobStore,
|
||||
type DesignSystemGenerationJob,
|
||||
} from '../src/design-system-generation-jobs.js';
|
||||
import {
|
||||
createUserDesignSystem,
|
||||
listUserDesignSystemRevisions,
|
||||
readDesignSystem,
|
||||
type UserDesignSystemInput,
|
||||
updateUserDesignSystemRevisionStatus,
|
||||
} from '../src/design-systems.js';
|
||||
|
||||
describe('design system generation jobs', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'od-design-system-jobs-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates a pollable job that produces a user design system draft', async () => {
|
||||
const store = createDesignSystemGenerationJobStore({
|
||||
root,
|
||||
delayMs: 0,
|
||||
idFactory: () => 'job-1',
|
||||
collectSourceContext: async () => ({ github: [], notes: '' }),
|
||||
});
|
||||
|
||||
const started = store.start({
|
||||
title: 'Acme Product',
|
||||
summary: 'Dense product UI.',
|
||||
category: 'Custom',
|
||||
status: 'draft',
|
||||
provenance: {
|
||||
companyBlurb: 'Acme builds dense product UI.',
|
||||
githubUrls: ['https://github.com/acme/product'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(started).toMatchObject({
|
||||
id: 'job-1',
|
||||
status: 'running',
|
||||
progress: 0,
|
||||
});
|
||||
|
||||
const done = await waitForJob(store, 'job-1');
|
||||
|
||||
expect(done).toMatchObject({
|
||||
id: 'job-1',
|
||||
status: 'succeeded',
|
||||
progress: 100,
|
||||
designSystemId: 'user:acme-product',
|
||||
});
|
||||
expect(done.steps.map((step) => step.status)).toEqual([
|
||||
'succeeded',
|
||||
'succeeded',
|
||||
'succeeded',
|
||||
'succeeded',
|
||||
'succeeded',
|
||||
]);
|
||||
expect(done.steps.map((step) => step.message).join('\n')).toContain(
|
||||
'1 GitHub link(s)',
|
||||
);
|
||||
});
|
||||
|
||||
it('merges collected source context into the generated draft', async () => {
|
||||
let capturedInput: UserDesignSystemInput | undefined;
|
||||
const store = createDesignSystemGenerationJobStore({
|
||||
root,
|
||||
delayMs: 0,
|
||||
idFactory: () => 'job-context',
|
||||
collectSourceContext: async () => ({
|
||||
github: [{
|
||||
url: 'https://github.com/acme/product',
|
||||
owner: 'acme',
|
||||
repo: 'product',
|
||||
description: 'Acme repository.',
|
||||
}],
|
||||
notes: 'Fetched GitHub context:\n- acme/product: README excerpt: Dense editor primitives.',
|
||||
}),
|
||||
createDesignSystem: async (targetRoot, input) => {
|
||||
capturedInput = input;
|
||||
return createUserDesignSystem(targetRoot, input);
|
||||
},
|
||||
});
|
||||
|
||||
store.start({
|
||||
title: 'Context Product',
|
||||
sourceNotes: 'GitHub/code: https://github.com/acme/product',
|
||||
provenance: {
|
||||
githubUrls: ['https://github.com/acme/product'],
|
||||
sourceNotes: 'GitHub/code: https://github.com/acme/product',
|
||||
},
|
||||
});
|
||||
|
||||
const done = await waitForJob(store, 'job-context');
|
||||
const body = await readDesignSystem(root, done.designSystemId ?? '', { idPrefix: 'user:' });
|
||||
|
||||
expect(done.steps.find((step) => step.id === 'explore-resources')?.message).toContain('read 1 GitHub repo');
|
||||
expect(capturedInput?.sourceNotes).toContain('GitHub/code: https://github.com/acme/product');
|
||||
expect(capturedInput?.sourceNotes).toContain('Fetched GitHub context');
|
||||
expect(capturedInput?.provenance?.sourceNotes).toContain('Dense editor primitives');
|
||||
expect(capturedInput?.provenance?.sourceNotes).not.toContain('GitHub/code:');
|
||||
expect(body).toContain('Dense editor primitives');
|
||||
});
|
||||
|
||||
it('exposes failed generation status when draft creation fails', async () => {
|
||||
const store = createDesignSystemGenerationJobStore({
|
||||
root,
|
||||
delayMs: 0,
|
||||
idFactory: () => 'job-fail',
|
||||
createDesignSystem: async () => {
|
||||
throw new Error('draft write failed');
|
||||
},
|
||||
});
|
||||
|
||||
store.start({ title: 'Broken System' });
|
||||
|
||||
const done = await waitForJob(store, 'job-fail');
|
||||
|
||||
expect(done).toMatchObject({
|
||||
id: 'job-fail',
|
||||
status: 'failed',
|
||||
error: 'draft write failed',
|
||||
});
|
||||
expect(done.steps.find((step) => step.id === 'create-draft')?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('runs a revision job against an existing user design system', async () => {
|
||||
const store = createDesignSystemGenerationJobStore({
|
||||
root,
|
||||
delayMs: 0,
|
||||
idFactory: () => 'revision-1',
|
||||
});
|
||||
const created = await createUserDesignSystem(root, {
|
||||
title: 'Revision Product',
|
||||
summary: 'Initial system.',
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
const started = store.revise({
|
||||
designSystemId: created.id,
|
||||
sectionTitle: 'Visual Foundations',
|
||||
feedback: 'Make the palette warmer and reduce decorative effects.',
|
||||
});
|
||||
|
||||
expect(started).toMatchObject({
|
||||
id: 'revision-1',
|
||||
kind: 'revision',
|
||||
designSystemId: created.id,
|
||||
});
|
||||
|
||||
const done = await waitForJob(store, 'revision-1');
|
||||
const body = await readDesignSystem(root, created.id, { idPrefix: 'user:' });
|
||||
const revisions = await listUserDesignSystemRevisions(root, created.id);
|
||||
|
||||
expect(done).toMatchObject({
|
||||
id: 'revision-1',
|
||||
status: 'succeeded',
|
||||
progress: 100,
|
||||
designSystemId: created.id,
|
||||
revisionId: expect.any(String),
|
||||
});
|
||||
expect(body).not.toContain('## Revision Request: Visual Foundations');
|
||||
expect(revisions?.[0]).toMatchObject({
|
||||
status: 'pending',
|
||||
feedback: 'Make the palette warmer and reduce decorative effects.',
|
||||
sectionTitle: 'Visual Foundations',
|
||||
});
|
||||
expect(revisions?.[0]?.proposedBody).toContain('## Revision Request: Visual Foundations');
|
||||
|
||||
const accepted = await updateUserDesignSystemRevisionStatus(
|
||||
root,
|
||||
created.id,
|
||||
revisions?.[0]?.id ?? '',
|
||||
'accepted',
|
||||
);
|
||||
const acceptedBody = await readDesignSystem(root, created.id, { idPrefix: 'user:' });
|
||||
|
||||
expect(accepted?.status).toBe('accepted');
|
||||
expect(acceptedBody).toContain('## Revision Request: Visual Foundations');
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForJob(
|
||||
store: ReturnType<typeof createDesignSystemGenerationJobStore>,
|
||||
id: string,
|
||||
): Promise<DesignSystemGenerationJob> {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
const job = store.get(id);
|
||||
if (job && (job.status === 'succeeded' || job.status === 'failed')) return job;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${id}`);
|
||||
}
|
||||
103
apps/daemon/tests/design-system-source-context.test.ts
Normal file
103
apps/daemon/tests/design-system-source-context.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectDesignSystemSourceContext,
|
||||
mergeSourceContextIntoInput,
|
||||
type FetchLike,
|
||||
} from '../src/design-system-source-context.js';
|
||||
|
||||
describe('design system source context', () => {
|
||||
it('reads GitHub metadata, README, and package context', async () => {
|
||||
const fetchFn: FetchLike = async (url) => {
|
||||
if (url === 'https://api.github.com/repos/acme/product') {
|
||||
return jsonResponse({
|
||||
description: 'Acme product UI repository.',
|
||||
homepage: 'https://acme.example',
|
||||
default_branch: 'trunk',
|
||||
language: 'TypeScript',
|
||||
stargazers_count: 42,
|
||||
topics: ['design-system', 'dashboard'],
|
||||
});
|
||||
}
|
||||
if (url === 'https://raw.githubusercontent.com/acme/product/trunk/README.md') {
|
||||
return textResponse('# Acme Product\n\nDesign tokens and dense workflow components for Acme.');
|
||||
}
|
||||
if (url === 'https://raw.githubusercontent.com/acme/product/trunk/package.json') {
|
||||
return textResponse(JSON.stringify({
|
||||
name: '@acme/product',
|
||||
description: 'Workspace UI package.',
|
||||
}));
|
||||
}
|
||||
return textResponse('not found', 404);
|
||||
};
|
||||
|
||||
const context = await collectDesignSystemSourceContext({
|
||||
provenance: {
|
||||
githubUrls: ['https://github.com/acme/product/tree/trunk'],
|
||||
},
|
||||
}, {
|
||||
fetch: fetchFn,
|
||||
maxReadmeChars: 160,
|
||||
});
|
||||
|
||||
expect(context.github[0]).toMatchObject({
|
||||
owner: 'acme',
|
||||
repo: 'product',
|
||||
description: 'Acme product UI repository.',
|
||||
defaultBranch: 'trunk',
|
||||
language: 'TypeScript',
|
||||
stars: 42,
|
||||
packageName: '@acme/product',
|
||||
});
|
||||
expect(context.notes).toContain('Fetched GitHub context');
|
||||
expect(context.notes).toContain('Design tokens and dense workflow components');
|
||||
|
||||
const merged = mergeSourceContextIntoInput({
|
||||
sourceNotes: 'GitHub/code: https://github.com/acme/product',
|
||||
provenance: {
|
||||
githubUrls: ['https://github.com/acme/product'],
|
||||
sourceNotes: 'GitHub/code: https://github.com/acme/product',
|
||||
},
|
||||
}, context);
|
||||
|
||||
expect(merged.sourceNotes).toContain('GitHub/code: https://github.com/acme/product');
|
||||
expect(merged.sourceNotes).toContain('Fetched GitHub context');
|
||||
expect(merged.provenance?.sourceNotes).toContain('README excerpt');
|
||||
expect(merged.provenance?.sourceNotes).not.toContain('GitHub/code:');
|
||||
});
|
||||
|
||||
it('keeps generation usable when GitHub metadata is unavailable', async () => {
|
||||
const context = await collectDesignSystemSourceContext({
|
||||
provenance: {
|
||||
githubUrls: ['git@github.com:acme/missing.git'],
|
||||
},
|
||||
}, {
|
||||
fetch: async () => textResponse('not found', 404),
|
||||
});
|
||||
|
||||
expect(context.github[0]).toMatchObject({
|
||||
owner: 'acme',
|
||||
repo: 'missing',
|
||||
error: 'GitHub repository metadata unavailable (HTTP 404)',
|
||||
});
|
||||
expect(context.notes).toContain('GitHub repository metadata unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(value: unknown, status = 200): ReturnType<FetchLike> {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => value,
|
||||
text: async () => JSON.stringify(value),
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(value: string, status = 200): ReturnType<FetchLike> {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => JSON.parse(value),
|
||||
text: async () => value,
|
||||
});
|
||||
}
|
||||
350
apps/daemon/tests/design-systems.test.ts
Normal file
350
apps/daemon/tests/design-systems.test.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createUserDesignSystem,
|
||||
deleteUserDesignSystem,
|
||||
linkUserDesignSystemProject,
|
||||
listDesignSystems,
|
||||
listUserDesignSystemFiles,
|
||||
readDesignSystem,
|
||||
readUserDesignSystemFile,
|
||||
updateUserDesignSystem,
|
||||
} from '../src/design-systems.js';
|
||||
|
||||
describe('design systems registry', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'od-design-systems-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists bundled design systems as published and non-editable', async () => {
|
||||
await mkdir(path.join(root, 'acme'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, 'acme', 'DESIGN.md'),
|
||||
'# Acme\n\n> Category: Custom\n> Surface: web\n\nAcme brand.\n',
|
||||
);
|
||||
|
||||
const systems = await listDesignSystems(root);
|
||||
|
||||
expect(systems).toMatchObject([
|
||||
{
|
||||
id: 'acme',
|
||||
title: 'Acme',
|
||||
category: 'Custom',
|
||||
status: 'published',
|
||||
source: 'built-in',
|
||||
isEditable: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates, updates, reads, and deletes user design systems with prefixed ids', async () => {
|
||||
const created = await createUserDesignSystem(root, {
|
||||
title: 'Acme Product',
|
||||
summary: 'Dense product UI.',
|
||||
category: 'Custom',
|
||||
status: 'draft',
|
||||
provenance: {
|
||||
companyBlurb: 'Acme builds dense product UI.',
|
||||
githubUrls: ['https://github.com/acme/product'],
|
||||
localCodeFiles: ['src/components/Button.tsx'],
|
||||
figFiles: ['brand.fig'],
|
||||
assetFiles: ['logo.svg'],
|
||||
notes: 'Use compact review flows.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(created.id).toBe('user:acme-product');
|
||||
expect(created.source).toBe('user');
|
||||
expect(created.isEditable).toBe(true);
|
||||
expect(created.status).toBe('draft');
|
||||
expect(created.provenance).toMatchObject({
|
||||
companyBlurb: 'Acme builds dense product UI.',
|
||||
githubUrls: ['https://github.com/acme/product'],
|
||||
localCodeFiles: ['src/components/Button.tsx'],
|
||||
figFiles: ['brand.fig'],
|
||||
assetFiles: ['logo.svg'],
|
||||
notes: 'Use compact review flows.',
|
||||
});
|
||||
const files = await listUserDesignSystemFiles(root, created.id);
|
||||
expect(files?.map((file) => file.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'DESIGN.md',
|
||||
'README.md',
|
||||
'SKILL.md',
|
||||
'context/provenance.json',
|
||||
'context/provenance.md',
|
||||
'colors_and_type.css',
|
||||
'preview/colors-primary.html',
|
||||
'preview/typography-specimens.html',
|
||||
'assets/logo.svg',
|
||||
'ui_kits/app/index.html',
|
||||
'ui_kits/app/README.md',
|
||||
'ui_kits/app/components/App.jsx',
|
||||
'ui_kits/app/components/Sidebar.jsx',
|
||||
'ui_kits/app/components/AssistantsList.jsx',
|
||||
'ui_kits/app/components/ChatArea.jsx',
|
||||
'ui_kits/app/components/InputBar.jsx',
|
||||
'ui_kits/app/components/MessageBubble.jsx',
|
||||
]),
|
||||
);
|
||||
await expect(readUserDesignSystemFile(root, created.id, 'ui_kits/app/index.html'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.stringContaining('ReactDOM.createRoot'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, created.id, 'ui_kits/app/index.html'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.stringContaining('components/App.jsx'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, created.id, 'ui_kits/app/components/App.jsx'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.stringContaining('<Sidebar'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, created.id, 'ui_kits/app/components/App.jsx'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.stringContaining('window.App = App'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, created.id, 'README.md'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
path: 'README.md',
|
||||
kind: 'document',
|
||||
content: expect.stringContaining('Acme Product'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, created.id, 'context/provenance.json'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
path: 'context/provenance.json',
|
||||
kind: 'data',
|
||||
content: expect.stringContaining('https://github.com/acme/product'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, created.id, 'context/provenance.md'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
path: 'context/provenance.md',
|
||||
kind: 'document',
|
||||
content: expect.stringContaining('Acme builds dense product UI.'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, created.id, '../metadata.json'))
|
||||
.resolves
|
||||
.toBeNull();
|
||||
|
||||
const linked = await linkUserDesignSystemProject(root, created.id, 'ds-acme-product');
|
||||
expect(linked?.projectId).toBe('ds-acme-product');
|
||||
await expect(listDesignSystems(root, { idPrefix: 'user:' }))
|
||||
.resolves
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: created.id, projectId: 'ds-acme-product' }),
|
||||
]));
|
||||
|
||||
const updated = await updateUserDesignSystem(root, created.id, {
|
||||
title: 'Acme Product System',
|
||||
status: 'published',
|
||||
body: '# Acme Product System\n\n> Category: Custom\n> Surface: web\n\nPublished.\n',
|
||||
});
|
||||
|
||||
expect(updated?.status).toBe('published');
|
||||
expect(updated?.title).toBe('Acme Product System');
|
||||
expect(updated?.projectId).toBe('ds-acme-product');
|
||||
await expect(readDesignSystem(root, created.id, { idPrefix: 'user:' }))
|
||||
.resolves
|
||||
.toContain('Published.');
|
||||
|
||||
await expect(deleteUserDesignSystem(root, created.id)).resolves.toBe(true);
|
||||
await expect(listDesignSystems(root, { idPrefix: 'user:' })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects traversal ids when reading design systems', async () => {
|
||||
await expect(readDesignSystem(root, '../package')).resolves.toBeNull();
|
||||
await expect(readDesignSystem(root, 'user:../package', { idPrefix: 'user:' }))
|
||||
.resolves
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it('backfills generated files for older user design systems', async () => {
|
||||
await mkdir(path.join(root, 'legacy'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, 'legacy', 'DESIGN.md'),
|
||||
'# Legacy System\n\n> Category: Custom\n> Surface: web\n\nLegacy body.\n',
|
||||
);
|
||||
|
||||
const files = await listUserDesignSystemFiles(root, 'user:legacy');
|
||||
|
||||
expect(files?.map((file) => file.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'README.md',
|
||||
'SKILL.md',
|
||||
'context/provenance.json',
|
||||
'colors_and_type.css',
|
||||
'preview/colors-primary.html',
|
||||
'ui_kits/app/components/App.jsx',
|
||||
'ui_kits/app/components/Sidebar.jsx',
|
||||
'ui_kits/app/components/AssistantsList.jsx',
|
||||
'ui_kits/app/components/ChatArea.jsx',
|
||||
'ui_kits/app/components/InputBar.jsx',
|
||||
'ui_kits/app/components/MessageBubble.jsx',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('migrates older review artifact names into the Claude-style package structure', async () => {
|
||||
await mkdir(path.join(root, 'legacy', 'preview'), { recursive: true });
|
||||
await mkdir(path.join(root, 'legacy', 'ui_kits', 'generated_interface'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, 'legacy', 'DESIGN.md'),
|
||||
'# Legacy System\n\n> Category: Custom\n> Surface: web\n\nLegacy body.\n',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, 'legacy', 'README.md'),
|
||||
'# Legacy\n\nReview preview/typography-scale.html and ui_kits/generated_interface/index.html first.\n',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, 'legacy', 'SKILL.md'),
|
||||
'# Legacy Skill\n\nUse preview/colors-ui-palette.html, preview/spacing-system.html, and ui_kits/generated_interface/.\n',
|
||||
);
|
||||
await writeFile(path.join(root, 'legacy', 'preview', 'colors-ui-palette.html'), '<!doctype html><html><body>colors</body></html>');
|
||||
await writeFile(path.join(root, 'legacy', 'preview', 'colors-node-types.html'), '<!doctype html><html><body>nodes</body></html>');
|
||||
await writeFile(path.join(root, 'legacy', 'preview', 'typography-scale.html'), '<!doctype html><html><body>type</body></html>');
|
||||
await writeFile(path.join(root, 'legacy', 'preview', 'spacing-system.html'), '<!doctype html><html><body>spacing</body></html>');
|
||||
await writeFile(path.join(root, 'legacy', 'preview', 'logo-variants.html'), '<!doctype html><html><body>logo</body></html>');
|
||||
await writeFile(
|
||||
path.join(root, 'legacy', 'ui_kits', 'generated_interface', 'index.html'),
|
||||
'<!doctype html><html><body>legacy app kit</body></html>',
|
||||
);
|
||||
|
||||
const files = await listUserDesignSystemFiles(root, 'user:legacy');
|
||||
|
||||
expect(files?.map((file) => file.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'preview/colors-primary.html',
|
||||
'preview/colors-theme-light.html',
|
||||
'preview/colors-theme-dark.html',
|
||||
'preview/typography-specimens.html',
|
||||
'preview/spacing-tokens.html',
|
||||
'preview/spacing-radius.html',
|
||||
'preview/spacing-shadows.html',
|
||||
'preview/components-buttons.html',
|
||||
'preview/components-inputs.html',
|
||||
'preview/brand-assets.html',
|
||||
'ui_kits/app/index.html',
|
||||
'ui_kits/app/README.md',
|
||||
'ui_kits/app/components/App.jsx',
|
||||
'ui_kits/app/components/Sidebar.jsx',
|
||||
'ui_kits/app/components/AssistantsList.jsx',
|
||||
'ui_kits/app/components/ChatArea.jsx',
|
||||
'ui_kits/app/components/InputBar.jsx',
|
||||
'ui_kits/app/components/MessageBubble.jsx',
|
||||
]),
|
||||
);
|
||||
expect(files?.map((file) => file.path)).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
'preview/colors-ui-palette.html',
|
||||
'preview/colors-node-types.html',
|
||||
'preview/typography-scale.html',
|
||||
'preview/spacing-system.html',
|
||||
'preview/logo-variants.html',
|
||||
'ui_kits/generated_interface/index.html',
|
||||
]),
|
||||
);
|
||||
await expect(readUserDesignSystemFile(root, 'user:legacy', 'ui_kits/app/index.html'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.stringContaining('legacy app kit'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, 'user:legacy', 'README.md'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.not.stringContaining('ui_kits/generated_interface'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, 'user:legacy', 'README.md'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.stringContaining('ui_kits/app/index.html'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, 'user:legacy', 'SKILL.md'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.not.stringContaining('preview/colors-ui-palette.html'),
|
||||
});
|
||||
});
|
||||
|
||||
it('adds modular UI-kit components to existing app kits', async () => {
|
||||
await mkdir(path.join(root, 'legacy', 'ui_kits', 'app'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, 'legacy', 'DESIGN.md'),
|
||||
'# Legacy System\n\n> Category: Custom\n> Surface: web\n\nLegacy body.\n',
|
||||
);
|
||||
await writeFile(path.join(root, 'legacy', 'README.md'), '# Legacy\n');
|
||||
await writeFile(path.join(root, 'legacy', 'ui_kits', 'app', 'index.html'), '<!doctype html><html><body>app kit</body></html>');
|
||||
|
||||
const files = await listUserDesignSystemFiles(root, 'user:legacy');
|
||||
|
||||
expect(files?.map((file) => file.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ui_kits/app/components/App.jsx',
|
||||
'ui_kits/app/components/Sidebar.jsx',
|
||||
'ui_kits/app/components/AssistantsList.jsx',
|
||||
'ui_kits/app/components/ChatArea.jsx',
|
||||
'ui_kits/app/components/InputBar.jsx',
|
||||
'ui_kits/app/components/MessageBubble.jsx',
|
||||
]),
|
||||
);
|
||||
await expect(readUserDesignSystemFile(root, 'user:legacy', 'ui_kits/app/components/App.jsx'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.stringContaining('<Sidebar'),
|
||||
});
|
||||
await expect(readUserDesignSystemFile(root, 'user:legacy', 'ui_kits/app/components/App.jsx'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
content: expect.stringContaining('window.App = App'),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not backfill agent-managed review artifacts before the agent writes them', async () => {
|
||||
const created = await createUserDesignSystem(root, {
|
||||
title: 'Agent Managed',
|
||||
summary: 'The agent will create review artifacts in the workspace.',
|
||||
status: 'draft',
|
||||
artifactMode: 'agent-managed',
|
||||
});
|
||||
|
||||
const initialFiles = await listUserDesignSystemFiles(root, created.id);
|
||||
|
||||
expect(initialFiles?.map((file) => file.path)).toEqual(['DESIGN.md']);
|
||||
expect(initialFiles?.map((file) => file.path)).not.toEqual(expect.arrayContaining(['README.md', 'preview/colors-primary.html']));
|
||||
await expect(readUserDesignSystemFile(root, created.id, 'README.md'))
|
||||
.resolves
|
||||
.toBeNull();
|
||||
|
||||
const contextDir = path.join(root, created.id.slice('user:'.length), 'context');
|
||||
await mkdir(contextDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(contextDir, 'source-context.md'),
|
||||
'# Source Context\n\nConnector evidence remains available as project context.\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const generatedFiles = await listUserDesignSystemFiles(root, created.id);
|
||||
|
||||
expect(generatedFiles?.map((file) => file.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'DESIGN.md',
|
||||
'context/source-context.md',
|
||||
]),
|
||||
);
|
||||
expect(generatedFiles?.map((file) => file.path)).not.toEqual(expect.arrayContaining(['README.md']));
|
||||
});
|
||||
});
|
||||
326
apps/daemon/tests/project-design-system-routes.test.ts
Normal file
326
apps/daemon/tests/project-design-system-routes.test.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import type http from 'node:http';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
describe('project design system route gates', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
const projectsToClean: string[] = [];
|
||||
const designSystemsToClean: string[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||
url: string;
|
||||
server: http.Server;
|
||||
};
|
||||
baseUrl = started.url;
|
||||
server = started.server;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of projectsToClean.splice(0)) {
|
||||
await fetch(`${baseUrl}/api/projects/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {});
|
||||
}
|
||||
for (const id of designSystemsToClean.splice(0)) {
|
||||
await fetch(`${baseUrl}/api/design-systems/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {});
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function uniqueId(prefix: string): string {
|
||||
return `${prefix}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
async function createUserDesignSystem(status: 'draft' | 'published') {
|
||||
const resp = await fetch(`${baseUrl}/api/design-systems`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: `Route Gate ${uniqueId(status)}`,
|
||||
summary: 'Route-level design system usage guard.',
|
||||
status,
|
||||
}),
|
||||
});
|
||||
expect(resp.status).toBe(201);
|
||||
const body = (await resp.json()) as {
|
||||
designSystem: { id: string; status: string };
|
||||
};
|
||||
designSystemsToClean.push(body.designSystem.id);
|
||||
return body.designSystem;
|
||||
}
|
||||
|
||||
async function createProject(body: Record<string, unknown>) {
|
||||
return fetch(`${baseUrl}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function writeProjectText(projectId: string, name: string, content: string) {
|
||||
const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, content }),
|
||||
});
|
||||
expect(resp.status).toBe(200);
|
||||
}
|
||||
|
||||
async function readProjectText(projectId: string, name: string) {
|
||||
const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/files/${name}`);
|
||||
expect(resp.status).toBe(200);
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
it('rejects draft design systems when creating a project', async () => {
|
||||
const draft = await createUserDesignSystem('draft');
|
||||
const id = uniqueId('project-draft-ds');
|
||||
|
||||
const resp = await createProject({
|
||||
id,
|
||||
name: 'Draft Design System Project',
|
||||
designSystemId: draft.id,
|
||||
});
|
||||
|
||||
expect(resp.status).toBe(400);
|
||||
const body = (await resp.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/draft design systems cannot be used/i);
|
||||
});
|
||||
|
||||
it('allows published design systems when creating a project', async () => {
|
||||
const published = await createUserDesignSystem('published');
|
||||
const id = uniqueId('project-published-ds');
|
||||
|
||||
const resp = await createProject({
|
||||
id,
|
||||
name: 'Published Design System Project',
|
||||
designSystemId: published.id,
|
||||
});
|
||||
|
||||
expect(resp.status).toBe(200);
|
||||
projectsToClean.push(id);
|
||||
const body = (await resp.json()) as {
|
||||
project: { id: string; designSystemId: string | null };
|
||||
};
|
||||
expect(body.project.designSystemId).toBe(published.id);
|
||||
});
|
||||
|
||||
it('preserves a pending first agent task when a design-system workspace is re-opened', async () => {
|
||||
const draft = await createUserDesignSystem('draft');
|
||||
|
||||
const workspaceResp = await fetch(
|
||||
`${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
expect(workspaceResp.status).toBe(201);
|
||||
const workspaceBody = (await workspaceResp.json()) as {
|
||||
project: { id: string; pendingPrompt?: string | null };
|
||||
};
|
||||
const projectId = workspaceBody.project.id;
|
||||
projectsToClean.push(projectId);
|
||||
|
||||
const prompt =
|
||||
'Create this project as a complete Open Design design system workspace.';
|
||||
const patchResp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pendingPrompt: prompt }),
|
||||
});
|
||||
expect(patchResp.status).toBe(200);
|
||||
|
||||
const reopenedResp = await fetch(
|
||||
`${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
expect(reopenedResp.status).toBe(201);
|
||||
const reopenedBody = (await reopenedResp.json()) as {
|
||||
project: { id: string; pendingPrompt?: string | null };
|
||||
};
|
||||
|
||||
expect(reopenedBody.project.id).toBe(projectId);
|
||||
expect(reopenedBody.project.pendingPrompt).toBe(prompt);
|
||||
});
|
||||
|
||||
it('audits generated design-system package files from the project workspace', async () => {
|
||||
const projectId = uniqueId('project-ds-audit');
|
||||
const createResp = await createProject({
|
||||
id: projectId,
|
||||
name: 'Package Audit Project',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
});
|
||||
expect(createResp.status).toBe(200);
|
||||
projectsToClean.push(projectId);
|
||||
|
||||
await writeProjectText(projectId, 'DESIGN.md', '# Package Audit Project\n\nOnly the rules file exists so far.\n');
|
||||
|
||||
const auditResp = await fetch(
|
||||
`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/design-system-package-audit`,
|
||||
);
|
||||
expect(auditResp.status).toBe(200);
|
||||
const body = (await auditResp.json()) as {
|
||||
audit: {
|
||||
ok: boolean;
|
||||
filesInspected: number;
|
||||
errors: Array<{ code: string; path?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.audit.ok).toBe(false);
|
||||
expect(body.audit.filesInspected).toBeGreaterThan(0);
|
||||
expect(body.audit.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'missing_required_file', path: 'README.md' }),
|
||||
expect.objectContaining({ code: 'missing_required_file', path: 'SKILL.md' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes legacy design-system artifact names when re-opening a migrated workspace', async () => {
|
||||
const draft = await createUserDesignSystem('draft');
|
||||
|
||||
const workspaceResp = await fetch(
|
||||
`${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
expect(workspaceResp.status).toBe(201);
|
||||
const workspaceBody = (await workspaceResp.json()) as {
|
||||
project: { id: string };
|
||||
};
|
||||
const projectId = workspaceBody.project.id;
|
||||
projectsToClean.push(projectId);
|
||||
|
||||
await writeProjectText(
|
||||
projectId,
|
||||
'preview/typography-scale.html',
|
||||
'<!doctype html><html><body>old type</body></html>',
|
||||
);
|
||||
await writeProjectText(
|
||||
projectId,
|
||||
'preview/colors-ui-palette.html',
|
||||
'<!doctype html><html><body>old colors</body></html>',
|
||||
);
|
||||
await writeProjectText(
|
||||
projectId,
|
||||
'ui_kits/generated_interface/index.html',
|
||||
'<!doctype html><html><body>old app</body></html>',
|
||||
);
|
||||
|
||||
const reopenedResp = await fetch(
|
||||
`${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
expect(reopenedResp.status).toBe(201);
|
||||
const reopenedBody = (await reopenedResp.json()) as {
|
||||
files: Array<{ path: string }>;
|
||||
};
|
||||
const paths = reopenedBody.files.map((file) => file.path);
|
||||
|
||||
expect(paths).toEqual(expect.arrayContaining([
|
||||
'preview/typography-specimens.html',
|
||||
'preview/colors-primary.html',
|
||||
'ui_kits/app/index.html',
|
||||
]));
|
||||
expect(paths).not.toEqual(expect.arrayContaining([
|
||||
'preview/typography-scale.html',
|
||||
'preview/colors-ui-palette.html',
|
||||
'ui_kits/generated_interface/index.html',
|
||||
]));
|
||||
});
|
||||
|
||||
it('refreshes stale design-system workspace docs that still point at legacy package paths', async () => {
|
||||
const draft = await createUserDesignSystem('draft');
|
||||
|
||||
const workspaceResp = await fetch(
|
||||
`${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
expect(workspaceResp.status).toBe(201);
|
||||
const workspaceBody = (await workspaceResp.json()) as {
|
||||
project: { id: string };
|
||||
};
|
||||
const projectId = workspaceBody.project.id;
|
||||
projectsToClean.push(projectId);
|
||||
|
||||
await writeProjectText(
|
||||
projectId,
|
||||
'README.md',
|
||||
'# Stale README\n\nReview preview/typography-scale.html and ui_kits/generated_interface/index.html.\n',
|
||||
);
|
||||
await writeProjectText(
|
||||
projectId,
|
||||
'SKILL.md',
|
||||
'# Stale Skill\n\nUse preview/colors-ui-palette.html and ui_kits/generated_interface/.\n',
|
||||
);
|
||||
|
||||
const reopenedResp = await fetch(
|
||||
`${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
expect(reopenedResp.status).toBe(201);
|
||||
|
||||
const readme = await readProjectText(projectId, 'README.md');
|
||||
const skill = await readProjectText(projectId, 'SKILL.md');
|
||||
expect(readme).toContain('preview/');
|
||||
expect(readme).not.toContain('ui_kits/generated_interface');
|
||||
expect(readme).not.toContain('preview/typography-scale.html');
|
||||
expect(skill).not.toContain('ui_kits/generated_interface');
|
||||
expect(skill).not.toContain('preview/colors-ui-palette.html');
|
||||
});
|
||||
|
||||
it('rejects patching an existing project to a draft design system', async () => {
|
||||
const draft = await createUserDesignSystem('draft');
|
||||
const id = uniqueId('project-patch-draft-ds');
|
||||
const createResp = await createProject({ id, name: 'Patch Guard Project' });
|
||||
expect(createResp.status).toBe(200);
|
||||
projectsToClean.push(id);
|
||||
|
||||
const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ designSystemId: draft.id }),
|
||||
});
|
||||
|
||||
expect(resp.status).toBe(400);
|
||||
const body = (await resp.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/draft design systems cannot be used/i);
|
||||
});
|
||||
|
||||
it('rejects draft design systems when importing a folder as a project', async () => {
|
||||
const draft = await createUserDesignSystem('draft');
|
||||
const folder = mkdtempSync(path.join(tmpdir(), 'od-import-draft-ds-'));
|
||||
tempDirs.push(folder);
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const resp = await fetch(`${baseUrl}/api/import/folder`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
baseDir: folder,
|
||||
name: 'Imported Draft Design System Project',
|
||||
designSystemId: draft.id,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resp.status).toBe(400);
|
||||
const body = (await resp.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/draft design systems cannot be used/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -55,6 +55,7 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
|
|||
designSystemTitle: 'Brand',
|
||||
});
|
||||
|
||||
expect(out).toContain('Do not emit a direction question-form');
|
||||
expect(out).not.toContain('<question-form id="direction"');
|
||||
expect(out).not.toContain('Pick a visual direction');
|
||||
expect(out).toContain('if a design system is active and no new brand/reference source was provided, use it as the visual direction without asking again');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,10 @@ import { PetOverlay } from './components/pet/PetOverlay';
|
|||
import { migrateCustomPetAtlas } from './components/pet/pets';
|
||||
import { ProjectView } from './components/ProjectView';
|
||||
import { WorkspaceTabsBar } from './components/WorkspaceTabsBar';
|
||||
import {
|
||||
DesignSystemCreationFlow,
|
||||
DesignSystemDetailView,
|
||||
} from './components/DesignSystemFlow';
|
||||
import {
|
||||
SettingsDialog,
|
||||
switchApiProtocolConfig,
|
||||
|
|
@ -521,6 +525,11 @@ export function App() {
|
|||
setProjects(list);
|
||||
}, []);
|
||||
|
||||
const refreshDesignSystems = useCallback(async () => {
|
||||
const list = await fetchDesignSystems();
|
||||
setDesignSystems(list);
|
||||
}, []);
|
||||
|
||||
const refreshTemplates = useCallback(async () => {
|
||||
const list = await listTemplates();
|
||||
setTemplates(list);
|
||||
|
|
@ -1149,6 +1158,44 @@ export function App() {
|
|||
appMain = <MarketplaceView />;
|
||||
} else if (route.kind === 'marketplace-detail') {
|
||||
appMain = <PluginDetailView pluginId={route.pluginId} />;
|
||||
} else if (route.kind === 'design-system-create') {
|
||||
appMain = (
|
||||
<DesignSystemCreationFlow
|
||||
onBack={() => navigate({ kind: 'home', view: 'design-systems' })}
|
||||
onCreated={(projectId, project) => {
|
||||
if (project) {
|
||||
setProjects((curr) => [
|
||||
project,
|
||||
...curr.filter((p) => p.id !== project.id),
|
||||
]);
|
||||
}
|
||||
navigate({ kind: 'project', projectId, conversationId: null, fileName: null });
|
||||
}}
|
||||
onProjectPrepared={(project) => {
|
||||
setProjects((curr) => [
|
||||
project,
|
||||
...curr.filter((p) => p.id !== project.id),
|
||||
]);
|
||||
}}
|
||||
onSystemsRefresh={refreshDesignSystems}
|
||||
config={config}
|
||||
onOpenConnectorsTab={() => openSettings('composio')}
|
||||
/>
|
||||
);
|
||||
} else if (route.kind === 'design-system-detail') {
|
||||
appMain = (
|
||||
<DesignSystemDetailView
|
||||
id={route.designSystemId}
|
||||
selectedId={config.designSystemId}
|
||||
config={config}
|
||||
agents={agents}
|
||||
onBack={() => navigate({ kind: 'home', view: 'design-systems' })}
|
||||
onOpenProject={(projectId) => navigate({ kind: 'project', projectId, conversationId: null, fileName: null })}
|
||||
onSetDefault={handleChangeDefaultDesignSystem}
|
||||
onSystemsRefresh={refreshDesignSystems}
|
||||
onProjectsRefresh={refreshProjects}
|
||||
/>
|
||||
);
|
||||
} else if (activeProject) {
|
||||
appMain = (
|
||||
<ProjectView
|
||||
|
|
@ -1214,6 +1261,9 @@ export function App() {
|
|||
onDeleteProject={handleDeleteProject}
|
||||
onRenameProject={handleRenameProject}
|
||||
onChangeDefaultDesignSystem={handleChangeDefaultDesignSystem}
|
||||
onCreateDesignSystem={() => navigate({ kind: 'design-system-create' })}
|
||||
onOpenDesignSystem={(id: string) => navigate({ kind: 'design-system-detail', designSystemId: id })}
|
||||
onDesignSystemsRefresh={refreshDesignSystems}
|
||||
onPersistComposioKey={handleConfigPersistComposioKey}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import { copyToClipboard } from '../lib/copy-to-clipboard';
|
|||
import { projectRawUrl } from '../providers/registry';
|
||||
import type { TodoItem } from '../runtime/todos';
|
||||
import type { AppliedPluginSnapshot } from '@open-design/contracts';
|
||||
import {
|
||||
DESIGN_SYSTEM_WORKSPACE_DISPLAY_DESCRIPTION,
|
||||
DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE,
|
||||
isDesignSystemWorkspacePrompt,
|
||||
} from '../design-system-auto-prompt';
|
||||
import { latestTodoWriteInputFromMessages } from '../runtime/todos';
|
||||
import { TodoCard } from './ToolCard';
|
||||
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, ChatMessageFeedbackChange, Conversation, PreviewComment, ProjectFile, ProjectMetadata, SkillSummary } from '../types';
|
||||
|
|
@ -1183,6 +1188,8 @@ function UserMessage({
|
|||
}, 2000);
|
||||
}
|
||||
|
||||
const isDesignSystemWorkspaceRequest = isDesignSystemWorkspacePrompt(message.content);
|
||||
|
||||
return (
|
||||
<div className="msg user">
|
||||
<div className="role">
|
||||
|
|
@ -1234,7 +1241,19 @@ function UserMessage({
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{message.content ? (
|
||||
{message.content && isDesignSystemWorkspaceRequest ? (
|
||||
<div className="user-text-wrap user-status-wrap">
|
||||
<div className="user-status-card design-system-generation-status">
|
||||
<span className="user-status-card__icon">
|
||||
<Icon name="palette" size={15} />
|
||||
</span>
|
||||
<span className="user-status-card__copy">
|
||||
<strong>{DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE}</strong>
|
||||
<span>{DESIGN_SYSTEM_WORKSPACE_DISPLAY_DESCRIPTION}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : message.content ? (
|
||||
<div className="user-text-wrap">
|
||||
<div className="user-text user-bubble">{message.content}</div>
|
||||
<button
|
||||
|
|
|
|||
3146
apps/web/src/components/DesignSystemFlow.tsx
Normal file
3146
apps/web/src/components/DesignSystemFlow.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,8 +4,13 @@ import {
|
|||
localizeDesignSystemCategory,
|
||||
localizeDesignSystemSummary,
|
||||
} from '../i18n/content';
|
||||
import { fetchDesignSystemShowcase } from '../providers/registry';
|
||||
import {
|
||||
deleteDesignSystemDraft,
|
||||
fetchDesignSystemShowcase,
|
||||
updateDesignSystemDraft,
|
||||
} from '../providers/registry';
|
||||
import { buildSrcdoc } from '../runtime/srcdoc';
|
||||
import { Icon } from './Icon';
|
||||
import type { DesignSystemSummary, Surface } from '../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -13,6 +18,9 @@ interface Props {
|
|||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onPreview: (id: string) => void;
|
||||
onCreate?: () => void;
|
||||
onOpenSystem?: (id: string) => void;
|
||||
onSystemsRefresh?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
|
|
@ -29,6 +37,7 @@ const CATEGORY_ORDER = [
|
|||
];
|
||||
|
||||
type SurfaceFilter = 'all' | Surface;
|
||||
type UserListFilter = 'all' | 'published' | 'draft';
|
||||
|
||||
const SURFACE_PILLS: { value: SurfaceFilter; labelKey: 'examples.modeAll' | 'ds.surfaceWeb' | 'ds.surfaceImage' | 'ds.surfaceVideo' | 'ds.surfaceAudio' }[] = [
|
||||
{ value: 'all', labelKey: 'examples.modeAll' },
|
||||
|
|
@ -42,9 +51,35 @@ function surfaceOf(system: DesignSystemSummary): Surface {
|
|||
return system.surface ?? 'web';
|
||||
}
|
||||
|
||||
export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: Props) {
|
||||
function isUserSystem(system: DesignSystemSummary): boolean {
|
||||
return system.source === 'user' || system.isEditable === true;
|
||||
}
|
||||
|
||||
function formatShortDate(value: string | undefined): string {
|
||||
if (!value) return 'just now';
|
||||
const time = Date.parse(value);
|
||||
if (!Number.isFinite(time)) return value;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(time));
|
||||
}
|
||||
|
||||
export function DesignSystemsTab({
|
||||
systems,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onPreview,
|
||||
onCreate,
|
||||
onOpenSystem,
|
||||
onSystemsRefresh,
|
||||
}: Props) {
|
||||
const { locale, t } = useI18n();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [userFilter, setUserFilter] = useState<UserListFilter>('all');
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
|
||||
const [category, setCategory] = useState<string>('All');
|
||||
// Cache fetched showcase HTML across re-renders so cards never re-flicker
|
||||
|
|
@ -52,16 +87,29 @@ export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: P
|
|||
// "not yet requested". Mirrors the pattern used by ExamplesTab.
|
||||
const [thumbs, setThumbs] = useState<Record<string, string | null>>({});
|
||||
|
||||
const surfaceScoped = useMemo(
|
||||
() => surfaceFilter === 'all' ? systems : systems.filter((s) => surfaceOf(s) === surfaceFilter),
|
||||
[systems, surfaceFilter],
|
||||
const librarySystems = useMemo(
|
||||
() => systems.filter((system) => !isUserSystem(system)),
|
||||
[systems],
|
||||
);
|
||||
|
||||
const surfaceScoped = useMemo(
|
||||
() => surfaceFilter === 'all'
|
||||
? librarySystems
|
||||
: librarySystems.filter((s) => surfaceOf(s) === surfaceFilter),
|
||||
[librarySystems, surfaceFilter],
|
||||
);
|
||||
|
||||
const userSystems = useMemo(() => {
|
||||
const editable = systems.filter(isUserSystem);
|
||||
if (userFilter === 'all') return editable;
|
||||
return editable.filter((system) => (system.status ?? 'draft') === userFilter);
|
||||
}, [systems, userFilter]);
|
||||
|
||||
const surfaceCounts = useMemo(() => {
|
||||
const counts: Record<SurfaceFilter, number> = { all: systems.length, web: 0, image: 0, video: 0, audio: 0 };
|
||||
for (const s of systems) counts[surfaceOf(s)]++;
|
||||
const counts: Record<SurfaceFilter, number> = { all: librarySystems.length, web: 0, image: 0, video: 0, audio: 0 };
|
||||
for (const s of librarySystems) counts[surfaceOf(s)]++;
|
||||
return counts;
|
||||
}, [systems]);
|
||||
}, [librarySystems]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
|
|
@ -121,68 +169,237 @@ export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: P
|
|||
});
|
||||
}
|
||||
|
||||
async function refreshSystems() {
|
||||
await onSystemsRefresh?.();
|
||||
}
|
||||
|
||||
async function togglePublished(system: DesignSystemSummary) {
|
||||
setBusyId(system.id);
|
||||
try {
|
||||
await updateDesignSystemDraft(system.id, {
|
||||
status: system.status === 'published' ? 'draft' : 'published',
|
||||
});
|
||||
await refreshSystems();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSystem(system: DesignSystemSummary) {
|
||||
const ok = window.confirm(`Delete "${system.title}"? This removes the draft design system from this device.`);
|
||||
if (!ok) return;
|
||||
setBusyId(system.id);
|
||||
try {
|
||||
const deleted = await deleteDesignSystemDraft(system.id);
|
||||
if (!deleted) return;
|
||||
if (selectedId === system.id) {
|
||||
const fallback = systems.find((candidate) =>
|
||||
candidate.id !== system.id && isUserSystem(candidate),
|
||||
);
|
||||
if (fallback) onSelect(fallback.id);
|
||||
}
|
||||
await refreshSystems();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tab-panel" data-testid="design-systems-tab">
|
||||
<div className="tab-panel-toolbar">
|
||||
<input
|
||||
data-testid="design-systems-search"
|
||||
placeholder={t('ds.searchPlaceholder')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
data-testid="design-systems-category-select"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{renderCategory(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className="examples-filter-row"
|
||||
role="tablist"
|
||||
aria-label={t('ds.surfaceLabel')}
|
||||
>
|
||||
<span className="examples-filter-label">{t('ds.surfaceLabel')}</span>
|
||||
{SURFACE_PILLS.filter((p) => p.value === 'all' || surfaceCounts[p.value] > 0).map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={surfaceFilter === p.value}
|
||||
data-testid={`design-systems-surface-${p.value}`}
|
||||
className={`filter-pill ${surfaceFilter === p.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSurfaceFilter(p.value);
|
||||
setCategory('All');
|
||||
}}
|
||||
<div className="tab-panel design-systems-manager" data-testid="design-systems-tab">
|
||||
<section className="ds-settings-card" aria-label="Design Systems">
|
||||
<div className="ds-settings-card__head">
|
||||
<div>
|
||||
<span className="ds-manager-eyebrow">Design Systems</span>
|
||||
<h2>Your systems</h2>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Filter design systems"
|
||||
value={userFilter}
|
||||
onChange={(event) => setUserFilter(event.target.value as UserListFilter)}
|
||||
>
|
||||
{t(p.labelKey)}
|
||||
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
|
||||
<option value="all">All</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{onCreate ? (
|
||||
<button type="button" className="ds-create-row" onClick={onCreate}>
|
||||
<span>
|
||||
<strong>Create new design system</strong>
|
||||
<small>Teach Open Design your brand, product, code, assets, and design references.</small>
|
||||
</span>
|
||||
<span className="ds-create-row__action">Create</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="tab-empty" data-testid="design-systems-empty">{t('ds.emptyNoMatch')}</div>
|
||||
) : (
|
||||
<div className="ds-grid" data-testid="design-systems-grid">
|
||||
{filtered.map((s) => (
|
||||
<DesignSystemCard
|
||||
key={s.id}
|
||||
system={s}
|
||||
active={s.id === selectedId}
|
||||
thumbHtml={thumbs[s.id]}
|
||||
onIntersect={() => loadThumb(s.id)}
|
||||
onSelect={() => onSelect(s.id)}
|
||||
onPreview={() => onPreview(s.id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{userSystems.length === 0 ? (
|
||||
<div className="ds-user-empty">
|
||||
No design systems yet. Create one from real product context, review the draft, then publish it for future projects.
|
||||
</div>
|
||||
) : (
|
||||
<div className="ds-user-list">
|
||||
{userSystems.map((system) => {
|
||||
const status = system.status ?? 'draft';
|
||||
const canUseInProjects = status === 'published';
|
||||
const selected = canUseInProjects && system.id === selectedId;
|
||||
const busy = busyId === system.id;
|
||||
return (
|
||||
<div className="ds-user-row" key={system.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="ds-user-row__open"
|
||||
onClick={() => onOpenSystem?.(system.id)}
|
||||
>
|
||||
<span className="ds-user-row__title">
|
||||
<span>{system.title}</span>
|
||||
{selected ? <span className="ds-card-badge">Default</span> : null}
|
||||
</span>
|
||||
<span className="ds-user-row__meta">
|
||||
You · updated {formatShortDate(system.updatedAt)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="ds-user-row__actions">
|
||||
{onOpenSystem ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost compact"
|
||||
onClick={() => onOpenSystem(system.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
) : null}
|
||||
{!selected && canUseInProjects ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost compact"
|
||||
onClick={() => onSelect(system.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Make default
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`ds-status-toggle ${status === 'published' ? 'is-on' : ''}`}
|
||||
aria-pressed={status === 'published'}
|
||||
onClick={() => void togglePublished(system)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span>{status === 'published' ? 'Published' : 'Draft'}</span>
|
||||
<i aria-hidden />
|
||||
</button>
|
||||
{onOpenSystem ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
aria-label={`Open ${system.title}`}
|
||||
onClick={() => onOpenSystem(system.id)}
|
||||
>
|
||||
<Icon name="external-link" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn danger"
|
||||
aria-label={`Delete ${system.title}`}
|
||||
onClick={() => void deleteSystem(system)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ds-settings-card ds-templates-card" aria-label="Templates">
|
||||
<div className="ds-settings-card__head">
|
||||
<div>
|
||||
<span className="ds-manager-eyebrow">Templates</span>
|
||||
<h2>Templates</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ds-user-empty">
|
||||
No templates yet. Create one from any generated project via Share once template publishing is enabled.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="ds-private-note">Only you can view these settings.</p>
|
||||
|
||||
<section className="ds-settings-card" aria-label="Built-in design systems">
|
||||
<div className="ds-settings-card__head">
|
||||
<div>
|
||||
<span className="ds-manager-eyebrow">Library</span>
|
||||
<h2>Built-in library</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tab-panel-toolbar ds-manager-toolbar">
|
||||
<input
|
||||
data-testid="design-systems-search"
|
||||
placeholder={t('ds.searchPlaceholder')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
data-testid="design-systems-category-select"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{renderCategory(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className="examples-filter-row"
|
||||
role="tablist"
|
||||
aria-label={t('ds.surfaceLabel')}
|
||||
>
|
||||
<span className="examples-filter-label">{t('ds.surfaceLabel')}</span>
|
||||
{SURFACE_PILLS.filter((p) => p.value === 'all' || surfaceCounts[p.value] > 0).map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={surfaceFilter === p.value}
|
||||
data-testid={`design-systems-surface-${p.value}`}
|
||||
className={`filter-pill ${surfaceFilter === p.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSurfaceFilter(p.value);
|
||||
setCategory('All');
|
||||
}}
|
||||
>
|
||||
{t(p.labelKey)}
|
||||
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="tab-empty" data-testid="design-systems-empty">{t('ds.emptyNoMatch')}</div>
|
||||
) : (
|
||||
<div className="ds-grid" data-testid="design-systems-grid">
|
||||
{filtered.map((s) => (
|
||||
<DesignSystemCard
|
||||
key={s.id}
|
||||
system={s}
|
||||
active={s.id === selectedId}
|
||||
thumbHtml={thumbs[s.id]}
|
||||
onIntersect={() => loadThumb(s.id)}
|
||||
onSelect={() => onSelect(s.id)}
|
||||
onOpenSystem={onOpenSystem ? () => onOpenSystem(s.id) : undefined}
|
||||
onPreview={() => onPreview(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -193,6 +410,7 @@ interface CardProps {
|
|||
thumbHtml: string | null | undefined;
|
||||
onIntersect: () => void;
|
||||
onSelect: () => void;
|
||||
onOpenSystem?: () => void;
|
||||
onPreview: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +420,7 @@ function DesignSystemCard({
|
|||
thumbHtml,
|
||||
onIntersect,
|
||||
onSelect,
|
||||
onOpenSystem,
|
||||
onPreview,
|
||||
}: CardProps) {
|
||||
const { locale, t } = useI18n();
|
||||
|
|
@ -318,6 +537,19 @@ function DesignSystemCard({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{onOpenSystem ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenSystem();
|
||||
}}
|
||||
>
|
||||
<Icon name={system.isEditable ? 'edit' : 'external-link'} />
|
||||
{system.isEditable ? 'Edit' : 'Open'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -227,6 +227,9 @@ interface Props {
|
|||
onDeleteProject: (id: string) => void;
|
||||
onRenameProject: (id: string, name: string) => void;
|
||||
onChangeDefaultDesignSystem: (id: string) => void;
|
||||
onCreateDesignSystem?: () => void;
|
||||
onOpenDesignSystem?: (id: string) => void;
|
||||
onDesignSystemsRefresh?: () => Promise<void> | void;
|
||||
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
|
||||
onOpenSettings: (
|
||||
section?:
|
||||
|
|
@ -278,6 +281,9 @@ export function EntryShell({
|
|||
onDeleteProject,
|
||||
onRenameProject,
|
||||
onChangeDefaultDesignSystem,
|
||||
onCreateDesignSystem,
|
||||
onOpenDesignSystem,
|
||||
onDesignSystemsRefresh,
|
||||
onPersistComposioKey,
|
||||
onOpenSettings,
|
||||
}: Props) {
|
||||
|
|
@ -813,6 +819,9 @@ export function EntryShell({
|
|||
systems={designSystems}
|
||||
selectedId={defaultDesignSystemId}
|
||||
onSelect={onChangeDefaultDesignSystem}
|
||||
onCreate={onCreateDesignSystem}
|
||||
onOpenSystem={onOpenDesignSystem}
|
||||
onSystemsRefresh={onDesignSystemsRefresh}
|
||||
onPreview={(id) => setPreviewSystemId(id)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ interface Props {
|
|||
onDeleteProject: (id: string) => void;
|
||||
onRenameProject: (id: string, name: string) => void;
|
||||
onChangeDefaultDesignSystem: (id: string) => void;
|
||||
onCreateDesignSystem?: () => void;
|
||||
onOpenDesignSystem?: (id: string) => void;
|
||||
onDesignSystemsRefresh?: () => Promise<void> | void;
|
||||
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
|
||||
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'library' | 'about') => void;
|
||||
}
|
||||
|
|
@ -257,6 +260,9 @@ export function EntryView({
|
|||
onDeleteProject,
|
||||
onRenameProject,
|
||||
onChangeDefaultDesignSystem,
|
||||
onCreateDesignSystem,
|
||||
onOpenDesignSystem,
|
||||
onDesignSystemsRefresh,
|
||||
onPersistComposioKey,
|
||||
onOpenSettings,
|
||||
}: Props) {
|
||||
|
|
@ -343,6 +349,9 @@ export function EntryView({
|
|||
onDeleteProject={onDeleteProject}
|
||||
onRenameProject={onRenameProject}
|
||||
onChangeDefaultDesignSystem={onChangeDefaultDesignSystem}
|
||||
onCreateDesignSystem={onCreateDesignSystem}
|
||||
onOpenDesignSystem={onOpenDesignSystem}
|
||||
onDesignSystemsRefresh={onDesignSystemsRefresh}
|
||||
onPersistComposioKey={onPersistComposioKey}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -26,6 +26,7 @@ import {
|
|||
fetchPreviewComments,
|
||||
fetchDesignSystem,
|
||||
fetchDesignTemplate,
|
||||
fetchProjectDesignSystemPackageAudit,
|
||||
fetchLiveArtifacts,
|
||||
fetchProjectFiles,
|
||||
fetchSkill,
|
||||
|
|
@ -53,7 +54,15 @@ import { randomUUID } from '../utils/uuid';
|
|||
import { DEFAULT_NOTIFICATIONS } from '../state/config';
|
||||
import type { TodoItem } from '../runtime/todos';
|
||||
import { appendErrorStatusEvent } from '../runtime/chat-events';
|
||||
import {
|
||||
buildDesignSystemPackageAuditRepairPrompt,
|
||||
summarizeDesignSystemPackageAudit,
|
||||
} from '../runtime/design-system-package-audit';
|
||||
import { isLiveArtifactTabId, liveArtifactTabId } from '../types';
|
||||
import {
|
||||
DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE,
|
||||
isDesignSystemWorkspacePrompt,
|
||||
} from '../design-system-auto-prompt';
|
||||
import {
|
||||
createConversation,
|
||||
deleteConversation as deleteConversationApi,
|
||||
|
|
@ -82,6 +91,7 @@ import type {
|
|||
DesignSystemSummary,
|
||||
OpenTabsState,
|
||||
Project,
|
||||
ProjectMetadata,
|
||||
PreviewComment,
|
||||
PreviewCommentTarget,
|
||||
ProjectFile,
|
||||
|
|
@ -181,8 +191,16 @@ const MAX_CHAT_PANEL_WIDTH = 720;
|
|||
const MIN_WORKSPACE_PANEL_WIDTH = 400;
|
||||
const SPLIT_RESIZE_HANDLE_WIDTH = 8;
|
||||
const CHAT_PANEL_KEYBOARD_STEP = 16;
|
||||
const DESIGN_SYSTEM_AUDIT_AUTO_REPAIR_ATTEMPTS = 2;
|
||||
const MIN_NORMAL_SPLIT_WIDTH =
|
||||
MIN_CHAT_PANEL_WIDTH + SPLIT_RESIZE_HANDLE_WIDTH + MIN_WORKSPACE_PANEL_WIDTH;
|
||||
type DesignSystemReviewEntry = NonNullable<ProjectMetadata['designSystemReview']>[string];
|
||||
type DesignSystemReviewAgentTask = NonNullable<DesignSystemReviewEntry['agentTask']>;
|
||||
interface DesignSystemReviewDetails {
|
||||
feedback?: string;
|
||||
files?: string[];
|
||||
agentTask?: DesignSystemReviewAgentTask;
|
||||
}
|
||||
|
||||
function workspacePanelMinWidthForSplit(splitWidth: number): number {
|
||||
if (!Number.isFinite(splitWidth) || splitWidth <= 0) return MIN_WORKSPACE_PANEL_WIDTH;
|
||||
|
|
@ -206,6 +224,41 @@ function clampChatPanelWidth(width: number, maxWidth = MAX_CHAT_PANEL_WIDTH): nu
|
|||
return Math.min(effectiveMax, Math.max(effectiveMin, Math.round(width)));
|
||||
}
|
||||
|
||||
function designSystemFeedbackAttachments(
|
||||
projectFiles: ProjectFile[],
|
||||
sectionFiles: string[],
|
||||
): ChatAttachment[] {
|
||||
const fileLookup = new Map(projectFiles.map((file) => [file.name, file]));
|
||||
return sectionFiles
|
||||
.map((name) => fileLookup.get(name))
|
||||
.filter((file): file is ProjectFile => Boolean(file))
|
||||
.slice(0, 8)
|
||||
.map((file) => ({
|
||||
path: file.name,
|
||||
name: file.name,
|
||||
kind: file.kind === 'image' ? 'image' : 'file',
|
||||
size: file.size,
|
||||
}));
|
||||
}
|
||||
|
||||
function designSystemNeedsWorkPrompt(
|
||||
sectionTitle: string,
|
||||
feedback: string,
|
||||
sectionFiles: string[],
|
||||
): string {
|
||||
const fileList =
|
||||
sectionFiles.length > 0
|
||||
? sectionFiles.map((name) => `- @${name}`).join('\n')
|
||||
: '- No generated files are registered for this section yet.';
|
||||
return (
|
||||
`Needs work on the design system section "${sectionTitle}".\n\n` +
|
||||
`User feedback:\n${feedback}\n\n` +
|
||||
`Relevant section files:\n${fileList}\n\n` +
|
||||
'Revise the design-system project files directly. Keep DESIGN.md, tokens, previews, UI kit examples, and assets consistent with the feedback. ' +
|
||||
'After editing, summarize what changed and which files should be reviewed again.'
|
||||
);
|
||||
}
|
||||
|
||||
function readSavedChatPanelWidth(): number {
|
||||
if (typeof window === 'undefined') return DEFAULT_CHAT_PANEL_WIDTH;
|
||||
try {
|
||||
|
|
@ -239,6 +292,10 @@ function autoSendAttachmentsKey(projectId: string): string {
|
|||
return `od:auto-send-attachments:${projectId}`;
|
||||
}
|
||||
|
||||
function designSystemAuditAutoRepairKey(projectId: string): string {
|
||||
return `od:design-system-audit-auto-repair:${projectId}`;
|
||||
}
|
||||
|
||||
function readAutoSendAttachments(projectId: string): ChatAttachment[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
|
|
@ -262,6 +319,53 @@ function clearAutoSendSession(projectId: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function markDesignSystemAuditAutoRepairEligible(projectId: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
designSystemAuditAutoRepairKey(projectId),
|
||||
String(DESIGN_SYSTEM_AUDIT_AUTO_REPAIR_ATTEMPTS),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function consumeDesignSystemAuditAutoRepair(projectId: string): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const key = designSystemAuditAutoRepairKey(projectId);
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
const attemptsRemaining = raw ? Number.parseInt(raw, 10) : 0;
|
||||
if (!Number.isFinite(attemptsRemaining) || attemptsRemaining <= 0) {
|
||||
window.sessionStorage.removeItem(key);
|
||||
return false;
|
||||
}
|
||||
const nextAttemptsRemaining = attemptsRemaining - 1;
|
||||
if (nextAttemptsRemaining > 0) {
|
||||
window.sessionStorage.setItem(key, String(nextAttemptsRemaining));
|
||||
} else {
|
||||
window.sessionStorage.removeItem(key);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDesignSystemAuditAutoRepair(projectId: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(designSystemAuditAutoRepairKey(projectId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function isDesignSystemWorkspaceMetadata(metadata: ProjectMetadata | undefined): boolean {
|
||||
return metadata?.importedFrom === 'design-system';
|
||||
}
|
||||
|
||||
function isStoredChatAttachment(value: unknown): value is ChatAttachment {
|
||||
if (value === null || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
|
|
@ -412,6 +516,9 @@ export function ProjectView({
|
|||
details: string | null;
|
||||
code?: string | null;
|
||||
} | null>(null);
|
||||
const [chatSeed, setChatSeed] = useState<{ id: string; value: string } | null>(null);
|
||||
const [autoAuditRepairSeed, setAutoAuditRepairSeed] =
|
||||
useState<{ id: string; value: string } | null>(null);
|
||||
const [chatPanelWidth, setChatPanelWidth] = useState(readSavedChatPanelWidth);
|
||||
const [chatPanelMaxWidth, setChatPanelMaxWidth] = useState(MAX_CHAT_PANEL_WIDTH);
|
||||
const [workspacePanelMinWidth, setWorkspacePanelMinWidth] = useState(MIN_WORKSPACE_PANEL_WIDTH);
|
||||
|
|
@ -480,6 +587,10 @@ export function ProjectView({
|
|||
useEffect(() => {
|
||||
projectIdRef.current = project.id;
|
||||
}, [project.id]);
|
||||
useEffect(() => {
|
||||
setChatSeed(null);
|
||||
setAutoAuditRepairSeed(null);
|
||||
}, [project.id]);
|
||||
// Monotonic token bumped on every `conversation-created` refresh dispatch.
|
||||
// Two rapid events (e.g. concurrent routine runs against the same reused
|
||||
// project, #1502) can start overlapping `listConversations` calls; if the
|
||||
|
|
@ -1228,6 +1339,51 @@ export function ProjectView({
|
|||
[updateMessageById],
|
||||
);
|
||||
|
||||
const auditDesignSystemWorkspaceAfterRun = useCallback(
|
||||
async (assistantMessageId: string) => {
|
||||
if (!isDesignSystemWorkspaceMetadata(project.metadata)) return;
|
||||
try {
|
||||
const audit = await fetchProjectDesignSystemPackageAudit(project.id);
|
||||
if (!audit) return;
|
||||
const auditSummary = summarizeDesignSystemPackageAudit(audit);
|
||||
updateMessageById(
|
||||
assistantMessageId,
|
||||
(prev) => ({
|
||||
...prev,
|
||||
events: [...(prev.events ?? []), { kind: 'status', label: 'audit', detail: auditSummary }],
|
||||
}),
|
||||
true,
|
||||
{ telemetryFinalized: true },
|
||||
);
|
||||
const repairPrompt = buildDesignSystemPackageAuditRepairPrompt(audit);
|
||||
if (repairPrompt) {
|
||||
const seed = { id: `audit-${Date.now()}`, value: repairPrompt };
|
||||
setChatSeed(seed);
|
||||
if (consumeDesignSystemAuditAutoRepair(project.id)) {
|
||||
setAutoAuditRepairSeed(seed);
|
||||
}
|
||||
} else {
|
||||
clearDesignSystemAuditAutoRepair(project.id);
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
updateMessageById(
|
||||
assistantMessageId,
|
||||
(prev) => ({
|
||||
...prev,
|
||||
events: [
|
||||
...(prev.events ?? []),
|
||||
{ kind: 'status', label: 'audit', detail: `Package audit could not run: ${detail}` },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
{ telemetryFinalized: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
[project.id, project.metadata, updateMessageById],
|
||||
);
|
||||
|
||||
const refreshPreviewComments = useCallback(async () => {
|
||||
if (!activeConversationId) return;
|
||||
const next = await fetchPreviewComments(project.id, activeConversationId);
|
||||
|
|
@ -1432,7 +1588,7 @@ export function ProjectView({
|
|||
clearActiveRunRefs(reattachConversationId, controller, cancelController);
|
||||
clearStreamingMarker(reattachConversationId);
|
||||
persistNow({ telemetryFinalized: true });
|
||||
void refreshProjectFiles();
|
||||
void refreshProjectFiles().then(() => auditDesignSystemWorkspaceAfterRun(message.id));
|
||||
onProjectsRefresh();
|
||||
},
|
||||
onError: (err) => {
|
||||
|
|
@ -1519,6 +1675,7 @@ export function ProjectView({
|
|||
project.id,
|
||||
updateMessageById,
|
||||
persistMessageById,
|
||||
auditDesignSystemWorkspaceAfterRun,
|
||||
markStreamingConversation,
|
||||
clearStreamingMarker,
|
||||
clearActiveRunRefs,
|
||||
|
|
@ -1537,6 +1694,7 @@ export function ProjectView({
|
|||
if (messagesConversationIdRef.current !== activeConversationId) return;
|
||||
if (currentConversationBusy) return;
|
||||
if (!prompt.trim() && attachments.length === 0 && commentAttachments.length === 0) return;
|
||||
setChatSeed(null);
|
||||
const runConversationId = activeConversationId;
|
||||
setError(null);
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -1629,7 +1787,9 @@ export function ProjectView({
|
|||
// so the conversation is identifiable in the dropdown without a
|
||||
// round-trip through the agent.
|
||||
if (messages.length === 0) {
|
||||
const title = prompt.slice(0, 60).trim();
|
||||
const title = isDesignSystemWorkspacePrompt(prompt)
|
||||
? DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE
|
||||
: prompt.slice(0, 60).trim();
|
||||
if (title) {
|
||||
setConversations((curr) =>
|
||||
curr.map((c) =>
|
||||
|
|
@ -1834,7 +1994,7 @@ export function ProjectView({
|
|||
// refresh signal) so we can diff against the pre-turn snapshot
|
||||
// and attach the new files to the assistant message as download
|
||||
// chips.
|
||||
void refreshProjectFiles().then((nextFiles) => {
|
||||
void refreshProjectFiles().then(async (nextFiles) => {
|
||||
const produced = nextFiles.filter((f) => !beforeFileNames.has(f.name));
|
||||
setMessages((curr) => {
|
||||
const updated = curr.map((m) =>
|
||||
|
|
@ -1846,6 +2006,7 @@ export function ProjectView({
|
|||
if (finalized) persistMessage(finalized, { telemetryFinalized: true });
|
||||
return updated;
|
||||
});
|
||||
await auditDesignSystemWorkspaceAfterRun(assistantId);
|
||||
});
|
||||
onProjectsRefresh();
|
||||
},
|
||||
|
|
@ -2033,6 +2194,7 @@ export function ProjectView({
|
|||
requestOpenFile,
|
||||
persistMessage,
|
||||
persistMessageById,
|
||||
auditDesignSystemWorkspaceAfterRun,
|
||||
patchAttachedStatuses,
|
||||
updateMessageById,
|
||||
markStreamingConversation,
|
||||
|
|
@ -2042,6 +2204,23 @@ export function ProjectView({
|
|||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoAuditRepairSeed) return;
|
||||
if (!activeConversationId) return;
|
||||
if (!messagesInitialized) return;
|
||||
if (currentConversationBusy) return;
|
||||
const repairText = autoAuditRepairSeed.value.trim();
|
||||
setAutoAuditRepairSeed(null);
|
||||
if (!repairText) return;
|
||||
void handleSend(repairText, [], []);
|
||||
}, [
|
||||
activeConversationId,
|
||||
autoAuditRepairSeed,
|
||||
currentConversationBusy,
|
||||
handleSend,
|
||||
messagesInitialized,
|
||||
]);
|
||||
|
||||
const handleSendBoardCommentAttachments = useCallback(
|
||||
async (commentAttachments: ChatCommentAttachment[]) => {
|
||||
if (currentConversationActionDisabled || commentAttachments.length === 0) return;
|
||||
|
|
@ -2187,6 +2366,113 @@ export function ProjectView({
|
|||
[currentConversationActionDisabled, handleSend],
|
||||
);
|
||||
|
||||
const sentDesignSystemReviewTaskKeysRef = useRef<Set<string>>(new Set());
|
||||
const persistDesignSystemReviewEntry = useCallback((
|
||||
sectionTitle: string,
|
||||
entry: DesignSystemReviewEntry,
|
||||
) => {
|
||||
const baseMetadata: ProjectMetadata = {
|
||||
kind: project.metadata?.kind ?? 'other',
|
||||
...project.metadata,
|
||||
};
|
||||
const metadata: ProjectMetadata = {
|
||||
...baseMetadata,
|
||||
designSystemReview: {
|
||||
...(baseMetadata.designSystemReview ?? {}),
|
||||
[sectionTitle]: entry,
|
||||
},
|
||||
};
|
||||
onProjectChange({ ...project, metadata });
|
||||
void patchProject(project.id, { metadata });
|
||||
}, [onProjectChange, project]);
|
||||
const sendDesignSystemFeedback = useCallback((
|
||||
sectionTitle: string,
|
||||
feedback: string,
|
||||
sectionFiles: string[],
|
||||
): DesignSystemReviewAgentTask | void => {
|
||||
const cleanFeedback = feedback.trim();
|
||||
if (!cleanFeedback) return;
|
||||
const prompt = designSystemNeedsWorkPrompt(sectionTitle, cleanFeedback, sectionFiles);
|
||||
const queuedAt = new Date().toISOString();
|
||||
if (!activeConversationId || !messagesInitialized || currentConversationActionDisabled) {
|
||||
return {
|
||||
status: 'queued',
|
||||
prompt,
|
||||
queuedAt,
|
||||
};
|
||||
}
|
||||
const task: DesignSystemReviewAgentTask = {
|
||||
status: 'sent',
|
||||
prompt,
|
||||
queuedAt,
|
||||
sentAt: queuedAt,
|
||||
};
|
||||
sentDesignSystemReviewTaskKeysRef.current.add(`${sectionTitle}:${queuedAt}`);
|
||||
void handleSend(prompt, designSystemFeedbackAttachments(projectFiles, sectionFiles), []);
|
||||
return task;
|
||||
}, [
|
||||
activeConversationId,
|
||||
currentConversationActionDisabled,
|
||||
handleSend,
|
||||
messagesInitialized,
|
||||
projectFiles,
|
||||
]);
|
||||
const persistDesignSystemReviewDecision = useCallback((
|
||||
sectionTitle: string,
|
||||
decision: DesignSystemReviewEntry['decision'],
|
||||
details?: DesignSystemReviewDetails,
|
||||
) => {
|
||||
const entry: DesignSystemReviewEntry = {
|
||||
decision,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (details?.feedback) entry.feedback = details.feedback;
|
||||
if (details?.files) entry.files = details.files;
|
||||
if (details?.agentTask) entry.agentTask = details.agentTask;
|
||||
persistDesignSystemReviewEntry(sectionTitle, entry);
|
||||
}, [persistDesignSystemReviewEntry]);
|
||||
useEffect(() => {
|
||||
if (!activeConversationId || !messagesInitialized || currentConversationActionDisabled) return;
|
||||
const queued = Object.entries(project.metadata?.designSystemReview ?? {}).find(
|
||||
([, entry]) =>
|
||||
entry.decision === 'needs-work'
|
||||
&& Boolean(entry.feedback?.trim())
|
||||
&& entry.agentTask?.status === 'queued',
|
||||
);
|
||||
if (!queued) return;
|
||||
const [sectionTitle, entry] = queued;
|
||||
const task = entry.agentTask;
|
||||
if (!task) return;
|
||||
const taskKey = `${sectionTitle}:${task.queuedAt}`;
|
||||
if (sentDesignSystemReviewTaskKeysRef.current.has(taskKey)) return;
|
||||
sentDesignSystemReviewTaskKeysRef.current.add(taskKey);
|
||||
const sectionFiles = entry.files ?? [];
|
||||
const prompt = task.prompt || designSystemNeedsWorkPrompt(
|
||||
sectionTitle,
|
||||
entry.feedback ?? '',
|
||||
sectionFiles,
|
||||
);
|
||||
const sentAt = new Date().toISOString();
|
||||
persistDesignSystemReviewEntry(sectionTitle, {
|
||||
...entry,
|
||||
agentTask: {
|
||||
...task,
|
||||
status: 'sent',
|
||||
prompt,
|
||||
sentAt,
|
||||
},
|
||||
});
|
||||
void handleSend(prompt, designSystemFeedbackAttachments(projectFiles, sectionFiles), []);
|
||||
}, [
|
||||
activeConversationId,
|
||||
currentConversationActionDisabled,
|
||||
handleSend,
|
||||
messagesInitialized,
|
||||
persistDesignSystemReviewEntry,
|
||||
project.metadata?.designSystemReview,
|
||||
projectFiles,
|
||||
]);
|
||||
|
||||
const handleExportAsPptx = useCallback(
|
||||
(fileName: string) => {
|
||||
if (currentConversationActionDisabled) return;
|
||||
|
|
@ -2404,6 +2690,16 @@ export function ProjectView({
|
|||
return [skill, ds].filter(Boolean).join(' · ') || t('project.metaFreeform');
|
||||
}, [skills, designTemplates, designSystems, project.skillId, project.designSystemId, t]);
|
||||
|
||||
const designSystemProject = useMemo(() => {
|
||||
if (project.metadata?.importedFrom !== 'design-system') return null;
|
||||
if (!project.designSystemId) return null;
|
||||
return designSystems.find((d) => d.id === project.designSystemId) ?? null;
|
||||
}, [designSystems, project.designSystemId, project.metadata?.importedFrom]);
|
||||
const designSystemActivityEvents = useMemo(
|
||||
() => designSystemProject ? latestDesignSystemActivityEvents(messages) : [],
|
||||
[designSystemProject, messages],
|
||||
);
|
||||
|
||||
const isDeck = useMemo(
|
||||
() =>
|
||||
(skills.find((s) => s.id === project.skillId) ??
|
||||
|
|
@ -2630,7 +2926,7 @@ export function ProjectView({
|
|||
onClearPendingPrompt();
|
||||
}, [project.id, project.pendingPrompt, onClearPendingPrompt]);
|
||||
const chatInitialDraft =
|
||||
initialDraft?.projectId === project.id ? initialDraft.value : undefined;
|
||||
chatSeed?.value ?? (initialDraft?.projectId === project.id ? initialDraft.value : undefined);
|
||||
|
||||
// Continue in CLI / Finalize design package handlers + keyboard
|
||||
// shortcut wiring. Close to the JSX so the data flow is easy to
|
||||
|
|
@ -2810,6 +3106,9 @@ export function ProjectView({
|
|||
return;
|
||||
}
|
||||
autoSentRef.current = true;
|
||||
if (isDesignSystemWorkspaceMetadata(project.metadata)) {
|
||||
markDesignSystemAuditAutoRepairEligible(project.id);
|
||||
}
|
||||
clearAutoSendSession(project.id);
|
||||
autoSendAttachmentsRef.current = [];
|
||||
void handleSend(seed, attachments, []);
|
||||
|
|
@ -2819,6 +3118,7 @@ export function ProjectView({
|
|||
streaming,
|
||||
messages.length,
|
||||
project.id,
|
||||
project.metadata,
|
||||
initialDraft,
|
||||
project.pendingPrompt,
|
||||
handleSend,
|
||||
|
|
@ -2994,7 +3294,7 @@ export function ProjectView({
|
|||
<ChatPane
|
||||
// The conversation id is part of the key so switching conversations
|
||||
// resets internal scroll/draft state inside ChatPane and ChatComposer.
|
||||
key={`${project.id}:${activeConversationId ?? 'conversation-unavailable'}`}
|
||||
key={`${project.id}:${activeConversationId ?? 'conversation-unavailable'}:${chatSeed?.id ?? 'ready'}`}
|
||||
messages={messages}
|
||||
streaming={currentConversationStreaming}
|
||||
sendDisabled={currentConversationSendDisabled}
|
||||
|
|
@ -3082,6 +3382,7 @@ export function ProjectView({
|
|||
streaming={currentConversationActionDisabled}
|
||||
openRequest={openRequest}
|
||||
liveArtifactEvents={liveArtifactEvents}
|
||||
designSystemActivityEvents={designSystemActivityEvents}
|
||||
tabsState={openTabsState}
|
||||
onTabsStateChange={persistTabsState}
|
||||
previewComments={previewComments}
|
||||
|
|
@ -3091,6 +3392,10 @@ export function ProjectView({
|
|||
onPluginFolderAgentAction={handlePluginFolderAgentAction}
|
||||
focusMode={workspaceFocused}
|
||||
onFocusModeChange={setWorkspaceFocused}
|
||||
designSystemProject={designSystemProject}
|
||||
onDesignSystemNeedsWork={sendDesignSystemFeedback}
|
||||
designSystemReview={project.metadata?.designSystemReview}
|
||||
onDesignSystemReviewDecision={persistDesignSystemReviewDecision}
|
||||
/>
|
||||
</div>
|
||||
{projectActionsToast ? (
|
||||
|
|
@ -3130,6 +3435,16 @@ function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
|
|||
return status === 'queued' || status === 'running';
|
||||
}
|
||||
|
||||
function latestDesignSystemActivityEvents(messages: ChatMessage[]): AgentEvent[] {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message || message.role !== 'assistant') continue;
|
||||
if ((message.events?.length ?? 0) > 0) return message.events ?? [];
|
||||
if (isActiveRunStatus(message.runStatus)) return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// A daemon assistant message that is "queued/running" but has no runId yet
|
||||
// is in-flight on the client: POST /api/runs has not returned. Persisting it
|
||||
// in this state creates a phantom DB row that the reattach loop can never
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ function tabFromRoute(route: Route, timestamp = Date.now()): WorkspaceChromeTab
|
|||
lastActiveAt: timestamp,
|
||||
};
|
||||
}
|
||||
return createEntryTab(route.view, timestamp);
|
||||
return createEntryTab(route.kind === 'home' ? route.view : 'design-systems', timestamp);
|
||||
}
|
||||
|
||||
function routeForTab(tab: WorkspaceChromeTab): Route {
|
||||
|
|
|
|||
12
apps/web/src/design-system-auto-prompt.ts
Normal file
12
apps/web/src/design-system-auto-prompt.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export const DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX =
|
||||
'Create this project as a complete Open Design design system workspace.';
|
||||
|
||||
export const DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE =
|
||||
'Creating design system workspace';
|
||||
|
||||
export const DESIGN_SYSTEM_WORKSPACE_DISPLAY_DESCRIPTION =
|
||||
'Open Design is using the setup sources to generate this project.';
|
||||
|
||||
export function isDesignSystemWorkspacePrompt(content: string): boolean {
|
||||
return content.trimStart().startsWith(DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;500;600;700&display=swap');
|
||||
@import './styles/design-system-flow.css';
|
||||
|
||||
/* ============================================================
|
||||
Open Design — neutral product workspace
|
||||
|
|
@ -1802,6 +1803,49 @@ a.avatar-item:visited {
|
|||
.msg.user .user-text-wrap .user-text {
|
||||
padding-inline-end: 28px;
|
||||
}
|
||||
.msg.user .user-status-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.user-status-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: min(320px, 100%);
|
||||
padding: 9px 11px;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-xs);
|
||||
color: var(--text);
|
||||
text-align: start;
|
||||
}
|
||||
.user-status-card__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.user-status-card__copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.user-status-card__copy strong {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.2;
|
||||
font-weight: 650;
|
||||
}
|
||||
.user-status-card__copy span {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.35;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.msg.user .user-copy-btn {
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -31,10 +31,19 @@ import type {
|
|||
DeployConfigResponse,
|
||||
DeployProjectFileResponse,
|
||||
DesignSystemDetail,
|
||||
DesignSystemFileDetail,
|
||||
DesignSystemFileSummary,
|
||||
DesignSystemGenerationJob,
|
||||
DesignSystemPackageAudit,
|
||||
DesignSystemProvenance,
|
||||
DesignSystemRevision,
|
||||
DesignSystemRevisionJobRequest,
|
||||
DesignSystemRevisionStatus,
|
||||
DesignSystemSummary,
|
||||
LiveArtifact,
|
||||
LiveArtifactRefreshLogEntry,
|
||||
LiveArtifactSummary,
|
||||
Project,
|
||||
ProjectDeploymentsResponse,
|
||||
PromptTemplateDetail,
|
||||
PromptTemplateSummary,
|
||||
|
|
@ -348,12 +357,216 @@ export async function fetchDesignSystem(id: string): Promise<DesignSystemDetail
|
|||
try {
|
||||
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}`);
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as DesignSystemDetail;
|
||||
return parseDesignSystemDetail(await resp.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDesignSystemFiles(
|
||||
id: string,
|
||||
): Promise<DesignSystemFileSummary[]> {
|
||||
try {
|
||||
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}/files`);
|
||||
if (!resp.ok) return [];
|
||||
const json = (await resp.json()) as { files: DesignSystemFileSummary[] };
|
||||
return json.files ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDesignSystemFile(
|
||||
id: string,
|
||||
filePath: string,
|
||||
): Promise<DesignSystemFileDetail | null> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/design-systems/${encodeURIComponent(id)}/file?path=${encodeURIComponent(filePath)}`,
|
||||
);
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { file?: DesignSystemFileDetail };
|
||||
return json.file ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDesignSystemWorkspace(
|
||||
id: string,
|
||||
): Promise<{ project: Project; files: ProjectFile[] } | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}/workspace`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as { project: Project; files: ProjectFile[] };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDesignSystemDetail(json: unknown): DesignSystemDetail | null {
|
||||
if (!json || typeof json !== 'object') return null;
|
||||
const wrapper = json as { designSystem?: DesignSystemDetail };
|
||||
return wrapper.designSystem ?? (json as DesignSystemDetail);
|
||||
}
|
||||
|
||||
export interface DesignSystemDraftInput {
|
||||
title: string;
|
||||
summary?: string;
|
||||
category?: string;
|
||||
surface?: 'web' | 'image' | 'video' | 'audio';
|
||||
status?: 'draft' | 'published';
|
||||
artifactMode?: 'generated' | 'agent-managed';
|
||||
body?: string;
|
||||
sourceNotes?: string;
|
||||
provenance?: DesignSystemProvenance;
|
||||
}
|
||||
|
||||
export async function createDesignSystemDraft(
|
||||
input: DesignSystemDraftInput,
|
||||
): Promise<DesignSystemDetail | null> {
|
||||
try {
|
||||
const resp = await fetch('/api/design-systems', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return parseDesignSystemDetail(await resp.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDesignSystemGenerationJob(
|
||||
input: DesignSystemDraftInput,
|
||||
): Promise<DesignSystemGenerationJob | null> {
|
||||
try {
|
||||
const resp = await fetch('/api/design-systems/generation-jobs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { job?: DesignSystemGenerationJob };
|
||||
return json.job ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDesignSystemGenerationJob(
|
||||
id: string,
|
||||
): Promise<DesignSystemGenerationJob | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/design-systems/generation-jobs/${encodeURIComponent(id)}`);
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { job?: DesignSystemGenerationJob };
|
||||
return json.job ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProjectDesignSystemPackageAudit(
|
||||
projectId: string,
|
||||
): Promise<DesignSystemPackageAudit | null> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/design-system-package-audit`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { audit?: DesignSystemPackageAudit };
|
||||
return json.audit ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDesignSystemRevisions(
|
||||
id: string,
|
||||
): Promise<DesignSystemRevision[]> {
|
||||
try {
|
||||
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}/revisions`);
|
||||
if (!resp.ok) return [];
|
||||
const json = (await resp.json()) as { revisions?: DesignSystemRevision[] };
|
||||
return json.revisions ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDesignSystemRevisionStatus(
|
||||
id: string,
|
||||
revisionId: string,
|
||||
status: Extract<DesignSystemRevisionStatus, 'accepted' | 'rejected'>,
|
||||
): Promise<DesignSystemRevision | null> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/design-systems/${encodeURIComponent(id)}/revisions/${encodeURIComponent(revisionId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { revision?: DesignSystemRevision };
|
||||
return json.revision ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDesignSystemRevisionJob(
|
||||
id: string,
|
||||
input: DesignSystemRevisionJobRequest,
|
||||
): Promise<DesignSystemGenerationJob | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}/revision-jobs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { job?: DesignSystemGenerationJob };
|
||||
return json.job ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDesignSystemDraft(
|
||||
id: string,
|
||||
input: Partial<DesignSystemDraftInput>,
|
||||
): Promise<DesignSystemDetail | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return parseDesignSystemDetail(await resp.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDesignSystemDraft(id: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importLocalDesignSystem(
|
||||
input: ImportLocalDesignSystemRequest,
|
||||
): Promise<ImportLocalDesignSystemResponse | { error: SkillImportError }> {
|
||||
|
|
@ -457,9 +670,11 @@ export async function fetchConnectors(): Promise<ConnectorDetail[]> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function fetchConnectorStatuses(): Promise<ConnectorStatusResponse['statuses']> {
|
||||
export async function fetchConnectorStatuses(options?: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ConnectorStatusResponse['statuses']> {
|
||||
try {
|
||||
const resp = await fetch('/api/connectors/status');
|
||||
const resp = await fetch('/api/connectors/status', { signal: options?.signal });
|
||||
if (!resp.ok) return {};
|
||||
const json = (await resp.json()) as ConnectorStatusResponse;
|
||||
return json.statuses ?? {};
|
||||
|
|
@ -570,14 +785,22 @@ export async function connectConnector(connectorId: string): Promise<ConnectorAc
|
|||
if (useExternalBrowser) {
|
||||
const opened = await openExternal(json.auth.redirectUrl);
|
||||
if (!opened) {
|
||||
return { connector: json.connector ?? null, error: popupBlockedMessage() };
|
||||
return {
|
||||
connector: json.connector ?? null,
|
||||
auth: json.auth,
|
||||
error: popupBlockedMessage(),
|
||||
};
|
||||
}
|
||||
} else if (authWindow) {
|
||||
openConnectorAuthRedirect(authWindow, json.auth.redirectUrl);
|
||||
} else {
|
||||
const redirected = window.open(json.auth.redirectUrl, '_blank');
|
||||
if (!redirected) {
|
||||
return { connector: json.connector ?? null, error: popupBlockedMessage() };
|
||||
return {
|
||||
connector: json.connector ?? null,
|
||||
auth: json.auth,
|
||||
error: popupBlockedMessage(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (json.auth?.kind === 'connected') {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export type EntryHomeView =
|
|||
|
||||
export type Route =
|
||||
| { kind: 'home'; view: EntryHomeView }
|
||||
| { kind: 'design-system-create' }
|
||||
| { kind: 'design-system-detail'; designSystemId: string }
|
||||
| {
|
||||
kind: 'project';
|
||||
projectId: string;
|
||||
|
|
@ -68,6 +70,12 @@ export function parseRoute(pathname: string): Route {
|
|||
return { kind: 'home', view: 'projects' };
|
||||
}
|
||||
if (parts[0] === 'design-systems') {
|
||||
if (parts[1] === 'create') {
|
||||
return { kind: 'design-system-create' };
|
||||
}
|
||||
if (parts[1]) {
|
||||
return { kind: 'design-system-detail', designSystemId: decodeURIComponent(parts[1]) };
|
||||
}
|
||||
return { kind: 'home', view: 'design-systems' };
|
||||
}
|
||||
if (parts[0] === 'automations' || parts[0] === 'tasks') {
|
||||
|
|
@ -104,6 +112,10 @@ export function buildPath(route: Route): string {
|
|||
}
|
||||
if (route.kind === 'marketplace') return '/marketplace';
|
||||
if (route.kind === 'marketplace-detail') return `/marketplace/${encodeURIComponent(route.pluginId)}`;
|
||||
if (route.kind === 'design-system-create') return '/design-systems/create';
|
||||
if (route.kind === 'design-system-detail') {
|
||||
return `/design-systems/${encodeURIComponent(route.designSystemId)}`;
|
||||
}
|
||||
const id = encodeURIComponent(route.projectId);
|
||||
const file = route.fileName
|
||||
? route.fileName.split('/').map((s) => encodeURIComponent(s)).join('/')
|
||||
|
|
|
|||
108
apps/web/src/runtime/design-system-package-audit.ts
Normal file
108
apps/web/src/runtime/design-system-package-audit.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type {
|
||||
DesignSystemPackageAudit,
|
||||
DesignSystemPackageAuditIssue,
|
||||
} from '../types';
|
||||
|
||||
function issueCountLabel(count: number, singular: string): string {
|
||||
return `${count} ${singular}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function auditIssueSummary(issue: DesignSystemPackageAuditIssue): string {
|
||||
return issue.path ? `${issue.code} (${issue.path})` : issue.code;
|
||||
}
|
||||
|
||||
function targetedAuditRepairActions(issues: DesignSystemPackageAuditIssue[]): string[] {
|
||||
const codes = new Set(issues.map((issue) => issue.code));
|
||||
const actions: string[] = [];
|
||||
const hasAny = (...values: string[]) => values.some((value) => codes.has(value));
|
||||
if (hasAny(
|
||||
'ui_kit_index_missing_component_references',
|
||||
'ui_kit_index_missing_runtime_bootstrap',
|
||||
'ui_kit_index_missing_component_composition',
|
||||
'ui_kit_index_missing_jsx_runtime',
|
||||
'ui_kit_component_missing_browser_global',
|
||||
)) {
|
||||
actions.push('- Rebuild `ui_kits/app/index.html` as a runnable UI-kit entry: load React, ReactDOM, Babel, and `../../colors_and_type.css`; create `#root`; load at least three `components/*.jsx` scripts; expose loaded components on `window.ComponentName`; then render `<App />` with `ReactDOM.createRoot(...).render(...)`.');
|
||||
}
|
||||
if (hasAny(
|
||||
'missing_modular_ui_kit',
|
||||
'thin_modular_ui_kit',
|
||||
'missing_ui_kit_component_roles',
|
||||
'ui_kit_app_missing_role_composition',
|
||||
)) {
|
||||
actions.push('- Make `ui_kits/app/components/` substantive and role-based: include an app shell plus navigation/sidebar, list or rail, main workspace, composer/input, and message/card components when source evidence contains those product surfaces.');
|
||||
}
|
||||
if (hasAny('missing_skill_frontmatter', 'skill_missing_reuse_sections')) {
|
||||
actions.push('- Rewrite `SKILL.md` as a discoverable skill package with YAML frontmatter (`name`, `description`, `user-invocable`) and sections for What is inside, Source context, When to use this skill, How to use, and Design system highlights.');
|
||||
}
|
||||
if (hasAny('readme_missing_product_overview', 'readme_missing_package_reuse_guide', 'readme_missing_preview_manifest')) {
|
||||
actions.push('- Rewrite `README.md` as a Claude Design package guide with Product Overview/Product Context, source/context references, Package Contents, preview-card manifest, preserved assets/fonts/build/source examples, `ui_kits/app/`, and a concrete reuse or review workflow.');
|
||||
}
|
||||
if (hasAny('readme_missing_preview_manifest')) {
|
||||
actions.push('- Add a `## Preview Manifest` section to `README.md` that lists every generated `preview/*.html` card with the exact path, review purpose, and source-backed components or assets it demonstrates.');
|
||||
}
|
||||
if (hasAny('missing_source_component_examples', 'thin_source_component_examples')) {
|
||||
actions.push('- Copy real high-signal source snapshots into `source_examples/` or equivalent package source files; keep original component code substantial enough to inspect, not tiny generated stubs.');
|
||||
}
|
||||
if (hasAny('missing_build_assets', 'build_assets_not_source_backed', 'brand_assets_preview_not_using_preserved_assets')) {
|
||||
actions.push('- Preserve runtime/build assets by copying originals from `context/.../files/build/...` into root `build/` byte-for-byte, keep original filenames such as `icon.png` or `tray_icon.png`, and update `preview/brand-assets.html` to visibly reference those files.');
|
||||
}
|
||||
if (hasAny('preview_cards_missing_source_component_context', 'generic_visual_artifacts')) {
|
||||
actions.push('- Update focused preview cards to name or model actual source components from the evidence, such as Sidebar, Navbar, Chat, Inputbar, Message, Topic, Settings, or selector components, instead of abstract token-only swatches.');
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function designSystemPackageAuditHasFindings(audit: DesignSystemPackageAudit): boolean {
|
||||
return audit.errors.length + audit.warnings.length > 0;
|
||||
}
|
||||
|
||||
export function summarizeDesignSystemPackageAudit(audit: DesignSystemPackageAudit): string {
|
||||
if (!designSystemPackageAuditHasFindings(audit)) {
|
||||
return `Package audit passed (${issueCountLabel(audit.filesInspected, 'file')} inspected).`;
|
||||
}
|
||||
const countLabel = [
|
||||
audit.errors.length ? issueCountLabel(audit.errors.length, 'error') : '',
|
||||
audit.warnings.length ? issueCountLabel(audit.warnings.length, 'warning') : '',
|
||||
].filter(Boolean).join(' and ');
|
||||
const findings = [...audit.errors, ...audit.warnings];
|
||||
const listed = findings.slice(0, 5).map(auditIssueSummary).join(', ');
|
||||
const extra = findings.length > 5 ? `, +${findings.length - 5} more` : '';
|
||||
return `Package audit found ${countLabel}: ${listed}${extra}.`;
|
||||
}
|
||||
|
||||
export function buildDesignSystemPackageAuditRepairPrompt(
|
||||
audit: DesignSystemPackageAudit,
|
||||
): string | null {
|
||||
if (!designSystemPackageAuditHasFindings(audit)) return null;
|
||||
const findings = [...audit.errors, ...audit.warnings]
|
||||
.slice(0, 16)
|
||||
.map((issue) => {
|
||||
const pathLabel = issue.path ? ` ${issue.path}` : '';
|
||||
return `- [${issue.severity}] ${issue.code}${pathLabel}: ${issue.message}`;
|
||||
});
|
||||
const hiddenCount = audit.errors.length + audit.warnings.length - findings.length;
|
||||
if (hiddenCount > 0) findings.push(`- ...and ${hiddenCount} more audit finding(s).`);
|
||||
const targetedActions = targetedAuditRepairActions([...audit.errors, ...audit.warnings]);
|
||||
return [
|
||||
'Fix the design-system package audit findings below.',
|
||||
'',
|
||||
'Treat every error and warning as blocking. Do not suppress the audit, delete evidence, or satisfy findings by only rewriting prose; update the real package artifacts and preserve source-backed files outside `context/` when the audit asks for them.',
|
||||
'',
|
||||
'Claude-style repair checklist:',
|
||||
'- If runtime/build assets are reported, preserve representative originals under root `build/` with their original filenames, copy them byte-for-byte from captured context snapshots, and make `preview/brand-assets.html` visibly reference the preserved files.',
|
||||
'- If source examples are reported, copy substantive original component snapshots into `source_examples/` or equivalent package source files; do not create tiny stubs that only share component names.',
|
||||
'- If UI-kit findings are reported, make `ui_kits/app/index.html` load `../../colors_and_type.css`, load/import modular files from `ui_kits/app/components/`, and mount a composed interface.',
|
||||
'- If README or SKILL findings are reported, keep them in sync with the final file structure and include Claude Design-style reusable package guidance.',
|
||||
'',
|
||||
...(targetedActions.length > 0 ? [
|
||||
'Targeted repair actions:',
|
||||
...targetedActions,
|
||||
'',
|
||||
] : []),
|
||||
'Update the package files directly, then rerun `"$OD_NODE_BIN" "$OD_BIN" tools connectors design-system-package-audit --path . --fail-on-warnings` until it passes.',
|
||||
'',
|
||||
'Audit findings:',
|
||||
...findings,
|
||||
].join('\n');
|
||||
}
|
||||
2188
apps/web/src/styles/design-system-flow.css
Normal file
2188
apps/web/src/styles/design-system-flow.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -21,6 +21,15 @@ import type {
|
|||
DeployConfigResponse,
|
||||
DeployProjectFileResponse,
|
||||
DesignSystemDetail,
|
||||
DesignSystemFileDetail,
|
||||
DesignSystemFileSummary,
|
||||
DesignSystemGenerationJob,
|
||||
DesignSystemPackageAudit,
|
||||
DesignSystemPackageAuditIssue,
|
||||
DesignSystemProvenance,
|
||||
DesignSystemRevision,
|
||||
DesignSystemRevisionJobRequest,
|
||||
DesignSystemRevisionStatus,
|
||||
DesignSystemSummary,
|
||||
LiveArtifact,
|
||||
LiveArtifactDetailResponse,
|
||||
|
|
@ -439,6 +448,15 @@ export type {
|
|||
DeployConfigResponse,
|
||||
DeployProjectFileResponse,
|
||||
DesignSystemDetail,
|
||||
DesignSystemFileDetail,
|
||||
DesignSystemFileSummary,
|
||||
DesignSystemGenerationJob,
|
||||
DesignSystemPackageAudit,
|
||||
DesignSystemPackageAuditIssue,
|
||||
DesignSystemProvenance,
|
||||
DesignSystemRevision,
|
||||
DesignSystemRevisionJobRequest,
|
||||
DesignSystemRevisionStatus,
|
||||
DesignSystemSummary,
|
||||
LiveArtifact,
|
||||
LiveArtifactDetailResponse,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { forwardRef } from 'react';
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatPane } from '../../src/components/ChatPane';
|
||||
import { DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX } from '../../src/design-system-auto-prompt';
|
||||
import type { ChatMessage, Conversation, ProjectMetadata } from '../../src/types';
|
||||
|
||||
vi.mock('../../src/i18n', () => ({
|
||||
|
|
@ -61,6 +62,42 @@ describe('ChatPane streaming state', () => {
|
|||
expect(bubble.closest('.msg.user')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('summarizes auto-sent design-system workspace prompts', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: `${DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX}
|
||||
Use the files in this project as the design system source for future projects.
|
||||
Expected output:
|
||||
- A clear DESIGN.md with all generated rules.`,
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ChatPane
|
||||
messages={messages}
|
||||
streaming={false}
|
||||
error={null}
|
||||
projectId="project-1"
|
||||
projectFiles={[]}
|
||||
onEnsureProject={async () => 'project-1'}
|
||||
onSend={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
conversations={conversations}
|
||||
activeConversationId="conv-1"
|
||||
onSelectConversation={vi.fn()}
|
||||
onDeleteConversation={vi.fn()}
|
||||
projectMetadata={projectMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Creating design system workspace')).toBeTruthy();
|
||||
expect(screen.queryByText(DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX, { exact: false })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'chat.copyPrompt' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps composer idle while active-run messages still render as streaming', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
|
|
|
|||
1702
apps/web/tests/components/DesignSystemFlow.test.tsx
Normal file
1702
apps/web/tests/components/DesignSystemFlow.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
87
apps/web/tests/components/DesignSystemsTab.test.tsx
Normal file
87
apps/web/tests/components/DesignSystemsTab.test.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DesignSystemSummary } from '@open-design/contracts';
|
||||
|
||||
import { DesignSystemsTab } from '../../src/components/DesignSystemsTab';
|
||||
|
||||
vi.mock('../../src/providers/registry', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
||||
'../../src/providers/registry',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
updateDesignSystemDraft: vi.fn(async () => null),
|
||||
deleteDesignSystemDraft: vi.fn(async () => true),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const systems: DesignSystemSummary[] = [
|
||||
{
|
||||
id: 'user:acme',
|
||||
title: 'Acme Design System',
|
||||
category: 'Custom',
|
||||
summary: 'Internal product system.',
|
||||
surface: 'web',
|
||||
source: 'user',
|
||||
status: 'draft',
|
||||
isEditable: true,
|
||||
updatedAt: '2026-05-13T03:19:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'linear',
|
||||
title: 'Linear',
|
||||
category: 'Productivity & SaaS',
|
||||
summary: 'Quiet issue-tracker system.',
|
||||
surface: 'web',
|
||||
source: 'built-in',
|
||||
status: 'published',
|
||||
isEditable: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe('DesignSystemsTab', () => {
|
||||
it('surfaces user-created design systems in the gallery', () => {
|
||||
render(
|
||||
<DesignSystemsTab
|
||||
systems={systems}
|
||||
selectedId="user:acme"
|
||||
onSelect={() => {}}
|
||||
onPreview={() => {}}
|
||||
onCreate={() => {}}
|
||||
onOpenSystem={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Create')).toBeTruthy();
|
||||
expect(screen.getByText('Acme Design System')).toBeTruthy();
|
||||
expect(screen.getByText('Linear')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('routes create and open actions to the dedicated design-system flow', () => {
|
||||
const onCreate = vi.fn();
|
||||
const onOpenSystem = vi.fn();
|
||||
render(
|
||||
<DesignSystemsTab
|
||||
systems={systems}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onPreview={() => {}}
|
||||
onCreate={onCreate}
|
||||
onOpenSystem={onOpenSystem}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Create'));
|
||||
expect(onCreate).toHaveBeenCalledOnce();
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(onOpenSystem).toHaveBeenCalledWith('user:acme');
|
||||
});
|
||||
});
|
||||
287
apps/web/tests/components/FileWorkspace.design-system.test.tsx
Normal file
287
apps/web/tests/components/FileWorkspace.design-system.test.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FileWorkspace } from '../../src/components/FileWorkspace';
|
||||
import type { AgentEvent, DesignSystemSummary, ProjectFile } from '../../src/types';
|
||||
|
||||
const registryMocks = vi.hoisted(() => ({
|
||||
updateDesignSystemDraft: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/providers/registry', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
||||
'../../src/providers/registry',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
updateDesignSystemDraft: registryMocks.updateDesignSystemDraft,
|
||||
};
|
||||
});
|
||||
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement | null = null;
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
}
|
||||
host?.remove();
|
||||
host = null;
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function workspaceFile(name: string): ProjectFile {
|
||||
return {
|
||||
name,
|
||||
path: name,
|
||||
type: 'file',
|
||||
size: 100,
|
||||
mtime: Date.parse('2026-05-14T00:00:00.000Z'),
|
||||
kind: name.endsWith('.html') ? 'html' : name.endsWith('.svg') ? 'image' : 'text',
|
||||
mime: name.endsWith('.html') ? 'text/html' : name.endsWith('.svg') ? 'image/svg+xml' : 'text/plain',
|
||||
};
|
||||
}
|
||||
|
||||
function designSystem(overrides: Partial<DesignSystemSummary> = {}): DesignSystemSummary {
|
||||
return {
|
||||
id: 'user:acme',
|
||||
title: 'Acme Design System',
|
||||
category: 'Custom',
|
||||
summary: 'Context project for Acme.',
|
||||
swatches: [],
|
||||
surface: 'web',
|
||||
source: 'user',
|
||||
status: 'draft',
|
||||
isEditable: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderWorkspace(element: React.ReactElement) {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
act(() => {
|
||||
root = createRoot(host!);
|
||||
root.render(element);
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
type ToolUseEvent = Extract<AgentEvent, { kind: 'tool_use' }>;
|
||||
type ToolResultEvent = Extract<AgentEvent, { kind: 'tool_result' }>;
|
||||
|
||||
function toolUse(name: string, input: unknown, id: string): ToolUseEvent {
|
||||
return { kind: 'tool_use', id, name, input };
|
||||
}
|
||||
|
||||
function toolOk(id: string): ToolResultEvent {
|
||||
return { kind: 'tool_result', toolUseId: id, content: '', isError: false };
|
||||
}
|
||||
|
||||
function todoWrite(
|
||||
todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm?: string }>,
|
||||
): ToolUseEvent {
|
||||
return toolUse('TodoWrite', { todos }, 'todo-write');
|
||||
}
|
||||
|
||||
describe('FileWorkspace design-system project surface', () => {
|
||||
it('keeps project-backed design systems inside the normal workspace tabs with inline preview cards', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="ds-acme"
|
||||
projectKind="prototype"
|
||||
files={[
|
||||
workspaceFile('DESIGN.md'),
|
||||
workspaceFile('colors_and_type.css'),
|
||||
workspaceFile('preview/typography-specimens.html'),
|
||||
workspaceFile('preview/colors-primary.html'),
|
||||
workspaceFile('preview/spacing-tokens.html'),
|
||||
workspaceFile('ui_kits/app/index.html'),
|
||||
workspaceFile('preview/brand-assets.html'),
|
||||
]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
designSystemProject={designSystem()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-testid="design-system-project-tab"');
|
||||
expect(markup).toContain('data-testid="design-files-tab"');
|
||||
expect(markup).toContain('Review draft design system');
|
||||
expect(markup).not.toContain('<h2>Needs review</h2>');
|
||||
expect(markup).toContain('Type');
|
||||
expect(markup).toContain('Colors');
|
||||
expect(markup).toContain('Spacing');
|
||||
expect(markup).toContain('Components');
|
||||
expect(markup).toContain('Brand');
|
||||
expect(markup).toContain('typography-specimens');
|
||||
expect(markup).toContain('colors-primary');
|
||||
expect(markup).toContain('spacing-tokens');
|
||||
expect(markup).toContain('app');
|
||||
expect(markup).toContain('brand-assets');
|
||||
expect(markup).toContain('<iframe');
|
||||
expect(markup).not.toContain('Preview cards will appear here as the agent creates them.');
|
||||
});
|
||||
|
||||
it('shows the creating state while the initial design-system project is still source-only', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="ds-acme"
|
||||
projectKind="prototype"
|
||||
files={[workspaceFile('context/source-context.md')]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
streaming
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
designSystemProject={designSystem({ provenance: { companyBlurb: 'Acme analytics workspace' } })}
|
||||
designSystemActivityEvents={[
|
||||
todoWrite([
|
||||
{ content: 'Create README.md with high-level company/product understanding', status: 'in_progress' },
|
||||
{ content: 'Create colors_and_type.css with CSS variables', status: 'pending' },
|
||||
]),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('Creating your design system...');
|
||||
expect(markup).toContain('Keep this tab open. You can come back in a few minutes.');
|
||||
expect(markup).toContain('role="progressbar"');
|
||||
expect(markup).not.toContain('Review draft design system');
|
||||
});
|
||||
|
||||
it('keeps generated preview cards hidden until the initial run finishes', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="ds-acme"
|
||||
projectKind="prototype"
|
||||
files={[
|
||||
workspaceFile('DESIGN.md'),
|
||||
workspaceFile('preview/typography-specimens.html'),
|
||||
workspaceFile('preview/colors-primary.html'),
|
||||
workspaceFile('ui_kits/app/index.html'),
|
||||
]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
streaming
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
designSystemProject={designSystem()}
|
||||
designSystemActivityEvents={[
|
||||
toolUse('Write', { file_path: '/project/preview/typography-specimens.html' }, 'write-preview'),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('Creating your design system...');
|
||||
expect(markup).not.toContain('Review draft design system');
|
||||
expect(markup).not.toContain('typography-specimens');
|
||||
expect(markup).not.toContain('<iframe');
|
||||
});
|
||||
|
||||
it('keeps source evidence files out of the Design System review tab', () => {
|
||||
const container = renderWorkspace(
|
||||
<FileWorkspace
|
||||
projectId="ds-acme"
|
||||
projectKind="prototype"
|
||||
files={[
|
||||
workspaceFile('DESIGN.md'),
|
||||
workspaceFile('context/source-context.md'),
|
||||
workspaceFile('context/github/acme-product.md'),
|
||||
workspaceFile('context/github/acme-product/files/src/components/Button.tsx'),
|
||||
workspaceFile('assets/logo.svg'),
|
||||
workspaceFile('preview/brand-assets.html'),
|
||||
]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
designSystemProject={designSystem({
|
||||
provenance: {
|
||||
githubUrls: ['https://github.com/acme/product'],
|
||||
sourceNotes: 'GitHub metadata: React UI library with token CSS.',
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Brand');
|
||||
expect(container.textContent).toContain('brand-assets');
|
||||
expect(container.textContent).not.toContain('context/github/acme-product.md');
|
||||
expect(container.textContent).not.toContain('GitHub metadata: React UI library with token CSS.');
|
||||
});
|
||||
|
||||
it('marks a section for review after the latest agent run edits it', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="ds-acme"
|
||||
projectKind="prototype"
|
||||
files={[workspaceFile('DESIGN.md'), workspaceFile('preview/colors.html')]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
designSystemProject={designSystem()}
|
||||
designSystemActivityEvents={[
|
||||
toolUse('Write', { file_path: '/project/preview/colors.html' }, 'write-preview'),
|
||||
toolOk('write-preview'),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('This section changed during the latest run. Review it before publishing.');
|
||||
});
|
||||
|
||||
it('blocks publishing GitHub-backed design systems until connector evidence snapshots exist', async () => {
|
||||
const container = renderWorkspace(
|
||||
<FileWorkspace
|
||||
projectId="ds-acme"
|
||||
projectKind="prototype"
|
||||
files={[
|
||||
workspaceFile('DESIGN.md'),
|
||||
workspaceFile('context/source-context.md'),
|
||||
workspaceFile('preview/colors.html'),
|
||||
]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
designSystemProject={designSystem({
|
||||
provenance: {
|
||||
companyBlurb: 'Acme analytics workspace',
|
||||
githubUrls: ['https://github.com/acme/product'],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const publishToggle = container.querySelector<HTMLInputElement>(
|
||||
'.ds-project-publish-card input[type="checkbox"]',
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Waiting for GitHub connector evidence');
|
||||
expect(publishToggle?.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
publishToggle?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(registryMocks.updateDesignSystemDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ const listMessages = vi.fn();
|
|||
const fetchPreviewComments = vi.fn();
|
||||
const loadTabs = vi.fn();
|
||||
const fetchProjectFiles = vi.fn();
|
||||
const fetchProjectDesignSystemPackageAudit = vi.fn();
|
||||
const fetchLiveArtifacts = vi.fn();
|
||||
const fetchSkill = vi.fn();
|
||||
const fetchDesignSystem = vi.fn();
|
||||
|
|
@ -49,6 +50,7 @@ vi.mock('../../src/providers/registry', () => ({
|
|||
deletePreviewComment: vi.fn(),
|
||||
fetchPreviewComments: (...args: unknown[]) => fetchPreviewComments(...args),
|
||||
fetchDesignSystem: (...args: unknown[]) => fetchDesignSystem(...args),
|
||||
fetchProjectDesignSystemPackageAudit: (...args: unknown[]) => fetchProjectDesignSystemPackageAudit(...args),
|
||||
fetchLiveArtifacts: (...args: unknown[]) => fetchLiveArtifacts(...args),
|
||||
fetchProjectFiles: (...args: unknown[]) => fetchProjectFiles(...args),
|
||||
fetchSkill: (...args: unknown[]) => fetchSkill(...args),
|
||||
|
|
@ -117,6 +119,7 @@ describe('ProjectView daemon cleanup', () => {
|
|||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('does not abort daemon cancel reattach controllers during unmount cleanup', async () => {
|
||||
|
|
@ -404,6 +407,170 @@ describe('ProjectView daemon cleanup', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('audits design-system workspace output after first auto-send and auto-sends bounded repair prompts', async () => {
|
||||
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
||||
listMessages.mockResolvedValue([]);
|
||||
fetchPreviewComments.mockResolvedValue([]);
|
||||
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
||||
fetchProjectFiles.mockResolvedValue([]);
|
||||
fetchLiveArtifacts.mockResolvedValue([]);
|
||||
fetchSkill.mockResolvedValue(null);
|
||||
fetchDesignSystem.mockResolvedValue(null);
|
||||
getTemplate.mockResolvedValue(null);
|
||||
listActiveChatRuns.mockResolvedValue([]);
|
||||
fetchProjectDesignSystemPackageAudit.mockResolvedValue({
|
||||
ok: false,
|
||||
projectPath: '/tmp/ds',
|
||||
filesInspected: 12,
|
||||
errors: [{
|
||||
severity: 'error',
|
||||
code: 'ui_kit_index_missing_runtime_bootstrap',
|
||||
message: 'ui_kits/app/index.html must mount the kit.',
|
||||
path: 'ui_kits/app/index.html',
|
||||
}],
|
||||
warnings: [],
|
||||
});
|
||||
streamViaDaemon.mockImplementation(async (options: {
|
||||
handlers: { onDone: () => void };
|
||||
onRunCreated?: (runId: string) => void;
|
||||
}) => {
|
||||
options.onRunCreated?.('run-ds-1');
|
||||
options.handlers.onDone();
|
||||
});
|
||||
|
||||
chatPaneSpy.mockClear();
|
||||
window.sessionStorage.setItem('od:auto-send-first:project-ds', '1');
|
||||
|
||||
render(
|
||||
<ProjectView
|
||||
project={{
|
||||
id: 'project-ds',
|
||||
name: 'Cherry Studio Design System',
|
||||
skillId: null,
|
||||
designSystemId: 'user:cherry-studio',
|
||||
pendingPrompt: 'Create this project as a design system.',
|
||||
metadata: {
|
||||
importedFrom: 'design-system',
|
||||
entryFile: 'DESIGN.md',
|
||||
sourceFileName: 'user:cherry-studio',
|
||||
},
|
||||
} as never}
|
||||
routeFileName={null}
|
||||
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
||||
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
||||
skills={[]}
|
||||
designTemplates={[]}
|
||||
designSystems={[]}
|
||||
daemonLive
|
||||
onModeChange={() => {}}
|
||||
onAgentChange={() => {}}
|
||||
onAgentModelChange={() => {}}
|
||||
onRefreshAgents={() => {}}
|
||||
onOpenSettings={() => {}}
|
||||
onBack={() => {}}
|
||||
onClearPendingPrompt={() => {}}
|
||||
onTouchProject={() => {}}
|
||||
onProjectChange={() => {}}
|
||||
onProjectsRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchProjectDesignSystemPackageAudit).toHaveBeenCalledWith('project-ds'));
|
||||
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(3));
|
||||
const repairMessages = saveMessage.mock.calls.filter((call) =>
|
||||
call[2]?.role === 'user'
|
||||
&& typeof call[2]?.content === 'string'
|
||||
&& call[2].content.includes('Fix the design-system package audit findings below.')
|
||||
&& call[2].content.includes('ui_kit_index_missing_runtime_bootstrap'),
|
||||
);
|
||||
expect(repairMessages).toHaveLength(2);
|
||||
expect(window.sessionStorage.getItem('od:design-system-audit-auto-repair:project-ds')).toBeNull();
|
||||
await waitFor(() => {
|
||||
const repairSeed = chatPaneSpy.mock.calls.find(
|
||||
(call) => typeof call[0]?.initialDraft === 'string'
|
||||
&& call[0].initialDraft.includes('Fix the design-system package audit findings below.')
|
||||
&& call[0].initialDraft.includes('ui_kit_index_missing_runtime_bootstrap'),
|
||||
);
|
||||
expect(repairSeed).toBeTruthy();
|
||||
});
|
||||
expect(saveMessage.mock.calls.some((call) =>
|
||||
call[2]?.role === 'assistant'
|
||||
&& call[2]?.events?.some((event: { kind?: string; label?: string; detail?: string }) =>
|
||||
event.kind === 'status'
|
||||
&& event.label === 'audit'
|
||||
&& event.detail?.includes('Package audit found 1 error'),
|
||||
),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears design-system auto-repair budget when the first audit passes', async () => {
|
||||
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
||||
listMessages.mockResolvedValue([]);
|
||||
fetchPreviewComments.mockResolvedValue([]);
|
||||
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
||||
fetchProjectFiles.mockResolvedValue([]);
|
||||
fetchLiveArtifacts.mockResolvedValue([]);
|
||||
fetchSkill.mockResolvedValue(null);
|
||||
fetchDesignSystem.mockResolvedValue(null);
|
||||
getTemplate.mockResolvedValue(null);
|
||||
listActiveChatRuns.mockResolvedValue([]);
|
||||
fetchProjectDesignSystemPackageAudit.mockResolvedValue({
|
||||
ok: true,
|
||||
projectPath: '/tmp/ds',
|
||||
filesInspected: 24,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
});
|
||||
streamViaDaemon.mockImplementation(async (options: {
|
||||
handlers: { onDone: () => void };
|
||||
onRunCreated?: (runId: string) => void;
|
||||
}) => {
|
||||
options.onRunCreated?.('run-ds-pass');
|
||||
options.handlers.onDone();
|
||||
});
|
||||
|
||||
chatPaneSpy.mockClear();
|
||||
window.sessionStorage.setItem('od:auto-send-first:project-ds-pass', '1');
|
||||
|
||||
render(
|
||||
<ProjectView
|
||||
project={{
|
||||
id: 'project-ds-pass',
|
||||
name: 'Passing Design System',
|
||||
skillId: null,
|
||||
designSystemId: 'user:passing-ds',
|
||||
pendingPrompt: 'Create this project as a design system.',
|
||||
metadata: {
|
||||
importedFrom: 'design-system',
|
||||
entryFile: 'DESIGN.md',
|
||||
sourceFileName: 'user:passing-ds',
|
||||
},
|
||||
} as never}
|
||||
routeFileName={null}
|
||||
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
||||
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
||||
skills={[]}
|
||||
designTemplates={[]}
|
||||
designSystems={[]}
|
||||
daemonLive
|
||||
onModeChange={() => {}}
|
||||
onAgentChange={() => {}}
|
||||
onAgentModelChange={() => {}}
|
||||
onRefreshAgents={() => {}}
|
||||
onOpenSettings={() => {}}
|
||||
onBack={() => {}}
|
||||
onClearPendingPrompt={() => {}}
|
||||
onTouchProject={() => {}}
|
||||
onProjectChange={() => {}}
|
||||
onProjectsRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchProjectDesignSystemPackageAudit).toHaveBeenCalledWith('project-ds-pass'));
|
||||
expect(streamViaDaemon).toHaveBeenCalledTimes(1);
|
||||
expect(window.sessionStorage.getItem('od:design-system-audit-auto-repair:project-ds-pass')).toBeNull();
|
||||
});
|
||||
|
||||
// Sister check: without the auto-send flag, the composer should still
|
||||
// seed from pendingPrompt so the user can edit before manually sending.
|
||||
it('seeds composer initialDraft with pendingPrompt when auto-send flag is absent', async () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
fetchAppVersionInfo,
|
||||
fetchConnectorDetail,
|
||||
fetchConnectorDiscovery,
|
||||
fetchProjectDesignSystemPackageAudit,
|
||||
fetchProjectFileText,
|
||||
fetchSkillExample,
|
||||
isDeployProviderId,
|
||||
|
|
@ -184,6 +185,43 @@ describe('fetchProjectFileText', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fetchProjectDesignSystemPackageAudit', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns the daemon package audit for a project', async () => {
|
||||
const audit = {
|
||||
ok: false,
|
||||
projectPath: '/tmp/project',
|
||||
filesInspected: 4,
|
||||
errors: [{
|
||||
severity: 'error',
|
||||
code: 'ui_kit_index_missing_runtime_bootstrap',
|
||||
message: 'UI kit must mount.',
|
||||
path: 'ui_kits/app/index.html',
|
||||
}],
|
||||
warnings: [],
|
||||
};
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ audit }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchProjectDesignSystemPackageAudit('ds acme')).resolves.toEqual(audit);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/projects/ds%20acme/design-system-package-audit',
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the audit endpoint is unavailable', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('missing', { status: 404 })));
|
||||
|
||||
await expect(fetchProjectDesignSystemPackageAudit('missing')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchConnectorDiscovery', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -356,6 +394,7 @@ describe('connectConnector', () => {
|
|||
|
||||
await expect(connectConnector('github')).resolves.toEqual({
|
||||
connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] },
|
||||
auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' },
|
||||
error: 'Popup blocked. Allow popups for Open Design and try again.',
|
||||
});
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -454,6 +493,7 @@ describe('connectConnector', () => {
|
|||
|
||||
await expect(connectConnector('github')).resolves.toEqual({
|
||||
connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] },
|
||||
auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' },
|
||||
error: 'Popup blocked. Allow popups for Open Design and try again.',
|
||||
});
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -62,6 +62,26 @@ export interface PromptTemplateMetadata {
|
|||
source?: PromptTemplateMetadataSource;
|
||||
}
|
||||
|
||||
export type DesignSystemReviewDecision = 'looks-good' | 'needs-work';
|
||||
|
||||
export type DesignSystemReviewTaskStatus = 'queued' | 'sent' | 'failed';
|
||||
|
||||
export interface DesignSystemReviewAgentTask {
|
||||
status: DesignSystemReviewTaskStatus;
|
||||
prompt: string;
|
||||
queuedAt: string;
|
||||
sentAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DesignSystemReviewEntry {
|
||||
decision: DesignSystemReviewDecision;
|
||||
updatedAt: string;
|
||||
feedback?: string;
|
||||
files?: string[];
|
||||
agentTask?: DesignSystemReviewAgentTask;
|
||||
}
|
||||
|
||||
export interface ProjectMetadata {
|
||||
kind: ProjectKind;
|
||||
intent?: 'live-artifact';
|
||||
|
|
@ -120,6 +140,9 @@ export interface ProjectMetadata {
|
|||
// context references; the explicit "Use plugin" snapshot, when present,
|
||||
// remains the primary executable plugin for the run.
|
||||
contextPlugins?: Array<{ id: string; title: string; description?: string }>;
|
||||
// Stored on design-system projects so the review overview can remember
|
||||
// which generated sections were accepted or sent back for another pass.
|
||||
designSystemReview?: Record<string, DesignSystemReviewEntry>;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
|
|
|
|||
|
|
@ -151,7 +151,13 @@ export interface DesignSystemSummary {
|
|||
summary: string;
|
||||
swatches?: string[];
|
||||
surface?: 'web' | 'image' | 'video' | 'audio';
|
||||
source?: 'built-in' | 'installed';
|
||||
source?: 'built-in' | 'installed' | 'user';
|
||||
status?: 'draft' | 'published';
|
||||
isEditable?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
provenance?: DesignSystemProvenance;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface DesignSystemDetail extends DesignSystemSummary {
|
||||
|
|
@ -166,6 +172,140 @@ export interface DesignSystemResponse {
|
|||
designSystem: DesignSystemDetail;
|
||||
}
|
||||
|
||||
export interface DesignSystemProvenance {
|
||||
companyBlurb?: string;
|
||||
githubUrls?: string[];
|
||||
localCodeFiles?: string[];
|
||||
figFiles?: string[];
|
||||
assetFiles?: string[];
|
||||
notes?: string;
|
||||
sourceNotes?: string;
|
||||
}
|
||||
|
||||
export type DesignSystemFileKind =
|
||||
| 'folder'
|
||||
| 'page'
|
||||
| 'stylesheet'
|
||||
| 'document'
|
||||
| 'image'
|
||||
| 'data'
|
||||
| 'asset';
|
||||
|
||||
export interface DesignSystemFileSummary {
|
||||
path: string;
|
||||
name: string;
|
||||
kind: DesignSystemFileKind;
|
||||
size?: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DesignSystemFileDetail extends DesignSystemFileSummary {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DesignSystemFilesResponse {
|
||||
files: DesignSystemFileSummary[];
|
||||
}
|
||||
|
||||
export interface DesignSystemFileResponse {
|
||||
file: DesignSystemFileDetail;
|
||||
}
|
||||
|
||||
export interface DesignSystemWorkspaceResponse {
|
||||
project: import('./projects.js').Project;
|
||||
files: import('./files.js').ProjectFile[];
|
||||
}
|
||||
|
||||
export type DesignSystemRevisionStatus = 'pending' | 'accepted' | 'rejected';
|
||||
|
||||
export interface DesignSystemRevision {
|
||||
id: string;
|
||||
designSystemId: string;
|
||||
status: DesignSystemRevisionStatus;
|
||||
feedback: string;
|
||||
baseBody: string;
|
||||
proposedBody: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sectionTitle?: string;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
export interface DesignSystemRevisionsResponse {
|
||||
revisions: DesignSystemRevision[];
|
||||
}
|
||||
|
||||
export interface DesignSystemRevisionResponse {
|
||||
revision: DesignSystemRevision;
|
||||
}
|
||||
|
||||
export type DesignSystemGenerationJobStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed';
|
||||
|
||||
export type DesignSystemGenerationStepStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed';
|
||||
|
||||
export interface DesignSystemGenerationStep {
|
||||
id: string;
|
||||
title: string;
|
||||
status: DesignSystemGenerationStepStatus;
|
||||
message?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface DesignSystemGenerationJob {
|
||||
id: string;
|
||||
kind?: 'generation' | 'revision';
|
||||
status: DesignSystemGenerationJobStatus;
|
||||
progress: number;
|
||||
steps: DesignSystemGenerationStep[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
designSystemId?: string;
|
||||
revisionId?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DesignSystemGenerationJobResponse {
|
||||
job: DesignSystemGenerationJob;
|
||||
}
|
||||
|
||||
export type DesignSystemPackageAuditSeverity = 'error' | 'warning';
|
||||
|
||||
export interface DesignSystemPackageAuditIssue {
|
||||
severity: DesignSystemPackageAuditSeverity;
|
||||
code: string;
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface DesignSystemPackageAudit {
|
||||
ok: boolean;
|
||||
projectPath: string;
|
||||
filesInspected: number;
|
||||
errors: DesignSystemPackageAuditIssue[];
|
||||
warnings: DesignSystemPackageAuditIssue[];
|
||||
}
|
||||
|
||||
export interface DesignSystemPackageAuditResponse {
|
||||
audit: DesignSystemPackageAudit;
|
||||
}
|
||||
|
||||
export interface DesignSystemRevisionJobRequest {
|
||||
feedback: string;
|
||||
sectionTitle?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface ImportLocalDesignSystemRequest {
|
||||
/** Absolute local project directory selected by the user. */
|
||||
baseDir: string;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ You are an expert designer working with the user as your manager. You produce de
|
|||
|
||||
Three hard rules govern the start of every new design task. They are not optional. The user is paying attention to *speed of feedback*; obeying these rules is what makes the agent feel responsive instead of stuck.
|
||||
|
||||
Active design system exception: if a later section in this same system prompt is titled \`## Active design system\`, the user has already selected the brand and visual direction. In that case:
|
||||
- Treat the active design system's palette, typography, spacing, and component rules as the visual direction.
|
||||
- Do not ask the user to pick a separate theme color, visual direction, palette, typography mood, or direction card.
|
||||
- Do not emit a direction question-form or any \`direction-cards\` question for this project.
|
||||
- In the turn-1 discovery form, drop brand/direction/theme-color questions unless the user explicitly asks to switch away from the active design system.
|
||||
- If an older discovery answer says \`brand: "Pick a direction for me"\`, ignore Branch A and proceed to RULE 3 using the active design system.
|
||||
|
||||
---
|
||||
|
||||
## RULE 1 — turn 1 must emit a \`<question-form id="discovery">\` (not tools, not thinking)
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export function formatElevenLabsVoiceOptionsErrorForPrompt(
|
|||
|
||||
export const SKIP_DISCOVERY_BRIEF_OVERRIDE = `# Automated project mode — skip discovery form
|
||||
|
||||
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Treat the user's first message and project metadata as the brief, then proceed directly to planning/building under the normal artifact workflow. Ask at most one concise follow-up only if a required detail is impossible to infer safely.`;
|
||||
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Do not call AskUserQuestion, do not emit any question form or choice card, and do not wait for user input. Treat the user's first message and project metadata as the brief, choose reasonable defaults for any missing details, then proceed directly to planning/building under the normal artifact workflow.`;
|
||||
|
||||
const ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE = `
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ describe('composeSystemPrompt — API mode (#313)', () => {
|
|||
|
||||
it('does not instruct agents to ask for a second visual-direction picker', () => {
|
||||
const prompt = composeSystemPrompt({});
|
||||
expect(prompt).toContain('Do not emit a direction question-form');
|
||||
expect(prompt).not.toContain('<question-form id="direction"');
|
||||
expect(prompt).not.toContain('Pick a visual direction');
|
||||
expect(prompt).toContain('if a design system is active and no new brand/reference source was provided, use it as the visual direction without asking again');
|
||||
|
|
@ -129,6 +130,8 @@ describe('composeSystemPrompt — API mode (#313)', () => {
|
|||
expect(skipIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(skipIdx).toBeLessThan(discoveryIdx);
|
||||
expect(prompt).toMatch(/do NOT emit `?<question-form id="discovery">`?/i);
|
||||
expect(prompt).toContain('Do not call AskUserQuestion');
|
||||
expect(prompt).toContain('choose reasonable defaults for any missing details');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
28
packages/contracts/tests/system-prompt.test.ts
Normal file
28
packages/contracts/tests/system-prompt.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { composeSystemPrompt } from '../src/prompts/system.js';
|
||||
|
||||
describe('composeSystemPrompt', () => {
|
||||
it('treats an active design system as the visual direction', () => {
|
||||
const prompt = composeSystemPrompt({
|
||||
designSystemTitle: 'ComfyUI',
|
||||
designSystemBody: '# ComfyUI\n\n--accent: #ffd500',
|
||||
metadata: { kind: 'prototype' } as any,
|
||||
activeStageBlocks: [
|
||||
'\n\n## Active stage: plan\n\n### direction-picker\n\nAsk for 3-5 directions.',
|
||||
],
|
||||
});
|
||||
|
||||
expect(prompt).toContain('## Active design system — ComfyUI');
|
||||
expect(prompt).toContain('Active design system exception');
|
||||
expect(prompt).toContain(
|
||||
'the active design system is the visual direction for this project',
|
||||
);
|
||||
expect(prompt).toContain('Do not ask the user to pick a separate theme color');
|
||||
expect(prompt).toContain('Do not emit a direction question-form');
|
||||
expect(prompt).not.toContain('<question-form id="direction"');
|
||||
expect(prompt.indexOf('## Active design system visual direction')).toBeGreaterThan(
|
||||
prompt.indexOf('### direction-picker'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { lstat, mkdir, open, rm, symlink, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import { lstat, mkdir, open, readdir, rm, symlink, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { cac } from "cac";
|
||||
|
|
@ -415,6 +415,7 @@ async function spawnDaemonRuntime(
|
|||
const logHandle = await openAppLog(config, APP_KEYS.DAEMON);
|
||||
|
||||
try {
|
||||
await ensureDaemonCliBuild(config, logHandle);
|
||||
await logHandle.write(`\n[tools-dev] launching daemon at ${new Date().toISOString()}\n`);
|
||||
if (webPort != null) await logHandle.write(`[tools-dev] trusting web origin port ${webPort}\n`);
|
||||
if (spawnOptions.requireDesktopAuth) {
|
||||
|
|
@ -494,6 +495,44 @@ async function buildDesktop(config: ToolDevConfig, logHandle: FileHandle): Promi
|
|||
});
|
||||
}
|
||||
|
||||
async function latestMtimeMs(filePath: string): Promise<number> {
|
||||
const entry = await lstat(filePath).catch(() => null);
|
||||
if (entry == null) return 0;
|
||||
if (!entry.isDirectory()) return entry.mtimeMs;
|
||||
|
||||
const children = await readdir(filePath, { withFileTypes: true }).catch(() => []);
|
||||
let latest = entry.mtimeMs;
|
||||
for (const child of children) {
|
||||
if (child.name === "node_modules" || child.name === "dist" || child.name === ".tmp") continue;
|
||||
latest = Math.max(latest, await latestMtimeMs(path.join(filePath, child.name)));
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function ensureDaemonCliBuild(config: ToolDevConfig, logHandle: FileHandle): Promise<void> {
|
||||
const daemonRoot = path.join(config.workspaceRoot, "apps/daemon");
|
||||
const distCliPath = path.join(daemonRoot, "dist/cli.js");
|
||||
const distMtime = await latestMtimeMs(distCliPath);
|
||||
const sourceMtime = Math.max(
|
||||
await latestMtimeMs(path.join(daemonRoot, "src")),
|
||||
await latestMtimeMs(path.join(daemonRoot, "package.json")),
|
||||
await latestMtimeMs(path.join(daemonRoot, "tsconfig.json")),
|
||||
);
|
||||
if (distMtime > 0 && distMtime >= sourceMtime) return;
|
||||
|
||||
const reason = distMtime > 0 ? "source is newer than apps/daemon/dist/cli.js" : "apps/daemon/dist/cli.js is missing";
|
||||
await logHandle.write(`\n[tools-dev] building @open-design/daemon because ${reason} at ${new Date().toISOString()}\n`);
|
||||
const invocation = createPackageManagerInvocation(["--filter", "@open-design/daemon", "build"], process.env);
|
||||
await runLoggedCommand({
|
||||
args: invocation.args,
|
||||
command: invocation.command,
|
||||
cwd: config.workspaceRoot,
|
||||
env: process.env,
|
||||
logFd: logHandle.fd,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureWebDevNodeModules(config: ToolDevConfig): Promise<void> {
|
||||
const webRuntimeRoot = path.dirname(config.apps.web.nextDistDir);
|
||||
const runtimeNodeModules = path.join(webRuntimeRoot, "node_modules");
|
||||
|
|
|
|||
Loading…
Reference in a new issue