Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
08b469f
feat(ipc): expose session-scoped ask cancellation events
hqhq1025 Sep 17, 2026
70d7121
fix(renderer): dismiss only host-cancelled clarification requests
hqhq1025 Sep 17, 2026
b712166
fix(workspace): coordinate conditional source publications
hqhq1025 Sep 17, 2026
10495fd
test(desktop): cover offline artifact integrity main chain
hqhq1025 Sep 17, 2026
0c16ef4
fix(exporters): encode archive asset URL path segments
hqhq1025 Sep 17, 2026
f4ac260
fix(desktop): reject inherited disabled preview controls
hqhq1025 Sep 17, 2026
e778b93
fix(workspace): preserve conflict recovery and cancel queued writes
hqhq1025 Sep 17, 2026
cdbf3cb
fix(renderer): preserve active context and monotonic chat history
hqhq1025 Sep 17, 2026
2334ef8
chore(changeset): document archive asset URL fix
hqhq1025 Sep 17, 2026
b3190e8
fix(core): cancel scaffolds before queued publication
hqhq1025 Sep 17, 2026
d4d4036
fix(desktop): preserve tweak typing through guarded autosaves
hqhq1025 Sep 17, 2026
b107296
fix(runtime): settle cancellation and bound native tool execution
hqhq1025 Sep 17, 2026
6a2336d
fix(runtime): preserve artifact component identity during tweaks
hqhq1025 Sep 17, 2026
ee57f77
fix(i18n): localize tweak compatibility notices
hqhq1025 Sep 17, 2026
cf0f0f2
Merge remote-tracking branch 'origin/main' into hqhq1025-local-startu…
hqhq1025 Sep 17, 2026
3e56195
fix(renderer): bind asynchronous answers to their question owner
hqhq1025 Sep 17, 2026
d074224
fix(renderer): order chat publication by actual read dispatch
hqhq1025 Sep 17, 2026
0aefcc1
fix(runtime): replay cached hook token captures during tweaks
hqhq1025 Sep 17, 2026
7bc55f1
fix(comments): apply only the revision submitted to generation
hqhq1025 Sep 17, 2026
5bd6c5c
test(renderer): cover cancelled imports into freeform questions
hqhq1025 Sep 17, 2026
8a53444
test(desktop): isolate tweak fixture dependency optimization
hqhq1025 Sep 17, 2026
f048ed5
test(desktop): serve built tweak browser fixture
hqhq1025 Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-chat-continuity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": patch
---

Allow text-only queue and steer messages during reference-URL and comment-application runs without resending their original context. Preserve delivered chat messages, initial history, and local tool updates when chat reads finish out of order or designs are switched.
5 changes: 5 additions & 0 deletions .changeset/cancelled-ask-modals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": patch
---

Dismiss host-cancelled clarification requests without answering them or disturbing unrelated questions and drafts, including cancellations that race initial pending-question replay.
10 changes: 10 additions & 0 deletions .changeset/coordinated-workspace-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@open-codesign/desktop": patch
"@open-codesign/core": patch
"@open-codesign/shared": patch
---

Coordinate participating workspace writers by canonical file path across designs.
Conditional saves and agent text edits compare expected disk bytes inside the
writer queue; stale agent edits fail visibly and refresh the file view for an
explicit retry. External processes remain outside the in-process atomicity boundary.
5 changes: 5 additions & 0 deletions .changeset/disabled-fieldset-preview-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": patch
---

Reject preview interactions with controls disabled by an enclosing fieldset before changing values or dispatching input. Preserve the native first-legend exception and existing inert and ARIA-disabled checks.
5 changes: 5 additions & 0 deletions .changeset/encoded-archive-asset-urls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": patch
---

Preserve URL encoding for local asset paths in exported ZIPs so filenames containing spaces, quotes, or parentheses load correctly in HTML and JSX designs without breaking CSS or JavaScript strings.
5 changes: 5 additions & 0 deletions .changeset/ordered-chat-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": patch
---

Order canonical chat snapshots by actual read dispatch after history seeding, preserving newer messages when seed requests complete out of order.
5 changes: 5 additions & 0 deletions .changeset/owned-question-imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": patch
---

Keep asynchronous clarification answers and file-field state bound to their request and session, so a cancelled upload cannot overwrite a later question with the same question ID.
8 changes: 8 additions & 0 deletions .changeset/preserve-artifact-tweak-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@open-codesign/desktop": patch
"@open-codesign/runtime": patch
---

Preserve React component identity, hook state, effects, and uncontrolled inputs when applying live tweaks to components that read tokens during rendering. Saved tweaks and filesystem watcher acknowledgements no longer rerun the artifact module on this path.

Artifacts that capture token values at module scope, render static root JSX/fragments/arrays, or use memoized components or memoization hooks (`useMemo` / `useCallback`) retain the previous module-replay behavior so their controls still update. Memoization hooks are conservatively classified even when a particular hook does not read tokens, because closures may capture values before the hook executes. An informational compatibility notice, localized in all four app languages, explains the resulting state reset once per artifact; it is not a runtime error and does not fail preview validation. Structural source replacements still rebuild the preview.
9 changes: 9 additions & 0 deletions .changeset/revision-safe-comment-application.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@open-codesign/desktop": patch
"@open-codesign/shared": patch
"@open-codesign/i18n": patch
---

Compare active-message context against the immutable comment content submitted to the running generation, not just comment IDs. Revised comments remain pending and drafts are retained.

Generation completion now uses an atomic expected-content check when marking comments applied. Only matching revisions are consumed; conflicts are reported without failing the completed generation. Late responses cannot overwrite newer visible edits, and editing an applied comment makes the new content pending. Existing explicit bulk marking remains compatible.
12 changes: 12 additions & 0 deletions .changeset/settled-runtime-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@open-codesign/core": patch
"@open-codesign/desktop": patch
---

Keep generation ownership until cancellation and cleanup settle. Bind clarification,
preview, verification, and workspace publication to cancellation; prevent cancelled
prompt/recovery admissions and preserve queued messages for explicit recovery.

Use pi's sequential ask barrier and reject remaining tools when the third failed
done check exhausts repair attempts. Report incomplete verification without another
provider turn or misclassifying the repair limit as a user cancellation.
5 changes: 5 additions & 0 deletions .changeset/steady-tweak-typing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": patch
---

Keep inferred tweak controls and keyboard focus stable while replacing text or colors, including pauses across autosave. Validate inferred colors with the browser instead of treating arbitrary words as color names. Serialize and coalesce slow saves without losing newer drafts, weakening source conflict guards, or resetting artifact state. File/design switches and independent source replacements reset control inference; save conflicts remain visible and retain recoverable text.
6 changes: 6 additions & 0 deletions .changeset/typed-ask-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@open-codesign/shared": patch
"@open-codesign/desktop": patch
---

Add a versioned, session-scoped ask cancellation event and an unsubscribable preload listener so host cancellation can dismiss interrupted questions without submitting an answer.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ Contract tests check loaded instructions, supported source examples, preference
### Preview and workflow

- **Source-backed tweaks** — the Tweaks panel reads the active preview file, not declarations in unrelated starters or previous tool results. A flat JSON object between `/*EDITMODE-BEGIN*/` and `/*EDITMODE-END*/` exposes values; optional `TWEAK_SCHEMA` chooses supported controls. Bind these values to the design (for example, `--ocd-tweak-card-radius` in CSS); discovering a declaration with `tweaks()` does not create a binding. Missing, intentionally empty, and invalid declarations have distinct explanations. Ask for useful controls in the current source rather than expecting a later generation to add them automatically. Saves merge only changed keys and reject conflicting source edits.

Conditional source saves compare the expected bytes with disk inside a per-canonical-file writer queue shared by the app's file IPC, agent text editor, scaffold, and import publications, including designs sharing a workspace. Agent replacements/inserts also check their cached source before writing and refresh the view on conflict for an explicit retry; explicit whole-file creation/replacement remains unconditional. Existing workspace rename leases are retained. This coordinates participating writers in one app process, not external editors, shell commands, other app processes, or hard-link aliases: an external change already visible at comparison is rejected, but an external write between comparison and publication cannot be made atomic by this in-process queue.

- **Compact desktop chrome** — workspace and settings headers use one row from 1,000 CSS pixels, with a two-row fallback at narrower widths and extra room for gallery tabs. Design/model tooltips preserve full names. Workspace paths and preview status share a wrapping summary row; expand the preview summary for connection settings without changing the active preview mode.
- **Fullscreen preview** — expand a runnable JSX/HTML preview within the app window without resizing sidebars manually. This works in Preview, a dedicated file tab such as `App.jsx`, and the Files tab's inline preview. Chat, file navigation, and tweak controls are temporarily hidden; the titlebar and **Exit fullscreen** remain available. Exit or press Escape to restore the previous layout without reloading the preview's forms or navigation state. Menus and artifact dialogs handle Escape first; changing designs or tabs leaves fullscreen. Other file types and connected URLs do not enable this mode.
- **Readable preview controls** — Comment mode, zoom, and Export retain visible labels and wrap below file tabs when space is tight. Zoom and export menus stay inside the window; use arrow keys / Home / End to navigate, Enter to select, and Escape to close and return focus. Preview zoom changes the artifact's display scale, not the app's control sizes or the artifact's source dimensions.
Expand Down
177 changes: 177 additions & 0 deletions apps/desktop/src/main/artifact-integrity.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { exportHtml, findSystemChrome } from '@open-codesign/exporters';
import { exportZip } from '@open-codesign/exporters/zip';
import JSZip from 'jszip';
import puppeteer, { type Browser, type Page } from 'puppeteer-core';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { runPreviewSteps } from './preview-interactions';
import { buildWorkspacePreviewDocument } from './preview-runtime';
import { createDesign, initInMemoryDb, updateDesignWorkspace } from './snapshots-db';
import { registerWorkspaceIpc } from './snapshots-ipc';

type Handler = (event: unknown, raw: unknown) => Promise<unknown>;
const handlers = vi.hoisted(() => new Map<string, Handler>());
vi.mock('./electron-runtime', () => ({
app: {
getPath: () => {
throw new Error('No application data access in this test');
},
},
dialog: {},
ipcMain: { handle: (channel: string, handler: Handler) => handlers.set(channel, handler) },
}));
vi.mock('./logger', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));

const chrome = await findSystemChrome().catch((error: unknown) => {
if (error instanceof Error && 'code' in error && error.code === 'EXPORTER_NO_CHROME') {
return null;
}
throw error;
});

describe.skipIf(chrome === null)('workspace artifact integrity main chain', () => {
let browser: Browser;
let scratch: string;
beforeAll(async () => {
if (chrome === null) throw new Error('System Chrome unavailable');
scratch = await mkdtemp(path.join(tmpdir(), 'codesign-integrity-browser-'));
browser = await puppeteer.launch({
executablePath: chrome,
userDataDir: path.join(scratch, 'browser-profile'),
headless: true,
args: [
'--no-first-run',
'--no-default-browser-check',
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
],
});
});
afterAll(async () => {
await browser?.close();
if (scratch) await rm(scratch, { recursive: true, force: true, maxRetries: 3 });
});

async function assertRendered(page: Page, file: string, title: string) {
await page.goto(pathToFileURL(file).href);
await page.waitForFunction(`document.querySelector('h1')?.textContent === ${JSON.stringify(title)}
&& Array.from(document.images).every(image => image.complete && image.naturalWidth > 0)`);
const steps = await runPreviewSteps(page, [
{ action: 'assert', selector: 'h1', text: title },
{ action: 'fill', selector: '#legend-input', value: 'Legend still enabled' },
{ action: 'assert', selector: '#legend-input', value: 'Legend still enabled' },
]);
expect(steps.every((step) => step.ok)).toBe(true);
const blocked = await runPreviewSteps(page, [
{ action: 'fill', selector: '#disabled-input', value: 'must not change' },
]);
expect(blocked[0]).toMatchObject({ ok: false });
expect(await page.evaluate(`document.querySelector('#disabled-input').value`)).toBe(
'untouched',
);
}

for (const sourcePath of ['index.html', 'App.jsx']) {
it(`preserves ${sourcePath} through source, preview, save/conflict, and offline HTML/ZIP`, async () => {
const workspace = path.join(scratch, sourcePath.replace('.', '-'));
await mkdir(path.join(workspace, 'assets'), { recursive: true });
const asset =
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><rect width="12" height="12" fill="green"/></svg>';
await writeFile(path.join(workspace, 'assets', 'mark.svg'), asset);
const body = `<main><h1>Seeded source</h1><img src="assets/mark.svg" alt="Local mark" /><fieldset disabled><legend><input id="legend-input" /></legend><input id="disabled-input" value="untouched" /></fieldset></main>`;
const source = sourcePath.endsWith('.jsx')
? `function App() { return (${body}); }`
: `<!doctype html><html><head><meta charset="utf-8"></head><body>${body}</body></html>`;
const db = initInMemoryDb();
const design = createDesign(db, 'Integrity fixture');
const sibling = createDesign(db, 'Shared workspace fixture');
updateDesignWorkspace(db, design.id, workspace);
updateDesignWorkspace(db, sibling.id, workspace);
registerWorkspaceIpc(db, () => null);
const write = handlers.get('codesign:files:v1:write');
const read = handlers.get('codesign:files:v1:read');
if (!write || !read) throw new Error('Workspace IPC not registered');
await write(null, {
schemaVersion: 1,
designId: design.id,
path: sourcePath,
content: source,
});
const page = await browser.newPage();
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(String(error)));
await page.setRequestInterception(true);
page.on('request', (request) => {
if (/^https?:/u.test(request.url())) void request.abort('blockedbyclient');
else void request.continue();
});
try {
const previewPath = path.join(workspace, 'preview.html');
await writeFile(
previewPath,
await buildWorkspacePreviewDocument(
await readFile(path.join(workspace, sourcePath), 'utf8'),
workspace,
sourcePath,
),
);
await assertRendered(page, previewPath, 'Seeded source');
const savedSource = source.replace('Seeded source', 'Saved source');
const saves = await Promise.allSettled([
write(null, {
schemaVersion: 1,
designId: design.id,
path: sourcePath,
content: savedSource,
expectedContent: source,
}),
write(null, {
schemaVersion: 1,
designId: sibling.id,
path: sourcePath,
content: savedSource,
expectedContent: source,
}),
]);
expect(saves.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(saves.find((result) => result.status === 'rejected')).toMatchObject({
reason: { code: 'IPC_CONFLICT' },
});
expect(
await read(null, { schemaVersion: 1, designId: design.id, path: sourcePath }),
).toMatchObject({
content: savedSource,
});
const diskSource = await readFile(path.join(workspace, sourcePath), 'utf8');
await writeFile(
previewPath,
await buildWorkspacePreviewDocument(diskSource, workspace, sourcePath),
);
await assertRendered(page, previewPath, 'Saved source');
const options = { assetRootPath: workspace, assetBasePath: workspace, sourcePath };
const htmlPath = path.join(workspace, 'export.html');
await exportHtml(diskSource, htmlPath, options);
await assertRendered(page, htmlPath, 'Saved source');
const zipPath = path.join(workspace, 'export.zip');
await exportZip(diskSource, zipPath, options);
const archive = await JSZip.loadAsync(await readFile(zipPath));
expect(await archive.file(`source/${sourcePath}`)?.async('string')).toBe(savedSource);
const extracted = path.join(workspace, 'extracted');
await mkdir(path.join(extracted, 'assets'), { recursive: true });
for (const entry of ['index.html', 'assets/mark.svg']) {
const file = archive.file(entry);
if (!file) throw new Error(`Missing ZIP entry: ${entry}`);
await writeFile(path.join(extracted, entry), await file.async('nodebuffer'));
}
await assertRendered(page, path.join(extracted, 'index.html'), 'Saved source');
expect(pageErrors).toEqual([]);
} finally {
await page.close();
}
}, 60_000);
}
});
Loading
Loading