Skip to content
Open
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
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, and show one hub card per workspace to avoid duplicate project cards.
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