feat(frontend): workspace/project import UI flow

This commit is contained in:
Aditi Chauhan 2026-06-29 21:33:54 +05:30
parent a31cf1b582
commit 8ad3bd37d6
8 changed files with 579 additions and 90 deletions

View File

@ -25,12 +25,13 @@ import {
type UpdateSettings,
type UpdateStatus,
} from "./main/update-settings";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, rm } from "node:fs/promises";
import { readdir, readFile, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { type DaemonLaunchSpec, resolveDaemonLaunch } from "./shared/daemon-launch";
import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery";
import type { DaemonStatus } from "./shared/daemon-status";
@ -49,6 +50,7 @@ import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view
import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link";
import { shouldLinkOnAttach } from "./main/daemon-owner";
import { readMigrationState, updateMigration, writeAppStateMarker, type MigrationState } from "./main/app-state";
import type { WorkspaceRepoScanItem } from "./preload";
// Globals injected at compile time by @electron-forge/plugin-vite.
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
@ -87,6 +89,8 @@ let browserViewHost: BrowserViewHost | null = null;
// Held for the app lifetime. Dropping it (on any exit) triggers daemon self-stop.
let supervisorLink: SupervisorLinkHandle | null = null;
const execFileAsync = promisify(execFile);
const isDev = !app.isPackaged;
const RENDERER_SCHEME = "app";
@ -263,6 +267,54 @@ function telemetryOverrides(): Record<string, string> {
};
}
async function gitOutput(cwd: string, args: string[]): Promise<string | null> {
try {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { timeout: 5000 });
return String(stdout).trim();
} catch {
return null;
}
}
async function isStandaloneGitRepo(repoPath: string): Promise<boolean> {
try {
const gitStat = await stat(path.join(repoPath, ".git"));
if (!gitStat.isDirectory()) return false;
} catch {
return false;
}
const topLevel = await gitOutput(repoPath, ["rev-parse", "--show-toplevel"]);
return topLevel !== null && path.resolve(topLevel) === path.resolve(repoPath);
}
async function scanWorkspaceRepos(parent: string): Promise<WorkspaceRepoScanItem[]> {
const entries = await readdir(parent, { withFileTypes: true });
const repos = await Promise.all(
entries
.filter((entry) => entry.isDirectory() && entry.name !== ".git")
.map(async (entry): Promise<WorkspaceRepoScanItem | null> => {
const repoPath = path.join(parent, entry.name);
if (!(await isStandaloneGitRepo(repoPath))) return null;
const [branch, remote] = await Promise.all([
gitOutput(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
gitOutput(repoPath, ["remote", "get-url", "origin"]),
]);
return {
name: entry.name,
path: repoPath,
branch,
remote,
valid: remote !== null && remote !== "",
error: remote ? undefined : "No remote configured",
};
}),
);
return repos
.filter((repo): repo is WorkspaceRepoScanItem => repo !== null)
.sort((a, b) => a.name.localeCompare(b.name));
}
// Run the user's login shell to dump its env. stdin is ignored so an rc that
// reads input hits EOF instead of hanging; stderr is ignored to drop banner
// noise. Never rejects: resolves null on spawn error, non-zero exit, or timeout
@ -793,13 +845,16 @@ ipcMain.handle("telemetry:getBootstrap", () =>
ipcMain.handle("app:chooseDirectory", async () => {
const options: OpenDialogOptions = {
properties: ["openDirectory"],
title: "Choose a git repository",
title: "Choose a folder",
};
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
if (result.canceled) return null;
return result.filePaths[0] ?? null;
});
ipcMain.handle("app:scanWorkspaceRepos", async (_event, selectedPath: string): Promise<WorkspaceRepoScanItem[]> => {
return scanWorkspaceRepos(selectedPath);
});
ipcMain.handle("clipboard:writeText", (_event, text: string) => {
clipboard.writeText(text, "clipboard");
if (process.platform === "linux") {

View File

@ -16,10 +16,21 @@ export type BrowserNavigateInput = {
url: string;
};
export type WorkspaceRepoScanItem = {
name: string;
path: string;
branch: string | null;
remote: string | null;
valid: boolean;
error?: string;
};
const api = {
app: {
getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise<string>,
chooseDirectory: () => ipcRenderer.invoke("app:chooseDirectory") as Promise<string | null>,
scanWorkspaceRepos: (path: string) =>
ipcRenderer.invoke("app:scanWorkspaceRepos", path) as Promise<WorkspaceRepoScanItem[]>,
},
clipboard: {
writeText: (text: string) => ipcRenderer.invoke("clipboard:writeText", text) as Promise<void>,

View File

@ -0,0 +1,417 @@
import * as Dialog from "@radix-ui/react-dialog";
import { ArrowLeft, Check, Folder, FolderPlus, GitBranch, Loader2, X } from "lucide-react";
import { useState, type ReactNode } from "react";
import type { WorkspaceRepoScanItem } from "../../preload";
import { apiErrorMessage } from "../lib/api-client";
import { aoBridge } from "../lib/bridge";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet";
export type ImportProjectInput = {
path: string;
workerAgent?: string;
orchestratorAgent?: string;
asWorkspace?: boolean;
};
type ImportProjectDialogProps = {
children: (state: { open: () => void; disabled: boolean; label: string }) => ReactNode;
onImport: (input: ImportProjectInput) => Promise<void>;
};
type ImportMode = "workspace" | "project";
type Step = "type" | "workspace" | "project";
export function ImportProjectDialog({ children, onImport }: ImportProjectDialogProps) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<Step>("type");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [repos, setRepos] = useState<WorkspaceRepoScanItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [isChoosing, setIsChoosing] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [agentSheetOpen, setAgentSheetOpen] = useState(false);
const reset = () => {
setStep("type");
setSelectedPath(null);
setRepos([]);
setError(null);
setIsChoosing(false);
setIsImporting(false);
setAgentSheetOpen(false);
};
const close = () => {
if (isChoosing || isImporting) return;
setOpen(false);
reset();
};
const chooseFolder = async (mode: ImportMode) => {
setError(null);
setIsChoosing(true);
try {
const path = await aoBridge.app.chooseDirectory();
if (!path) return;
setSelectedPath(path);
if (mode === "workspace") {
setRepos(await aoBridge.app.scanWorkspaceRepos(path));
}
} catch (err) {
setError(err instanceof Error ? err.message : "Could not inspect folder");
} finally {
setIsChoosing(false);
}
};
const importWorkspace = async () => {
if (!selectedPath || repos.length === 0 || repos.some((repo) => !repo.valid)) return;
setError(null);
setIsImporting(true);
try {
await onImport({ path: selectedPath, asWorkspace: true });
close();
} catch (err) {
setError(apiErrorMessage(err, "Could not import workspace"));
} finally {
setIsImporting(false);
}
};
const importProject = async (selection: CreateProjectAgentSelection) => {
if (!selectedPath) return;
setError(null);
setIsImporting(true);
try {
await onImport({ path: selectedPath, ...selection });
close();
} catch (err) {
setError(apiErrorMessage(err, "Could not import project"));
} finally {
setIsImporting(false);
}
};
const openDialog = () => {
reset();
setOpen(true);
};
return (
<>
{children({ open: openDialog, disabled: isChoosing || isImporting, label: isChoosing ? "Opening..." : "New project" })}
<Dialog.Root open={open} onOpenChange={(next) => (next ? setOpen(true) : close())}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/55 data-[state=open]:animate-overlay-in" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(680px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
{step === "type" ? (
<TypePicker onSelect={(mode) => setStep(mode)} />
) : (
<ImportFolderStep
error={error}
isChoosing={isChoosing}
isImporting={isImporting}
mode={step}
onBack={() => {
setError(null);
setSelectedPath(null);
setRepos([]);
setStep("type");
}}
onCancel={close}
onChoose={() => void chooseFolder(step)}
onImportProject={() => setAgentSheetOpen(true)}
onImportWorkspace={() => void importWorkspace()}
repos={repos}
selectedPath={selectedPath}
/>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<CreateProjectAgentSheet
error={error}
isCreating={isImporting}
onOpenChange={setAgentSheetOpen}
onSubmit={importProject}
open={agentSheetOpen}
path={selectedPath}
/>
</>
);
}
function TypePicker({ onSelect }: { onSelect: (mode: ImportMode) => void }) {
return (
<div className="p-5">
<Dialog.Title className="text-[16px] font-semibold text-foreground">Import to Agent Orchestrator</Dialog.Title>
<Dialog.Description className="sr-only">
Choose whether to import a workspace folder or a single project repository.
</Dialog.Description>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<TypeCard
description="Several Git repos that live under one parent folder"
icon={<WorkspacePreview />}
onClick={() => onSelect("workspace")}
tag="• Multiple repositories"
title="Workspace"
/>
<TypeCard
description="A single Git repository"
icon={<ProjectPreview />}
onClick={() => onSelect("project")}
tag="• One repository"
title="Project"
/>
</div>
</div>
);
}
function TypeCard({
description,
icon,
onClick,
tag,
title,
}: {
description: string;
icon: ReactNode;
onClick: () => void;
tag: string;
title: string;
}) {
return (
<button
type="button"
className="flex min-h-[220px] flex-col rounded-lg border border-border bg-surface p-4 text-left transition hover:border-border-strong hover:bg-raised focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
onClick={onClick}
>
<div className="flex-1">{icon}</div>
<div className="mt-4">
<div className="text-[15px] font-semibold text-foreground">{title}</div>
<p className="mt-1 min-h-10 text-[12px] leading-5 text-muted-foreground">{description}</p>
<div className="mt-3 text-[11px] font-medium uppercase tracking-[0.08em] text-passive">{tag}</div>
</div>
</button>
);
}
function WorkspacePreview() {
return (
<div className="rounded-md border border-border bg-background p-3">
<div className="flex items-center gap-2 text-[12px] font-medium text-foreground">
<Folder className="size-4 text-accent" aria-hidden="true" />
workspace
</div>
<div className="mt-3 space-y-2 pl-3">
{["api", "web", "workers"].map((name) => (
<div key={name} className="flex items-center gap-2 rounded border border-border bg-surface px-2 py-1.5">
<GitBranch className="size-3.5 text-passive" aria-hidden="true" />
<span className="text-[11px] text-muted-foreground">{name}</span>
</div>
))}
</div>
</div>
);
}
function ProjectPreview() {
return (
<div className="rounded-md border border-border bg-background p-3">
<div className="inline-flex items-center gap-2 rounded border border-border bg-surface px-2.5 py-1.5 text-[12px] text-foreground">
<GitBranch className="size-3.5 text-accent" aria-hidden="true" />
web-app · main
</div>
</div>
);
}
function ImportFolderStep({
error,
isChoosing,
isImporting,
mode,
onBack,
onCancel,
onChoose,
onImportProject,
onImportWorkspace,
repos,
selectedPath,
}: {
error: string | null;
isChoosing: boolean;
isImporting: boolean;
mode: "workspace" | "project";
onBack: () => void;
onCancel: () => void;
onChoose: () => void;
onImportProject: () => void;
onImportWorkspace: () => void;
repos: WorkspaceRepoScanItem[];
selectedPath: string | null;
}) {
const isWorkspace = mode === "workspace";
const failedCount = repos.filter((repo) => !repo.valid).length;
const canImportWorkspace = isWorkspace && selectedPath !== null && repos.length > 0 && failedCount === 0 && !isImporting;
const canImportProject = !isWorkspace && selectedPath !== null && !isImporting;
return (
<div className="flex max-h-[min(720px,calc(100vh-32px))] flex-col">
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<button
type="button"
aria-label="Back"
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-surface hover:text-foreground"
onClick={onBack}
disabled={isChoosing || isImporting}
>
<ArrowLeft className="size-4" aria-hidden="true" />
</button>
<div className="min-w-0">
<Dialog.Title className="text-[15px] font-semibold text-foreground">
{isWorkspace ? "Import workspace" : "Import project"}
</Dialog.Title>
<Dialog.Description className="mt-1 text-[12px] text-muted-foreground">
{isWorkspace
? "Pick a folder that contains your Git repositories."
: "Pick a folder that contains your Git repository."}
</Dialog.Description>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{selectedPath ? (
<SelectedFolder mode={mode} repos={repos} selectedPath={selectedPath} failedCount={failedCount} onChoose={onChoose} />
) : (
<button
type="button"
className="flex min-h-[260px] w-full flex-col items-center justify-center rounded-lg border border-dashed border-border-strong bg-background px-6 text-center transition hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
onClick={onChoose}
disabled={isChoosing}
>
{isChoosing ? (
<Loader2 className="size-8 animate-spin text-accent" aria-hidden="true" />
) : (
<FolderPlus className="size-9 text-accent" aria-hidden="true" />
)}
<div className="mt-4 text-[15px] font-semibold text-foreground">
{isWorkspace ? "Choose a folder" : "Choose a project folder"}
</div>
<p className="mt-1 max-w-[360px] text-[12px] leading-5 text-muted-foreground">
Opens your system file picker pick the folder that holds your repos
</p>
</button>
)}
{error && (
<div className="mt-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4">
<div className="min-w-0 text-[12px] text-muted-foreground">
{isWorkspace
? selectedPath && failedCount > 0
? `Resolve ${failedCount} failed ${failedCount === 1 ? "repository" : "repositories"} to continue`
: repos.length === 0
? "No repositories to import"
: `${repos.length} repositories ready`
: ""}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isChoosing || isImporting}>
Cancel
</Button>
<Button
type="button"
variant="primary"
disabled={isWorkspace ? !canImportWorkspace : !canImportProject}
onClick={isWorkspace ? onImportWorkspace : onImportProject}
>
{isImporting && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
{isWorkspace ? "Import workspace" : "Import project"}
</Button>
</div>
</div>
</div>
);
}
function SelectedFolder({
failedCount,
mode,
onChoose,
repos,
selectedPath,
}: {
failedCount: number;
mode: "workspace" | "project";
onChoose: () => void;
repos: WorkspaceRepoScanItem[];
selectedPath: string;
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2.5">
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase tracking-[0.08em] text-passive">Selected folder</div>
<div className="mt-1 break-all font-mono text-[12px] text-foreground">{selectedPath}</div>
</div>
<Button type="button" variant="secondary" size="sm" onClick={onChoose}>
Change
</Button>
</div>
{mode === "workspace" && failedCount > 0 && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-destructive">
VALIDATION FAILED · WORKSPACE NOT REGISTERED
</div>
)}
{mode === "workspace" && (
<div className="overflow-hidden rounded-md border border-border">
{repos.length === 0 ? (
<div className="px-3 py-4 text-[12px] text-muted-foreground">No repositories found in this folder.</div>
) : (
repos.map((repo) => <RepoRow key={repo.path} repo={repo} />)
)}
</div>
)}
</div>
);
}
function RepoRow({ repo }: { repo: WorkspaceRepoScanItem }) {
const host = repo.remote ? remoteHost(repo.remote) : "No remote configured";
return (
<div className="flex items-center gap-3 border-b border-border bg-surface px-3 py-2.5 last:border-b-0">
<div
className={cn(
"grid size-6 shrink-0 place-items-center rounded-full",
repo.valid ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive",
)}
>
{repo.valid ? <Check className="size-3.5" aria-hidden="true" /> : <X className="size-3.5" aria-hidden="true" />}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{repo.name}</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-passive">{repo.path}</div>
</div>
<div className={cn("shrink-0 text-right text-[12px]", repo.valid ? "text-muted-foreground" : "text-destructive")}>
{repo.valid ? `${repo.branch ?? "main"} ${host}` : repo.error}
</div>
</div>
);
}
function remoteHost(remote: string): string {
const sshMatch = remote.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
if (sshMatch) return `${sshMatch[1]}/${sshMatch[2].replace(/\.git$/, "")}`;
try {
const url = new URL(remote);
return `${url.host}${url.pathname.replace(/\.git$/, "")}`;
} catch {
return remote.replace(/^https?:\/\//, "").replace(/\.git$/, "");
}
}

View File

@ -29,14 +29,19 @@ const workspace: WorkspaceSummary = {
sessions: [],
};
type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
type ImportProjectHandler = (input: {
path: string;
workerAgent?: string;
orchestratorAgent?: string;
asWorkspace?: boolean;
}) => Promise<void>;
type RemoveProjectHandler = (projectId: string) => Promise<void>;
function renderSidebar({
onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler,
onCreateProject = vi.fn().mockResolvedValue(undefined) as ImportProjectHandler,
onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler,
}: {
onCreateProject?: CreateProjectHandler;
onCreateProject?: ImportProjectHandler;
onRemoveProject?: RemoveProjectHandler;
} = {}) {
const queryClient = new QueryClient({
@ -117,13 +122,16 @@ describe("Sidebar", () => {
it("requires explicit worker and orchestrator agents when creating a project", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler;
const onCreateProject = vi.fn().mockResolvedValue(undefined) as ImportProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project");
renderSidebar({ onCreateProject });
await user.click(screen.getByLabelText("New project"));
await user.click(await screen.findByRole("button", { name: /Project A single Git repository/i }));
await user.click(screen.getByRole("button", { name: /Choose a project folder/i }));
await user.click(await screen.findByRole("button", { name: "Import project" }));
expect(await screen.findByText("/repo/new-project")).toBeInTheDocument();
expect((await screen.findAllByText("/repo/new-project")).length).toBeGreaterThan(0);
const dialog = screen.getByRole("dialog", { name: "Project agents" });
expect(dialog).toHaveClass("left-1/2", "top-1/2", "-translate-x-1/2", "-translate-y-1/2");
await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex");
@ -139,6 +147,58 @@ describe("Sidebar", () => {
);
});
it("imports a valid workspace without opening the agent sheet", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as ImportProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/workspace");
window.ao!.app.scanWorkspaceRepos = vi.fn().mockResolvedValue([
{
name: "web",
path: "/repo/workspace/web",
branch: "main",
remote: "https://github.com/org/web.git",
valid: true,
},
]);
renderSidebar({ onCreateProject });
await user.click(screen.getByLabelText("New project"));
await user.click(await screen.findByRole("button", { name: /Workspace Several Git repos/i }));
await user.click(screen.getByRole("button", { name: /Choose a folder/i }));
expect(await screen.findByText("web")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Import workspace" }));
await waitFor(() => expect(onCreateProject).toHaveBeenCalledWith({ path: "/repo/workspace", asWorkspace: true }));
expect(screen.queryByRole("dialog", { name: "Project agents" })).not.toBeInTheDocument();
});
it("blocks workspace import when a discovered repository has no remote", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as ImportProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/workspace");
window.ao!.app.scanWorkspaceRepos = vi.fn().mockResolvedValue([
{
name: "web",
path: "/repo/workspace/web",
branch: "main",
remote: null,
valid: false,
error: "No remote configured",
},
]);
renderSidebar({ onCreateProject });
await user.click(screen.getByLabelText("New project"));
await user.click(await screen.findByRole("button", { name: /Workspace Several Git repos/i }));
await user.click(screen.getByRole("button", { name: /Choose a folder/i }));
expect(await screen.findByText("VALIDATION FAILED · WORKSPACE NOT REGISTERED")).toBeInTheDocument();
expect(screen.getByText("Resolve 1 failed repository to continue")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Import workspace" })).toBeDisabled();
expect(onCreateProject).not.toHaveBeenCalled();
});
it("opens global settings from the footer menu when no project is selected", async () => {
const user = userEvent.setup();
renderSidebar();

View File

@ -12,7 +12,7 @@ import {
Sun,
Trash2,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { useState } from "react";
import {
attentionZone,
isOrchestratorSession,
@ -21,7 +21,6 @@ import {
type WorkspaceSummary,
workerSessions,
} from "../types/workspace";
import { aoBridge } from "../lib/bridge";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { useEventsConnection } from "../hooks/useEventsConnection";
@ -55,7 +54,7 @@ import { OrchestratorIcon } from "./icons";
import aoLogo from "../assets/ao-logo.png";
import { cn } from "../lib/utils";
import { useUiStore } from "../stores/ui-store";
import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet";
import { ImportProjectDialog, type ImportProjectInput } from "./ImportProjectDialog";
// The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live
// in the full-width topbar's left inset (_shell renders the bar above the
@ -75,7 +74,7 @@ type SidebarProps = {
underTopbar?: boolean;
workspaceError?: string;
workspaces: WorkspaceSummary[];
onCreateProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
onCreateProject: (input: ImportProjectInput) => Promise<void>;
onRemoveProject: (projectId: string) => Promise<void>;
};
@ -598,15 +597,15 @@ function ProjectItem({
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
return (
<CreateProjectFlow onCreateProject={onCreateProject}>
{({ disabled, choosePath, label }) => (
<ImportProjectDialog onImport={onCreateProject}>
{({ disabled, open, label }) => (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-[18px] w-[18px] place-items-center rounded-[4px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={disabled}
onClick={choosePath}
onClick={open}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
@ -615,14 +614,14 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)}
</CreateProjectFlow>
</ImportProjectDialog>
);
}
function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
return (
<CreateProjectFlow onCreateProject={onCreateProject}>
{({ disabled, choosePath, label }) => (
<ImportProjectDialog onImport={onCreateProject}>
{({ disabled, open, label }) => (
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
<Tooltip>
<TooltipTrigger asChild>
@ -630,7 +629,7 @@ function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreate
aria-label="New project"
className="grid h-9 w-full place-items-center rounded-[5px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={disabled}
onClick={choosePath}
onClick={open}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
@ -640,71 +639,6 @@ function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreate
</Tooltip>
</SidebarMenuItem>
)}
</CreateProjectFlow>
);
}
function CreateProjectFlow({
children,
onCreateProject,
}: Pick<SidebarProps, "onCreateProject"> & {
children: (state: { choosePath: () => void; disabled: boolean; label: string }) => ReactNode;
}) {
const [error, setError] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [isChoosingPath, setIsChoosingPath] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const choosePath = async () => {
setError(null);
setIsChoosingPath(true);
try {
const path = await aoBridge.app.chooseDirectory();
if (path) setSelectedPath(path);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
setIsChoosingPath(false);
}
};
const createProject = async (selection: CreateProjectAgentSelection) => {
if (!selectedPath) return;
setError(null);
setIsCreating(true);
try {
await onCreateProject({ path: selectedPath, ...selection });
setSelectedPath(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
setIsCreating(false);
}
};
const label = isChoosingPath ? "Opening..." : isCreating ? "Creating..." : "New project";
return (
<>
{children({ choosePath: () => void choosePath(), disabled: isChoosingPath || isCreating, label })}
<CreateProjectAgentSheet
error={error}
isCreating={isCreating}
onOpenChange={(open) => {
if (!open) {
setSelectedPath(null);
setError(null);
}
}}
onSubmit={createProject}
open={selectedPath !== null}
path={selectedPath}
/>
{error && (
<span className="sr-only" role="status">
{error}
</span>
)}
</>
</ImportProjectDialog>
);
}

View File

@ -6,6 +6,7 @@ export const aoBridge: AoBridge =
app: {
getVersion: async () => "0.0.0-preview",
chooseDirectory: async () => null,
scanWorkspaceRepos: async () => [],
},
clipboard: {
writeText: async (text: string) => {

View File

@ -2,6 +2,7 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { type CSSProperties, useCallback, useEffect } from "react";
import { ShellTopbar } from "../components/ShellTopbar";
import type { ImportProjectInput } from "../components/ImportProjectDialog";
import { Sidebar } from "../components/Sidebar";
import { SidebarProvider } from "../components/ui/sidebar";
import { TitlebarNav } from "../components/TitlebarNav";
@ -54,7 +55,7 @@ function ShellLayout() {
);
const createProject = useCallback(
async (input: { path: string; workerAgent: string; orchestratorAgent: string }) => {
async (input: ImportProjectInput) => {
void addRendererExceptionStep("Project add requested", {
source: "project-add",
operation: "project_add",
@ -64,10 +65,14 @@ function ShellLayout() {
const { data, error } = await apiClient.POST("/api/v1/projects", {
body: {
path: input.path,
config: {
worker: { agent: input.workerAgent },
orchestrator: { agent: input.orchestratorAgent },
},
asWorkspace: input.asWorkspace,
config:
input.workerAgent && input.orchestratorAgent
? {
worker: { agent: input.workerAgent },
orchestrator: { agent: input.orchestratorAgent },
}
: undefined,
},
});
if (error) {
@ -90,6 +95,11 @@ function ShellLayout() {
};
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
if (input.asWorkspace) {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } });
return;
}
try {
const sessionId = await spawnOrchestrator(workspace.id);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });

View File

@ -58,6 +58,7 @@ if (typeof window !== "undefined") {
app: {
getVersion: async () => "0.0.0-test",
chooseDirectory: async () => null,
scanWorkspaceRepos: async () => [],
},
clipboard: {
writeText: async () => undefined,