Consolidate chat comments preview on main (#2906)

* feat(web): queue chat sends

* feat(web): render code comment directives

* feat(web): add preview comments and manual edits

* fix(web): polish shared chrome controls

* fix(web): align queued send loading state

* feat(web): open primary project artifacts

* fix(web): keep queued sends and tests aligned

* fix(web): restore docked comment tools layout

* fix(web): align preview comment toolbar

* fix(web): place local cli beside handoff

* fix(web): move agent menu beside handoff

* fix(web): make project instructions a direct header action

* fix(web): compact handoff and toolbar labels

* fix(web): clarify handoff menu and annotation label

* fix(web): restore compact cursor handoff trigger

* fix(web): align agent menu trigger with handoff

* fix(web): add draw toolbar close action

* fix(web): move inspect editing into edit mode

* fix(web): avoid reserving comment sidebar in annotation mode

* fix(web): float preview comments panel

* fix(web): keep edit canvas full width

* fix(web): polish preview annotation tools

* fix(web): highlight active preview comments

* fix(web): open comments panel after annotation save

* fix(web): polish comment handoff controls

* fix(web): remove palette preview tool

* fix(web): simplify draw annotation toolbar

* fix(web): restore queued tasks into composer

* fix(web): restore queued send strip styling

* fix(web): hide internal comment target ids

* fix(web): align manual edit panel header

* test(web): cover visual interaction contracts

* fix(web): address PR feedback regressions

* fix(web): preserve artifact chrome state

* fix(daemon): restore project raw file routes

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: mrcfps <mrc@powerformer.com>
This commit is contained in:
chaoxiaoche 2026-05-26 18:31:19 +08:00 committed by GitHub
parent b5bf28060b
commit fce444bcab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 6367 additions and 1875 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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,
);

View file

@ -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,
);

View file

@ -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);

View file

@ -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: '<!doctype html><h1>ok</h1>' }),
});
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('<h1>ok</h1>');
const fileResp = await fetch(`${baseUrl}/api/projects/${projectId}/files/index.html`);
expect(fileResp.status).toBe(200);
expect(await fileResp.text()).toContain('<h1>ok</h1>');
});
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`, {

View file

@ -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,
};
}

View file

@ -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.

View file

@ -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 {

View file

@ -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}
>
<Icon name="arrow-left" size={15} />
<RemixIcon name="arrow-left-line" size={16} />
</button>
) : null}
{children ? <div className="app-chrome-content">{children}</div> : null}
<div className="app-chrome-drag" aria-hidden />
<div id={APP_CHROME_FILE_ACTIONS_ID} className="app-chrome-file-actions" />
{fileActionsBefore ? <div className="app-chrome-file-actions-before">{fileActionsBefore}</div> : null}
<div
id={APP_CHROME_FILE_ACTIONS_ID}
className="app-chrome-file-actions"
data-app-chrome-file-actions="true"
/>
{actions ? <div className="app-chrome-actions">{actions}</div> : null}
</header>
);
@ -61,7 +69,7 @@ export function SettingsIconButton({
title={title}
aria-label={ariaLabel}
>
<Icon name="settings" size={17} />
<RemixIcon name="settings-line" size={18} />
</button>
);
}

View file

@ -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<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(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 (
<div className="avatar-menu" ref={wrapRef}>
<button
ref={triggerRef}
type="button"
className="settings-icon-btn"
className="avatar-agent-trigger"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
data-tooltip={t('avatar.title')}
title={t('avatar.title')}
aria-label={t('avatar.title')}
>
<Icon name="settings" size={17} />
{currentAgent ? (
<AgentIcon id={currentAgent.id} size={20} />
) : (
<RemixIcon name="link" size={20} />
)}
<RemixIcon name="arrow-down-s-line" size={14} />
</button>
{open ? (
<div className="avatar-popover" role="menu">
<div className="avatar-popover" role="dialog" aria-label={t('avatar.title')}>
<div className="avatar-popover-head">
<span className="who">
{config.mode === 'daemon'
@ -111,8 +122,16 @@ export function AvatarMenu({
<button
type="button"
className="avatar-item"
className={`avatar-item${config.mode === 'daemon' ? ' active' : ''}`}
aria-current={config.mode === 'daemon' ? 'true' : undefined}
onClick={() => {
if (config.mode === 'daemon') {
setOpen(false);
if (!daemonLive) {
onOpenSettings();
}
return;
}
onModeChange('daemon');
if (!daemonLive) {
// No daemon — let user know via settings page rather than
@ -124,7 +143,7 @@ export function AvatarMenu({
disabled={!daemonLive && config.mode !== 'daemon'}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="file-code" size={14} />
<RemixIcon name="file-code-line" size={15} />
</span>
<span>{t('avatar.useLocal')}</span>
{config.mode === 'daemon' ? (
@ -132,46 +151,60 @@ export function AvatarMenu({
) : !daemonLive ? (
<span className="avatar-item-meta">{t('avatar.metaOffline')}</span>
) : null}
{config.mode === 'daemon' ? (
<RemixIcon name="check-line" size={14} className="avatar-item-check" />
) : null}
</button>
<button
type="button"
className="avatar-item"
className={`avatar-item${config.mode === 'api' ? ' active' : ''}`}
aria-current={config.mode === 'api' ? 'true' : undefined}
onClick={() => onModeChange('api')}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="link" size={14} />
<RemixIcon name="link" size={15} />
</span>
<span>{t('avatar.useApi')}</span>
{config.mode === 'api' ? (
<span className="avatar-item-meta">{t('avatar.metaActive')}</span>
) : null}
{config.mode === 'api' ? (
<RemixIcon name="check-line" size={14} className="avatar-item-check" />
) : null}
</button>
{config.mode === 'daemon' && installedAgents.length > 0 ? (
<>
<div className="avatar-section-label">{t('avatar.codeAgent')}</div>
{installedAgents.map((a) => (
<button
type="button"
key={a.id}
className="avatar-item"
onClick={() => {
onAgentChange(a.id);
// Keep the popover open so the user can immediately
// pick a model for the agent they just chose.
}}
>
<AgentIcon id={a.id} size={18} />
<span>{a.name}</span>
{config.agentId === a.id ? (
<span className="avatar-item-meta">
{t('avatar.metaSelected')}
</span>
) : a.version ? (
<span className="avatar-item-meta">{a.version}</span>
) : null}
</button>
))}
{installedAgents.map((a) => {
const selected = config.agentId === a.id;
return (
<button
type="button"
key={a.id}
className={`avatar-item${selected ? ' active' : ''}`}
aria-current={selected ? 'true' : undefined}
onClick={() => {
onAgentChange(a.id);
// Keep the popover open so the user can immediately
// pick a model for the agent they just chose.
}}
>
<AgentIcon id={a.id} size={18} />
<span>{a.name}</span>
{selected ? (
<span className="avatar-item-meta">
{t('avatar.metaSelected')}
</span>
) : a.version ? (
<span className="avatar-item-meta">{a.version}</span>
) : null}
{selected ? (
<RemixIcon name="check-line" size={14} className="avatar-item-check" />
) : null}
</button>
);
})}
{currentAgent &&
currentAgent.available &&
((currentAgent.models && currentAgent.models.length > 0) ||
@ -245,7 +278,7 @@ export function AvatarMenu({
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="reload" size={14} />
<RemixIcon name="refresh-line" size={15} />
</span>
<span>{t('avatar.rescan')}</span>
</button>
@ -263,7 +296,7 @@ export function AvatarMenu({
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="settings" size={14} />
<RemixIcon name="settings-line" size={15} />
</span>
<span>{t('avatar.settings')}</span>
<span className="avatar-item-meta">{isMacPlatform() ? '⌘,' : 'Ctrl+,'}</span>
@ -278,7 +311,7 @@ export function AvatarMenu({
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="arrow-left" size={14} />
<RemixIcon name="arrow-left-line" size={15} />
</span>
<span>{t('avatar.backToProjects')}</span>
</button>

View file

@ -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 (
<div className="annotation-style-summary" data-testid={testId}>
{rows.map((row) => (
<div key={row.label} className="annotation-style-row">
<span>{row.label}</span>
<strong title={row.value}>
{row.swatch ? <i aria-hidden="true" style={{ backgroundColor: row.swatch }} /> : null}
{row.value}
</strong>
</div>
))}
</div>
);
}
export function AnnotationHoverPopover({ target, scale }: { target: PreviewCommentSnapshot; scale: number }) {
return (
<div
className="comment-popover annotation-hover-popover"
data-testid="annotation-hover-popover"
role="tooltip"
style={popoverAnchorStyle(target, scale)}
>
<AnnotationStyleSummary target={target} testId="annotation-hover-style-summary" />
</div>
);
}
export function BoardComposerPopover({
target,
existing,
@ -28,11 +133,12 @@ export function BoardComposerPopover({
onClose,
onSaveComment,
onSendBatch,
onRemove,
onRemoveMember,
onHoverMember,
sending,
t,
scale = 1,
docked = false,
}: {
target: PreviewCommentSnapshot;
existing: PreviewComment | null;
@ -44,23 +150,23 @@ export function BoardComposerPopover({
onClose: () => void;
onSaveComment: () => void | Promise<void>;
onSendBatch: () => void | Promise<void>;
onRemove: (commentId: string) => void | Promise<void>;
onRemoveMember: (elementId: string) => void;
onHoverMember?: (elementId: string | null) => void;
sending: boolean;
t: TranslateFn;
scale?: number;
docked?: boolean;
}) {
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
const podMembers = target.podMembers ?? [];
const titleId = useId();
const isFreePin = target.elementId.startsWith('pin-');
return (
<div
className="comment-popover"
className={`comment-popover${docked ? ' comment-popover-docked' : ''}`}
data-testid="comment-popover"
role="dialog"
aria-modal="false"
aria-labelledby={titleId}
aria-label="Annotation"
style={docked ? undefined : popoverAnchorStyle(target, scale)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
@ -68,30 +174,6 @@ export function BoardComposerPopover({
}
}}
>
<div className="comment-popover-head">
<div title={target.elementId}>
{isFreePin ? (
<>
<strong id={titleId}>{t('chat.comments.pin')}</strong>
<span>{t('chat.comments.pinAtCoords', { x: target.position.x + 12, y: target.position.y + 12 })}</span>
</>
) : (
<>
<strong id={titleId}>{target.label || target.elementId}</strong>
<span>{selectionKindLabel(target.selectionKind, target.memberCount)}</span>
</>
)}
</div>
<button
type="button"
className="comment-popover-close"
onClick={onClose}
title={t('common.close')}
aria-label={t('common.close')}
>
<Icon name="close" size={12} />
</button>
</div>
{podMembers.length > 0 ? (
<div className="board-pod-summary">
<strong>{t('chat.comments.capturedItems', { n: target.memberCount || podMembers.length })}</strong>
@ -147,16 +229,17 @@ export function BoardComposerPopover({
onChange={(event) => onDraft(event.target.value)}
/>
<div className="comment-popover-actions">
{existing ? (
<div className="comment-popover-actions-start">
<button
type="button"
className="comment-popover-remove"
onClick={() => onRemove(existing.id)}
title={t('chat.comments.remove')}
className="comment-popover-close"
onClick={onClose}
title={t('common.close')}
aria-label={t('common.close')}
>
{t('chat.comments.remove')}
<Icon name="close" size={12} />
</button>
) : null}
</div>
<div className="comment-popover-actions-end">
{target.selectionKind === 'pod' ? (
<button

View file

@ -35,7 +35,7 @@ import type {
ResearchOptions,
RunContextSelection,
} from '@open-design/contracts';
import { buildVisualAnnotationAttachment } from '../comments';
import { buildVisualAnnotationAttachment, commentTargetDisplayName } from '../comments';
import { Icon } from "./Icon";
import { PluginDetailsModal } from "./PluginDetailsModal";
import { PluginsSection, type PluginsSectionHandle } from "./PluginsSection";
@ -161,6 +161,11 @@ interface Props {
// push text into the composer without owning its draft state.
export interface ChatComposerHandle {
setDraft: (text: string) => void;
restoreDraft: (draft: {
text: string;
attachments?: ChatAttachment[];
commentAttachments?: ChatCommentAttachment[];
}) => void;
focus: () => void;
}
@ -680,6 +685,25 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
ta.setSelectionRange(pos, pos);
});
},
restoreDraft: ({ text, attachments = [], commentAttachments = [] }) => {
setDraft(text);
setStaged(attachments);
setStagedVisualComments(commentAttachments);
setStagedSkills([]);
setStagedMcpServers([]);
setStagedConnectors([]);
setUploadError(null);
setMention(null);
setSlash(null);
seededRef.current = true;
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
const pos = text.length;
ta.setSelectionRange(pos, pos);
});
},
focus: () => {
textareaRef.current?.focus();
},
@ -1965,8 +1989,8 @@ function StagedCommentAttachments({
<div className="staged-row comment-staged-row" data-testid="staged-comment-attachments">
{visibleAttachments.map((a) => (
<div key={a.id} className="staged-chip staged-comment">
<span className="staged-name" title={`${a.screenshotPath ? `${a.screenshotPath}: ` : ''}${a.elementId}: ${a.comment}`}>
<strong>{a.selectionKind === 'visual' ? 'Visual mark' : a.elementId}</strong>
<span className="staged-name" title={`${a.screenshotPath ? `${a.screenshotPath}: ` : ''}${commentTargetDisplayName(a)}: ${a.comment}`}>
<strong>{commentTargetDisplayName(a)}</strong>
<span>{a.comment}</span>
</span>
<button

View file

@ -17,7 +17,7 @@ import { latestTodoWriteInputForPinnedCard } from '../runtime/todos';
import { TodoCard } from './ToolCard';
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, ChatMessageFeedbackChange, Conversation, DesignSystemSummary, PreviewComment, ProjectFile, ProjectMetadata, SkillSummary } from '../types';
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
import { commentsToAttachments, simplePositionLabel } from '../comments';
import { commentTargetDisplayName, commentsToAttachments, simplePositionLabel } from '../comments';
import { AssistantMessage } from './AssistantMessage';
import {
ChatComposer,
@ -222,7 +222,7 @@ interface Props {
hasActiveDesignSystem?: boolean;
activeDesignSystem?: DesignSystemSummary | null;
sendDisabled?: boolean;
queuedItems?: Array<{ id: string; prompt: string }>;
queuedItems?: QueuedSendItem[];
onRemoveQueuedSend?: (id: string) => void;
onUpdateQueuedSend?: (id: string, prompt: string) => void;
onSendQueuedNow?: (id: string) => void;
@ -323,6 +323,13 @@ interface Props {
type Tab = 'chat' | 'comments';
interface QueuedSendItem {
id: string;
prompt: string;
attachments?: ChatAttachment[];
commentAttachments?: ChatCommentAttachment[];
}
export function ChatPane({
messages,
streaming,
@ -1492,7 +1499,7 @@ function CommentSection({
data-testid={`comment-card-${comment.elementId}`}
>
<div className="comment-card-top">
<strong>{comment.elementId}</strong>
<strong>{commentTargetDisplayName(comment)}</strong>
<div className="comment-card-actions">
{secondaryActionLabel && onSecondaryAction ? (
<button
@ -1512,7 +1519,7 @@ function CommentSection({
<div className="comment-card-meta">
<span>{comment.id}</span>
<span>{comment.filePath}</span>
<span>{comment.label}</span>
<span>{commentTargetDisplayName(comment)}</span>
<span>{simplePositionLabel(comment.position)}</span>
</div>
</article>
@ -1757,8 +1764,8 @@ function UserMessage({
<div className="user-attachments comment-history-attachments">
{commentAttachments.filter((attachment) => attachment.selectionKind !== 'visual').map((a) => (
<span key={a.id} className="user-attachment staged-comment">
<span className="staged-name" title={`${a.elementId}: ${a.comment}`}>
<strong>{a.selectionKind === 'visual' ? 'Visual mark' : a.elementId}</strong>
<span className="staged-name" title={`${commentTargetDisplayName(a)}: ${a.comment}`}>
<strong>{commentTargetDisplayName(a)}</strong>
<span>{a.comment}</span>
</span>
</span>

View file

@ -1,12 +1,6 @@
// Per-editor icon for the hand-off menu. Renders a small rounded-square
// badge with a brand-tinted background and a distinctive glyph — mirrors
// the macOS dock affordance where each app has its own colored tile,
// rather than a single abstract folder/handoff glyph that hides which
// target the user is about to launch.
//
// Glyphs are stylized (Feather/Lucide-style) representations — not the
// official trademarked logos — so we keep visual identification without
// shipping brand assets we don't have a license for.
// Per-editor icon for the hand-off menu. Keep these close to the real app
// marks rather than generic pictograms; the menu is primarily a target picker,
// so users should recognize the destination at a glance.
import type { HostEditorId } from '@open-design/contracts';
@ -16,194 +10,121 @@ interface Props {
}
interface EditorVisual {
// Tile background — chosen to match the editor's primary brand color
// closely enough to read at a glance in the hand-off menu.
bg: string;
// Foreground stroke / glyph color. White on dark tiles, dark on light.
fg: string;
glyph: (size: number) => JSX.Element;
}
function angleBrackets(size: number) {
const s = size * 0.62;
function simplePath(path: string) {
return (size: number) => {
const s = size * 0.76;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={path} />
</svg>
);
};
}
function vscodeLogo(size: number) {
const s = size * 0.76;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
fill="currentColor"
aria-hidden="true"
>
<path d="m9 7-5 5 5 5" />
<path d="m15 7 5 5-5 5" />
<path d="M23.15 2.587 18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352Zm-5.146 14.861L10.826 12l7.178-5.448v10.896Z" />
</svg>
);
}
function cursorPointer(size: number) {
const s = size * 0.6;
function finderLogo(size: number) {
const s = size * 0.78;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor">
<path d="M5 3l5 15 3-6 6-3z" />
<svg width={s} height={s} viewBox="0 0 24 24" aria-hidden="true">
<rect width="24" height="24" rx="5.4" fill="#2f9bff" />
<path d="M12 0h6.6A5.4 5.4 0 0 1 24 5.4v13.2a5.4 5.4 0 0 1-5.4 5.4H12Z" fill="#77c2ff" />
<path d="M11.6 2.2c-1.4 2.4-2.1 5.5-2.1 9.8s.7 7.4 2.1 9.8" fill="none" stroke="#0b4f93" strokeWidth="1.2" strokeLinecap="round" />
<path d="M7.4 9.1h.1M16.4 9.1h.1" stroke="#0b4f93" strokeWidth="1.8" strokeLinecap="round" />
<path d="M7.4 15.2c2.9 1.6 6.3 1.6 9.2 0" fill="none" stroke="#0b4f93" strokeWidth="1.2" strokeLinecap="round" />
</svg>
);
}
function lightningZ(size: number) {
const s = size * 0.62;
function terminalLogo(size: number) {
const s = size * 0.76;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor">
<path d="M6 4h12L9 13h9l-13 7 5-9H4z" />
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="1.5" y="3" width="21" height="18" rx="3" fill="#111" />
<path d="m6.2 8 4 4-4 4" stroke="#9be37a" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12.8 16h5" stroke="#9be37a" strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
function wave(size: number) {
const s = size * 0.66;
function folderLogo(size: number) {
const s = size * 0.76;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
>
<path d="M3 12c2 -3 4 -3 6 0s4 3 6 0 4 -3 6 0" />
<path d="M3 17c2 -3 4 -3 6 0s4 3 6 0 4 -3 6 0" />
</svg>
);
}
function macFace(size: number) {
const s = size * 0.7;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" fill="currentColor" opacity="0.95" />
<circle cx="9" cy="10" r="1.2" fill="#fff" />
<circle cx="15" cy="10" r="1.2" fill="#fff" />
<svg width={s} height={s} viewBox="0 0 24 24" aria-hidden="true">
<path
d="M8.5 14.5c1 1 2.2 1.5 3.5 1.5s2.5 -.5 3.5 -1.5"
stroke="#fff"
strokeWidth={1.4}
strokeLinecap="round"
fill="none"
/>
</svg>
);
}
function terminalPrompt(size: number) {
const s = size * 0.62;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 4 3 -4 3" />
<path d="M12 17h6" />
</svg>
);
}
function warpTriangle(size: number) {
const s = size * 0.66;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4 22 20H2z" />
</svg>
);
}
function hammer(size: number) {
const s = size * 0.66;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 12-8.5 8.5a2.12 2.12 0 0 1-3-3L12 9" />
<path d="m17.64 15 3.36-3.36a2.83 2.83 0 0 0 0-4l-2.64-2.64a2.83 2.83 0 0 0-4 0L11 8.36" />
</svg>
);
}
function diamond(size: number) {
const s = size * 0.66;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3 22 12l-10 9L2 12z" />
</svg>
);
}
function orbit(size: number) {
const s = size * 0.7;
return (
<svg
width={s}
height={s}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
>
<circle cx="12" cy="12" r="3" fill="currentColor" />
<ellipse cx="12" cy="12" rx="9" ry="4" transform="rotate(-30 12 12)" />
</svg>
);
}
function letter(ch: string, size: number) {
const s = size * 0.7;
return (
<svg width={s} height={s} viewBox="0 0 24 24">
<text
x="12"
y="17"
textAnchor="middle"
fontSize="15"
fontWeight="800"
fontFamily="'Inter', system-ui, -apple-system, sans-serif"
d="M2.75 5.75A2.75 2.75 0 0 1 5.5 3h4.32c.74 0 1.43.36 1.86.96l1.1 1.54h5.72a2.75 2.75 0 0 1 2.75 2.75v9.5a2.75 2.75 0 0 1-2.75 2.75h-13A2.75 2.75 0 0 1 2.75 17.75z"
fill="currentColor"
>
{ch}
</text>
/>
<path d="M3.7 8.1h16.6" stroke="#ffffff" strokeWidth="1.25" strokeLinecap="round" opacity=".7" />
</svg>
);
}
function qoderLogo(size: number) {
const s = size * 0.76;
return (
<svg width={s} height={s} viewBox="0 0 24 24" aria-hidden="true">
<rect width="24" height="24" rx="5" fill="#ffb15e" />
<path d="M12 4 20 12l-8 8-8-8 8-8Z" fill="#667085" />
<path d="M12 8.2 15.8 12 12 15.8 8.2 12 12 8.2Z" fill="#1f2937" opacity=".2" />
</svg>
);
}
function antigravityLogo(size: number) {
const s = size * 0.78;
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect width="24" height="24" rx="5" fill="#f8fafd" />
<path d="M6.5 15.4c1.5 2.4 5.1 3.2 8 1.8 2.9-1.4 4.2-4.5 2.7-6.9" stroke="#4285f4" strokeWidth="2.1" strokeLinecap="round" />
<path d="M17.5 8.6c-1.5-2.4-5.1-3.2-8-1.8-2.9 1.4-4.2 4.5-2.7 6.9" stroke="#ea4335" strokeWidth="2.1" strokeLinecap="round" />
<path d="M8.2 8.3 12 12l3.8 3.7" stroke="#34a853" strokeWidth="2.1" strokeLinecap="round" />
<circle cx="12" cy="12" r="2" fill="#fbbc04" />
</svg>
);
}
const cursorPath = 'M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23';
const zedPath = 'M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z';
const windsurfPath = 'M23.55 5.067c-1.2038-.002-2.1806.973-2.1806 2.1765v4.8676c0 .972-.8035 1.7594-1.7597 1.7594-.568 0-1.1352-.286-1.4718-.7659l-4.9713-7.1003c-.4125-.5896-1.0837-.941-1.8103-.941-1.1334 0-2.1533.9635-2.1533 2.153v4.8957c0 .972-.7969 1.7594-1.7596 1.7594-.57 0-1.1363-.286-1.4728-.7658L.4076 5.1598C.2822 4.9798 0 5.0688 0 5.2882v4.2452c0 .2147.0656.4228.1884.599l5.4748 7.8183c.3234.462.8006.8052 1.3509.9298 1.3771.313 2.6446-.747 2.6446-2.0977v-4.893c0-.972.7875-1.7593 1.7596-1.7593h.003a1.798 1.798 0 0 1 1.4718.7658l4.9723 7.0994c.4135.5905 1.05.941 1.8093.941 1.1587 0 2.1515-.9645 2.1515-2.153v-4.8948c0-.972.7875-1.7594 1.7596-1.7594h.194a.22.22 0 0 0 .2204-.2202v-4.622a.22.22 0 0 0-.2203-.2203Z';
const xcodePath = 'M19.06 5.3327c.4517-.1936.7744-.2581 1.097-.1936.5163.1291.7744.5163.968.7098.1936.3872.9034.7744 1.2261.8389.2581.0645.7098-.6453 1.0325-1.2906.3227-.5808.5163-1.3552.4517-1.5488-.0645-.1936-.968-.5808-1.1616-.5808-.1291 0-.3872.1291-.8389.0645-.4517-.0645-.9034-.5808-1.1616-.968-.4517-.6453-1.097-1.0325-1.6778-1.3552-.6453-.3227-1.3552-.5163-2.065-.6453-1.0325-.2581-2.065-.4517-3.0975-.3227-.5808.0645-1.2906.1291-1.8069.3227-.0645 0-.1936.1936-.0645.1936s.5808.0645.5808.0645-.5807.1292-.5807.2583c0 .1291.0645.1291.1291.1291.0645 0 1.4842-.0645 2.065 0 .6453.1291 1.3552.4517 1.8069 1.2261.7744 1.4197.4517 2.7749.2581 3.2266-.968 2.1295-8.6472 15.2294-9.0344 16.1328-.3873.9034-.5163 1.4842.5807 2.065s1.6778.3227 2.0005-.0645c.3872-.5163 7.0339-17.1654 9.2925-18.2624zm-3.6138 8.7117h1.5488c1.0325 0 1.2261.5163 1.2261.7098.0645.5163-.1936 1.1616-1.2261 1.1616h-.968l.7744 1.2906c.4517.7744.2581 1.1616 0 1.4197-.3872.3872-1.2261.3872-1.6778-.4517l-.9034-1.5488c-.6453 1.4197-1.2906 2.9684-2.065 4.7753h4.0009c1.9359 0 3.5492-1.6133 3.5492-3.5492V6.5588c-.0645-.1291-.1936-.0645-.2581 0-.3872.4517-1.4842 2.0004-4.001 7.4856zm-9.8087 8.0019h-.3227c-2.3231 0-4.1945-1.8714-4.1945-4.1945V7.0105c0-2.3231 1.8714-4.1945 4.1945-4.1945h9.3571c-.1936-.1936-.968-.5163-1.7423-.4517-.3227 0-.968.1291-1.3552-.1291-.3872-.3227-.3227-.5163-.9034-.5163H4.9277c-2.6458 0-4.7753 2.1295-4.7753 4.7753v11.7447c0 2.6458 2.1295 4.7753 4.4527 4.7108.6452 0 .8388-.5162 1.0324-.9034zM20.4152 6.9459v10.9058c0 2.3231-1.8714 4.1945-4.1945 4.1945H11.897s-.3872 1.0325.8389 1.0325h3.8719c2.6458 0 4.7753-2.1295 4.7753-4.7753V8.8173c.0646-.9034-.7098-1.4842-.9679-1.8714zm-18.5851.0646v10.8413c0 1.9359 1.6133 3.5492 3.5492 3.5492h.5808c0-.0645.7744-1.4197 2.4522-4.2591.1936-.3872.4517-.7744.7098-1.2261H4.4114c-.5808 0-.9034-.3872-.968-.7098-.1291-.5163.1936-1.1616.9034-1.1616h2.3877l3.033-5.2916s-.7098-1.2906-.9034-1.6133c-.2582-.4517-.1291-.9034.129-1.1615.3872-.3872 1.0325-.5808 1.6778.4517l.2581.3872.2581-.3872c.5808-.8389.968-.7744 1.2906-.7098.5163.1291.8389.7098.3872 1.6133L8.864 14.0444h1.3552c.4517-.7744.9034-1.5488 1.3552-2.3877-.0645-.3227-.1291-.7098-.0645-1.0325.0645-.5163.3227-.968.6453-1.3552l.3872.6453c1.2261-2.1295 2.1295-3.9364 2.3877-4.6463.1291-.3872.3227-1.1616.1291-1.8069H5.3794c-2.0005.0001-3.5493 1.6134-3.5493 3.5494zM4.605 17.7872c0-.0645.7744-1.4197.7744-1.4197 1.2261-.3227 1.8069.4517 1.8714.5163 0 0-.8389 1.4842-1.097 1.7423s-.5808.3227-.9034.2581c-.5164-.129-.839-.6453-.6454-1.097z';
const webstormPath = 'M0 0v24h24V0H0zm17.889 2.889c1.444 0 2.667.444 3.667 1.278l-1.111 1.667c-.889-.611-1.722-1-2.556-1s-1.278.389-1.278.889v.056c0 .667.444.889 2.111 1.333 2 .556 3.111 1.278 3.111 3v.056c0 2-1.5 3.111-3.611 3.111-1.5-.056-3-.611-4.167-1.667l1.278-1.556c.889.722 1.833 1.222 2.944 1.222.889 0 1.389-.333 1.389-.944v-.056c0-.556-.333-.833-2-1.278-2-.5-3.222-1.056-3.222-3.056v-.056c0-1.833 1.444-3 3.444-3zm-16.111.222h2.278l1.5 5.778 1.722-5.778h1.667l1.667 5.778 1.5-5.778h2.333l-2.833 9.944H9.723L8.112 7.277l-1.667 5.778H4.612L1.779 3.111zm.5 16.389h9V21h-9v-1.5z';
const ideaPath = 'M0 0v24h24V0zm3.723 3.111h5v1.834h-1.39v6.277h1.39v1.834h-5v-1.834h1.444V4.945H3.723zm11.055 0H17v6.5c0 .612-.055 1.111-.222 1.556-.167.444-.39.777-.723 1.11-.277.279-.666.557-1.11.668a3.933 3.933 0 0 1-1.445.278c-.778 0-1.444-.167-1.944-.445a4.81 4.81 0 0 1-1.279-1.056l1.39-1.555c.277.334.555.555.833.722.277.167.611.278.945.278.389 0 .721-.111 1-.389.221-.278.333-.667.333-1.278zM2.222 19.5h9V21h-9z';
const warpPath = 'M12.035 2.723h9.253A2.712 2.712 0 0 1 24 5.435v10.529a2.712 2.712 0 0 1-2.712 2.713H8.047Zm-1.681 2.6L6.766 19.677h5.598l-.399 1.6H2.712A2.712 2.712 0 0 1 0 18.565V8.036a2.712 2.712 0 0 1 2.712-2.712Z';
const EDITORS: Record<string, EditorVisual> = {
vscode: { bg: '#0078d4', fg: '#ffffff', glyph: angleBrackets },
cursor: { bg: '#0a0a0a', fg: '#ffffff', glyph: cursorPointer },
windsurf: { bg: '#0c8a55', fg: '#ffffff', glyph: wave },
zed: { bg: '#1a1a1a', fg: '#d0d0d0', glyph: lightningZ },
qoder: { bg: '#f5a623', fg: '#1a1a1a', glyph: diamond },
antigravity: { bg: '#7c4dff', fg: '#ffffff', glyph: orbit },
webstorm: { bg: '#f97316', fg: '#ffffff', glyph: (s) => letter('W', s) },
idea: { bg: '#e91e63', fg: '#ffffff', glyph: (s) => letter('I', s) },
xcode: { bg: '#1d76d6', fg: '#ffffff', glyph: hammer },
finder: { bg: '#3097f6', fg: '#ffffff', glyph: macFace },
explorer: { bg: '#fbbf24', fg: '#1a1a1a', glyph: (s) => letter('E', s) },
'file-manager': { bg: '#6b7280', fg: '#ffffff', glyph: (s) => letter('F', s) },
terminal: { bg: '#111111', fg: '#9be37a', glyph: terminalPrompt },
warp: { bg: '#ff5c1c', fg: '#ffffff', glyph: warpTriangle },
vscode: { bg: '#007ACC', fg: '#ffffff', glyph: vscodeLogo },
cursor: { bg: '#0a0a0a', fg: '#ffffff', glyph: simplePath(cursorPath) },
windsurf: { bg: '#f7fffb', fg: '#0b100f', glyph: simplePath(windsurfPath) },
zed: { bg: '#1348DC', fg: '#ffffff', glyph: simplePath(zedPath) },
qoder: { bg: '#ffb15e', fg: '#1f2937', glyph: qoderLogo },
antigravity: { bg: '#ffffff', fg: '#1f2937', glyph: antigravityLogo },
webstorm: { bg: '#000000', fg: '#ffffff', glyph: simplePath(webstormPath) },
idea: { bg: '#000000', fg: '#ffffff', glyph: simplePath(ideaPath) },
xcode: { bg: '#147EFB', fg: '#ffffff', glyph: simplePath(xcodePath) },
finder: { bg: '#3097f6', fg: '#ffffff', glyph: finderLogo },
explorer: { bg: '#fbbf24', fg: '#1a1a1a', glyph: folderLogo },
'file-manager': { bg: '#6b7280', fg: '#ffffff', glyph: folderLogo },
terminal: { bg: '#111111', fg: '#9be37a', glyph: terminalLogo },
warp: { bg: '#01A4FF', fg: '#ffffff', glyph: simplePath(warpPath) },
};
export function EditorIcon({ editorId, size = 16 }: Props) {

File diff suppressed because it is too large Load diff

View file

@ -106,6 +106,8 @@ interface Props {
onUseDesignSystem?: (id: string, title: string) => void;
onConnectRepo?: () => void;
githubConnected?: boolean;
commentPortalId?: string;
onCommentModeChange?: (active: boolean) => void;
}
interface SketchState {
@ -222,6 +224,8 @@ export function FileWorkspace({
onUseDesignSystem,
onConnectRepo,
githubConnected,
commentPortalId,
onCommentModeChange,
}: Props) {
const t = useT();
const analytics = useAnalytics();
@ -1065,6 +1069,8 @@ export function FileWorkspace({
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
onFileSaved={onRefreshFiles}
onOpenFileReplacing={openFileReplacing}
commentPortalId={commentPortalId}
onCommentModeChange={onCommentModeChange}
/>
) : (
<div className="viewer-empty">

View file

@ -93,6 +93,9 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
const preferred = readPreferred();
const primary =
available.find((e) => e.id === preferred) ?? available[0] ?? null;
const primaryTitle = primary
? t('handoff.openInTarget', { target: primary.label })
: t('handoff.action');
async function launch(editor: HostEditor) {
if (!editor.available) {
@ -161,7 +164,8 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
type="button"
className="handoff-trigger"
data-testid="handoff-trigger"
title={primary ? t('handoff.toTarget', { target: primary.label }) : t('handoff.action')}
title={primaryTitle}
aria-label={primaryTitle}
onClick={() => {
if (primary && busy !== primary.id) {
void launch(primary);
@ -174,14 +178,14 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
{primary ? (
<>
<EditorIcon editorId={primary.id} size={20} />
<span className="handoff-trigger-label">
{t('handoff.toTarget', { target: primary.label })}
<span className="handoff-trigger-label sr-only">
{primaryTitle}
</span>
</>
) : (
<>
<EditorIcon editorId="finder" size={20} />
<span className="handoff-trigger-label">{t('handoff.action')}</span>
<span className="handoff-trigger-label sr-only">{primaryTitle}</span>
</>
)}
</button>
@ -198,6 +202,7 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
</div>
{open ? (
<div className="handoff-menu" role="menu" data-testid="handoff-menu">
<div className="handoff-menu-title">{t('handoff.menuTitle')}</div>
{available.map((editor) => (
<button
key={editor.id}

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../i18n';
import { emptyManualEditStyles, type ManualEditHistoryEntry, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../edit-mode/types';
import { Icon } from './Icon';
export interface ManualEditDraft {
text: string;
@ -22,20 +23,18 @@ export function emptyManualEditDraft(source = ''): ManualEditDraft {
}
export function ManualEditPanel({
targets,
selectedTarget,
draft,
error,
canUndo,
onSelectTarget,
onDraftChange,
onStyleChange,
onInvalidStyle,
onApplyPatch,
onError,
onClearSelection,
onExit,
onApplyPatch,
onPickImage,
busy = false,
pageStylesEnabled = true,
}: {
targets: ManualEditTarget[];
@ -55,6 +54,7 @@ export function ManualEditPanel({
onPickImage?: (file: File) => Promise<string | null>;
onError: (message: string) => void;
onClearSelection: () => void;
onExit?: () => void;
onCancelDraft: () => void;
onUndo: () => void;
onRedo: () => void;
@ -68,10 +68,6 @@ export function ManualEditPanel({
useEffect(() => {
selectedTargetRef.current = selectedTarget;
}, [selectedTarget]);
const [activeTab, setActiveTab] = useState<ManualEditTab>('style');
const tab = targetForInspector
? (activeTab === 'page' ? 'style' : activeTab)
: (activeTab === 'source' ? 'source' : 'page');
const changeTargetStyle = (key: keyof ManualEditStyles, value: string) => {
const nextStyles = { ...draft.styles, [key]: value };
@ -88,160 +84,46 @@ export function ManualEditPanel({
onError('');
onStyleChange?.(targetForInspector.id, normalized.styles, `Style: ${targetForInspector.label}`);
};
const applyAttributes = () => {
if (!targetForInspector) return;
try {
const parsed = JSON.parse(draft.attributesText) as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error();
const attributes = Object.fromEntries(
Object.entries(parsed as Record<string, unknown>).map(([key, value]) => [key, String(value)]),
);
onError('');
onApplyPatch({ id: targetForInspector.id, kind: 'set-attributes', attributes }, `Attributes: ${targetForInspector.label}`);
} catch {
onError('Invalid attributes JSON.');
}
};
const applyContent = () => {
if (!targetForInspector) return;
if (targetForInspector.kind === 'link') {
onApplyPatch({ id: targetForInspector.id, kind: 'set-link', text: draft.text, href: draft.href }, `Content: ${targetForInspector.label}`);
return;
}
if (targetForInspector.kind === 'image') {
onApplyPatch({ id: targetForInspector.id, kind: 'set-image', src: draft.src, alt: draft.alt }, `Content: ${targetForInspector.label}`);
return;
}
onApplyPatch({ id: targetForInspector.id, kind: 'set-text', value: draft.text }, `Content: ${targetForInspector.label}`);
};
return (
<>
<section className="manual-edit-layers">
<div className="manual-edit-panel-head">
<h3>{t('manualEdit.layers')}</h3>
<span>{targets.length}</span>
</div>
<div className="manual-edit-layer-list">
{targets.length > 0 ? targets.map((target) => (
<aside className="manual-edit-right">
<section className="manual-edit-modal cc-panel">
<div className="manual-edit-titlebar">
<span>Edit</span>
{onExit ? (
<button
key={target.id}
type="button"
className={`manual-edit-layer-row${selectedTarget?.id === target.id ? ' selected' : ''}`}
onClick={() => onSelectTarget(target)}
className="manual-edit-titlebar-close"
aria-label="Close edit panel"
title="Close edit panel"
onClick={onExit}
>
<strong>{target.label}</strong>
<span>
{target.tagName}
{target.isHidden ? ` - ${t('manualEdit.hiddenBadge')}` : ''}
</span>
<Icon name="close" size={16} />
</button>
)) : (
<p className="manual-edit-empty">{t('manualEdit.noEditableLayers')}</p>
)}
</div>
</section>
<aside className="manual-edit-right">
<section className="manual-edit-modal cc-panel">
<div className="manual-edit-tabs" role="tablist" aria-label="Manual edit tabs">
{(targetForInspector ? ELEMENT_TABS : PAGE_TABS).map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={tab === item.id}
className={tab === item.id ? 'selected' : ''}
onClick={() => setActiveTab(item.id)}
>
{item.label}
</button>
))}
</div>
{targetForInspector ? (
<>
{tab === 'content' ? (
<ContentEditor
target={targetForInspector}
draft={draft}
busy={busy}
onDraftChange={onDraftChange}
onApply={applyContent}
/>
) : null}
{tab === 'style' ? (
<StyleInspector
styles={draft.styles}
layoutEnabled={targetForInspector.isLayoutContainer}
onClearSelection={onClearSelection}
onChange={changeTargetStyle}
/>
) : null}
{tab === 'attributes' ? (
<div className="manual-edit-tab-body">
<label className="manual-edit-field">
<span>Attributes JSON</span>
<textarea
className="manual-edit-code"
value={draft.attributesText}
onChange={(event) => onDraftChange({ ...draft, attributesText: event.currentTarget.value })}
/>
</label>
<button type="button" className="btn btn-primary" disabled={busy} onClick={applyAttributes}>Apply Attributes</button>
</div>
) : null}
{tab === 'html' ? (
<div className="manual-edit-tab-body">
<label className="manual-edit-field">
<span>Selected element HTML</span>
<textarea
className="manual-edit-code tall"
value={draft.outerHtml}
onChange={(event) => onDraftChange({ ...draft, outerHtml: event.currentTarget.value })}
/>
</label>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => onApplyPatch({ id: targetForInspector.id, kind: 'set-outer-html', html: draft.outerHtml }, `HTML: ${targetForInspector.label}`)}
>
Apply HTML
</button>
</div>
) : null}
{tab === 'source' ? (
<SourceEditor
draft={draft}
busy={busy}
onDraftChange={onDraftChange}
onApply={() => onApplyPatch({ kind: 'set-full-source', source: draft.fullSource }, 'Full source')}
/>
) : null}
</>
) : !targetForInspector ? (
tab === 'source' ? (
<SourceEditor
draft={draft}
busy={busy}
onDraftChange={onDraftChange}
onApply={() => onApplyPatch({ kind: 'set-full-source', source: draft.fullSource }, 'Full source')}
/>
) : (
<PageInspector
enabled={pageStylesEnabled}
onStyleChange={(styles) => {
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
if (!normalized.ok) {
onError(normalized.error);
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
return;
}
onError('');
onStyleChange?.('__body__', normalized.styles, 'Page styles');
}}
/>
)
) : null}
</div>
{targetForInspector ? (
<StyleInspector
styles={draft.styles}
layoutEnabled={targetForInspector.isLayoutContainer}
onClearSelection={onClearSelection}
onChange={changeTargetStyle}
/>
) : !targetForInspector ? (
<PageInspector
enabled={pageStylesEnabled}
onStyleChange={(styles) => {
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
if (!normalized.ok) {
onError(normalized.error);
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
return;
}
onError('');
onStyleChange?.('__body__', normalized.styles, 'Page styles');
}}
/>
) : null}
{targetForInspector?.kind === 'image' && onPickImage ? (
<div className="cc-section">
@ -326,93 +208,8 @@ export function ManualEditPanel({
) : null}
{error ? <div className="manual-edit-error">{error}</div> : null}
</section>
</aside>
</>
);
}
type ManualEditTab = 'page' | 'content' | 'style' | 'attributes' | 'html' | 'source';
const ELEMENT_TABS: ReadonlyArray<{ id: ManualEditTab; label: string }> = [
{ id: 'content', label: 'Content' },
{ id: 'style', label: 'Style' },
{ id: 'attributes', label: 'Attributes' },
{ id: 'html', label: 'HTML' },
{ id: 'source', label: 'Source' },
];
const PAGE_TABS: ReadonlyArray<{ id: ManualEditTab; label: string }> = [
{ id: 'page', label: 'Page' },
{ id: 'source', label: 'Source' },
];
function ContentEditor({
target,
draft,
busy,
onDraftChange,
onApply,
}: {
target: ManualEditTarget;
draft: ManualEditDraft;
busy: boolean;
onDraftChange: (draft: ManualEditDraft) => void;
onApply: () => void;
}) {
return (
<div className="manual-edit-tab-body">
{target.kind === 'image' ? (
<>
<label className="manual-edit-field">
<span>Image source</span>
<input value={draft.src} onChange={(event) => onDraftChange({ ...draft, src: event.currentTarget.value })} />
</label>
<label className="manual-edit-field">
<span>Alt text</span>
<input value={draft.alt} onChange={(event) => onDraftChange({ ...draft, alt: event.currentTarget.value })} />
</label>
</>
) : (
<label className="manual-edit-field">
<span>Text</span>
<textarea value={draft.text} onChange={(event) => onDraftChange({ ...draft, text: event.currentTarget.value })} />
</label>
)}
{target.kind === 'link' ? (
<label className="manual-edit-field">
<span>Href</span>
<input value={draft.href} onChange={(event) => onDraftChange({ ...draft, href: event.currentTarget.value })} />
</label>
) : null}
<button type="button" className="btn btn-primary" disabled={busy} onClick={onApply}>Apply Content</button>
</div>
);
}
function SourceEditor({
draft,
busy,
onDraftChange,
onApply,
}: {
draft: ManualEditDraft;
busy: boolean;
onDraftChange: (draft: ManualEditDraft) => void;
onApply: () => void;
}) {
return (
<div className="manual-edit-tab-body">
<label className="manual-edit-field">
<span>Full artifact source</span>
<textarea
className="manual-edit-code tall"
value={draft.fullSource}
onChange={(event) => onDraftChange({ ...draft, fullSource: event.currentTarget.value })}
/>
</label>
<button type="button" className="btn btn-primary" disabled={busy} onClick={onApply}>Apply Source</button>
</div>
</section>
</aside>
);
}

View file

@ -1,12 +1,11 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type WheelEvent } from 'react';
import { Icon } from './Icon';
import { RemixIcon } from './RemixIcon';
import type { PreviewVisualMarkKind } from '../types';
import { requestPreviewSnapshot } from '../runtime/exports';
import { isImeComposing } from '../utils/imeComposing';
export type PreviewDrawMode = 'click' | 'draw';
interface Point { x: number; y: number }
interface Stroke { points: Point[] }
interface CaptureTarget {
@ -34,8 +33,8 @@ export interface AnnotationEventDetail {
interface Props {
children: ReactNode;
active?: boolean;
captureViewport?: boolean;
onActiveChange?: (active: boolean) => void;
onModeChange?: (mode: PreviewDrawMode) => void;
captureTarget?: CaptureTarget | null;
filePath?: string;
sendDisabled?: boolean;
@ -44,14 +43,13 @@ interface Props {
const STROKE_COLOR = '#ff3b30';
const STROKE_WIDTH = 4;
const ACTIVE_BUTTON_COLOR = 'var(--accent)';
const TARGET_COLOR = '#1677ff';
export function PreviewDrawOverlay({
children,
active = false,
captureViewport = false,
onActiveChange,
onModeChange,
captureTarget = null,
filePath,
sendDisabled = false,
@ -59,24 +57,17 @@ export function PreviewDrawOverlay({
}: Props) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [mode, setMode] = useState<PreviewDrawMode>('click');
const [note, setNote] = useState('');
const strokesRef = useRef<Stroke[]>([]);
const undoneStrokesRef = useRef<Stroke[]>([]);
const drawingRef = useRef<Stroke | null>(null);
const composingRef = useRef(false);
const [hasInk, setHasInk] = useState(false);
const [undoCount, setUndoCount] = useState(0);
const [redoCount, setRedoCount] = useState(0);
const [pendingAction, setPendingAction] = useState<'queue' | 'send' | null>(null);
const sending = pendingAction !== null;
useEffect(() => {
if (active) setMode('draw');
else setMode('click');
}, [active]);
useEffect(() => {
onModeChange?.(mode);
}, [mode, onModeChange]);
const redraw = useCallback(() => {
const cvs = canvasRef.current;
if (!cvs) return;
@ -121,18 +112,29 @@ export function PreviewDrawOverlay({
const ro = new ResizeObserver(resize);
ro.observe(wrap);
return () => ro.disconnect();
}, [redraw, mode, hasInk]);
}, [redraw, active, hasInk]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
setMode('click');
onActiveChange?.(false);
return;
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) redoStroke();
else undoStroke();
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onActiveChange]);
}, [onActiveChange, sending]);
function syncHistoryState() {
setHasInk(strokesRef.current.length > 0);
setUndoCount(strokesRef.current.length);
setRedoCount(undoneStrokesRef.current.length);
}
function pointFromEvent(e: PointerEvent): Point {
const cvs = canvasRef.current!;
@ -148,28 +150,29 @@ export function PreviewDrawOverlay({
}
function onPointerDown(e: PointerEvent) {
if (mode !== 'draw' || sending) return;
if (!active || sending) return;
(e.target as Element).setPointerCapture?.(e.pointerId);
drawingRef.current = { points: [pointFromEvent(e)] };
redraw();
}
function onPointerMove(e: PointerEvent) {
if (mode !== 'draw' || sending || !drawingRef.current) return;
if (!active || sending || !drawingRef.current) return;
drawingRef.current.points.push(pointFromEvent(e));
redraw();
}
function onPointerUp() {
if (mode !== 'draw' || sending || !drawingRef.current) return;
if (!active || sending || !drawingRef.current) return;
if (drawingRef.current.points.length > 1) {
strokesRef.current.push(drawingRef.current);
setHasInk(true);
undoneStrokesRef.current = [];
syncHistoryState();
}
drawingRef.current = null;
redraw();
}
function onCanvasWheel(e: WheelEvent<HTMLCanvasElement>) {
if (mode !== 'draw' || sending) return;
if (!active || sending) return;
const iframe = activePreviewIframe();
const win = iframe?.contentWindow;
if (!win || typeof win.scrollBy !== 'function') return;
@ -179,16 +182,42 @@ export function PreviewDrawOverlay({
function clearInk() {
strokesRef.current = [];
undoneStrokesRef.current = [];
drawingRef.current = null;
setHasInk(false);
syncHistoryState();
redraw();
}
function undoStroke() {
if (sending) return;
const stroke = strokesRef.current.pop();
if (!stroke) return;
undoneStrokesRef.current.push(stroke);
drawingRef.current = null;
syncHistoryState();
redraw();
}
function redoStroke() {
if (sending) return;
const stroke = undoneStrokesRef.current.pop();
if (!stroke) return;
strokesRef.current.push(stroke);
drawingRef.current = null;
syncHistoryState();
redraw();
}
function closeOverlay() {
onActiveChange?.(false);
}
useEffect(() => {
if (active) return;
strokesRef.current = [];
undoneStrokesRef.current = [];
drawingRef.current = null;
setHasInk(false);
syncHistoryState();
redraw();
}, [active, redraw]);
@ -318,7 +347,7 @@ export function PreviewDrawOverlay({
async function send(action: 'queue' | 'send') {
const hasTarget = Boolean(captureTarget);
const shouldCapture = hasInk || hasTarget;
const shouldCapture = hasInk || hasTarget || captureViewport;
const canSubmit = shouldCapture || Boolean(note.trim());
if (sending || !canSubmit) return;
setPendingAction(action);
@ -368,10 +397,12 @@ export function PreviewDrawOverlay({
}
}
const overlayPointer = mode === 'draw' ? 'auto' : 'none';
const showCanvas = active || mode === 'draw' || hasInk;
const canSubmit = hasInk || Boolean(captureTarget) || Boolean(note.trim());
const overlayPointer = active ? 'auto' : 'none';
const showCanvas = active || hasInk;
const canSubmit = hasInk || Boolean(captureTarget) || captureViewport || Boolean(note.trim());
const canSend = canSubmit;
const canUndo = undoCount > 0 && !sending;
const canRedo = redoCount > 0 && !sending;
return (
<div
@ -396,7 +427,7 @@ export function PreviewDrawOverlay({
position: 'absolute',
inset: 0,
pointerEvents: overlayPointer,
cursor: mode === 'draw' ? 'crosshair' : 'default',
cursor: active ? 'crosshair' : 'default',
}}
/>
) : null}
@ -423,33 +454,30 @@ export function PreviewDrawOverlay({
>
<button
type="button"
onClick={() => setMode((m) => (m === 'draw' ? 'click' : 'draw'))}
disabled={sending}
style={pillStyle(mode === 'draw')}
aria-pressed={mode === 'draw'}
onClick={undoStroke}
disabled={!canUndo}
style={historyButtonStyle(canUndo)}
aria-label="Undo"
title="Undo"
>
Draw
<RemixIcon name="arrow-go-back-line" size={14} />
</button>
<button
type="button"
onClick={() => setMode('click')}
disabled={sending}
style={pillStyle(mode === 'click')}
aria-pressed={mode === 'click'}
onClick={redoStroke}
disabled={!canRedo}
style={historyButtonStyle(canRedo)}
aria-label="Redo"
title="Redo"
>
Click
<RemixIcon name="arrow-go-forward-line" size={14} />
</button>
{hasInk ? (
<button type="button" onClick={clearInk} disabled={sending} style={ghostStyle}>
Clear
</button>
) : null}
<input
className="preview-draw-note-input"
value={note}
onChange={(e) => setNote(e.target.value)}
disabled={sending}
placeholder="Type anywhere to add a note"
placeholder="Add a note for this annotation"
style={{
background: 'rgba(218, 97, 56, 0.18)',
border: '1px solid rgba(248, 150, 104, 0.82)',
@ -512,6 +540,16 @@ export function PreviewDrawOverlay({
'Send'
)}
</button>
<button
type="button"
onClick={closeOverlay}
disabled={sending}
aria-label="Close draw toolbar"
title="Close"
style={iconButtonStyle}
>
<Icon name="close" size={13} />
</button>
</div>
) : null}
</div>
@ -528,7 +566,7 @@ function pillStyle(active: boolean): CSSProperties {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: active ? ACTIVE_BUTTON_COLOR : 'transparent',
background: active ? 'var(--accent)' : 'transparent',
color: active ? '#fff' : 'inherit',
};
}
@ -545,3 +583,25 @@ const ghostStyle: CSSProperties = {
background: 'transparent',
color: 'inherit',
};
function historyButtonStyle(enabled: boolean): CSSProperties {
return {
...iconButtonStyle,
opacity: enabled ? 1 : 0.36,
cursor: enabled ? 'pointer' : 'not-allowed',
};
}
const iconButtonStyle: CSSProperties = {
border: '1px solid rgba(255,255,255,0.18)',
borderRadius: 999,
width: 28,
height: 28,
padding: 0,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255,255,255,0.06)',
color: 'inherit',
};

View file

@ -1,6 +1,7 @@
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
@ -570,6 +571,9 @@ export function ProjectView({
const [liveArtifacts, setLiveArtifacts] = useState<LiveArtifactSummary[]>([]);
const [liveArtifactEvents, setLiveArtifactEvents] = useState<LiveArtifactEventItem[]>([]);
const [workspaceFocused, setWorkspaceFocused] = useState(false);
const [commentInspectorActive, setCommentInspectorActive] = useState(false);
const commentInspectorPortalId = useId();
const leftInspectorActive = commentInspectorActive;
// Per-session override for the BYOK SenseAudio chat's generate_image
// tool. Seeded once from Settings (config.byokImageModel) so the
// composer dropdown opens on the user's chosen default; subsequent
@ -595,6 +599,7 @@ export function ProjectView({
setInstructionsMode('closed');
}
}, [project.customInstructions, instructionsMode]);
// PR #974 round 7 (mrcfps @ useDesignMdState.ts:131): counter that
// bumps on file-changed SSE events, live_artifact* events, and the
// chat streaming-completion edge so the staleness chip stays in sync
@ -642,6 +647,8 @@ export function ProjectView({
active: null,
});
const tabsLoadedRef = useRef(false);
const tabsHydratedFromSavedStateRef = useRef(false);
const hasAppliedInitialPrimaryOpenRef = useRef(false);
// Routed to FileWorkspace — bumped whenever the user clicks "open" on a
// tool card, an attachment chip, or a produced-file chip in chat. We
// include a nonce so re-clicking the same name after the user closed the
@ -726,7 +733,12 @@ export function ProjectView({
const currentConversationQueuedItems = activeConversationId
? queuedChatSends
.filter((item) => item.conversationId === activeConversationId)
.map((item) => ({ id: item.id, prompt: item.prompt }))
.map((item) => ({
id: item.id,
prompt: item.prompt,
attachments: item.attachments,
commentAttachments: item.commentAttachments,
}))
: [];
const newConversationDisabled = creatingConversation;
const activeCompletionNotificationRunsRef = useRef<Set<string>>(new Set());
@ -1011,9 +1023,12 @@ export function ProjectView({
useEffect(() => {
let cancelled = false;
tabsLoadedRef.current = false;
tabsHydratedFromSavedStateRef.current = false;
hasAppliedInitialPrimaryOpenRef.current = false;
(async () => {
const state = await loadTabs(project.id);
if (cancelled) return;
tabsHydratedFromSavedStateRef.current = state.hasSavedState === true;
setOpenTabsState(state);
tabsLoadedRef.current = true;
})();
@ -1080,6 +1095,24 @@ export function ProjectView({
return nextFiles;
}, [refreshLiveArtifacts, refreshProjectFiles]);
useEffect(() => {
if (!tabsLoadedRef.current) return;
if (hasAppliedInitialPrimaryOpenRef.current) return;
if (routeFileName) return;
if (openTabsState.active || openTabsState.tabs.length > 0) {
hasAppliedInitialPrimaryOpenRef.current = true;
return;
}
if (tabsHydratedFromSavedStateRef.current) {
hasAppliedInitialPrimaryOpenRef.current = true;
return;
}
const primaryFile = selectPrimaryProjectFile(projectFiles);
if (!primaryFile) return;
hasAppliedInitialPrimaryOpenRef.current = true;
persistTabsState({ tabs: [primaryFile.name], active: primaryFile.name });
}, [openTabsState.active, openTabsState.tabs.length, persistTabsState, projectFiles, routeFileName]);
const requestOpenFile = useCallback((name: string) => {
if (!name) return;
setOpenRequest({ name, nonce: Date.now() });
@ -2164,6 +2197,7 @@ export function ProjectView({
attachments: ChatAttachment[],
commentAttachments: ChatCommentAttachment[] = commentsToAttachments(attachedComments),
meta?: ProjectChatSendMeta,
baseMessages?: ChatMessage[],
) => {
if (!activeConversationId) return;
if (messagesConversationIdRef.current !== activeConversationId) return;
@ -2171,9 +2205,14 @@ export function ProjectView({
? resolveRetryTarget(messages, meta.retryOfAssistantId)
: null;
if (meta?.retryOfAssistantId && !retryTarget) return;
const historyBase = retryTarget ? retryTarget.priorMessages : baseMessages ?? messages;
if (
!retryTarget &&
!prompt.trim() &&
attachments.length === 0 &&
commentAttachments.length === 0
) return;
if (currentConversationBusy) {
if (meta?.retryOfAssistantId) return;
if (!prompt.trim() && attachments.length === 0 && commentAttachments.length === 0) return;
enqueueChatSend({
id: randomUUID(),
conversationId: activeConversationId,
@ -2191,12 +2230,6 @@ export function ProjectView({
}
return;
}
if (
!retryTarget &&
!prompt.trim() &&
attachments.length === 0 &&
commentAttachments.length === 0
) return;
setChatSeed(null);
const runConversationId = activeConversationId;
setError(null);
@ -2274,7 +2307,7 @@ export function ProjectView({
activeCompletionNotificationRunsRef.current.add(assistantId);
const nextHistory = retryTarget
? [...retryTarget.priorMessages, userMsg]
: [...messages, userMsg];
: [...historyBase, userMsg];
setMessages([...nextHistory, assistantMsg]);
markStreamingConversation(runConversationId);
updateConversationLatestRun(config.mode === 'daemon' ? 'running' : 'queued');
@ -2299,7 +2332,7 @@ export function ProjectView({
// If this is the first turn, derive a working title from the prompt
// so the conversation is identifiable in the dropdown without a
// round-trip through the agent.
if (!retryTarget && messages.length === 0) {
if (!retryTarget && historyBase.length === 0) {
const title = isDesignSystemWorkspacePrompt(prompt)
? DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE
: prompt.slice(0, 60).trim();
@ -2899,6 +2932,8 @@ export function ProjectView({
const handleSendBoardCommentAttachments = useCallback(
async (commentAttachments: ChatCommentAttachment[]) => {
if (currentConversationActionDisabled || commentAttachments.length === 0) return;
setWorkspaceFocused(false);
setCommentInspectorActive(false);
await handleSend('', [], commentAttachments);
},
[handleSend, currentConversationActionDisabled],
@ -4094,6 +4129,9 @@ export function ProjectView({
// pipeline; this hook only governs whether the web layer renders the
// resulting SSE stream.
const critiqueTheaterEnabled = useCritiqueTheaterEnabled();
const projectInstructions = (project.customInstructions ?? '').trim();
const hasProjectInstructions = projectInstructions.length > 0;
const projectInstructionsPreview = compactInlinePreview(projectInstructions);
return (
<div className="app">
@ -4105,8 +4143,22 @@ export function ProjectView({
showTrafficSpace={false}
onBack={onBack}
backLabel={t('project.backToProjects')}
actions={(
fileActionsBefore={(
<>
<button
type="button"
className="settings-icon-btn"
data-testid="project-settings-trigger"
title={t('project.customInstructions')}
aria-label={t('project.customInstructions')}
aria-expanded={instructionsMode !== 'closed'}
onClick={() => {
setInstructionsDraft(project.customInstructions ?? '');
setInstructionsMode(hasProjectInstructions ? 'review' : 'edit');
}}
>
<Icon name="sliders" size={16} />
</button>
<HandoffButton projectId={project.id} />
<AvatarMenu
config={config}
@ -4119,8 +4171,15 @@ export function ProjectView({
onRefreshAgents={onRefreshAgents}
onBack={onBack}
/>
<div
className="app-chrome-file-actions-before workspace-tabs-file-actions"
data-app-chrome-file-actions="true"
/>
</>
)}
actions={(
null
)}
>
<div className="app-project-title">
<span className="app-project-title-line">
@ -4142,39 +4201,28 @@ export function ProjectView({
>
{project.name}
</span>
<span className="meta" data-testid="project-meta">{projectMeta}</span>
{projectMeta !== t('project.metaFreeform') ? (
<span className="meta" data-testid="project-meta">{projectMeta}</span>
) : null}
<ProjectDesignSystemPicker
designSystems={designSystems}
selectedId={project.designSystemId ?? null}
onChange={handleChangeDesignSystemId}
/>
{(project.customInstructions ?? '').trim() ? (
{hasProjectInstructions ? (
<button
type="button"
className={`project-instructions-chip${instructionsMode !== 'closed' ? ' is-open' : ''}`}
data-testid="project-instructions-chip"
title={t('project.customInstructions')}
title={projectInstructions}
aria-label={t('project.customInstructions')}
aria-expanded={instructionsMode !== 'closed'}
onClick={() => setInstructionsMode((m) => (m === 'closed' ? 'review' : 'closed'))}
>
<Icon name="file" size={11} />
<span>{t('project.customInstructions')}</span>
<Icon name="sliders" size={11} />
<span>&quot;{projectInstructionsPreview}&quot;</span>
</button>
) : (
<button
type="button"
className="project-instructions-toggle"
data-testid="project-instructions-add"
title={t('project.customInstructions')}
aria-expanded={instructionsMode !== 'closed'}
onClick={() => {
setInstructionsDraft('');
setInstructionsMode((m) => (m === 'closed' ? 'edit' : 'closed'));
}}
>
<Icon name="edit" size={13} />
</button>
)}
) : null}
</span>
</div>
</AppChromeHeader>
@ -4246,6 +4294,7 @@ export function ProjectView({
ref={splitRef}
className={[
projectSplitClassName(workspaceFocused),
leftInspectorActive && !workspaceFocused ? 'split-manual-edit' : '',
resizingChatPanel && !workspaceFocused ? 'is-resizing-chat' : '',
].filter(Boolean).join(' ')}
style={workspaceFocused
@ -4256,7 +4305,13 @@ export function ProjectView({
}}
>
<div className="split-chat-slot" hidden={workspaceFocused}>
{activeConversationId || conversationLoadError ? (
{commentInspectorActive ? (
<div
id={commentInspectorPortalId}
className="comment-left-host"
aria-label="Comments"
/>
) : activeConversationId || conversationLoadError ? (
<ChatPane
// The conversation id is part of the key so switching conversations
// resets internal scroll/draft state inside ChatPane and ChatComposer.
@ -4336,20 +4391,24 @@ export function ProjectView({
)}
</div>
{!workspaceFocused ? (
<div
className="split-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={chatResizeLabel}
aria-valuemin={chatPanelAriaMinWidth}
aria-valuemax={chatPanelMaxWidth}
aria-valuenow={chatPanelWidth}
tabIndex={0}
title={chatResizeLabel}
onPointerDown={handleChatResizePointerDown}
onKeyDown={handleChatResizeKeyDown}
onBlur={handleChatResizeBlur}
/>
leftInspectorActive ? (
<div className="split-edit-divider" aria-hidden />
) : (
<div
className="split-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={chatResizeLabel}
aria-valuemin={chatPanelAriaMinWidth}
aria-valuemax={chatPanelMaxWidth}
aria-valuenow={chatPanelWidth}
tabIndex={0}
title={chatResizeLabel}
onPointerDown={handleChatResizePointerDown}
onKeyDown={handleChatResizeKeyDown}
onBlur={handleChatResizeBlur}
/>
)
) : null}
<FileWorkspace
projectId={project.id}
@ -4385,6 +4444,8 @@ export function ProjectView({
onDesignSystemReviewDecision={persistDesignSystemReviewDecision}
onConnectRepo={handleConnectRepo}
githubConnected={githubConnected}
commentPortalId={commentInspectorPortalId}
onCommentModeChange={setCommentInspectorActive}
/>
</div>
{projectActionsToast ? (
@ -4455,6 +4516,68 @@ export function findExistingArtifactProjectFile(
return currentRunFiles.find((file) => file.name === candidateFileName) ?? null;
}
export function selectPrimaryProjectFile(files: ProjectFile[]): ProjectFile | null {
const candidates = files
.filter((file) => !isProcessArtifactFile(file.name))
.map((file) => ({ file, rank: primaryProjectFileRank(file) }))
.filter((candidate) => Number.isFinite(candidate.rank));
if (candidates.length === 0) return null;
candidates.sort((a, b) => a.rank - b.rank || b.file.mtime - a.file.mtime);
return candidates[0]?.file ?? null;
}
function isProcessArtifactFile(name: string): boolean {
const base = name.split('/').pop()?.toLowerCase() ?? name.toLowerCase();
return (
base === 'critique.json'
|| base.endsWith('.log')
|| base.endsWith('.meta.json')
|| base.endsWith('.artifact.json')
|| base.endsWith('.map')
);
}
function primaryProjectFileRank(file: ProjectFile): number {
if (manifestDeclaresPrimary(file)) return 0;
if (file.artifactManifest && file.artifactManifest.metadata?.inferred !== true) return 1;
if (file.kind === 'html') return 2;
if (file.kind === 'image') return 3;
if (file.kind === 'video') return 4;
if (file.kind === 'sketch') return 5;
if (file.kind === 'pdf') return 6;
if (file.kind === 'presentation') return 7;
if (file.kind === 'document') return 8;
if (file.kind === 'spreadsheet') return 9;
return Number.POSITIVE_INFINITY;
}
function manifestDeclaresPrimary(file: ProjectFile): boolean {
const manifest = file.artifactManifest;
if (!manifest) return false;
if (primaryValueTargetsFile(manifest.primary, file.name)) return true;
const metadata = manifest.metadata;
if (!metadata || typeof metadata !== 'object') return false;
if (primaryValueTargetsFile(metadata.primary, file.name)) return true;
const outputs = metadata.outputs;
if (outputs && typeof outputs === 'object' && !Array.isArray(outputs)) {
return primaryValueTargetsFile(
(outputs as { primary?: unknown }).primary,
file.name,
);
}
return false;
}
function primaryValueTargetsFile(value: unknown, fileName: string): boolean {
if (value === true) return true;
if (typeof value !== 'string') return false;
return normalizeProjectFileName(value) === normalizeProjectFileName(fileName);
}
function normalizeProjectFileName(value: string): string {
return value.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
}
function assistantAgentDisplayName(
agentId: string | null,
fallbackName?: string,
@ -4470,6 +4593,10 @@ function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
return status === 'queued' || status === 'running';
}
function compactInlinePreview(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
export interface RetryTarget {
failedAssistant: ChatMessage;
userMsg: ChatMessage;

View file

@ -0,0 +1,27 @@
import type { CSSProperties } from 'react';
interface RemixIconProps {
name: string;
size?: number;
className?: string;
style?: CSSProperties;
}
export function RemixIcon({ name, size = 14, className, style }: RemixIconProps) {
return (
<i
className={`ri-${name}${className ? ` ${className}` : ''}`}
aria-hidden="true"
style={{
fontSize: size,
lineHeight: 1,
width: size,
height: size,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
...style,
}}
/>
);
}

View file

@ -207,6 +207,76 @@ export function buildManualEditBridge(enabled: boolean): string {
}
return fallback;
}
function caretRangeFromClick(clickEvent){
try {
if (document.caretPositionFromPoint) {
var position = document.caretPositionFromPoint(clickEvent.clientX, clickEvent.clientY);
if (!position) return null;
var positionRange = document.createRange();
positionRange.setStart(position.offsetNode, position.offset);
positionRange.collapse(true);
return positionRange;
}
if (document.caretRangeFromPoint) {
return document.caretRangeFromPoint(clickEvent.clientX, clickEvent.clientY);
}
} catch (e) {}
return null;
}
function placeCaretFromClick(clickEvent, el){
var range = caretRangeFromClick(clickEvent);
if (!range) {
range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
}
try {
var sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
sel.addRange(range);
} catch (e) {}
}
function makeEditable(el, clickEvent){
if (!el || el.getAttribute('contenteditable') === 'true') return;
var originalText = el.textContent || '';
clearSelectedTarget();
el.setAttribute('contenteditable', 'plaintext-only');
el.setAttribute('data-od-editing', 'true');
try { el.focus(); } catch (e) {}
placeCaretFromClick(clickEvent, el);
function finish(commit){
el.removeAttribute('contenteditable');
el.removeAttribute('data-od-editing');
el.removeEventListener('blur', onBlur);
el.removeEventListener('keydown', onKey);
var value = (el.textContent || '').trim();
if (commit && value !== originalText.trim()) {
window.parent.postMessage({
type: 'od-edit-text-commit',
id: stableId(el),
value: value
}, '*');
} else if (!commit) {
el.textContent = originalText;
}
}
function onBlur(){ finish(true); }
function onKey(ev){
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
finish(true);
try { el.blur(); } catch (e) {}
}
if (ev.key === 'Escape') {
ev.preventDefault();
finish(false);
try { el.blur(); } catch (e) {}
}
}
el.addEventListener('blur', onBlur);
el.addEventListener('keydown', onKey);
}
function camelToKebab(name){ return String(name).replace(/[A-Z]/g, function(m){ return '-' + m.toLowerCase(); }); }
function cssEscapeId(value){ if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value); return String(value).replace(/"/g, '\\\\"'); }
function findById(id){
@ -270,10 +340,16 @@ export function buildManualEditBridge(enabled: boolean): string {
});
document.addEventListener('click', function(ev){
if (!enabled) return;
if (ev.target && ev.target.closest && ev.target.closest('[data-od-editing="true"]')) return;
var el = closestTarget(ev);
if (!el) return;
ev.preventDefault();
ev.stopPropagation();
var kind = inferKind(el);
if (kind === 'text' || kind === 'link') {
makeEditable(el, ev);
return;
}
window.parent.postMessage({ type: 'od-edit-select', target: targetFrom(el, true) }, '*');
}, true);
window.addEventListener('resize', postTargets);
@ -295,5 +371,11 @@ html[data-od-edit-mode] [data-od-edit-selected] {
outline-offset: 4px;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.16);
}
html[data-od-edit-mode] [data-od-editing="true"] {
outline: 2px solid #2563eb !important;
outline-offset: 4px;
background: rgba(37, 99, 235, 0.06);
cursor: text !important;
}
</style>`;
}

View file

@ -105,10 +105,17 @@ export interface ManualEditPreviewAppliedMessage {
error?: string;
}
export interface ManualEditTextCommitMessage {
type: 'od-edit-text-commit';
id: string;
value: string;
}
export type ManualEditBridgeMessage =
| ManualEditTargetMessage
| ManualEditSelectMessage
| ManualEditPreviewAppliedMessage;
| ManualEditPreviewAppliedMessage
| ManualEditTextCommitMessage;
export const MANUAL_EDIT_STYLE_PROPS: readonly (keyof ManualEditStyles)[] = [
'fontFamily', 'fontSize', 'fontWeight', 'color', 'textAlign', 'lineHeight', 'letterSpacing',

View file

@ -762,6 +762,10 @@ export const ar: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'المحادثات',
'chat.conversationsAria': 'سجل المحادثات',
'chat.newConversation': 'محادثة جديدة',

View file

@ -650,6 +650,10 @@ export const de: Dict = {
'chat.comments.pinAtCoords': 'bei {x}, {y}',
'chat.comments.capturedItems': '{n} erfasste Elemente',
'chat.comments.clear': 'Löschen',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Konversationen',
'chat.conversationsAria': 'Konversationsverlauf',
'chat.newConversation': 'Neue Konversation',

View file

@ -483,6 +483,9 @@ export const en: Dict = {
'workingDirPicker.replace': 'Clear and replace directory…',
'workingDirPicker.recent': 'Recent directories',
'handoff.toTarget': 'Hand off to {target}',
'handoff.openInTarget': 'Open in {target}',
'handoff.openAction': 'Open',
'handoff.menuTitle': 'Open in which editor?',
'handoff.action': 'Hand off',
'handoff.fallbackTitle': 'No editors found on $PATH - opens in {target}',
'handoff.chooseTargetAria': 'Choose hand-off target',
@ -1371,6 +1374,10 @@ export const en: Dict = {
'chat.openFile': 'Open {name}',
'chat.copyPrompt': 'Copy prompt',
'chat.copyDone': 'Copied!',
'chat.inspect.noEditableTargets': 'No editable text or style targets found.',
'chat.inspect.noCommentTargets': 'No commentable text or visual targets found.',
'chat.inspect.editHint': 'Select a text or style target in the preview to edit it.',
'chat.inspect.commentHint': 'Select text or an area in the preview to comment on it.',
'chat.composerPlaceholder': "Describe what you want to generate…",
'chat.composerHint': "⌘/Ctrl + Enter to send · include goals, content, style, and format",
'chat.cliSettingsTitle': 'CLI & model settings',

View file

@ -651,6 +651,10 @@ export const esES: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Conversaciones',
'chat.conversationsAria': 'Historial de conversaciones',
'chat.newConversation': 'Nueva conversación',

View file

@ -784,6 +784,10 @@ export const fa: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'مکالمات',
'chat.conversationsAria': 'تاریخچه مکالمات',
'chat.newConversation': 'مکالمه جدید',

View file

@ -778,6 +778,10 @@ export const fr: Dict = {
'chat.comments.pinAtCoords': 'à {x}, {y}',
'chat.comments.capturedItems': '{n} élément(s) capturé(s)',
'chat.comments.clear': 'Effacer',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Conversations',
'chat.conversationsAria': 'Historique des conversations',
'chat.newConversation': 'Nouvelle conversation',

View file

@ -762,6 +762,10 @@ export const hu: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Beszélgetések',
'chat.conversationsAria': 'Beszélgetések előzménye',
'chat.newConversation': 'Új beszélgetés',

View file

@ -876,6 +876,10 @@ export const id: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Percakapan',
'chat.conversationsAria': 'Buka percakapan',
'chat.newConversation': 'Percakapan baru',

View file

@ -680,6 +680,10 @@ export const it: Dict = {
'chat.comments.updateSend': 'Aggiorna e invia',
'chat.comments.removeAttachment': 'Rimuovi commento allegato',
'chat.comments.removeAttachmentAria': 'Rimuovi commento allegato per {name}',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Conversazioni',
'chat.conversationsAria': 'Cronologia delle conversazioni',
'chat.newConversation': 'Nuova conversazione',

View file

@ -649,6 +649,10 @@ export const ja: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': '会話',
'chat.conversationsAria': '会話履歴',
'chat.newConversation': '新しい会話',

View file

@ -762,6 +762,10 @@ export const ko: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': '대화 목록',
'chat.conversationsAria': '대화 내역',
'chat.newConversation': '새 대화 시작',

View file

@ -762,6 +762,10 @@ export const pl: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Rozmowy',
'chat.conversationsAria': 'Historia rozmów',
'chat.newConversation': 'Nowa rozmowa',

View file

@ -783,6 +783,10 @@ export const ptBR: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Conversas',
'chat.conversationsAria': 'Histórico de conversas',
'chat.newConversation': 'Nova conversa',

View file

@ -783,6 +783,10 @@ export const ru: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Разговоры',
'chat.conversationsAria': 'История разговоров',
'chat.newConversation': 'Новый разговор',

View file

@ -718,6 +718,10 @@ export const th: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'การสนทนา',
'chat.conversationsAria': 'ประวัติ',
'chat.newConversation': 'สนทนาใหม่',

View file

@ -751,6 +751,10 @@ export const tr: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Konuşmalar',
'chat.conversationsAria': 'Konuşma geçmişi',
'chat.newConversation': 'Yeni konuşma',

View file

@ -784,6 +784,10 @@ export const uk: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
'chat.inspect.commentHint': 'Click an element in the canvas to add a comment.',
'chat.conversationsTitle': 'Розмови',
'chat.conversationsAria': 'Історія розмов',
'chat.newConversation': 'Нова розмова',

View file

@ -483,6 +483,9 @@ export const zhCN: Dict = {
'workingDirPicker.replace': '清空并替换目录…',
'workingDirPicker.recent': '最近使用的目录',
'handoff.toTarget': '交付给 {target}',
'handoff.openInTarget': '在 {target} 中打开',
'handoff.openAction': '打开',
'handoff.menuTitle': '在哪个编辑器中打开?',
'handoff.action': '交付',
'handoff.fallbackTitle': '未找到编辑器 - 使用 {target} 打开',
'handoff.chooseTargetAria': '选择交付目标',
@ -1362,6 +1365,10 @@ export const zhCN: Dict = {
'chat.openFile': '打开 {name}',
'chat.copyPrompt': '复制提示词',
'chat.copyDone': '已复制!',
'chat.inspect.noEditableTargets': '未找到可编辑的文本或样式目标。',
'chat.inspect.noCommentTargets': '未找到可评论的文本或视觉目标。',
'chat.inspect.editHint': '在预览中选择文本或样式目标进行编辑。',
'chat.inspect.commentHint': '在预览中选择文本或区域进行评论。',
'chat.composerPlaceholder': "描述你想生成的内容…",
'chat.composerHint': "⌘/Ctrl + Enter 发送 · 说清目标、内容、风格和格式",
'chat.cliSettingsTitle': 'CLI 与模型设置',
@ -1563,7 +1570,7 @@ export const zhCN: Dict = {
'fileViewer.jsxModuleTitle': '无独立预览',
'fileViewer.jsxModuleBody': '此文件是由其他页面加载的组件模块。',
'fileViewer.jsxModuleCta': '请打开渲染它的页面:',
'fileViewer.comment': '评论',
'fileViewer.comment': '注释',
'fileViewer.edit': '编辑',
'fileViewer.draw': '绘制',
'manualEdit.layers': '图层',

View file

@ -16,6 +16,9 @@ export const zhTW: Dict = {
'workingDirPicker.replace': "清空並替換目錄…",
'workingDirPicker.recent': "最近使用的目錄",
'handoff.toTarget': '交付給 {target}',
'handoff.openInTarget': '在 {target} 中開啟',
'handoff.openAction': '開啟',
'handoff.menuTitle': '在哪個編輯器中開啟?',
'handoff.action': '交付',
'handoff.fallbackTitle': '未找到編輯器 - 使用 {target} 開啟',
'handoff.chooseTargetAria': '選擇交付目標',
@ -974,6 +977,10 @@ export const zhTW: Dict = {
'chat.openFile': '開啟 {name}',
'chat.copyPrompt': '複製提示詞',
'chat.copyDone': '已複製!',
'chat.inspect.noEditableTargets': '未找到可編輯的文字或樣式目標。',
'chat.inspect.noCommentTargets': '未找到可評論的文字或視覺目標。',
'chat.inspect.editHint': '在預覽中選擇文字或樣式目標進行編輯。',
'chat.inspect.commentHint': '在預覽中選擇文字或區域進行評論。',
'chat.composerPlaceholder': "描述你想生成的內容…",
'chat.composerHint': "⌘/Ctrl + Enter 傳送 · 說清目標、內容、風格和格式",
'chat.cliSettingsTitle': 'CLI 與模型設定',
@ -1172,7 +1179,7 @@ export const zhTW: Dict = {
'fileViewer.jsxModuleTitle': '無獨立預覽',
'fileViewer.jsxModuleBody': '此檔案是由其他頁面載入的元件模組。',
'fileViewer.jsxModuleCta': '請開啟轉譯它的頁面:',
'fileViewer.comment': '評論',
'fileViewer.comment': '註釋',
'fileViewer.edit': '編輯',
'fileViewer.draw': '繪製',
'manualEdit.layers': "Layers",

View file

@ -793,6 +793,9 @@ export interface Dict {
'workingDirPicker.replace': string;
'workingDirPicker.recent': string;
'handoff.toTarget': string;
'handoff.openInTarget': string;
'handoff.openAction': string;
'handoff.menuTitle': string;
'handoff.action': string;
'handoff.fallbackTitle': string;
'handoff.chooseTargetAria': string;
@ -1662,6 +1665,10 @@ export interface Dict {
'chat.comments.pinAtCoords': string;
'chat.comments.capturedItems': string;
'chat.comments.clear': string;
'chat.inspect.noEditableTargets': string;
'chat.inspect.noCommentTargets': string;
'chat.inspect.editHint': string;
'chat.inspect.commentHint': string;
'chat.conversationsTitle': string;
'chat.conversationsAria': string;
'chat.newConversation': string;

View file

@ -1,4 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;500;600;700&display=swap');
@import './styles/remixicon/remixicon.css';
@import './styles/design-system-flow.css';
@import './styles/tokens.css';
@import './styles/base.css';

View file

@ -11,10 +11,9 @@
* Output is a React fragment of typed elements no dangerouslySetInnerHTML,
* so untrusted text can't smuggle markup through.
*/
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import { Fragment, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react';
import { useT } from '../i18n';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import type { MouseEvent } from 'react';
export type MarkdownLinkClickHandler = (
href: string,

View file

@ -529,6 +529,118 @@
margin: 0 8px;
}
.chat-queued-send-strip {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
box-shadow: var(--shadow-xs);
}
.chat-queued-send-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 20px;
}
.chat-queued-send-heading {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
color: var(--text-muted);
font-size: 11.5px;
line-height: 1.2;
white-space: nowrap;
}
.chat-queued-send-heading strong {
color: var(--text);
font-weight: 650;
}
.chat-queued-send-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 6px 6px 6px 10px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
}
.chat-queued-send-row-active {
border-color: var(--border);
background: var(--bg-subtle);
}
.chat-queued-send-title {
min-width: 0;
flex: 1;
overflow: hidden;
color: var(--text);
font-size: 12.5px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-queued-send-actions {
display: inline-flex;
align-items: center;
gap: 3px;
flex: 0 0 auto;
}
.chat-queued-send-action {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
}
.chat-queued-send-action:hover:not(:disabled) {
border-color: var(--border);
background: var(--bg-panel);
color: var(--text);
}
.chat-queued-send-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.chat-queued-send-overflow {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
font-size: 11.5px;
line-height: 1.2;
}
.chat-queued-send-overflow-line {
flex: 1;
height: 1px;
background: var(--border);
}
.chat-queued-send-extra {
color: var(--text-muted);
font-weight: 600;
}
.preview-draw-note-input::placeholder {
color: rgba(255, 225, 210, 0.86);
}

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -552,13 +552,23 @@
gap: 6px;
flex-shrink: 0;
}
.app-chrome-file-actions-before {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.app-chrome-file-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.app-chrome-file-actions:not(:empty) + .app-chrome-actions {
.app-chrome-file-actions-before:not(:empty) + .app-chrome-file-actions:not(:empty) {
margin-left: 4px;
}
.app-chrome-file-actions:not(:empty) + .app-chrome-actions,
.app-chrome-file-actions-before:not(:empty) + .app-chrome-file-actions:empty + .app-chrome-actions {
margin-left: 4px;
padding-left: 8px;
border-left: 1px solid var(--border);
@ -1005,6 +1015,29 @@
/* -------- Avatar menu (replaces in-topbar agent picker) ------------- */
.avatar-menu { position: relative; }
.avatar-agent-trigger {
width: 58px;
height: 32px;
padding: 0;
display: inline-grid;
grid-template-columns: 32px 24px;
align-items: center;
justify-items: center;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-panel);
color: var(--text);
cursor: pointer;
overflow: hidden;
transition: border-color 200ms cubic-bezier(0.23, 1, 0.32, 1),
background 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.avatar-agent-trigger:hover:not(:disabled) {
border-color: var(--border-strong, var(--border));
}
.avatar-agent-trigger > svg:last-child {
color: var(--text-muted);
}
.avatar-btn {
width: 32px;
height: 32px;
@ -1210,6 +1243,48 @@ a.avatar-item:visited {
flex: 1 1 auto;
min-width: 0;
}
.split-edit-divider {
width: 8px;
min-width: 8px;
height: 100%;
border-right: 1px solid var(--border);
background: var(--bg-panel);
}
.comment-left-host {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--bg-panel);
}
.comment-left-host > .comment-side-panel {
position: relative;
top: auto;
right: auto;
bottom: auto;
flex: 1 1 auto;
width: 100%;
max-width: none;
min-width: 0;
min-height: 0;
border: 0;
border-radius: 0;
box-shadow: none;
z-index: 20;
}
.comment-left-host .comment-side-header {
min-height: 40px;
}
.comment-left-host .comment-side-list {
padding: 16px 12px;
}
.comment-side-composer {
flex: 0 0 auto;
padding: 12px;
border-top: 1px solid var(--border);
background: var(--bg-panel);
}
.pane {
display: flex;
flex-direction: column;

View file

@ -19,7 +19,7 @@
flex-shrink: 0;
}
.viewer-toolbar-left { display: inline-flex; align-items: center; gap: 8px; }
.viewer-toolbar-actions { display: inline-flex; gap: 2px; align-items: center; flex-wrap: wrap; }
.viewer-toolbar-actions { display: inline-flex; gap: 2px; align-items: center; }
.viewer-toolbar .icon-only,
.viewer-toolbar-actions .icon-only {
width: 28px;
@ -134,6 +134,99 @@
opacity: 0.75;
cursor: progress;
}
.viewer-action-icon {
position: relative;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
}
.viewer-action-icon[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
z-index: 80;
top: calc(100% + 7px);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 4px 7px;
border-radius: 6px;
background: var(--text);
color: var(--bg-panel);
font-size: 11px;
line-height: 1.2;
opacity: 0;
pointer-events: none;
white-space: nowrap;
transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1), transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.viewer-action-icon[data-tooltip]:hover::after,
.viewer-action-icon[data-tooltip]:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.viewer-comment-toggle.active {
border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 8%, transparent);
}
.viewer-action-active-dot {
position: absolute;
right: 5px;
top: 5px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 2px var(--bg-panel);
}
.artifact-tool-menu-anchor {
position: relative;
display: inline-flex;
}
.artifact-tool-menu-trigger[aria-expanded='true'] {
background: var(--bg-subtle);
color: var(--text);
}
.artifact-tool-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 59;
min-width: 216px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-md, 10px);
box-shadow: var(--shadow-md, 0 8px 24px rgba(0, 0, 0, 0.12));
}
.artifact-tool-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: 12.5px;
line-height: 1.2;
white-space: nowrap;
text-align: left;
cursor: pointer;
}
.artifact-tool-menu-item:hover {
background: var(--bg-subtle);
}
.artifact-tool-menu-item.active {
background: var(--bg-muted);
color: var(--text);
}
.artifact-tool-menu-item .palette-tweaks-badge {
margin-left: auto;
}
/* Preview-only controls: keep layout stable across modes.
When inactive, reserve the same horizontal slot but fully disable interaction. */
.viewer-preview-controls {
@ -1015,6 +1108,9 @@
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
}
.annotation-hover-popover {
pointer-events: none;
}
.comment-popover-head {
display: flex;
align-items: flex-start;
@ -1154,16 +1250,6 @@
gap: 10px;
min-width: 0;
}
.comment-popover-remove {
color: var(--red);
background: transparent;
border-color: transparent;
}
.comment-popover-remove:hover:not(:disabled) {
background: var(--red-bg);
border-color: var(--red-border);
}
/* Right-side comment thread panel. Shown while board (comment) mode
is on; takes the place of the chat sidebar's removed Comments tab.
Floats over the artifact preview at the right edge. */
@ -1297,6 +1383,11 @@
background: #fff1ec;
border-color: #ff8c75;
}
.comment-side-item.active {
background: color-mix(in srgb, var(--accent) 10%, var(--bg-panel));
border-color: color-mix(in srgb, var(--accent) 46%, var(--border));
box-shadow: inset 3px 0 0 var(--accent);
}
.comment-side-item-head {
display: flex;
align-items: center;
@ -1431,6 +1522,50 @@
pointer-events: auto;
}
.screenshot-toast-anchor {
position: fixed;
top: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 1200;
pointer-events: auto;
}
.screenshot-toast {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 0 12px 0 16px;
border: 1px solid rgba(87, 149, 206, 0.5);
border-radius: 16px;
background: #0d3858;
color: #fff;
box-shadow: 0 12px 32px rgba(6, 22, 36, 0.28);
font-size: 15px;
font-weight: 650;
white-space: nowrap;
}
.screenshot-toast > i {
color: #fff;
flex: 0 0 auto;
}
.screenshot-toast button {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 50%;
background: transparent;
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
}
.screenshot-toast button:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Inspect panel sibling of the comment popover. Anchored to the
right side of the preview surface. Width is fixed so layout doesn't
reflow as the user scrubs slider values; controls reserve space for

View file

@ -640,22 +640,21 @@
}
/* Manual edit mode */
.manual-edit-workspace {
display: grid;
grid-template-columns: 280px minmax(420px, 1fr) 280px;
gap: 10px;
position: relative;
width: 100%;
height: 100%;
min-height: 0;
padding: 10px;
background: var(--bg);
}
.manual-edit-workspace > .manual-edit-canvas { grid-column: 2; grid-row: 1; }
.manual-edit-workspace > .manual-edit-layers { grid-column: 1; grid-row: 1; }
.manual-edit-workspace > .manual-edit-right {
grid-column: 3;
grid-row: 1;
height: 100%;
min-height: 0;
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
width: 320px;
max-width: calc(100% - 16px);
overflow: hidden;
z-index: 30;
}
/* Claude Code-style edit inspector panel. */
@ -810,17 +809,15 @@
.cc-disclosure > summary { cursor: pointer; padding: 4px 0; }
.manual-edit-workspace.preview-viewport:not(.preview-viewport-desktop) {
grid-template-columns: 240px calc(var(--preview-viewport-width) * var(--preview-scale, 1)) 344px;
justify-content: start;
justify-content: center;
}
.manual-edit-canvas {
position: relative;
position: absolute;
inset: 0;
min-width: 0;
min-height: 0;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg-panel);
}
@ -929,6 +926,46 @@
scrollbar-gutter: stable;
}
.manual-edit-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 52px;
padding: 14px 18px 10px;
}
.manual-edit-titlebar > span {
min-width: 0;
overflow: hidden;
color: var(--text);
font-size: 16px;
font-weight: 600;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.manual-edit-titlebar-close {
flex: 0 0 auto;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
color: var(--text);
box-shadow: var(--shadow-xs);
}
.manual-edit-titlebar-close:hover {
background: var(--bg-subtle);
border-color: var(--border-strong);
}
.manual-edit-modal-head {
display: flex;
align-items: flex-start;

View file

@ -1507,19 +1507,23 @@
display: inline-flex;
align-items: stretch;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--bg-panel);
border: 1px solid transparent;
background: transparent;
overflow: hidden;
transition: border-color 200ms cubic-bezier(0.23, 1, 0.32, 1);
transition: background 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.app .handoff-split:hover {
border-color: var(--border-strong, var(--border));
background: color-mix(in srgb, var(--text-muted) 6%, transparent);
}
.app .handoff-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 3px 6px 3px 4px;
justify-content: center;
width: 32px;
min-width: 32px;
height: 30px;
gap: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text);
@ -1541,16 +1545,15 @@
gap: 8px;
padding: 3px 12px 3px 4px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--bg-panel);
border: 1px solid transparent;
background: transparent;
color: var(--text);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.app .handoff-trigger--solo:hover {
border-color: var(--border-strong, var(--border));
background: color-mix(in srgb, var(--text-muted) 6%, var(--bg-panel));
background: color-mix(in srgb, var(--text-muted) 6%, transparent);
}
.app .handoff-trigger-label {
white-space: nowrap;
@ -1562,11 +1565,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
/* Real tap target at least 28px on the short axis. The previous
caret was a 16-18px nested span which the user flagged as too
small to click reliably. */
min-width: 28px;
padding: 0 6px;
min-width: 24px;
width: 24px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
@ -1607,6 +1608,14 @@
display: flex;
flex-direction: column;
}
.app .handoff-menu-title {
padding: 6px 10px 7px;
border-bottom: 1px solid var(--border);
margin: 0 0 4px;
color: var(--text-muted);
font-size: 11.5px;
line-height: 16px;
}
.app .handoff-menu-item {
display: inline-flex;
align-items: center;

View file

@ -522,4 +522,5 @@ export type {
export interface OpenTabsState {
tabs: ProjectWorkspaceTabId[];
active: ProjectWorkspaceTabId | null;
hasSavedState?: boolean;
}

View file

@ -54,6 +54,20 @@ describe('parseArtifactManifest', () => {
const out = parseArtifactManifest(raw);
expect(out?.status).toBe('streaming');
});
it('preserves primary file hints', () => {
const raw = JSON.stringify({
version: 1,
kind: 'html',
title: 'x',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
primary: 'index.html',
});
const out = parseArtifactManifest(raw);
expect(out?.primary).toBe('index.html');
});
});
describe('inferLegacyManifest', () => {
@ -112,6 +126,7 @@ describe('createHtmlArtifactManifest', () => {
expect(out.renderer).toBe('html');
expect(out.status).toBe('complete');
expect(out.exports).toEqual(['html', 'pdf', 'zip']);
expect(out.primary).toBe(true);
expect(out.entry).toBe('index.html');
expect(out.title).toBe('Landing');
expect(typeof out.createdAt).toBe('string');

View file

@ -53,7 +53,6 @@ function renderPopover(overrides: {
onClose={() => {}}
onSaveComment={() => {}}
onSendBatch={() => {}}
onRemove={() => {}}
onRemoveMember={() => {}}
onHoverMember={overrides.onHoverMember}
sending={false}

View file

@ -53,7 +53,6 @@ function renderPopover(overrides: {
onClose={() => {}}
onSaveComment={() => {}}
onSendBatch={() => {}}
onRemove={() => {}}
onRemoveMember={overrides.onRemoveMember}
sending={false}
t={((key: string) => String(key)) as never}

View file

@ -109,7 +109,6 @@ describe('ChatComposer infinite re-render regression (#2097)', () => {
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
await waitFor(() => expect(window.localStorage.getItem(key)).toBeNull());
});
it('does not re-sync the composer scroll offset on every plain-text keystroke', () => {
const scrollTopGetter = vi.fn(() => 0);
const original = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'scrollTop');

View file

@ -7,7 +7,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';
import type { Conversation, ProjectMetadata } from '../../src/types';
const composerMocks = vi.hoisted(() => ({ setDraft: vi.fn() }));
const composerMocks = vi.hoisted(() => ({
focus: vi.fn(),
restoreDraft: vi.fn(),
setDraft: vi.fn(),
}));
vi.mock('../../src/i18n', () => ({
useI18n: () => ({ locale: 'en', setLocale: () => undefined, t: (key: string) => key }),
@ -16,7 +20,11 @@ vi.mock('../../src/i18n', () => ({
vi.mock('../../src/components/ChatComposer', () => ({
ChatComposer: forwardRef((_props, ref) => {
useImperativeHandle(ref, () => ({ setDraft: composerMocks.setDraft }));
useImperativeHandle(ref, () => ({
focus: composerMocks.focus,
restoreDraft: composerMocks.restoreDraft,
setDraft: composerMocks.setDraft,
}));
return <output data-testid="composer" />;
}),
}));

View file

@ -1,13 +1,20 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { forwardRef } from 'react';
import { forwardRef, useImperativeHandle } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatPane, retryableAssistantMessage } from '../../src/components/ChatPane';
import { DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX } from '../../src/design-system-auto-prompt';
import { readExpandedIndexCss } from '../helpers/read-expanded-css';
import type { ChatMessage, Conversation, ProjectMetadata } from '../../src/types';
const composerMocks = vi.hoisted(() => ({
focus: vi.fn(),
restoreDraft: vi.fn(),
setDraft: vi.fn(),
}));
const translations: Record<string, string> = {
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
@ -35,9 +42,14 @@ vi.mock('../../src/components/AssistantMessage', () => ({
}));
vi.mock('../../src/components/ChatComposer', () => ({
ChatComposer: forwardRef(({ streaming }: { streaming: boolean }, _ref) => (
<output data-testid="composer-streaming">{streaming ? 'streaming' : 'idle'}</output>
)),
ChatComposer: forwardRef(({ streaming }: { streaming: boolean }, ref) => {
useImperativeHandle(ref, () => ({
focus: composerMocks.focus,
restoreDraft: composerMocks.restoreDraft,
setDraft: composerMocks.setDraft,
}));
return <output data-testid="composer-streaming">{streaming ? 'streaming' : 'idle'}</output>;
}),
}));
class MockResizeObserver {
@ -80,10 +92,25 @@ beforeEach(() => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe('ChatPane streaming state', () => {
it('keeps queued-send strip styles compact above the composer', () => {
const css = readExpandedIndexCss();
expect(css).toContain('.chat-queued-send-strip');
expect(css).toContain('display: flex;');
expect(css).toContain('.chat-queued-send-row');
expect(css).toContain('align-items: center;');
expect(css).toContain('.chat-queued-send-title');
expect(css).toContain('text-overflow: ellipsis;');
expect(css).toContain('.chat-queued-send-action');
expect(css).toContain('width: 26px;');
expect(css).toContain('height: 26px;');
});
it('exposes retry only for the last failed assistant when the pane is idle', () => {
const failed: ChatMessage = {
id: 'assistant-1',
@ -137,6 +164,54 @@ describe('ChatPane streaming state', () => {
expect(bubble.closest('.msg.user')).not.toBeNull();
});
it('hides internal path ids from comment attachment chips', () => {
const messages: ChatMessage[] = [
{
id: 'user-1',
role: 'user',
content: '',
createdAt: 1,
commentAttachments: [
{
id: 'comment-1',
order: 1,
filePath: 'preview.html',
elementId: 'path-0-0-0-0-1',
selector: '[data-od-id="path-0-0-0-0-1"]',
label: '',
comment: '222',
currentText: '',
pagePosition: { x: 10, y: 20, width: 30, height: 40 },
htmlHint: '<div>',
},
],
},
];
render(
<ChatPane
projectKindForTracking="prototype"
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('Annotation')).toBeTruthy();
expect(screen.getByText('222')).toBeTruthy();
expect(screen.queryByText('path-0-0-0-0-1')).toBeNull();
});
it('summarizes auto-sent design-system workspace prompts', () => {
const messages: ChatMessage[] = [
{
@ -273,7 +348,25 @@ Expected output:
projectId="project-1"
projectFiles={[]}
queuedItems={[
{ id: 'queued-1', prompt: 'Make the export button larger and use a warmer accent' },
{
id: 'queued-1',
prompt: 'Make the export button larger and use a warmer accent',
attachments: [{ path: 'brief.md', name: 'brief.md', kind: 'file' }],
commentAttachments: [
{
id: 'comment-1',
order: 1,
filePath: 'preview.html',
elementId: 'hero',
selector: '#hero',
label: 'Hero',
comment: 'Use a warmer accent',
currentText: 'Export',
pagePosition: { x: 10, y: 20, width: 30, height: 40 },
htmlHint: '<section id="hero">',
},
],
},
{ id: 'queued-2', prompt: 'Then adjust the title spacing' },
{ id: 'queued-3', prompt: 'Reduce the subtitle size' },
{ id: 'queued-4', prompt: 'Switch to a lighter font weight' },
@ -392,7 +485,6 @@ Expected output:
expect(log!.scrollTop).toBe(600);
});
});
const conversations: Conversation[] = [

View file

@ -23,6 +23,19 @@ vi.mock('../../src/components/ManualEditPanel', async (importOriginal) => {
import { FileViewer } from '../../src/components/FileViewer';
function openManualTools() {
// Manual tools now live directly in the primary toolbar.
}
function clickManualTool(testId: string) {
openManualTools();
fireEvent.click(screen.getByTestId(testId));
}
function clickAgentTool(testId: string) {
fireEvent.click(screen.getByTestId(testId));
}
afterEach(() => {
cleanup();
panelState.props = null;
@ -64,16 +77,17 @@ describe('FileViewer manual edit history regressions', () => {
/>,
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
clickManualTool('manual-edit-mode-toggle');
await waitFor(() => expect(panelState.props).not.toBeNull());
act(() => {
panelState.props?.onStyleChange?.('hero', { color: '#ef4444' }, 'Style: Hero');
});
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
clickAgentTool('draw-overlay-toggle');
await waitFor(() => expect(savedSources).toHaveLength(1));
expect(savedSources[0]).toContain('rgb(239, 68, 68)');
openManualTools();
expect(screen.getByTestId('manual-edit-mode-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('false');
@ -85,7 +99,10 @@ describe('FileViewer manual edit history regressions', () => {
await saveResponse;
});
await waitFor(() => expect(screen.getByTestId('manual-edit-mode-toggle').getAttribute('aria-pressed')).toBe('false'));
await waitFor(() => {
openManualTools();
expect(screen.getByTestId('manual-edit-mode-toggle').getAttribute('aria-pressed')).toBe('false');
});
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('true');
});
@ -123,7 +140,7 @@ describe('FileViewer manual edit history regressions', () => {
/>,
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
clickManualTool('manual-edit-mode-toggle');
await waitFor(() => expect(panelState.props).not.toBeNull());
act(() => {
@ -195,8 +212,6 @@ describe('FileViewer manual edit history regressions', () => {
expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc');
expect(panelState.props?.draft.fullSource).toContain('Hero');
});
const postMessageSpy = vi.spyOn(getActivePreviewFrame().contentWindow!, 'postMessage');
act(() => {
panelState.props?.onApplyPatch(
{ id: 'hero', kind: 'set-text', value: 'Updated hero' },
@ -207,13 +222,7 @@ describe('FileViewer manual edit history regressions', () => {
await waitFor(() => expect(savedSources).toHaveLength(1));
await waitFor(() => expect(panelState.props?.draft.fullSource).toContain('Updated hero'));
await waitFor(() => {
expect(postMessageSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'od:srcdoc-transport-activate',
html: expect.stringContaining('Updated hero'),
}),
'*',
);
expect(getActivePreviewFrame().srcdoc).toContain('Updated hero');
});
});
@ -280,13 +289,8 @@ describe('FileViewer manual edit history regressions', () => {
'*',
);
await waitFor(() => {
expect(postMessageSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'od:srcdoc-transport-activate',
html: expect.not.stringContaining('data-od-id="hero"'),
}),
'*',
);
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc)
.not.toContain('data-od-id="hero"');
});
});
});

View file

@ -15,6 +15,10 @@ afterEach(() => {
});
describe('FileViewer manual edit regressions', () => {
function clickManualTool(testId: string) {
fireEvent.click(screen.getByTestId(testId));
}
it('removes invalid fields from pending manual edit style saves without dropping unrelated fields', () => {
expect(cancelManualEditPendingStyleSnapshot({
id: 'hero',
@ -145,6 +149,56 @@ describe('FileViewer manual edit regressions', () => {
vi.useRealTimers();
}
});
it('clears a prior manual edit save error after a later successful save', async () => {
const source = '<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>';
let saveAttempts = 0;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
saveAttempts += 1;
if (saveAttempts === 1) {
return new Response(JSON.stringify({
error: { code: 'FORBIDDEN', message: 'Request failed (403).' },
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/raw/preview.html')) {
return new Response(source, { status: 200 });
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml={source}
/>,
);
clickManualTool('manual-edit-mode-toggle');
const baseSizeInput = await waitFor(() => {
const input = Array.from(document.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Base size'))
?.querySelector('input') as HTMLInputElement | null;
if (!input) throw new Error('Base size input not found');
return input;
});
fireEvent.change(baseSizeInput, { target: { value: '18' } });
await waitFor(() => {
expect(screen.getByText(/Could not save the edited file/)).toBeTruthy();
});
fireEvent.change(baseSizeInput, { target: { value: '19' } });
await waitFor(() => {
expect(screen.queryByText(/Could not save the edited file/)).toBeNull();
});
});
});
function htmlPreviewFile(): ProjectFile {

View file

@ -78,6 +78,10 @@ function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
});
}
function clickAgentTool(testId: string) {
fireEvent.click(screen.getByTestId(testId));
}
describe('FileViewer preview scale', () => {
it('keeps file viewer selectors in the effective global stylesheet', () => {
const css = readExpandedIndexCss();
@ -87,6 +91,19 @@ describe('FileViewer preview scale', () => {
expect(css).toContain('.viewer-action');
});
it('keeps the manual edit titlebar from overlapping the close button', () => {
const css = readExpandedIndexCss();
expect(css).toContain('.manual-edit-titlebar');
expect(css).toContain('justify-content: space-between;');
expect(css).toContain('.manual-edit-titlebar > span');
expect(css).toContain('text-overflow: ellipsis;');
expect(css).toContain('.manual-edit-titlebar-close');
expect(css).toContain('flex: 0 0 auto;');
expect(css).toContain('width: 38px;');
expect(css).toContain('height: 38px;');
});
it('uses the requested zoom for desktop preview overlays', () => {
expect(effectivePreviewScale('desktop', 1.5, { width: 320, height: 480 })).toBe(1.5);
});
@ -377,10 +394,6 @@ describe('FileViewer SVG artifacts', () => {
<FileViewer projectId="project-1" projectKind="prototype" file={file} liveHtml="<html><body>hi</body></html>" />,
);
// Both iframes are always mounted (the lazy srcDoc transport avoids
// booting the artifact in the inactive frame). `data-od-active` and
// the testid pair identify which iframe is currently the user-facing
// one without unmounting either side.
expect(markup).toContain('data-testid="artifact-preview-frame"');
expect(markup).toContain('data-od-render-mode="url-load"');
expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"');
@ -424,8 +437,7 @@ describe('FileViewer SVG artifacts', () => {
expect(srcDocFrame?.srcdoc).toContain('data-od-lazy-srcdoc-transport');
expect(srcDocFrame?.srcdoc).not.toContain('__odArtifactBootCount');
const postMessageSpy = vi.spyOn(srcDocFrame!.contentWindow!, 'postMessage');
fireEvent.click(screen.getByTestId('inspect-mode-toggle'));
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
const urlFrameAfter = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement | null;
const srcDocFrameAfter = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null;
@ -435,15 +447,8 @@ describe('FileViewer SVG artifacts', () => {
expect(urlFrameAfter?.getAttribute('data-od-active')).toBe('false');
expect(urlFrameAfter?.getAttribute('src')).toBe('about:blank');
expect(srcDocFrameAfter?.getAttribute('data-od-active')).toBe('true');
expect(srcDocFrameAfter?.srcdoc).toContain('data-od-lazy-srcdoc-transport');
expect(srcDocFrameAfter?.srcdoc).not.toContain('__odArtifactBootCount');
await waitFor(() => {
const activations = srcDocActivationMessages(postMessageSpy.mock.calls);
expect(activations.at(-1)?.html).toContain('__odArtifactBootCount');
expect(activations.at(-1)?.html).toContain('data-od-selection-bridge');
expect(activations.at(-1)?.html).toContain('data-od-preview-focus-guard');
});
expect(srcDocFrameAfter?.srcdoc).toContain('__odArtifactBootCount');
expect(srcDocFrameAfter?.srcdoc).toContain('data-od-edit-bridge');
});
it('reactivates the srcDoc transport after switching source back to preview', async () => {
@ -471,31 +476,23 @@ describe('FileViewer SVG artifacts', () => {
/>,
);
fireEvent.click(screen.getByTestId('inspect-mode-toggle'));
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => {
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
});
fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Code' }));
fireEvent.click(screen.getByRole('tab', { name: 'Code' }));
expect(screen.queryByTestId('artifact-preview-frame')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Code' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Preview' }));
const remountedFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
const postMessageSpy = vi.spyOn(remountedFrame.contentWindow!, 'postMessage');
fireEvent.click(screen.getByRole('tab', { name: 'Preview' }));
await waitFor(() => {
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
});
await waitFor(() => {
const activations = srcDocActivationMessages(postMessageSpy.mock.calls);
expect(activations.at(-1)?.html).toContain('data-od-selection-bridge');
expect(activations.at(-1)?.html).toContain('Hero');
expect(activeFrame.srcdoc).toContain('data-od-edit-bridge');
expect(activeFrame.srcdoc).toContain('Hero');
});
});
@ -683,7 +680,7 @@ describe('FileViewer SVG artifacts', () => {
// Back on Preview, clicking the entry opens the HTML page and closes the
// dead-end module tab (icons.jsx) in one move.
fireEvent.click(screen.getByRole('button', { name: /preview/i }));
fireEvent.click(screen.getByRole('button', { name: /^preview$/i }));
fireEvent.click(await screen.findByRole('button', { name: /backups\.html/ }));
expect(onOpenFileReplacing).toHaveBeenCalledWith('backups.html', 'icons.jsx');
});
@ -772,17 +769,16 @@ describe('FileViewer SVG artifacts', () => {
);
expect(container.querySelector('.deck-nav')).toBeTruthy();
expect(container.querySelector('.palette-tweaks-anchor')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Manual' })).toBeNull();
expect(container.querySelector('.viewer-viewport-switcher')).toBeTruthy();
expect(screen.queryByTestId('palette-tweaks-toggle')).toBeNull();
fireEvent.click(container.querySelector('.viewer-mode-trigger')!);
fireEvent.click(screen.getByRole('menuitem', { name: /code/i }));
fireEvent.click(screen.getByRole('tab', { name: 'Code' }));
await waitFor(() => {
expect(container.querySelector('.deck-nav')).toBeNull();
expect(container.querySelector('.palette-tweaks-anchor')).toBeNull();
expect(container.querySelector('.viewer-viewport-switcher')).toBeNull();
expect(screen.getByTestId('manual-edit-mode-toggle')).toBeTruthy();
expect(screen.queryByTestId('manual-edit-mode-toggle')).toBeNull();
expect(screen.queryByTestId('draw-overlay-toggle')).toBeNull();
expect(screen.queryByTestId('palette-tweaks-toggle')).toBeNull();
expect(screen.queryByRole('button', { name: /100%/ })).toBeNull();
@ -833,7 +829,7 @@ describe('FileViewer SVG artifacts', () => {
expect(screen.queryByLabelText('Pages project name')).toBeNull();
});
it('nudges the share button once when an artifact becomes exportable', async () => {
it('nudges the export button once when an artifact becomes exportable', async () => {
const file = baseFile({
name: 'nudge.html',
path: 'nudge.html',
@ -1427,58 +1423,41 @@ describe('FileViewer tweaks toolbar', () => {
});
}
it('renders the toolbar Draw entry alongside restored Comment and Inspect entries', () => {
it('renders Annotation, Edit, and Draw as the primary preview tools', async () => {
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
expect(screen.getByTestId('palette-tweaks-toggle')).toBeTruthy();
expect(screen.queryByTestId('palette-tweaks-toggle')).toBeNull();
expect(screen.queryByTestId('inspect-mode-toggle')).toBeNull();
expect(screen.getByTestId('board-mode-toggle')).toBeTruthy();
expect(screen.getByTestId('inspect-mode-toggle')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'More annotation tools' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: 'Pick element' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: 'Region' })).toBeNull();
expect(screen.getByTestId('draw-overlay-toggle')).toBeTruthy();
expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
expect(screen.queryByTestId('comment-mode-toggle')).toBeNull();
expect(screen.getByRole('button', { name: 'Draw' })).toBeTruthy();
expect(screen.getByTestId('screenshot-capture-toggle')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Screenshot' })).toBeTruthy();
expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull();
expect(screen.queryByRole('button', { name: 'Pods' })).toBeNull();
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
expect(screen.getByPlaceholderText('Type anywhere to add a note')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Click' })).toBeTruthy();
expect(screen.getByPlaceholderText('Add a note for this annotation')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Click' })).toBeNull();
expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Redo' })).toBeTruthy();
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
});
clickAgentTool('draw-overlay-toggle');
expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull();
it('shows an inspect notice when a clicked child resolves to an annotated ancestor', async () => {
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero"><h1>Hero</h1></main></body></html>'
/>,
);
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
fireEvent.click(screen.getByTestId('inspect-mode-toggle'));
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od:comment-target',
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'main',
text: 'Hero',
style: {},
clickedDescendant: {
label: 'h1',
text: 'Hero',
},
},
}));
const notice = await screen.findByTestId('inspect-ancestor-notice');
expect(notice.textContent).toContain('You clicked h1');
expect(notice.textContent).toContain('Editing main instead');
fireEvent.click(screen.getByTestId('screenshot-capture-toggle'));
expect(screen.getByPlaceholderText('Add a note for this annotation')).toBeTruthy();
expect(screen.queryByRole('status')).toBeNull();
expect(screen.getByTestId('screenshot-capture-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('false');
expect(screen.getByRole('button', { name: 'Send' })).toHaveProperty('disabled', false);
});
it('keeps the Draw bar open after queueing an annotation', () => {
@ -1488,20 +1467,19 @@ describe('FileViewer tweaks toolbar', () => {
/>,
);
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
const note = screen.getByPlaceholderText('Type anywhere to add a note');
clickAgentTool('draw-overlay-toggle');
const note = screen.getByPlaceholderText('Add a note for this annotation');
fireEvent.change(note, { target: { value: 'mark this' } });
fireEvent.click(screen.getByRole('button', { name: 'Queue' }));
expect(screen.getByPlaceholderText('Type anywhere to add a note')).toBeTruthy();
expect(screen.getAllByRole('button', { name: 'Draw' })[1]?.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByRole('button', { name: 'Click' }).getAttribute('aria-pressed')).toBe('false');
expect(screen.getByPlaceholderText('Add a note for this annotation')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Click' })).toBeNull();
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
clickAgentTool('draw-overlay-toggle');
expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull();
});
it('keeps the preloaded selection bridge mounted while the Draw bar switches to click mode', async () => {
it('keeps the preloaded selection bridge mounted while the Draw bar is open', async () => {
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
@ -1511,7 +1489,7 @@ describe('FileViewer tweaks toolbar', () => {
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).getAttribute('data-od-render-mode')).toBe('url-load');
const inactiveSrcDocFrame = screen.getByTestId('artifact-preview-frame-srcdoc') as HTMLIFrameElement;
const postMessageSpy = vi.spyOn(inactiveSrcDocFrame.contentWindow!, 'postMessage');
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
clickAgentTool('draw-overlay-toggle');
const frame = await waitFor(() => {
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
@ -1522,12 +1500,10 @@ describe('FileViewer tweaks toolbar', () => {
await waitFor(() => {
expect(srcDocActivationMessages(postMessageSpy.mock.calls).at(-1)?.html).toContain('data-od-selection-bridge');
});
const initialSrcDoc = frame.srcdoc;
fireEvent.click(screen.getByRole('button', { name: 'Click' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Click' }).getAttribute('aria-pressed')).toBe('true'));
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(initialSrcDoc);
expect(screen.queryByRole('button', { name: 'Click' })).toBeNull();
expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Redo' })).toBeTruthy();
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(frame.srcdoc);
});
it('lets Draw direct send emit a queued annotation while a task is running', async () => {
@ -1541,8 +1517,8 @@ describe('FileViewer tweaks toolbar', () => {
/>,
);
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
fireEvent.change(screen.getByPlaceholderText('Type anywhere to add a note'), {
clickAgentTool('draw-overlay-toggle');
fireEvent.change(screen.getByPlaceholderText('Add a note for this annotation'), {
target: { value: 'mark this' },
});
@ -1592,15 +1568,15 @@ describe('FileViewer tweaks toolbar', () => {
/>,
);
fireEvent.click(screen.getByTestId('board-mode-toggle'));
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(screen.queryByTestId('comment-saved-marker-pin-applying')).toBeNull();
expect(screen.queryByText('Already sent to Claude')).toBeNull();
});
it('keeps the picker hint clear of the open comment side panel', () => {
render(
it('keeps comments and annotation picker mutually exclusive', () => {
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
@ -1609,25 +1585,29 @@ describe('FileViewer tweaks toolbar', () => {
/>,
);
fireEvent.click(screen.getByTestId('board-mode-toggle'));
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
expect(container.querySelector('.comment-preview-layer')?.className).not.toContain('comment-preview-layer-comments-open');
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(screen.getByTestId('inspect-empty-hint-container').className).toContain(
'comment-side-panel-open',
);
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false');
const closeButton = screen
.getByTestId('comment-side-panel')
.querySelector<HTMLButtonElement>('.comment-side-close');
expect(closeButton).toBeTruthy();
fireEvent.click(closeButton!);
clickAgentTool('board-mode-toggle');
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
expect(container.querySelector('.comment-preview-layer')?.className).not.toContain('comment-preview-layer-comments-open');
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('false');
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull();
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false');
expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull();
});
it('keeps saved comment marker numbers aligned with the side panel order', () => {
it('keeps saved comment pins visible while adding another comment', async () => {
const olderComment: PreviewComment = {
id: 'comment-older',
projectId: 'project-1',
@ -1666,11 +1646,46 @@ describe('FileViewer tweaks toolbar', () => {
/>,
);
fireEvent.click(screen.getByTestId('board-mode-toggle'));
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
expect(screen.getAllByTestId('comment-side-item')[0]?.textContent).toContain('Newer comment');
expect(screen.getByTestId('comment-saved-marker-pin-newer').textContent).toContain('1');
expect(screen.getByTestId('comment-saved-marker-pin-older').textContent).toContain('2');
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(screen.getByTestId('comment-saved-marker-pin-newer').textContent).toBe('C');
expect(screen.getByTestId('comment-saved-marker-pin-older').textContent).toBe('C');
clickAgentTool('board-mode-toggle');
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
expect(screen.queryByTestId('comment-saved-marker-pin-newer')).toBeNull();
expect(screen.queryByTestId('comment-saved-marker-pin-older')).toBeNull();
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od:comment-target',
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'Hero',
text: 'Hero',
position: { x: 8, y: 12, width: 120, height: 48 },
hoverPoint: { x: 12, y: 16 },
htmlHint: '<main data-od-id="hero">Hero</main>',
},
}));
expect((await screen.findByTestId('comment-active-pin')).textContent).toBe('C');
expect(screen.getByTestId('comment-saved-marker-pin-newer')).toBeTruthy();
expect(screen.getByTestId('comment-saved-marker-pin-older')).toBeTruthy();
fireEvent.click(screen.getByTestId('comment-saved-marker-pin-newer'));
await waitFor(() => {
const activeItem = document.querySelector('[data-comment-id="comment-newer"]');
expect(activeItem?.className).toContain('active');
expect(activeItem?.getAttribute('aria-current')).toBe('true');
});
expect(document.querySelector('[data-comment-id="comment-older"]')?.className).not.toContain('active');
});
it('does not preload non-open element comments into the picker composer', async () => {
@ -1702,7 +1717,7 @@ describe('FileViewer tweaks toolbar', () => {
);
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
fireEvent.click(screen.getByTestId('board-mode-toggle'));
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
@ -1723,6 +1738,185 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByText('Do not resurrect this note')).toBeNull();
});
it('keeps the comment composer focused on the note after picking an element', async () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od:comment-target',
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'p',
text: 'Hero',
position: { x: 8, y: 12, width: 312, height: 63 },
htmlHint: '<p data-od-id="hero">Hero</p>',
style: {
color: 'rgb(26, 25, 22)',
fontSize: '13.5px',
fontFamily: 'Inter, "PingFang SC", sans-serif',
lineHeight: '20px',
},
},
}));
expect(await screen.findByTestId('comment-popover-input')).toBeTruthy();
expect(screen.queryByTestId('annotation-style-summary')).toBeNull();
});
it('switches to the comment panel after saving an annotation comment', async () => {
function Harness() {
const [comments, setComments] = useState<PreviewComment[]>([]);
return (
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
previewComments={comments}
onSavePreviewComment={async (target, note) => {
const saved: PreviewComment = {
id: 'comment-saved',
projectId: 'project-1',
conversationId: 'conversation-1',
filePath: target.filePath,
elementId: target.elementId,
selector: target.selector,
label: target.label,
text: target.text,
htmlHint: target.htmlHint,
position: target.position,
style: target.style,
selectionKind: target.selectionKind,
memberCount: target.memberCount,
podMembers: target.podMembers,
note,
status: 'open',
createdAt: 20,
updatedAt: 20,
};
setComments([saved]);
return saved;
}}
/>
);
}
render(<Harness />);
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od:comment-target',
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'Hero',
text: 'Hero',
position: { x: 8, y: 12, width: 120, height: 48 },
htmlHint: '<main data-od-id="hero">Hero</main>',
},
}));
const input = await screen.findByTestId('comment-popover-input');
fireEvent.change(input, { target: { value: '加大字号' } });
fireEvent.click(screen.getByTestId('comment-popover-save'));
await waitFor(() => expect(screen.queryByTestId('comment-popover')).toBeNull());
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByText('加大字号')).toBeTruthy();
const activeItem = document.querySelector('[data-comment-id="comment-saved"]');
expect(activeItem?.className).toContain('active');
expect(activeItem?.getAttribute('aria-current')).toBe('true');
});
it('returns to element picking from the Comment button while another annotation tool is active', async () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
clickAgentTool('draw-overlay-toggle');
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('true');
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
expect(screen.queryByRole('menuitem', { name: 'Pick element' })).toBeNull();
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false');
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
});
it('shows element parameters on annotation hover and opens comments on click', async () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
clickAgentTool('board-mode-toggle');
const target = {
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'p',
text: 'Hero',
position: { x: 8, y: 12, width: 312, height: 63 },
hoverPoint: { x: 200, y: 100 },
htmlHint: '<p data-od-id="hero">Hero</p>',
style: {
color: 'rgb(26, 25, 22)',
fontSize: '13.5px',
fontFamily: 'Inter, "PingFang SC", sans-serif',
},
};
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: { ...target, type: 'od:comment-hover' },
}));
const summary = await screen.findByTestId('annotation-hover-style-summary');
expect(summary.textContent).toContain('Color');
expect(summary.textContent).toContain('#1A1916');
expect(summary.textContent).toContain('13.5px');
expect(screen.queryByTestId('inspect-panel')).toBeNull();
expect(screen.queryByTestId('comment-popover-input')).toBeNull();
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: { ...target, type: 'od:comment-target' },
}));
expect(await screen.findByTestId('comment-popover-input')).toBeTruthy();
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false');
expect(screen.queryByTestId('inspect-panel')).toBeNull();
await waitFor(() => {
expect(screen.queryByTestId('annotation-hover-popover')).toBeNull();
});
});
it('closes an open saved-comment composer when that comment leaves the open state', async () => {
const openComment: PreviewComment = {
id: 'comment-status-transition',
@ -1751,7 +1945,7 @@ describe('FileViewer tweaks toolbar', () => {
/>,
);
fireEvent.click(screen.getByTestId('board-mode-toggle'));
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
fireEvent.click(screen.getByRole('button', { name: 'Open comment for pin-transition' }));
expect((await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement).value)
@ -1802,6 +1996,7 @@ describe('FileViewer tweaks toolbar', () => {
},
]}
selectedIds={new Set(['comment-1'])}
activeCommentId={null}
collapsed={collapsed}
onCollapsedChange={(next) => {
onCollapseChange(next);
@ -1834,141 +2029,6 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
});
// PR #1643 regression: the once-per-file guard that mirrors a `.twk-panel`
// artifact's default-open state into the toolbar `tweaksMode` lives in a
// message-event listener that previously had an empty deps array. The
// handler therefore closed over the first-render `file.name`, so switching
// to a second `.twk-panel` file left the guard comparing against the
// stale captured name and never re-mirrored the new artifact's open state
// back to ON. Surfaced by Siri-Ray in
// https://github.com/nexu-io/open-design/pull/1643#discussion_r3266838151.
it('mirrors __edit_mode_available default-open state for each switched-to .twk-panel file', async () => {
function twkFile(name: string): ProjectFile {
return baseFile({
name,
path: name,
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: name,
entry: name,
renderer: 'html',
exports: ['html'],
},
});
}
function Switcher() {
const [file, setFile] = useState<ProjectFile>(twkFile('first.html'));
return (
<div>
<button type="button" onClick={() => setFile(twkFile('second.html'))}>
Switch file
</button>
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>
</div>
);
}
render(<Switcher />);
const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
const tweaksButton = () =>
Array.from(document.querySelectorAll('button')).find(
(b) => b.getAttribute('title') === 'Tweaks' || b.getAttribute('aria-label') === 'Tweaks',
) as HTMLButtonElement | undefined;
// First file: artifact posts __edit_mode_available → toolbar starts ON.
window.dispatchEvent(
new MessageEvent('message', {
source: firstFrame.contentWindow,
data: { type: '__edit_mode_available' },
}),
);
await waitFor(() => expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('true'));
// User toggles OFF on first file.
fireEvent.click(tweaksButton()!);
await waitFor(() => expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('false'));
// Switch to second file. The second artifact also mounts panel-visible
// and emits __edit_mode_available. The toolbar must mirror that into ON
// again — the bug was that the handler kept comparing against the first
// file's name in a stale closure, so the second emission was treated as
// a "second emission for the same file" and the OFF state stuck.
fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
const secondFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
window.dispatchEvent(
new MessageEvent('message', {
source: secondFrame.contentWindow,
data: { type: '__edit_mode_available' },
}),
);
await waitFor(() => expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('true'));
});
// PR #1643 regression: Protocol A in `design-templates/tweaks/SKILL.md`
// says the artifact MAY declare a default-closed panel via
// `{ type: '__edit_mode_available', visible: false }`. The handler used
// to unconditionally mirror availability into `tweaksMode = true`, so a
// default-closed dynamic artifact would be force-opened by the next
// `syncBridgeModes` posting `__activate_edit_mode`. The host must now
// read `visible` and only flip to ON when the panel reports itself open
// (or omits `visible` — back-compat shim for the common open-by-default
// case). Surfaced by Siri-Ray in
// https://github.com/nexu-io/open-design/pull/1643#discussion_r3269955351.
it('respects __edit_mode_available { visible: false } for default-closed dynamic artifacts', async () => {
const file = baseFile({
name: 'closed.html',
path: 'closed.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'closed',
entry: 'closed.html',
renderer: 'html',
exports: ['html'],
},
});
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
const tweaksButton = () =>
Array.from(document.querySelectorAll('button')).find(
(b) => b.getAttribute('title') === 'Tweaks' || b.getAttribute('aria-label') === 'Tweaks',
) as HTMLButtonElement | undefined;
// Artifact announces availability AND declares the panel is currently
// closed. The toolbar must enable (panel exists) but stay OFF — opening
// it without intent would override the artifact-declared default.
window.dispatchEvent(
new MessageEvent('message', {
source: frame.contentWindow,
data: { type: '__edit_mode_available', visible: false },
}),
);
await waitFor(() => expect(tweaksButton()?.disabled).toBe(false));
expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('false');
});
});
describe('applyInspectOverridesToSource', () => {
@ -2782,14 +2842,14 @@ describe('LiveArtifactViewer', () => {
expect(container.querySelector('.ghost-link')?.getAttribute('tabindex')).toBe('-1');
});
fireEvent.click(screen.getByRole('button', { name: /preview/i }));
fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
await waitFor(() => {
expect(screen.getByRole('link', { name: /^open$/i }).getAttribute('tabindex')).not.toBe('-1');
});
});
it('keeps the live preview iframe mounted across viewport and tab changes', async () => {
it('preserves the live preview iframe when switching away from preview and back', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
@ -2798,41 +2858,36 @@ describe('LiveArtifactViewer', () => {
if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
}
if (url === '/api/live-artifacts/la_1/code?projectId=proj_1&variant=template') {
return new Response('<main>Preview</main>', { status: 200 });
}
return new Response(JSON.stringify({}), { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
render(
const { container } = render(
<LiveArtifactViewer
projectId="proj_1"
liveArtifact={baseLiveArtifactWorkspaceEntry()}
/>,
);
const frame = await screen.findByTestId('live-artifact-preview-frame');
fireEvent.click(screen.getByRole('button', { name: /preview viewport/i }));
fireEvent.click(screen.getByRole('option', { name: /mobile/i }));
await screen.findByRole('link', { name: /^open$/i });
const previewLayer = frame.closest('.live-artifact-preview-layer');
expect(previewLayer?.classList.contains('preview-viewport-mobile')).toBe(true);
const previewFrame = container.querySelector('[data-testid="live-artifact-preview-frame"]');
expect(previewFrame).toBeTruthy();
expect(container.querySelector('.live-artifact-preview-layer')?.getAttribute('data-active')).toBe('true');
fireEvent.click(screen.getByRole('button', { name: /code/i }));
await waitFor(() => {
expect(previewLayer?.getAttribute('data-active')).toBe('false');
expect(container.querySelector('.live-artifact-preview-layer')?.getAttribute('data-active')).toBe('false');
});
expect(screen.getByTestId('live-artifact-preview-frame')).toBe(frame);
expect(container.querySelector('[data-testid="live-artifact-preview-frame"]')).toBe(previewFrame);
fireEvent.click(screen.getByRole('button', { name: /preview/i }));
fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
await waitFor(() => {
expect(previewLayer?.getAttribute('data-active')).toBe('true');
expect(container.querySelector('.live-artifact-preview-layer')?.getAttribute('data-active')).toBe('true');
});
expect(screen.getByTestId('live-artifact-preview-frame')).toBe(frame);
expect(frame.getAttribute('src')).toBe('/api/live-artifacts/la_1/preview?projectId=proj_1&v=0');
expect(container.querySelector('[data-testid="live-artifact-preview-frame"]')).toBe(previewFrame);
});
it('closes the present menu on Escape without tearing down the viewer', async () => {

View file

@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { HandoffButton } from '../../src/components/HandoffButton';
import { I18nProvider, type Locale } from '../../src/i18n';
import { readExpandedIndexCss } from '../helpers/read-expanded-css';
afterEach(() => {
cleanup();
@ -34,14 +35,26 @@ function renderLocalized(locale: Locale) {
}
describe('HandoffButton i18n', () => {
it('keeps the header trigger as an icon-sized split control', () => {
const css = readExpandedIndexCss();
expect(css).toContain('.app .handoff-split');
expect(css).toContain('border: 1px solid transparent;');
expect(css).toContain('.app .handoff-trigger');
expect(css).toContain('width: 32px;');
expect(css).toContain('height: 30px;');
expect(css).toContain('.app .handoff-caret');
expect(css).toContain('width: 24px;');
});
it('localizes the primary handoff label', async () => {
stubEditors([{ id: 'finder', label: 'Finder', available: true }]);
renderLocalized('en');
const trigger = await screen.findByTestId('handoff-trigger');
expect(trigger.getAttribute('title')).toBe('Hand off to Finder');
expect(trigger.textContent).toContain('Hand off to Finder');
expect(trigger.getAttribute('title')).toBe('Open in Finder');
expect(trigger.querySelector('.handoff-trigger-label')?.classList.contains('sr-only')).toBe(true);
});
it('localizes the unavailable editor section', async () => {

View file

@ -3,7 +3,6 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Simulate } from 'react-dom/test-utils';
import { JSDOM } from 'jsdom';
import { I18nProvider } from '../../src/i18n';
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary, normalizeManualEditStyles, type ManualEditDraft } from '../../src/components/ManualEditPanel';
import { emptyManualEditStyles, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../../src/edit-mode/types';
@ -53,57 +52,11 @@ describe('ManualEditPanel', () => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
it('restores manual edit tabs for content, HTML, and source edits', () => {
it('renders the style inspector without the advanced editor entry', () => {
renderPanel();
expect(host.textContent).toContain('TYPOGRAPHY');
expect(host.textContent).toContain('Content');
expect(host.textContent).toContain('HTML');
expect(host.textContent).toContain('Source');
});
it('applies selected-element HTML from the manual edit panel', () => {
const onApplyPatch = vi.fn();
renderPanel({
onApplyPatch,
outerHtml: '<h1 data-od-id="hero-title">Updated</h1>',
});
clickTab('HTML');
const htmlArea = host.querySelector('.manual-edit-code.tall') as HTMLTextAreaElement | null;
if (!htmlArea) throw new Error('HTML editor not found');
expect(htmlArea.value).toBe('<h1 data-od-id="hero-title">Updated</h1>');
const apply = buttonByText('Apply HTML');
act(() => {
apply.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onApplyPatch).toHaveBeenCalledWith(
{ id: 'hero-title', kind: 'set-outer-html', html: '<h1 data-od-id="hero-title">Updated</h1>' },
'HTML: Hero Title',
);
});
it('applies full source edits from the manual edit panel', () => {
const onApplyPatch = vi.fn();
renderPanel({
onApplyPatch,
fullSource: '<html><body><h1>Updated source</h1></body></html>',
});
clickTab('Source');
const sourceArea = host.querySelector('.manual-edit-code.tall') as HTMLTextAreaElement | null;
if (!sourceArea) throw new Error('Source editor not found');
expect(sourceArea.value).toBe('<html><body><h1>Updated source</h1></body></html>');
const apply = buttonByText('Apply Source');
act(() => {
apply.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onApplyPatch).toHaveBeenCalledWith(
{ kind: 'set-full-source', source: '<html><body><h1>Updated source</h1></body></html>' },
'Full source',
);
expect(host.textContent).not.toContain('Advanced');
});
it('allows returning from an element inspector to the page inspector', () => {
@ -120,55 +73,6 @@ describe('ManualEditPanel', () => {
expect(onClearSelection).toHaveBeenCalledTimes(1);
});
it('lists hidden targets so they can be selected outside the canvas', () => {
const onSelectTarget = vi.fn();
const hiddenTarget: ManualEditTarget = {
...target,
id: 'authors',
label: 'Authors',
tagName: 'section',
kind: 'container',
rect: { x: 0, y: 0, width: 0, height: 0 },
isHidden: true,
};
renderPanel({ targets: [target, hiddenTarget], selectedTarget: null, onSelectTarget });
const hiddenRow = Array.from(host.querySelectorAll('.manual-edit-layer-row'))
.find((row) => row.textContent?.includes('Authors')) as HTMLButtonElement | undefined;
if (!hiddenRow) throw new Error('Hidden target row not found');
expect(hiddenRow.textContent).toContain('Hidden');
act(() => {
hiddenRow.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onSelectTarget).toHaveBeenCalledWith(hiddenTarget);
});
it('renders layer panel labels from the active locale', () => {
const hiddenTarget: ManualEditTarget = {
...target,
id: 'authors',
label: 'Authors',
tagName: 'section',
kind: 'container',
isHidden: true,
};
renderPanel({ targets: [hiddenTarget], selectedTarget: null, locale: 'fr' });
expect(host.textContent).toContain('Calques');
expect(host.textContent).toContain('Masqué');
expect(host.textContent).not.toContain('Layers');
expect(host.textContent).not.toContain('Hidden');
});
it('renders the empty layers message from the active locale', () => {
renderPanel({ targets: [], selectedTarget: null, locale: 'fr' });
expect(host.textContent).toContain('Aucun calque modifiable trouvé.');
expect(host.textContent).not.toContain('No editable layers found.');
});
it('normalizes font stacks and writes a usable font-family value', () => {
const onDraftChange = vi.fn();
const onStyleChange = vi.fn();
@ -514,48 +418,6 @@ describe('ManualEditPanel', () => {
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { flexDirection: 'column' }, 'Style: Hero Title');
});
it('keeps layout controls enabled for hidden layout containers', () => {
const onStyleChange = vi.fn();
const hiddenLayoutTarget: ManualEditTarget = {
...target,
id: 'hidden-section',
label: 'Hidden Section',
tagName: 'section',
kind: 'container',
rect: { x: 0, y: 0, width: 0, height: 0 },
isHidden: true,
isLayoutContainer: true,
};
renderPanel({
onStyleChange,
targets: [hiddenLayoutTarget],
selectedTarget: hiddenLayoutTarget,
styles: {
...emptyManualEditStyles(),
gap: '12px',
flexDirection: 'row',
},
});
const layoutSection = sectionByTitle('LAYOUT');
expect(layoutSection.classList.contains('cc-section-inactive')).toBe(false);
expect(layoutSection.textContent).not.toContain('Select a container or group to edit layout.');
const gapIncrease = layoutSection.querySelector('button[aria-label="Gap increase"]') as HTMLButtonElement | null;
const directionSelect = layoutSection.querySelector('select') as HTMLSelectElement | null;
if (!gapIncrease || !directionSelect) throw new Error('Layout controls not found');
expect(gapIncrease.disabled).toBe(false);
expect(directionSelect.disabled).toBe(false);
act(() => {
gapIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
directionSelect.value = 'column';
directionSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('hidden-section', { gap: '13px' }, 'Style: Hidden Section');
expect(onStyleChange).toHaveBeenCalledWith('hidden-section', { flexDirection: 'column' }, 'Style: Hidden Section');
});
it('summarizes full-source history entries without rendering the full file', () => {
const source = '<html><body>' + 'x'.repeat(10_000) + '</body></html>';
@ -572,20 +434,6 @@ describe('ManualEditPanel', () => {
return section;
}
function buttonByText(text: string): HTMLButtonElement {
const button = Array.from(host.querySelectorAll('button'))
.find((candidate) => candidate.textContent === text) as HTMLButtonElement | undefined;
if (!button) throw new Error(`${text} button not found`);
return button;
}
function clickTab(text: string) {
const button = buttonByText(text);
act(() => {
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
}
function renderPanel({
onDraftChange = vi.fn<OnDraftChange>(),
onApplyPatch = vi.fn<OnApplyPatch>(),
@ -593,15 +441,10 @@ describe('ManualEditPanel', () => {
onStyleChange = vi.fn<OnStyleChange>(),
onInvalidStyle = vi.fn<OnInvalidStyle>(),
onClearSelection = vi.fn<OnClearSelection>(),
onSelectTarget = vi.fn<(target: ManualEditTarget) => void>(),
attributesText = '{}',
targets = [target],
selectedTarget = target,
styles = emptyManualEditStyles(),
pageStylesEnabled = true,
outerHtml = target.outerHtml,
fullSource = '<html></html>',
locale,
}: {
onDraftChange?: OnDraftChange;
onApplyPatch?: OnApplyPatch;
@ -609,27 +452,22 @@ describe('ManualEditPanel', () => {
onStyleChange?: OnStyleChange;
onInvalidStyle?: OnInvalidStyle;
onClearSelection?: OnClearSelection;
onSelectTarget?: (target: ManualEditTarget) => void;
attributesText?: string;
targets?: ManualEditTarget[];
selectedTarget?: ManualEditTarget | null;
styles?: ReturnType<typeof emptyManualEditStyles>;
pageStylesEnabled?: boolean;
outerHtml?: string;
fullSource?: string;
locale?: 'en' | 'fr';
} = {}) {
const draft = {
...emptyManualEditDraft(fullSource),
...emptyManualEditDraft('<html></html>'),
text: 'Updated copy',
attributesText,
styles,
outerHtml,
outerHtml: target.outerHtml,
};
act(() => {
const panel = (
root.render(
<ManualEditPanel
targets={targets}
targets={[target]}
selectedTarget={selectedTarget}
draft={draft}
history={[]}
@ -637,7 +475,7 @@ describe('ManualEditPanel', () => {
canUndo={false}
canRedo={false}
pageStylesEnabled={pageStylesEnabled}
onSelectTarget={onSelectTarget}
onSelectTarget={vi.fn<(target: ManualEditTarget) => void>()}
onDraftChange={onDraftChange}
onStyleChange={onStyleChange}
onInvalidStyle={onInvalidStyle}
@ -647,10 +485,7 @@ describe('ManualEditPanel', () => {
onCancelDraft={vi.fn<() => void>()}
onUndo={vi.fn<() => void>()}
onRedo={vi.fn<() => void>()}
/>
);
root.render(
locale ? <I18nProvider initial={locale}>{panel}</I18nProvider> : panel,
/>,
);
});
}

View file

@ -1,10 +1,14 @@
// @vitest-environment jsdom
import { fireEvent, render, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PreviewDrawOverlay } from '../../src/components/PreviewDrawOverlay';
afterEach(() => {
cleanup();
});
describe('PreviewDrawOverlay', () => {
it('uses the visible primary send action when Enter submits a note', async () => {
const annotation = vi.fn();
@ -105,4 +109,17 @@ describe('PreviewDrawOverlay', () => {
expect(scrollBy).toHaveBeenCalledWith({ left: 12, top: 180, behavior: 'auto' });
});
it('closes the draw toolbar from an explicit close button', async () => {
const onActiveChange = vi.fn();
const { getByRole } = render(
<PreviewDrawOverlay active onActiveChange={onActiveChange}>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
fireEvent.click(getByRole('button', { name: 'Close draw toolbar' }));
expect(onActiveChange).toHaveBeenCalledWith(false);
});
});

View file

@ -89,7 +89,21 @@ vi.mock('../../src/state/projects', async () => {
});
vi.mock('../../src/components/AppChromeHeader', () => ({
AppChromeHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
AppChromeHeader: ({
children,
fileActionsBefore,
actions,
}: {
children: ReactNode;
fileActionsBefore?: ReactNode;
actions?: ReactNode;
}) => (
<header>
{children}
{fileActionsBefore}
{actions}
</header>
),
}));
vi.mock('../../src/components/AvatarMenu', () => ({
@ -170,6 +184,10 @@ function ProjectViewHarness({ initialProject }: { initialProject: Project }) {
const SAVED = 'Always use tabs, never spaces.';
async function openProjectInstructionsFromSettings() {
fireEvent.click(await screen.findByTestId('project-settings-trigger'));
}
describe('ProjectView saved Project instructions surface (#1822)', () => {
beforeEach(() => {
mockedListConversations.mockResolvedValue([conversation]);
@ -222,10 +240,10 @@ describe('ProjectView saved Project instructions surface (#1822)', () => {
it('offers an add affordance and opens an empty editor when no instructions are saved', async () => {
render(<ProjectViewHarness initialProject={baseProject} />);
const add = await screen.findByTestId('project-instructions-add');
expect(await screen.findByTestId('project-settings-trigger')).toBeTruthy();
expect(screen.queryByTestId('project-instructions-chip')).toBeNull();
fireEvent.click(add);
await openProjectInstructionsFromSettings();
const textarea = screen.getByTestId('project-instructions-textarea') as HTMLTextAreaElement;
expect(textarea.value).toBe('');
@ -235,7 +253,7 @@ describe('ProjectView saved Project instructions surface (#1822)', () => {
mockedPatchProject.mockResolvedValue({ ...baseProject, customInstructions: SAVED });
render(<ProjectViewHarness initialProject={baseProject} />);
fireEvent.click(await screen.findByTestId('project-instructions-add'));
await openProjectInstructionsFromSettings();
fireEvent.change(screen.getByTestId('project-instructions-textarea'), {
target: { value: SAVED },
});

View file

@ -9,6 +9,7 @@ import {
findExistingArtifactProjectFile,
resolveRetryTarget,
resolveSucceededRunStatus,
selectPrimaryProjectFile,
shouldClearActiveRunRefs,
} from '../../src/components/ProjectView';
import type { Artifact, ChatMessage, ProjectFile } from '../../src/types';
@ -65,6 +66,22 @@ function artifactProjectFile(name: string, mtime: number): ProjectFile {
};
}
function projectFile(
name: string,
kind: ProjectFile['kind'],
mtime: number,
artifactManifest?: ProjectFile['artifactManifest'],
): ProjectFile {
return {
artifactManifest,
kind,
mime: kind === 'html' ? 'text/html' : 'application/octet-stream',
mtime,
name,
size: 100,
};
}
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
@ -168,6 +185,30 @@ describe('terminal replay artifact recovery', () => {
});
});
describe('selectPrimaryProjectFile', () => {
it('prefers explicit primary manifests over newer renderable files', () => {
const newer = projectFile('preview.html', 'html', 2_000);
const primary = projectFile('index.html', 'html', 1_000, {
entry: 'index.html',
exports: ['html'],
kind: 'html',
primary: true,
renderer: 'html',
title: 'Index',
version: 1,
});
expect(selectPrimaryProjectFile([newer, primary])).toBe(primary);
});
it('ignores sidecar manifest files when choosing a fallback', () => {
const sidecar = projectFile('index.html.artifact.json', 'text', 2_000);
const html = projectFile('index.html', 'html', 1_000);
expect(selectPrimaryProjectFile([sidecar, html])).toBe(html);
});
});
describe('retry target resolution', () => {
const userMessage: ChatMessage = {
id: 'user-1',

View file

@ -108,12 +108,30 @@ vi.mock('../../src/components/FileWorkspace', () => ({
FileWorkspace: ({
streaming,
onSendBoardCommentAttachments,
onCommentModeChange,
onFocusModeChange,
}: {
streaming: boolean;
onSendBoardCommentAttachments: (attachments: unknown[]) => void;
onCommentModeChange?: (active: boolean) => void;
onFocusModeChange?: (focused: boolean) => void;
}) => (
<>
<output data-testid="workspace-streaming-state">{streaming ? 'streaming' : 'idle'}</output>
<button
type="button"
data-testid="workspace-open-comments"
onClick={() => onCommentModeChange?.(true)}
>
open comments
</button>
<button
type="button"
data-testid="workspace-focus-mode"
onClick={() => onFocusModeChange?.(true)}
>
focus workspace
</button>
<button
type="button"
data-testid="workspace-send-comment"
@ -130,35 +148,35 @@ vi.mock('../../src/components/Loading', () => ({
}));
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: ({
activeConversationId,
conversations,
streaming,
sendDisabled,
queuedItems,
previewComments,
attachedComments,
onAttachComment,
onSelectConversation,
onSend,
onSendQueuedNow,
onNewConversation,
error,
}: {
activeConversationId: string | null;
conversations: Conversation[];
streaming: boolean;
sendDisabled?: boolean;
queuedItems?: Array<{ id: string; prompt: string }>;
previewComments?: PreviewComment[];
attachedComments?: PreviewComment[];
error: string | null;
onAttachComment?: (comment: PreviewComment) => void;
onSelectConversation: (id: string) => void;
onSend: (prompt: string, attachments: unknown[], commentAttachments: unknown[]) => void;
onSendQueuedNow?: (id: string) => void;
onNewConversation: () => void;
}) => {
ChatPane: ({
activeConversationId,
conversations,
streaming,
sendDisabled,
queuedItems,
previewComments,
attachedComments,
onAttachComment,
onSelectConversation,
onSend,
onSendQueuedNow,
onNewConversation,
error,
}: {
activeConversationId: string | null;
conversations: Conversation[];
streaming: boolean;
sendDisabled?: boolean;
queuedItems?: Array<{ id: string; prompt: string }>;
previewComments?: PreviewComment[];
attachedComments?: PreviewComment[];
error: string | null;
onAttachComment?: (comment: PreviewComment) => void;
onSelectConversation: (id: string) => void;
onSend: (prompt: string, attachments: unknown[], commentAttachments: unknown[]) => void;
onSendQueuedNow?: (id: string) => void;
onNewConversation: () => void;
}) => {
const attached = attachedComments ?? [];
return (
<section>
@ -515,6 +533,30 @@ describe('ProjectView conversation run isolation', () => {
expect(reattachDaemonRun).not.toHaveBeenCalled();
});
it('returns to chat after sending board comments from the comment surface', async () => {
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
fireEvent.click(screen.getByTestId('conversation-select-conv-b'));
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-b'));
if (!resolveConversationBMessages) throw new Error('Expected conv-b message load to be pending');
resolveConversationBMessages([]);
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
fireEvent.click(screen.getByTestId('workspace-focus-mode'));
await waitFor(() =>
expect(screen.getByTestId('active-conversation').closest('.split-chat-slot')?.hasAttribute('hidden')).toBe(true),
);
fireEvent.click(screen.getByTestId('workspace-open-comments'));
fireEvent.click(screen.getByTestId('workspace-send-comment'));
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-b'));
expect(screen.getByTestId('active-conversation').closest('.split-chat-slot')?.hasAttribute('hidden')).toBe(false);
expect(streamViaDaemon).toHaveBeenCalledWith(expect.objectContaining({
conversationId: 'conv-b',
projectId: 'project-1',
}));
});
it('detaches saved comment attachments after queueing them for a busy conversation', async () => {
fetchPreviewComments.mockResolvedValue([previewComment]);
@ -610,7 +652,6 @@ describe('ProjectView conversation run isolation', () => {
};
expect(payload.history?.at(-1)).toMatchObject({ role: 'user', content: 'hello from c' });
});
it('surfaces conversation message load errors and keeps sends disabled until messages load', async () => {
let conversationBLoadAttempts = 0;
listMessages.mockImplementation(async (_projectId: string, conversationId: string) => {

View file

@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { cleanup, render, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@ -19,8 +19,9 @@ import {
listConversations,
listMessages,
loadTabs,
saveTabs,
} from '../../src/state/projects';
import { fetchPreviewComments } from '../../src/providers/registry';
import { fetchPreviewComments, fetchProjectFiles } from '../../src/providers/registry';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
@ -98,7 +99,21 @@ vi.mock('../../src/components/AvatarMenu', () => ({
}));
vi.mock('../../src/components/FileWorkspace', () => ({
FileWorkspace: () => <div data-testid="file-workspace" />,
FileWorkspace: ({ tabsState, onTabsStateChange }: {
tabsState: { tabs: string[]; active: string | null };
onTabsStateChange: (state: { tabs: string[]; active: string | null }) => void;
}) => (
<div data-testid="file-workspace">
<output data-testid="workspace-active-tab">{tabsState.active ?? ''}</output>
<button
type="button"
data-testid="close-all-tabs"
onClick={() => onTabsStateChange({ tabs: [], active: null })}
>
close all tabs
</button>
</div>
),
}));
vi.mock('../../src/components/Loading', () => ({
@ -113,7 +128,9 @@ const mockedListConversations = vi.mocked(listConversations);
const mockedCreateConversation = vi.mocked(createConversation);
const mockedListMessages = vi.mocked(listMessages);
const mockedLoadTabs = vi.mocked(loadTabs);
const mockedSaveTabs = vi.mocked(saveTabs);
const mockedFetchPreviewComments = vi.mocked(fetchPreviewComments);
const mockedFetchProjectFiles = vi.mocked(fetchProjectFiles);
const mockedNavigate = vi.mocked(navigate);
const config: AppConfig = {
@ -174,6 +191,7 @@ describe('ProjectView tab URL hydration', () => {
mockedCreateConversation.mockResolvedValue(conversation);
mockedListMessages.mockResolvedValue([]);
mockedLoadTabs.mockResolvedValue({ tabs: ['index.html'], active: 'index.html' });
mockedFetchProjectFiles.mockResolvedValue([]);
mockedFetchPreviewComments.mockResolvedValue([]);
});
@ -251,4 +269,70 @@ describe('ProjectView tab URL hydration', () => {
);
});
});
it('does not reopen the primary file after the user closes the last tab', async () => {
mockedLoadTabs.mockResolvedValue({ tabs: [], active: null });
mockedFetchProjectFiles.mockResolvedValue([
{
name: 'index.html',
path: 'index.html',
type: 'file',
size: 1,
mtime: 1,
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Index',
entry: 'index.html',
renderer: 'html',
primary: true,
exports: ['html'],
},
},
]);
renderProjectView();
await waitFor(() => expect(screen.getByTestId('workspace-active-tab').textContent).toBe('index.html'));
expect(mockedSaveTabs).toHaveBeenCalledWith(project.id, { tabs: ['index.html'], active: 'index.html' });
fireEvent.click(screen.getByTestId('close-all-tabs'));
await waitFor(() => expect(screen.getByTestId('workspace-active-tab').textContent).toBe(''));
await waitFor(() => {
expect(mockedSaveTabs.mock.calls.at(-1)).toEqual([project.id, { tabs: [], active: null }]);
});
expect(mockedSaveTabs).toHaveBeenCalledTimes(2);
});
it('does not auto-open the primary file when saved tabs were explicitly empty', async () => {
mockedLoadTabs.mockResolvedValue({ tabs: [], active: null, hasSavedState: true });
mockedFetchProjectFiles.mockResolvedValue([
{
name: 'index.html',
path: 'index.html',
type: 'file',
size: 1,
mtime: 1,
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Index',
entry: 'index.html',
renderer: 'html',
primary: true,
exports: ['html'],
},
},
]);
renderProjectView();
await waitFor(() => expect(screen.getByTestId('workspace-active-tab').textContent).toBe(''));
expect(mockedSaveTabs).not.toHaveBeenCalled();
});
});

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import {
buildManualEditBridge,
@ -338,4 +338,59 @@ describe('manual edit bridge target normalization', () => {
expect(bridge).toContain('isLayoutContainer: isLayoutContainer(el)');
expect(bridge).toContain("display.indexOf('flex') >= 0 || display.indexOf('grid') >= 0");
});
it('turns text targets into inline editors and commits changed text', () => {
const dom = new JSDOM(
`<main><h1 data-od-id="title">Original title</h1></main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const title = dom.window.document.querySelector('[data-od-id="title"]') as HTMLElement;
const postMessage = vi.spyOn(dom.window.parent, 'postMessage');
title.dispatchEvent(new dom.window.MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: 8,
clientY: 8,
}));
expect(title.getAttribute('contenteditable')).toBe('plaintext-only');
expect(title.getAttribute('data-od-editing')).toBe('true');
title.textContent = 'Edited title';
title.dispatchEvent(new dom.window.FocusEvent('blur', { bubbles: false }));
expect(title.hasAttribute('contenteditable')).toBe(false);
expect(title.hasAttribute('data-od-editing')).toBe(false);
expect(postMessage).toHaveBeenCalledWith({
type: 'od-edit-text-commit',
id: 'title',
value: 'Edited title',
}, '*');
dom.window.close();
});
it('cancels inline text edits with Escape without posting a commit', () => {
const dom = new JSDOM(
`<main><p data-od-id="body">Original body</p></main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const body = dom.window.document.querySelector('[data-od-id="body"]') as HTMLElement;
const postMessage = vi.spyOn(dom.window.parent, 'postMessage');
body.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
body.textContent = 'Draft body';
body.dispatchEvent(new dom.window.KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Escape',
}));
expect(body.textContent).toBe('Original body');
expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'od-edit-text-commit',
}), '*');
dom.window.close();
});
});

View file

@ -228,8 +228,8 @@ test('captures the settings BYOK surface', async ({ page }) => {
});
async function openAvatarMenu(page: Parameters<typeof configureVisualPage>[0]) {
await page.locator('.avatar-menu .settings-icon-btn').click();
const menu = page.locator('.avatar-popover[role="menu"]');
await page.locator('.avatar-menu .avatar-agent-trigger').click();
const menu = page.locator('.avatar-popover[role="dialog"]');
await expect(menu).toBeVisible();
return menu;
}

View file

@ -100,9 +100,14 @@ export interface ArtifactManifest {
/**
* Optional for backward compatibility with pre-streaming artifacts.
* Daemon/web manifest normalization defaults missing values 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;
supportingFiles?: string[];
createdAt?: string;
updatedAt?: string;