diff --git a/.ade/ade.yaml b/.ade/ade.yaml index f07e43ded..abc6ebd97 100644 --- a/.ade/ade.yaml +++ b/.ade/ade.yaml @@ -1,32 +1,6 @@ version: 1 -processes: - - id: ad55deza - name: Dev - command: - - npm - - run - - dev - cwd: apps/desktop +processes: [] stackButtons: [] testSuites: [] laneOverlayPolicies: [] automations: [] -ai: - features: - narratives: true - conflict_proposals: true - commit_messages: true - pr_descriptions: true - terminal_summaries: true - memory_consolidation: true - mission_planning: true - orchestrator: true - initial_context: true - featureModelOverrides: - commit_messages: openai/gpt-5.3-codex-spark - pr_descriptions: openai/gpt-5.3-codex-spark - terminal_summaries: openai/gpt-5.3-codex-spark - chat: - autoTitleEnabled: true - autoTitleModelId: openai/gpt-5.3-codex-spark - autoTitleRefreshOnComplete: true diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml index 07f19bb9c..e7a73393e 100644 --- a/.ade/cto/identity.yaml +++ b/.ade/cto/identity.yaml @@ -1,6 +1,13 @@ name: CTO -version: 3 -persona: Persistent project CTO with strategic personality. +version: 1 +persona: >- + You are the CTO for this project inside ADE. + + You are the persistent technical lead who owns architecture, execution + quality, engineering continuity, and team direction. + + Use ADE's tools and project context to help the team move forward with clear, + concrete decisions. personality: strategic modelPreferences: provider: claude @@ -21,8 +28,4 @@ openclawContextPolicy: - secret - token - system_prompt -onboardingState: - completedSteps: - - identity - completedAt: 2026-03-26T18:45:21.214Z -updatedAt: 2026-03-26T18:45:21.216Z +updatedAt: 1970-01-01T00:00:00.000Z diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 5b9285af4..05368516a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,5 +1,4 @@ import { app, BrowserWindow, nativeImage, shell } from "electron"; -import { execFileSync } from "node:child_process"; import path from "node:path"; type NodePtyType = typeof import("node-pty"); import { registerIpc } from "./services/ipc/registerIpc"; @@ -29,6 +28,7 @@ import { createGitOperationsService } from "./services/git/gitOperationsService" import { runGit } from "./services/git/git"; import { createJobEngine } from "./services/jobs/jobEngine"; import { createAiIntegrationService } from "./services/ai/aiIntegrationService"; +import { augmentProcessPathWithShellAndKnownCliDirs } from "./services/ai/cliExecutableResolver"; import { createAgentChatService } from "./services/chat/agentChatService"; import { createGithubService } from "./services/github/githubService"; import { createPrService } from "./services/prs/prService"; @@ -113,38 +113,13 @@ import type { Logger } from "./services/logging/logger"; * the AI SDK can locate the CLI. */ function fixElectronShellPath(): void { - if (process.platform !== "darwin" && process.platform !== "linux") return; - - const currentPath = process.env.PATH ?? ""; - const hasUserLocalBin = currentPath.includes(".local/bin"); - const hasCommonCliBin = currentPath.includes("/usr/local/bin") || currentPath.includes("/opt/homebrew/bin"); - // Already rich — likely launched from terminal or already fixed. - if (hasUserLocalBin && hasCommonCliBin) return; - - try { - const loginShell = process.env.SHELL || "/bin/zsh"; - // Use execFileSync so SHELL is treated as a path, not interpolated shell text. - const resolved = execFileSync(loginShell, ["-lc", 'printf "%s" "$PATH"'], { - encoding: "utf-8", - timeout: 5_000, - }).trim(); - - if (resolved && resolved.length > currentPath.length) { - process.env.PATH = resolved; - } - } catch { - // Shell resolution failed — manually append common paths as fallback. - const extras = [ - "/usr/local/bin", - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - `${process.env.HOME}/.local/bin`, - `${process.env.HOME}/.nvm/current/bin`, - ].filter((p) => !currentPath.includes(p)); - - if (extras.length) { - process.env.PATH = `${currentPath}:${extras.join(":")}`; - } + const nextPath = augmentProcessPathWithShellAndKnownCliDirs({ + env: process.env, + includeInteractiveShell: true, + timeoutMs: 1_500, + }); + if (nextPath) { + process.env.PATH = nextPath; } } diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index 22c728036..c4a5aaf51 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -160,7 +160,9 @@ beforeEach(() => { describe("aiIntegrationService", () => { it("routes executeTask through unified executor", async () => { - const { service, runCalls } = makeService(); + const { service, runCalls } = makeService({ + aiConfig: { features: { mission_planning: true } }, + }); const result = await service.executeTask({ feature: "mission_planning", @@ -176,18 +178,25 @@ describe("aiIntegrationService", () => { expect(usageInsertCalls(runCalls)).toHaveLength(1); }); - it("treats commit_messages as opt-in until explicitly enabled", () => { + it("preserves legacy defaults for missing AI feature toggles", () => { const { service } = makeService(); const { service: enabledService } = makeService({ aiConfig: { features: { commit_messages: true, + terminal_summaries: true, + pr_descriptions: true, }, }, }); expect(service.getFeatureFlag("commit_messages")).toBe(false); + expect(service.getFeatureFlag("terminal_summaries")).toBe(true); + expect(service.getFeatureFlag("pr_descriptions")).toBe(true); + expect(service.getFeatureFlag("orchestrator")).toBe(true); expect(enabledService.getFeatureFlag("commit_messages")).toBe(true); + expect(enabledService.getFeatureFlag("terminal_summaries")).toBe(true); + expect(enabledService.getFeatureFlag("pr_descriptions")).toBe(true); }); it("routes generated commit messages through the commit_messages feature", async () => { @@ -248,7 +257,9 @@ describe("aiIntegrationService", () => { }); it("uses planning tools for mission planning tasks", async () => { - const { service } = makeService(); + const { service } = makeService({ + aiConfig: { features: { mission_planning: true } }, + }); await service.executeTask({ feature: "mission_planning", @@ -264,7 +275,9 @@ describe("aiIntegrationService", () => { }); it("resolves a default task model when model is omitted", async () => { - const { service } = makeService(); + const { service } = makeService({ + aiConfig: { features: { orchestrator: true } }, + }); await service.executeTask({ feature: "orchestrator", @@ -280,7 +293,9 @@ describe("aiIntegrationService", () => { }); it("resolves a default model for memory consolidation tasks when model is omitted", async () => { - const { service } = makeService(); + const { service } = makeService({ + aiConfig: { features: { memory_consolidation: true } }, + }); await service.executeTask({ feature: "memory_consolidation", @@ -296,7 +311,9 @@ describe("aiIntegrationService", () => { }); it("uses planning tools for read-only orchestrator tasks and none for other read-only tasks", async () => { - const { service } = makeService(); + const { service } = makeService({ + aiConfig: { features: { orchestrator: true, terminal_summaries: true } }, + }); await service.executeTask({ feature: "orchestrator", @@ -324,7 +341,9 @@ describe("aiIntegrationService", () => { }); it("forwards memory context and compaction identifiers to the unified executor when provided", async () => { - const { service } = makeService(); + const { service } = makeService({ + aiConfig: { features: { orchestrator: true } }, + }); const memoryService = { writeMemory: vi.fn(), } as any; diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 340b947b0..d12d4b656 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -121,6 +121,18 @@ type RuntimeTaskDefaults = { timeoutMs: number; }; +const DEFAULT_AI_FEATURE_FLAGS: Record = { + narratives: true, + conflict_proposals: true, + commit_messages: false, + pr_descriptions: true, + terminal_summaries: true, + memory_consolidation: true, + mission_planning: true, + orchestrator: true, + initial_context: true, +}; + const DEFAULT_CLAUDE_TASK_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; const DEFAULT_CODEX_TASK_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex"; @@ -461,10 +473,7 @@ export function createAiIntegrationService(args: { const aiConfig = extractAiConfig(snapshot); const features = isRecord(aiConfig.features) ? aiConfig.features : {}; const value = features[feature]; - if (value == null) { - return feature === "commit_messages" ? false : true; - } - return Boolean(value); + return value == null ? DEFAULT_AI_FEATURE_FLAGS[feature] : Boolean(value); }; const getDailyBudgetLimit = (feature: AiFeatureKey): number | null => { diff --git a/apps/desktop/src/main/services/ai/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index 3161519f8..26416a9ef 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -1,7 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; const spawnMock = vi.fn(); +const execFileSyncMock = vi.fn(); const getAllApiKeysMock = vi.fn(); /** Helper: create a fake ChildProcess that immediately emits close with the given result. */ @@ -36,6 +40,7 @@ vi.mock("node:child_process", async () => { return { ...actual, spawn: (...args: unknown[]) => spawnMock(...args), + execFileSync: (...args: unknown[]) => execFileSyncMock(...args), }; }); @@ -47,9 +52,18 @@ vi.mock("./apiKeyStore", () => ({ let detectAllAuth: typeof import("./authDetector").detectAllAuth; let detectCliAuthStatuses: typeof import("./authDetector").detectCliAuthStatuses; let verifyProviderApiKey: typeof import("./authDetector").verifyProviderApiKey; +const originalPlatform = process.platform; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value, + configurable: true, + }); +} beforeEach(async () => { vi.resetModules(); + setPlatform("darwin"); const mod = await import("./authDetector"); detectAllAuth = mod.detectAllAuth; detectCliAuthStatuses = mod.detectCliAuthStatuses; @@ -58,9 +72,11 @@ beforeEach(async () => { describe("authDetector", () => { const originalEnv = { ...process.env }; + let tempHomeDir: string | null = null; beforeEach(() => { spawnMock.mockReset(); + execFileSyncMock.mockReset(); getAllApiKeysMock.mockReset(); vi.unstubAllGlobals(); process.env = { ...originalEnv }; @@ -68,7 +84,12 @@ describe("authDetector", () => { afterEach(() => { process.env = { ...originalEnv }; + setPlatform(originalPlatform); vi.unstubAllGlobals(); + if (tempHomeDir) { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + tempHomeDir = null; + } }); it("reports installed-but-unauthenticated CLI providers", async () => { @@ -236,6 +257,89 @@ describe("authDetector", () => { expect(claude?.authenticated).toBe(true); }); + it("finds codex through an npm-global prefix when PATH lookup fails", async () => { + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-auth-detector-")); + const prefixDir = path.join(tempHomeDir, ".npm-global"); + fs.mkdirSync(path.join(prefixDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tempHomeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8"); + fs.writeFileSync(path.join(prefixDir, "bin", "codex"), "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(path.join(prefixDir, "bin", "codex"), 0o755); + process.env.HOME = tempHomeDir; + process.env.PATH = "/usr/bin:/bin"; + + spawnMock.mockImplementation((command: string, args: string[] = []) => { + if (args[0] === "--version") { + if (command === "codex") return fakeError(); + if (command === path.join(prefixDir, "bin", "codex")) return fakeChild({ status: 0, stdout: "0.105.0\n" }); + return fakeError(); + } + if (command === "which") { + return fakeChild({ status: 1 }); + } + if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { + return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" }); + } + return fakeChild({ status: 1 }); + }); + + const statuses = await detectCliAuthStatuses(); + const codex = statuses.find((entry) => entry.cli === "codex"); + + expect(codex).toEqual({ + cli: "codex", + installed: true, + path: path.join(prefixDir, "bin", "codex"), + authenticated: true, + verified: true, + }); + }); + + it("repairs PATH from the interactive shell during a forced refresh", async () => { + process.env.PATH = "/usr/bin:/bin:/usr/sbin:/sbin"; + process.env.SHELL = "/bin/zsh"; + + execFileSyncMock.mockImplementation((_command: string, args: string[]) => { + if (args[0] === "-lc") { + return "__ADE_PATH_START__/Users/arul/.local/bin:/usr/local/bin:/usr/bin:/bin__ADE_PATH_END__"; + } + if (args[0] === "-ic") { + return "shell noise\n__ADE_PATH_START__/Users/arul/.npm-global/bin:/Users/arul/.local/bin:/usr/local/bin:/usr/bin:/bin__ADE_PATH_END__"; + } + throw new Error(`unexpected shell args: ${args.join(" ")}`); + }); + + spawnMock.mockImplementation((command: string, args: string[] = []) => { + if (args[0] === "--version") { + if (command === "codex" && process.env.PATH?.includes("/Users/arul/.npm-global/bin")) { + return fakeChild({ status: 0, stdout: "codex-cli 0.117.0\n" }); + } + return fakeError(); + } + if (command === "which") { + if (args[0] === "codex" && process.env.PATH?.includes("/Users/arul/.npm-global/bin")) { + return fakeChild({ status: 0, stdout: "/Users/arul/.npm-global/bin/codex\n" }); + } + return fakeChild({ status: 1 }); + } + if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { + return fakeChild({ status: 0, stdout: "Logged in using ChatGPT\n" }); + } + return fakeChild({ status: 1 }); + }); + + const statuses = await detectCliAuthStatuses({ force: true }); + const codex = statuses.find((entry) => entry.cli === "codex"); + + expect(process.env.PATH).toContain("/Users/arul/.npm-global/bin"); + expect(codex).toEqual({ + cli: "codex", + installed: true, + path: "/Users/arul/.npm-global/bin/codex", + authenticated: true, + verified: true, + }); + }); + it("verifies API keys with provider endpoints", async () => { vi.stubGlobal( "fetch", diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 641f1ef51..4c9c4dafe 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -2,10 +2,11 @@ // Auth Detector — discovers available authentication methods // --------------------------------------------------------------------------- -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { spawnAsync } from "../shared/utils"; +import { + augmentProcessPathWithShellAndKnownCliDirs, + resolveExecutableFromKnownLocations, +} from "./cliExecutableResolver"; type CliName = "claude" | "codex"; @@ -99,26 +100,12 @@ function hasPattern(text: string, patterns: RegExp[]): boolean { return patterns.some((pattern) => pattern.test(text)); } -const HOME_DIR = os.homedir(); - -const COMMON_BIN_DIRS = [ - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/usr/local/bin", - "/usr/local/sbin", - "/usr/bin", - "/bin", - `${HOME_DIR}/.local/bin`, - `${HOME_DIR}/.nvm/current/bin`, -].filter(Boolean); - function getLookupShell(): string { return process.env.SHELL || "/bin/zsh"; } function findExplicitCommandPath(command: string): string | null { - const match = COMMON_BIN_DIRS.find((dir) => fs.existsSync(path.join(dir, command))); - return match ? path.join(match, command) : null; + return resolveExecutableFromKnownLocations(command)?.path ?? null; } async function commandExists(command: string): Promise { @@ -174,24 +161,13 @@ async function commandPath(command: string): Promise { } async function refreshProcessPathFromShell(): Promise { - if (process.platform !== "darwin" && process.platform !== "linux") return; - const currentPath = process.env.PATH ?? ""; - const loginShell = process.env.SHELL || "/bin/zsh"; - - try { - const resolved = await spawnAsync(loginShell, ["-lc", "printf '%s' \"$PATH\""], { timeout: 5_000 }); - const nextPath = resolved.stdout.trim(); - if (resolved.status === 0 && nextPath.length > 0) { - process.env.PATH = nextPath; - return; - } - } catch { - // Fall through to best-effort path augmentation below. - } - - const extras = COMMON_BIN_DIRS.filter((entry) => !currentPath.includes(entry)); - if (extras.length > 0) { - process.env.PATH = currentPath.length > 0 ? `${currentPath}:${extras.join(":")}` : extras.join(":"); + const nextPath = augmentProcessPathWithShellAndKnownCliDirs({ + env: process.env, + includeInteractiveShell: true, + timeoutMs: 2_000, + }); + if (nextPath) { + process.env.PATH = nextPath; } } diff --git a/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts b/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts index 64256f103..5eb5ad1f7 100644 --- a/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts +++ b/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts @@ -1,47 +1,11 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import type { DetectedAuth } from "./authDetector"; +import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; export type ClaudeCodeExecutableResolution = { path: string; source: "env" | "auth" | "path" | "common-dir" | "fallback-command"; }; -const HOME_DIR = os.homedir(); -const COMMON_BIN_DIRS = [ - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/usr/local/bin", - "/usr/local/sbin", - "/usr/bin", - "/bin", - `${HOME_DIR}/.local/bin`, - `${HOME_DIR}/.nvm/current/bin`, -].filter(Boolean); - -function isExecutableFile(candidatePath: string): boolean { - try { - const stat = fs.statSync(candidatePath); - return stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0); - } catch { - return false; - } -} - -function resolveFromPathEntries(command: string, pathValue: string | undefined): string | null { - if (!pathValue) return null; - for (const entry of pathValue.split(path.delimiter)) { - const trimmed = entry.trim(); - if (!trimmed) continue; - const candidatePath = path.join(trimmed, command); - if (isExecutableFile(candidatePath)) { - return candidatePath; - } - } - return null; -} - function findClaudeAuthPath(auth?: DetectedAuth[]): string | null { for (const entry of auth ?? []) { if (entry.type !== "cli-subscription" || entry.cli !== "claude") continue; @@ -68,16 +32,12 @@ export function resolveClaudeCodeExecutable(args?: { return { path: authPath, source: "auth" }; } - const pathResolved = resolveFromPathEntries("claude", env.PATH); - if (pathResolved) { - return { path: pathResolved, source: "path" }; - } - - for (const binDir of COMMON_BIN_DIRS) { - const candidatePath = path.join(binDir, "claude"); - if (isExecutableFile(candidatePath)) { - return { path: candidatePath, source: "common-dir" }; - } + const resolved = resolveExecutableFromKnownLocations("claude", env); + if (resolved) { + return { + path: resolved.path, + source: resolved.source === "path" ? "path" : "common-dir", + }; } return { path: "claude", source: "fallback-command" }; diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts new file mode 100644 index 000000000..12619043c --- /dev/null +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + augmentPathWithKnownCliDirs, + resolveExecutableFromKnownLocations, +} from "./cliExecutableResolver"; + +const originalPlatform = process.platform; + +function makeExecutable(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(filePath, 0o755); +} + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value, + configurable: true, + }); +} + +describe("cliExecutableResolver", () => { + let tempRoot: string | null = null; + + afterEach(() => { + setPlatform(originalPlatform); + if (tempRoot) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + it("discovers codex from an npm prefix configured in ~/.npmrc", () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); + const homeDir = path.join(tempRoot, "home"); + const prefixDir = path.join(homeDir, ".npm-global"); + makeExecutable(path.join(prefixDir, "bin", "codex")); + fs.mkdirSync(homeDir, { recursive: true }); + fs.writeFileSync(path.join(homeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8"); + + const env = { + HOME: homeDir, + PATH: "/usr/bin:/bin", + }; + + expect(resolveExecutableFromKnownLocations("codex", env)).toEqual({ + path: path.join(prefixDir, "bin", "codex"), + source: "known-dir", + }); + }); + + it("augments PATH with npm-global bins discovered from ~/.npmrc", () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); + const homeDir = path.join(tempRoot, "home"); + fs.mkdirSync(homeDir, { recursive: true }); + fs.writeFileSync(path.join(homeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8"); + + const nextPath = augmentPathWithKnownCliDirs("/usr/bin:/bin", { + HOME: homeDir, + PATH: "/usr/bin:/bin", + }); + + expect(nextPath.split(path.delimiter)).toContain(path.join(homeDir, ".npm-global", "bin")); + }); + + it("keeps both Intel and Apple Silicon Homebrew bins on PATH", () => { + const nextPath = augmentPathWithKnownCliDirs("/usr/local/bin:/usr/bin:/bin", { + HOME: "/tmp/ade-home", + PATH: "/usr/local/bin:/usr/bin:/bin", + }); + + const entries = nextPath.split(path.delimiter); + expect(entries).toContain("/usr/local/bin"); + expect(entries).toContain("/opt/homebrew/bin"); + }); + + it("resolves Windows executables using PATHEXT suffixes", () => { + setPlatform("win32"); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); + const binDir = path.join(tempRoot, "bin"); + const executablePath = path.join(binDir, "codex.CMD"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(executablePath, "@echo off\r\n", "utf8"); + + const env = { + PATH: binDir, + PATHEXT: ".EXE;.CMD;.BAT", + }; + + expect(resolveExecutableFromKnownLocations("codex", env)).toEqual({ + path: executablePath, + source: "path", + }); + }); +}); diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts new file mode 100644 index 000000000..b1c3f12d8 --- /dev/null +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -0,0 +1,225 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +type ResolutionSource = "path" | "known-dir"; +const PATH_MARKER_START = "__ADE_PATH_START__"; +const PATH_MARKER_END = "__ADE_PATH_END__"; + +export type ResolvedExecutable = { + path: string; + source: ResolutionSource; +}; + +function getHomeDir(env: NodeJS.ProcessEnv): string { + const home = env.HOME?.trim(); + return home && home.length > 0 ? home : os.homedir(); +} + +function uniqueNonEmpty(values: Iterable): string[] { + const out = new Set(); + for (const value of values) { + const trimmed = value.trim(); + if (!trimmed) continue; + out.add(trimmed); + } + return [...out]; +} + +function expandHomePath(input: string, homeDir: string): string { + if (input === "~") return homeDir; + if (input.startsWith("~/")) return path.join(homeDir, input.slice(2)); + return input; +} + +function parseNpmPrefix(line: string, homeDir: string): string | null { + const match = line.match(/^\s*prefix\s*=\s*(.+?)\s*$/); + if (!match) return null; + const raw = match[1].trim().replace(/^['"]|['"]$/g, ""); + if (!raw) return null; + return expandHomePath(raw, homeDir); +} + +function readNpmPrefixBinDirs(env: NodeJS.ProcessEnv): string[] { + const homeDir = getHomeDir(env); + const rcPaths = [ + path.join(homeDir, ".npmrc"), + path.join(homeDir, ".config", "npm", "npmrc"), + ]; + const prefixes = new Set(); + + for (const rcPath of rcPaths) { + try { + const raw = fs.readFileSync(rcPath, "utf8"); + for (const line of raw.split(/\r?\n/)) { + const prefix = parseNpmPrefix(line, homeDir); + if (prefix) prefixes.add(prefix); + } + } catch { + // Ignore unreadable npmrc files. + } + } + + return [...prefixes].map((prefix) => path.join(prefix, "bin")); +} + +function getKnownBinDirs( + command: string, + env: NodeJS.ProcessEnv, +): string[] { + const homeDir = getHomeDir(env); + const bunInstall = env.BUN_INSTALL?.trim(); + const voltaHome = env.VOLTA_HOME?.trim(); + const pnpmHome = env.PNPM_HOME?.trim(); + const asdfDataDir = env.ASDF_DATA_DIR?.trim(); + + return uniqueNonEmpty([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + `${homeDir}/.local/bin`, + `${homeDir}/.npm-global/bin`, + `${homeDir}/.yarn/bin`, + `${homeDir}/.config/yarn/global/node_modules/.bin`, + `${homeDir}/Library/pnpm`, + `${homeDir}/.pnpm-global/bin`, + `${homeDir}/.bun/bin`, + `${homeDir}/.volta/bin`, + `${homeDir}/.asdf/shims`, + `${homeDir}/.asdf/bin`, + `${homeDir}/.nvm/current/bin`, + `${homeDir}/.mise/shims`, + `${homeDir}/.mise/bin`, + `${homeDir}/bin`, + bunInstall ? path.join(bunInstall, "bin") : "", + voltaHome ? path.join(voltaHome, "bin") : "", + pnpmHome || "", + asdfDataDir ? path.join(asdfDataDir, "shims") : "", + ...readNpmPrefixBinDirs(env), + command === "codex" ? "/Applications/Codex.app/Contents/Resources" : "", + ]); +} + +function isExecutableFile(candidatePath: string): boolean { + try { + const stat = fs.statSync(candidatePath); + return stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0); + } catch { + return false; + } +} + +function resolveFromDirs( + command: string, + dirs: Iterable, + env: NodeJS.ProcessEnv = process.env, +): string | null { + const pathext = process.platform === "win32" + ? uniqueNonEmpty((env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")) + : []; + const commandHasExtension = path.extname(command).length > 0; + + for (const dir of dirs) { + const candidatePaths = [path.join(dir, command)]; + if (process.platform === "win32" && !commandHasExtension) { + for (const ext of pathext) { + candidatePaths.push(path.join(dir, `${command}${ext}`)); + } + } + + for (const candidatePath of candidatePaths) { + if (isExecutableFile(candidatePath)) return candidatePath; + } + } + return null; +} + +export function splitPathEntries(pathValue: string | undefined): string[] { + if (!pathValue) return []; + return uniqueNonEmpty(pathValue.split(path.delimiter)); +} + +export function mergePathEntries(...values: Array): string { + return uniqueNonEmpty(values.flatMap((value) => splitPathEntries(value ?? undefined))).join(path.delimiter); +} + +export function augmentPathWithKnownCliDirs( + pathValue: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): string { + return mergePathEntries( + pathValue, + getKnownBinDirs("claude", env).join(path.delimiter), + getKnownBinDirs("codex", env).join(path.delimiter), + ); +} + +function readShellPath( + shellPath: string, + shellFlag: "-lc" | "-ic", + timeoutMs: number, + env?: NodeJS.ProcessEnv, +): string | null { + try { + const raw = execFileSync( + shellPath, + [shellFlag, `printf '${PATH_MARKER_START}%s${PATH_MARKER_END}' "$PATH"`], + { + encoding: "utf-8", + env, + timeout: timeoutMs, + }, + ); + const startIdx = raw.indexOf(PATH_MARKER_START); + const endIdx = raw.indexOf(PATH_MARKER_END, startIdx + PATH_MARKER_START.length); + if (startIdx === -1 || endIdx === -1) return null; + const resolved = raw.slice(startIdx + PATH_MARKER_START.length, endIdx).trim(); + return resolved.length > 0 ? resolved : null; + } catch { + return null; + } +} + +export function augmentProcessPathWithShellAndKnownCliDirs(args?: { + env?: NodeJS.ProcessEnv; + includeInteractiveShell?: boolean; + timeoutMs?: number; +}): string { + if (process.platform !== "darwin" && process.platform !== "linux") { + return args?.env?.PATH ?? process.env.PATH ?? ""; + } + + const env = args?.env ?? process.env; + const shellPath = env.SHELL?.trim() || "/bin/sh"; + const timeoutMs = args?.timeoutMs ?? 1_000; + const loginPath = readShellPath(shellPath, "-lc", timeoutMs, env); + const interactivePath = args?.includeInteractiveShell + ? readShellPath(shellPath, "-ic", timeoutMs, env) + : null; + + return augmentPathWithKnownCliDirs( + mergePathEntries(env.PATH, loginPath, interactivePath), + env, + ); +} + +export function resolveExecutableFromKnownLocations( + command: string, + env: NodeJS.ProcessEnv = process.env, +): ResolvedExecutable | null { + const fromPath = resolveFromDirs(command, splitPathEntries(env.PATH), env); + if (fromPath) { + return { path: fromPath, source: "path" }; + } + + const fromKnownDirs = resolveFromDirs(command, getKnownBinDirs(command, env), env); + if (fromKnownDirs) { + return { path: fromKnownDirs, source: "known-dir" }; + } + + return null; +} diff --git a/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts b/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts new file mode 100644 index 000000000..d9e8dc4de --- /dev/null +++ b/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts @@ -0,0 +1,78 @@ +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const execFileSyncMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFileSync: (...args: unknown[]) => execFileSyncMock(...args), + }; +}); + +let augmentProcessPathWithShellAndKnownCliDirs: typeof import("./cliExecutableResolver").augmentProcessPathWithShellAndKnownCliDirs; +const originalPlatform = process.platform; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value, + configurable: true, + }); +} + +describe("augmentProcessPathWithShellAndKnownCliDirs", () => { + beforeEach(async () => { + vi.resetModules(); + execFileSyncMock.mockReset(); + setPlatform("darwin"); + ({ augmentProcessPathWithShellAndKnownCliDirs } = await import("./cliExecutableResolver")); + }); + + afterEach(() => { + setPlatform(originalPlatform); + }); + + it("merges login and interactive shell PATH entries on macOS", () => { + execFileSyncMock.mockImplementation((_shellPath: string, args: string[]) => { + if (args[0] === "-lc") { + return "noise __ADE_PATH_START__/usr/bin:/bin:/opt/custom/login/bin__ADE_PATH_END__"; + } + if (args[0] === "-ic") { + return "__ADE_PATH_START__/usr/bin:/bin:/Users/test/.interactive/bin__ADE_PATH_END__"; + } + return ""; + }); + + const env: NodeJS.ProcessEnv = { + HOME: "/Users/test", + SHELL: "/bin/zsh", + PATH: "/usr/bin:/bin", + }; + + const nextPath = augmentProcessPathWithShellAndKnownCliDirs({ + env, + includeInteractiveShell: true, + timeoutMs: 250, + }); + + const entries = nextPath.split(path.delimiter); + expect(entries).toContain("/opt/custom/login/bin"); + expect(entries).toContain("/Users/test/.interactive/bin"); + expect(entries).toContain("/Users/test/.npm-global/bin"); + expect(env.PATH).toBe("/usr/bin:/bin"); + expect(nextPath).not.toBe(env.PATH); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 1, + "/bin/zsh", + expect.any(Array), + expect.objectContaining({ env }), + ); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 2, + "/bin/zsh", + expect.any(Array), + expect.objectContaining({ env }), + ); + }); +}); diff --git a/apps/desktop/src/main/services/ai/codexExecutable.test.ts b/apps/desktop/src/main/services/ai/codexExecutable.test.ts new file mode 100644 index 000000000..785b257c9 --- /dev/null +++ b/apps/desktop/src/main/services/ai/codexExecutable.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => ({ + resolveExecutableFromKnownLocations: vi.fn(), +})); + +vi.mock("./cliExecutableResolver", () => ({ + resolveExecutableFromKnownLocations: (...args: unknown[]) => mockState.resolveExecutableFromKnownLocations(...args), +})); + +import { resolveCodexExecutable } from "./codexExecutable"; + +describe("resolveCodexExecutable", () => { + it("uses the detected Codex auth path before falling back to PATH lookup", () => { + mockState.resolveExecutableFromKnownLocations.mockReset(); + + expect( + resolveCodexExecutable({ + auth: [ + { + type: "cli-subscription", + cli: "codex", + path: "/Users/arul/.npm-global/bin/codex", + authenticated: true, + verified: true, + }, + ], + env: { + PATH: "/usr/bin:/bin", + }, + }), + ).toEqual({ + path: "/Users/arul/.npm-global/bin/codex", + source: "auth", + }); + expect(mockState.resolveExecutableFromKnownLocations).not.toHaveBeenCalled(); + }); + + it("honors CODEX_EXECUTABLE before PATH lookup", () => { + mockState.resolveExecutableFromKnownLocations.mockReset(); + + expect( + resolveCodexExecutable({ + env: { + CODEX_EXECUTABLE: "/opt/codex/bin/codex", + PATH: "/usr/bin:/bin", + }, + }), + ).toEqual({ + path: "/opt/codex/bin/codex", + source: "path", + }); + expect(mockState.resolveExecutableFromKnownLocations).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/services/ai/codexExecutable.ts b/apps/desktop/src/main/services/ai/codexExecutable.ts new file mode 100644 index 000000000..9a5efca3e --- /dev/null +++ b/apps/desktop/src/main/services/ai/codexExecutable.ts @@ -0,0 +1,42 @@ +import type { DetectedAuth } from "./authDetector"; +import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; + +export type CodexExecutableResolution = { + path: string; + source: "auth" | "path" | "common-dir" | "fallback-command"; +}; + +function findCodexAuthPath(auth?: DetectedAuth[]): string | null { + for (const entry of auth ?? []) { + if (entry.type !== "cli-subscription" || entry.cli !== "codex") continue; + const candidate = entry.path.trim(); + if (candidate) return candidate; + } + return null; +} + +export function resolveCodexExecutable(args?: { + auth?: DetectedAuth[]; + env?: NodeJS.ProcessEnv; +}): CodexExecutableResolution { + const env = args?.env ?? process.env; + const authPath = findCodexAuthPath(args?.auth); + if (authPath) { + return { path: authPath, source: "auth" }; + } + + const envPath = env.CODEX_EXECUTABLE?.trim() || env.CODEX_EXECUTABLE_PATH?.trim(); + if (envPath) { + return { path: envPath, source: "path" }; + } + + const resolved = resolveExecutableFromKnownLocations("codex", env); + if (resolved) { + return { + path: resolved.path, + source: resolved.source === "path" ? "path" : "common-dir", + }; + } + + return { path: "codex", source: "fallback-command" }; +} diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 08fce0f94..a107b527f 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -76,7 +76,7 @@ export async function buildProviderConnections( return `${providerLabel} CLI is installed but no login was detected. Run: ${loginHint}`; } if (!flags.runtimeDetected) { - return `Local credentials exist but the ${providerLabel} CLI is not on ADE's PATH.`; + return `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH, login-shell PATH, interactive-shell PATH, and common install directories. If ${providerLabel} is installed elsewhere, add that bin directory to your shell PATH and refresh.`; } if (extraBlocker) return extraBlocker; return null; diff --git a/apps/desktop/src/main/services/ai/providerResolver.test.ts b/apps/desktop/src/main/services/ai/providerResolver.test.ts index b23ad567f..36fe877e3 100644 --- a/apps/desktop/src/main/services/ai/providerResolver.test.ts +++ b/apps/desktop/src/main/services/ai/providerResolver.test.ts @@ -25,6 +25,13 @@ vi.mock("./claudeCodeExecutable", () => ({ }), })); +vi.mock("./codexExecutable", () => ({ + resolveCodexExecutable: () => ({ + path: "/mock/bin/codex", + source: "auth", + }), +})); + describe("providerResolver codex CLI", () => { beforeEach(() => { createCodexCliMock.mockReset(); @@ -66,6 +73,7 @@ describe("providerResolver codex CLI", () => { expect.objectContaining({ defaultSettings: expect.objectContaining({ cwd: "/tmp/worktree", + codexPath: "/mock/bin/codex", mcpServers: { ade: { transport: "stdio", diff --git a/apps/desktop/src/main/services/ai/providerResolver.ts b/apps/desktop/src/main/services/ai/providerResolver.ts index 45a4ff606..d2e44c3ad 100644 --- a/apps/desktop/src/main/services/ai/providerResolver.ts +++ b/apps/desktop/src/main/services/ai/providerResolver.ts @@ -10,6 +10,7 @@ import { } from "../../../shared/modelRegistry"; import type { DetectedAuth } from "./authDetector"; import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; +import { resolveCodexExecutable } from "./codexExecutable"; import { wrapWithMiddleware, type WrapMiddlewareOpts } from "./middleware"; import { resolveViaAdeProviderRegistry } from "./adeProviderRegistry"; export { buildProviderOptions } from "./providerOptions"; @@ -239,6 +240,9 @@ function buildCliDefaultSettings( if (provider === "claude" && settings.pathToClaudeCodeExecutable == null) { settings.pathToClaudeCodeExecutable = resolveClaudeCodeExecutable({ auth }).path; } + if (provider === "codex" && settings.codexPath == null) { + settings.codexPath = resolveCodexExecutable({ auth }).path; + } return settings; } diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index a749163e5..eb5c32ab6 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -27,9 +27,11 @@ import { } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import type { Logger } from "../logging/logger"; +import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; +import { resolveCodexExecutable } from "../ai/codexExecutable"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "../lanes/laneService"; -import { resolvePathWithinRoot } from "../shared/utils"; +import { getErrorMessage, quoteIfNeeded, resolvePathWithinRoot } from "../shared/utils"; function resolveAutomationCwdBase( projectRoot: string, @@ -330,6 +332,7 @@ async function runCodexExec(args: { cwd: string; prompt: string; schema: Record; + logger: Logger; sandbox: "read-only" | "workspace-write" | "danger-full-access"; askForApproval: "untrusted" | "on-failure" | "on-request" | "never"; webSearch: boolean; @@ -369,9 +372,23 @@ async function runCodexExec(args: { cliArgs.push(args.prompt); - const commandPreview = ["codex", ...cliArgs.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a))].join(" "); + let codexExecutable: string; + try { + const resolvedCodexExecutable = resolveCodexExecutable(); + if (!resolvedCodexExecutable) { + throw new Error("Codex executable could not be resolved."); + } + codexExecutable = resolvedCodexExecutable.path; + } catch (error) { + args.logger.error("automations.planner.codex_executable_resolution_failed", { + cwd: args.cwd, + error: getErrorMessage(error), + }); + throw error; + } + const commandPreview = [quoteIfNeeded(codexExecutable), ...cliArgs.map(quoteIfNeeded)].join(" "); - const child = spawn("codex", cliArgs, { + const child = spawn(codexExecutable, cliArgs, { cwd: args.cwd, env: { ...process.env, @@ -449,9 +466,10 @@ async function runClaudeHeadless(args: { cliArgs.push(args.prompt); - const commandPreview = ["claude", ...cliArgs.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a))].join(" "); + const claudeExecutable = resolveClaudeCodeExecutable().path; + const commandPreview = [quoteIfNeeded(claudeExecutable), ...cliArgs.map(quoteIfNeeded)].join(" "); - const child = spawn("claude", cliArgs, { + const child = spawn(claudeExecutable, cliArgs, { cwd: args.cwd, env: { ...process.env, @@ -932,6 +950,7 @@ export function createAutomationPlannerService({ cwd: projectRoot, prompt, schema, + logger, sandbox: cfg.sandbox, askForApproval: cfg.askForApproval, webSearch: cfg.webSearch, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 8e078e184..ceede8d07 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -39,6 +39,7 @@ import type { createProcessService } from "../processes/processService"; import { runGit } from "../git/git"; import { CLAUDE_RUNTIME_AUTH_ERROR, isClaudeRuntimeAuthError } from "../ai/claudeRuntimeProbe"; import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; +import { resolveCodexExecutable } from "../ai/codexExecutable"; import { fileSizeOrZero, isEnoentError, nowIso, readFileWithinRootSecure, resolvePathWithinRoot } from "../shared/utils"; import type { EpisodicSummaryService } from "../memory/episodicSummaryService"; import { @@ -6571,7 +6572,21 @@ export function createAgentChatService(args: { path: process.env.PATH ?? "", ...(adeMcpLaunch ? { adeMcpLaunch } : {}), }); - const proc = spawn("codex", ["app-server"], { + let codexExecutable: string; + try { + codexExecutable = resolveCodexExecutable().path; + if (!codexExecutable) { + throw new Error("Codex executable path was empty."); + } + } catch (error) { + logger.error("Failed to resolve Codex executable for spawn in agentChatService (resolveCodexExecutable)", { + sessionId: managed.session.id, + cwd: managed.laneWorktreePath, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + const proc = spawn(codexExecutable, ["app-server"], { cwd: managed.laneWorktreePath, stdio: ["pipe", "pipe", "pipe"] }); diff --git a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts index 67b8fd691..75299fd19 100644 --- a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts +++ b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createWorkerAdapterRuntimeService } from "./workerAdapterRuntimeService"; import type { AgentIdentity } from "../../../shared/types"; @@ -107,7 +108,7 @@ describe("workerAdapterRuntimeService", () => { prompt: "fix this", }); - expect(capture.command).toBe("codex"); + expect(path.basename(capture.command)).toBe("codex"); expect(capture.args).toEqual(["--model", "gpt-5.3-codex", "--json"]); expect(result.ok).toBe(true); expect(result.effectiveSurface).toBe("process"); diff --git a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts index 1fb777bfe..2cb790605 100644 --- a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts +++ b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts @@ -5,6 +5,7 @@ import type { WorkerContinuationHandle, WorkerRuntimeSurface, } from "../../../shared/types"; +import { resolveCodexExecutable } from "../ai/codexExecutable"; import type { createAgentChatService } from "../chat/agentChatService"; type WorkerAdapterRuntimeServiceArgs = { @@ -346,7 +347,7 @@ export function createWorkerAdapterRuntimeService(args: WorkerAdapterRuntimeServ } if (adapterType === "claude-local" || adapterType === "codex-local") { - const binary = adapterType === "claude-local" ? "claude" : "codex"; + const binary = adapterType === "claude-local" ? "claude" : resolveCodexExecutable().path; const model = typeof config.model === "string" && config.model.trim().length ? config.model.trim() : typeof config.modelId === "string" && config.modelId.trim().length diff --git a/apps/desktop/src/main/services/devTools/devToolsService.test.ts b/apps/desktop/src/main/services/devTools/devToolsService.test.ts new file mode 100644 index 000000000..05452fd65 --- /dev/null +++ b/apps/desktop/src/main/services/devTools/devToolsService.test.ts @@ -0,0 +1,71 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Logger } from "../logging/logger"; +import type * as SharedUtilsModule from "../shared/utils"; + +const { + spawnAsyncMock, + whichCommandMock, + resolveExecutableFromKnownLocationsMock, +} = vi.hoisted(() => ({ + spawnAsyncMock: vi.fn(), + whichCommandMock: vi.fn(), + resolveExecutableFromKnownLocationsMock: vi.fn(), +})); + +vi.mock("../shared/utils", async () => { + const actual = await vi.importActual("../shared/utils"); + return { + ...actual, + spawnAsync: spawnAsyncMock, + whichCommand: whichCommandMock, + }; +}); + +vi.mock("../ai/cliExecutableResolver", () => ({ + resolveExecutableFromKnownLocations: resolveExecutableFromKnownLocationsMock, +})); + +import { createDevToolsService } from "./devToolsService"; + +function createLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe("devToolsService", () => { + beforeEach(() => { + spawnAsyncMock.mockReset(); + whichCommandMock.mockReset(); + resolveExecutableFromKnownLocationsMock.mockReset(); + }); + + it("detects GitHub CLI from known install locations and reads version via the resolved path", async () => { + resolveExecutableFromKnownLocationsMock.mockImplementation((command: string) => { + if (command === "git") return { path: "/usr/bin/git", source: "path" }; + if (command === "gh") return { path: "/opt/homebrew/bin/gh", source: "known-dir" }; + return null; + }); + spawnAsyncMock.mockImplementation(async (command: string) => ({ + status: 0, + stdout: `${path.basename(command)} version 1.0.0\n`, + stderr: "", + })); + + const service = createDevToolsService({ logger: createLogger() }); + const result = await service.detect(true); + const gh = result.tools.find((tool) => tool.id === "gh"); + + expect(gh).toMatchObject({ + installed: true, + detectedPath: "/opt/homebrew/bin/gh", + detectedVersion: "gh version 1.0.0", + }); + expect(spawnAsyncMock).toHaveBeenCalledWith("/opt/homebrew/bin/gh", ["--version"]); + expect(whichCommandMock).not.toHaveBeenCalledWith("gh"); + }); +}); diff --git a/apps/desktop/src/main/services/devTools/devToolsService.ts b/apps/desktop/src/main/services/devTools/devToolsService.ts index 1a1d47ac6..dda43cc3f 100644 --- a/apps/desktop/src/main/services/devTools/devToolsService.ts +++ b/apps/desktop/src/main/services/devTools/devToolsService.ts @@ -1,6 +1,7 @@ import type { DevToolStatus, DevToolsCheckResult } from "../../../shared/types/devTools"; import type { Logger } from "../logging/logger"; import { firstLine, spawnAsync, whichCommand } from "../shared/utils"; +import { resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; type ToolSpec = { id: "git" | "gh"; @@ -15,9 +16,9 @@ const TOOL_SPECS: ToolSpec[] = [ { id: "gh", label: "GitHub CLI", command: "gh", versionArgs: ["--version"], required: false }, ]; -async function readVersion(spec: ToolSpec): Promise { +async function readVersion(commandPath: string, versionArgs: string[]): Promise { try { - const res = await spawnAsync(spec.command, spec.versionArgs); + const res = await spawnAsync(commandPath, versionArgs); const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`.trim(); const line = firstLine(out); return line.length ? line.slice(0, 160) : null; @@ -27,9 +28,10 @@ async function readVersion(spec: ToolSpec): Promise { } async function detectOneTool(spec: ToolSpec): Promise { - const detectedPath = await whichCommand(spec.command); + const detectedPath = resolveExecutableFromKnownLocations(spec.command)?.path + ?? await whichCommand(spec.command); const installed = Boolean(detectedPath); - const detectedVersion = installed ? await readVersion(spec) : null; + const detectedVersion = detectedPath ? await readVersion(detectedPath, spec.versionArgs) : null; return { id: spec.id, label: spec.label, diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index a4c489a0a..191b86bb4 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -5,6 +5,7 @@ import type { Logger } from "../logging/logger"; import { runGit } from "../git/git"; import type { GitHubRepoRef, GitHubStatus } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; +import { parseGitHubScopeHeaders } from "../../../shared/githubScopes"; import { nowIso, asString } from "../shared/utils"; @@ -176,10 +177,7 @@ export function createGithubService({ } }); - const scopes = (response.headers.get("x-oauth-scopes") ?? "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + const scopes = parseGitHubScopeHeaders(response.headers); const payload = (await response.json().catch(() => ({}))) as Record; if (!response.ok) { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 7b6d3e76f..301cf14d1 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -311,6 +311,8 @@ import type { AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, + MemoryHealthScope, + MemoryHealthStats, SyncDesktopConnectionDraft, SyncDeviceRecord, SyncDeviceRuntimeState, @@ -757,7 +759,6 @@ function toRecentProjectSummary(entry: { rootPath: string; displayName: string; type MemoryScope = "user" | "project" | "lane" | "mission"; type UnifiedMemoryScope = "project" | "agent" | "mission"; -type MemoryHealthScope = "project" | "agent" | "mission"; type MemoryHealthCountRow = { scope: string | null; @@ -814,8 +815,6 @@ function normalizeUnifiedMemoryScope(rawScope: unknown): UnifiedMemoryScope | un if (trimmed === "mission" || trimmed === "lane") return "mission"; return undefined; } - - function normalizeMemoryHealthScope(rawScope: unknown): MemoryHealthScope | null { const trimmed = typeof rawScope === "string" ? rawScope.trim() : ""; if (trimmed === "project") return "project"; @@ -824,7 +823,23 @@ function normalizeMemoryHealthScope(rawScope: unknown): MemoryHealthScope | null return null; } -function createEmptyMemoryHealthStats() { +type MemoryHealthModelStatus = MemoryHealthStats["embeddings"]["model"]; + +function createEmptyMemoryHealthStats(): MemoryHealthStats { + const model: MemoryHealthModelStatus = { + modelId: "Xenova/all-MiniLM-L6-v2", + state: "idle", + activity: "idle", + installState: "missing", + cacheDir: null, + installPath: null, + progress: null, + loaded: null, + total: null, + file: null, + error: null, + }; + return { scopes: MEMORY_HEALTH_SCOPES.map((scope) => ({ scope, @@ -849,73 +864,8 @@ function createEmptyMemoryHealthStats() { cacheHits: 0, cacheMisses: 0, cacheHitRate: 0, - model: { - modelId: "Xenova/all-MiniLM-L6-v2", - state: "idle" as const, - progress: null, - loaded: null, - total: null, - file: null, - error: null, - }, + model, }, - } as { - scopes: Array<{ - scope: MemoryHealthScope; - current: number; - max: number; - counts: { - tier1: number; - tier2: number; - tier3: number; - archived: number; - }; - }>; - lastSweep: { - sweepId: string; - projectId: string; - reason: "manual" | "startup"; - startedAt: string; - completedAt: string; - entriesDecayed: number; - entriesDemoted: number; - entriesPromoted: number; - entriesArchived: number; - entriesOrphaned: number; - durationMs: number; - } | null; - lastConsolidation: { - consolidationId: string; - projectId: string; - reason: "manual" | "auto"; - startedAt: string; - completedAt: string; - clustersFound: number; - entriesMerged: number; - entriesCreated: number; - tokensUsed: number; - durationMs: number; - } | null; - embeddings: { - entriesEmbedded: number; - entriesTotal: number; - queueDepth: number; - processing: boolean; - lastBatchProcessedAt: string | null; - cacheEntries: number; - cacheHits: number; - cacheMisses: number; - cacheHitRate: number; - model: { - modelId: string; - state: "idle" | "loading" | "ready" | "unavailable"; - progress: number | null; - loaded: number | null; - total: number | null; - file: string | null; - error: string | null; - }; - }; }; } @@ -991,6 +941,10 @@ function getMemoryHealthStats(ctx: AppContext) { model: { modelId: embeddingStatus?.modelId ?? "Xenova/all-MiniLM-L6-v2", state: embeddingStatus?.state ?? "idle", + activity: embeddingStatus?.activity ?? "idle", + installState: embeddingStatus?.installState ?? "missing", + cacheDir: embeddingStatus?.cacheDir ?? null, + installPath: embeddingStatus?.installPath ?? null, progress: embeddingStatus?.progress ?? null, loaded: embeddingStatus?.loaded ?? null, total: embeddingStatus?.total ?? null, @@ -5451,7 +5405,9 @@ export function registerIpc({ if (!ctx.embeddingService?.preload) { throw new Error("Embedding service is not available."); } - void ctx.embeddingService.preload({ forceRetry: true }).catch(() => { + const embeddingStatus = ctx.embeddingService.getStatus(); + const localFilesOnly = embeddingStatus.installState === "installed" && embeddingStatus.state !== "unavailable"; + void ctx.embeddingService.preload({ forceRetry: true, localFilesOnly }).catch(() => { // Health polling will pick up the unavailable state; the click itself should remain responsive. }); return getMemoryHealthStats(ctx); diff --git a/apps/desktop/src/main/services/memory/embeddingService.test.ts b/apps/desktop/src/main/services/memory/embeddingService.test.ts index f247a1109..8faf18f6f 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.test.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createEmbeddingService, @@ -27,6 +30,22 @@ function buildVector(seed: string): Float32Array { return vector; } +function createTempCacheDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "ade-embedding-cache-")); +} + +function writeInstalledModel(cacheDir: string) { + const modelDir = path.join(cacheDir, "Xenova", "all-MiniLM-L6-v2"); + fs.mkdirSync(path.join(modelDir, "onnx"), { recursive: true }); + fs.writeFileSync(path.join(modelDir, "config.json"), "{}"); + fs.writeFileSync(path.join(modelDir, "tokenizer.json"), "{}"); + fs.writeFileSync(path.join(modelDir, "tokenizer_config.json"), "{}"); + fs.writeFileSync(path.join(modelDir, "onnx", "model.onnx"), "model"); + return modelDir; +} + +type ProgressCallback = (event: { file?: string; progress?: number; loaded?: number; total?: number }) => void; + describe("embeddingService", () => { it("loads the MiniLM pipeline on first use and returns a 384-d embedding", async () => { const logger = createLogger(); @@ -52,6 +71,16 @@ describe("embeddingService", () => { const embedding = await service.embed("Memory embeddings stay local."); expect(loadRuntime).toHaveBeenCalledTimes(1); + expect(extractor).toHaveBeenNthCalledWith( + 1, + "ADE embedding verification probe", + expect.objectContaining({ pooling: "mean", normalize: true }), + ); + expect(extractor).toHaveBeenNthCalledWith( + 2, + "Memory embeddings stay local.", + expect.objectContaining({ pooling: "mean", normalize: true }), + ); expect(pipeline).toHaveBeenCalledWith( DEFAULT_EMBEDDING_TASK, DEFAULT_EMBEDDING_MODEL_ID, @@ -103,7 +132,7 @@ describe("embeddingService", () => { const second = await service.embed("same content"); const third = await service.embed("different content"); - expect(extractor).toHaveBeenCalledTimes(2); + expect(extractor).toHaveBeenCalledTimes(3); expect(Array.from(second)).toEqual(Array.from(first)); expect(Array.from(third)).not.toEqual(Array.from(first)); expect(service.hashContent("same content")).toBe(service.hashContent("same content")); @@ -151,4 +180,408 @@ describe("embeddingService", () => { expect.objectContaining({ error: "transformers bootstrap failed" }), ); }); + + it("keeps the model unavailable when the smoke-test inference fails", async () => { + const logger = createLogger(); + const cacheDir = createTempCacheDir(); + writeInstalledModel(cacheDir); + const extractor = Object.assign( + vi.fn(async () => ({ data: new Float32Array(EXPECTED_EMBEDDING_DIMENSIONS), dims: [1, EXPECTED_EMBEDDING_DIMENSIONS] })), + { dispose: vi.fn(async () => {}) }, + ); + const service = createEmbeddingService({ + logger, + cacheDir, + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline: vi.fn(async () => extractor), + }), + }); + + await expect(service.preload({ forceRetry: true, localFilesOnly: true })).rejects.toThrow( + "The installed local model files are incompatible or corrupted. Download the model again to repair the cache.", + ); + + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "unavailable", + activity: "error", + installState: "installed", + error: "The installed local model files are incompatible or corrupted. Download the model again to repair the cache.", + })); + }); + + it("reports an installed local model path and loads from local cache during probe", async () => { + const logger = createLogger(); + const cacheDir = createTempCacheDir(); + const installPath = writeInstalledModel(cacheDir); + const extractor = Object.assign( + vi.fn(async (text: string) => ({ data: buildVector(text), dims: [1, EXPECTED_EMBEDDING_DIMENSIONS] })), + { dispose: vi.fn(async () => {}) }, + ); + const pipeline = vi.fn(async (_task, _model, options?: { progress_callback?: (event: { file?: string; progress?: number }) => void }) => { + options?.progress_callback?.({ file: "tokenizer.json", progress: 100 }); + return extractor; + }); + + const service = createEmbeddingService({ + logger, + cacheDir, + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline, + }), + }); + + expect(service.getStatus()).toEqual(expect.objectContaining({ + installState: "installed", + installPath, + activity: "idle", + state: "idle", + })); + + await service.probeCache(); + + expect(pipeline).toHaveBeenCalledTimes(1); + expect(pipeline.mock.calls[0]?.[1]).toBe(installPath); + expect(pipeline.mock.calls[0]?.[2]).toBeDefined(); + expect(service.getStatus()).toEqual(expect.objectContaining({ + installState: "installed", + installPath, + activity: "ready", + state: "ready", + })); + }); + + it("does not auto-download from a partial cache during startup probing", async () => { + const logger = createLogger(); + const cacheDir = createTempCacheDir(); + const modelDir = path.join(cacheDir, "Xenova", "all-MiniLM-L6-v2"); + fs.mkdirSync(modelDir, { recursive: true }); + fs.writeFileSync(path.join(modelDir, "tokenizer.json"), "{}"); + const pipeline = vi.fn(async () => { + throw new Error("pipeline should not run for partial installs"); + }); + + const service = createEmbeddingService({ + logger, + cacheDir, + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline, + }), + }); + + await service.probeCache(); + + expect(pipeline).not.toHaveBeenCalled(); + expect(service.getStatus()).toEqual(expect.objectContaining({ + installState: "partial", + installPath: modelDir, + activity: "idle", + state: "idle", + })); + }); + + it("reports loading-local while a fully installed model is still initializing", async () => { + const logger = createLogger(); + const cacheDir = createTempCacheDir(); + const installPath = writeInstalledModel(cacheDir); + let releasePipeline: (() => void) | null = null; + let resolvePipelineStarted: (() => void) | null = null; + const pipelineStarted = new Promise((resolve) => { + resolvePipelineStarted = resolve; + }); + const pipeline = vi.fn(async () => { + resolvePipelineStarted?.(); + await new Promise((resolve) => { + releasePipeline = resolve; + }); + return Object.assign( + vi.fn(async (text: string) => ({ data: buildVector(text), dims: [1, EXPECTED_EMBEDDING_DIMENSIONS] })), + { dispose: vi.fn(async () => {}) }, + ); + }); + + const service = createEmbeddingService({ + logger, + cacheDir, + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline, + }), + }); + + const preloadPromise = service.preload({ forceRetry: true }); + await pipelineStarted; + + expect(service.getStatus()).toEqual(expect.objectContaining({ + installState: "installed", + installPath, + state: "loading", + activity: "loading-local", + })); + + expect(releasePipeline).toBeTypeOf("function"); + releasePipeline!(); + await preloadPromise; + }); + + it("does not revert back to loading when stale progress events arrive after a load failure", async () => { + const logger = createLogger(); + let capturedProgress: ProgressCallback | null = null; + const service = createEmbeddingService({ + logger, + cacheDir: createTempCacheDir(), + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline: vi.fn(async (_task, _model, options) => { + capturedProgress = options?.progress_callback ?? null; + throw new Error("Protobuf parsing failed"); + }), + }), + }); + + await expect(service.preload({ forceRetry: true })).rejects.toThrow("Protobuf parsing failed"); + + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "unavailable", + activity: "error", + error: "Protobuf parsing failed", + })); + + if (capturedProgress) { + (capturedProgress as ProgressCallback)({ file: "tokenizer.json", progress: 100, loaded: 711661, total: 711661 }); + } + + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "unavailable", + activity: "error", + error: "Protobuf parsing failed", + })); + }); + + it("ignores stale progress callbacks from an earlier load attempt after forceRetry", async () => { + const logger = createLogger(); + let firstProgress: ProgressCallback | null = null; + let secondProgress: ProgressCallback | null = null; + let releaseSecondAttempt: (() => void) | null = null; + let resolveSecondStarted: (() => void) | null = null; + const secondStarted = new Promise((resolve) => { + resolveSecondStarted = resolve; + }); + const extractor = Object.assign( + vi.fn(async (text: string) => ({ data: buildVector(text), dims: [1, EXPECTED_EMBEDDING_DIMENSIONS] })), + { dispose: vi.fn(async () => {}) }, + ); + const loadRuntime = vi + .fn() + .mockResolvedValueOnce({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline: vi.fn(async (_task, _model, options) => { + firstProgress = options?.progress_callback ?? null; + throw new Error("first attempt failed"); + }), + }) + .mockResolvedValueOnce({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline: vi.fn(async (_task, _model, options) => { + secondProgress = options?.progress_callback ?? null; + resolveSecondStarted?.(); + await new Promise((resolve) => { + releaseSecondAttempt = resolve; + }); + return extractor; + }), + }); + + const service = createEmbeddingService({ + logger, + cacheDir: createTempCacheDir(), + loadRuntime, + }); + + await expect(service.preload({ forceRetry: true })).rejects.toThrow("first attempt failed"); + + const secondAttempt = service.preload({ forceRetry: true }); + await secondStarted; + + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "loading", + activity: "downloading", + progress: 0, + file: null, + })); + + expect(firstProgress).toBeTypeOf("function"); + const staleProgress = firstProgress as unknown as ProgressCallback; + staleProgress({ file: "stale-tokenizer.json", progress: 97, loaded: 97, total: 100 }); + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "loading", + activity: "downloading", + progress: 0, + file: null, + })); + + expect(secondProgress).toBeTypeOf("function"); + const currentProgress = secondProgress as unknown as ProgressCallback; + currentProgress({ file: "current-tokenizer.json", progress: 12, loaded: 12, total: 100 }); + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "loading", + activity: "downloading", + progress: 12, + file: "current-tokenizer.json", + })); + + expect(releaseSecondAttempt).toBeTypeOf("function"); + releaseSecondAttempt!(); + await secondAttempt; + }); + + it("rejects an in-flight load that finishes after dispose and keeps the service idle", async () => { + const logger = createLogger(); + let releasePipeline: (() => void) | null = null; + let resolvePipelineStarted: (() => void) | null = null; + const pipelineStarted = new Promise((resolve) => { + resolvePipelineStarted = resolve; + }); + const extractor = Object.assign( + vi.fn(async (text: string) => ({ data: buildVector(text), dims: [1, EXPECTED_EMBEDDING_DIMENSIONS] })), + { dispose: vi.fn(async () => {}) }, + ); + const service = createEmbeddingService({ + logger, + cacheDir: createTempCacheDir(), + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline: vi.fn(async () => { + resolvePipelineStarted?.(); + await new Promise((resolve) => { + releasePipeline = resolve; + }); + return extractor; + }), + }), + }); + + const preloadPromise = service.preload({ forceRetry: true }); + await pipelineStarted; + + await service.dispose(); + + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "idle", + activity: "idle", + progress: null, + error: null, + })); + + expect(releasePipeline).toBeTypeOf("function"); + releasePipeline!(); + await expect(preloadPromise).rejects.toThrow("Embedding extractor load became stale."); + + expect(extractor.dispose).toHaveBeenCalledTimes(1); + expect(service.isAvailable()).toBe(false); + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "idle", + activity: "idle", + progress: null, + error: null, + })); + }); + + it("re-checks the install state after a failed download before normalizing the error", async () => { + const logger = createLogger(); + const cacheDir = createTempCacheDir(); + + const service = createEmbeddingService({ + logger, + cacheDir, + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline: vi.fn(async () => { + writeInstalledModel(cacheDir); + return Object.assign( + vi.fn(async () => ({ + data: new Float32Array(EXPECTED_EMBEDDING_DIMENSIONS - 1), + dims: [1, EXPECTED_EMBEDDING_DIMENSIONS - 1], + })), + { + dispose: vi.fn(async () => {}), + }, + ); + }), + }), + }); + + await expect(service.preload({ forceRetry: true })).rejects.toThrow( + "The installed local model files are incompatible or corrupted. Download the model again to repair the cache.", + ); + + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "unavailable", + error: "The installed local model files are incompatible or corrupted. Download the model again to repair the cache.", + })); + }); + + it("rejects model IDs that escape the cache directory", () => { + expect(() => createEmbeddingService({ + logger: createLogger(), + cacheDir: createTempCacheDir(), + modelId: "../outside", + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline: vi.fn(), + }), + })).toThrow("Invalid embedding model ID segment: .."); + }); }); diff --git a/apps/desktop/src/main/services/memory/embeddingService.ts b/apps/desktop/src/main/services/memory/embeddingService.ts index 536804132..f1d1cad2d 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.ts @@ -8,6 +8,13 @@ import { getErrorMessage } from "../shared/utils"; export const DEFAULT_EMBEDDING_TASK = "feature-extraction" as const; export const DEFAULT_EMBEDDING_MODEL_ID = "Xenova/all-MiniLM-L6-v2"; export const EXPECTED_EMBEDDING_DIMENSIONS = 384; +const EMBEDDING_SMOKE_TEST_INPUT = "ADE embedding verification probe"; +const REQUIRED_MODEL_FILES = [ + "config.json", + "tokenizer.json", + "tokenizer_config.json", + path.join("onnx", "model.onnx"), +] as const; type EmbeddingProgressEvent = { status?: string; @@ -37,14 +44,17 @@ type TransformersRuntime = { pipeline: ( task: typeof DEFAULT_EMBEDDING_TASK, model: string, - options?: { progress_callback?: (event: EmbeddingProgressEvent) => void }, + options?: { progress_callback?: (event: EmbeddingProgressEvent) => void; local_files_only?: boolean }, ) => Promise; }; export type EmbeddingServiceStatus = { modelId: string; cacheDir: string; + installPath: string; + installState: "missing" | "partial" | "installed"; state: "idle" | "loading" | "ready" | "unavailable"; + activity: "idle" | "loading-local" | "downloading" | "ready" | "error"; progress: number | null; loaded: number | null; total: number | null; @@ -75,6 +85,77 @@ function resolveCacheDir(cacheDir?: string): string { return path.resolve(path.join(app.getPath("userData"), "transformers-cache")); } +function resolveInstallPath(cacheDir: string, modelId: string): string { + const resolvedCacheDir = path.resolve(cacheDir); + const segments = modelId.split(/[\\/]/); + const safeSegments: string[] = []; + + for (const segment of segments) { + if (!segment || segment === "." || segment === ".." || path.isAbsolute(segment)) { + throw new Error(`Invalid embedding model ID segment: ${segment || "(empty)"}`); + } + safeSegments.push(segment); + } + + const installPath = path.resolve(resolvedCacheDir, ...safeSegments); + const cacheDirPrefix = resolvedCacheDir.endsWith(path.sep) + ? resolvedCacheDir + : `${resolvedCacheDir}${path.sep}`; + if (installPath !== resolvedCacheDir && !installPath.startsWith(cacheDirPrefix)) { + throw new Error(`Embedding model install path escaped the cache dir: ${modelId}`); + } + return installPath; +} + +function inspectInstallPath(installPath: string): { + installState: EmbeddingServiceStatus["installState"]; +} { + if (!fs.existsSync(installPath)) { + return { installState: "missing" }; + } + + const presentRequiredFiles = REQUIRED_MODEL_FILES.filter((relativePath) => + fs.existsSync(path.join(installPath, relativePath)), + ); + + if (presentRequiredFiles.length === REQUIRED_MODEL_FILES.length) { + return { installState: "installed" }; + } + + return { installState: "partial" }; +} + +function deriveReportedActivity(args: { + state: EmbeddingServiceStatus["state"]; + activity: EmbeddingServiceStatus["activity"]; + installState: EmbeddingServiceStatus["installState"]; +}): EmbeddingServiceStatus["activity"] { + if (args.state === "ready") return "ready"; + if (args.state === "unavailable") return "error"; + if (args.state !== "loading") return "idle"; + if (args.installState === "installed") return "loading-local"; + if (args.activity === "loading-local" || args.activity === "downloading") return args.activity; + return "downloading"; +} + +function normalizeLoadError(args: { + message: string; + installState: EmbeddingServiceStatus["installState"]; + localFilesOnly: boolean; +}): string { + const message = args.message.trim(); + if ((args.localFilesOnly || args.installState === "installed") && /protobuf parsing failed/i.test(message)) { + return "The installed local model files are corrupted. Download the model again to repair the cache."; + } + if ( + (args.localFilesOnly || args.installState === "installed") + && (/expected 384 embedding/i.test(message) || /embedding output/i.test(message)) + ) { + return "The installed local model files are incompatible or corrupted. Download the model again to repair the cache."; + } + return message; +} + function cloneVector(vector: Float32Array): Float32Array { return new Float32Array(vector); } @@ -113,6 +194,17 @@ function validateVector(vector: Float32Array, dims?: readonly number[]): Float32 return vector; } +async function runExtractorSmokeTest(activeExtractor: EmbeddingExtractor): Promise { + const output = await activeExtractor(EMBEDDING_SMOKE_TEST_INPUT, { + pooling: "mean", + normalize: true, + }); + validateVector( + toFloat32Array((output as EmbeddingTensorLike)?.data ?? (output as ArrayLike)), + (output as EmbeddingTensorLike)?.dims, + ); +} + async function loadTransformersRuntime(): Promise { return await import("@huggingface/transformers") as unknown as TransformersRuntime; } @@ -127,6 +219,7 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { const logger = opts.logger; const modelId = opts.modelId ?? DEFAULT_EMBEDDING_MODEL_ID; const cacheDir = resolveCacheDir(opts.cacheDir); + const installPath = resolveInstallPath(cacheDir, modelId); const loadRuntime = opts.loadRuntime ?? loadTransformersRuntime; fs.mkdirSync(cacheDir, { recursive: true }); @@ -138,16 +231,31 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { let extractorPromise: Promise | null = null; let lastError: string | null = null; let state: EmbeddingServiceStatus["state"] = "idle"; + let activity: EmbeddingServiceStatus["activity"] = "idle"; let progress: number | null = null; let loaded: number | null = null; let total: number | null = null; let file: string | null = null; + let cachedInstall = inspectInstallPath(installPath); + let loadAttemptId = 0; + + function refreshCachedInstall() { + cachedInstall = inspectInstallPath(installPath); + return cachedInstall; + } function getStatus(): EmbeddingServiceStatus { return { modelId, cacheDir, + installPath, + installState: cachedInstall.installState, state, + activity: deriveReportedActivity({ + state, + activity, + installState: cachedInstall.installState, + }), progress, loaded, total, @@ -175,7 +283,21 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { return typeof value === "number" && Number.isFinite(value) ? value : current; } - function handleProgress(event: EmbeddingProgressEvent) { + function isCurrentLoadAttempt(attemptId: number): boolean { + return attemptId === loadAttemptId; + } + + function handleProgress(event: EmbeddingProgressEvent, attemptId: number) { + if (!isCurrentLoadAttempt(attemptId)) { + return; + } + // Transformers.js may emit late file progress events even after the model + // session creation has already failed. Do not let those stale events revive + // the service back into a loading state. + if (state === "unavailable" || activity === "error") { + return; + } + progress = finiteOrKeep(event.progress, progress); loaded = finiteOrKeep(event.loaded, loaded); total = finiteOrKeep(event.total, total); @@ -189,11 +311,39 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { emitStatus(); } - async function ensureExtractor(forceRetry = false): Promise { + function createStaleLoadError(): Error { + return new Error("Embedding extractor load became stale."); + } + + async function disposeExtractorSafely( + candidate: EmbeddingExtractor | null, + logEvent: "memory.embedding.dispose_failed_after_smoke_test" | "memory.embedding.dispose_failed_after_stale_load", + ) { + if (!candidate?.dispose) return; + try { + await candidate.dispose(); + } catch (disposeError) { + logger.warn(logEvent, { + modelId, + cacheDir, + error: getErrorMessage(disposeError), + }); + } + } + + async function ensureExtractor(opts: { + forceRetry?: boolean; + localFilesOnly?: boolean; + installInspection?: ReturnType; + } = {}): Promise { + const forceRetry = opts.forceRetry === true; + const localFilesOnly = opts.localFilesOnly === true; if (extractor) return extractor; if (extractorPromise) return extractorPromise; if (forceRetry) { + loadAttemptId += 1; state = "idle"; + activity = "idle"; lastError = null; progress = null; loaded = null; @@ -204,7 +354,13 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { throw new EmbeddingUnavailableError(lastError); } + if (!forceRetry) { + loadAttemptId += 1; + } + const attemptId = loadAttemptId; + const install = opts.installInspection ?? refreshCachedInstall(); state = "loading"; + activity = localFilesOnly || install.installState === "installed" ? "loading-local" : "downloading"; progress = 0; loaded = null; total = null; @@ -217,15 +373,43 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { const runtime = await loadRuntime(); runtime.env.cacheDir = cacheDir; runtime.env.allowLocalModels = true; - runtime.env.allowRemoteModels = true; + runtime.env.allowRemoteModels = !localFilesOnly; runtime.env.useFSCache = true; - const nextExtractor = await runtime.pipeline(DEFAULT_EMBEDDING_TASK, modelId, { - progress_callback: handleProgress, - }); - - extractor = nextExtractor; + let nextExtractor: EmbeddingExtractor | null = null; + try { + const loadedExtractor = await runtime.pipeline( + DEFAULT_EMBEDDING_TASK, + localFilesOnly ? installPath : modelId, + { + progress_callback: (event) => handleProgress(event, attemptId), + local_files_only: localFilesOnly, + }, + ); + nextExtractor = loadedExtractor; + + await runExtractorSmokeTest(loadedExtractor); + cachedInstall = localFilesOnly ? install : refreshCachedInstall(); + if (!isCurrentLoadAttempt(attemptId)) { + await disposeExtractorSafely(loadedExtractor, "memory.embedding.dispose_failed_after_stale_load"); + nextExtractor = null; + throw createStaleLoadError(); + } + extractor = loadedExtractor; + } catch (error) { + await disposeExtractorSafely(nextExtractor, "memory.embedding.dispose_failed_after_smoke_test"); + throw error; + } + // Defensive guard in case a future async refactor makes isCurrentLoadAttempt stale after assigning nextExtractor, so disposeExtractorSafely still cleans up before createStaleLoadError. + if (!isCurrentLoadAttempt(attemptId)) { + if (extractor === nextExtractor) { + extractor = null; + } + await disposeExtractorSafely(nextExtractor, "memory.embedding.dispose_failed_after_stale_load"); + throw createStaleLoadError(); + } state = "ready"; + activity = "ready"; progress = 100; emitStatus(); @@ -238,10 +422,19 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { return nextExtractor; })().catch((error) => { + if (!isCurrentLoadAttempt(attemptId)) { + throw error; + } extractorPromise = null; extractor = null; state = "unavailable"; - lastError = getErrorMessage(error); + activity = "error"; + const freshInstall = refreshCachedInstall(); + lastError = normalizeLoadError({ + message: getErrorMessage(error), + installState: freshInstall.installState, + localFilesOnly, + }); progress = null; loaded = null; total = null; @@ -285,16 +478,25 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { } async function dispose() { + loadAttemptId += 1; const activeExtractor = extractor; extractor = null; extractorPromise = null; + state = "idle"; + activity = "idle"; + lastError = null; + progress = null; + loaded = null; + total = null; + file = null; + emitStatus(); if (activeExtractor?.dispose) { await activeExtractor.dispose(); } } - async function preload(opts: { forceRetry?: boolean } = {}): Promise { - await ensureExtractor(opts.forceRetry === true); + async function preload(opts: { forceRetry?: boolean; localFilesOnly?: boolean } = {}): Promise { + await ensureExtractor({ forceRetry: opts.forceRetry === true, localFilesOnly: opts.localFilesOnly === true }); } /** @@ -305,12 +507,18 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { async function probeCache(): Promise { if (state === "ready" || state === "loading") return; try { - // The HuggingFace transformers cache stores model files in a subdirectory - // If the cache dir has files, attempt a (fast, local-only) load - const entries = fs.readdirSync(cacheDir); - if (entries.length === 0) return; - logger.info("memory.embedding.probe_cache", { modelId, cacheDir, entries: entries.length }); - await ensureExtractor(); + const install = refreshCachedInstall(); + if (install.installState !== "installed") { + logger.info("memory.embedding.probe_cache_skipped", { + modelId, + cacheDir, + installPath, + installState: install.installState, + }); + return; + } + logger.info("memory.embedding.probe_cache", { modelId, cacheDir, installPath }); + await ensureExtractor({ localFilesOnly: true, installInspection: install }); } catch (error) { // Probe is best-effort — don't block startup logger.warn("memory.embedding.probe_cache_failed", { diff --git a/apps/desktop/src/main/services/shared/utils.ts b/apps/desktop/src/main/services/shared/utils.ts index 7da46af39..84408a44b 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -113,7 +113,10 @@ export async function whichCommand(command: string): Promise { const line = firstLine(res.stdout ?? ""); return line.length ? line : null; } - const res = await spawnAsync("sh", ["-lc", 'command -v "$1" 2>/dev/null || true', "--", command]); + // Always use a POSIX shell here so user-configured shells cannot change the + // semantics of the `command -v` lookup. + const lookupShell = "/bin/sh"; + const res = await spawnAsync(lookupShell, ["-lc", 'command -v "$1" 2>/dev/null || true', "--", command]); const line = firstLine(res.stdout ?? ""); return line.length ? line : null; } catch { @@ -623,6 +626,10 @@ export function normalizeSet(values: string[] | undefined): Set { return new Set((values ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)); } +export function quoteIfNeeded(value: string): string { + return /\s/.test(value) ? JSON.stringify(value) : value; +} + // ── Template rendering helpers ────────────────────────────────────── /** Walk a dotted path like "a.b.c" into a nested object. */ diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 7f54e47d0..525c0ba22 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -61,6 +61,10 @@ function createMockMemoryHealthStats(overrides: Partial = {}): any { model: { modelId: "Xenova/all-MiniLM-L6-v2", state: "idle", + activity: "idle", + installState: "missing", + cacheDir: "/tmp/mock-transformers-cache", + installPath: "/tmp/mock-transformers-cache/Xenova/all-MiniLM-L6-v2", progress: null, loaded: null, total: null, @@ -737,6 +741,24 @@ if (typeof window !== "undefined" && !(window as any).ade) { // Flag for App.tsx to switch to BrowserRouter (window as any).__adeBrowserMock = true; + const sharedMemoryHealthStats = createMockMemoryHealthStats(); + const resolveDownloadedMemoryHealthStats = async () => { + sharedMemoryHealthStats.embeddings = { + ...sharedMemoryHealthStats.embeddings, + model: { + ...sharedMemoryHealthStats.embeddings.model, + state: "ready", + activity: "ready", + installState: "installed", + progress: 100, + loaded: 1, + total: 1, + file: "/tmp/mock-model.onnx", + error: null, + }, + }; + return sharedMemoryHealthStats; + }; (window as any).ade = { app: { @@ -1680,29 +1702,8 @@ if (typeof window !== "undefined" && !(window as any).ade) { lastError: null, }), search: resolvedArg([]), - getHealthStats: resolved(createMockMemoryHealthStats()), - downloadEmbeddingModel: resolved(createMockMemoryHealthStats({ - embeddings: { - entriesEmbedded: 0, - entriesTotal: 0, - queueDepth: 0, - processing: false, - lastBatchProcessedAt: null, - cacheEntries: 0, - cacheHits: 0, - cacheMisses: 0, - cacheHitRate: 0, - model: { - modelId: "Xenova/all-MiniLM-L6-v2", - state: "ready", - progress: 100, - loaded: 1, - total: 1, - file: "/tmp/mock-model.onnx", - error: null, - }, - }, - })), + getHealthStats: resolved(sharedMemoryHealthStats), + downloadEmbeddingModel: resolveDownloadedMemoryHealthStats, runSweep: resolved(createMockSweepResult()), runConsolidation: resolved(createMockConsolidationResult()), }, diff --git a/apps/desktop/src/renderer/components/app/App.test.tsx b/apps/desktop/src/renderer/components/app/App.test.tsx new file mode 100644 index 000000000..96dc3e229 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/App.test.tsx @@ -0,0 +1,84 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { RequireProject } from "./App"; +import { useAppStore } from "../../state/appStore"; + +function resetStore() { + useAppStore.setState({ + project: null, + projectHydrated: false, + showWelcome: true, + lanes: [], + selectedLaneId: null, + runLaneId: null, + focusedSessionId: null, + laneInspectorTabs: {}, + terminalAttention: { + runningCount: 0, + activeCount: 0, + needsAttentionCount: 0, + indicator: "none", + byLaneId: {}, + }, + workViewByProject: {}, + laneWorkViewByScope: {}, + }); +} + +describe("RequireProject", () => { + beforeEach(() => { + resetStore(); + }); + + afterEach(() => { + cleanup(); + }); + + it("waits for project hydration instead of redirecting settings immediately", () => { + render( + + + +
Settings content
+ + )} + /> + Run page} /> +
+
, + ); + + expect(screen.getByText("Loading...")).toBeTruthy(); + expect(screen.queryByText("Run page")).toBeNull(); + }); + + it("redirects to run after hydration when there is no active project", () => { + useAppStore.getState().setProjectHydrated(true); + + render( + + + +
Settings content
+ + )} + /> + Run page} /> +
+
, + ); + + expect(screen.getByText("Run page")).toBeTruthy(); + expect(screen.queryByText("Settings content")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 6c186f0b9..6756214a7 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -50,6 +50,12 @@ const CtoPage = React.lazy(() => import { useAppStore } from "../../state/appStore"; +const GuardLoadingFallback = ( +
+
Loading...
+
+); + /* ---------- Per-route error boundary ---------- */ type PageErrorBoundaryState = { hasError: boolean; message: string }; @@ -111,11 +117,16 @@ function PageErrorBoundary({ children }: { children: React.ReactNode }) { ); } -function RequireProject({ children }: { children: React.ReactElement }): React.ReactElement { +export function RequireProject({ children }: { children: React.ReactElement }): React.ReactElement { + const projectHydrated = useAppStore((s) => s.projectHydrated); const showWelcome = useAppStore((s) => s.showWelcome); const project = useAppStore((s) => s.project); const location = useLocation(); + if (!projectHydrated) { + return GuardLoadingFallback; + } + const hasActiveProject = Boolean(project?.rootPath); if ((!hasActiveProject || showWelcome) && location.pathname !== "/project" && location.pathname !== "/onboarding") { return ; @@ -124,11 +135,7 @@ function RequireProject({ children }: { children: React.ReactElement }): React.R return children; } -const LazyFallback = ( -
-
Loading...
-
-); +const LazyFallback = GuardLoadingFallback; function guarded(element: React.ReactElement): React.ReactElement { return ( diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 906476a32..bc9d55a92 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -116,6 +116,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const location = useLocation(); const navigate = useNavigate(); const setProject = useAppStore((s) => s.setProject); + const setProjectHydrated = useAppStore((s) => s.setProjectHydrated); const refreshLanes = useAppStore((s) => s.refreshLanes); const refreshProviderMode = useAppStore((s) => s.refreshProviderMode); const refreshKeybindings = useAppStore((s) => s.refreshKeybindings); @@ -168,6 +169,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { useEffect(() => { let cancelled = false; + setProjectHydrated(false); const initializeProjectState = async () => { try { const nextProject = await window.ade.app.getProject(); @@ -201,6 +203,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { setProject(null); setProjectMissing(false); setShowWelcome(true); + } finally { + if (!cancelled) setProjectHydrated(true); } }; @@ -208,7 +212,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { return () => { cancelled = true; }; - }, [setProject, refreshLanes, refreshProviderMode, refreshKeybindings, setShowWelcome]); + }, [setProject, setProjectHydrated, refreshLanes, refreshProviderMode, refreshKeybindings, setShowWelcome]); useEffect(() => { if (!shouldTrackTerminalAttention) { @@ -639,8 +643,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { ) : null} {!hideSidebar && project?.rootPath && !showWelcome && (contextStatus?.generation.state === "pending" || contextStatus?.generation.state === "running") ? ( -
- ADE context docs are generating in the background. Open context settings +
+ Generating context docs... Open context settings
) : null} diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 0f0261cfc..c700f0ba1 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { ArrowsClockwise, Folder, FolderOpen, Plus, Minus, Trash, X } from "@phosphor-icons/react"; +import { useNavigate } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; import { isRunOwnedSession } from "../../lib/sessions"; @@ -41,6 +42,7 @@ function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { } export function TopBar() { + const navigate = useNavigate(); const project = useAppStore((s) => s.project); const closeProject = useAppStore((s) => s.closeProject); const terminalAttention = useAppStore((s) => s.terminalAttention); @@ -443,9 +445,7 @@ export function TopBar() { data-variant="ghost" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} title={`Sync mode: ${syncSnapshot.mode}`} - onClick={() => { - window.location.hash = "#/settings"; - }} + onClick={() => navigate("/settings")} > { + const detect = vi.fn(); + + beforeEach(() => { + detect.mockReset(); + detect.mockResolvedValue({ + platform: "darwin", + tools: [ + { + id: "git", + label: "Git", + command: "git", + installed: true, + detectedPath: "/usr/bin/git", + detectedVersion: "git version 2.50.1", + required: true, + }, + { + id: "gh", + label: "GitHub CLI", + command: "gh", + installed: false, + detectedPath: null, + detectedVersion: null, + required: false, + }, + ], + }); + + globalThis.window.ade = { + ...originalAde, + devTools: { + detect, + }, + } as typeof window.ade; + }); + + afterEach(() => { + cleanup(); + globalThis.window.ade = originalAde; + }); + + it("renders requirement copy without conflicting requirement badges", async () => { + const onStatusChange = vi.fn(); + render(); + + await waitFor(() => expect(screen.getByText("Installed")).toBeTruthy()); + + expect(screen.queryByText("REQUIRED")).toBeNull(); + expect(screen.queryByText("RECOMMENDED")).toBeNull(); + expect(screen.getByText("Required to continue setup.")).toBeTruthy(); + expect(screen.getByText("Optional, but recommended for PR workflows.")).toBeTruthy(); + expect(onStatusChange).toHaveBeenCalledWith(true); + }); + + it("forces a fresh scan when scan again is clicked", async () => { + render(); + + await waitFor(() => expect(detect).toHaveBeenCalledWith(undefined)); + + fireEvent.click(screen.getByRole("button", { name: "Scan again" })); + + await waitFor(() => expect(detect).toHaveBeenCalledWith(true)); + }); +}); diff --git a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx index 62a76150b..afbfa5157 100644 --- a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx +++ b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useCallback } from "react"; +import { ArrowsClockwise, GitBranch, Terminal } 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"; @@ -33,21 +34,55 @@ export function DevToolsSection({ onStatusChange }: Props) { return (
- - + {/* Info header */} +
+
+ ADE relies on these developer tools +
+
+
+ + git — version control, branching, and lane isolation +
+
+ + gh — PR creation, review, and GitHub workflows +
+
+
+ + + +
); } -function ToolCard({ tool, platform, loading }: { tool: DevToolStatus | null; platform: NodeJS.Platform; loading: boolean }) { +function ToolCard({ tool, platform, loading, toolId }: { tool: DevToolStatus | null; platform: NodeJS.Platform; loading: boolean; toolId: string }) { + const isGit = toolId === "git"; + const accentColor = isGit ? COLORS.success : COLORS.info; + const Icon = isGit ? GitBranch : Terminal; + if (loading || !tool) { return ( -
+
Detecting...
); @@ -55,18 +90,35 @@ function ToolCard({ tool, platform, loading }: { tool: DevToolStatus | null; pla const installed = tool.installed; const statusColor = installed ? COLORS.success : tool.required ? COLORS.danger : COLORS.warning; - const statusLabel = installed ? "INSTALLED" : "NOT INSTALLED"; - const kindLabel = tool.required ? "REQUIRED" : "RECOMMENDED"; - const kindColor = tool.required ? COLORS.danger : COLORS.warning; + const statusLabel = installed ? "Installed" : "Not found"; + const requirementLabel = tool.required + ? "Required to continue setup." + : "Optional, but recommended for PR workflows."; return ( -
+
-
-
- {tool.label} +
+
+ +
+
+
+ {tool.label} +
+
+ {requirementLabel} +
- {kindLabel}
{statusLabel}
@@ -79,10 +131,22 @@ function ToolCard({ tool, platform, loading }: { tool: DevToolStatus | null; pla )}
) : ( -
-
+
+
{tool.id === "git" ? gitInstallHelp(platform) : ghInstallHelp(platform)}
+
+ After installing, click Scan again. Restart ADE only if the tool still does not appear. +
)}
@@ -127,12 +191,13 @@ function ghInstallHelp(platform: NodeJS.Platform): React.ReactNode { return <>Install from cli.github.com; } -function cardStyle(): React.CSSProperties { +function cardStyle(accentColor: string): React.CSSProperties { return { padding: 18, background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, borderRadius: 14, + borderLeft: `3px solid ${accentColor}`, }; } @@ -142,7 +207,7 @@ function codeStyle(): React.CSSProperties { fontSize: 11, padding: "2px 6px", borderRadius: 4, - background: "rgba(255,255,255,0.06)", + background: "rgba(255,255,255,0.08)", color: COLORS.textPrimary, }; } diff --git a/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.test.tsx b/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.test.tsx new file mode 100644 index 000000000..6ec5704a5 --- /dev/null +++ b/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.test.tsx @@ -0,0 +1,175 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { EmbeddingsSection } from "./EmbeddingsSection"; + +function createHealthStats(overrides: Partial = {}) { + return { + scopes: [ + { scope: "project", current: 0, max: 2000, counts: { tier1: 0, tier2: 0, tier3: 0, archived: 0 } }, + { scope: "agent", current: 0, max: 500, counts: { tier1: 0, tier2: 0, tier3: 0, archived: 0 } }, + { scope: "mission", current: 0, max: 200, counts: { tier1: 0, tier2: 0, tier3: 0, archived: 0 } }, + ], + lastSweep: null, + lastConsolidation: null, + embeddings: { + entriesEmbedded: 0, + entriesTotal: 0, + queueDepth: 0, + processing: false, + lastBatchProcessedAt: null, + cacheEntries: 0, + cacheHits: 0, + cacheMisses: 0, + cacheHitRate: 0, + model: { + modelId: "Xenova/all-MiniLM-L6-v2", + state: "idle", + activity: "idle", + installState: "missing", + cacheDir: "/tmp/mock-transformers-cache", + installPath: "/tmp/mock-transformers-cache/Xenova/all-MiniLM-L6-v2", + progress: null, + loaded: null, + total: null, + file: null, + error: null, + }, + }, + ...overrides, + }; +} + +describe("EmbeddingsSection", () => { + const originalAde = globalThis.window.ade; + + beforeEach(() => { + globalThis.window.ade = { + memory: { + getHealthStats: vi.fn().mockResolvedValue(createHealthStats()), + downloadEmbeddingModel: vi.fn().mockResolvedValue(createHealthStats()), + }, + } as any; + }); + + afterEach(() => { + cleanup(); + if (originalAde === undefined) { + delete (globalThis.window as any).ade; + } else { + globalThis.window.ade = originalAde; + } + }); + + it("shows the machine-wide install path when the model is already installed", async () => { + const memoryApi = window.ade?.memory as any; + const installedStats = createHealthStats({ + embeddings: { + entriesEmbedded: 0, + entriesTotal: 0, + queueDepth: 0, + processing: false, + lastBatchProcessedAt: null, + cacheEntries: 0, + cacheHits: 0, + cacheMisses: 0, + cacheHitRate: 0, + model: { + modelId: "Xenova/all-MiniLM-L6-v2", + state: "idle", + activity: "idle", + installState: "installed", + cacheDir: "/tmp/mock-transformers-cache", + installPath: "/tmp/mock-transformers-cache/Xenova/all-MiniLM-L6-v2", + progress: null, + loaded: null, + total: null, + file: null, + error: null, + }, + }, + }); + memoryApi.getHealthStats.mockResolvedValue(installedStats); + + render(); + + expect(await screen.findByText(/Smart search only shows Ready after the model loads and passes a local verification check/i)).toBeTruthy(); + expect(screen.getByText("/tmp/mock-transformers-cache/Xenova/all-MiniLM-L6-v2")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /verify model/i })); + expect(memoryApi.downloadEmbeddingModel).toHaveBeenCalledTimes(1); + }); + + it("describes a local cache load instead of a fresh download", async () => { + const memoryApi = window.ade?.memory as any; + const loadingLocalStats = createHealthStats({ + embeddings: { + entriesEmbedded: 0, + entriesTotal: 0, + queueDepth: 0, + processing: false, + lastBatchProcessedAt: null, + cacheEntries: 0, + cacheHits: 0, + cacheMisses: 0, + cacheHitRate: 0, + model: { + modelId: "Xenova/all-MiniLM-L6-v2", + state: "loading", + activity: "loading-local", + installState: "installed", + cacheDir: "/tmp/mock-transformers-cache", + installPath: "/tmp/mock-transformers-cache/Xenova/all-MiniLM-L6-v2", + progress: 100, + loaded: 1024, + total: 1024, + file: "tokenizer.json", + error: null, + }, + }, + }); + memoryApi.getHealthStats.mockResolvedValue(loadingLocalStats); + + render(); + + expect(await screen.findByText(/ADE is loading it from local cache/i)).toBeTruthy(); + expect(screen.queryByText(/Downloading model files/i)).toBeNull(); + }); + + it("still treats a fully installed model as local loading even if activity is stale", async () => { + const memoryApi = window.ade?.memory as any; + const contradictoryStats = createHealthStats({ + embeddings: { + entriesEmbedded: 0, + entriesTotal: 0, + queueDepth: 0, + processing: false, + lastBatchProcessedAt: null, + cacheEntries: 0, + cacheHits: 0, + cacheMisses: 0, + cacheHitRate: 0, + model: { + modelId: "Xenova/all-MiniLM-L6-v2", + state: "loading", + activity: "downloading", + installState: "installed", + cacheDir: "/tmp/mock-transformers-cache", + installPath: "/tmp/mock-transformers-cache/Xenova/all-MiniLM-L6-v2", + progress: 100, + loaded: 695 * 1024, + total: 695 * 1024, + file: "tokenizer.json", + error: null, + }, + }, + }); + memoryApi.getHealthStats.mockResolvedValue(contradictoryStats); + + render(); + + expect(await screen.findByText(/without downloading it again/i)).toBeTruthy(); + expect(screen.queryByText(/Downloading tokenizer\.json/i)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.tsx b/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.tsx index f2569c5b5..c495ce7a5 100644 --- a/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.tsx +++ b/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useCallback, useRef } from "react"; +import { Brain, Cube, Desktop, MagnifyingGlass, ShieldCheck, TextT } from "@phosphor-icons/react"; import type { MemoryHealthStats } from "../../../shared/types"; import { COLORS, SANS_FONT, MONO_FONT, LABEL_STYLE, inlineBadge } from "../lanes/laneDesignTokens"; import { Button } from "../ui/Button"; @@ -17,6 +18,58 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +function getVisualState(model: MemoryHealthStats["embeddings"]["model"] | null | undefined) { + if (!model) return "missing" as const; + if (model.state === "ready") return "ready" as const; + if (model.state === "loading" && (model.activity === "loading-local" || model.installState === "installed")) return "loading-local" as const; + if (model.state === "loading") return "downloading" as const; + if (model.state === "unavailable") return "error" as const; + if (model.installState === "installed") return "installed" as const; + if (model.installState === "partial") return "partial" as const; + return "missing" as const; +} + +function ProgressBar({ + value, + max, + color, + label, + description, +}: { + value: number; + max: number; + color: string; + label: string; + description?: string | null; +}) { + const pct = max > 0 ? Math.max(0, Math.min(100, Math.round((value / max) * 100))) : 0; + return ( +
+
+
+ {label} +
+ {description ? ( +
+ {description} +
+ ) : null} +
+
+
+
+
+ ); +} + export function EmbeddingsSection() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -38,7 +91,7 @@ export function EmbeddingsSection() { useEffect(() => { void loadStats(); }, [loadStats]); - // Poll — fast while downloading, slow otherwise + // Poll -- fast while downloading, slow otherwise useEffect(() => { if (pollRef.current) { clearTimeout(pollRef.current); pollRef.current = null; } const isDownloading = stats?.embeddings.model.state === "loading"; @@ -57,10 +110,32 @@ export function EmbeddingsSection() { } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } - }, []); + }, [memoryApi]); const model = stats?.embeddings.model; const state = model?.state ?? "idle"; + const visualState = getVisualState(model); + const installPath = model?.installPath ?? model?.cacheDir ?? null; + const installPathLabel = visualState === "ready" + ? "VERIFIED AT" + : model?.installState === "installed" + ? "FOUND ON DISK AT" + : model?.installState === "partial" + ? "PARTIAL DOWNLOAD AT" + : "INSTALLS TO"; + const installPathHelp = visualState === "ready" + ? "ADE loaded and verified this machine-wide model install. Future projects reuse the same cache path." + : model?.installState === "installed" + ? "ADE detected model files at this machine-wide cache path. Smart search turns ready only after ADE loads and verifies them locally." + : model?.installState === "partial" + ? "ADE found partially downloaded model files here. Repairing the download finishes the install for every project on this machine." + : "ADE stores the model under this ADE app-data path on your machine. Future projects reuse the same install."; + const actionLabel = + model?.installState === "partial" || (model?.state === "unavailable" && model?.installState === "installed") + ? "Repair model" + : model?.installState === "installed" + ? "Verify model" + : "Download model"; const downloadPct = (() => { if (!model) return 0; @@ -92,7 +167,25 @@ export function EmbeddingsSection() { This is optional — basic text search works without it.
- {/* Model details row */} + {/* Visual flow diagram */} +
+ } label="Your Text" /> + + } label="Vector Model" /> + + } label="Smart Search" /> +
+ + {/* Model details row with icon badges */}
-
MODEL
+
+ + MODEL +
{MODEL_DISPLAY_NAME}
-
DIMENSIONS
+
+ + DIMENSIONS +
{MODEL_DIMENSIONS}
-
RUNS
+
+ + RUNS +
Locally (CPU)
+ {installPath ? ( +
+
{installPathLabel}
+
+ {installPath} +
+
+ {installPathHelp} +
+
+ ) : null} + {loading && !stats ? (
Checking model status...
- ) : state === "ready" ? ( + ) : visualState === "ready" ? (
READY Semantic search is active — {MODEL_DISPLAY_NAME} loaded
- ) : state === "loading" ? ( + ) : visualState === "installed" ? ( +
+
+ ON DISK + + ADE found model files on this machine. Smart search only shows Ready after the model loads and passes a local verification check. + +
+
+ +
+
+ ) : visualState === "loading-local" ? ( +
+ LOADING + + Found the installed model on this machine. ADE is loading it from local cache without downloading it again. + This usually finishes in a few seconds and continues in the background if you leave setup. + +
+ ) : visualState === "downloading" ? (
{model?.file ? <>Downloading {model.file}... : "Downloading model files..."}
-
-
-
+
{downloadPct}% {bytesLabel ? {bytesLabel} : null}
+ ) : visualState === "partial" ? ( +
+
+ ADE found a partial model download on this machine. Repair it to finish enabling semantic search. +
+
+ +
+
) : (
)} @@ -181,7 +334,10 @@ export function EmbeddingsSection() {
{/* How it works explainer */} -
+
How it works
@@ -189,9 +345,62 @@ export function EmbeddingsSection() { When you save a memory, ADE converts the text into a numerical vector using this model. Later, when you search, your query is also vectorized and compared against stored vectors to find results that are semantically related — even if the exact words don't match. - Everything runs locally; no data leaves your machine.
+ + {/* Privacy note */} +
+ +
+ Privacy.{" "} + All processing happens locally on your machine. No data leaves your device — embeddings are computed and stored entirely offline. +
+
+
+ ); +} + +function FlowBox({ icon, label }: { icon: React.ReactNode; label: string }) { + return ( +
+ {icon} +
+ {label} +
+
+ ); +} + +function FlowArrow() { + return ( +
+ →
); } diff --git a/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx b/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx index 717a050e7..50e42dbb7 100644 --- a/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx +++ b/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx @@ -1,11 +1,12 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { CheckCircle, Circle } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import type { ContextRefreshEvents, ContextStatus } from "../../../shared/types"; import { Button } from "../ui/Button"; -import { AiSettingsSection } from "../settings/AiSettingsSection"; +import { AiFeaturesSection } from "../settings/AiFeaturesSection"; import { GitHubSection } from "../settings/GitHubSection"; import { LinearSection } from "../settings/LinearSection"; +import { ProvidersSection } from "../settings/ProvidersSection"; import { DevToolsSection } from "./DevToolsSection"; import { EmbeddingsSection } from "./EmbeddingsSection"; import { UnifiedModelSelector } from "../shared/UnifiedModelSelector"; @@ -15,45 +16,50 @@ import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; import { publishOnboardingStatusUpdated } from "../../lib/onboardingStatusEvents"; import { listActionableContextDocs } from "../context/contextShared"; -type SetupStep = "tools" | "ai" | "github" | "embeddings" | "linear" | "context"; +type SetupStep = "tools" | "ai" | "helpers" | "github" | "embeddings" | "linear" | "context"; -const STEP_ORDER: SetupStep[] = ["tools", "ai", "github", "embeddings", "linear", "context"]; +const STEP_ORDER: SetupStep[] = ["tools", "ai", "helpers", "github", "embeddings", "linear", "context"]; const STEP_META: Record = { tools: { title: "Dev tools", - subtitle: "Check for git and GitHub CLI.", + subtitle: "Verify git and GitHub CLI are ready", }, ai: { - title: "AI setup", - subtitle: "Connect a provider and choose defaults.", + title: "AI connections", + subtitle: "Connect your AI providers", + }, + helpers: { + title: "Background helpers", + subtitle: "Optional AI-powered automations", }, github: { title: "GitHub", - subtitle: "Add a token for PRs, reviews, and repo actions.", + subtitle: "Enable PR and code review workflows", }, embeddings: { - title: "Smart search", - subtitle: "Optional local embedding model for semantic memory search.", + title: "Semantic search", + subtitle: "Local vector model for smart memory search", }, linear: { title: "Linear", - subtitle: "Optional. Connect for issue links and CTO routing.", + subtitle: "Issue tracking and CTO workflow routing", }, context: { title: "Context docs", - subtitle: "Generate PRD and architecture docs from your repo.", + subtitle: "Auto-generate PRD and architecture docs", }, }; /* Step header — short title on top, subtitle below */ const STEP_HEADERS: Record = { - tools: { heading: "Dev tools check", sub: "ADE needs git installed. GitHub CLI is recommended for PR workflows." }, - ai: { heading: "Connect AI", sub: "Choose a provider and model defaults for this project." }, - github: { heading: "Connect GitHub", sub: "Add a personal access token (classic or fine-grained) so lane PRs and reviews work." }, - embeddings: { heading: "Local embedding model", sub: "Download all-MiniLM-L6-v2 (~31 MB) to enable semantic memory search. Runs entirely on your machine." }, - linear: { heading: "Connect Linear", sub: "Optional — connect for issue routing and CTO workflows." }, - context: { heading: "Generate context docs", sub: "Pick a model and triggers, then kick off generation." }, + tools: { heading: "Developer Tools", sub: "ADE needs git for version control. GitHub CLI unlocks PR creation, review requests, and CI checks." }, + ai: { heading: "Connect AI Providers", sub: "Link your AI accounts so ADE can power chat, code generation, and background automations." }, + helpers: { heading: "Background Helpers", sub: "These lightweight AI automations run in the background while you work. All are optional and can be changed anytime in Settings." }, + github: { heading: "GitHub Integration", sub: "A personal access token lets ADE create PRs, request reviews, and monitor CI on your behalf." }, + embeddings: { heading: "Semantic Search", sub: "A small local model that enables meaning-based memory search instead of just keyword matching." }, + linear: { heading: "Linear Integration", sub: "Connect your Linear workspace to route issues, sync statuses, and enable CTO workflows." }, + context: { heading: "Context Documents", sub: "Generate a PRD and architecture overview from your codebase. These help ADE understand your project deeply." }, }; const EVENT_TOGGLES: { key: keyof ContextRefreshEvents; label: string; help: string }[] = [ @@ -66,7 +72,7 @@ const EVENT_TOGGLES: { key: keyof ContextRefreshEvents; label: string; help: str { key: "onLaneCreate", label: "Lane create", help: "When a new lane is created" }, ]; -const DEFAULT_EVENTS: ContextRefreshEvents = { onPrCreate: true, onMissionStart: true }; +const DEFAULT_EVENTS: ContextRefreshEvents = {}; function isContextGenerationActive(status: ContextStatus["generation"] | null | undefined): boolean { return status?.state === "pending" || status?.state === "running"; @@ -136,7 +142,8 @@ export function ProjectSetupPage() { .then((next) => { if (!cancelled) setStatus(next); }) - .catch(() => { + .catch((error) => { + console.error("ProjectSetupPage: failed to load onboarding status", error); if (!cancelled) setStatus({ completedAt: null, dismissedAt: null }); }); @@ -145,7 +152,9 @@ export function ProjectSetupPage() { .then((aiStatus) => { if (!cancelled) setAvailableModelIds(deriveConfiguredModelIds(aiStatus)); }) - .catch(() => {}); + .catch((error) => { + console.error("ProjectSetupPage: failed to load AI status", error); + }); // Load saved context doc prefs window.ade.context?.getPrefs?.() @@ -159,7 +168,10 @@ export function ProjectSetupPage() { } setPrefsLoaded(true); }) - .catch(() => { setPrefsLoaded(true); }); + .catch((error) => { + console.error("ProjectSetupPage: failed to load context doc prefs", error); + setPrefsLoaded(true); + }); return () => { cancelled = true; }; }, []); @@ -169,7 +181,8 @@ export function ProjectSetupPage() { try { const next = await window.ade.context.getStatus(); setContextStatus(next); - } catch { + } catch (error) { + console.error("ProjectSetupPage: failed to refresh context status", error); setContextStatus(null); } finally { setContextLoading(false); @@ -185,8 +198,6 @@ export function ProjectSetupPage() { return window.ade.context?.onStatusChanged?.(setContextStatus) ?? (() => {}); }, []); - const progressLabel = useMemo(() => `${stepIndex + 1} / ${STEP_ORDER.length}`, [stepIndex]); - const handleNext = async () => { if (isLastStep) { setBusy(true); @@ -238,7 +249,9 @@ export function ProjectSetupPage() { modelId: contextModelId, reasoningEffort: contextReasoningEffort, events: contextEvents, - }).catch(() => {}); + }).catch((error) => { + console.error("ProjectSetupPage: failed to launch context docs generation", error); + }); setContextLaunchNotice("Generation started in the background. You can finish setup now."); window.setTimeout(() => { void reloadContextStatus(); }, 800); @@ -258,7 +271,9 @@ export function ProjectSetupPage() { modelId: contextModelId, reasoningEffort: contextReasoningEffort, events: contextEvents, - }).catch(() => {}); + })?.catch((error) => { + console.error("ProjectSetupPage: failed to auto-save context doc prefs", error); + }); }, 400); return () => { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); }; }, [prefsLoaded, contextModelId, contextReasoningEffort, contextEvents]); @@ -267,7 +282,8 @@ export function ProjectSetupPage() { const stepContent = (() => { if (step === "tools") return ; - if (step === "ai") return ; + if (step === "ai") return ; + if (step === "helpers") return ; if (step === "github") return ; if (step === "embeddings") return ; if (step === "linear") return ; @@ -395,88 +411,114 @@ export function ProjectSetupPage() { alignSelf: "start", position: "sticky", top: 0, - padding: 20, background: "rgba(18, 17, 24, 0.88)", border: `1px solid ${COLORS.border}`, borderRadius: 16, backdropFilter: "blur(20px)", + overflow: "hidden", }} > -
- Project setup -
-
- {project?.displayName ?? "Current project"} -
-
- Quick setup for the essentials. Everything here is editable later in Settings. -
+ {/* Gradient accent bar */}
- Step {progressLabel} -
+ /> -
- {STEP_ORDER.map((stepId, index) => { - const active = stepId === step; - const complete = index < stepIndex || (stepId === "context" && Boolean(status?.completedAt)); - return ( - - ); - })} -
+ /> +
+
+ {stepIndex + 1} of {STEP_ORDER.length} +
+
-
- - + {/* Step list with vertical connecting line */} +
+ {/* Vertical connecting line */} +
+ +
+ {STEP_ORDER.map((stepId, index) => { + const active = stepId === step; + const complete = index < stepIndex || (stepId === "context" && Boolean(status?.completedAt)); + return ( + + ); + })} +
+
@@ -492,43 +534,43 @@ export function ProjectSetupPage() { }} > {/* Header */} -
-
-
- {header.heading} -
-
- {header.sub} -
+
+
+
+ {header.heading} +
+
+ {header.sub}
-
{/* Step content */}
{stepContent}
{/* Footer */} -
+
+ -
- {!isLastStep ? ( - - ) : null} - -
+
+
diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx index 21e260745..4737f5edc 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx @@ -7,23 +7,27 @@ import type { import { COLORS, MONO_FONT, + SANS_FONT, LABEL_STYLE, cardStyle, } from "../lanes/laneDesignTokens"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { getModelById, resolveModelAlias } from "../../../shared/modelRegistry"; import { UnifiedModelSelector } from "../shared/UnifiedModelSelector"; +import { ChatCircleDots, GitPullRequest, GitCommit, ChatText, type Icon } from "@phosphor-icons/react"; type FeatureInfo = { key: AiFeatureKey; label: string; description: string; + subtitle: string; + icon: Icon; }; const FEATURES: FeatureInfo[] = [ - { key: "terminal_summaries", label: "Chat & terminal summaries", description: "Summarize closed terminal sessions and keep chat session summaries updated" }, - { key: "pr_descriptions", label: "PR description drafting", description: "Draft PR descriptions when you trigger the action in the PR flows" }, - { key: "commit_messages", label: "Commit messages", description: "Generate a brief git commit subject when the field is empty" }, + { key: "terminal_summaries", label: "Chat & terminal summaries", description: "Summarize closed terminal sessions and keep chat session summaries updated", subtitle: "Never lose track of what happened in closed sessions", icon: ChatCircleDots }, + { key: "pr_descriptions", label: "PR description drafting", description: "Draft PR descriptions when you trigger the action in the PR flows", subtitle: "Get a head start on PR descriptions when you're ready to merge", icon: GitPullRequest }, + { key: "commit_messages", label: "Commit messages", description: "Generate a brief git commit subject when the field is empty", subtitle: "Meaningful commit messages generated from your staged changes", icon: GitCommit }, ]; const sectionLabelStyle: React.CSSProperties = { @@ -162,6 +166,7 @@ export function AiFeaturesSection() { }, [loadStatus]); const availableModelIds = React.useMemo(() => deriveConfiguredModelIds(status), [status]); + const featureRowHoverCss = `.ai-feature-row:hover { background: ${COLORS.hoverBg}; }`; const saveChatTitleSettings = useCallback(async (patch: Partial>) => { const nextModelId = @@ -282,193 +287,229 @@ export function AiFeaturesSection() { } return ( -
-
HELPER DEFAULTS
-
- Configure the lightweight helpers ADE can run automatically while you work. Mission orchestration and conflict-resolution models are configured in their own surfaces. -
- -
-
-
ON
-
FEATURE
-
MODEL
-
TODAY
+ <> + +
+
AI-Powered Automations
+
+ ADE can handle routine tasks in the background while you focus on what matters. Enable the helpers you want and pick a model for each.
- {FEATURES.map((feature, index) => { - const row = status.features.find((entry) => entry.feature === feature.key); - const enabled = row?.enabled ?? false; - const dailyUsage = row?.dailyUsage ?? 0; - const selectedModel = featureModels[feature.key] ?? ""; - const needsModelSelection = enabled && !selectedModel; +
+
+
ON
+
FEATURE
+
MODEL
+
TODAY
+
- return ( -
- handleToggle(feature.key, value)} /> + {FEATURES.map((feature, index) => { + const row = status.features.find((entry) => entry.feature === feature.key); + const enabled = row?.enabled ?? false; + const dailyUsage = row?.dailyUsage ?? 0; + const selectedModel = featureModels[feature.key] ?? ""; + const needsModelSelection = enabled && !selectedModel; + const IconComponent = feature.icon; + + return ( +
+ handleToggle(feature.key, value)} /> + +
+ +
+
+ {feature.label} +
+
+ {feature.subtitle} +
+ {needsModelSelection ? ( +
+ Select a model to enable this feature. +
+ ) : null} +
+
+ +
+ void handleModelChange(feature.key, modelId)} + availableModelIds={availableModelIds} + disabled={!enabled} + showReasoning + reasoningEffort={featureReasoning[feature.key] ?? null} + onReasoningEffortChange={(effort) => void handleReasoningChange(feature.key, effort)} + /> +
-
0 ? COLORS.textSecondary : COLORS.textDim, + textAlign: "right", }} > - {feature.label} + {dailyUsage}
-
- {feature.description} -
- {needsModelSelection ? ( +
+ ); + })} + + {/* Auto-name chat tabs */} +
+ void saveChatTitleSettings({ autoTitleEnabled: value })} + /> + +
+ +
+
- Select a model to enable this feature. + Auto-name chat tabs
- ) : null} -
- -
- void handleModelChange(feature.key, modelId)} - availableModelIds={availableModelIds} - disabled={!enabled} - showReasoning - reasoningEffort={featureReasoning[feature.key] ?? null} - onReasoningEffortChange={(effort) => void handleReasoningChange(feature.key, effort)} - /> +
+ Tabs automatically get descriptive names based on conversation content +
+ +
+
-
0 ? COLORS.textSecondary : COLORS.textDim, - textAlign: "right", +
+ { + setUtilityModel(modelId); + void saveChatTitleSettings({ autoTitleModelId: modelId }); }} - > - {dailyUsage} -
+ availableModelIds={availableModelIds} + disabled={!chatAutoTitleEnabled} + showReasoning + reasoningEffort={chatAutoTitleReasoning} + onReasoningEffortChange={(effort) => { + setChatAutoTitleReasoning(effort); + void saveChatTitleSettings({ autoTitleReasoningEffort: effort }); + }} + />
- ); - })} - - {/* Auto-name chat tabs */} -
- void saveChatTitleSettings({ autoTitleEnabled: value })} - /> - -
+
- Auto-name chat tabs -
-
- Generate a title from chat content + —
- -
- -
- { - setUtilityModel(modelId); - void saveChatTitleSettings({ autoTitleModelId: modelId }); - }} - availableModelIds={availableModelIds} - disabled={!chatAutoTitleEnabled} - showReasoning - reasoningEffort={chatAutoTitleReasoning} - onReasoningEffortChange={(effort) => { - setChatAutoTitleReasoning(effort); - void saveChatTitleSettings({ autoTitleReasoningEffort: effort }); - }} - /> -
- -
- —
-
+ ); } diff --git a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx index cfa7df098..b05a1a78c 100644 --- a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx @@ -1,10 +1,9 @@ import { useEffect, useState, type CSSProperties } from "react"; import type { GitHubStatus } from "../../../shared/types"; -import { GithubLogo, CheckCircle, Warning, ArrowsClockwise, ShieldCheck, LinkBreak, Key } from "@phosphor-icons/react"; +import { GithubLogo, CheckCircle, Warning, ArrowsClockwise, ShieldCheck, LinkBreak, Key, Shield, GitPullRequest, Eye, GitBranch, UsersThree } from "@phosphor-icons/react"; +import { getGitHubTokenAccessState, REQUIRED_GITHUB_CLASSIC_SCOPES } from "../../../shared/githubScopes"; import { COLORS, MONO_FONT, SANS_FONT, cardStyle, LABEL_STYLE, inlineBadge, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; -const REQUIRED_SCOPES = ["repo", "workflow", "read:org"]; - type TokenType = "classic" | "fine-grained" | "unknown"; function detectTokenType(token: string): TokenType { @@ -19,6 +18,7 @@ export function GitHubSection() { const [githubStatus, setGithubStatus] = useState(null); const [githubTokenDraft, setGithubTokenDraft] = useState(""); const [githubBusy, setGithubBusy] = useState(false); + const [tokenFocused, setTokenFocused] = useState(false); useEffect(() => { let cancelled = false; @@ -49,7 +49,12 @@ export function GitHubSection() { .then((status) => { setGithubStatus(status); setGithubTokenDraft(""); - setSaveNotice("GitHub token saved and verified."); + const accessState = getGitHubTokenAccessState(status.scopes ?? []); + setSaveNotice( + accessState.hasRequiredAccess + ? "GitHub token saved and verified." + : "GitHub token saved. Additional GitHub permissions are still required.", + ); }) .catch((err) => setActionError(err instanceof Error ? err.message : String(err))) .finally(() => setGithubBusy(false)); @@ -79,9 +84,12 @@ export function GitHubSection() { .finally(() => setGithubBusy(false)); }; - const isConnected = githubStatus?.tokenStored && githubStatus?.userLogin; - const missingScopes = REQUIRED_SCOPES.filter((scope) => !(githubStatus?.scopes ?? []).includes(scope)); - const hasMissingScopes = isConnected && missingScopes.length > 0; + const isConnected = Boolean(githubStatus?.tokenStored && githubStatus?.userLogin); + const accessState = getGitHubTokenAccessState(githubStatus?.scopes ?? []); + const hasFullAccess = isConnected && accessState.hasRequiredAccess; + const hasMissingScopes = isConnected && !accessState.hasRequiredAccess; + const statusColor = hasFullAccess ? COLORS.success : isConnected ? COLORS.warning : COLORS.textMuted; + const statusLabel = hasFullAccess ? "CONNECTED" : isConnected ? "LIMITED ACCESS" : "NOT CONNECTED"; const sectionGap: CSSProperties = { display: "flex", @@ -110,18 +118,26 @@ export function GitHubSection() { }; const inputStyle: CSSProperties = { - height: 36, + height: 40, background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, - borderRadius: 0, - padding: "0 12px", + borderRadius: 8, + padding: "0 14px", fontSize: 12, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none", width: "100%", + transition: "border-color 150ms ease, box-shadow 150ms ease", }; + const inputFocusedStyle: CSSProperties = tokenFocused + ? { + borderColor: COLORS.accent, + boxShadow: `0 0 0 3px ${COLORS.accent}22`, + } + : {}; + const scopeRowStyle = (present: boolean): CSSProperties => ({ display: "flex", alignItems: "center", @@ -147,17 +163,19 @@ export function GitHubSection() { {saveNotice ?
{saveNotice}
: null} {actionError ?
{actionError}
: null} -
+
- + GitHub connection
{githubStatus ? ( - - {isConnected ? "CONNECTED" : "NOT CONNECTED"} + + {statusLabel} ) : null}
@@ -176,20 +194,20 @@ export function GitHubSection() { >
USER
-
- {githubStatus.userLogin} +
+ {githubStatus?.userLogin ?? "Unknown"}
REPOSITORY
- {githubStatus.repo ? `${githubStatus.repo.owner}/${githubStatus.repo.name}` : "N/A"} + {githubStatus?.repo ? `${githubStatus.repo.owner}/${githubStatus.repo.name}` : "N/A"}
TOKEN SCOPE
- {githubStatus.storageScope === "app" ? "App-wide" : "Project"} + {githubStatus?.storageScope === "app" ? "App-wide" : "Project"}
@@ -197,8 +215,8 @@ export function GitHubSection() {
TOKEN SCOPES
- {REQUIRED_SCOPES.map((scope) => { - const present = (githubStatus?.scopes ?? []).includes(scope); + {REQUIRED_GITHUB_CLASSIC_SCOPES.map((scope) => { + const present = accessState.requirements[scope].present; return (
{present ? : } @@ -211,7 +229,9 @@ export function GitHubSection() { {hasMissingScopes ? (
- Missing required scopes: {missingScopes.join(", ")}. Regenerate the token with the required permissions. + Missing required {accessState.usesFineGrainedPermissions ? "permissions" : "scopes"}: {accessState.missingDescriptions.join(", ")}. + {" "} + Regenerate the token with the required permissions.
) : null} @@ -233,41 +253,56 @@ export function GitHubSection() {
{/* Token type tabs */} -
+
{/* Classic PAT */} -
-
- Classic token +
+
+ +
+ Classic token +
Prefix: ghp_...
-
+
Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token
-
REQUIRED SCOPES
-
- {REQUIRED_SCOPES.map((scope) => ( -
- ● {scope} -
+
REQUIRED SCOPES
+
+ {REQUIRED_GITHUB_CLASSIC_SCOPES.map((scope) => ( + + {scope} + ))}
{/* Fine-grained PAT */} -
-
- Fine-grained token +
+
+ +
+ Fine-grained token +
Prefix: github_pat_...
-
+
Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token
-
REQUIRED PERMISSIONS
-
+
REQUIRED PERMISSIONS
+
{[ "Contents: Read & Write", "Pull requests: Read & Write", @@ -275,9 +310,19 @@ export function GitHubSection() { "Workflows: Read & Write", "Members (org): Read", ].map((perm) => ( -
- ● {perm} -
+ + {perm} + ))}
@@ -290,7 +335,9 @@ export function GitHubSection() { value={githubTokenDraft} onChange={(event) => setGithubTokenDraft(event.target.value)} placeholder="ghp_... or github_pat_..." - style={inputStyle} + style={{ ...inputStyle, ...inputFocusedStyle }} + onFocus={() => setTokenFocused(true)} + onBlur={() => setTokenFocused(false)} /> {githubTokenDraft.trim() ? ( @@ -312,21 +359,41 @@ export function GitHubSection() {
-
+
Why these permissions?
-
- ADE uses your token to create PRs, inspect CI checks, and request reviewers.{" "} - Classic tokens use broad scopes - (repo,{" "} - workflow,{" "} - read:org).{" "} - Fine-grained tokens let you grant narrower, - per-repository permissions — they are the newer GitHub recommendation. - Either type works; fine-grained tokens won't show traditional scopes in the verification above. +
+ ADE needs a few GitHub permissions to work on your behalf. Either token type works — fine-grained tokens are recommended for tighter control. + Fine-grained tokens also need Metadata: Read so ADE can inspect repository metadata alongside the other permissions below. +
+
+
+ + + Pull requests — create PRs, request reviewers, and post review comments + +
+
+ + + Contents — read repository files and push branch changes + +
+
+ + + Workflows — inspect CI check results and trigger re-runs + +
+
+ + + Organization — read org members to suggest reviewers + +
diff --git a/apps/desktop/src/renderer/components/settings/LinearSection.test.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.test.tsx new file mode 100644 index 000000000..18b291c7c --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/LinearSection.test.tsx @@ -0,0 +1,117 @@ +/* @vitest-environment jsdom */ + +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LinearSection } from "./LinearSection"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe("LinearSection", () => { + const originalAde = globalThis.window.ade; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + globalThis.window.ade = originalAde; + }); + + it("keeps the newer connected state when an earlier status load resolves late", async () => { + const initialStatus = deferred(); + const getLinearConnectionStatus = vi.fn().mockImplementationOnce(() => initialStatus.promise); + const getLinearProjects = vi.fn().mockResolvedValue([ + { id: "project-1", name: "Core platform", teamName: "ADE" }, + ]); + const setLinearToken = vi.fn().mockResolvedValue({ + connected: true, + tokenStored: true, + authMode: "token", + viewerName: "Taylor", + message: null, + oauthAvailable: true, + projectCount: 1, + }); + + globalThis.window.ade = { + cto: { + getLinearConnectionStatus, + getLinearProjects, + setLinearToken, + startLinearOAuth: vi.fn(), + getLinearOAuthSession: vi.fn(), + clearLinearToken: vi.fn(), + }, + app: { + openExternal: vi.fn(), + }, + } as any; + + render(); + + fireEvent.change(screen.getByLabelText("Linear API key"), { target: { value: "lin_api_test" } }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => { + expect(setLinearToken).toHaveBeenCalledWith({ token: "lin_api_test" }); + }); + expect(await screen.findByText("Connected to Linear")).toBeTruthy(); + expect(await screen.findByText("Core platform")).toBeTruthy(); + + await act(async () => { + initialStatus.resolve({ + connected: false, + tokenStored: false, + authMode: null, + viewerName: null, + message: null, + oauthAvailable: true, + projectCount: 0, + }); + await initialStatus.promise; + }); + + expect(screen.getByText("Connected to Linear")).toBeTruthy(); + expect(screen.getByText("Core platform")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Connect" })).toBeNull(); + }); + + it("does not start OAuth when the browser bridge is unavailable", async () => { + const startLinearOAuth = vi.fn(); + + globalThis.window.ade = { + cto: { + getLinearConnectionStatus: vi.fn().mockResolvedValue({ + connected: false, + tokenStored: false, + authMode: null, + viewerName: null, + message: null, + oauthAvailable: true, + projectCount: 0, + }), + getLinearProjects: vi.fn(), + setLinearToken: vi.fn(), + startLinearOAuth, + getLinearOAuthSession: vi.fn(), + clearLinearToken: vi.fn(), + }, + app: {}, + } as any; + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Sign in with Linear" })); + + await waitFor(() => { + expect(startLinearOAuth).not.toHaveBeenCalled(); + expect(screen.getByText("Browser sign-in is not available in this ADE build.")).toBeTruthy(); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/settings/LinearSection.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.tsx index 516b8a7f3..f6b176feb 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -1,98 +1,533 @@ -import { useMemo, useState, type CSSProperties } from "react"; -import { CheckCircle, Info, WarningCircle } from "@phosphor-icons/react"; -import type { LinearConnectionStatus } from "../../../shared/types"; -import { LinearConnectionPanel } from "../cto/LinearConnectionPanel"; -import { COLORS, SANS_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowsLeftRight, + ArrowsClockwise, + ArrowSquareOut, + CheckCircle, + CircleNotch, + Key, + Lightning, + Plugs, + XCircle, +} from "@phosphor-icons/react"; +import type { CtoLinearProject, LinearConnectionStatus } from "../../../shared/types"; +import { COLORS, SANS_FONT, MONO_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; +import { Button } from "../ui/Button"; + +const LINEAR_BRAND = "#5E6AD2"; +const LINEAR_API_SETTINGS_URL = "https://linear.app/settings/api"; +const FEATURES = [ + { icon: ArrowsLeftRight, title: "Issue Routing", desc: "Link Linear issues to ADE lanes automatically" }, + { icon: Lightning, title: "CTO Workflows", desc: "Dispatch missions directly from Linear" }, + { icon: ArrowsClockwise, title: "Status Sync", desc: "Keep statuses in sync across both tools" }, +]; export function LinearSection() { const [connection, setConnection] = useState(null); - const [panelReloadToken] = useState(0); + const [projects, setProjects] = useState([]); + const [tokenInput, setTokenInput] = useState(""); + const [validating, setValidating] = useState(false); + const [oauthStarting, setOauthStarting] = useState(false); + const [oauthSessionId, setOauthSessionId] = useState(null); + const [error, setError] = useState(null); + const validatingRef = useRef(false); + const oauthStartingRef = useRef(false); + const oauthSessionIdRef = useRef(null); + const requestEpochRef = useRef(0); + + const invalidateLoadRequests = useCallback(() => { + requestEpochRef.current += 1; + return requestEpochRef.current; + }, []); + + const isCurrentLoadRequest = useCallback((requestId: number) => requestEpochRef.current === requestId, []); + + const setValidatingState = useCallback((value: boolean) => { + validatingRef.current = value; + setValidating(value); + }, []); + + const setOauthStartingState = useCallback((value: boolean) => { + oauthStartingRef.current = value; + setOauthStarting(value); + }, []); + + const setOauthSessionIdState = useCallback((value: string | null) => { + if (oauthSessionIdRef.current !== value) { + invalidateLoadRequests(); + } + oauthSessionIdRef.current = value; + setOauthSessionId(value); + }, [invalidateLoadRequests]); const isConnected = Boolean(connection?.connected); - const oauthConfigured = connection?.oauthAvailable === true; const authModeLabel = useMemo(() => { if (!connection?.authMode) return null; return connection.authMode === "oauth" ? "OAuth" : "API key"; }, [connection?.authMode]); - const noticeStyle: CSSProperties = { - background: `${COLORS.warning}08`, - border: `1px solid ${COLORS.warning}18`, - padding: "10px 14px", - fontSize: 11, - fontFamily: SANS_FONT, - color: COLORS.textSecondary, - borderRadius: 10, - lineHeight: "18px", - display: "flex", - alignItems: "flex-start", - gap: 10, - }; + /* ── Load helpers ── */ + const loadProjects = useCallback(async (requestIdArg?: number) => { + if (!window.ade?.cto) return; + const requestId = requestIdArg ?? invalidateLoadRequests(); + try { + const nextProjects = await window.ade.cto.getLinearProjects(); + if (!isCurrentLoadRequest(requestId)) return; + setProjects(nextProjects); + } catch { + if (!isCurrentLoadRequest(requestId)) return; + setProjects([]); + } + }, [invalidateLoadRequests, isCurrentLoadRequest]); + + const loadStatus = useCallback(async () => { + if (!window.ade?.cto) return; + const requestId = invalidateLoadRequests(); + try { + const status = await window.ade.cto.getLinearConnectionStatus(); + if (!isCurrentLoadRequest(requestId)) return; + setConnection(status); + if (status.connected) { + if (isCurrentLoadRequest(requestId)) { + void loadProjects(requestId); + } + } else { + setProjects([]); + } + } catch { + if (!isCurrentLoadRequest(requestId)) return; + setConnection(null); + setProjects([]); + } + }, [invalidateLoadRequests, isCurrentLoadRequest, loadProjects]); + + /* ── Initial load ── */ + useEffect(() => { + void loadStatus(); + }, [loadStatus]); + + /* ── OAuth polling ── */ + useEffect(() => { + if (!oauthSessionId) return; + const activeSessionId = oauthSessionId; + const cto = window.ade?.cto; + if (!cto) { + setOauthSessionIdState(null); + setOauthStartingState(false); + setError("Linear integration is unavailable in this environment."); + return; + } + + let active = true; + let timer: number | null = null; + let timeout: number | null = null; + + const poll = async () => { + try { + const session = await cto.getLinearOAuthSession({ sessionId: activeSessionId }); + if (!active || oauthSessionIdRef.current !== activeSessionId) return; + if (session.status === "completed") { + setOauthSessionIdState(null); + setOauthStartingState(false); + setConnection(session.connection ?? null); + setError(null); + if (session.connection?.connected) void loadProjects(); + else void loadStatus(); + return; + } + if (session.status === "failed" || session.status === "expired") { + setOauthSessionIdState(null); + setOauthStartingState(false); + setError(session.error ?? "OAuth failed."); + } + } catch (err) { + if (!active || oauthSessionIdRef.current !== activeSessionId) return; + setOauthSessionIdState(null); + setOauthStartingState(false); + setError(err instanceof Error ? err.message : "OAuth failed."); + } + }; + void poll(); + timer = window.setInterval(() => void poll(), 1500); + timeout = window.setTimeout(() => { + if (!active || oauthSessionIdRef.current !== activeSessionId) return; + setOauthSessionIdState(null); + setOauthStartingState(false); + setError("OAuth timed out. Please try again."); + }, 5 * 60 * 1000); + return () => { + active = false; + if (timer != null) clearInterval(timer); + if (timeout != null) clearTimeout(timeout); + }; + }, [loadProjects, loadStatus, oauthSessionId, setOauthSessionIdState, setOauthStartingState]); + + /* ── Handlers ── */ + const handleValidate = useCallback(async () => { + const submittedToken = tokenInput.trim(); + if ( + !window.ade?.cto + || !submittedToken + || validatingRef.current + || oauthStartingRef.current + || oauthSessionIdRef.current + ) { + return; + } + const requestId = invalidateLoadRequests(); + setValidatingState(true); + setError(null); + try { + const status = await window.ade.cto.setLinearToken({ token: submittedToken }); + if ( + !validatingRef.current + || oauthStartingRef.current + || oauthSessionIdRef.current + || !isCurrentLoadRequest(requestId) + ) { + return; + } + setConnection(status); + if (status.connected) { + void loadProjects(requestId); + setTokenInput(""); + } else { + setError(status.message ?? "Token validation failed."); + } + } catch (err) { + if ( + !validatingRef.current + || oauthStartingRef.current + || oauthSessionIdRef.current + || !isCurrentLoadRequest(requestId) + ) { + return; + } + setError(err instanceof Error ? err.message : "Validation failed."); + } finally { + if (validatingRef.current) { + setValidatingState(false); + } + } + }, [invalidateLoadRequests, isCurrentLoadRequest, loadProjects, setValidatingState, tokenInput]); + + const handleStartOAuth = useCallback(async () => { + if (oauthSessionIdRef.current) { + setOauthSessionIdState(null); + setOauthStartingState(false); + return; + } + const cto = window.ade?.cto; + const openExternal = window.ade?.app?.openExternal; + if (!cto || validatingRef.current || oauthStartingRef.current) return; + if (!openExternal) { + setOauthSessionIdState(null); + setOauthStartingState(false); + setError("Browser sign-in is not available in this ADE build."); + return; + } + invalidateLoadRequests(); + setOauthStartingState(true); + setError(null); + try { + const session = await cto.startLinearOAuth(); + if (!oauthStartingRef.current || validatingRef.current) return; + await openExternal(session.authUrl); + if (!oauthStartingRef.current || validatingRef.current) return; + setOauthSessionIdState(session.sessionId); + } catch (err) { + if (!oauthStartingRef.current) return; + setOauthStartingState(false); + setError(err instanceof Error ? err.message : "Unable to start OAuth."); + } + }, [invalidateLoadRequests, setOauthSessionIdState, setOauthStartingState]); + + const handleDisconnect = useCallback(async () => { + if (!window.ade?.cto) return; + invalidateLoadRequests(); + try { + const status = await window.ade.cto.clearLinearToken(); + setConnection(status); + setProjects([]); + setTokenInput(""); + setError(null); + setOauthSessionIdState(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to disconnect Linear."); + } finally { + setValidatingState(false); + setOauthStartingState(false); + } + }, [invalidateLoadRequests, setOauthSessionIdState, setOauthStartingState, setValidatingState]); return ( -
-
-
-
-
STATUS
-
- {isConnected ? : } - {isConnected ? "Connected" : "Not connected"} -
-
- {isConnected - ? `Signed in${connection?.viewerName ? ` as ${connection.viewerName}` : ""}${authModeLabel ? ` via ${authModeLabel}` : ""}${connection?.projectCount ? ` · ${connection.projectCount} project${connection.projectCount === 1 ? "" : "s"} visible` : ""}.` - : "Use browser sign-in or an API key to connect."} -
- {isConnected && (connection?.projectPreview?.length ?? 0) > 0 ? ( -
- Projects: {connection?.projectPreview?.join(", ")} +
+ + {/* ── Connected State ── */} + {isConnected ? ( +
+
+
+
+ +
+
+
+ Connected to Linear +
+
+ {connection?.viewerName ? `Signed in as ${connection.viewerName}` : "Signed in"} + {authModeLabel ? ` via ${authModeLabel}` : ""} + {connection?.projectCount ? ` · ${connection.projectCount} project${connection.projectCount === 1 ? "" : "s"}` : ""} +
- ) : null} +
+
+ + {/* Project list */} + {projects.length > 0 ? ( +
+
+ PROJECTS ({projects.length}) +
+
+ {projects.map((p) => ( + + {p.name} + {p.teamName} + + ))} +
+
+ ) : null} +
+ ) : ( + <> + {/* ── Disconnected: Connection Methods ── */}
-
BROWSER SIGN-IN
-
- {oauthConfigured ? : } - {oauthConfigured ? "Ready" : "Not configured"} + {/* OAuth — recommended */} +
+
+ Recommended +
+
+ +
+
+
+ Sign in with Linear +
+
+ Opens Linear in your browser for a secure OAuth flow. No keys to manage. +
+
+ + {connection?.oauthAvailable === false ? ( +
+ Browser sign-in is not available in this ADE build. +
+ ) : null}
-
- ADE opens Linear in the browser and handles sign-in locally. + + {/* API Key — manual */} +
+
+ +
+
+
+ API Key +
+
+ Paste a personal API key from your Linear settings. Good if OAuth isn't working. +
+
+
+ setTokenInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !validating && !oauthStarting && !oauthSessionId && tokenInput.trim()) { + e.preventDefault(); + void handleValidate(); + } + }} + style={{ + flex: 1, height: 36, borderRadius: 8, + background: "rgba(255,255,255,0.03)", + border: `1px solid ${COLORS.border}`, + padding: "0 12px", fontSize: 12, fontFamily: MONO_FONT, + color: COLORS.textPrimary, outline: "none", + transition: "border-color 0.15s", + }} + onFocus={(e) => { e.currentTarget.style.borderColor = `${LINEAR_BRAND}50`; }} + onBlur={(e) => { e.currentTarget.style.borderColor = COLORS.border; }} + /> + +
+
-
+ + )} -
- - - Known Linear OAuth issue:{" "} - Clicking “Authorize” sometimes redirects back to the same page. - If this happens, return to ADE, switch away from this tab, then come back and try again. Switching browsers can also help. - + {/* ── Error ── */} + {error ? ( +
+ + {error}
+ ) : null} -
- + {/* ── Feature Preview ── */} +
+
+ WHAT LINEAR INTEGRATION ENABLES +
+
+ {FEATURES.map(({ icon: Icon, title, desc }) => ( +
+
+ +
+
+
+ {title} +
+
+ {desc} +
+
+
+ ))}
diff --git a/apps/desktop/src/renderer/components/settings/MemoryHealthTab.test.tsx b/apps/desktop/src/renderer/components/settings/MemoryHealthTab.test.tsx index aa61e8f03..34fd44d98 100644 --- a/apps/desktop/src/renderer/components/settings/MemoryHealthTab.test.tsx +++ b/apps/desktop/src/renderer/components/settings/MemoryHealthTab.test.tsx @@ -28,6 +28,10 @@ function createHealthStats() { model: { modelId: "Xenova/all-MiniLM-L6-v2", state: "idle", + activity: "idle", + installState: "missing", + cacheDir: null, + installPath: null, progress: null, loaded: null, total: null, @@ -171,4 +175,37 @@ describe("MemoryHealthTab", () => { expect(screen.queryByText(/Imported skill: finalize/i)).toBeNull(); expect(screen.getByText("1 memory")).toBeTruthy(); }); + + it("treats an on-disk model as unverified until ADE loads it successfully", async () => { + const ade = window.ade as any; + ade.memory.getHealthStats.mockResolvedValue({ + ...createHealthStats(), + embeddings: { + ...createHealthStats().embeddings, + model: { + modelId: "Xenova/all-MiniLM-L6-v2", + state: "idle", + activity: "idle", + installState: "installed", + cacheDir: "/tmp/mock-transformers-cache", + installPath: "/tmp/mock-transformers-cache/Xenova/all-MiniLM-L6-v2", + progress: null, + loaded: null, + total: null, + file: null, + error: null, + }, + }, + }); + + render( + + + , + ); + + expect(await screen.findByText(/Model found on disk/i)).toBeTruthy(); + expect(screen.getByText(/smart search turns active only after a local verification succeeds/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /verify model/i })).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/renderer/components/settings/MemoryHealthTab.tsx b/apps/desktop/src/renderer/components/settings/MemoryHealthTab.tsx index 4f8abe85e..81b440978 100644 --- a/apps/desktop/src/renderer/components/settings/MemoryHealthTab.tsx +++ b/apps/desktop/src/renderer/components/settings/MemoryHealthTab.tsx @@ -125,7 +125,19 @@ function createEmptyHealthStats(): MemoryHealthStats { cacheHits: 0, cacheMisses: 0, cacheHitRate: 0, - model: { modelId: "Xenova/all-MiniLM-L6-v2", state: "idle", progress: null, loaded: null, total: null, file: null, error: null }, + model: { + modelId: "Xenova/all-MiniLM-L6-v2", + state: "idle", + activity: "idle", + installState: "missing", + cacheDir: null, + installPath: null, + progress: null, + loaded: null, + total: null, + file: null, + error: null, + }, }, }; } @@ -325,6 +337,16 @@ function embeddingsReady(stats: MemoryHealthStats): boolean { return stats.embeddings.model.state === "ready"; } +function getEmbeddingVisualState(model: MemoryHealthStats["embeddings"]["model"]) { + if (model.state === "ready") return "ready" as const; + if (model.state === "loading" && (model.activity === "loading-local" || model.installState === "installed")) return "loading-local" as const; + if (model.state === "loading") return "downloading" as const; + if (model.state === "unavailable") return "error" as const; + if (model.installState === "installed") return "installed" as const; + if (model.installState === "partial") return "partial" as const; + return "missing" as const; +} + function shouldPollEmbeddings(stats: MemoryHealthStats): boolean { // Only poll during active model download or active batch processing if (stats.embeddings.model.state === "loading") return true; @@ -620,6 +642,7 @@ export function MemoryHealthTab() { const candidateEntries = searchInput.trim().length > 0 ? [] : visibleCandidateEntries.filter((e) => matchesFilters(e, scopeFilter, categoryFilter, "pending")); const embReady = embeddingsReady(stats); + const modelVisualState = getEmbeddingVisualState(stats.embeddings.model); const _embProgress = pct(stats.embeddings.entriesEmbedded, Math.max(stats.embeddings.entriesTotal, 1)); const modelDownloadPct = (() => { const { progress, loaded, total } = stats.embeddings.model; @@ -627,7 +650,22 @@ export function MemoryHealthTab() { if (typeof loaded === "number" && typeof total === "number" && total > 0) return pct(loaded, total); return 0; })(); - const showDownload = stats.embeddings.model.state !== "loading" && stats.embeddings.model.state !== "ready"; + const showDownload = modelVisualState !== "downloading" && modelVisualState !== "loading-local" && modelVisualState !== "ready"; + const installPath = stats.embeddings.model.installPath ?? stats.embeddings.model.cacheDir ?? null; + const installPathLabel = modelVisualState === "ready" + ? "Verified at" + : stats.embeddings.model.installState === "installed" + ? "Found on disk at" + : stats.embeddings.model.installState === "partial" + ? "Partial download at" + : "Installs to"; + const modelActionLabel = + stats.embeddings.model.installState === "partial" + || (stats.embeddings.model.state === "unavailable" && stats.embeddings.model.installState === "installed") + ? "Repair Model" + : stats.embeddings.model.installState === "installed" + ? "Verify Model" + : "Download Model"; /* ═══════════════════════════════════════════════════════════════════════ Data loading @@ -999,21 +1037,46 @@ export function MemoryHealthTab() {
- {embReady ? "Smart search is active" : stats.embeddings.model.state === "loading" ? "Downloading model..." : "Smart search not enabled"} + {embReady + ? "Smart search is active" + : modelVisualState === "loading-local" + ? "Loading installed model..." + : modelVisualState === "installed" + ? "Model found on disk" + : modelVisualState === "downloading" + ? "Downloading model..." + : modelVisualState === "partial" + ? "Model download needs repair" + : "Smart search not enabled"}
{embReady ? `${fmtNum(stats.embeddings.entriesEmbedded)} of ${fmtNum(stats.embeddings.entriesTotal)} memories indexed` - : "Download the model to enable meaning-based search"} + : modelVisualState === "loading-local" + ? "ADE found the installed model on this machine and is loading it from local cache" + : modelVisualState === "installed" + ? "ADE found model files on this machine; smart search turns active only after a local verification succeeds" + : modelVisualState === "partial" + ? "ADE found a partial model download; repair it to finish enabling smart search" + : "Download the model to enable meaning-based search"}
{showDownload ? ( ) : null}
- {stats.embeddings.model.state === "loading" ? ( + {installPath ? ( +
+ {installPathLabel}: {installPath} +
+ ) : null} + {modelVisualState === "loading-local" ? ( +
+ Loading the cached model locally. No new download is required. This usually finishes in a few seconds. +
+ ) : modelVisualState === "downloading" ? ( <>
{modelDownloadPct}%
diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 158035c34..2d4764cec 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -45,6 +45,7 @@ import { useAppStore, THEME_IDS } from "./appStore"; function resetStore() { useAppStore.setState({ project: null, + projectHydrated: false, showWelcome: true, lanes: [], selectedLaneId: null, @@ -98,6 +99,13 @@ describe("appStore", () => { expect(useAppStore.getState().project).toBe(project); }); + it("setProjectHydrated tracks whether startup project hydration finished", () => { + useAppStore.getState().setProjectHydrated(true); + expect(useAppStore.getState().projectHydrated).toBe(true); + useAppStore.getState().setProjectHydrated(false); + expect(useAppStore.getState().projectHydrated).toBe(false); + }); + it("setShowWelcome toggles the welcome screen flag", () => { useAppStore.getState().setShowWelcome(false); expect(useAppStore.getState().showWelcome).toBe(false); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 23a050910..1de9b7e21 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -86,6 +86,7 @@ function persistTheme(theme: ThemeId) { type AppState = { project: ProjectInfo | null; + projectHydrated: boolean; /** True when the user removed all projects — forces welcome screen even though backend still has a project loaded. */ showWelcome: boolean; lanes: LaneSummary[]; @@ -102,6 +103,7 @@ type AppState = { laneWorkViewByScope: Record; setProject: (project: ProjectInfo | null) => void; + setProjectHydrated: (hydrated: boolean) => void; setShowWelcome: (show: boolean) => void; setLanes: (lanes: LaneSummary[]) => void; selectLane: (laneId: string | null) => void; @@ -162,6 +164,7 @@ function scheduleProjectHydration(get: () => AppState) { export const useAppStore = create((set, get) => ({ project: null, + projectHydrated: false, showWelcome: true, lanes: [], selectedLaneId: null, @@ -177,6 +180,7 @@ export const useAppStore = create((set, get) => ({ laneWorkViewByScope: {}, setProject: (project) => set({ project }), + setProjectHydrated: (projectHydrated) => set({ projectHydrated }), setShowWelcome: (showWelcome) => set({ showWelcome }), setLanes: (lanes) => set({ lanes }), selectLane: (laneId) => set({ selectedLaneId: laneId }), @@ -252,7 +256,7 @@ export const useAppStore = create((set, get) => ({ refreshProject: async () => { const project = await window.ade.app.getProject(); - set({ project }); + set({ project, projectHydrated: true }); }, refreshLanes: async (options) => { @@ -323,6 +327,7 @@ export const useAppStore = create((set, get) => ({ if (!project) return null; set({ project, + projectHydrated: true, showWelcome: false, lanes: [], selectedLaneId: null, @@ -344,6 +349,7 @@ export const useAppStore = create((set, get) => ({ const project = await window.ade.project.switchToPath(rootPath); set({ project, + projectHydrated: true, showWelcome: false, lanes: [], selectedLaneId: null, @@ -364,6 +370,7 @@ export const useAppStore = create((set, get) => ({ await window.ade.project.closeCurrent(); set({ project: null, + projectHydrated: true, showWelcome: true, lanes: [], selectedLaneId: null, diff --git a/apps/desktop/src/shared/githubScopes.test.ts b/apps/desktop/src/shared/githubScopes.test.ts new file mode 100644 index 000000000..a3e620390 --- /dev/null +++ b/apps/desktop/src/shared/githubScopes.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + getGitHubTokenAccessState, + parseGitHubScopeHeaders, +} from "./githubScopes"; + +function createHeaders(values: Record): Pick { + const lowered = new Map(Object.entries(values).map(([key, value]) => [key.toLowerCase(), value])); + return { + get(name: string) { + return lowered.get(name.toLowerCase()) ?? null; + }, + }; +} + +describe("githubScopes", () => { + it("returns only the granted OAuth scopes from the response headers", () => { + const scopes = parseGitHubScopeHeaders(createHeaders({ + "x-oauth-scopes": "repo, workflow", + "x-accepted-oauth-scopes": "Read:Org, workflow", + "x-accepted-scopes": "checks=read", + })); + + expect(scopes).toEqual(["repo", "workflow"]); + }); + + it("does not treat classic sub-scopes as satisfying the top-level requirement", () => { + const access = getGitHubTokenAccessState([ + "repo:status", + "workflow", + "read:org", + ]); + + expect(access.hasRequiredAccess).toBe(false); + expect(access.requirements.repo.present).toBe(false); + expect(access.missingClassicScopes).toEqual(["repo"]); + }); + + it("treats valid fine-grained permissions as full access", () => { + const access = getGitHubTokenAccessState([ + "Contents=write", + "PULL_REQUESTS=write", + "Actions=write", + "Members=read", + ]); + + expect(access.hasRequiredAccess).toBe(true); + expect(access.usesFineGrainedPermissions).toBe(true); + expect(access.missingDescriptions).toEqual([]); + expect(access.requirements.repo.present).toBe(true); + expect(access.requirements.workflow.present).toBe(true); + expect(access.requirements["read:org"].present).toBe(true); + }); + + it("reports the missing fine-grained permissions when access is incomplete", () => { + const access = getGitHubTokenAccessState([ + "contents=write", + "pull_requests=write", + "checks=read", + ]); + + expect(access.hasRequiredAccess).toBe(false); + expect(access.missingDescriptions).toEqual(["Members"]); + expect(access.missingClassicScopes).toEqual(["read:org"]); + }); +}); diff --git a/apps/desktop/src/shared/githubScopes.ts b/apps/desktop/src/shared/githubScopes.ts new file mode 100644 index 000000000..c647827e6 --- /dev/null +++ b/apps/desktop/src/shared/githubScopes.ts @@ -0,0 +1,109 @@ +export const REQUIRED_GITHUB_CLASSIC_SCOPES = [ + "repo", + "workflow", + "read:org", +] as const; + +export type GitHubClassicScope = (typeof REQUIRED_GITHUB_CLASSIC_SCOPES)[number]; + +type ScopeRequirementState = { + id: GitHubClassicScope; + present: boolean; +}; + +export type GitHubTokenAccessState = { + normalizedScopes: string[]; + usesFineGrainedPermissions: boolean; + hasRequiredAccess: boolean; + missingClassicScopes: GitHubClassicScope[]; + missingDescriptions: string[]; + requirements: Record; +}; + +const REPO_FINE_GRAINED_PERMISSIONS = ["contents", "pull_requests"] as const; +const WORKFLOW_FINE_GRAINED_PERMISSIONS = ["workflow", "workflows", "actions", "checks"] as const; +const ORG_FINE_GRAINED_PERMISSIONS = ["read:org", "admin:org", "members", "organization_members", "read_org"] as const; +const FINE_GRAINED_PERMISSION_PREFIXES = [ + ...REPO_FINE_GRAINED_PERMISSIONS, + ...WORKFLOW_FINE_GRAINED_PERMISSIONS, + ...ORG_FINE_GRAINED_PERMISSIONS, + "metadata", +] as const; + +function normalizeScopeToken(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ""); +} + +function splitHeaderScopes(value: string | null | undefined): string[] { + if (!value) return []; + return value + .split(",") + .map(normalizeScopeToken) + .filter(Boolean); +} + +function hasScopeLike(normalizedScopes: Set, candidate: string): boolean { + return [...normalizedScopes].some((scope) => ( + scope === candidate + || scope.startsWith(`${candidate}=`) + )); +} + +function hasAnyScopeLike(normalizedScopes: Set, candidates: readonly string[]): boolean { + return candidates.some((candidate) => hasScopeLike(normalizedScopes, candidate)); +} + +export function parseGitHubScopeHeaders(headers: Pick): string[] { + return splitHeaderScopes(headers.get("x-oauth-scopes")); +} + +export function getGitHubTokenAccessState(scopes: Iterable): GitHubTokenAccessState { + const normalizedScopes = new Set( + [...scopes] + .map((value) => normalizeScopeToken(String(value ?? ""))) + .filter(Boolean), + ); + + const repoPresent = hasScopeLike(normalizedScopes, "repo") + || REPO_FINE_GRAINED_PERMISSIONS.every((permission) => hasScopeLike(normalizedScopes, permission)); + const workflowPresent = hasAnyScopeLike(normalizedScopes, WORKFLOW_FINE_GRAINED_PERMISSIONS); + const orgPresent = hasAnyScopeLike(normalizedScopes, ORG_FINE_GRAINED_PERMISSIONS); + + const usesFineGrainedPermissions = FINE_GRAINED_PERMISSION_PREFIXES.some((candidate) => + hasScopeLike(normalizedScopes, candidate), + ); + + const missingClassicScopes = REQUIRED_GITHUB_CLASSIC_SCOPES.filter((scope) => { + switch (scope) { + case "repo": + return !repoPresent; + case "workflow": + return !workflowPresent; + case "read:org": + return !orgPresent; + default: + return true; + } + }); + + const missingDescriptions = usesFineGrainedPermissions + ? [ + !repoPresent ? "Contents and Pull requests" : null, + !workflowPresent ? "Actions/Workflows or Checks" : null, + !orgPresent ? "Members" : null, + ].filter((value): value is string => Boolean(value)) + : [...missingClassicScopes]; + + return { + normalizedScopes: [...normalizedScopes], + usesFineGrainedPermissions, + hasRequiredAccess: missingClassicScopes.length === 0, + missingClassicScopes, + missingDescriptions, + requirements: { + repo: { id: "repo", present: repoPresent }, + workflow: { id: "workflow", present: workflowPresent }, + "read:org": { id: "read:org", present: orgPresent }, + }, + }; +} diff --git a/apps/desktop/src/shared/types/memory.ts b/apps/desktop/src/shared/types/memory.ts index a90adbb75..32443b2cc 100644 --- a/apps/desktop/src/shared/types/memory.ts +++ b/apps/desktop/src/shared/types/memory.ts @@ -153,10 +153,16 @@ export type MemoryHealthScopeStats = { export type MemorySearchMode = "lexical" | "hybrid"; export type MemoryEmbeddingModelState = "idle" | "loading" | "ready" | "unavailable"; +export type MemoryEmbeddingInstallState = "missing" | "partial" | "installed"; +export type MemoryEmbeddingModelActivity = "idle" | "loading-local" | "downloading" | "ready" | "error"; export type MemoryEmbeddingModelStatus = { modelId: string; state: MemoryEmbeddingModelState; + activity: MemoryEmbeddingModelActivity; + installState: MemoryEmbeddingInstallState; + cacheDir: string | null; + installPath: string | null; progress: number | null; loaded: number | null; total: number | null;