Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/local-workspace-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": minor
---

Add workspace-backed design creation, file browsing, project rebinding, and local preview modes, including a packaged preview runtime dependency fix so desktop builds include `ms` for `puppeteer-core`/`debug`.
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
},
"dependencies": {
"jszip": "^3.10.1",
"ms": "2.1.3",
"puppeteer-core": "^24.42.0"
},
"devDependencies": {
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/main/ask-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ describe('ask-ipc', () => {
await expect(second).resolves.toEqual({ status: 'cancelled', answers: [] });
});

it('lists pending ask requests so the renderer can recover a missed event', async () => {
handlers.clear();
registerAskIpc();
const send = vi.fn();
const fakeWindow = {
isDestroyed: () => false,
webContents: { send },
} as unknown as Electron.BrowserWindow;
const inFlight = requestAsk('session-recover', sampleInput, () => fakeWindow);
const listPending = handlers.get('ask:list-pending');
if (!listPending) throw new Error('ask:list-pending handler not registered');

const result = listPending(null, undefined);

expect(result).toEqual([
expect.objectContaining({ sessionId: 'session-recover', input: sampleInput }),
]);
cancelPendingAskRequests('session-recover');
await expect(inFlight).resolves.toEqual({ status: 'cancelled', answers: [] });
});

it('rejects malformed answers for a known request instead of leaving it pending', async () => {
handlers.clear();
registerAskIpc();
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/main/ask-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface PendingAsk {
resolve: (result: AskResult) => void;
reject: (reason?: unknown) => void;
sessionId: string;
input: AskInput;
}

const pending = new Map<string, PendingAsk>();
Expand All @@ -31,6 +32,8 @@ export interface AskRequestPayload {
}

export function registerAskIpc(): void {
ipcMain.handle('ask:list-pending', () => listPendingAskRequests());

ipcMain.handle('ask:resolve', (_event, raw: unknown) => {
const requestId = readRequestId(raw, 'ask:resolve');
const entry = pending.get(requestId);
Expand Down Expand Up @@ -70,7 +73,7 @@ export function requestAsk(
): Promise<AskResult> {
const requestId = `ask-${randomUUID()}`;
return new Promise<AskResult>((resolve, reject) => {
pending.set(requestId, { resolve, reject, sessionId });
pending.set(requestId, { resolve, reject, sessionId, input });
const win = getMainWindow();
if (!win || win.isDestroyed()) {
pending.delete(requestId);
Expand All @@ -88,6 +91,14 @@ export function requestAsk(
});
}

export function listPendingAskRequests(): AskRequestPayload[] {
return Array.from(pending, ([requestId, entry]) => ({
requestId,
sessionId: entry.sessionId,
input: entry.input,
}));
}

export function cancelPendingAskRequests(sessionId: string): void {
for (const [id, entry] of pending) {
if (entry.sessionId !== sessionId) continue;
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/design-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync } from 'node:fs';
import { copyFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import type { Design } from '@open-codesign/shared';
import type { Design, WorkspaceMode } from '@open-codesign/shared';
import { type BrowserWindow, dialog, shell } from 'electron';
import { getLogger } from './logger';
import {
Expand Down Expand Up @@ -145,6 +145,7 @@ export async function bindWorkspace(
designId: string,
workspacePath: string | null,
migrateFiles: boolean,
workspaceMode?: WorkspaceMode,
): Promise<Design> {
const current = requireDesign(db, designId);

Expand Down Expand Up @@ -183,7 +184,7 @@ export async function bindWorkspace(
await migrateWorkspaceFiles(db, designId, normalizedPath);
}

const updated = updateDesignWorkspace(db, designId, normalizedPath);
const updated = updateDesignWorkspace(db, designId, normalizedPath, workspaceMode);
if (updated === null) {
throw new Error(`Design not found: ${designId}`);
}
Expand Down
26 changes: 12 additions & 14 deletions apps/desktop/src/main/done-verify.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
formatRuntimeLoadError,
Expand Down Expand Up @@ -34,23 +37,18 @@ describe('done runtime verifier error formatting', () => {
});

it('allows only the verifier file for file:// requests', () => {
const verifyFilePath = join(tmpdir(), 'codesign-done', 'verify.html');
expect(isDoneVerifierRequestAllowed(pathToFileURL(verifyFilePath).href, verifyFilePath)).toBe(
true,
);
expect(
isDoneVerifierRequestAllowed(
'file:///tmp/codesign-done/verify.html',
'/tmp/codesign-done/verify.html',
),
).toBe(true);
expect(
isDoneVerifierRequestAllowed(
'file:///Users/me/private.txt',
'/tmp/codesign-done/verify.html',
pathToFileURL(join(tmpdir(), 'private.txt')).href,
verifyFilePath,
),
).toBe(false);
expect(
isDoneVerifierRequestAllowed(
'https://fonts.googleapis.com/css2',
'/tmp/codesign-done/verify.html',
),
).toBe(true);
expect(isDoneVerifierRequestAllowed('https://fonts.googleapis.com/css2', verifyFilePath)).toBe(
true,
);
});
});
14 changes: 7 additions & 7 deletions apps/desktop/src/main/exporter-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { CodesignError } from '@open-codesign/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
Expand Down Expand Up @@ -64,7 +64,7 @@ describe('parseRequest', () => {
expect(result.sourcePath).toBe('screens/home/index.html');
expect(exportAssetOptions(result)).toMatchObject({
assetRootPath: '/workspace',
assetBasePath: '/workspace/screens/home',
assetBasePath: resolve('/workspace', 'screens/home'),
sourcePath: 'screens/home/index.html',
});
});
Expand Down Expand Up @@ -103,7 +103,7 @@ describe('parseRequest', () => {

expect(result.sourcePath).toBe('screens/home/App.jsx');
expect(exportAssetOptions(result)).toMatchObject({
assetBasePath: '/workspace/screens/home',
assetBasePath: resolve('/workspace', 'screens/home'),
sourcePath: 'screens/home/App.jsx',
});
});
Expand Down Expand Up @@ -131,7 +131,7 @@ describe('export path helpers', () => {
now: new Date('2026-05-05T10:20:30.000Z'),
});

expect(out).toBe('/Users/roy/Downloads/Launch-Deck-Q2-Home-2026-05-05-102030.pptx');
expect(out).toBe(join('/Users/roy/Downloads', 'Launch-Deck-Q2-Home-2026-05-05-102030.pptx'));
});

it('falls back to an open-codesign name inside Downloads when no design name is available', () => {
Expand All @@ -143,7 +143,7 @@ describe('export path helpers', () => {
now: new Date('2026-05-05T10:20:30.000Z'),
});

expect(out).toBe('/Users/roy/Downloads/open-codesign-App-2026-05-05-102030.md');
expect(out).toBe(join('/Users/roy/Downloads', 'open-codesign-App-2026-05-05-102030.md'));
});

it('treats legacy defaultFilename as a Downloads filename, not a cwd-relative path', () => {
Expand All @@ -154,7 +154,7 @@ describe('export path helpers', () => {
now: new Date('2026-05-05T10:20:30.000Z'),
});

expect(out).toBe('/Users/roy/Downloads/codesign-2026-05-05T06-04-43.html');
expect(out).toBe(join('/Users/roy/Downloads', 'codesign-2026-05-05T06-04-43.html'));
});

it('keeps legacy defaultFilename on the requested format extension', () => {
Expand All @@ -165,7 +165,7 @@ describe('export path helpers', () => {
now: new Date('2026-05-05T10:20:30.000Z'),
});

expect(out).toBe('/Users/roy/Downloads/preview.html.pdf');
expect(out).toBe(join('/Users/roy/Downloads', 'preview.html.pdf'));
});

it('keeps export files on the selected format extension', () => {
Expand Down
41 changes: 40 additions & 1 deletion apps/desktop/src/main/generation-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CancelGenerationPayloadV1, CodesignError } from '@open-codesign/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
acquireInFlightWorkspaceGeneration,
armGenerationTimeout,
cancelGenerationRequest,
extractGenerationTimeoutError,
Expand Down Expand Up @@ -197,12 +198,50 @@ describe('withInFlightGenerationForDesign', () => {
const controller = makeController();
const inFlight = new Map([['gen-1', controller]]);
const inFlightByDesign = new Map([['design-1', { generationId: 'gen-1', startedAt: 1234 }]]);
const inFlightByWorkspace = new Map([
['/workspace', { generationId: 'gen-1', startedAt: 1234 }],
]);
const logIpc = { info: vi.fn() };

cancelGenerationRequest('gen-1', inFlight, logIpc, inFlightByDesign);
cancelGenerationRequest('gen-1', inFlight, logIpc, inFlightByDesign, inFlightByWorkspace);

expect(inFlight.has('gen-1')).toBe(false);
expect(inFlightByDesign.has('design-1')).toBe(false);
expect(inFlightByWorkspace.has('/workspace')).toBe(false);
});
});

describe('acquireInFlightWorkspaceGeneration', () => {
it('rejects a second generation for the same workspace while the first is running', () => {
const inFlightByWorkspace = new Map<string, { generationId: string; startedAt: number }>();
const release = acquireInFlightWorkspaceGeneration('gen-1', '/workspace', inFlightByWorkspace);

expect(() =>
acquireInFlightWorkspaceGeneration('gen-2', '/workspace', inFlightByWorkspace),
).toThrow(CodesignError);

release();
expect(inFlightByWorkspace.has('/workspace')).toBe(false);
});

it('allows different workspaces to run concurrently', () => {
const inFlightByWorkspace = new Map<string, { generationId: string; startedAt: number }>();
const releaseOne = acquireInFlightWorkspaceGeneration(
'gen-1',
'/workspace-a',
inFlightByWorkspace,
);
const releaseTwo = acquireInFlightWorkspaceGeneration(
'gen-2',
'/workspace-b',
inFlightByWorkspace,
);

expect([...inFlightByWorkspace.keys()].sort()).toEqual(['/workspace-a', '/workspace-b']);

releaseOne();
releaseTwo();
expect(inFlightByWorkspace.size).toBe(0);
});
});

Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/main/generation-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function cancelGenerationRequest(
inFlight: Map<string, AbortController>,
logIpc: CancellationLogger,
inFlightByDesign?: Map<string, InFlightGeneration>,
inFlightByWorkspace?: Map<string, InFlightGeneration>,
): void {
if (typeof raw !== 'string') {
throw new CodesignError(
Expand All @@ -32,6 +33,11 @@ export function cancelGenerationRequest(
if (generation.generationId === raw) inFlightByDesign.delete(designId);
}
}
if (inFlightByWorkspace !== undefined) {
for (const [workspaceKey, generation] of inFlightByWorkspace) {
if (generation.generationId === raw) inFlightByWorkspace.delete(workspaceKey);
}
}
logIpc.info('generate.cancelled', { id: raw });
}

Expand Down Expand Up @@ -77,6 +83,27 @@ export async function withInFlightGenerationForDesign<T>(
}
}

export function acquireInFlightWorkspaceGeneration(
id: string,
workspaceKey: string,
inFlightByWorkspace: Map<string, InFlightGeneration>,
): () => void {
const existing = inFlightByWorkspace.get(workspaceKey);
if (existing !== undefined && existing.generationId !== id) {
throw new CodesignError(
'A generation is already running for this workspace. Wait for it to finish or stop it before continuing.',
'GENERATION_ALREADY_RUNNING',
);
}
const startedAt = existing?.startedAt ?? Date.now();
inFlightByWorkspace.set(workspaceKey, { generationId: id, startedAt });
return () => {
if (inFlightByWorkspace.get(workspaceKey)?.generationId === id) {
inFlightByWorkspace.delete(workspaceKey);
}
};
}

export function listInFlightGenerations(
inFlightByDesign: ReadonlyMap<string, InFlightGeneration>,
): Array<{ designId: string; generationId: string; startedAt: number }> {
Expand Down
19 changes: 18 additions & 1 deletion apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { CHATGPT_CODEX_PROVIDER_ID, getCodexTokenStore } from '../codex-oauth-ip
import { makeRuntimeVerifier } from '../done-verify';
import { app, ipcMain } from '../electron-runtime';
import {
acquireInFlightWorkspaceGeneration,
armGenerationTimeout,
cancelGenerationRequest,
extractGenerationTimeoutError,
Expand Down Expand Up @@ -673,6 +674,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
/** In-flight requests: generationId → AbortController */
const inFlight = new Map<string, AbortController>();
const inFlightByDesign = new Map<string, { generationId: string; startedAt: number }>();
const inFlightByWorkspace = new Map<string, { generationId: string; startedAt: number }>();

const armTimeout = (id: string, controller: AbortController) =>
armGenerationTimeout(
Expand Down Expand Up @@ -820,7 +822,13 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe

const t0 = Date.now();
let clearTimeoutGuard: () => void = () => {};
let releaseWorkspaceGeneration: () => void = () => {};
try {
releaseWorkspaceGeneration = acquireInFlightWorkspaceGeneration(
id,
workspaceRoot,
inFlightByWorkspace,
);
clearTimeoutGuard = await armTimeout(id, controller);
const isCodex = active.model.provider === CHATGPT_CODEX_PROVIDER_ID;
let capturedMessages: DesignBriefConversationMessages | null = null;
Expand Down Expand Up @@ -1251,6 +1259,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
throw rethrow;
} finally {
clearTimeoutGuard();
releaseWorkspaceGeneration();
}
},
);
Expand All @@ -1259,7 +1268,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe

ipcMain.handle('codesign:v1:cancel-generation', (_e, raw: unknown) => {
const { generationId } = CancelGenerationPayloadV1.parse(raw);
cancelGenerationRequest(generationId, inFlight, logIpc, inFlightByDesign);
cancelGenerationRequest(generationId, inFlight, logIpc, inFlightByDesign, inFlightByWorkspace);
});

ipcMain.handle('codesign:v1:generation-status', () => ({
Expand Down Expand Up @@ -1334,7 +1343,13 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe

const t0 = Date.now();
let clearTimeoutGuard: () => void = () => {};
let releaseWorkspaceGeneration: () => void = () => {};
try {
releaseWorkspaceGeneration = acquireInFlightWorkspaceGeneration(
id,
workspaceRoot,
inFlightByWorkspace,
);
clearTimeoutGuard = await armTimeout(id, controller);
const isCodex = active.model.provider === CHATGPT_CODEX_PROVIDER_ID;
const result = await runGenerate(
Expand Down Expand Up @@ -1402,6 +1417,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
throw rethrow;
} finally {
clearTimeoutGuard();
releaseWorkspaceGeneration();
}
},
);
Expand Down Expand Up @@ -1468,5 +1484,6 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
}
inFlight.clear();
inFlightByDesign.clear();
inFlightByWorkspace.clear();
};
}
Loading
Loading