Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions .changeset/workspace-chat-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@open-codesign/desktop": patch
"@open-codesign/i18n": patch
---

Add workspace chat tabs for starting and switching fresh conversations that share the same workspace.
23 changes: 23 additions & 0 deletions apps/desktop/src/main/design-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,29 @@ describe('bindWorkspace', () => {
expect(getDesign(db, design.id)?.workspacePath).toBeNull();
});

it('allows an explicit fresh conversation to share an existing workspace binding', async () => {
const db = initInMemoryDb();
const design = createDesign(db);
const otherDesign = createDesign(db, 'Landing page variants');
const sharedPath = normalizeWorkspacePath(await makeTempDir('ocd-ws-shared-'));
await writeWorkspaceFile(
sharedPath,
'App.jsx',
'export default function App() { return null; }',
);
updateDesignWorkspace(db, otherDesign.id, sharedPath);

const rebound = await bindWorkspace(db, design.id, sharedPath, false, 'work-on-project', {
allowExistingWorkspaceBinding: true,
});

expect(rebound.workspacePath).toBe(sharedPath);
expect(getDesign(db, otherDesign.id)?.workspacePath).toBe(sharedPath);
await expect(readFile(path.join(sharedPath, 'App.jsx'), 'utf8')).resolves.toBe(
'export default function App() { return null; }',
);
});

it('rejects empty and relative workspace bindings before touching the db', async () => {
const db = initInMemoryDb();
const design = createDesign(db);
Expand Down
10 changes: 3 additions & 7 deletions apps/desktop/src/main/design-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,13 @@ import {
listDesigns,
updateDesignWorkspace,
} from './snapshots-db';
import { normalizeWorkspacePath } from './workspace-path';
import { normalizeWorkspacePath, workspacePathComparisonKey } from './workspace-path';
import { listWorkspaceFilesAt, resolveSafeWorkspaceChildPath } from './workspace-reader';

export { normalizeWorkspacePath } from './workspace-path';

const logger = getLogger('design-workspace');

function workspacePathComparisonKey(p: string): string {
const normalized = normalizeWorkspacePath(p);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}

export async function pickWorkspaceFolder(win: BrowserWindow): Promise<string | null> {
const result = await dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory'],
Expand Down Expand Up @@ -146,6 +141,7 @@ export async function bindWorkspace(
workspacePath: string | null,
migrateFiles: boolean,
workspaceMode?: WorkspaceMode,
options?: { allowExistingWorkspaceBinding?: boolean },
): Promise<Design> {
const current = requireDesign(db, designId);

Expand All @@ -169,7 +165,7 @@ export async function bindWorkspace(
return current;
}
const conflict = findWorkspaceConflict(db, designId, normalizedPath);
if (conflict !== null) {
if (conflict !== null && options?.allowExistingWorkspaceBinding !== true) {
throw new Error(workspaceConflictMessage(conflict));
}
await assertExistingWorkspaceDirectory(normalizedPath);
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/main/generation-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ function makeController() {
return { abort: vi.fn() } as unknown as AbortController;
}

function withMockedPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
const original = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
try {
return run();
} finally {
if (original) {
Object.defineProperty(process, 'platform', original);
}
}
}

describe('cancelGenerationRequest', () => {
it('parses the public v1 cancel-generation payload', () => {
const payload = CancelGenerationPayloadV1.parse({
Expand Down Expand Up @@ -224,6 +239,24 @@ describe('acquireInFlightWorkspaceGeneration', () => {
expect(inFlightByWorkspace.has('/workspace')).toBe(false);
});

it('treats case-only path differences as the same workspace on Windows', () => {
withMockedPlatform('win32', () => {
const inFlightByWorkspace = new Map<string, { generationId: string; startedAt: number }>();
const release = acquireInFlightWorkspaceGeneration(
'gen-1',
'C:/Users/Roy/Workspace',
inFlightByWorkspace,
);

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

release();
expect(inFlightByWorkspace.has('c:/users/roy/workspace')).toBe(false);
});
});

it('allows different workspaces to run concurrently', () => {
const inFlightByWorkspace = new Map<string, { generationId: string; startedAt: number }>();
const releaseOne = acquireInFlightWorkspaceGeneration(
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/generation-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
import { workspacePathComparisonKey } from './workspace-path';

export interface CancellationLogger {
info: (event: string, payload: { id: string }) => void;
Expand Down Expand Up @@ -85,9 +86,10 @@ export async function withInFlightGenerationForDesign<T>(

export function acquireInFlightWorkspaceGeneration(
id: string,
workspaceKey: string,
workspacePath: string,
inFlightByWorkspace: Map<string, InFlightGeneration>,
): () => void {
const workspaceKey = workspacePathComparisonKey(workspacePath);
const existing = inFlightByWorkspace.get(workspaceKey);
if (existing !== undefined && existing.generationId !== id) {
throw new CodesignError(
Expand Down
127 changes: 127 additions & 0 deletions apps/desktop/src/main/snapshots-ipc.create-design.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import path from 'node:path';
import type { Design } from '@open-codesign/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createDesign,
createSnapshot,
initInMemoryDb,
listDesigns,
listSnapshots,
updateDesignWorkspace,
} from './snapshots-db';
import { registerSnapshotsIpc } from './snapshots-ipc';
import { normalizeWorkspacePath } from './workspace-path';

type Handler = (event: unknown, raw: unknown) => unknown;

const handlers = vi.hoisted(() => new Map<string, Handler>());
const testRoots = vi.hoisted(() => {
const fallback = process.platform === 'win32' ? 'C:/Temp' : '/tmp';
const base = (process.env['TEMP'] ?? process.env['TMP'] ?? fallback).replaceAll('\\', '/');
return { documentsRoot: `${base}/open-codesign-create-design-tests` };
});

vi.mock('./electron-runtime', () => ({
app: {
getPath: vi.fn(() => testRoots.documentsRoot),
},
dialog: {
showOpenDialog: vi.fn(),
},
ipcMain: {
handle: vi.fn((channel: string, handler: Handler) => {
handlers.set(channel, handler);
}),
},
}));

vi.mock('./logger', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));

function getHandler(channel: string): Handler {
const handler = handlers.get(channel);
if (!handler) throw new Error(`Missing IPC handler: ${channel}`);
return handler;
}

describe('snapshots create-design workspace reuse', () => {
let root: string;

beforeEach(async () => {
handlers.clear();
await mkdir(testRoots.documentsRoot, { recursive: true });
root = await mkdtemp(path.join(testRoots.documentsRoot, 'case-'));
});

afterEach(async () => {
await rm(root, { recursive: true, force: true });
});

it('creates a fresh conversation that shares an existing workspace without copying history', async () => {
const db = initInMemoryDb();
const workspacePath = path.join(root, 'workspace');
await mkdir(workspacePath);
const source = createDesign(db, 'Existing workspace');
updateDesignWorkspace(db, source.id, workspacePath);
createSnapshot(db, {
designId: source.id,
parentId: null,
type: 'initial',
prompt: 'make a homepage',
artifactType: 'html',
artifactSource: '<main>Existing</main>',
});
registerSnapshotsIpc(db);

const created = (await getHandler('snapshots:v1:create-design')(null, {
schemaVersion: 1,
name: 'Fresh conversation',
workspacePath,
workspaceReuse: 'fresh-conversation',
})) as Design;

expect(created.id).not.toBe(source.id);
expect(created.workspacePath).toBe(normalizeWorkspacePath(workspacePath));
expect(listSnapshots(db, created.id)).toEqual([]);
expect(listSnapshots(db, source.id)).toHaveLength(1);
expect(
listDesigns(db).filter(
(design) => design.workspacePath === normalizeWorkspacePath(workspacePath),
),
).toHaveLength(2);
});

it('keeps ordinary create-design workspace conflicts exclusive', async () => {
const db = initInMemoryDb();
const workspacePath = path.join(root, 'workspace');
await mkdir(workspacePath);
const source = createDesign(db, 'Existing workspace');
updateDesignWorkspace(db, source.id, workspacePath);
registerSnapshotsIpc(db);

await expect(
getHandler('snapshots:v1:create-design')(null, {
schemaVersion: 1,
name: 'Ordinary create',
workspacePath,
}),
).rejects.toMatchObject({ code: 'IPC_CONFLICT' });

expect(listDesigns(db).map((design) => design.id)).toEqual([source.id]);
});

it('rejects workspace reuse without an explicit workspace path', async () => {
const db = initInMemoryDb();
registerSnapshotsIpc(db);

await expect(
getHandler('snapshots:v1:create-design')(null, {
schemaVersion: 1,
name: 'Fresh conversation',
workspaceReuse: 'fresh-conversation',
}),
).rejects.toMatchObject({ code: 'IPC_BAD_INPUT' });
});
});
20 changes: 20 additions & 0 deletions apps/desktop/src/main/snapshots-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ function parseCreateDesignWorkspacePath(r: Record<string, unknown>): string | un
}
}

function parseCreateDesignWorkspaceReuse(
r: Record<string, unknown>,
): 'fresh-conversation' | undefined {
const raw = r['workspaceReuse'];
if (raw === undefined) return undefined;
if (raw === 'fresh-conversation') return raw;
throw new CodesignError(
'workspaceReuse must be fresh-conversation when provided',
'IPC_BAD_INPUT',
);
}

function translateWorkspaceBindError(err: unknown, fallbackMessage: string): CodesignError {
if (err instanceof CodesignError) return err;
if (err instanceof Error && err.message.includes('already bound')) {
Expand Down Expand Up @@ -1265,6 +1277,13 @@ export function registerSnapshotsIpc(db: Database): void {
}
const name = (r['name'] as string).trim();
const requestedWorkspacePath = parseCreateDesignWorkspacePath(r);
const workspaceReuse = parseCreateDesignWorkspaceReuse(r);
if (workspaceReuse !== undefined && requestedWorkspacePath === undefined) {
throw new CodesignError(
'workspaceReuse requires an explicit workspacePath',
'IPC_BAD_INPUT',
);
}
const design = runDb('create-design', () => createDesign(db, name));
// v0.2: every design MUST have a workspace — per docs/v0.2-plan.md §2.3.
// When the user hasn't picked one explicitly, seed
Expand All @@ -1282,6 +1301,7 @@ export function registerSnapshotsIpc(db: Database): void {
workspacePath,
false,
requestedWorkspacePath === undefined ? 'blank-canvas' : 'work-on-project',
{ allowExistingWorkspaceBinding: workspaceReuse === 'fresh-conversation' },
);
} catch (err) {
if (autoWorkspacePath !== null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,8 @@ type Handler = (event: unknown, raw: unknown) => unknown;

const handlers = vi.hoisted(() => new Map<string, Handler>());
const testRoots = vi.hoisted(() => {
const base = (
process.env['RUNNER_TEMP'] ??
process.env['TMPDIR'] ??
process.env['TEMP'] ??
process.env['TMP'] ??
(process.platform === 'win32' ? 'C:/Temp' : '/tmp')
).replaceAll('\\', '/');
const fallback = process.platform === 'win32' ? 'C:/Temp' : '/tmp';
const base = (process.env['TEMP'] ?? process.env['TMP'] ?? fallback).replaceAll('\\', '/');
return { documentsRoot: `${base}/open-codesign-rename-tests` };
});
const renameControl = vi.hoisted(() => {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/workspace-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export function normalizeWorkspacePath(rawPath: string): string {
return stripTrailingSlash(normalized);
}

export function workspacePathComparisonKey(workspacePath: string): string {
const normalized = stripTrailingSlash(workspacePath.replaceAll('\\', '/'));
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}

export function assertWorkspacePath(rawPath: string): string {
return normalizeWorkspacePath(rawPath);
}
9 changes: 8 additions & 1 deletion apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ export interface RenameDesignOptions {
renameWorkspace?: boolean;
}

export interface CreateDesignOptions {
workspaceReuse?: 'fresh-conversation';
}

export interface ExportInvokeResponse {
status: 'saved' | 'cancelled';
path?: string;
Expand Down Expand Up @@ -688,11 +692,14 @@ const api = {
snapshots: {
listDesigns: () =>
ipcRenderer.invoke('snapshots:v1:list-designs', { schemaVersion: 1 }) as Promise<Design[]>,
createDesign: (name: string, workspacePath?: string | null) =>
createDesign: (name: string, workspacePath?: string | null, options?: CreateDesignOptions) =>
ipcRenderer.invoke('snapshots:v1:create-design', {
schemaVersion: 1,
name,
...(workspacePath !== undefined ? { workspacePath } : {}),
...(options?.workspaceReuse !== undefined
? { workspaceReuse: options.workspaceReuse }
: {}),
}) as Promise<Design>,
getDesign: (id: string) =>
ipcRenderer.invoke('snapshots:v1:get-design', {
Expand Down
Loading
Loading