diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 895f6d3690..14d335186d 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -122,6 +122,7 @@ import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "./s import { createAgentChatService, writeSessionLinearIssueContextFile } from "./services/chat/agentChatService"; import { createGithubService } from "./services/github/githubService"; import { createProjectScaffoldService } from "./services/projects/projectScaffoldService"; +import { consumeFirstOpenStabilityMarker } from "./services/projects/projectLocalDatabase"; import { createFeedbackReporterService } from "./services/feedback/feedbackReporterService"; import { createPrService } from "./services/prs/prService"; import { createPrPollingService } from "./services/prs/prPollingService"; @@ -2570,9 +2571,10 @@ app.whenReady().then(async () => { userSelectedProject?: boolean; }): Promise => { // The .ade directory may exist from git (shared scaffold files like ade.yaml), - // but the db is gitignored and machine-local. A missing db means this machine - // has never completed setup, so onboarding should run. + // but the db is gitignored and machine-local. A missing db, or a scaffold + // first-open marker, means this machine has not finished a real bind yet. const hadAdeDir = fs.existsSync(path.join(projectRoot, ".ade", "ade.db")); + const scaffoldedFirstOpen = consumeFirstOpenStabilityMarker(projectRoot); const adePaths = ensureAdeDirs(projectRoot); const { initApiKeyStore } = await import("./services/ai/apiKeyStore"); initApiKeyStore(projectRoot, { @@ -2591,7 +2593,7 @@ app.whenReady().then(async () => { }); const packagedFirstOpenStabilityMode = app.isPackaged - && !hadAdeDir + && (!hadAdeDir || scaffoldedFirstOpen) && process.env.ADE_DISABLE_FIRST_OPEN_STABILITY !== "1"; const projectStabilityMode = devStabilityMode || packagedFirstOpenStabilityMode; diff --git a/apps/desktop/src/main/services/projects/projectLocalDatabase.test.ts b/apps/desktop/src/main/services/projects/projectLocalDatabase.test.ts new file mode 100644 index 0000000000..72f176e660 --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectLocalDatabase.test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + consumeFirstOpenStabilityMarker, + markFirstOpenStability, +} from "./projectLocalDatabase"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("first-open stability marker", () => { + it("is consumed once after scaffold marks it", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-first-open-")); + tempDirs.push(projectRoot); + + expect(consumeFirstOpenStabilityMarker(projectRoot)).toBe(false); + markFirstOpenStability(projectRoot); + expect( + fs.existsSync(path.join(projectRoot, ".ade", "cache", "first-open-stability")), + ).toBe(true); + expect(consumeFirstOpenStabilityMarker(projectRoot)).toBe(true); + expect(consumeFirstOpenStabilityMarker(projectRoot)).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/services/projects/projectLocalDatabase.ts b/apps/desktop/src/main/services/projects/projectLocalDatabase.ts new file mode 100644 index 0000000000..3a963c5c0a --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectLocalDatabase.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; +import path from "node:path"; +import { openKvDb } from "../state/kvDb"; +import type { Logger } from "../logging/logger"; +import { resolveAdeLayout } from "../../../shared/adeLayout"; + +const FIRST_OPEN_STABILITY_MARKER = "first-open-stability"; + +function firstOpenStabilityMarkerPath(projectRoot: string): string { + return path.join(resolveAdeLayout(projectRoot).cacheDir, FIRST_OPEN_STABILITY_MARKER); +} + +/** + * Remember that this machine has never completed a real project bind, even + * after scaffold warms `ade.db`. Packaged first-open still throttles + * background tasks on the following bind. + */ +export function markFirstOpenStability(projectRoot: string): void { + const { cacheDir } = resolveAdeLayout(projectRoot); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, FIRST_OPEN_STABILITY_MARKER), ""); +} + +/** True when scaffold asked the next bind to stay in first-open stability mode. */ +export function consumeFirstOpenStabilityMarker(projectRoot: string): boolean { + const markerPath = firstOpenStabilityMarkerPath(projectRoot); + if (!fs.existsSync(markerPath)) return false; + try { + fs.unlinkSync(markerPath); + } catch { + // Keep treating this bind as first-open even if unlink fails. + } + return true; +} + +/** + * Open (creating if needed) and immediately close `ade.db` so first project + * bind does not pay schema setup on the Work paint path. + */ +export async function ensureProjectLocalDatabase( + projectRoot: string, + logger: Logger, +): Promise { + const { dbPath } = resolveAdeLayout(projectRoot); + const db = await openKvDb(dbPath, logger); + db.close(); + markFirstOpenStability(projectRoot); +} diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts index d53574eeaa..b706e592a5 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts @@ -77,7 +77,7 @@ describe("createLocalProject", () => { runGitMock.mockReset(); }); - it("creates README + .gitignore and runs init/add/commit on main", async () => { + it("creates README + .gitignore and inits git without an initial commit", async () => { runGitMock.mockResolvedValue(gitOk()); const parentDir = makeTempDir("ade-scaffold-create-"); const service = createProjectScaffoldService({ @@ -100,17 +100,19 @@ describe("createLocalProject", () => { const argsList = runGitMock.mock.calls.map((c) => c[0] as string[]); expect(argsList[0]).toEqual(["init", "--initial-branch=main"]); - expect(argsList).toContainEqual(["add", "."]); - expect(argsList).toContainEqual(["commit", "-m", "Initial commit"]); + expect(argsList).not.toContainEqual(["add", "."]); + expect(argsList.some((args) => args[0] === "commit")).toBe(false); + expect(fs.existsSync(path.join(result.rootPath, ".ade", "ade.db"))).toBe(true); + expect( + fs.existsSync(path.join(result.rootPath, ".ade", "cache", "first-open-stability")), + ).toBe(true); }); it("falls back to plain init + symbolic-ref when --initial-branch is unsupported", async () => { runGitMock .mockResolvedValueOnce(gitFail("error: unknown option `initial-branch'")) .mockResolvedValueOnce(gitOk()) // git init (plain) - .mockResolvedValueOnce(gitOk()) // symbolic-ref - .mockResolvedValueOnce(gitOk()) // add . - .mockResolvedValueOnce(gitOk()); // commit + .mockResolvedValueOnce(gitOk()); // symbolic-ref const parentDir = makeTempDir("ade-scaffold-fallback-init-"); const service = createProjectScaffoldService({ @@ -126,14 +128,9 @@ describe("createLocalProject", () => { expect(argsList[2]).toEqual(["symbolic-ref", "HEAD", "refs/heads/main"]); }); - it("retries the initial commit with the ADE author when git identity is missing", async () => { - runGitMock - .mockResolvedValueOnce(gitOk()) // init --initial-branch=main - .mockResolvedValueOnce(gitOk()) // add . - .mockResolvedValueOnce(gitFail("Please tell me who you are.")) - .mockResolvedValueOnce(gitOk()); // retry commit - - const parentDir = makeTempDir("ade-scaffold-author-fallback-"); + it("never runs git commit during create", async () => { + runGitMock.mockResolvedValue(gitOk()); + const parentDir = makeTempDir("ade-scaffold-no-commit-"); const service = createProjectScaffoldService({ logger: makeLogger(), githubService: makeGithubServiceStub(), @@ -141,36 +138,8 @@ describe("createLocalProject", () => { await service.createLocalProject({ name: "no-config-project", parentDir }); - const calls = runGitMock.mock.calls; - expect(calls).toHaveLength(4); - expect(calls[2]?.[0]).toEqual(["commit", "-m", "Initial commit"]); - expect(calls[3]?.[0]).toEqual([ - "commit", - "-m", - "Initial commit", - "--author=ADE ", - ]); - const retryEnv = (calls[3]?.[1] as { env?: Record }).env ?? {}; - expect(retryEnv.GIT_COMMITTER_NAME).toBe("ADE"); - expect(retryEnv.GIT_COMMITTER_EMAIL).toBe("ade@local"); - }); - - it("does not throw when the author-fallback commit also fails (best-effort)", async () => { - runGitMock - .mockResolvedValueOnce(gitOk()) - .mockResolvedValueOnce(gitOk()) - .mockResolvedValueOnce(gitFail("Please tell me who you are.")) - .mockResolvedValueOnce(gitFail("still no identity")); - - const parentDir = makeTempDir("ade-scaffold-author-retry-fail-"); - const service = createProjectScaffoldService({ - logger: makeLogger(), - githubService: makeGithubServiceStub(), - }); - - await expect( - service.createLocalProject({ name: "uncommittable", parentDir }), - ).resolves.toEqual({ rootPath: path.join(parentDir, "uncommittable") }); + const argsList = runGitMock.mock.calls.map((c) => c[0] as string[]); + expect(argsList.some((args) => args[0] === "commit")).toBe(false); }); it("rejects names with path separators", async () => { @@ -239,11 +208,10 @@ describe("createLocalProject", () => { ).resolves.toEqual({ rootPath: empty }); }); - it("rolls back the created directory when a step after mkdir fails", async () => { - // init ok, then `git add .` fails after README/.gitignore are written. + it("rolls back the created directory when git init fails", async () => { runGitMock - .mockResolvedValueOnce(gitOk()) - .mockResolvedValueOnce(gitFail("fatal: not a git repository", 128)); + .mockResolvedValueOnce(gitFail("fatal: could not create work tree")) + .mockResolvedValueOnce(gitFail("fatal: could not create work tree")); const parentDir = makeTempDir("ade-scaffold-rollback-"); const service = createProjectScaffoldService({ @@ -254,15 +222,14 @@ describe("createLocalProject", () => { const rootPath = path.join(parentDir, "doomed"); await expect( service.createLocalProject({ name: "doomed", parentDir }), - ).rejects.toThrow(); + ).rejects.toThrow(/git init failed/i); - // The directory we created must be gone so a retry isn't blocked by target_exists. expect(fs.existsSync(rootPath)).toBe(false); }); it("does not roll back a pre-existing empty directory on failure", async () => { runGitMock - .mockResolvedValueOnce(gitOk()) + .mockResolvedValueOnce(gitFail("boom", 128)) .mockResolvedValueOnce(gitFail("boom", 128)); const parentDir = makeTempDir("ade-scaffold-rollback-preexist-"); @@ -343,6 +310,10 @@ describe("cloneRepository", () => { "https://github.com/octocat/Hello-World", path.join(parentDir, "Hello-World"), ]); + expect(fs.existsSync(path.join(result.rootPath, ".ade", "ade.db"))).toBe(true); + expect( + fs.existsSync(path.join(result.rootPath, ".ade", "cache", "first-open-stability")), + ).toBe(true); }); it("uses the explicit name override when provided", async () => { diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.ts index 3159447bf0..a2b0bf0a9f 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.ts @@ -16,6 +16,7 @@ import { runGit } from "../git/git"; import type { Logger } from "../logging/logger"; import type { createGithubService } from "../github/githubService"; import { initializeOrRepairAdeProject } from "./adeProjectService"; +import { ensureProjectLocalDatabase } from "./projectLocalDatabase"; type GithubService = ReturnType; @@ -62,15 +63,6 @@ function isDirectoryNonEmpty(dirPath: string): boolean { } } -function isGitIdentityError(stderr: string): boolean { - const text = stderr.toLowerCase(); - return ( - text.includes("please tell me who you are") || - text.includes("user.email") || - text.includes("author identity") - ); -} - function hashToken(token: string): string { return crypto.createHash("sha256").update(token).digest("hex").slice(0, 16); } @@ -84,6 +76,17 @@ export function createProjectScaffoldService({ }) { let cachedRepos: { tokenHash: string; expiresAt: number; repos: MyGitHubRepoSummary[] } | null = null; + const warmLocalDatabase = async (rootPath: string, eventName: string): Promise => { + try { + await ensureProjectLocalDatabase(rootPath, logger); + } catch (dbErr) { + logger.warn(eventName, { + rootPath, + error: dbErr instanceof Error ? dbErr.message : String(dbErr), + }); + } + }; + const createLocalProject = async (input: CreateProjectInput): Promise => { const name = validateProjectName(input.name); const parentDir = (input.parentDir ?? "").trim(); @@ -98,9 +101,8 @@ export function createProjectScaffoldService({ } // Track whether WE created the dir so failures only roll back our own work. - // Without this, a partial scaffold (init + README written, then `git add` - // fails) leaves a non-empty dir that the `target_exists` guard rejects on - // retry, permanently blocking the user. + // Without this, a partial scaffold (git init or README write) leaves a + // non-empty dir that the `target_exists` guard rejects on retry. const preexisted = fs.existsSync(rootPath); fs.mkdirSync(rootPath, { recursive: true }); @@ -126,40 +128,7 @@ export function createProjectScaffoldService({ fs.writeFileSync(path.join(rootPath, "README.md"), `# ${name}\n`, "utf8"); fs.writeFileSync(path.join(rootPath, ".gitignore"), GITIGNORE_CONTENT, "utf8"); initializeOrRepairAdeProject(rootPath, { logger, mode: "shared" }); - - const addRes = await runGit(["add", "."], { cwd: rootPath, timeoutMs: 15_000 }); - if (addRes.exitCode !== 0) { - throw new Error(`git add failed: ${addRes.stderr.trim() || `exit ${addRes.exitCode}`}`); - } - - const commitRes = await runGit(["commit", "-m", "Initial commit"], { cwd: rootPath, timeoutMs: 15_000 }); - if (commitRes.exitCode !== 0) { - if (isGitIdentityError(commitRes.stderr)) { - const retry = await runGit( - ["commit", "-m", "Initial commit", "--author=ADE "], - { - cwd: rootPath, - timeoutMs: 15_000, - env: { - ...process.env, - GIT_COMMITTER_NAME: "ADE", - GIT_COMMITTER_EMAIL: "ade@local", - }, - }, - ); - if (retry.exitCode !== 0) { - logger.warn("project_scaffold.initial_commit_retry_failed", { - rootPath, - stderr: retry.stderr.trim(), - }); - } - } else { - logger.warn("project_scaffold.initial_commit_failed", { - rootPath, - stderr: commitRes.stderr.trim(), - }); - } - } + await warmLocalDatabase(rootPath, "project_scaffold.local_db_warm_failed"); return { rootPath }; } catch (err) { @@ -241,6 +210,9 @@ export function createProjectScaffoldService({ throw new Error(cloneRes.stderr.trim() || `git clone failed (exit ${cloneRes.exitCode})`); } + initializeOrRepairAdeProject(rootPath, { logger, mode: "shared" }); + await warmLocalDatabase(rootPath, "project_scaffold.clone_local_db_warm_failed"); + return { rootPath }; } catch (err) { if (!preexistedRoot) { diff --git a/apps/desktop/src/main/services/state/projectState.ts b/apps/desktop/src/main/services/state/projectState.ts index 8bec1209a7..b799d76c44 100644 --- a/apps/desktop/src/main/services/state/projectState.ts +++ b/apps/desktop/src/main/services/state/projectState.ts @@ -31,7 +31,7 @@ function resolveProjectRoot(): string { return path.resolve(process.cwd(), "..", ".."); } - // Packaged fallback: keep state somewhere writable until onboarding picks a repo. + // Packaged fallback: keep state somewhere writable until a project is opened. return path.resolve(app.getPath("userData"), "ade-project"); } diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index cd94014156..8fa312ba55 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -22,7 +22,6 @@ import { ClipboardDeeplinkBanner } from "./ClipboardDeeplinkBanner"; import { CrossRepoPrBanner } from "./CrossRepoPrBanner"; import { ProjectRecoveryScreen } from "./ProjectRecoveryScreen"; import { ProjectWelcomePage } from "../projects/ProjectWelcomePage"; -import { ProjectSetupPage } from "../onboarding/ProjectSetupPage"; import { OnboardingBootstrap } from "../onboarding/OnboardingBootstrap"; import { LaunchGate } from "../onboarding/LaunchGate"; import { GlossaryPage } from "../onboarding/GlossaryPage"; @@ -324,7 +323,6 @@ function serializeProjectRoute(location: ReturnType): string "/automations", "/cto", "/settings", - "/onboarding", ]; if (!allowedRoots.some((root) => pathname === root || pathname.startsWith(`${root}/`))) { return null; @@ -545,7 +543,7 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string {active && !isWorkRoute && !isLanesRoute ? ( } /> - } /> + } /> } /> @@ -789,10 +787,7 @@ function ProjectTabHost() { } previousActiveSurfaceKeyRef.current = activeSurfaceKey; if (!activeSurfaceKey) return; - const shouldKeepInitialRoute = - currentRoute && - currentRoute !== "/onboarding"; - if (!previousSurfaceKey && shouldKeepInitialRoute) { + if (!previousSurfaceKey && currentRoute) { writeStoredProjectRoute(activeSurfaceKey, currentRoute); setRoutesBySurfaceKey((prev) => (prev[activeSurfaceKey] === currentRoute ? prev : { ...prev, [activeSurfaceKey]: currentRoute })); return; diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index 30678262fe..334ca25bdc 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -170,10 +170,6 @@ vi.mock("../projects/ProjectWelcomePage", () => ({ ProjectWelcomePage: () =>
, })); -vi.mock("../onboarding/ProjectSetupPage", () => ({ - ProjectSetupPage: () =>
, -})); - vi.mock("../onboarding/GlossaryPage", () => ({ GlossaryPage: () =>
, })); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index d548e3490e..a5a4c1cbff 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -32,7 +32,6 @@ import { Button } from "../ui/Button"; import type { AiSettingsStatus, GitHubStatus, - OnboardingStatus, PrEventPayload, ProjectInfo, OpenProjectBinding, @@ -67,7 +66,6 @@ import { applyShellHeaderInset, } from "../../lib/zoom"; import { syncWindowsTitleBarOverlay } from "../../lib/windowControlsOverlay"; -import { ONBOARDING_STATUS_UPDATED_EVENT } from "../../lib/onboardingStatusEvents"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { holdLayoutSettle } from "../../lib/layoutSettle"; import { cn } from "../ui/cn"; @@ -118,7 +116,6 @@ const PRODUCT_ANALYTICS_ROUTE_ROOTS = [ "/cto", "/settings", "/chats", - "/onboarding", ] as const; export function productAnalyticsScreenForPathname(pathname: string): string { @@ -346,9 +343,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [aiStatusLoaded, setAiStatusLoaded] = useState(false); const [githubStatus, setGithubStatus] = useState(null); const [githubConnectionGeneration, setGithubConnectionGeneration] = useState(0); - const [onboardingStatus, setOnboardingStatus] = - useState(null); - const [onboardingStatusLoading, setOnboardingStatusLoading] = useState(false); // Connection/health banner dismissals now live in a durable localStorage store // (see IntegrationBannerHost / bannerDismiss.ts) so they survive restart, rather // than the session-only Zustand maps this used to read. @@ -369,7 +363,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { isRemoteProject, pathname: location.pathname, }; - const isOnboardingRoute = location.pathname === "/onboarding"; const isPersonalChatsRoute = location.pathname === "/chats" || location.pathname.startsWith("/chats/"); const activityDeepLink = isActivityRoute(location.pathname); @@ -813,48 +806,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { }; }, [isRemoteProject, isWorkAdjacentRoute, project?.rootPath, showWelcome]); - useEffect(() => { - let cancelled = false; - if (!project?.rootPath || showWelcome || isRemoteProject) { - setOnboardingStatus(null); - setOnboardingStatusLoading(false); - return () => { - cancelled = true; - }; - } - setOnboardingStatusLoading(true); - void window.ade.onboarding - .getStatus() - .then((status) => { - if (cancelled) return; - setOnboardingStatus(status); - }) - .catch(() => { - if (cancelled) return; - setOnboardingStatus(null); - }) - .finally(() => { - if (cancelled) return; - setOnboardingStatusLoading(false); - }); - return () => { - cancelled = true; - }; - }, [isRemoteProject, project?.rootPath, showWelcome]); - - useEffect(() => { - const handler = (event: Event) => { - if (isRemoteProject) return; - const detail = (event as CustomEvent).detail; - if (!detail) return; - setOnboardingStatus(detail); - setOnboardingStatusLoading(false); - }; - window.addEventListener(ONBOARDING_STATUS_UPDATED_EVENT, handler); - return () => - window.removeEventListener(ONBOARDING_STATUS_UPDATED_EVENT, handler); - }, [isRemoteProject]); - // Track visited tabs — mark after a short delay so stagger animation can play on first visit useEffect(() => { const timer = setTimeout(() => { @@ -1060,30 +1011,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { return dispose; }, []); - useEffect(() => { - if (!project?.rootPath || showWelcome) return; - if (isRemoteProject) return; - if (isOnboardingRoute) return; - if (onboardingStatusLoading) return; - if ( - !onboardingStatus?.freshProject || - onboardingStatus.completedAt || - onboardingStatus.dismissedAt - ) - return; - navigate("/onboarding", { replace: true }); - }, [ - isOnboardingRoute, - navigate, - onboardingStatus?.completedAt, - onboardingStatus?.dismissedAt, - onboardingStatus?.freshProject, - onboardingStatusLoading, - isRemoteProject, - project?.rootPath, - showWelcome, - ]); - useEffect(() => { setAiFailure(null); setAiMockProvider(null); @@ -1197,12 +1124,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { return tintMap[primaryTabPath(location.pathname)] ?? ""; }, [location.pathname]); - const shouldHoldProjectRouteForOnboarding = - Boolean(project?.rootPath) && - !showWelcome && - location.pathname === "/work" && - onboardingStatusLoading; - const hideSidebar = isOnboardingRoute || shouldHoldProjectRouteForOnboarding; const staleCliNoticeAgeHours = staleCliNotice ? getStaleRunningCliSessionAgeHours({ status: "running", @@ -1270,7 +1191,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { - {!hideSidebar && projectMissing && project?.rootPath ? ( + {projectMissing && project?.rootPath ? (
Project directory not found — it may have been moved or deleted. @@ -1324,7 +1245,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
) : null} - {!hideSidebar && !showWelcome && project?.rootPath ? ( + {!showWelcome && project?.rootPath ? ( ) : null} - {!hideSidebar && providerMode === "subscription" && aiFailure ? ( + {providerMode === "subscription" && aiFailure ? (
Last AI job failed:{" "} {aiFailure.jobId ? `job ${shortId(aiFailure.jobId)} · ` : ""} @@ -1371,27 +1292,24 @@ export function AppShell({ children }: { children: React.ReactNode }) {
) : null} - {!hideSidebar && feedbackGenerating ? ( + {feedbackGenerating ? (
Generating feedback report...
) : null}
- {hideSidebar ? null : ( - // Graph page uses `fixed` viewport layers up to z-[96]; keep the tab rail above them. - - )} +
@@ -1399,15 +1317,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { className="relative z-[1] min-h-0 flex-1 w-full" data-tab-revisit={!isFirstVisit || undefined} > - {shouldHoldProjectRouteForOnboarding ? ( -
-
- Opening project setup... -
-
- ) : ( - children - )} + {children}
{staleCliNotice || prToasts.length > 0 || autoLinkToasts.length > 0 || storeToasts.length > 0 ? (
diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index a30263080c..da98253a21 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -1195,4 +1195,80 @@ describe("CommandPalette", () => { expect(await screen.findByText("Product analytics")).toBeTruthy(); }); + + it("creates a project and opens Work without a success interstitial", async () => { + const onOpenChange = vi.fn(); + const switchProjectToPath = vi.fn(async () => {}); + seedStore({ switchProjectToPath }); + const createLocal = vi.fn(async () => ({ rootPath: "/tmp/spark" })); + globalThis.window.ade.project.getDefaultParentDir = vi.fn(async () => "/tmp"); + globalThis.window.ade.project.createLocal = createLocal; + browseDirectories.mockResolvedValue({ + inputPath: "", + resolvedPath: "", + directoryPath: "", + parentPath: null, + exactDirectoryPath: null, + openableProjectRoot: null, + entries: [], + }); + + render( + + + + , + ); + + await screen.findByPlaceholderText("my-new-project"); + fireEvent.change(screen.getByPlaceholderText("my-new-project"), { + target: { value: "spark" }, + }); + fireEvent.click(screen.getByRole("button", { name: /create and open/i })); + + await waitFor(() => { + expect(createLocal).toHaveBeenCalledWith({ name: "spark", parentDir: "/tmp" }); + expect(switchProjectToPath).toHaveBeenCalledWith("/tmp/spark"); + expect(screen.getByTestId("location").textContent).toBe("/work"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + expect(screen.queryByText(/^Created/)).toBeNull(); + }); + + it("keeps the create form open when switching the new project fails", async () => { + const onOpenChange = vi.fn(); + const switchProjectToPath = vi.fn(async () => { + throw new Error("bind failed"); + }); + seedStore({ switchProjectToPath }); + globalThis.window.ade.project.getDefaultParentDir = vi.fn(async () => "/tmp"); + globalThis.window.ade.project.createLocal = vi.fn(async () => ({ + rootPath: "/tmp/spark", + })); + browseDirectories.mockResolvedValue({ + inputPath: "", + resolvedPath: "", + directoryPath: "", + parentPath: null, + exactDirectoryPath: null, + openableProjectRoot: null, + entries: [], + }); + + render( + + + , + ); + + await screen.findByPlaceholderText("my-new-project"); + fireEvent.change(screen.getByPlaceholderText("my-new-project"), { + target: { value: "spark" }, + }); + fireEvent.click(screen.getByRole("button", { name: /create and open/i })); + + expect(await screen.findByText("bind failed")).toBeTruthy(); + expect(screen.getByRole("button", { name: /create and open/i })).toBeTruthy(); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); }); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index 089839194e..ee9f7b0e7b 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -64,6 +64,7 @@ import { import { cn } from "../ui/cn"; import { setPendingSessionAnchor } from "../terminals/pendingSessionAnchors"; import { readStoredPrsRoute } from "../prs/prsRouteState"; +import { writeStoredProjectRoute } from "./projectRouteStorage"; import { AddProjectChooser } from "../projects/AddProjectChooser"; import { CloneProjectForm } from "../projects/CloneProjectForm"; import { CreateProjectForm } from "../projects/CreateProjectForm"; @@ -96,6 +97,7 @@ type ProjectActionOutcome = { rootPath: string; location: ProjectLocation; projectId?: string; + error?: string; }; type Command = { @@ -1663,11 +1665,41 @@ export function CommandPalette({ : "Paste a path, type to filter, or drop a folder anywhere…" : "Search commands, projects, and threads…"; + const openCreatedOrClonedProject = useCallback( + async ( + result: { rootPath: string; displayName: string; projectId?: string }, + location: ProjectLocation, + ) => { + writeStoredProjectRoute( + location.kind === "remote" && result.projectId + ? `remote:${location.targetId}:${result.projectId}` + : `local:${result.rootPath}`, + "/work", + ); + try { + if (location.kind === "remote" && result.projectId) { + await switchRemoteProject(location.targetId, result.projectId); + } else { + await switchProjectToPath(result.rootPath); + } + navigate("/work"); + onOpenChange(false); + } catch (error) { + throw new Error(extractError(error) || "Failed to open the new project"); + } + }, + [navigate, onOpenChange, switchProjectToPath, switchRemoteProject], + ); + const handleProjectActionSuccess = useCallback( - ( + async ( verb: "Created" | "Cloned", result: { rootPath: string; displayName: string; projectId?: string }, ) => { + if (verb === "Created") { + await openCreatedOrClonedProject(result, activeProjectLocation); + return; + } setActionOutcome({ verb, displayName: result.displayName, @@ -1677,7 +1709,7 @@ export function CommandPalette({ }); setMode("project-success"); }, - [activeProjectLocation], + [activeProjectLocation, openCreatedOrClonedProject], ); const handleSuccessOpen = useCallback(async () => { @@ -1686,19 +1718,14 @@ export function CommandPalette({ return; } try { - if (actionOutcome.location.kind === "remote" && actionOutcome.projectId) { - await switchRemoteProject( - actionOutcome.location.targetId, - actionOutcome.projectId, - ); - } else { - await switchProjectToPath(actionOutcome.rootPath); - } + await openCreatedOrClonedProject(actionOutcome, actionOutcome.location); } catch (error) { - console.error("Failed to open new project", error); + setActionOutcome({ + ...actionOutcome, + error: extractError(error) || "Failed to open the new project", + }); } - onOpenChange(false); - }, [actionOutcome, onOpenChange, switchProjectToPath, switchRemoteProject]); + }, [actionOutcome, onOpenChange, openCreatedOrClonedProject]); const handleSuccessStay = useCallback(() => { onOpenChange(false); @@ -2030,6 +2057,7 @@ export function CommandPalette({ verb={actionOutcome.verb} displayName={actionOutcome.displayName} rootPath={actionOutcome.rootPath} + error={actionOutcome.error} onStay={handleSuccessStay} onOpen={() => { void handleSuccessOpen(); diff --git a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx deleted file mode 100644 index 53b9bba39c..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import type { AgentToolCacheState, AgentToolsCacheSnapshot } from "../../../shared/types"; -import { - availableRuntimes, - cursorInstallCommand, - runtimeToolState, - toolFetchFailureText, -} from "./AiRuntimesBand"; - -describe("cursorInstallCommand", () => { - it("uses Cursor's PowerShell installer on Windows", () => { - const command = cursorInstallCommand("win32"); - expect(command).toContain("powershell.exe"); - expect(command).toContain("cursor.com/install?win32=true"); - expect(command).not.toContain("curl"); - expect(command).not.toContain("mkdir -p"); - expect(command).not.toContain("$HOME"); - }); - - it("keeps the documented POSIX one-liner elsewhere", () => { - for (const platform of ["darwin", "linux"] as const) { - const command = cursorInstallCommand(platform); - expect(command).toContain("curl https://cursor.com/install -fsS | bash"); - expect(command).not.toContain("powershell"); - } - }); -}); - -// Onboarding must not offer a runtime that cannot be installed usefully: -// @cursor/sdk has no win32-arm64 build. See shared/providerPlatformSupport.ts. -describe("availableRuntimes", () => { - function setRuntimeTarget(platform: string, arch: string) { - (globalThis as { window?: unknown }).window = { - ade: { app: { runtimeTarget: { platform, arch } } }, - }; - } - - afterEach(() => { - delete (globalThis as { window?: unknown }).window; - }); - - it("drops Cursor on Windows on ARM and keeps every other runtime", () => { - setRuntimeTarget("win32", "arm64"); - const ids = availableRuntimes().map((rt) => rt.id); - expect(ids).not.toContain("cursor"); - expect(ids).toEqual(["claude", "codex", "droid", "opencode"]); - }); - - it("keeps Cursor on Windows x64 and on macOS", () => { - for (const [platform, arch] of [["win32", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const) { - setRuntimeTarget(platform, arch); - const ids = availableRuntimes().map((rt) => rt.id); - expect(ids, `${platform}-${arch}`).toEqual(["claude", "codex", "cursor", "droid", "opencode"]); - } - }); - - // ADE fetches these three into the machine cache; the pinned tool names come - // from the tools manifest and are NOT the runtime ids. - it("maps only the fetched runtimes onto their pinned tool names", () => { - setRuntimeTarget("darwin", "arm64"); - const byId = Object.fromEntries(availableRuntimes().map((rt) => [rt.id, rt.toolName])); - expect(byId).toEqual({ - claude: "claude-code", - codex: "codex", - cursor: undefined, - droid: undefined, - opencode: "opencode", - }); - }); -}); - -describe("runtimeToolState", () => { - const state = (over: Partial = {}): AgentToolCacheState => ({ - tool: "claude-code", - status: "fetching", - percent: 42, - errorKind: null, - ...over, - }); - const snapshot = (...tools: AgentToolCacheState[]): AgentToolsCacheSnapshot => ({ - tools, - fetching: tools.some((tool) => tool.status === "fetching"), - }); - - it("surfaces a fetch in flight for the runtime's own tool", () => { - const found = runtimeToolState({ toolName: "claude-code" }, snapshot(state()), "missing"); - expect(found?.percent).toBe(42); - }); - - it("ignores other tools' states", () => { - expect(runtimeToolState({ toolName: "codex" }, snapshot(state()), "missing")).toBeNull(); - }); - - it("stays quiet for runtimes ADE does not fetch", () => { - expect(runtimeToolState({ toolName: undefined }, snapshot(state()), "missing")).toBeNull(); - }); - - // A CLI already on PATH satisfies the runtime, so a cache failure is noise. - it("never shouts a failure over a runtime that is already ready", () => { - const failed = snapshot(state({ status: "failed", percent: null, errorKind: "network" })); - expect(runtimeToolState({ toolName: "claude-code" }, failed, "ready")).toBeNull(); - expect(runtimeToolState({ toolName: "claude-code" }, failed, "missing")?.errorKind).toBe("network"); - }); - - // "installed"/"missing" are the states the existing readiness scan already - // describes better than the cache can. - it("defers to the normal readiness treatment when nothing is in flight", () => { - for (const status of ["installed", "missing"] as const) { - const quiet = snapshot(state({ status, percent: null })); - expect(runtimeToolState({ toolName: "claude-code" }, quiet, "missing"), status).toBeNull(); - } - }); -}); - -describe("toolFetchFailureText", () => { - it("names the failures a user can act on", () => { - expect(toolFetchFailureText("network")).toContain("connection"); - expect(toolFetchFailureText("disk-space")).toContain("disk space"); - }); - - it("falls back for kinds with no tailored copy", () => { - expect(toolFetchFailureText("extract")).toBe("Download failed"); - expect(toolFetchFailureText(null)).toBe("Download failed"); - }); -}); diff --git a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx deleted file mode 100644 index 9ee80d3cd6..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx +++ /dev/null @@ -1,832 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { - AgentToolCacheState, - AgentToolsCacheSnapshot, - AiClaudeAvailability, - AiConfig, - AiFeatureKey, - AiSettingsStatus, - ToolErrorKind, -} from "../../../shared/types"; -import { EMPTY_AGENT_TOOLS_CACHE_SNAPSHOT } from "../../../shared/types"; -import { ArrowsClockwise, ArrowUpRight, Check, Copy, Key } from "@phosphor-icons/react"; -import { ClaudeLogo, CodexLogo, CursorAgentLogo, OpenCodeLogo } from "../terminals/ToolLogos"; -import { DroidLogo, ProviderLogo } from "../shared/ProviderLogos"; -import { COLORS, SANS_FONT, MONO_FONT } from "../lanes/laneDesignTokens"; -import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; -import { deriveConfiguredModelIds } from "../../lib/modelOptions"; -import { openExternalUrl } from "../../lib/openExternal"; -import { cursorProviderAvailable, rendererPlatformAttribute } from "../../lib/platform"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; -import { docs } from "../../onboarding/docsLinks"; -import { InputPopover } from "./InputPopover"; -import { RescanButton } from "./RescanButton"; -import { Button } from "../ui/Button"; -import { BRAND, CARD_BASE, SECTION_LABEL, brandCard } from "./onboardingTheme"; - -type FeatureKey = AiFeatureKey | "auto_title"; - -type CliName = "claude" | "codex" | "cursor" | "droid"; - -type RuntimeMeta = { - id: CliName | "opencode"; - label: string; - brand: string; - Logo: React.ComponentType<{ size?: number }>; - /** Installation instructions ADE can follow to detect this runtime. */ - docsUrl: string; - /** Shell command to install the CLI (omit for bundled / API-key runtimes). */ - installCommand?: string; - /** Shell command to sign in once the CLI is installed. */ - authCommand?: string; - /** - * Pinned tool ADE fetches into the machine cache for this runtime, if any. - * Cursor and Droid are user-installed, so they have none and keep the plain - * detected/not-detected treatment. - */ - toolName?: string; -}; - -// Factory publishes a PowerShell installer for its native Windows build; the -// POSIX shell pipeline is not runnable there. The command shown here is the one -// the user is expected to paste into their own shell. -// https://docs.factory.ai/cli/getting-started/quickstart -const DROID_INSTALL_COMMAND = rendererPlatformAttribute() === "win32" - ? "irm https://app.factory.ai/cli/windows | iex" - : "curl -fsSL https://app.factory.ai/cli | sh"; - -/** - * Cursor ships a PowerShell installer for native Windows; the `curl … | bash` - * one-liner is documented for macOS, Linux and WSL only. Keep this in step with - * `cursorInstallCommand()` in apps/ade-cli/src/services/agentRegistry.ts. - */ -export function cursorInstallCommand(platform = rendererPlatformAttribute()): string { - if (platform === "win32") { - return `powershell.exe -NoProfile -Command "irm 'https://cursor.com/install?win32=true' | iex"`; - } - return 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash'; -} - -const RUNTIMES: RuntimeMeta[] = [ - { id: "claude", label: "Claude Code", brand: BRAND.claude, Logo: ClaudeLogo, docsUrl: docs.multiAgentSetup, installCommand: "npm install -g @anthropic-ai/claude-code", authCommand: "claude /login", toolName: "claude-code" }, - { id: "codex", label: "Codex", brand: BRAND.codex, Logo: CodexLogo, docsUrl: docs.multiAgentSetup, installCommand: "npm install -g @openai/codex", authCommand: "codex login", toolName: "codex" }, - { id: "cursor", label: "Cursor", brand: BRAND.cursor, Logo: CursorAgentLogo, docsUrl: docs.multiAgentSetup, installCommand: cursorInstallCommand() }, - { id: "droid", label: "Factory Droid", brand: BRAND.droid, Logo: DroidLogo, docsUrl: "https://docs.factory.ai/cli/getting-started/quickstart", installCommand: DROID_INSTALL_COMMAND, authCommand: "droid login" }, - { id: "opencode", label: "OpenCode", brand: BRAND.opencode, Logo: OpenCodeLogo, docsUrl: docs.multiAgentSetup, toolName: "opencode" }, -]; - -/** - * Runtimes offered on this machine. Cursor drops out on Windows on ARM because - * `@cursor/sdk` has no win32-arm64 build, so onboarding must not ask the user to - * install something that cannot run. See shared/providerPlatformSupport.ts. - */ -export function availableRuntimes(): RuntimeMeta[] { - if (cursorProviderAvailable()) return RUNTIMES; - return RUNTIMES.filter((rt) => rt.id !== "cursor"); -} - -/** - * The fetch state ADE is in for this runtime's pinned tool, or null when the - * runtime is not fetched, is already ready, or the cache has nothing to say. - * A stale `failed` must never shout over a runtime that resolved anyway (a - * user-installed CLI on PATH satisfies the runtime without the cache). - */ -export function runtimeToolState( - meta: Pick, - snapshot: AgentToolsCacheSnapshot | null, - phase: RuntimePhase, -): AgentToolCacheState | null { - if (!meta.toolName || !snapshot || phase === "ready") return null; - const state = snapshot.tools.find((tool) => tool.tool === meta.toolName) ?? null; - if (!state || state.status === "installed" || state.status === "missing") return null; - return state; -} - -/** - * Branch on `kind`, never on message text — see ade-cli/src/services/tools/errors.ts. - * Partial on purpose: the kinds with no entry are ones a user cannot act on - * differently, and they take the generic fallback below. - */ -const TOOL_ERROR_TEXT: Partial> = { - network: "Download failed — check your connection", - "disk-space": "Not enough disk space to unpack", - integrity: "Download failed its checksum check", - "lock-timeout": "Another ADE is already downloading this", - "unsupported-target": "No pinned build for this platform", -}; - -export function toolFetchFailureText(errorKind: ToolErrorKind | null): string { - return (errorKind ? TOOL_ERROR_TEXT[errorKind] : null) ?? "Download failed"; -} - -/** Same percent rounding the update pill uses, so the two never disagree. */ -function percentLabel(percent: number | null): string | null { - if (percent == null || !Number.isFinite(percent)) return null; - return `${Math.max(0, Math.min(100, Math.round(percent)))}%`; -} - -/** Initial read plus live pushes from the main-process tools cache. */ -function useAgentToolsCache(): AgentToolsCacheSnapshot { - const [snapshot, setSnapshot] = useState(EMPTY_AGENT_TOOLS_CACHE_SNAPSHOT); - - useEffect(() => { - let cancelled = false; - - void window.ade.ai.getToolsCache() - .then((next) => { - if (!cancelled) setSnapshot(next); - }) - .catch(() => { - // Best effort only; live events will fill in. - }); - - const unsubscribe = window.ade.ai.onToolsCacheEvent((next) => { - if (!cancelled) setSnapshot(next); - }); - - return () => { - cancelled = true; - unsubscribe(); - }; - }, []); - - return snapshot; -} - -const FEATURES: Array<{ key: FeatureKey; label: string }> = [ - { key: "terminal_summaries", label: "Terminal summaries" }, - { key: "pr_descriptions", label: "PR descriptions" }, - { key: "commit_messages", label: "Commit messages" }, - { key: "auto_title", label: "Auto-name chats" }, -]; - -export function AiRuntimesBand() { - const [status, setStatus] = useState(null); - const [aiConfig, setAiConfig] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - const refresh = useCallback(async (force = false) => { - setLoading(true); - try { - const [nextStatus, snapshot] = await Promise.all([ - window.ade.ai.getStatus({ force, refreshOpenCodeInventory: force }), - window.ade.projectConfig.get(), - ]); - setStatus(nextStatus); - const eff = snapshot.effective?.ai; - setAiConfig(eff && typeof eff === "object" ? (eff as AiConfig) : null); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { void refresh(); }, [refresh]); - - const toolsCache = useAgentToolsCache(); - const wasFetching = useRef(false); - // A finished fetch changes what is on disk, so the readiness scan behind these - // cards is stale the moment the last tool lands. Re-scan instead of leaving - // the user staring at "Not detected" until they hit Rescan themselves. - useEffect(() => { - if (wasFetching.current && !toolsCache.fetching) void refresh(true); - wasFetching.current = toolsCache.fetching; - }, [toolsCache.fetching, refresh]); - - const retryToolFetch = useCallback(() => { - // Coalesced in the main process; progress lands through the push events. - void window.ade.ai.ensureToolsCache().catch(() => { - // Failures are already on the snapshot. - }); - }, []); - - const runtimes = useMemo(() => availableRuntimes(), []); - - const readyCount = useMemo(() => { - if (!status) return 0; - let n = 0; - if (status.availableProviders.claude.binary.present && status.availableProviders.claude.auth.ready) n++; - if (status.providerConnections?.codex?.runtimeAvailable) n++; - if (cursorProviderAvailable() && status.providerConnections?.cursor?.runtimeAvailable) n++; - if (status.providerConnections?.droid?.runtimeAvailable) n++; - if (status.opencodeBinaryInstalled !== false) n++; - return n; - }, [status]); - - const enabledFeatureMap = useMemo(() => { - const map: Record = {}; - if (status?.features) { - for (const f of status.features) map[f.feature] = f.enabled; - } - return map; - }, [status]); - - const titlesEnabled = aiConfig?.sessionIntelligence?.titles?.enabled ?? false; - const hasAnyModel = (status?.availableModelIds?.length ?? 0) > 0; - const availableModelIds = useMemo(() => deriveConfiguredModelIds(status), [status]); - - const selectedModelFor = (key: FeatureKey): string => { - if (key === "auto_title") return aiConfig?.sessionIntelligence?.titles?.modelId ?? ""; - if (key === "terminal_summaries") { - return aiConfig?.sessionIntelligence?.summaries?.modelId - ?? aiConfig?.featureModelOverrides?.terminal_summaries - ?? ""; - } - return aiConfig?.featureModelOverrides?.[key] ?? ""; - }; - - const setModelFor = async (key: FeatureKey, modelId: string) => { - if (saving) return; - setSaving(true); - try { - if (key === "auto_title") { - await window.ade.ai.updateConfig({ - sessionIntelligence: { titles: { modelId: modelId || null } } as AiConfig["sessionIntelligence"], - }); - } else { - const overrides: Record = {}; - for (const [k, v] of Object.entries(aiConfig?.featureModelOverrides ?? {})) { - if (typeof v === "string" && v) overrides[k] = v; - } - if (modelId) overrides[key] = modelId; - else overrides[key] = null; - await window.ade.ai.updateConfig({ - featureModelOverrides: overrides as AiConfig["featureModelOverrides"], - ...(key === "terminal_summaries" - ? { sessionIntelligence: { summaries: { modelId: modelId || null } } as AiConfig["sessionIntelligence"] } - : {}), - }); - } - await refresh(); - } finally { - setSaving(false); - } - }; - - const toggleFeature = async (key: FeatureKey, next: boolean) => { - if (saving) return; - setSaving(true); - try { - if (key === "auto_title") { - await window.ade.ai.updateConfig({ - sessionIntelligence: { - titles: { enabled: next }, - } as AiConfig["sessionIntelligence"], - }); - } else { - const features: Record = { ...enabledFeatureMap, [key]: next }; - await window.ade.ai.updateConfig({ - features: features as AiConfig["features"], - ...(key === "terminal_summaries" - ? { sessionIntelligence: { summaries: { enabled: next } } as AiConfig["sessionIntelligence"] } - : {}), - }); - } - await refresh(); - } finally { - setSaving(false); - } - }; - - const saveCursorKey = async (key: string) => { - try { - await window.ade.ai.storeApiKey("cursor", key); - const verified = await window.ade.ai.verifyApiKey("cursor"); - await refresh(true); - return { ok: verified.ok, message: verified.ok ? "Cursor connected" : verified.message }; - } catch (e) { - return { ok: false, message: e instanceof Error ? e.message : String(e) }; - } - }; - - return ( -
-
-
- AI runtimes - - {loading ? "Checking…" : `${readyCount} of ${runtimes.length} ready`} - -
- void refresh(true)} /> -
- -
- {runtimes.map((rt) => ( - - ))} -
- -
-
Background helpers
-
- {FEATURES.map((f) => { - const checked = f.key === "auto_title" ? titlesEnabled : Boolean(enabledFeatureMap[f.key]); - const locked = !hasAnyModel && !checked; - return ( -
- - {checked ? ( -
- Model - void setModelFor(f.key, modelId)} - availableModelIds={availableModelIds} - surfaceKey={`onboarding-helper-${f.key}`} - disabled={saving} - /> -
- ) : null} -
- ); - })} -
-
- {hasAnyModel - ? "Each helper can use its own model · API keys in Settings · AI Connections." - : "Connect a runtime to enable helpers · API keys in Settings · AI Connections."} -
-
-
- ); -} - -function RuntimeCard({ - meta, status, toolsCache, onRetryToolFetch, onSaveCursorKey, -}: { - meta: RuntimeMeta; - status: AiSettingsStatus | null; - toolsCache: AgentToolsCacheSnapshot; - onRetryToolFetch: () => void; - onSaveCursorKey: (key: string) => Promise<{ ok: boolean; message?: string }>; -}) { - const { tone, detail, cta } = resolveCardPresentation({ - meta, status, toolsCache, onRetryToolFetch, onSaveCursorKey, - }); - const { Logo } = meta; - return ( -
-
- - - {meta.label} - - - {tone.label} - -
-
- {detail} -
- {cta ?
{cta}
: null} -
- ); -} - -function Toggle({ checked }: { checked: boolean }) { - return ( - - - - ); -} - -type RuntimePhase = "ready" | "auth" | "missing" | "checking"; - -function getPhase(meta: RuntimeMeta, status: AiSettingsStatus | null): RuntimePhase { - if (!status) return "checking"; - if (meta.id === "claude") { - const a: AiClaudeAvailability = status.availableProviders.claude; - if (a.binary.present && a.auth.ready) return "ready"; - if (a.binary.present) return "auth"; - return "missing"; - } - if (meta.id === "opencode") { - return status.opencodeBinaryInstalled === false ? "missing" : "ready"; - } - const conn = status.providerConnections?.[meta.id as Exclude]; - if (conn?.runtimeAvailable) return "ready"; - if (conn?.runtimeDetected || conn?.authAvailable) return "auth"; - return "missing"; -} - -export type CardPresentation = { - tone: { color: string; label: string }; - detail: React.ReactNode; - cta: React.ReactNode; -}; - -/** - * The one dispatch behind a runtime card: every card slot comes out of a single - * pass over (readiness phase, cache fetch state) rather than each slot deciding - * for itself and hoping the three agree. - * - * A live fetch state wins outright, and `runtimeToolState` only ever returns one - * for a runtime that is NOT already ready, so a cache fetch can never overwrite - * a "Ready" card. Exactly three outcomes exist: - * - * fetching — nothing for the user to do yet, so no CTA at all. - * failed — the retry, with the manual install path kept underneath it so a - * permanently broken download is never a dead end. - * neither — the plain readiness treatment. - */ -export function resolveCardPresentation(args: { - meta: RuntimeMeta; - status: AiSettingsStatus | null; - toolsCache: AgentToolsCacheSnapshot; - onRetryToolFetch: () => void; - onSaveCursorKey: (key: string) => Promise<{ ok: boolean; message?: string }>; -}): CardPresentation { - const { meta, status, toolsCache } = args; - const phase = getPhase(meta, status); - const toolState = runtimeToolState(meta, toolsCache, phase); - - if (toolState?.status === "fetching") { - return { - tone: { color: COLORS.accent, label: "Fetching" }, - detail: , - cta: null, - }; - } - - const readinessCta = getCta(meta, status, phase, args.onSaveCursorKey); - if (toolState?.status === "failed") { - return { - tone: { color: COLORS.danger, label: "Fetch failed" }, - detail: toolFetchFailureText(toolState.errorKind), - cta: ( -
- - {readinessCta} -
- ), - }; - } - - return { - tone: getTone(phase), - detail: getDetailText(meta, status, phase), - cta: readinessCta, - }; -} - -/** - * Same treatment the update pill uses while downloading: a spinning glyph, the - * verb, and a dimmer percent that simply disappears when the size is unknown. - */ -function FetchProgress({ percent }: { percent: number | null }) { - const label = percentLabel(percent); - return ( - - - Fetching… - {label ? {label} : null} - - ); -} - -function RetryFetchButton({ onClick }: { onClick: () => void }) { - return ( -
- -
- ); -} - -function getTone(phase: RuntimePhase): { color: string; label: string } { - switch (phase) { - case "ready": return { color: COLORS.success, label: "Ready" }; - case "auth": return { color: COLORS.warning, label: "Sign in needed" }; - case "missing": return { color: COLORS.danger, label: "Not detected" }; - default: return { color: COLORS.textDim, label: "Checking" }; - } -} - -function getDetailText(meta: RuntimeMeta, status: AiSettingsStatus | null, phase: RuntimePhase): string { - if (phase === "checking") return "Checking…"; - if (meta.id === "claude") { - if (phase === "ready") return "Signed in"; - if (phase === "auth") return status?.availableProviders.claude.auth.detail || "Installed · sign in to continue"; - return "CLI not found on PATH"; - } - if (meta.id === "opencode") { - return phase === "missing" ? "CLI not found on PATH" : "Bundled with ADE"; - } - if (phase === "ready") return "Connected and ready"; - if (phase === "auth") return "Installed · sign in to continue"; - return "CLI not found on PATH"; -} - -function getCta( - meta: RuntimeMeta, - status: AiSettingsStatus | null, - phase: RuntimePhase, - onSaveCursorKey: (key: string) => Promise<{ ok: boolean; message?: string }>, -): React.ReactNode { - if (phase === "checking") return null; - if (meta.id === "opencode") return ; - if (phase === "ready") return null; - // Cursor authenticates with Sign in or an API key as equal peers: install - // first (missing), then either path once the SDK is detected (auth). - if (meta.id === "cursor") { - if (phase === "missing") return ; - return ; - } - if (phase === "auth" && meta.authCommand) { - return ; - } - return ; -} - -function CursorKeyPopover({ onSave }: { onSave: (key: string) => Promise<{ ok: boolean; message?: string }> }) { - const [loginBusy, setLoginBusy] = useState(false); - const [loginError, setLoginError] = useState(null); - const [loginUrl, setLoginUrl] = useState(null); - const { copy, copied } = useCopyToClipboard({ timeout: 1200 }); - - useEffect(() => { - const subscribe = window.ade?.ai?.onCursorAuthStatus; - if (typeof subscribe !== "function") return undefined; - return subscribe((event) => { - if (event.url) setLoginUrl(event.url); - if (event.state === "pending") setLoginBusy(true); - if (event.state === "success" || event.state === "error" || event.state === "cancelled" || event.state === "logged-out") { - setLoginBusy(false); - if (event.state === "success" || event.state === "cancelled" || event.state === "logged-out") { - setLoginUrl(null); - } - if (event.state === "error" && event.error) setLoginError(event.error); - } - }); - }, []); - - const signIn = async () => { - const login = window.ade?.ai?.cursorAuthLogin; - if (typeof login !== "function") { - setLoginError("Cursor sign-in is unavailable in this session."); - return; - } - setLoginBusy(true); - setLoginError(null); - try { - const result = await login(); - if (!result.ok) setLoginError(result.error); - } catch (error) { - setLoginError(error instanceof Error ? error.message : String(error)); - } finally { - setLoginBusy(false); - } - }; - - return ( -
-
- - Get a key at cursor.com/dashboard/api} - placeholder="cur_..." - onSave={onSave} - align="left" - /> -
- {loginUrl ? ( - - ) : null} - {loginError ? ( -
{loginError}
- ) : null} -
- ); -} - -function InstallBlock({ docsUrl, command }: { docsUrl: string; command?: string }) { - return ( -
- - {command ? : null} -
- ); -} - -function AuthBlock({ command, docsUrl }: { command: string; docsUrl: string }) { - return ( -
- - -
- ); -} - -function DocsLink({ url, label }: { url: string; label: string }) { - return ( - - ); -} - -function CommandLine({ text }: { text: string }) { - const { copy, copied } = useCopyToClipboard({ timeout: 1200 }); - return ( -
- {text} - -
- ); -} - -function OpenCodeProviders() { - return ( -
-
- - - -
- - Local models & API key providers - -
- ); -} - -const docsLinkStyle: React.CSSProperties = { - display: "inline-flex", - alignItems: "center", - gap: 4, - alignSelf: "flex-start", - padding: 0, - background: "transparent", - border: "none", - cursor: "pointer", - fontSize: 11, - fontFamily: SANS_FONT, - fontWeight: 600, - color: COLORS.accent, -}; - -const cmdRowStyle: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 6, - padding: "5px 7px", - borderRadius: 7, - background: "rgba(255,255,255,0.04)", - border: `1px solid ${COLORS.border}`, - minWidth: 0, -}; - -const cmdTextStyle: React.CSSProperties = { - flex: 1, - minWidth: 0, - fontFamily: MONO_FONT, - fontSize: 10, - lineHeight: 1.4, - color: COLORS.textSecondary, - whiteSpace: "normal", - overflowWrap: "anywhere", - wordBreak: "break-word", -}; - -const cmdCopyStyle: React.CSSProperties = { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - width: 20, - height: 20, - padding: 0, - borderRadius: 5, - background: "transparent", - border: "none", - cursor: "pointer", - color: COLORS.textDim, -}; - -const sectionStyle: React.CSSProperties = { - ...CARD_BASE, - padding: 22, - flex: 1, - width: "100%", - display: "flex", - flexDirection: "column", -}; - -const sectionHeader: React.CSSProperties = { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - marginBottom: 16, -}; - -function helperCardStyle(checked: boolean, dimmed: boolean): React.CSSProperties { - return { - display: "flex", - flexDirection: "row", - alignItems: "center", - gap: 10, - padding: "9px 12px", - background: checked ? "color-mix(in srgb, var(--color-accent) 10%, transparent)" : "rgba(255,255,255,0.02)", - border: `1px solid ${checked ? "color-mix(in srgb, var(--color-accent) 28%, transparent)" : COLORS.border}`, - borderRadius: 10, - opacity: dimmed ? 0.5 : 1, - minWidth: 0, - }; -} - -function helperToggleStyle(dimmed: boolean): React.CSSProperties { - return { - display: "inline-flex", - alignItems: "center", - gap: 10, - padding: 0, - background: "transparent", - border: "none", - cursor: dimmed ? "not-allowed" : "pointer", - minWidth: 0, - textAlign: "left", - }; -} - -const codeStyle: React.CSSProperties = { - fontFamily: "var(--font-mono)", - fontSize: 10, - padding: "1px 4px", - borderRadius: 3, - background: "rgba(255,255,255,0.08)", - color: COLORS.textPrimary, -}; diff --git a/apps/desktop/src/renderer/components/onboarding/DevToolsRow.tsx b/apps/desktop/src/renderer/components/onboarding/DevToolsRow.tsx deleted file mode 100644 index 54736fc410..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/DevToolsRow.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { GitBranch, TerminalWindow } from "@phosphor-icons/react"; -import type { AdeCliStatus, DevToolsCheckResult } from "../../../shared/types"; -import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; -import { Button } from "../ui/Button"; -import { RescanButton } from "./RescanButton"; -import { CARD_BASE, SECTION_LABEL, statusDot } from "./onboardingTheme"; - -type Props = { - onGitStatusChange: (installed: boolean) => void; -}; - -export function DevToolsRow({ onGitStatusChange }: Props) { - const [devTools, setDevTools] = useState(null); - const [adeCli, setAdeCli] = useState(null); - const [loading, setLoading] = useState(true); - const [installing, setInstalling] = useState(false); - const [scanError, setScanError] = useState(null); - const [installError, setInstallError] = useState(null); - - const refresh = useCallback(async (force = false) => { - setLoading(true); - setScanError(null); - try { - const tools = await window.ade.devTools.detect(force); - setDevTools(tools); - const git = tools.tools.find((t) => t.id === "git"); - onGitStatusChange(git?.installed ?? false); - } catch (e) { - setDevTools(null); - onGitStatusChange(false); - setScanError(e instanceof Error ? e.message : String(e)); - } - try { - const cli = await (window.ade.adeCli?.getStatus?.() ?? Promise.resolve(null)); - setAdeCli(cli); - } catch { - setAdeCli(null); - } finally { - setLoading(false); - } - }, [onGitStatusChange]); - - useEffect(() => { void refresh(); }, [refresh]); - - const installAde = async () => { - if (!window.ade.adeCli?.installForUser) return; - setInstalling(true); - setInstallError(null); - try { - const result = await window.ade.adeCli.installForUser(); - setAdeCli(result.status); - if (!result.ok) setInstallError(result.message); - } catch (e) { - setInstallError(e instanceof Error ? e.message : String(e)); - } finally { - setInstalling(false); - } - }; - - const git = devTools?.tools.find((t) => t.id === "git") ?? null; - const gitInstalled = git?.installed ?? false; - const adeTerminalReady = adeCli?.terminalInstalled === true; - const adeBundled = adeCli?.bundledAvailable === true; - const canAddToPath = !adeTerminalReady && adeCli?.installAvailable === true; - - return ( -
-
- Essentials - void refresh(true)} /> -
-
- } - tone={gitInstalled ? COLORS.success : COLORS.danger} - required={!gitInstalled} - title="Git" - detail={ - gitInstalled - ? `Installed · ${git?.detectedVersion ?? "ready"}` - : installHint(devTools?.platform ?? "darwin") - } - /> - } - tone={adeTerminalReady ? COLORS.success : adeBundled ? COLORS.warning : COLORS.danger} - title="ADE CLI" - detail={ - adeTerminalReady - ? "Installed · on your terminal PATH" - : adeBundled - ? "Installed · not on your terminal PATH" - : "Not detected" - } - action={ - canAddToPath ? ( - - ) : null - } - /> -
- {installError ? ( -
- {installError} -
- ) : null} - {scanError ? ( -
- {scanError} -
- ) : null} -
- ); -} - -function ToolRow({ - icon, tone, title, detail, action, required = false, -}: { - icon: React.ReactNode; - tone: string; - title: string; - detail: React.ReactNode; - action?: React.ReactNode; - required?: boolean; -}) { - return ( -
- - - {icon} - -
- - {title} - - {required ? ( - - Required - - ) : null} - - {detail} - -
- {action ?
{action}
: null} -
- ); -} - -function installHint(platform: NodeJS.Platform): string { - if (platform === "darwin") return "brew install git"; - if (platform === "win32") return "git-scm.com"; - return "sudo apt install git"; -} - -const sectionHeader: React.CSSProperties = { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - marginBottom: 14, -}; diff --git a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx deleted file mode 100644 index 9684b51cc8..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React, { useEffect, useState, useCallback } from "react"; -import { ArrowsClockwise, GitBranch } from "@phosphor-icons/react"; -import type { DevToolsCheckResult, DevToolStatus } from "../../../shared/types"; -import { COLORS, SANS_FONT, MONO_FONT, inlineBadge } from "../lanes/laneDesignTokens"; -import { Button } from "../ui/Button"; -import { AdeCliSection } from "../settings/AdeCliSection"; - -type Props = { - onStatusChange: (gitInstalled: boolean) => void; -}; - -export function DevToolsSection({ onStatusChange }: Props) { - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(true); - - const detect = useCallback(async (force?: boolean) => { - setLoading(true); - try { - const r = await window.ade.devTools.detect(force); - setResult(r); - const git = r.tools.find((t) => t.id === "git"); - onStatusChange(git?.installed ?? false); - } catch { - // leave previous result in place - } finally { - setLoading(false); - } - }, [onStatusChange]); - - useEffect(() => { void detect(); }, [detect]); - - const git = result?.tools.find((t) => t.id === "git") ?? null; - const platform = result?.platform ?? "darwin"; - - return ( -
- {/* Info header */} -
-
- ADE relies on these developer tools -
-
-
- - git — version control, branching, and lane isolation -
-
- $ - ade — bundled command for agent sessions, optional Terminal install -
-
-
- - - - -
- -
-
- ); -} - -function ToolCard({ tool, platform, loading }: { tool: DevToolStatus | null; platform: NodeJS.Platform; loading: boolean }) { - const accentColor = COLORS.success; - const Icon = GitBranch; - - if (loading || !tool) { - return ( -
-
Detecting...
-
- ); - } - - const installed = tool.installed; - const statusColor = installed ? COLORS.success : tool.required ? COLORS.danger : COLORS.warning; - const statusLabel = installed ? "Installed" : "Not found"; - const requirementLabel = tool.required ? "Required to continue setup." : "Optional"; - - return ( -
-
-
-
- -
-
-
- {tool.label} -
-
- {requirementLabel} -
-
-
- {statusLabel} -
- - {installed ? ( -
- {tool.detectedVersion &&
{tool.detectedVersion}
} - {tool.detectedPath && ( -
{tool.detectedPath}
- )} -
- ) : ( -
-
- {gitInstallHelp(platform)} -
-
- After installing, click Scan again. Restart ADE only if the tool still does not appear. -
-
- )} -
- ); -} - -function gitInstallHelp(platform: NodeJS.Platform): React.ReactNode { - if (platform === "darwin") { - return ( - <> - Install with xcode-select --install or{" "} - brew install git - - ); - } - if (platform === "win32") { - return <>Download from git-scm.com and run the installer.; - } - return ( - <> - Install with sudo apt install git or{" "} - sudo dnf install git - - ); -} - -function cardStyle(accentColor: string): React.CSSProperties { - return { - padding: 18, - background: COLORS.cardBg, - border: `1px solid ${COLORS.border}`, - borderRadius: 14, - borderLeft: `3px solid ${accentColor}`, - }; -} - -function codeStyle(): React.CSSProperties { - return { - fontFamily: MONO_FONT, - fontSize: 11, - padding: "2px 6px", - borderRadius: 4, - background: "rgba(255,255,255,0.08)", - color: COLORS.textPrimary, - }; -} diff --git a/apps/desktop/src/renderer/components/onboarding/GitHubCard.tsx b/apps/desktop/src/renderer/components/onboarding/GitHubCard.tsx deleted file mode 100644 index 2dba1ceff8..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/GitHubCard.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { GithubLogo } from "@phosphor-icons/react"; -import type { GitHubStatus } from "../../../shared/types"; -import { COLORS, SANS_FONT, MONO_FONT } from "../lanes/laneDesignTokens"; -import { Button } from "../ui/Button"; -import { GitHubAppInstallPanel } from "../github/GitHubAppInstallPanel"; -import { InputPopover } from "./InputPopover"; -import { RescanButton } from "./RescanButton"; -import { BRAND, CARD_BASE, SECTION_LABEL, logoTile, statusDot } from "./onboardingTheme"; -import { describeGithubPatVerification } from "../../lib/githubIntegrationStatus"; - -export function GitHubCard() { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - - const refresh = useCallback(async (force = false) => { - setLoading(true); - try { - const next = await window.ade.github.getStatus(force ? { forceRefresh: true } : undefined); - setStatus(next as GitHubStatus); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { void refresh(); }, [refresh]); - - const savePat = async (token: string) => { - try { - const next = await window.ade.github.setToken(token); - setStatus(next as GitHubStatus); - const verification = describeGithubPatVerification(next); - return { ok: verification.verified, message: verification.message }; - } catch (e) { - return { ok: false, message: e instanceof Error ? e.message : String(e) }; - } - }; - - const disconnect = async () => { - if (!window.confirm("Disconnect GitHub token? ADE will fall back to gh auth or environment credentials when available.")) return; - try { - const next = await window.ade.github.clearToken(); - setStatus(next as GitHubStatus); - } catch { /* ignore */ } - }; - - const connected = status?.connected === true; - const tone = connected ? COLORS.success : COLORS.warning; - const patStored = status?.patTokenStored === true; - const canDisconnect = status?.authSource === "pat"; - - return ( -
-
- - - -
-
- GitHub - -
-
- {loading ? "Checking…" : connected ? `Signed in as ${status?.userLogin ?? "you"}` : "Not signed in"} -
-
- void refresh(true)} /> -
- - {connected ? ( -
- {status?.repo ? ( - - ) : status?.hasOrigin ? ( - - ) : ( - - )} - - {status?.repo && status.repoAccessOk === false ? ( - - ) : null} -
- ) : ( -
- Run gh auth login then Rescan, or paste a personal access token. -
- )} - - - -
- {canDisconnect ? ( - - ) : null} - - Classic token at github.com/settings/tokens (needs repo, workflow). Fine-grained also supported. - - } - placeholder="ghp_..." - saveLabel="Save token" - onSave={savePat} - /> -
-
- ); -} - -function MetaRow({ - label, value, mono = false, dim = false, tone, -}: { - label: string; - value: string; - mono?: boolean; - dim?: boolean; - tone?: string; -}) { - return ( -
- {label} - - {value} - -
- ); -} - -function authLabel(status: GitHubStatus | null): string { - if (!status) return "—"; - if (status.authSource === "app") return "ADE GitHub App"; - if (status.authSource === "gh") return "GitHub CLI"; - if (status.authSource === "environment") return "Environment variable"; - if (status.authSource === "pat") { - if (status.tokenType === "fine-grained") return "Personal token (fine-grained)"; - if (status.tokenType === "classic") return "Personal token (classic)"; - return "Personal token"; - } - return "Not signed in"; -} - -const cardStyle: React.CSSProperties = { - ...CARD_BASE, - height: "100%", - boxSizing: "border-box", - display: "flex", - flexDirection: "column", -}; - -const subtitleStyle: React.CSSProperties = { - fontSize: 11.5, - fontFamily: SANS_FONT, - color: COLORS.textMuted, - marginTop: 2, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}; - -const headerRow: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 12, -}; - -const metaList: React.CSSProperties = { - marginTop: 14, - display: "flex", - flexDirection: "column", - gap: 8, -}; - -const actionRow: React.CSSProperties = { - marginTop: 14, - display: "flex", - gap: 8, - justifyContent: "flex-end", -}; - -const codeStyle: React.CSSProperties = { - fontFamily: MONO_FONT, - fontSize: 11, - padding: "1px 5px", - borderRadius: 4, - background: "rgba(255,255,255,0.08)", - color: COLORS.textPrimary, -}; diff --git a/apps/desktop/src/renderer/components/onboarding/InputPopover.tsx b/apps/desktop/src/renderer/components/onboarding/InputPopover.tsx deleted file mode 100644 index 401c531b02..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/InputPopover.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback } from "react"; -import { COLORS, SANS_FONT, MONO_FONT } from "../lanes/laneDesignTokens"; -import { Button } from "../ui/Button"; - -type Props = { - triggerLabel: string; - triggerVariant?: "primary" | "outline" | "ghost"; - title: string; - helpText?: React.ReactNode; - placeholder: string; - saveLabel?: string; - inputType?: "text" | "password"; - onSave: (value: string) => Promise<{ ok: boolean; message?: string }>; - disabled?: boolean; - align?: "left" | "right"; -}; - -export function InputPopover({ - triggerLabel, - triggerVariant = "outline", - title, - helpText, - placeholder, - saveLabel = "Save", - inputType = "password", - onSave, - disabled = false, - align = "right", -}: Props) { - const [open, setOpen] = useState(false); - const [value, setValue] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - const anchorRef = useRef(null); - const popoverRef = useRef(null); - - const close = useCallback(() => { - setOpen(false); - setError(null); - setSuccess(null); - }, []); - - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - const target = e.target as Node; - if (popoverRef.current?.contains(target)) return; - if (anchorRef.current?.contains(target)) return; - close(); - }; - const onEsc = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; - document.addEventListener("mousedown", handler); - document.addEventListener("keydown", onEsc); - return () => { - document.removeEventListener("mousedown", handler); - document.removeEventListener("keydown", onEsc); - }; - }, [open, close]); - - const submit = useCallback(async () => { - const trimmed = value.trim(); - if (!trimmed) return; - setBusy(true); - setError(null); - setSuccess(null); - try { - const result = await onSave(trimmed); - if (result.ok) { - setSuccess(result.message ?? "Saved"); - setValue(""); - window.setTimeout(close, 800); - } else { - setError(result.message ?? "Save failed"); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); - } - }, [value, onSave, close]); - - return ( -
- - {open ? ( -
-
- {title} -
- {helpText ? ( -
- {helpText} -
- ) : null} - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !busy && value.trim()) void submit(); - }} - style={{ - marginTop: 10, - width: "100%", - padding: "8px 10px", - fontSize: 12, - fontFamily: MONO_FONT, - background: "rgba(0,0,0,0.35)", - border: `1px solid ${COLORS.border}`, - borderRadius: 8, - color: COLORS.textPrimary, - outline: "none", - boxSizing: "border-box", - }} - /> - {error ? ( -
{error}
- ) : null} - {success ? ( -
{success}
- ) : null} -
- - -
-
- ) : null} -
- ); -} diff --git a/apps/desktop/src/renderer/components/onboarding/LinearCard.tsx b/apps/desktop/src/renderer/components/onboarding/LinearCard.tsx deleted file mode 100644 index 2d501cd427..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/LinearCard.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; -import type { LinearConnectionStatus } from "../../../shared/types"; -import { COLORS, SANS_FONT, MONO_FONT } from "../lanes/laneDesignTokens"; -import { Button } from "../ui/Button"; -import { InputPopover } from "./InputPopover"; -import { RescanButton } from "./RescanButton"; -import { LinearMark } from "../lanes/linearBrand"; -import { BRAND, CARD_BASE, SECTION_LABEL, logoTile, statusDot } from "./onboardingTheme"; -import { openExternalUrl } from "../../lib/openExternal"; - -const openExternal = (url: string) => { - if (!/^https?:\/\//i.test(url)) throw new Error("Unsupported Linear OAuth URL protocol"); - openExternalUrl(url); -}; - -export function LinearCard() { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - const [oauthBusy, setOauthBusy] = useState(false); - const [oauthError, setOauthError] = useState(null); - const pollRef = useRef(null); - const oauthInFlightRef = useRef(false); - - const stopPolling = useCallback(() => { - if (pollRef.current != null) { - window.clearInterval(pollRef.current); - pollRef.current = null; - } - }, []); - - const refresh = useCallback(async () => { - if (!window.ade.cto) { - setLoading(false); - return; - } - setLoading(true); - try { - const next = await window.ade.cto.getLinearConnectionStatus(); - setStatus(next as LinearConnectionStatus); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void refresh(); - return () => { - stopPolling(); - oauthInFlightRef.current = false; - }; - }, [refresh, stopPolling]); - - const beginOAuth = async () => { - if (oauthInFlightRef.current) return; - const cto = window.ade.cto; - if (!cto) { - setOauthError("Linear OAuth is not available in this build"); - return; - } - stopPolling(); - oauthInFlightRef.current = true; - setOauthBusy(true); - setOauthError(null); - try { - const session = await cto.startLinearOAuth(); - openExternal(session.authUrl); - const sessionId = session.sessionId; - pollRef.current = window.setInterval(async () => { - try { - const update = await cto.getLinearOAuthSession({ sessionId }); - if (update.status === "completed") { - stopPolling(); - await refresh(); - oauthInFlightRef.current = false; - setOauthBusy(false); - } else if (update.status === "failed" || update.status === "expired") { - stopPolling(); - oauthInFlightRef.current = false; - setOauthError(update.error ?? "OAuth failed"); - setOauthBusy(false); - } - } catch (e) { - stopPolling(); - oauthInFlightRef.current = false; - setOauthError(e instanceof Error ? e.message : String(e)); - setOauthBusy(false); - } - }, 1500); - } catch (e) { - oauthInFlightRef.current = false; - setOauthError(e instanceof Error ? e.message : String(e)); - setOauthBusy(false); - } - }; - - const saveApiKey = async (token: string) => { - if (!window.ade.cto) { - return { ok: false, message: "Linear API is not available in this build" }; - } - try { - const next = await window.ade.cto.setLinearToken({ token }); - setStatus(next as LinearConnectionStatus); - return { ok: (next as LinearConnectionStatus).connected, message: "Linear connected" }; - } catch (e) { - return { ok: false, message: e instanceof Error ? e.message : String(e) }; - } - }; - - const disconnect = async () => { - if (!window.ade.cto) return; - if (!window.confirm("Disconnect Linear? You can reconnect from this setup card.")) return; - try { - const next = await window.ade.cto.clearLinearToken(); - setStatus(next as LinearConnectionStatus); - } catch { /* ignore */ } - }; - - const connected = status?.connected === true; - const tone = connected ? COLORS.success : COLORS.warning; - const projectPreview = (status?.projectPreview ?? []).slice(0, 3).join(", "); - - return ( -
-
- - - -
-
- Linear - -
-
- {loading ? "Checking…" : connected ? `Signed in as ${status?.viewerName ?? "you"}` : "Not connected"} -
-
- {connected ? void refresh()} /> : null} -
- - {connected ? ( -
- {status?.organizationName ? ( - - ) : null} - {typeof status?.projectCount === "number" ? ( - - ) : null} - {projectPreview ? : null} - -
- ) : ( -
- Connect Linear to link issues to lanes, chats, and PRs. -
- )} - - {oauthError ? ( -
{oauthError}
- ) : null} - - {connected ? ( -
- -
- ) : ( -
- - Get a personal API key at linear.app/settings/api} - placeholder="lin_api_..." - saveLabel="Connect" - onSave={saveApiKey} - /> -
- )} -
- ); -} - -function MetaRow({ label, value, dim = false }: { label: string; value: string; dim?: boolean }) { - return ( -
- {label} - - {value} - -
- ); -} - -const cardStyle: React.CSSProperties = { - ...CARD_BASE, - height: "100%", - boxSizing: "border-box", - display: "flex", - flexDirection: "column", -}; - -const subtitleStyle: React.CSSProperties = { - fontSize: 11.5, - fontFamily: SANS_FONT, - color: COLORS.textMuted, - marginTop: 2, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}; - -const headerRow: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 12, -}; - -const metaList: React.CSSProperties = { - marginTop: 14, - display: "flex", - flexDirection: "column", - gap: 8, -}; - -const actionRow: React.CSSProperties = { - marginTop: 14, - display: "flex", - gap: 8, - justifyContent: "flex-end", -}; - -const codeStyle: React.CSSProperties = { - fontFamily: MONO_FONT, - fontSize: 11, - padding: "1px 5px", - borderRadius: 4, - background: "rgba(255,255,255,0.08)", - color: COLORS.textPrimary, -}; diff --git a/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx b/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx deleted file mode 100644 index d73fde488e..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Button } from "../ui/Button"; -import { useAppStore } from "../../state/appStore"; -import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; -import { publishOnboardingStatusUpdated } from "../../lib/onboardingStatusEvents"; -import { DevToolsRow } from "./DevToolsRow"; -import { AiRuntimesBand } from "./AiRuntimesBand"; -import { GitHubCard } from "./GitHubCard"; -import { LinearCard } from "./LinearCard"; - -export function ProjectSetupPage() { - const navigate = useNavigate(); - const project = useAppStore((s) => s.project); - const [busyAction, setBusyAction] = useState<"finish" | "skip" | null>(null); - const [gitInstalled, setGitInstalled] = useState(null); - const [setupError, setSetupError] = useState(null); - - const finish = async () => { - setBusyAction("finish"); - setSetupError(null); - try { - const next = await window.ade.onboarding.complete(); - publishOnboardingStatusUpdated(next); - navigate("/work", { replace: true }); - } catch (err) { - setSetupError(err instanceof Error ? err.message : String(err)); - } finally { - setBusyAction(null); - } - }; - - const skip = async () => { - setBusyAction("skip"); - setSetupError(null); - try { - const next = await window.ade.onboarding.setDismissed(true); - publishOnboardingStatusUpdated(next); - navigate("/work", { replace: true }); - } catch (err) { - setSetupError(err instanceof Error ? err.message : String(err)); - } finally { - setBusyAction(null); - } - }; - - const busy = busyAction != null; - - return ( -
-
-
- -
-
-
-
Set up
-

{project?.displayName ?? "Your project"}

-

- Connect your tools and accounts. Everything's optional except git — change anything later in Settings. -

-
-
-
- - -
- {gitInstalled === false ? ( - - Install git to finish setup - - ) : null} - {setupError ? ( - - {setupError} - - ) : null} -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
- ); -} - -const pageStyle: React.CSSProperties = { - position: "relative", - height: "100%", - overflow: "auto", - display: "flex", - background: `radial-gradient(1200px 600px at 0% -10%, color-mix(in srgb, ${COLORS.accent} 10%, transparent), transparent 60%), ${COLORS.pageBg}`, -}; - -const glowViolet: React.CSSProperties = { - position: "absolute", - top: -120, - left: "8%", - width: 480, - height: 480, - borderRadius: "50%", - background: "radial-gradient(circle, rgba(167,139,250,0.16), transparent 70%)", - filter: "blur(20px)", - pointerEvents: "none", -}; - -const glowBlue: React.CSSProperties = { - position: "absolute", - bottom: -160, - right: "6%", - width: 520, - height: 520, - borderRadius: "50%", - background: "radial-gradient(circle, rgba(96,165,250,0.12), transparent 70%)", - filter: "blur(20px)", - pointerEvents: "none", -}; - -const containerStyle: React.CSSProperties = { - position: "relative", - margin: "auto", - width: "100%", - maxWidth: 1440, - padding: "40px 32px", - display: "flex", - flexDirection: "column", - gap: 24, -}; - -const headerStyle: React.CSSProperties = { - display: "flex", - alignItems: "flex-end", - justifyContent: "space-between", - gap: 24, - flexWrap: "wrap", -}; - -const eyebrowStyle: React.CSSProperties = { - fontSize: 11, - fontFamily: SANS_FONT, - fontWeight: 700, - letterSpacing: "0.14em", - textTransform: "uppercase", - color: COLORS.accent, -}; - -const titleStyle: React.CSSProperties = { - margin: "6px 0 0", - fontSize: 30, - lineHeight: 1.1, - fontWeight: 700, - fontFamily: SANS_FONT, - color: COLORS.textPrimary, -}; - -const subtitleStyle: React.CSSProperties = { - margin: "8px 0 0", - fontSize: 13, - fontFamily: SANS_FONT, - color: COLORS.textMuted, - maxWidth: 560, - lineHeight: 1.55, -}; - -const bodyStyle: React.CSSProperties = { - display: "flex", - gap: 20, - alignItems: "stretch", - flexWrap: "wrap", -}; - -const railStyle: React.CSSProperties = { - flex: "1 1 340px", - minWidth: 0, - display: "flex", - flexDirection: "column", - gap: 20, -}; - -const connectionsRowStyle: React.CSSProperties = { - display: "flex", - gap: 20, - alignItems: "stretch", -}; - -const connectionColStyle: React.CSSProperties = { - flex: "1 1 0", - minWidth: 0, - display: "flex", - flexDirection: "column", -}; diff --git a/apps/desktop/src/renderer/components/onboarding/RescanButton.tsx b/apps/desktop/src/renderer/components/onboarding/RescanButton.tsx deleted file mode 100644 index 7c9257bff9..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/RescanButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import { ArrowsClockwise } from "@phosphor-icons/react"; -import { Button } from "../ui/Button"; - -export function RescanButton({ - loading = false, - onClick, - iconOnly = false, -}: { - loading?: boolean; - onClick: () => void; - iconOnly?: boolean; -}) { - return ( - - ); -} diff --git a/apps/desktop/src/renderer/components/onboarding/onboardingTheme.ts b/apps/desktop/src/renderer/components/onboarding/onboardingTheme.ts deleted file mode 100644 index 4c97102cdf..0000000000 --- a/apps/desktop/src/renderer/components/onboarding/onboardingTheme.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { CSSProperties } from "react"; -import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; - -/** Per-integration brand accents — keeps the page multi-colored, never two-tone. */ -export const BRAND = { - claude: "#D97757", - codex: "#34D399", - cursor: "#60A5FA", - droid: "#A78BFA", - opencode: "#FBBF24", - github: "#3FB950", - linear: "#5E6AD2", -} as const; - -const CARD_BG = "color-mix(in srgb, var(--color-surface) 90%, var(--color-bg) 10%)"; - -export const CARD_BASE: CSSProperties = { - background: CARD_BG, - border: `1px solid ${COLORS.border}`, - borderRadius: 16, - padding: 18, -}; - -/** Card with a subtle wash + border in a brand color — no left-border accent stripe. */ -export function brandCard(brand: string, overrides?: CSSProperties): CSSProperties { - return { - ...CARD_BASE, - background: `color-mix(in srgb, ${brand} 9%, ${CARD_BG})`, - border: `1px solid color-mix(in srgb, ${brand} 32%, var(--color-border))`, - ...overrides, - }; -} - -export const SECTION_LABEL: CSSProperties = { - fontSize: 11, - fontWeight: 700, - fontFamily: SANS_FONT, - letterSpacing: "0.08em", - textTransform: "uppercase", - color: COLORS.textSecondary, -}; - -export function statusDot(color: string, size = 8): CSSProperties { - return { - width: size, - height: size, - borderRadius: size, - background: color, - boxShadow: `0 0 0 3px color-mix(in srgb, ${color} 18%, transparent)`, - flexShrink: 0, - }; -} - -/** Rounded brand glyph holder for non-avatar marks (GitHub, Linear). */ -export function logoTile(brand: string, size = 36): CSSProperties { - return { - width: size, - height: size, - borderRadius: 10, - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - background: `color-mix(in srgb, ${brand} 22%, transparent)`, - border: `1px solid color-mix(in srgb, ${brand} 40%, transparent)`, - color: `color-mix(in srgb, ${brand} 55%, var(--color-fg))`, - flexShrink: 0, - }; -} - -export const META_TEXT: CSSProperties = { - fontSize: 12, - fontFamily: SANS_FONT, - color: COLORS.textMuted, - lineHeight: 1.5, -}; diff --git a/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx b/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx index 130cc4b8af..73f922b052 100644 --- a/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx +++ b/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx @@ -18,6 +18,7 @@ import { } from "@phosphor-icons/react"; import { motion, AnimatePresence } from "motion/react"; import { extractError } from "../../lib/format"; +import { joinParentAndName } from "../../lib/pathUtils"; import { describeGithubPatVerification } from "../../lib/githubIntegrationStatus"; import type { CloneProjectInput, @@ -120,17 +121,6 @@ function deriveSlug(url: string): string { return match?.[1]?.replace(/\.git$/i, "") ?? ""; } -function joinPath(parent: string, name: string): string { - if (!parent) return name; - const sep = parent.includes("\\") ? "\\" : "/"; - const trimmed = - parent.endsWith("/") || parent.endsWith("\\") - ? parent.slice(0, -1) - : parent; - if (!name) return trimmed; - return `${trimmed}${sep}${name}`; -} - function relativeFromNow(iso: string | null | undefined): string { if (!iso) return ""; const then = new Date(iso).getTime(); @@ -354,7 +344,7 @@ function UrlTab({ const trimmedName = name.trim(); const urlValid = isGitHubRepoUrl(trimmedUrl); const previewPath = useMemo( - () => (parentDir && trimmedName ? joinPath(parentDir, trimmedName) : ""), + () => (parentDir && trimmedName ? joinParentAndName(parentDir, trimmedName) : ""), [parentDir, trimmedName], ); @@ -1028,7 +1018,7 @@ function RepoRow({ const checkRequestRef = useRef(0); const trimmedName = name.trim(); const previewPath = - parentDir && trimmedName ? joinPath(parentDir, trimmedName) : ""; + parentDir && trimmedName ? joinParentAndName(parentDir, trimmedName) : ""; useEffect(() => { if (!expanded || !previewPath) { diff --git a/apps/desktop/src/renderer/components/projects/CreateProjectForm.test.tsx b/apps/desktop/src/renderer/components/projects/CreateProjectForm.test.tsx new file mode 100644 index 0000000000..9e367d17f7 --- /dev/null +++ b/apps/desktop/src/renderer/components/projects/CreateProjectForm.test.tsx @@ -0,0 +1,130 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProjectBrowseResult } from "../../../shared/types"; +import { CreateProjectForm } from "./CreateProjectForm"; + +function emptyBrowse(): ProjectBrowseResult { + return { + inputPath: "", + resolvedPath: "", + directoryPath: "", + parentPath: null, + exactDirectoryPath: null, + openableProjectRoot: null, + entries: [], + }; +} + +describe("CreateProjectForm", () => { + afterEach(() => { + cleanup(); + }); + const home = process.env.HOME ?? process.env.USERPROFILE ?? "/Users/test"; + const defaultParent = `${home.replace(/\\/g, "/")}/Projects`; + + it("shows the default location and opens the folder picker from Change", async () => { + const getDefaultParentDir = vi.fn(async () => defaultParent); + const chooseDirectory = vi.fn(async () => `${home.replace(/\\/g, "/")}/Code`); + const browseDirectories = vi.fn(async () => emptyBrowse()); + const createProject = vi.fn(); + + render( + , + ); + + expect(await screen.findByText("~/Projects")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /^change$/i })); + await waitFor(() => { + expect(chooseDirectory).toHaveBeenCalledWith({ + title: "Choose where to create the project", + defaultPath: defaultParent, + }); + }); + expect(await screen.findByDisplayValue(`${home.replace(/\\/g, "/")}/Code`)).toBeTruthy(); + }); + + it("creates then reports the project without a success interstitial", async () => { + const onCreated = vi.fn(); + const createProject = vi.fn(async () => ({ + rootPath: `${defaultParent}/spark`, + })); + + render( + defaultParent} + browseDirectories={async () => emptyBrowse()} + chooseDirectory={vi.fn()} + createProject={createProject} + />, + ); + + await screen.findByPlaceholderText("my-new-project"); + const name = screen.getByPlaceholderText("my-new-project"); + fireEvent.change(name, { target: { value: "spark" } }); + fireEvent.click(screen.getByRole("button", { name: /create and open/i })); + + await waitFor(() => { + expect(createProject).toHaveBeenCalledWith({ + name: "spark", + parentDir: defaultParent, + }); + expect(onCreated).toHaveBeenCalledWith({ + rootPath: `${defaultParent}/spark`, + displayName: "spark", + projectId: undefined, + }); + }); + }); + + it("keeps Create and open disabled until the open callback finishes", async () => { + let resolveOpen!: () => void; + const onCreated = vi.fn( + () => + new Promise((resolve) => { + resolveOpen = resolve; + }), + ); + const createProject = vi.fn(async () => ({ + rootPath: `${defaultParent}/spark`, + })); + + render( + defaultParent} + browseDirectories={async () => emptyBrowse()} + chooseDirectory={vi.fn()} + createProject={createProject} + />, + ); + + await screen.findByPlaceholderText("my-new-project"); + fireEvent.change(screen.getByPlaceholderText("my-new-project"), { + target: { value: "spark" }, + }); + fireEvent.click(screen.getByRole("button", { name: /create and open/i })); + + await screen.findByText("Opening…"); + expect(screen.getByRole("button", { name: /opening/i })).toHaveProperty("disabled", true); + resolveOpen(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /create and open/i })).toHaveProperty( + "disabled", + false, + ); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx b/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx index 62b61b17dd..4dc43a9ff0 100644 --- a/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx +++ b/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx @@ -14,12 +14,12 @@ import type { ProjectBrowseResult, } from "../../../shared/types"; import { extractError } from "../../lib/format"; +import { abbreviateHome, arePathsEqual, joinParentAndName } from "../../lib/pathUtils"; import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, - cardStyle, outlineButton, primaryButton, } from "../lanes/laneDesignTokens"; @@ -30,7 +30,7 @@ export type CreateProjectFormProps = { rootPath: string; displayName: string; projectId?: string; - }) => void; + }) => void | Promise; machineName?: string; getDefaultParentDir?: () => Promise; browseDirectories?: ( @@ -61,26 +61,26 @@ function validateName(rawName: string): NameValidation { return { ok: true }; } -function joinPath(parent: string, name: string): string { - if (!parent) return name; - const sep = parent.includes("\\") ? "\\" : "/"; - const trimmed = - parent.endsWith("/") || parent.endsWith("\\") - ? parent.slice(0, -1) - : parent; - if (!name) return trimmed; - return `${trimmed}${sep}${name}`; +function formatLocationDisplay( + parentDirLoading: boolean, + previewPath: string, + parentDir: string, +): string { + if (parentDirLoading) return "Finding a default folder…"; + if (previewPath) return abbreviateHome(previewPath); + if (parentDir) return abbreviateHome(parentDir); + return "Choose a folder"; } const inputStyle: CSSProperties = { - height: 36, + height: 40, padding: "0 12px", - fontSize: 13, + fontSize: 14, fontFamily: SANS_FONT, color: COLORS.textPrimary, background: "color-mix(in srgb, var(--color-fg) 4%, transparent)", border: `1px solid ${COLORS.border}`, - borderRadius: 8, + borderRadius: 10, outline: "none", width: "100%", boxSizing: "border-box", @@ -99,6 +99,7 @@ export function CreateProjectForm({ const [parentDir, setParentDir] = useState(""); const [parentDirLoading, setParentDirLoading] = useState(true); const [pending, setPending] = useState(false); + const [opening, setOpening] = useState(false); const [error, setError] = useState(null); const [pathExists, setPathExists] = useState(false); const [pickerPending, setPickerPending] = useState(false); @@ -136,9 +137,14 @@ export function CreateProjectForm({ const validation = useMemo(() => validateName(name), [name]); const trimmedName = name.trim(); const previewPath = useMemo( - () => (parentDir && trimmedName ? joinPath(parentDir, trimmedName) : ""), + () => (parentDir && trimmedName ? joinParentAndName(parentDir, trimmedName) : ""), [parentDir, trimmedName], ); + const locationDisplay = formatLocationDisplay( + parentDirLoading, + previewPath, + parentDir, + ); useEffect(() => { if (!previewPath || !validation.ok) { @@ -150,7 +156,12 @@ export function CreateProjectForm({ void browse({ partialPath: previewPath }) .then((result) => { if (checkRequestRef.current !== requestId) return; - setPathExists(Boolean(result.exactDirectoryPath === previewPath)); + setPathExists( + Boolean( + result.exactDirectoryPath && + arePathsEqual(result.exactDirectoryPath, previewPath), + ), + ); }) .catch(() => { if (checkRequestRef.current !== requestId) return; @@ -176,7 +187,7 @@ export function CreateProjectForm({ setError(null); try { const selected = await pickDirectory({ - title: "Choose parent directory", + title: "Choose where to create the project", defaultPath: parentDir || undefined, }); if (selected) { @@ -193,6 +204,7 @@ export function CreateProjectForm({ setSubmitAttempted(true); if (!validation.ok || !parentDir || pathExists) return; setPending(true); + setOpening(false); setError(null); try { const result = await create({ @@ -203,15 +215,19 @@ export function CreateProjectForm({ "projectId" in result && typeof result.projectId === "string" ? result.projectId : undefined; - onCreated({ - rootPath: result.rootPath, - displayName: trimmedName, - projectId, - }); + setOpening(true); + await Promise.resolve( + onCreated({ + rootPath: result.rootPath, + displayName: trimmedName, + projectId, + }), + ); } catch (err) { setError(extractError(err)); } finally { setPending(false); + setOpening(false); } }, [create, onCreated, parentDir, pathExists, trimmedName, validation.ok]); @@ -220,11 +236,11 @@ export function CreateProjectForm({ style={{ display: "flex", flexDirection: "column", - gap: 16, + gap: 18, width: "100%", }} > - + {showNameError && !validation.ok ? ( {validation.reason} - ) : null} + ) : ( + This becomes the folder name. + )} {machineName ? ( - Target: {machineName} + Creating on {machineName} ) : null} - -
- setParentDir(event.target.value)} - placeholder={parentDirLoading ? "Loading…" : "Parent directory"} + +
+
+ > + + {locationDisplay} + +
{pickDirectory ? ( ) : null}
+
+ setParentDir(event.target.value)} + placeholder={parentDirLoading ? "Loading…" : "Parent folder"} + aria-label="Parent folder" + style={{ + ...inputStyle, + height: 34, + fontFamily: MONO_FONT, + fontSize: 12, + color: parentDir ? COLORS.textPrimary : COLORS.textMuted, + }} + disabled={pending || parentDirLoading} + /> + {pathExists ? ( + A folder already exists at that path + ) : ( + + Default is fine — Change or edit the folder if you want it somewhere else. + + )} +
- - {error ? {error} : null}
{ void handleSubmit(); @@ -320,10 +378,10 @@ export function CreateProjectForm({ {pending ? ( <> - Creating… + {opening ? "Opening…" : "Creating…"} ) : ( - "Create" + "Create and open" )}
@@ -339,65 +397,18 @@ function Field({ children: React.ReactNode; }) { return ( -
diff --git a/apps/desktop/src/renderer/components/settings/AdeCliSection.tsx b/apps/desktop/src/renderer/components/settings/AdeCliSection.tsx index b3cd693426..c2d06ae0fc 100644 --- a/apps/desktop/src/renderer/components/settings/AdeCliSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AdeCliSection.tsx @@ -14,7 +14,6 @@ const TERMINAL_INSTALL_COMMAND = rendererPlatformAttribute() === "win32" : "curl -fsSL https://ade-app.dev/install.sh | sh"; type Props = { - compact?: boolean; embedded?: boolean; }; @@ -23,7 +22,7 @@ type Notice = { text: string; } | null; -export function AdeCliSection({ compact = false, embedded = false }: Props) { +export function AdeCliSection({ embedded = false }: Props) { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [installing, setInstalling] = useState(false); @@ -174,7 +173,7 @@ export function AdeCliSection({ compact = false, embedded = false }: Props) { ); return ( -
+
{body}
); diff --git a/apps/desktop/src/renderer/lib/pathUtils.test.ts b/apps/desktop/src/renderer/lib/pathUtils.test.ts index b4dc740507..1492c98100 100644 --- a/apps/desktop/src/renderer/lib/pathUtils.test.ts +++ b/apps/desktop/src/renderer/lib/pathUtils.test.ts @@ -5,6 +5,7 @@ import { isWindowsAbsolutePath, isWindowsDrivePath, isWindowsUncPath, + joinParentAndName, normalizePath, normalizePathForComparison, normalizePathForWorkspaceComparison, @@ -83,4 +84,11 @@ describe("Windows path predicates", () => { expect(isWindowsAbsolutePath("relative/path")).toBe(false); expect(isWindowsAbsolutePath("")).toBe(false); }); + + it("joins a parent folder and project name without duplicate slashes", () => { + expect(joinParentAndName("/tmp/Projects/", "demo")).toBe("/tmp/Projects/demo"); + expect(joinParentAndName("C:\\Users\\me\\Projects", "demo")).toBe( + "C:/Users/me/Projects/demo", + ); + }); }); diff --git a/apps/desktop/src/renderer/lib/pathUtils.ts b/apps/desktop/src/renderer/lib/pathUtils.ts index c1eb7f8b9d..5f3ea92a07 100644 --- a/apps/desktop/src/renderer/lib/pathUtils.ts +++ b/apps/desktop/src/renderer/lib/pathUtils.ts @@ -134,6 +134,15 @@ export function normalizePathForWorkspaceComparison(value: string, workspaceRoot : normalized; } +export function joinParentAndName(parent: string, name: string): string { + const trimmedParent = parent.trim(); + const trimmedName = name.trim(); + if (!trimmedParent) return trimmedName; + if (!trimmedName) return normalizePath(trimmedParent); + const stripped = trimmedParent.replace(/[\\/]+$/, ""); + return normalizePath(`${stripped}/${trimmedName}`); +} + export function arePathsEqual(left: string, right: string, workspaceRoot?: string | null): boolean { return normalizePathForWorkspaceComparison(left, workspaceRoot) === normalizePathForWorkspaceComparison(right, workspaceRoot); } diff --git a/docs/PRD.md b/docs/PRD.md index ed964c2f5e..45fed614f5 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -116,7 +116,7 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec - [**Terminals and Sessions**](./features/terminals-and-sessions/README.md) — PTY and session services. Canonical cross-client session lifecycle, two-tier attention, the quiet settled tier, agent-authored status notes, AI titles, lazy resume-target hydration, and stale reconciliation. - [**Files and Editor**](./features/files-and-editor/README.md) — Atomic writes, ref-counted chokidar watcher, file search index, Monaco surfaces (edit/diff/conflict), preload trust boundary. - [**Universal Search**](./features/search/README.md) — One deterministic FTS5 index per project (disposable `.ade/cache/search-index.db`) over chat/terminal/PR/commit/branch text, unioned at query time with delegated lanes/files/artifacts/Linear. The machine router keeps non-chat kinds in the active project and aggregates bounded, explicitly truncated chat hits across every registered project. Debounced off-hot-path ingestion, deterministic ranking tiers, one `search` action domain behind ⌘K, the TUI palette, and `ade search`. -- [**Onboarding and Settings**](./features/onboarding-and-settings/README.md) — First-run wizard (stack detection, suggested config, import), 9-tab settings, configuration schema with trust model. +- [**Onboarding and Settings**](./features/onboarding-and-settings/README.md) — Launch/account gates, project open/create landing on Work, 10-tab settings, configuration schema with trust model. ### Integrations diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 72991a49df..1148425072 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -4,10 +4,9 @@ Two related but distinct flows: - **Onboarding** — the fastest path to a usable installation and a usable project. Covers registering the project with the runtime so every client - (desktop, `ade code`, iOS) sees it, detecting essentials, connecting AI - runtimes, GitHub, and Linear, and optionally attaching existing git - worktrees as lanes. The first-run project setup page is a single dashboard - of status cards rather than a blocking step-by-step wizard. + (desktop, `ade code`, iOS) sees it. Opening or creating a project lands on + Work immediately; AI runtimes, GitHub, and Linear live in Settings. There is + no blocking project setup dashboard. - **Settings** — long-lived configuration organized by tab. Project configuration persists to `.ade/ade.yaml` (shared) and `.ade/local.yaml` (local) through `projectConfigService`; machine-level desktop preferences @@ -174,33 +173,10 @@ Renderer — onboarding: — projectless welcome and project-picker surface. It lists recent local and remote projects, opens or forgets entries, and launches project creation, clone, or folder selection before a project-bound route is available. -- `apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx` - — first-run and manual "re-run setup" dashboard. It renders the project - header, Finish / Skip actions, the AI runtimes band, essentials row, - GitHub / Linear cards, and existing-worktree import card. -- `apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx` - — compact setup surface for Claude, Codex, Cursor, Factory Droid, and - OpenCode. Shows runtime readiness, install / sign-in commands, Cursor API-key - entry, helper toggles, and per-helper model pickers. Claude, Codex, and - OpenCode are backed by pinned tools, so the band also subscribes to the - agent-tools cache and renders a per-runtime downloading percent or a - `kind`-specific failure ("Not enough disk space to unpack", "No pinned build - for this platform") with a retry. Cursor and Droid are user-installed and keep - the plain detected / not-detected treatment. A cache `failed` never shows for - a runtime that resolved anyway — a user's own CLI on PATH satisfies it - without the cache. -- `apps/desktop/src/renderer/components/onboarding/DevToolsRow.tsx` - — essential local tooling status for git and the terminal `ade` CLI. -- `apps/desktop/src/renderer/components/onboarding/GitHubCard.tsx`, - `LinearCard.tsx` — setup cards for repository auth and Linear OAuth / - API-key auth. There is no worktree-import card: every git worktree in the - project already appears as a lane, so there is nothing to select. -- `apps/desktop/src/renderer/components/onboarding/InputPopover.tsx`, - `RescanButton.tsx`, `onboardingTheme.ts` — shared setup-card controls and - brand/status styling tokens. -- `apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx` - — legacy full-size dev tool detection surface retained for existing routes - that still mount it. +- `apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx` + — name plus a first-class location row (default parent, Change folder, + editable path). Create opens Work; it does not show a success interstitial + or the removed project-setup dashboard. - `apps/desktop/src/renderer/components/onboarding/OnboardingBootstrap.tsx` — top-level passive help mount. It renders the one-time ADE welcome video gate plus `DidYouKnow`; guided per-tab tours and the old @@ -447,8 +423,7 @@ Renderer — settings: — surfaces `window.ade.adeCli.getStatus()` / `installForUser()`. Status carries `terminalInstalled`, `agentPathReady`, `bundledAvailable`, and the resolved `installTargetPath` for the - bundled `ade` binary. In compact form (used by the Integrations tab and - the onboarding `DevToolsSection`) it shows the current install + bundled `ade` binary. It shows the current install path, an Install / Repair button that runs the platform install-path helper, and an "Add to PATH" hint when the install target isn't on the user's `$PATH`. Agents launched by ADE always @@ -1012,9 +987,8 @@ banner): - [configuration-schema.md](./configuration-schema.md) — shape of `.ade/ade.yaml` and `.ade/local.yaml` as consumed by `projectConfigService`; types in `shared/types/config.ts`. -- [first-run.md](./first-run.md) — the first-run setup dashboard, - stack detection, existing-lane import, and the UX contract that lets - users skip optional integrations. +- [first-run.md](./first-run.md) — first launch lands on Work. There is + no blocking project-setup dashboard; optional integrations live in Settings. ## Onboarding responsibilities @@ -1061,8 +1035,7 @@ the General settings tab via `AdeCliSection`: command" card calls `window.ade.adeCli.installForUser()`, which delegates to the platform helper script bundled with the desktop (`/Applications/ADE.app/Contents/Resources/ade-cli/install-path.sh` - on macOS, equivalents on other platforms). The compact form embedded - in the Integrations tab and the onboarding `DevToolsSection` shows the + on macOS, equivalents on other platforms). Settings → General shows the current install path, an Install / Repair button, and an "Add to PATH" hint when the install target is not on the user's `$PATH`. 5. Register projects with the runtime. Opening a project on desktop diff --git a/docs/features/onboarding-and-settings/first-run.md b/docs/features/onboarding-and-settings/first-run.md index baba4d3513..5e59275424 100644 --- a/docs/features/onboarding-and-settings/first-run.md +++ b/docs/features/onboarding-and-settings/first-run.md @@ -1,27 +1,24 @@ # First-Run Setup -The first-run setup page turns a freshly opened project into something usable -without forcing a step-by-step flow. It is a status-card dashboard for checking -local tooling, AI runtimes, optional GitHub / Linear connections, suggested -project config, and existing branch import. +Opening or creating a project goes straight to Work. There is no blocking +project-setup dashboard. `.ade` layout and `ade.db` are created as soon as ADE +knows the folder (create/clone scaffold, then the normal project bind). + +AI runtimes, GitHub, and Linear stay in Settings. A new local repo can stay +unpublished; the header Publish pill appears until `origin` exists. The canonical backend is -`apps/desktop/src/main/services/onboarding/onboardingService.ts`. The setup UI is -`apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx`. +`apps/desktop/src/main/services/onboarding/onboardingService.ts` +(status, suggested config, glossary help). `/onboarding` redirects to `/work`. -## Setup surfaces +## Surfaces | Surface | Component | Purpose | |---|---|---| -| Project header | `ProjectSetupPage.tsx` | Shows project identity, setup state, Finish / Skip actions, and repair affordances. | -| Developer tools | `DevToolsRow.tsx` | Checks `git`, the user-facing `ade` CLI install, and terminal readiness. | -| AI runtimes | `AiRuntimesBand.tsx` | Detects Claude, Codex, Cursor, Factory Droid, and OpenCode readiness; surfaces install/sign-in helpers and model picker entry points. | -| GitHub | `GitHubCard.tsx` | Guides repository auth and PR capability setup. | -| Linear | `LinearCard.tsx` | Guides Linear OAuth / API-key auth and optional workflow sync. | - -The dashboard can be finished even when optional integrations are incomplete. -Users can return to the same setup surface later, and long-lived preferences -live in Settings. +| Create project | `CreateProjectForm.tsx` | Name + first-class location; create opens Work. | +| Work | Work tab / new chat | Default landing for new, existing, and first-open projects. | +| Publish | header Publish pill | Optional GitHub repo creation when there is no `origin`. | +| Settings | Agents, Integrations | AI runtimes, GitHub, Linear. | ## Onboarding service API @@ -37,10 +34,9 @@ getHelpState(): OnboardingHelpState markGlossaryTermSeen(termId: string): OnboardingHelpState ``` -The first six methods power project setup. `getHelpState` and -`markGlossaryTermSeen` support passive glossary/help chips; guided tours, -per-tab walkthroughs, and the old welcome wizard are no longer part of the -renderer contract. +The first six methods remain for suggested config and status storage. The +desktop shell no longer redirects on `freshProject`. `getHelpState` and +`markGlossaryTermSeen` support passive glossary/help chips. ### Detection @@ -82,38 +78,20 @@ It also seeds: `applySuggestedConfig(suggestedConfig)` merges this partial config into the shared YAML via `projectConfigService.save`. -## ProjectSetupPage wiring - -The page is stateful and reacts to: - -- `window.ade.onboarding.getStatus()` on mount -- provider/tool readiness reads for the AI runtimes and developer-tool rows -- `detectDefaults()` when the user scans - -Clicking Finish calls `window.ade.onboarding.complete()` and publishes an -`onboardingStatusUpdated` renderer event via `publishOnboardingStatusUpdated` so -other surfaces refresh. - -Dismiss calls `setDismissed(true)` without stamping `completedAt`, leaving setup -available through explicit re-entry. - ## UX contract -- Do not block on optional integrations. GitHub and Linear are skippable. -- Keep setup responsive. Model detection, CLI probes, and lane detection run - concurrently where possible. -- Show the fastest path first. For Linear that means personal API keys, with - OAuth available but secondary. -- Defer heavy work to the feature surface that owns it. +- Never intercept project open with a setup route. Work is the landing. +- Do not block chatting on GitHub, Linear, or an initial commit. +- Create shows the default location clearly; changing it is a first-class control. ## Gotchas -- `freshProject` is computed at `createOnboardingService` construction and is - the signal for "this project has never been set up." Passing the wrong value - reopens first-run setup on a mature repo. +- `freshProject` is still computed at `createOnboardingService` construction + from missing `.ade/ade.db`. Create/clone now warm that database so first open + is not a special UI state. Do not reintroduce a shell redirect on the flag. - Existing-lane import runs `git rev-list --left-right --count` per candidate - branch, capped at 200. Large repos can still see noticeable latency, so the UI - shows an explicit loading state. + branch, capped at 200. Large repos can still see noticeable latency on the + Lanes tab, not on Work paint. - Workflow command parsing keeps only single-line steps; multi-line `run: |` blocks are skipped. Teams with complex CI flows should curate imported commands manually in `ade.yaml`.