mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
b5bf28060b
commit
fce444bcab
74 changed files with 6367 additions and 1875 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>"{projectInstructionsPreview}"</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;
|
||||
|
|
|
|||
27
apps/web/src/components/RemixIcon.tsx
Normal file
27
apps/web/src/components/RemixIcon.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'محادثة جديدة',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'مکالمه جدید',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '新しい会話',
|
||||
|
|
|
|||
|
|
@ -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': '새 대화 시작',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Новый разговор',
|
||||
|
|
|
|||
|
|
@ -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': 'สนทนาใหม่',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Нова розмова',
|
||||
|
|
|
|||
|
|
@ -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': '图层',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
3270
apps/web/src/styles/remixicon/remixicon.css
Normal file
3270
apps/web/src/styles/remixicon/remixicon.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
apps/web/src/styles/remixicon/remixicon.woff2
Normal file
BIN
apps/web/src/styles/remixicon/remixicon.woff2
Normal file
Binary file not shown.
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -522,4 +522,5 @@ export type {
|
|||
export interface OpenTabsState {
|
||||
tabs: ProjectWorkspaceTabId[];
|
||||
active: ProjectWorkspaceTabId | null;
|
||||
hasSavedState?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ function renderPopover(overrides: {
|
|||
onClose={() => {}}
|
||||
onSaveComment={() => {}}
|
||||
onSendBatch={() => {}}
|
||||
onRemove={() => {}}
|
||||
onRemoveMember={() => {}}
|
||||
onHoverMember={overrides.onHoverMember}
|
||||
sending={false}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ function renderPopover(overrides: {
|
|||
onClose={() => {}}
|
||||
onSaveComment={() => {}}
|
||||
onSendBatch={() => {}}
|
||||
onRemove={() => {}}
|
||||
onRemoveMember={overrides.onRemoveMember}
|
||||
sending={false}
|
||||
t={((key: string) => String(key)) as never}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue