feat(frontend): workspace/project import UI flow
This commit is contained in:
parent
a31cf1b582
commit
8ad3bd37d6
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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$/, "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ if (typeof window !== "undefined") {
|
|||
app: {
|
||||
getVersion: async () => "0.0.0-test",
|
||||
chooseDirectory: async () => null,
|
||||
scanWorkspaceRepos: async () => [],
|
||||
},
|
||||
clipboard: {
|
||||
writeText: async () => undefined,
|
||||
|
|
|
|||
Loading…
Reference in New Issue