diff --git a/apps/daemon/src/artifact-manifest.ts b/apps/daemon/src/artifact-manifest.ts index 00ef2c91c..ead9b55e0 100644 --- a/apps/daemon/src/artifact-manifest.ts +++ b/apps/daemon/src/artifact-manifest.ts @@ -132,6 +132,13 @@ export function validateArtifactManifestInput( } } + if (manifest.primary !== undefined) { + if (manifest.primary !== true) { + const primaryErr = validateSupportingPath(manifest.primary); + if (primaryErr) return { ok: false, error: `artifactManifest.primary ${primaryErr}` }; + } + } + if (manifest.supportingFiles !== undefined) { if (!Array.isArray(manifest.supportingFiles)) { return { ok: false, error: 'artifactManifest.supportingFiles must be an array' }; @@ -216,6 +223,12 @@ export function sanitizeManifest( renderer: manifest.renderer, status: typeof manifest.status === 'string' && ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete', exports: manifest.exports, + primary: + manifest.primary === true + ? true + : typeof manifest.primary === 'string' + ? manifest.primary.replace(/\\/g, '/') + : undefined, supportingFiles: Array.isArray(manifest.supportingFiles) ? manifest.supportingFiles.map((x) => String(x).replace(/\\/g, '/')) : undefined, diff --git a/apps/daemon/src/db.ts b/apps/daemon/src/db.ts index 42798e752..6c3245ad7 100644 --- a/apps/daemon/src/db.ts +++ b/apps/daemon/src/db.ts @@ -138,6 +138,12 @@ function migrate(db: SqliteDb): void { FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS tabs_state ( + project_id TEXT PRIMARY KEY, + updated_at INTEGER NOT NULL, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_tabs_project ON tabs(project_id, position); @@ -1537,15 +1543,24 @@ export function listTabs(db: SqliteDb, projectId: string) { FROM tabs WHERE project_id = ? ORDER BY position ASC`, ) .all(projectId) as DbRow[]; + const state = db + .prepare(`SELECT project_id FROM tabs_state WHERE project_id = ? LIMIT 1`) + .get(projectId) as DbRow | undefined; const active = (rows as DbRow[]).find((r: DbRow) => r.isActive) ?? null; return { tabs: (rows as DbRow[]).map((r: DbRow) => r.name), active: active ? active.name : null, + hasSavedState: rows.length > 0 || Boolean(state), }; } export function setTabs(db: SqliteDb, projectId: string, names: string[], activeName: string | null) { const tx = db.transaction(() => { + db.prepare( + `INSERT INTO tabs_state (project_id, updated_at) + VALUES (?, ?) + ON CONFLICT(project_id) DO UPDATE SET updated_at = excluded.updated_at`, + ).run(projectId, Date.now()); db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId); const ins = db.prepare( `INSERT INTO tabs (project_id, name, position, is_active) diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index e3224fb12..15f9c6c90 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -512,7 +512,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx } const project = getProject(db, req.params.id); - const splatParam = req.params.splat; + const splatParam = (req.params as { splat?: string | string[] }).splat; const relPath = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? ''); // PR #1312 round-5 (lefarcen P2): stat the owner file BEFORE diff --git a/apps/daemon/src/project-routes.ts b/apps/daemon/src/project-routes.ts index 0dadafcd6..dc7ba442f 100644 --- a/apps/daemon/src/project-routes.ts +++ b/apps/daemon/src/project-routes.ts @@ -872,7 +872,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile // Preflight for the raw file route. Current artifact fetches are simple GETs // (no preflight needed), but an explicit handler future-proofs the route if // artifacts ever add custom request headers. - app.options('/api/projects/:id/raw/*splat', (req, res) => { + app.options(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, (req, res) => { if (req.headers.origin === 'null') { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET'); @@ -881,11 +881,12 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile res.sendStatus(204); }); - app.get('/api/projects/:id/raw/*splat', async (req, res) => { + app.get(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => { try { - const splatParam = req.params.splat; - const relPath = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? ''); - const project = getProject(db, req.params.id); + const params = req.params as unknown as { 0?: string; 1?: string }; + const projectId = String(params[0] ?? ''); + const relPath = String(params[1] ?? ''); + const project = getProject(db, projectId); // PreviewModal loads artifact HTML via srcdoc, giving the iframe Origin: "null". // data: URIs, file://, and some sandboxed iframes also send null — all are // local-only callers, so this is safe. Real cross-origin sites send a real @@ -896,7 +897,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile const meta = await resolveProjectFilePath( PROJECTS_DIR, - req.params.id, + projectId, relPath, project?.metadata, ); @@ -945,7 +946,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile return; } - const file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath, project?.metadata); + const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata); res.type(file.mime).send(file.buffer); } catch (err: any) { const status = err && err.code === 'ENOENT' ? 404 : 400; @@ -958,12 +959,13 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile } }); - app.delete('/api/projects/:id/raw/*splat', async (req, res) => { + app.delete(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => { try { - const project = getProject(db, req.params.id); - const splatParam = req.params.splat; - const rawSplat = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? ''); - await deleteProjectFile(PROJECTS_DIR, req.params.id, rawSplat, project?.metadata); + const params = req.params as unknown as { 0?: string; 1?: string }; + const projectId = String(params[0] ?? ''); + const rawSplat = String(params[1] ?? ''); + const project = getProject(db, projectId); + await deleteProjectFile(PROJECTS_DIR, projectId, rawSplat, project?.metadata); /** @type {import('@open-design/contracts').DeleteProjectFileResponse} */ const body = { ok: true }; res.json(body); @@ -1005,14 +1007,15 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile } }); - app.get('/api/projects/:id/files/*splat', async (req, res) => { + app.get(/^\/api\/projects\/([^/]+)\/files\/(.+)$/u, async (req, res) => { try { - const project = getProject(db, req.params.id); - const splatParam = req.params.splat; - const fileSplat = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? ''); + const params = req.params as unknown as { 0?: string; 1?: string }; + const projectId = String(params[0] ?? ''); + const fileSplat = String(params[1] ?? ''); + const project = getProject(db, projectId); const file = await readProjectFile( PROJECTS_DIR, - req.params.id, + projectId, fileSplat, project?.metadata, ); diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 699629866..ae85ec79b 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -8844,7 +8844,7 @@ export async function startServer({ // Preflight for the raw file route. Current artifact fetches are simple GETs // (no preflight needed), but an explicit handler future-proofs the route if // artifacts ever add custom request headers. - app.options('/api/projects/:id/raw/*splat', (req, res) => { + app.options(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, (req, res) => { if (req.headers.origin === 'null') { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET'); @@ -8853,12 +8853,12 @@ export async function startServer({ res.sendStatus(204); }); - app.get('/api/projects/:id/raw/*splat', async (req, res) => { + app.get(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => { try { - const splatParam = req.params.splat; - const relPath = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? ''); - const project = getProject(db, req.params.id); - const file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath, project?.metadata); + const projectId = String(req.params[0] ?? ''); + const relPath = String(req.params[1] ?? ''); + const project = getProject(db, projectId); + const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata); // PreviewModal loads artifact HTML via srcdoc, giving the iframe Origin: "null". // data: URIs, file://, and some sandboxed iframes also send null — all are // local-only callers, so this is safe. Real cross-origin sites send a real @@ -8913,12 +8913,12 @@ export async function startServer({ } }); - app.delete('/api/projects/:id/raw/*splat', async (req, res) => { + app.delete(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => { try { - const project = getProject(db, req.params.id); - const splatParam = req.params.splat; - const rawSplat = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? ''); - await deleteProjectFile(PROJECTS_DIR, req.params.id, rawSplat, project?.metadata); + const projectId = String(req.params[0] ?? ''); + const rawSplat = String(req.params[1] ?? ''); + const project = getProject(db, projectId); + await deleteProjectFile(PROJECTS_DIR, projectId, rawSplat, project?.metadata); /** @type {import('@open-design/contracts').DeleteProjectFileResponse} */ const body = { ok: true }; res.json(body); @@ -8960,14 +8960,14 @@ export async function startServer({ } }); - app.get('/api/projects/:id/files/*splat', async (req, res) => { + app.get(/^\/api\/projects\/([^/]+)\/files\/(.+)$/u, async (req, res) => { try { - const project = getProject(db, req.params.id); - const splatParam = req.params.splat; - const fileSplat = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? ''); + const projectId = String(req.params[0] ?? ''); + const fileSplat = String(req.params[1] ?? ''); + const project = getProject(db, projectId); const file = await readProjectFile( PROJECTS_DIR, - req.params.id, + projectId, fileSplat, project?.metadata, ); diff --git a/apps/daemon/src/static-resource-routes.ts b/apps/daemon/src/static-resource-routes.ts index 87f9092b6..1c572b4bb 100644 --- a/apps/daemon/src/static-resource-routes.ts +++ b/apps/daemon/src/static-resource-routes.ts @@ -512,7 +512,7 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe if (!skill) { return res.status(404).type('text/plain').send('skill not found'); } - const splatParam = req.params.splat; + const splatParam = (req.params as { splat?: string | string[] }).splat; const relPath = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam || ''); const assetsRoot = path.resolve(skill.dir, 'assets'); const target = path.resolve(assetsRoot, relPath); diff --git a/apps/daemon/tests/projects-routes.test.ts b/apps/daemon/tests/projects-routes.test.ts index 9d8cc3095..35fd1c7fb 100644 --- a/apps/daemon/tests/projects-routes.test.ts +++ b/apps/daemon/tests/projects-routes.test.ts @@ -128,6 +128,37 @@ describe('GET /api/projects/:id resolvedDir', () => { expect(body.project.metadata?.skipDiscoveryBrief).toBe(true); }); + it('serves project files through raw and files path routes', async () => { + const projectId = `proj-raw-route-${Date.now()}`; + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Raw route fixture', + skillId: null, + designSystemId: null, + }), + }); + expect(createResp.status).toBe(200); + + const writeResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'index.html', content: '

ok

' }), + }); + expect(writeResp.status).toBe(200); + + const rawResp = await fetch(`${baseUrl}/api/projects/${projectId}/raw/index.html`); + expect(rawResp.status).toBe(200); + expect(rawResp.headers.get('content-type')).toContain('text/html'); + expect(await rawResp.text()).toContain('

ok

'); + + const fileResp = await fetch(`${baseUrl}/api/projects/${projectId}/files/index.html`); + expect(fileResp.status).toBe(200); + expect(await fileResp.text()).toContain('

ok

'); + }); + it('rejects non-boolean skipDiscoveryBrief on POST /api/projects', async () => { const projectId = `proj-skip-discovery-bad-${Date.now()}`; const resp = await fetch(`${baseUrl}/api/projects`, { diff --git a/apps/web/src/artifacts/manifest.ts b/apps/web/src/artifacts/manifest.ts index 2aa058959..6efdaa881 100644 --- a/apps/web/src/artifacts/manifest.ts +++ b/apps/web/src/artifacts/manifest.ts @@ -85,6 +85,7 @@ export function createHtmlArtifactManifest(input: { renderer: 'html', status: 'complete', exports: ['html', 'pdf', 'zip'], + primary: true, createdAt: now, updatedAt: now, sourceSkillId: input.sourceSkillId, @@ -124,6 +125,10 @@ export function parseArtifactManifest(raw: string): ArtifactManifest | null { ? (parsed.status as ArtifactStatus) : 'complete', exports: parsed.exports as ArtifactExportKind[], + primary: + parsed.primary === true || typeof parsed.primary === 'string' + ? parsed.primary + : undefined, supportingFiles: Array.isArray(parsed.supportingFiles) ? parsed.supportingFiles.filter((x): x is string => typeof x === 'string') : undefined, @@ -178,6 +183,7 @@ export function inferLegacyManifest(input: { renderer, status: 'complete', exports: exportsForKind(resolvedKind), + primary: resolvedKind === 'html' || resolvedKind === 'deck' ? true : undefined, metadata: input.metadata, }; } diff --git a/apps/web/src/artifacts/types.ts b/apps/web/src/artifacts/types.ts index 3aac63847..22b8bd9d9 100644 --- a/apps/web/src/artifacts/types.ts +++ b/apps/web/src/artifacts/types.ts @@ -42,6 +42,11 @@ export interface ArtifactManifest { // Frontend + daemon normalize missing status to "complete". status?: ArtifactStatus; exports: ArtifactExportKind[]; + /** + * Optional primary entry hint for multi-file outputs. When omitted, clients + * may fall back to renderable-file heuristics. + */ + primary?: string | boolean; /** * Reserved for future multi-file artifact packaging. * Current generators only persist a single entry file, so this is not yet populated. diff --git a/apps/web/src/comments.ts b/apps/web/src/comments.ts index 858696409..78f59d7b4 100644 --- a/apps/web/src/comments.ts +++ b/apps/web/src/comments.ts @@ -53,6 +53,27 @@ export interface VisualAnnotationAttachmentInput { target?: VisualAnnotationTarget | null; } +export function isInternalCommentTargetName(value: string | undefined | null): boolean { + const trimmed = String(value ?? '').trim(); + return /^path-\d+(?:-\d+)*$/.test(trimmed); +} + +export function commentTargetDisplayName( + target: { + elementId?: string | null; + label?: string | null; + selectionKind?: ChatCommentSelectionKind | PreviewCommentSelectionKind | null; + }, + fallback = 'Annotation', +): string { + if (target.selectionKind === 'visual') return 'Visual mark'; + const label = String(target.label ?? '').trim(); + if (label && !isInternalCommentTargetName(label)) return label; + const elementId = String(target.elementId ?? '').trim(); + if (elementId && !isInternalCommentTargetName(elementId)) return elementId; + return fallback; +} + export function targetFromSnapshot(snapshot: PreviewCommentSnapshot): PreviewCommentTarget { const podMembers = normalizeMembers(snapshot.podMembers); return { diff --git a/apps/web/src/components/AppChromeHeader.tsx b/apps/web/src/components/AppChromeHeader.tsx index 833e35f2d..79597851c 100644 --- a/apps/web/src/components/AppChromeHeader.tsx +++ b/apps/web/src/components/AppChromeHeader.tsx @@ -1,20 +1,23 @@ import type { ReactNode } from 'react'; import { useT } from '../i18n'; -import { Icon } from './Icon'; +import { RemixIcon } from './RemixIcon'; interface Props { actions?: ReactNode; children?: ReactNode; + fileActionsBefore?: ReactNode; onBack?: () => void; backLabel?: string; showTrafficSpace?: boolean; } export const APP_CHROME_FILE_ACTIONS_ID = 'app-chrome-file-actions'; +export const APP_CHROME_FILE_ACTIONS_SELECTOR = '[data-app-chrome-file-actions="true"]'; export function AppChromeHeader({ actions, children, + fileActionsBefore, onBack, backLabel, showTrafficSpace = true, @@ -33,12 +36,17 @@ export function AppChromeHeader({ title={resolvedBackLabel} aria-label={resolvedBackLabel} > - + ) : null} {children ?
{children}
: null}
-
+ {fileActionsBefore ?
{fileActionsBefore}
: null} +
{actions ?
{actions}
: null} ); @@ -61,7 +69,7 @@ export function SettingsIconButton({ title={title} aria-label={ariaLabel} > - + ); } diff --git a/apps/web/src/components/AvatarMenu.tsx b/apps/web/src/components/AvatarMenu.tsx index 1c6ef1a55..81ae81c23 100644 --- a/apps/web/src/components/AvatarMenu.tsx +++ b/apps/web/src/components/AvatarMenu.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../i18n'; import { AgentIcon } from './AgentIcon'; -import { Icon } from './Icon'; +import { RemixIcon } from './RemixIcon'; import { renderModelOptions } from './modelOptions'; import type { AgentInfo, AppConfig, ExecMode } from '../types'; import { apiProtocolLabel } from '../utils/apiProtocol'; @@ -41,6 +41,7 @@ export function AvatarMenu({ const t = useT(); const [open, setOpen] = useState(false); const wrapRef = useRef(null); + const triggerRef = useRef(null); useEffect(() => { if (!open) return; @@ -49,7 +50,10 @@ export function AvatarMenu({ if (!wrapRef.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false); + if (e.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } }; document.addEventListener('mousedown', onClick); document.addEventListener('keydown', onKey); @@ -82,18 +86,25 @@ export function AvatarMenu({ return (
{open ? ( -
+
{config.mode === 'daemon' @@ -111,8 +122,16 @@ export function AvatarMenu({ {config.mode === 'daemon' && installedAgents.length > 0 ? ( <>
{t('avatar.codeAgent')}
- {installedAgents.map((a) => ( - - ))} + {installedAgents.map((a) => { + const selected = config.agentId === a.id; + return ( + + ); + })} {currentAgent && currentAgent.available && ((currentAgent.models && currentAgent.models.length > 0) || @@ -245,7 +278,7 @@ export function AvatarMenu({ }} > - + {t('avatar.rescan')} @@ -263,7 +296,7 @@ export function AvatarMenu({ }} > - + {t('avatar.settings')} {isMacPlatform() ? '⌘,' : 'Ctrl+,'} @@ -278,7 +311,7 @@ export function AvatarMenu({ }} > - + {t('avatar.backToProjects')} diff --git a/apps/web/src/components/BoardComposerPopover.tsx b/apps/web/src/components/BoardComposerPopover.tsx index bb2bd3451..d65862807 100644 --- a/apps/web/src/components/BoardComposerPopover.tsx +++ b/apps/web/src/components/BoardComposerPopover.tsx @@ -1,6 +1,6 @@ -import { useId } from 'react'; +import type { CSSProperties } from 'react'; -import { selectionKindLabel, type PreviewCommentSnapshot } from '../comments'; +import type { PreviewCommentSnapshot } from '../comments'; import type { Dict } from '../i18n/types'; import type { PreviewComment, PreviewCommentMember } from '../types'; @@ -17,6 +17,111 @@ function summarizeMember(member: PreviewCommentMember): string { return member.label || member.elementId; } +function cssColorToHex(value: string | undefined): string | null { + if (!value) return null; + const raw = value.trim(); + if (!raw || raw === 'transparent' || raw === 'rgba(0, 0, 0, 0)') return null; + if (/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(raw)) { + if (raw.length === 4) { + return '#' + raw.slice(1).split('').map((char) => char + char).join('').toUpperCase(); + } + return raw.toUpperCase(); + } + const match = raw.match(/rgba?\(\s*([0-9.]+)[ ,]+([0-9.]+)[ ,]+([0-9.]+)/i); + if (!match) return raw; + const toHex = (part: string | undefined) => { + const value = Math.max(0, Math.min(255, Math.round(Number(part ?? 0)))); + return value.toString(16).padStart(2, '0').toUpperCase(); + }; + return `#${toHex(match[1])}${toHex(match[2])}${toHex(match[3])}`; +} + +function compactFontFamily(value: string | undefined): string | null { + if (!value) return null; + const first = value.split(',')[0]?.trim().replace(/^["']|["']$/g, ''); + return first || null; +} + +type AnnotationStyleRow = { label: string; value: string; swatch?: string }; + +function annotationStyleRows(target: PreviewCommentSnapshot): AnnotationStyleRow[] { + const rows: AnnotationStyleRow[] = []; + const width = Math.round(target.position.width); + const height = Math.round(target.position.height); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + rows.push({ label: 'Size', value: `${width}x${height}` }); + } + const color = cssColorToHex(target.style?.color); + if (color) rows.push({ label: 'Color', value: color, swatch: color }); + const background = cssColorToHex(target.style?.backgroundColor); + if (background) rows.push({ label: 'Bg', value: background, swatch: background }); + + const fontParts = [ + target.style?.fontSize, + target.style?.fontWeight && target.style.fontWeight !== '400' ? target.style.fontWeight : null, + compactFontFamily(target.style?.fontFamily), + ].filter((part): part is string => Boolean(part)); + if (fontParts.length > 0) { + rows.push({ label: 'Font', value: fontParts.join(' ') }); + } + if (target.style?.lineHeight) rows.push({ label: 'Line', value: target.style.lineHeight }); + return rows; +} + +function clampPopoverCoordinate(value: number, min: number): number { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.round(value)); +} + +function popoverAnchorStyle(target: PreviewCommentSnapshot, scale: number): CSSProperties { + const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; + const anchor = target.hoverPoint ?? { + x: target.position.x + Math.min(target.position.width, 24), + y: target.position.y + Math.min(target.position.height, 24), + }; + return { + left: clampPopoverCoordinate(anchor.x * safeScale + 14, 14), + top: clampPopoverCoordinate(anchor.y * safeScale + 14, 14), + }; +} + +export function AnnotationStyleSummary({ + target, + testId = 'annotation-style-summary', +}: { + target: PreviewCommentSnapshot; + testId?: string; +}) { + const rows = annotationStyleRows(target); + if (rows.length === 0) return null; + return ( +
+ {rows.map((row) => ( +
+ {row.label} + + {row.swatch ?