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
9 changes: 9 additions & 0 deletions .agents/docs/ui-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ Before creating a new component, check if an existing one handles the use case.
- **`BranchSelector`** handles branch picking and worktree creation in `ThreadDraftView`. Reuse it for any branch-related UI.
- **`OptionMenu`** is the dropdown for model/effort/permission selections. It supports custom label formatters via the provider registry.

### Dialogs

Match the canonical dialog look — do not restyle. Reference: `CreatePrModal`, `ContinueInProviderDialog`.

- **Form / input dialogs:** HeroUI `Modal` (`Modal.Backdrop` → `Container` → `Dialog`), kept **compact** (`Dialog` `sm:max-w-[~460px]`, `Modal.Body className="p-4"` with inner `gap-3`). Include a `Modal.CloseTrigger`.
- **Footer buttons:** Cancel is a **muted ghost** — `<Button slot="close" variant="ghost" className="text-muted">Cancel</Button>`. The confirm/primary action is the **white tertiary** — `variant="tertiary"`. Do **not** use `variant="primary"` for the action in these dialogs.
- **Destructive confirms:** use the shared `ConfirmDialog` (`AlertDialog`) with `confirmVariant="danger"`; its Cancel is `variant="tertiary"` by convention.
- Keep dialog body height stable — avoid controls that appear/disappear as the user types (fold previews into an existing control rather than adding a conditional line).

## ACP Composer Behavior

- **Inline file mentions stay text-first, then serialize to structured segments.** `MentionInput` + `serializeMentions` accept raw `@path` tokens, so repo-relative references like `@.agents/docs/ui-patterns.md` become `{ kind: "file" }` prompt segments on submit without requiring a picker chip.
Expand Down
90 changes: 90 additions & 0 deletions docs/superpowers/specs/2026-06-08-create-project-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Create Project — Design

Date: 2026-06-08

## Goal

Implement a unified "create project" flow with:

- A `+` menu offering **Start from scratch** and **Use an existing folder**.
- A runtime picker for **Native / WSL** (WSL shown only when distros exist).
- A folder picker whose default is preselected as **last-used → home**, scoped **per runtime**.

## Decisions (confirmed)

- **Start from scratch** = name the project → pick runtime + parent folder (in a custom modal) → create `<parent>/<name>` on disk (`mkdir`) → open it as the new project.
- **Use an existing folder** = opens the **native OS folder picker directly** (no custom modal), exactly like the original flow → opens the chosen directory as a project. The picked path is authoritative for the runtime (a `\\wsl...` path → WSL project, else native). The dialog opens at the last-used native directory → home.
- **Runtime picker** lives in the scratch modal only (the OS dialog cannot host a Native/WSL toggle); for existing folders the runtime is inferred from the picked path.
- **Last-used directory** is remembered **per runtime** (`"native"` or the WSL distro name), falling back to that runtime's home.
- Both the sidebar `+` and the `WelcomeOverlay` CTA route through the same flow (default decision for consistency).

## Current state (baseline)

- `SidebarHeaderControls.tsx`: `FolderPlus` button. Windows → dropdown ("Add Windows Project" / "Add WSL Project"); macOS/Linux → direct `pickFolder()`. Flow: `pickFolder()` → `addProject(location)` → `autoDetectSetupScript` → `openDraft`.
- `WelcomeOverlay.tsx`: "Add Project" → `pickFolder()` → `addProject`.
- `projectSlice.addProject(location, nameOverride?)` — `nameOverride` exists but is unused.
- `ProjectLocation` (`src/shared/contracts/common.ts`): discriminated union `windows | wsl | posix`.
- Helpers (`src/shared/wsl.ts`): `parseWslUncPath`, `getProjectName`, `toWslUncPath`, `getProjectFsPath`.
- IPC: `pickFolder(defaultPath?)`, `listWslDistros()` already exist (`procedures/app.ts`, `localHandlers.ts`).
- Settings: JSON at `~/.lightcode/settings.json`; renderer store `sharedSettingsStore.ts`; schema `src/shared/settings.ts`. **No last-used directory persisted today.** **No create-directory IPC today.**

The screenshots' "Start from scratch" / "Use an existing folder" menu and "Name project" modal do **not** exist in code yet — they are the target.

## Architecture

### Entry points

- Sidebar `+` (`SidebarHeaderControls`) and the `WelcomeOverlay` CTA both render `CreateProjectMenu` (two items).
- "Start from scratch" → `panelStore.openCreateProjectModal()` (the scratch modal, mounted once in `AppOverlays`).
- "Use an existing folder" → `addExistingProject()` → native `pickFolder` → create project. No modal.

### `CreateProjectModal` (scratch only)

HeroUI `Modal` (mirrors `CreatePrModal`). Fields:

- **Runtime selector** — visible only when WSL distros exist. Options: `Native` + one per distro. Hidden on macOS/Linux (runtime = `posix`). Changing it re-resolves the default location.
- **Location** (read-only path + **Browse** → `pickFolder(defaultPath)`):
- scratch: "Parent folder".
- existing: "Folder".
- Default on open / runtime change: `lastUsedProjectDirs[runtimeKey]` → else runtime home (`homeDir` native; `\\wsl.localhost\<distro>\home` for WSL).
- **Name** (text input, placeholder "New project"):
- scratch: required; legal single path segment.
- existing: prefilled with basename of picked folder; editable; display-name only.
- **Footer**: Cancel / Save. Save disabled until valid. Scratch shows a live preview of the final path.

`runtimeKey` = `"native"` for windows/posix native, else the distro name.

### Save behavior

1. Derive `ProjectLocation` from the final path (`deriveLocationFromPath`): `parseWslUncPath` succeeds → `wsl`; else `isWindows()` → `windows`; else `posix`. The picked path is authoritative.
2. scratch: `createProjectDirectory({ parent, name, kind })` → returns created absolute path → derive location from it.
3. `addProject(location, name)` → `autoDetectSetupScript(project)` → `openDraft(project.id)`.
4. `setLastUsedProjectDir(runtimeKey, parentDir)` where `parentDir` is the directory the user browsed (scratch: the parent field; existing: `dirname(folder)`).
5. Guards (inline modal errors, not silent fallbacks): scratch+WSL requires a `\\wsl...` parent; scratch+native rejects a WSL UNC parent.

### Persistence & IPC

- `sharedSettingsSchema` + `defaultSharedSettings`: add `lastUsedProjectDirs: Record<string,string>` (default `{}`).
- `sharedSettingsStore`: add `setLastUsedProjectDir(runtimeKey, dir)` mirroring `pushRecentModel` (merge → `persistSettings`).
- Native home: reuse the existing `getHomeScopeLocation()` IPC (cached via `loadHomeScopeLocation()`) — no preload change needed. WSL home is `\\wsl.localhost\<distro>\home`.
- New main-local IPC `createProjectDirectory` (`procedures/app.ts` + `localHandlers.ts`): `{ parent, name, kind }` → `{ path }`. `path.win32.join` for windows/wsl, `path.posix.join` for posix; refuses to clobber an existing folder; non-recursive `mkdir` so a missing parent is reported; maps common `errno` codes (EACCES/ENOSPC/ENOENT/…) to user-grade messages.
- Reuse `pickFolder`, `listWslDistros`, `parseWslUncPath`, `getProjectName`.

### State / components / files

- `panelStore`: `createProjectModal: { open, mode }` + `openCreateProject(mode)` / `closeCreateProject()`.
- New files: `CreateProjectMenu`, `CreateProjectModal`, `useCreateProjectFlow` controller (with pure `deriveLocationFromPath`, name validation, target-path build).
- Edits: `SidebarHeaderControls.tsx`, `WelcomeOverlay.tsx`, `settings.ts`, `sharedSettingsStore.ts`, preload + bridge type, IPC procedure map + handler, `AppOverlays`.

## Testing (TDD)

- Pure unit: `deriveLocationFromPath` (UNC→wsl / win→windows / posix), legal-name validation, scratch target-path build, runtime/parent mismatch guards.
- Settings store: `setLastUsedProjectDir` merge + persist.
- Main handler: `createProjectDirectory` mkdir + conflict error.
- Component: `CreateProjectModal` — Save gating, runtime-selector visibility, scratch path preview.

## Out of scope

- Cloning a repo from a URL.
- Multi-folder / monorepo workspace projects.
- Renaming/migrating existing projects' runtimes.
2 changes: 2 additions & 0 deletions src/main/ipc/localHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
saveClipboardImageFile,
saveHandoffContextFile,
} from "../attachments/localFiles";
import { createProjectDirectory } from "../projectDirectory";
import { readSharedSettingsFile, writeSharedSettingsFile } from "../sharedSettingsFile";
import { readKeybindingsFile } from "../keybindingsFile";
import type { AutoUpdaterController } from "../updates/autoUpdater";
Expand Down Expand Up @@ -102,6 +103,7 @@ export function createLocalIpcHandlers(
saveClipboardImageFile(options.requireLightcodePaths(), payload),
saveHandoffContext: (payload) =>
saveHandoffContextFile(options.requireLightcodePaths(), payload),
createProjectDirectory: (payload) => createProjectDirectory(payload),
openExternal: async (url) => {
const safeUrl = assertSafeExternalUrl(url);
const browserPanel = options.getBrowserPanelManager();
Expand Down
57 changes: 57 additions & 0 deletions src/main/projectDirectory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createProjectDirectory, describeMkdirError } from "./projectDirectory";

describe("createProjectDirectory", () => {
let root: string;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "lc-create-project-"));
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

test("creates the folder under the parent and returns its path", async () => {
const result = await createProjectDirectory({ parent: root, name: "new-app", kind: "posix" });

const expected = join(root, "new-app");
expect(result.path).toBe(expected);
expect(existsSync(expected)).toBe(true);
expect(statSync(expected).isDirectory()).toBe(true);
});

test("throws when a folder with that name already exists", async () => {
await createProjectDirectory({ parent: root, name: "dup", kind: "posix" });

await expect(
createProjectDirectory({ parent: root, name: "dup", kind: "posix" }),
).rejects.toThrow(/already exists/i);
});

test("surfaces a friendly message when the parent does not exist", async () => {
await expect(
createProjectDirectory({ parent: join(root, "missing"), name: "app", kind: "posix" }),
).rejects.toThrow(/no longer exists/i);
});
});

describe("describeMkdirError", () => {
test("maps permission errors", () => {
expect(describeMkdirError({ code: "EACCES" }, "x")).toMatch(/permission/i);
expect(describeMkdirError({ code: "EPERM" }, "x")).toMatch(/permission/i);
});

test("maps out-of-space, missing-parent and not-a-directory codes", () => {
expect(describeMkdirError({ code: "ENOSPC" }, "x")).toMatch(/disk space/i);
expect(describeMkdirError({ code: "ENOENT" }, "x")).toMatch(/no longer exists/i);
expect(describeMkdirError({ code: "ENOTDIR" }, "x")).toMatch(/not a folder/i);
});

test("falls back to the raw error message", () => {
expect(describeMkdirError(new Error("boom"), "x")).toBe("boom");
});
});
53 changes: 53 additions & 0 deletions src/main/projectDirectory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { mkdir } from "node:fs/promises";
import { posix, win32 } from "node:path";
import type { ScratchKind } from "@/shared/createProject";

export interface CreateProjectDirectoryPayload {
/** Absolute parent directory (native path, or a `\\wsl...` UNC path). */
parent: string;
/** New folder name (already validated by the renderer). */
name: string;
kind: ScratchKind;
}

/** Translate a Node `mkdir` error into a message fit to show the user. */
export function describeMkdirError(error: unknown, name: string): string {
const code = (error as NodeJS.ErrnoException | null)?.code;
switch (code) {
case "EACCES":
case "EPERM":
return `You don't have permission to create "${name}" there.`;
case "ENOSPC":
return "There isn't enough disk space to create the folder.";
case "ENOENT":
return "That parent folder no longer exists. Pick another location.";
case "ENOTDIR":
return "The chosen location is not a folder.";
case "EEXIST":
return `A folder named "${name}" already exists here.`;
default:
return error instanceof Error ? error.message : `Couldn't create "${name}".`;
}
}

/**
* Create the directory for a "start from scratch" project. Joins with the
* separator appropriate to the location kind (posix uses `/`; windows and WSL
* UNC paths use `\`), refusing to clobber an existing folder. The parent must
* already exist — `mkdir` is non-recursive so a stale/removed parent (ENOENT)
* or an existing target (EEXIST) surfaces as a clear error rather than silently
* materializing the path elsewhere or clobbering it.
*/
export async function createProjectDirectory(
payload: CreateProjectDirectoryPayload,
): Promise<{ path: string }> {
const join = payload.kind === "posix" ? posix.join : win32.join;
const target = join(payload.parent, payload.name);

try {
await mkdir(target);
} catch (error) {
throw new Error(describeMkdirError(error, payload.name), { cause: error });
}
return { path: target };
}
2 changes: 2 additions & 0 deletions src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe("sharedSettingsFile", () => {
gitReviewMode: "panel",
providerConfigs: {},
lastPresentationModeByAgent: {},
lastUsedProjectDirs: {},
editorLspEnabled: false,
searchUseIgnoreFiles: true,
searchExclude: {},
Expand Down Expand Up @@ -155,6 +156,7 @@ describe("sharedSettingsFile", () => {
gitReviewMode: "panel",
providerConfigs: {},
lastPresentationModeByAgent: {},
lastUsedProjectDirs: {},
editorLspEnabled: false,
searchUseIgnoreFiles: true,
searchExclude: {},
Expand Down
145 changes: 145 additions & 0 deletions src/renderer/actions/createProjectActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, test, vi } from "vitest";

const mocks = vi.hoisted(() => ({
createProjectDirectory: vi.fn<(p: unknown) => Promise<{ path: string }>>(),
pickFolder: vi.fn<(d?: string) => Promise<string | null>>(),
addProject: vi.fn<(location: unknown, name?: string) => unknown>((location, name) => ({
id: "p1",
name: name ?? "x",
location,
createdAt: "t",
})),
openDraft: vi.fn<(id: string) => void>(),
setLastUsedProjectDir: vi.fn<(key: string, dir: string) => void>(),
autoDetectSetupScript: vi.fn<(project: unknown) => void>(),
loadHomeScopeLocation: vi.fn<() => Promise<{ kind: string; path: string }>>(),
lastUsedProjectDirs: {} as Record<string, string>,
}));

const { createProjectDirectory, addProject, openDraft, setLastUsedProjectDir } = mocks;

vi.mock("@/renderer/bridge", () => ({
readBridge: () => ({
platform: "darwin",
createProjectDirectory: mocks.createProjectDirectory,
pickFolder: mocks.pickFolder,
}),
}));
vi.mock("@/renderer/actions/projectActions", () => ({
loadHomeScopeLocation: mocks.loadHomeScopeLocation,
}));
vi.mock("@/renderer/state/appStore", () => ({
useAppStore: { getState: () => ({ addProject: mocks.addProject, openDraft: mocks.openDraft }) },
}));
vi.mock("@/renderer/state/sharedSettingsStore", () => ({
useSharedSettings: {
getState: () => ({
setLastUsedProjectDir: mocks.setLastUsedProjectDir,
lastUsedProjectDirs: mocks.lastUsedProjectDirs,
}),
},
}));
vi.mock("@/renderer/utils/gitHelpers", () => ({
autoDetectSetupScript: mocks.autoDetectSetupScript,
}));

import { addExistingProject, commitCreateProject } from "./createProjectActions";

describe("commitCreateProject", () => {
beforeEach(() => {
vi.clearAllMocks();
});

test("existing folder: adds the project and records its parent as last-used", async () => {
await commitCreateProject({
mode: "existing",
choice: { kind: "native" },
dir: "/Users/me/code/app",
name: "app",
});

expect(createProjectDirectory).not.toHaveBeenCalled();
expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/app" }, "app");
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
expect(openDraft).toHaveBeenCalledWith("p1");
});

test("scratch: creates the directory, then adds the project at the returned path", async () => {
createProjectDirectory.mockResolvedValue({ path: "/Users/me/code/new" });

await commitCreateProject({
mode: "scratch",
choice: { kind: "native" },
dir: "/Users/me/code",
name: "new",
});

expect(createProjectDirectory).toHaveBeenCalledWith({
parent: "/Users/me/code",
name: "new",
kind: "posix",
});
expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/new" }, "new");
// scratch records the parent the user browsed, not the new folder.
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
});

test("scratch failure propagates and does not add a project", async () => {
createProjectDirectory.mockRejectedValue(
new Error('A folder named "new" already exists here.'),
);

await expect(
commitCreateProject({
mode: "scratch",
choice: { kind: "native" },
dir: "/Users/me/code",
name: "new",
}),
).rejects.toThrow(/already exists/i);

expect(addProject).not.toHaveBeenCalled();
expect(setLastUsedProjectDir).not.toHaveBeenCalled();
});
});

describe("addExistingProject", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.lastUsedProjectDirs = {};
mocks.loadHomeScopeLocation.mockResolvedValue({ kind: "posix", path: "/Users/me" });
});

test("opens the picker at home when no last-used dir, then adds the picked folder", async () => {
mocks.pickFolder.mockResolvedValue("/Users/me/code/app");

await addExistingProject();

expect(mocks.pickFolder).toHaveBeenCalledWith("/Users/me");
expect(addProject).toHaveBeenCalledWith(
{ kind: "posix", path: "/Users/me/code/app" },
undefined,
);
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
expect(createProjectDirectory).not.toHaveBeenCalled();
});

test("opens the picker at the last-used native dir when present", async () => {
mocks.lastUsedProjectDirs = { native: "/Users/me/projects" };
mocks.pickFolder.mockResolvedValue("/Users/me/projects/app");

await addExistingProject();

expect(mocks.pickFolder).toHaveBeenCalledWith("/Users/me/projects");
expect(mocks.loadHomeScopeLocation).not.toHaveBeenCalled();
});

test("does nothing when the picker is cancelled", async () => {
mocks.pickFolder.mockResolvedValue(null);

await addExistingProject();

expect(addProject).not.toHaveBeenCalled();
expect(setLastUsedProjectDir).not.toHaveBeenCalled();
});
});
Loading