feat: add workspace import UI flow

This commit is contained in:
Aditi Chauhan 2026-07-01 19:16:42 +05:30
parent 0c87a02b87
commit 357f6b0f40
17 changed files with 4236 additions and 4467 deletions

View File

@ -1,12 +1,12 @@
{
"name": "agent-orchestrator",
"version": "0.0.0",
"version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agent-orchestrator",
"version": "0.0.0",
"version": "0.10.0",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.16",
@ -156,6 +156,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@ -493,6 +494,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@ -541,6 +543,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@ -2777,33 +2780,10 @@
"node": ">= 10.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@ -3056,6 +3036,7 @@
"integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^3.0.1",
"@inquirer/confirm": "^4.0.1",
@ -3472,6 +3453,7 @@
"integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^4.0.0",
"@octokit/graphql": "^7.1.0",
@ -5676,6 +5658,40 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
@ -6066,6 +6082,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.15.tgz",
"integrity": "sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.162.0",
"@tanstack/react-store": "^0.9.3",
@ -6358,8 +6375,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
@ -6476,6 +6492,7 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -6486,6 +6503,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -6894,7 +6912,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
@ -6926,6 +6945,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -6989,6 +7009,7 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -7535,6 +7556,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@ -8686,7 +8708,6 @@
"integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.15.3",
"builder-util": "26.15.3",
@ -8700,7 +8721,6 @@
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@ -8716,7 +8736,6 @@
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@ -8730,7 +8749,6 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@ -8740,8 +8758,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.11",
@ -9355,7 +9372,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@ -9376,7 +9392,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@ -9393,31 +9408,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@ -11843,7 +11833,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@ -12324,7 +12313,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@ -13275,7 +13263,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@ -13291,7 +13278,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@ -13522,6 +13508,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -13531,6 +13518,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -13543,8 +13531,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
@ -13919,7 +13906,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@ -14116,6 +14102,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz",
"integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@ -14595,7 +14582,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@ -14955,6 +14941,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -15176,6 +15163,7 @@
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@ -15225,6 +15213,7 @@
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ nodeLinker: hoisted
# Electron's postinstall downloads the runtime binary into node_modules/electron/dist;
# without this approval pnpm skips it and `electron-forge start` cannot launch.
allowBuilds:
core-js: false
electron: true
electron-winstaller: true
# @electron/rebuild (via electron-forge) depends on @electron/node-gyp as a git

View File

@ -26,12 +26,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";
@ -96,6 +97,22 @@ 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);
type GitRepoScanResult = {
name: string;
path: string;
relativePath: string;
branch: string;
remote: string;
hasRemote: boolean;
};
type ImportFolderScanResult = {
path: string;
repos: GitRepoScanResult[];
};
const isDev = !app.isPackaged;
const RENDERER_SCHEME = "app";
@ -825,15 +842,90 @@ ipcMain.handle("app:getVersion", () => app.getVersion());
ipcMain.handle("telemetry:getBootstrap", () =>
buildTelemetryBootstrap(process.env, app.getVersion(), process.platform),
);
ipcMain.handle("app:chooseDirectory", async () => {
async function chooseDirectory(title: string): Promise<string | null> {
const options: OpenDialogOptions = {
properties: ["openDirectory"],
title: "Choose a git repository",
title,
};
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
if (result.canceled) return null;
return result.filePaths[0] ?? null;
}
async function gitOutput(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, { cwd, timeout: 5000 });
return String(stdout).trim();
}
async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await stat(path.join(repoPath, ".git"));
await gitOutput(repoPath, ["rev-parse", "--show-toplevel"]);
return true;
} catch {
return false;
}
}
async function resolveDefaultBranch(repoPath: string): Promise<string> {
try {
const ref = await gitOutput(repoPath, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
if (ref) return ref.replace(/^origin\//, "");
} catch {
// Fall back to the checked-out branch when origin/HEAD is unavailable.
}
try {
const branch = await gitOutput(repoPath, ["branch", "--show-current"]);
if (branch) return branch;
} catch {
// Detached or unreadable HEAD is represented below.
}
return "HEAD";
}
async function scanGitRepo(repoPath: string, rootPath: string): Promise<GitRepoScanResult | null> {
if (!(await isGitRepo(repoPath))) return null;
const [branchResult, remoteResult] = await Promise.allSettled([
resolveDefaultBranch(repoPath),
gitOutput(repoPath, ["remote", "get-url", "origin"]),
]);
const relativePath = repoPath === rootPath ? "." : path.relative(rootPath, repoPath);
return {
name: path.basename(repoPath),
path: repoPath,
relativePath,
branch: branchResult.status === "fulfilled" && branchResult.value ? branchResult.value : "HEAD",
remote: remoteResult.status === "fulfilled" ? remoteResult.value : "",
hasRemote: remoteResult.status === "fulfilled" && remoteResult.value.length > 0,
};
}
async function scanImportFolder(rootPath: string, mode: "project" | "workspace"): Promise<ImportFolderScanResult> {
if (mode === "project") {
const repo = await scanGitRepo(rootPath, rootPath);
return { path: rootPath, repos: repo ? [repo] : [] };
}
const entries = await readdir(rootPath, { withFileTypes: true });
const repos = await Promise.all(
entries
.filter((entry) => entry.isDirectory() && entry.name !== ".git")
.map((entry) => scanGitRepo(path.join(rootPath, entry.name), rootPath)),
);
return {
path: rootPath,
repos: repos
.filter((repo): repo is GitRepoScanResult => repo !== null)
.sort((a, b) => a.name.localeCompare(b.name)),
};
}
ipcMain.handle("app:chooseDirectory", async (_event, title?: string) => {
return chooseDirectory(typeof title === "string" && title.trim() ? title : "Choose a git repository");
});
ipcMain.handle("app:scanImportFolder", async (_event, input: { path: string; mode: "project" | "workspace" }) => {
return scanImportFolder(input.path, input.mode);
});
ipcMain.handle("clipboard:writeText", (_event, text: string) => {
clipboard.writeText(text, "clipboard");

View File

@ -16,10 +16,28 @@ export type BrowserNavigateInput = {
url: string;
};
export type ImportFolderMode = "project" | "workspace";
export type ImportRepoScan = {
name: string;
path: string;
relativePath: string;
branch: string;
remote: string;
hasRemote: boolean;
};
export type ImportFolderScan = {
path: string;
repos: ImportRepoScan[];
};
const api = {
app: {
getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise<string>,
chooseDirectory: () => ipcRenderer.invoke("app:chooseDirectory") as Promise<string | null>,
chooseDirectory: (title?: string) => ipcRenderer.invoke("app:chooseDirectory", title) as Promise<string | null>,
scanImportFolder: (input: { path: string; mode: ImportFolderMode }) =>
ipcRenderer.invoke("app:scanImportFolder", input) as Promise<ImportFolderScan>,
},
clipboard: {
writeText: (text: string) => ipcRenderer.invoke("clipboard:writeText", text) as Promise<void>,

View File

@ -6,6 +6,7 @@ import type { components } from "../../api/schema";
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
import { AGENT_OPTIONS } from "../lib/agent-options";
import { buildIntake, type IntakeForm, IntakeFields, intakeNeedsRule } from "./IntakeFields";
import type { ProjectKind } from "../types/workspace";
import { Button } from "./ui/button";
import { Label } from "./ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
@ -25,6 +26,7 @@ const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "", assignee: "" };
type CreateProjectAgentSheetProps = {
error?: string | null;
isCreating: boolean;
kind: ProjectKind;
onOpenChange: (open: boolean) => void;
onSubmit: (selection: CreateProjectAgentSelection) => Promise<void>;
open: boolean;
@ -34,6 +36,7 @@ type CreateProjectAgentSheetProps = {
export function CreateProjectAgentSheet({
error,
isCreating,
kind,
onOpenChange,
onSubmit,
open,
@ -85,7 +88,9 @@ export function CreateProjectAgentSheet({
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(420px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div className="min-w-0">
<Dialog.Title className="text-[15px] font-semibold text-foreground">Project agents</Dialog.Title>
<Dialog.Title className="text-[15px] font-semibold text-foreground">
{kind === "workspace" ? "Workspace agents" : "Project agents"}
</Dialog.Title>
<Dialog.Description className="mt-1 break-all text-[12px] text-muted-foreground">
{path ?? ""}
</Dialog.Description>
@ -177,7 +182,7 @@ export function CreateProjectAgentSheet({
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!canSubmit}>
{isCreating ? "Creating..." : "Create and start"}
{isCreating ? "Creating..." : kind === "workspace" ? "Create workspace and start" : "Create and start"}
</Button>
</div>
</form>

View File

@ -195,11 +195,38 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
</CardHeader>
<CardContent className="flex flex-col gap-2 font-mono text-[12px] text-muted-foreground">
<ReadonlyRow label="id" value={project.id} />
<ReadonlyRow label="kind" value={project.kind === "workspace" ? "workspace" : "single repo"} />
<ReadonlyRow label="path" value={project.path} />
<ReadonlyRow label="repo" value={project.repo || "—"} />
</CardContent>
</Card>
{project.kind === "workspace" && (
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Workspace repos</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{project.workspaceRepos?.length ? (
project.workspaceRepos.map((repo) => (
<div
key={repo.name}
className="grid grid-cols-[minmax(0,120px)_minmax(0,1fr)] gap-3 rounded-md border border-border px-3 py-2 font-mono text-[12px]"
>
<span className="truncate text-foreground">{repo.name}</span>
<span className="min-w-0 truncate text-muted-foreground">
{repo.relativePath}
{repo.repo ? ` · ${repo.repo}` : ""}
</span>
</div>
))
) : (
<p className="text-[12px] text-muted-foreground">No child repositories are registered.</p>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Worktrees</CardTitle>

View File

@ -58,7 +58,13 @@ const session: WorkspaceSession = {
prs: [],
};
type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
type CreateProjectInput = {
path: string;
workerAgent: string;
orchestratorAgent: string;
asWorkspace?: boolean;
};
type CreateProjectHandler = (input: CreateProjectInput) => Promise<void>;
type RemoveProjectHandler = (projectId: string) => Promise<void>;
function renderSidebar({
@ -190,6 +196,12 @@ describe("Sidebar", () => {
renderSidebar({ onCreateProject });
await user.click(screen.getByLabelText("New project"));
expect(screen.getByRole("dialog", { name: "Import to Agent Orchestrator" })).toBeInTheDocument();
expect(window.ao!.app.chooseDirectory).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /^Project/i }));
expect(await screen.findByRole("dialog", { name: "Import project" })).toBeInTheDocument();
expect(window.ao!.app.chooseDirectory).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /Choose a project folder/i }));
expect(await screen.findByText("/repo/new-project")).toBeInTheDocument();
const dialog = screen.getByRole("dialog", { name: "Project agents" });
@ -203,10 +215,95 @@ describe("Sidebar", () => {
path: "/repo/new-project",
workerAgent: "codex",
orchestratorAgent: "claude-code",
asWorkspace: false,
}),
);
});
it("can create a workspace project from the project add flow", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/workspace");
renderSidebar({ onCreateProject });
await user.click(screen.getByLabelText("New project"));
await user.click(screen.getByRole("button", { name: /^Workspace/i }));
expect(await screen.findByRole("dialog", { name: "Import workspace" })).toBeInTheDocument();
expect(window.ao!.app.chooseDirectory).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /Choose a folder/i }));
expect(await screen.findByText("/repo/workspace")).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "Workspace agents" })).toBeInTheDocument();
await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex");
await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "claude-code");
await user.click(screen.getByRole("button", { name: "Create workspace and start" }));
await waitFor(() =>
expect(onCreateProject).toHaveBeenCalledWith({
path: "/repo/workspace",
workerAgent: "codex",
orchestratorAgent: "claude-code",
asWorkspace: true,
}),
);
});
it("shows detected repository validation when workspace import fails", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockRejectedValue(new Error("workspace not registered")) as CreateProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/Users/test/dev/acme");
window.ao!.app.scanImportFolder = vi.fn().mockResolvedValue({
path: "/Users/test/dev/acme",
repos: [
{
name: "web",
path: "/Users/test/dev/acme/web",
relativePath: "web",
branch: "HEAD",
remote: "",
hasRemote: false,
},
{
name: "api",
path: "/Users/test/dev/acme/api",
relativePath: "api",
branch: "main",
remote: "git@github.com:acme/api.git",
hasRemote: true,
},
],
});
renderSidebar({ onCreateProject });
await user.click(screen.getByLabelText("New project"));
await user.click(screen.getByRole("button", { name: /^Workspace/i }));
await user.click(await screen.findByRole("button", { name: /Choose a folder/i }));
await screen.findByRole("dialog", { name: "Workspace agents" });
await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex");
await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "claude-code");
await user.click(screen.getByRole("button", { name: "Create workspace and start" }));
expect(await screen.findByText(/Validation failed · workspace not registered/i)).toBeInTheDocument();
expect(screen.getByText("web")).toBeInTheDocument();
expect(screen.getByText("No remote configured")).toBeInTheDocument();
expect(screen.getByText("api")).toBeInTheDocument();
expect(screen.getByText("main github.com/acme/api")).toBeInTheDocument();
expect(screen.getByText("Resolve 1 failed repository to continue")).toBeInTheDocument();
expect(window.ao!.app.scanImportFolder).toHaveBeenCalledWith({
path: "/Users/test/dev/acme",
mode: "workspace",
});
});
it("opens global settings from the footer menu when no project is selected", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: /project actions/i }));
expect(await screen.findByRole("menuitem", { name: /settings/i })).toBeInTheDocument();
});
it("shows needs-auth agents as unavailable while keeping authorized agents selectable", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler;

View File

@ -1,7 +1,11 @@
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
import {
ChevronRight,
CheckCircle2,
Folder,
FolderPlus,
GitPullRequest,
LayoutDashboard,
Moon,
@ -12,12 +16,16 @@ import {
Settings,
Sun,
Trash2,
X,
XCircle,
} from "lucide-react";
import { useRef, useState, type ReactNode } from "react";
import type { ImportFolderScan } from "../../preload";
import {
attentionZone,
newestActiveOrchestrator,
sessionIsActive,
type ProjectKind,
type WorkspaceSession,
type WorkspaceSummary,
workerSessions,
@ -58,6 +66,7 @@ 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 { Button } from "./ui/button";
// 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
@ -253,7 +262,7 @@ export function Sidebar({
<div className="px-2 py-3 group-data-[collapsible=icon]:hidden">
<p className="text-[12px] text-passive">No projects yet.</p>
<p className="mt-1 text-[11px] text-passive">
Click <span className="text-foreground">+</span> above to register a git repo.
Click <span className="text-foreground">+</span> above to register a repo or workspace.
</p>
</div>
) : (
@ -769,16 +778,34 @@ function CreateProjectFlow({
children: (state: { choosePath: () => void; disabled: boolean; label: string }) => ReactNode;
}) {
const [error, setError] = useState<string | null>(null);
const [modePickerOpen, setModePickerOpen] = useState(false);
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
const [selectedKind, setSelectedKind] = useState<ProjectKind>("single_repo");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [validationScan, setValidationScan] = useState<ImportFolderScan | null>(null);
const [isChoosingPath, setIsChoosingPath] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const openFolderStep = (kind: ProjectKind) => {
setError(null);
setValidationScan(null);
setSelectedKind(kind);
setModePickerOpen(false);
window.requestAnimationFrame(() => setFolderPickerOpen(true));
};
const choosePath = async () => {
setError(null);
setIsChoosingPath(true);
try {
const path = await aoBridge.app.chooseDirectory();
if (path) setSelectedPath(path);
const path = await aoBridge.app.chooseDirectory(
selectedKind === "workspace" ? "Choose a workspace folder" : "Choose a project repository",
);
if (path) {
setValidationScan(null);
setSelectedPath(path);
setFolderPickerOpen(false);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
@ -791,10 +818,22 @@ function CreateProjectFlow({
setError(null);
setIsCreating(true);
try {
await onCreateProject({ path: selectedPath, ...selection });
await onCreateProject({ path: selectedPath, asWorkspace: selectedKind === "workspace", ...selection });
setSelectedPath(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
const message = err instanceof Error ? err.message : "Could not add project";
setError(message);
try {
const scan = await aoBridge.app.scanImportFolder({
path: selectedPath,
mode: selectedKind === "workspace" ? "workspace" : "project",
});
setValidationScan(scan);
} catch {
setValidationScan({ path: selectedPath, repos: [] });
}
setSelectedPath(null);
setFolderPickerOpen(true);
} finally {
setIsCreating(false);
}
@ -804,14 +843,44 @@ function CreateProjectFlow({
return (
<>
{children({ choosePath: () => void choosePath(), disabled: isChoosingPath || isCreating, label })}
{children({ choosePath: () => setModePickerOpen(true), disabled: isChoosingPath || isCreating, label })}
<CreateProjectModeDialog
disabled={isChoosingPath || isCreating}
open={modePickerOpen}
onOpenChange={(open) => !isChoosingPath && !isCreating && setModePickerOpen(open)}
onSelect={openFolderStep}
/>
<CreateProjectFolderDialog
disabled={isChoosingPath || isCreating}
error={error}
kind={selectedKind}
open={folderPickerOpen}
scan={validationScan}
onBack={() => {
setError(null);
setValidationScan(null);
setFolderPickerOpen(false);
window.requestAnimationFrame(() => setModePickerOpen(true));
}}
onChooseFolder={() => void choosePath()}
onOpenChange={(open) => {
if (!isChoosingPath && !isCreating) {
setFolderPickerOpen(open);
if (!open) {
setError(null);
setValidationScan(null);
}
}
}}
/>
<CreateProjectAgentSheet
error={error}
isCreating={isCreating}
kind={selectedKind}
onOpenChange={(open) => {
if (!open) {
setSelectedPath(null);
setError(null);
if (!folderPickerOpen) setError(null);
}
}}
onSubmit={createProject}
@ -826,3 +895,317 @@ function CreateProjectFlow({
</>
);
}
function CreateProjectModeDialog({
disabled,
onOpenChange,
onSelect,
open,
}: {
disabled: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (kind: ProjectKind) => void;
open: boolean;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<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 flex max-h-[min(720px,calc(100svh-24px))] w-[min(680px,calc(100vw-24px))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
<div className="flex shrink-0 items-start justify-between gap-4 px-4 pb-3 pt-4 sm:px-6 sm:pb-4 sm:pt-5">
<div className="min-w-0">
<Dialog.Title className="text-[18px] font-semibold text-foreground">
Import to Agent Orchestrator
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] font-medium text-muted-foreground">
What are you importing?
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
aria-label="Close new project dialog"
disabled={disabled}
>
<X className="size-4" aria-hidden="true" />
</button>
</Dialog.Close>
</div>
<div className="grid min-h-0 gap-3 overflow-y-auto px-4 pb-4 sm:grid-cols-2 sm:px-6 sm:pb-6">
<ProjectModeButton
description="Several Git repos that live under one parent folder."
disabled={disabled}
kind="workspace"
onClick={() => onSelect("workspace")}
/>
<ProjectModeButton
description="A single Git repository — one codebase, tracked in one repo."
disabled={disabled}
kind="single_repo"
onClick={() => onSelect("single_repo")}
/>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function ProjectModeButton({
description,
disabled,
kind,
onClick,
}: {
description: string;
disabled: boolean;
kind: ProjectKind;
onClick: () => void;
}) {
const isWorkspace = kind === "workspace";
return (
<button
type="button"
aria-label={isWorkspace ? "Workspace" : "Project"}
className="flex min-h-[176px] w-full flex-col justify-end rounded-lg border border-border bg-card px-4 py-4 text-left transition-colors hover:bg-background focus-visible:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50 sm:min-h-[220px] sm:px-5 sm:py-5"
disabled={disabled}
onClick={onClick}
>
<span className="mb-3 flex min-h-[70px] w-full items-center justify-center sm:mb-4 sm:min-h-[92px]">
{isWorkspace ? (
<span className="mx-auto w-[min(210px,100%)] rounded-lg border border-dashed border-border px-3 py-3">
<span className="mx-auto mb-2 flex w-[min(160px,100%)] items-center gap-2 font-mono text-[11px] font-semibold text-muted-foreground">
<Folder className="size-3.5" aria-hidden="true" />
my-workspace/
</span>
{["web-app", "api-server", "shared-libs"].map((repo) => (
<span
key={repo}
className="mx-auto mb-1.5 flex w-[min(170px,100%)] items-center gap-2 rounded-md bg-background px-2.5 py-1.5 font-mono text-[12px] font-semibold text-foreground last:mb-0"
>
<span className="size-1.5 rounded-full bg-success" aria-hidden="true" />
{repo}
</span>
))}
</span>
) : (
<span className="mx-auto max-w-full rounded-lg border border-border bg-background px-4 py-3 font-mono text-[12px] font-semibold text-foreground sm:px-5 sm:py-3.5 sm:text-[13px]">
<span className="mr-2 inline-block size-1.5 rounded-full bg-success" aria-hidden="true" />
web-app <span className="px-2 text-muted-foreground">·</span>
<span className="text-muted-foreground">main</span>
</span>
)}
</span>
<span className="block text-[15px] font-semibold text-foreground sm:text-[16px]">
{isWorkspace ? "Workspace" : "Project"}
</span>
<span className="mt-2 block text-[12px] leading-5 text-muted-foreground sm:min-h-[40px] sm:text-[13px]">
{description}
</span>
<span className="mt-3 font-mono text-[12px] font-semibold text-passive">
<span className="mr-2 text-passive"></span>
{isWorkspace ? "Multiple repositories" : "One repository"}
</span>
</button>
);
}
function CreateProjectFolderDialog({
disabled,
error,
kind,
onBack,
onChooseFolder,
onOpenChange,
open,
scan,
}: {
disabled: boolean;
error: string | null;
kind: ProjectKind;
onBack: () => void;
onChooseFolder: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
scan: ImportFolderScan | null;
}) {
const isWorkspace = kind === "workspace";
const failedRepos = scan?.repos.filter((repo) => !repo.hasRemote) ?? [];
const hasScan = scan !== null;
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<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 flex max-h-[min(640px,calc(100svh-24px))] w-[min(640px,calc(100vw-24px))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
<div className="flex shrink-0 items-start gap-3 border-b border-border px-4 py-4 sm:gap-4 sm:px-6 sm:py-5">
<button
type="button"
className="grid size-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
aria-label="Back to import type"
disabled={disabled}
onClick={onBack}
>
<ChevronRight className="size-4 rotate-180" aria-hidden="true" />
</button>
<div className="min-w-0 flex-1">
<Dialog.Title className="text-[18px] font-semibold text-foreground">
{isWorkspace ? "Import workspace" : "Import project"}
</Dialog.Title>
<Dialog.Description className="mt-1 max-w-[520px] text-[13px] font-medium leading-5 text-muted-foreground">
{isWorkspace
? "Pick a folder that contains your Git repositories. Each repo inside it joins the workspace."
: "Import a single Git repository as one project."}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
aria-label="Close import dialog"
disabled={disabled}
>
<X className="size-4" aria-hidden="true" />
</button>
</Dialog.Close>
</div>
<div className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-6">
{hasScan ? (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border border-border bg-background px-4 py-3">
<Folder className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-[14px] font-semibold text-foreground">
{displayImportPath(scan.path)}
</div>
<div className="mt-0.5 text-[12px] text-muted-foreground">
{isWorkspace ? "Workspace root" : "Project folder"}
</div>
</div>
<Button type="button" variant="outline" disabled={disabled} onClick={onChooseFolder}>
Change
</Button>
</div>
{error && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10">
<div className="border-b border-destructive/30 px-4 py-3 font-mono text-[12px] font-semibold uppercase tracking-[0.12em] text-destructive">
<span className="mr-2 inline-block size-2 rounded-full bg-destructive" aria-hidden="true" />
Validation failed · {isWorkspace ? "workspace" : "project"} not registered
</div>
{failedRepos.length > 0 ? (
failedRepos.map((repo) => <ImportRepoRow key={repo.path} repo={repo} failed />)
) : (
<div className="px-4 py-3 text-[12px] leading-5 text-destructive">{error}</div>
)}
</div>
)}
{scan.repos
.filter((repo) => repo.hasRemote)
.map((repo) => (
<div key={repo.path} className="rounded-lg border border-border bg-background">
<ImportRepoRow repo={repo} />
</div>
))}
{scan.repos.length === 0 && (
<div className="rounded-lg border border-border bg-background px-4 py-4 text-[12px] text-muted-foreground">
No repositories detected in this folder.
</div>
)}
</div>
) : (
<button
type="button"
className="flex min-h-[132px] w-full flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background px-4 py-5 text-center transition-colors hover:bg-surface disabled:pointer-events-none disabled:opacity-50 sm:min-h-[160px] sm:px-5 sm:py-6"
disabled={disabled}
onClick={onChooseFolder}
>
<span className="mb-4 grid size-11 place-items-center rounded-xl bg-card text-muted-foreground">
<FolderPlus className="size-5" aria-hidden="true" />
</span>
<span className="text-[15px] font-semibold text-foreground">
{isWorkspace ? "Choose a folder" : "Choose a project folder"}
</span>
<span className="mt-2 max-w-full text-pretty text-[12px] text-muted-foreground sm:text-[13px]">
{isWorkspace
? "Opens your system file picker — pick the folder that holds your repos"
: "Opens your system file picker — select one repo folder"}
</span>
</button>
)}
{error && (
<div
className={cn(
"mt-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive",
hasScan && "sr-only",
)}
>
{error}
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-3 border-t border-border px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<p className="text-[12px] font-medium text-muted-foreground">
{hasScan && failedRepos.length > 0
? `Resolve ${failedRepos.length} failed ${failedRepos.length === 1 ? "repository" : "repositories"} to continue`
: isWorkspace
? "No repositories to import"
: "No project selected"}
</p>
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
<Button type="button" variant="outline" disabled={disabled} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" variant="primary" disabled>
{isWorkspace ? "Import workspace" : "Import project"}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function ImportRepoRow({ failed = false, repo }: { failed?: boolean; repo: ImportFolderScan["repos"][number] }) {
return (
<div className="flex items-center gap-3 px-4 py-3">
{failed ? (
<XCircle className="size-5 shrink-0 text-destructive" aria-hidden="true" />
) : (
<CheckCircle2 className="size-5 shrink-0 text-success" aria-hidden="true" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-semibold text-foreground">{repo.name}</div>
<div className="mt-0.5 truncate font-mono text-[12px] text-muted-foreground">
{displayImportPath(repo.path)}
</div>
</div>
<div
className={cn(
"hidden max-w-[260px] shrink-0 truncate text-right font-mono text-[12px] sm:block",
failed ? "text-muted-foreground" : "text-muted-foreground",
)}
>
{repo.hasRemote ? `${repo.branch} ${remoteDisplay(repo.remote)}` : "No remote configured"}
</div>
</div>
);
}
function displayImportPath(value: string) {
return value.replace(/^\/Users\/[^/]+/, "~");
}
function remoteDisplay(remote: string) {
const ssh = remote.match(/^[^@]+@([^:]+):(.+)$/);
if (ssh?.[1] && ssh[2]) return `${ssh[1]}/${ssh[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

@ -43,6 +43,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
return (projectsData?.projects ?? []).map((project) => ({
id: project.id,
name: project.name,
kind: project.kind === "workspace" ? "workspace" : "single_repo",
path: project.path,
orchestratorAgent: project.orchestratorAgent ? toAgentProvider(project.orchestratorAgent) : undefined,
sessions: (sessionsData?.sessions ?? [])

View File

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

View File

@ -8,204 +8,209 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root";
import { Route as ShellRouteImport } from "./routes/_shell";
import { Route as ShellIndexRouteImport } from "./routes/_shell.index";
import { Route as ShellSettingsRouteImport } from "./routes/_shell.settings";
import { Route as ShellPrsRouteImport } from "./routes/_shell.prs";
import { Route as ShellSessionsSessionIdRouteImport } from "./routes/_shell.sessions.$sessionId";
import { Route as ShellProjectsProjectIdRouteImport } from "./routes/_shell.projects.$projectId";
import { Route as ShellProjectsProjectIdSettingsRouteImport } from "./routes/_shell.projects.$projectId_.settings";
import { Route as ShellProjectsProjectIdSessionsSessionIdRouteImport } from "./routes/_shell.projects.$projectId_.sessions.$sessionId";
import { Route as rootRouteImport } from './routes/__root'
import { Route as ShellRouteImport } from './routes/_shell'
import { Route as ShellIndexRouteImport } from './routes/_shell.index'
import { Route as ShellSettingsRouteImport } from './routes/_shell.settings'
import { Route as ShellPrsRouteImport } from './routes/_shell.prs'
import { Route as ShellSessionsSessionIdRouteImport } from './routes/_shell.sessions.$sessionId'
import { Route as ShellProjectsProjectIdRouteImport } from './routes/_shell.projects.$projectId'
import { Route as ShellProjectsProjectIdSettingsRouteImport } from './routes/_shell.projects.$projectId_.settings'
import { Route as ShellProjectsProjectIdSessionsSessionIdRouteImport } from './routes/_shell.projects.$projectId_.sessions.$sessionId'
const ShellRoute = ShellRouteImport.update({
id: "/_shell",
getParentRoute: () => rootRouteImport,
} as any);
id: '/_shell',
getParentRoute: () => rootRouteImport,
} as any)
const ShellIndexRoute = ShellIndexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => ShellRoute,
} as any);
id: '/',
path: '/',
getParentRoute: () => ShellRoute,
} as any)
const ShellSettingsRoute = ShellSettingsRouteImport.update({
id: "/settings",
path: "/settings",
getParentRoute: () => ShellRoute,
} as any);
id: '/settings',
path: '/settings',
getParentRoute: () => ShellRoute,
} as any)
const ShellPrsRoute = ShellPrsRouteImport.update({
id: "/prs",
path: "/prs",
getParentRoute: () => ShellRoute,
} as any);
id: '/prs',
path: '/prs',
getParentRoute: () => ShellRoute,
} as any)
const ShellSessionsSessionIdRoute = ShellSessionsSessionIdRouteImport.update({
id: "/sessions/$sessionId",
path: "/sessions/$sessionId",
getParentRoute: () => ShellRoute,
} as any);
id: '/sessions/$sessionId',
path: '/sessions/$sessionId',
getParentRoute: () => ShellRoute,
} as any)
const ShellProjectsProjectIdRoute = ShellProjectsProjectIdRouteImport.update({
id: "/projects/$projectId",
path: "/projects/$projectId",
getParentRoute: () => ShellRoute,
} as any);
const ShellProjectsProjectIdSettingsRoute = ShellProjectsProjectIdSettingsRouteImport.update({
id: "/projects/$projectId_/settings",
path: "/projects/$projectId/settings",
getParentRoute: () => ShellRoute,
} as any);
const ShellProjectsProjectIdSessionsSessionIdRoute = ShellProjectsProjectIdSessionsSessionIdRouteImport.update({
id: "/projects/$projectId_/sessions/$sessionId",
path: "/projects/$projectId/sessions/$sessionId",
getParentRoute: () => ShellRoute,
} as any);
id: '/projects/$projectId',
path: '/projects/$projectId',
getParentRoute: () => ShellRoute,
} as any)
const ShellProjectsProjectIdSettingsRoute =
ShellProjectsProjectIdSettingsRouteImport.update({
id: '/projects/$projectId_/settings',
path: '/projects/$projectId/settings',
getParentRoute: () => ShellRoute,
} as any)
const ShellProjectsProjectIdSessionsSessionIdRoute =
ShellProjectsProjectIdSessionsSessionIdRouteImport.update({
id: '/projects/$projectId_/sessions/$sessionId',
path: '/projects/$projectId/sessions/$sessionId',
getParentRoute: () => ShellRoute,
} as any)
export interface FileRoutesByFullPath {
"/": typeof ShellIndexRoute;
"/prs": typeof ShellPrsRoute;
"/settings": typeof ShellSettingsRoute;
"/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
"/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute;
"/projects/$projectId/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute;
'/': typeof ShellIndexRoute
'/prs': typeof ShellPrsRoute
'/settings': typeof ShellSettingsRoute
'/projects/$projectId': typeof ShellProjectsProjectIdRoute
'/sessions/$sessionId': typeof ShellSessionsSessionIdRoute
'/projects/$projectId/settings': typeof ShellProjectsProjectIdSettingsRoute
'/projects/$projectId/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute
}
export interface FileRoutesByTo {
"/prs": typeof ShellPrsRoute;
"/settings": typeof ShellSettingsRoute;
"/": typeof ShellIndexRoute;
"/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
"/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute;
"/projects/$projectId/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute;
'/prs': typeof ShellPrsRoute
'/settings': typeof ShellSettingsRoute
'/': typeof ShellIndexRoute
'/projects/$projectId': typeof ShellProjectsProjectIdRoute
'/sessions/$sessionId': typeof ShellSessionsSessionIdRoute
'/projects/$projectId/settings': typeof ShellProjectsProjectIdSettingsRoute
'/projects/$projectId/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/_shell": typeof ShellRouteWithChildren;
"/_shell/prs": typeof ShellPrsRoute;
"/_shell/settings": typeof ShellSettingsRoute;
"/_shell/": typeof ShellIndexRoute;
"/_shell/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/_shell/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
"/_shell/projects/$projectId_/settings": typeof ShellProjectsProjectIdSettingsRoute;
"/_shell/projects/$projectId_/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute;
__root__: typeof rootRouteImport
'/_shell': typeof ShellRouteWithChildren
'/_shell/prs': typeof ShellPrsRoute
'/_shell/settings': typeof ShellSettingsRoute
'/_shell/': typeof ShellIndexRoute
'/_shell/projects/$projectId': typeof ShellProjectsProjectIdRoute
'/_shell/sessions/$sessionId': typeof ShellSessionsSessionIdRoute
'/_shell/projects/$projectId_/settings': typeof ShellProjectsProjectIdSettingsRoute
'/_shell/projects/$projectId_/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths:
| "/"
| "/prs"
| "/settings"
| "/projects/$projectId"
| "/sessions/$sessionId"
| "/projects/$projectId/settings"
| "/projects/$projectId/sessions/$sessionId";
fileRoutesByTo: FileRoutesByTo;
to:
| "/prs"
| "/settings"
| "/"
| "/projects/$projectId"
| "/sessions/$sessionId"
| "/projects/$projectId/settings"
| "/projects/$projectId/sessions/$sessionId";
id:
| "__root__"
| "/_shell"
| "/_shell/prs"
| "/_shell/settings"
| "/_shell/"
| "/_shell/projects/$projectId"
| "/_shell/sessions/$sessionId"
| "/_shell/projects/$projectId_/settings"
| "/_shell/projects/$projectId_/sessions/$sessionId";
fileRoutesById: FileRoutesById;
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/prs'
| '/settings'
| '/projects/$projectId'
| '/sessions/$sessionId'
| '/projects/$projectId/settings'
| '/projects/$projectId/sessions/$sessionId'
fileRoutesByTo: FileRoutesByTo
to:
| '/prs'
| '/settings'
| '/'
| '/projects/$projectId'
| '/sessions/$sessionId'
| '/projects/$projectId/settings'
| '/projects/$projectId/sessions/$sessionId'
id:
| '__root__'
| '/_shell'
| '/_shell/prs'
| '/_shell/settings'
| '/_shell/'
| '/_shell/projects/$projectId'
| '/_shell/sessions/$sessionId'
| '/_shell/projects/$projectId_/settings'
| '/_shell/projects/$projectId_/sessions/$sessionId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
ShellRoute: typeof ShellRouteWithChildren;
ShellRoute: typeof ShellRouteWithChildren
}
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/_shell": {
id: "/_shell";
path: "";
fullPath: "/";
preLoaderRoute: typeof ShellRouteImport;
parentRoute: typeof rootRouteImport;
};
"/_shell/": {
id: "/_shell/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof ShellIndexRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/settings": {
id: "/_shell/settings";
path: "/settings";
fullPath: "/settings";
preLoaderRoute: typeof ShellSettingsRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/prs": {
id: "/_shell/prs";
path: "/prs";
fullPath: "/prs";
preLoaderRoute: typeof ShellPrsRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/sessions/$sessionId": {
id: "/_shell/sessions/$sessionId";
path: "/sessions/$sessionId";
fullPath: "/sessions/$sessionId";
preLoaderRoute: typeof ShellSessionsSessionIdRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/projects/$projectId": {
id: "/_shell/projects/$projectId";
path: "/projects/$projectId";
fullPath: "/projects/$projectId";
preLoaderRoute: typeof ShellProjectsProjectIdRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/projects/$projectId_/settings": {
id: "/_shell/projects/$projectId_/settings";
path: "/projects/$projectId/settings";
fullPath: "/projects/$projectId/settings";
preLoaderRoute: typeof ShellProjectsProjectIdSettingsRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/projects/$projectId_/sessions/$sessionId": {
id: "/_shell/projects/$projectId_/sessions/$sessionId";
path: "/projects/$projectId/sessions/$sessionId";
fullPath: "/projects/$projectId/sessions/$sessionId";
preLoaderRoute: typeof ShellProjectsProjectIdSessionsSessionIdRouteImport;
parentRoute: typeof ShellRoute;
};
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_shell': {
id: '/_shell'
path: ''
fullPath: '/'
preLoaderRoute: typeof ShellRouteImport
parentRoute: typeof rootRouteImport
}
'/_shell/': {
id: '/_shell/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof ShellIndexRouteImport
parentRoute: typeof ShellRoute
}
'/_shell/settings': {
id: '/_shell/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof ShellSettingsRouteImport
parentRoute: typeof ShellRoute
}
'/_shell/prs': {
id: '/_shell/prs'
path: '/prs'
fullPath: '/prs'
preLoaderRoute: typeof ShellPrsRouteImport
parentRoute: typeof ShellRoute
}
'/_shell/sessions/$sessionId': {
id: '/_shell/sessions/$sessionId'
path: '/sessions/$sessionId'
fullPath: '/sessions/$sessionId'
preLoaderRoute: typeof ShellSessionsSessionIdRouteImport
parentRoute: typeof ShellRoute
}
'/_shell/projects/$projectId': {
id: '/_shell/projects/$projectId'
path: '/projects/$projectId'
fullPath: '/projects/$projectId'
preLoaderRoute: typeof ShellProjectsProjectIdRouteImport
parentRoute: typeof ShellRoute
}
'/_shell/projects/$projectId_/settings': {
id: '/_shell/projects/$projectId_/settings'
path: '/projects/$projectId/settings'
fullPath: '/projects/$projectId/settings'
preLoaderRoute: typeof ShellProjectsProjectIdSettingsRouteImport
parentRoute: typeof ShellRoute
}
'/_shell/projects/$projectId_/sessions/$sessionId': {
id: '/_shell/projects/$projectId_/sessions/$sessionId'
path: '/projects/$projectId/sessions/$sessionId'
fullPath: '/projects/$projectId/sessions/$sessionId'
preLoaderRoute: typeof ShellProjectsProjectIdSessionsSessionIdRouteImport
parentRoute: typeof ShellRoute
}
}
}
interface ShellRouteChildren {
ShellPrsRoute: typeof ShellPrsRoute;
ShellSettingsRoute: typeof ShellSettingsRoute;
ShellIndexRoute: typeof ShellIndexRoute;
ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute;
ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute;
ShellProjectsProjectIdSettingsRoute: typeof ShellProjectsProjectIdSettingsRoute;
ShellProjectsProjectIdSessionsSessionIdRoute: typeof ShellProjectsProjectIdSessionsSessionIdRoute;
ShellPrsRoute: typeof ShellPrsRoute
ShellSettingsRoute: typeof ShellSettingsRoute
ShellIndexRoute: typeof ShellIndexRoute
ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute
ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute
ShellProjectsProjectIdSettingsRoute: typeof ShellProjectsProjectIdSettingsRoute
ShellProjectsProjectIdSessionsSessionIdRoute: typeof ShellProjectsProjectIdSessionsSessionIdRoute
}
const ShellRouteChildren: ShellRouteChildren = {
ShellPrsRoute: ShellPrsRoute,
ShellSettingsRoute: ShellSettingsRoute,
ShellIndexRoute: ShellIndexRoute,
ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute,
ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute,
ShellProjectsProjectIdSettingsRoute: ShellProjectsProjectIdSettingsRoute,
ShellProjectsProjectIdSessionsSessionIdRoute: ShellProjectsProjectIdSessionsSessionIdRoute,
};
ShellPrsRoute: ShellPrsRoute,
ShellSettingsRoute: ShellSettingsRoute,
ShellIndexRoute: ShellIndexRoute,
ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute,
ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute,
ShellProjectsProjectIdSettingsRoute: ShellProjectsProjectIdSettingsRoute,
ShellProjectsProjectIdSessionsSessionIdRoute:
ShellProjectsProjectIdSessionsSessionIdRoute,
}
const ShellRouteWithChildren = ShellRoute._addFileChildren(ShellRouteChildren);
const ShellRouteWithChildren = ShellRoute._addFileChildren(ShellRouteChildren)
const rootRouteChildren: RootRouteChildren = {
ShellRoute: ShellRouteWithChildren,
};
export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes<FileRouteTypes>();
ShellRoute: ShellRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@ -69,6 +69,7 @@ function ShellLayout() {
workerAgent: string;
orchestratorAgent: string;
trackerIntake?: components["schemas"]["TrackerIntakeConfig"];
asWorkspace?: boolean;
}) => {
void addRendererExceptionStep("Project add requested", {
source: "project-add",
@ -83,6 +84,7 @@ function ShellLayout() {
const { data, error } = await apiClient.POST("/api/v1/projects", {
body: {
path: input.path,
asWorkspace: input.asWorkspace || undefined,
config: {
worker: { agent: input.workerAgent },
orchestrator: { agent: input.orchestratorAgent },
@ -104,7 +106,9 @@ function ShellLayout() {
const workspace: WorkspaceSummary = {
id: data.project.id,
name: data.project.name,
kind: data.project.kind === "workspace" ? "workspace" : "single_repo",
path: data.project.path,
workspaceRepos: data.project.workspaceRepos,
type: "main",
orchestratorAgent: input.orchestratorAgent as WorkspaceSummary["orchestratorAgent"],
sessions: [],

View File

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

View File

@ -174,6 +174,14 @@ export function canonicalTrackerIssueId(issueId?: string): string | undefined {
return TRACKER_PROVIDER_PREFIXES.some((prefix) => issueId.startsWith(prefix)) ? issueId : undefined;
}
export type ProjectKind = "single_repo" | "workspace";
export type WorkspaceRepoSummary = {
name: string;
relativePath: string;
repo: string;
};
/** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */
export type WorkerDisplayStatus =
"working" | "needs_you" | "mergeable" | "ci_failed" | "no_signal" | "done" | "unknown";
@ -355,7 +363,9 @@ export function attentionZone(session: WorkspaceSession): AttentionZone {
export type WorkspaceSummary = {
id: string;
name: string;
kind?: ProjectKind;
path: string;
workspaceRepos?: WorkspaceRepoSummary[];
type?: "main" | "worktree";
orchestratorAgent?: AgentProvider;
accentColor?: string;

View File

@ -92,6 +92,11 @@ describe("buildDaemonEnv", () => {
const env = buildDaemonEnv({ ...minimalProcessEnv, TERM: "screen-256color" }, null, {});
expect(env.TERM).toBe("screen-256color");
});
it("replaces TERM=dumb with a tmux-usable default", () => {
const env = buildDaemonEnv({ ...minimalProcessEnv, TERM: "dumb" }, null, {});
expect(env.TERM).toBe("xterm-256color");
});
});
describe("resolveShellPath", () => {

View File

@ -63,8 +63,15 @@ export function withFallbackPath(currentPath: string | undefined): string {
return result.join(":");
}
function normalizeTerm(term: string | undefined): string {
const trimmed = term?.trim();
if (!trimmed || trimmed === "dumb") return "xterm-256color";
return trimmed;
}
// Base = shell env, overlaid by processEnv so Electron/AO runtime vars win, then
// PATH forced to the shell's PATH (with floor), then explicit overrides.
// PATH forced to the shell's PATH (with floor), TERM forced to a tmux-usable
// value, then explicit overrides.
//
// TERM defaults to xterm-256color (what the renderer's xterm.js emulates): a
// Finder/Dock launch starts under launchd with no controlling tty, so TERM is
@ -78,6 +85,7 @@ export function buildDaemonEnv(
): NodeJS.ProcessEnv {
const merged: NodeJS.ProcessEnv = { TERM: "xterm-256color", ...(shellEnv ?? {}), ...processEnv };
merged.PATH = withFallbackPath(shellEnv?.PATH ?? processEnv.PATH);
merged.TERM = normalizeTerm(merged.TERM);
return { ...merged, ...overrides };
}