From 27eeeb00a96c0f9406b7df3b93f10c6563e5cb8c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:19:12 -0400 Subject: [PATCH 1/6] Onboarding fixes: CLI resolution, embeddings, settings UI, and test coverage Refactor CLI executable resolution, improve onboarding sections (DevTools, Embeddings), enhance settings panels (AI Features, GitHub, Linear, Memory Health), add Codex executable support, and expand test coverage across services and UI components. Co-Authored-By: Claude Opus 4.6 (1M context) --- .ade/ade.yaml | 28 +- .ade/cto/identity.yaml | 17 +- apps/desktop/src/main/main.ts | 40 +- .../services/ai/aiIntegrationService.test.ts | 32 +- .../main/services/ai/aiIntegrationService.ts | 2 +- .../src/main/services/ai/authDetector.test.ts | 94 ++++ .../src/main/services/ai/authDetector.ts | 47 +- .../main/services/ai/claudeCodeExecutable.ts | 54 +-- .../services/ai/cliExecutableResolver.test.ts | 69 +++ .../main/services/ai/cliExecutableResolver.ts | 210 ++++++++ .../ai/cliExecutableShellPath.test.ts | 65 +++ .../main/services/ai/codexExecutable.test.ts | 26 + .../src/main/services/ai/codexExecutable.ts | 36 ++ .../services/ai/providerConnectionStatus.ts | 2 +- .../main/services/ai/providerResolver.test.ts | 8 + .../src/main/services/ai/providerResolver.ts | 4 + .../automations/automationPlannerService.ts | 6 +- .../main/services/chat/agentChatService.ts | 4 +- .../cto/workerAdapterRuntimeService.test.ts | 3 +- .../cto/workerAdapterRuntimeService.ts | 3 +- .../services/devTools/devToolsService.test.ts | 69 +++ .../main/services/devTools/devToolsService.ts | 10 +- .../src/main/services/ipc/registerIpc.ts | 16 +- .../services/memory/embeddingService.test.ts | 233 ++++++++- .../main/services/memory/embeddingService.ts | 134 +++++- .../desktop/src/main/services/shared/utils.ts | 3 +- apps/desktop/src/renderer/browserMock.ts | 8 + .../src/renderer/components/app/App.test.tsx | 84 ++++ .../src/renderer/components/app/App.tsx | 19 +- .../src/renderer/components/app/AppShell.tsx | 11 +- .../src/renderer/components/app/TopBar.tsx | 6 +- .../onboarding/DevToolsSection.test.tsx | 73 +++ .../components/onboarding/DevToolsSection.tsx | 99 +++- .../onboarding/EmbeddingsSection.test.tsx | 174 +++++++ .../onboarding/EmbeddingsSection.tsx | 193 +++++++- .../onboarding/ProjectSetupPage.tsx | 267 ++++++----- .../components/settings/AiFeaturesSection.tsx | 173 ++++--- .../components/settings/GitHubSection.tsx | 117 +++-- .../components/settings/LinearSection.tsx | 450 +++++++++++++++--- .../settings/MemoryHealthTab.test.tsx | 37 ++ .../components/settings/MemoryHealthTab.tsx | 75 ++- .../src/renderer/state/appStore.test.ts | 8 + apps/desktop/src/renderer/state/appStore.ts | 9 +- apps/desktop/src/shared/types/memory.ts | 6 + 44 files changed, 2499 insertions(+), 525 deletions(-) create mode 100644 apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts create mode 100644 apps/desktop/src/main/services/ai/cliExecutableResolver.ts create mode 100644 apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts create mode 100644 apps/desktop/src/main/services/ai/codexExecutable.test.ts create mode 100644 apps/desktop/src/main/services/ai/codexExecutable.ts create mode 100644 apps/desktop/src/main/services/devTools/devToolsService.test.ts create mode 100644 apps/desktop/src/renderer/components/app/App.test.tsx create mode 100644 apps/desktop/src/renderer/components/onboarding/DevToolsSection.test.tsx create mode 100644 apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.test.tsx 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..30285ce98 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,39 +113,11 @@ 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(":")}`; - } - } + augmentProcessPathWithShellAndKnownCliDirs({ + env: process.env, + includeInteractiveShell: true, + timeoutMs: 1_500, + }); } // Must run before any service or child process is created. diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index 22c728036..23fe72bbb 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,24 @@ describe("aiIntegrationService", () => { expect(usageInsertCalls(runCalls)).toHaveLength(1); }); - it("treats commit_messages as opt-in until explicitly enabled", () => { + it("treats all features as opt-in until explicitly enabled", () => { 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(false); + expect(service.getFeatureFlag("pr_descriptions")).toBe(false); 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 +256,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 +274,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 +292,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 +310,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 +340,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..40ec8d736 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -462,7 +462,7 @@ export function createAiIntegrationService(args: { const features = isRecord(aiConfig.features) ? aiConfig.features : {}; const value = features[feature]; if (value == null) { - return feature === "commit_messages" ? false : true; + return false; } return Boolean(value); }; diff --git a/apps/desktop/src/main/services/ai/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index 3161519f8..1f1832888 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), }; }); @@ -58,9 +63,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 }; @@ -69,6 +76,10 @@ describe("authDetector", () => { afterEach(() => { process.env = { ...originalEnv }; vi.unstubAllGlobals(); + if (tempHomeDir) { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + tempHomeDir = null; + } }); it("reports installed-but-unauthenticated CLI providers", async () => { @@ -236,6 +247,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..e632ff3ac 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,25 +161,11 @@ 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(":"); - } + augmentProcessPathWithShellAndKnownCliDirs({ + env: process.env, + includeInteractiveShell: true, + timeoutMs: 2_000, + }); } /** JSON fields that indicate a positive login state across CLI versions. */ 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..431360082 --- /dev/null +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts @@ -0,0 +1,69 @@ +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"; + +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); +} + +describe("cliExecutableResolver", () => { + let tempRoot: string | null = null; + + afterEach(() => { + 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"); + }); +}); 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..6d3252cd0 --- /dev/null +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -0,0 +1,210 @@ +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): string | null { + for (const dir of dirs) { + const candidatePath = path.join(dir, command); + 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, +): string | null { + try { + const raw = execFileSync( + shellPath, + [shellFlag, `printf '${PATH_MARKER_START}%s${PATH_MARKER_END}' "$PATH"`], + { + encoding: "utf-8", + 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/zsh"; + const timeoutMs = args?.timeoutMs ?? 1_000; + const loginPath = readShellPath(shellPath, "-lc", timeoutMs); + const interactivePath = args?.includeInteractiveShell + ? readShellPath(shellPath, "-ic", timeoutMs) + : null; + + const nextPath = augmentPathWithKnownCliDirs( + mergePathEntries(env.PATH, loginPath, interactivePath), + env, + ); + if (nextPath.length > 0) { + env.PATH = nextPath; + } + return env.PATH ?? ""; +} + +export function resolveExecutableFromKnownLocations( + command: string, + env: NodeJS.ProcessEnv = process.env, +): ResolvedExecutable | null { + const fromPath = resolveFromDirs(command, splitPathEntries(env.PATH)); + if (fromPath) { + return { path: fromPath, source: "path" }; + } + + const fromKnownDirs = resolveFromDirs(command, getKnownBinDirs(command, 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..d61f0425e --- /dev/null +++ b/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts @@ -0,0 +1,65 @@ +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(); + ({ augmentProcessPathWithShellAndKnownCliDirs } = await import("./cliExecutableResolver")); + }); + + afterEach(() => { + setPlatform(originalPlatform); + }); + + it("merges login and interactive shell PATH entries on macOS", () => { + setPlatform("darwin"); + 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(nextPath); + }); +}); 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..faf57d822 --- /dev/null +++ b/apps/desktop/src/main/services/ai/codexExecutable.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { resolveCodexExecutable } from "./codexExecutable"; + +describe("resolveCodexExecutable", () => { + it("uses the detected Codex auth path before falling back to PATH lookup", () => { + 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", + }); + }); +}); 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..d9d705607 --- /dev/null +++ b/apps/desktop/src/main/services/ai/codexExecutable.ts @@ -0,0 +1,36 @@ +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 authPath = findCodexAuthPath(args?.auth); + if (authPath) { + return { path: authPath, source: "auth" }; + } + + const resolved = resolveExecutableFromKnownLocations("codex", args?.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..9bed65ed5 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -27,6 +27,7 @@ import { } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import type { Logger } from "../logging/logger"; +import { resolveCodexExecutable } from "../ai/codexExecutable"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "../lanes/laneService"; import { resolvePathWithinRoot } from "../shared/utils"; @@ -369,9 +370,10 @@ async function runCodexExec(args: { cliArgs.push(args.prompt); - const commandPreview = ["codex", ...cliArgs.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a))].join(" "); + const codexExecutable = resolveCodexExecutable().path; + const commandPreview = [codexExecutable, ...cliArgs.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a))].join(" "); - const child = spawn("codex", cliArgs, { + const child = spawn(codexExecutable, cliArgs, { cwd: args.cwd, env: { ...process.env, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 8e078e184..62664202b 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,8 @@ export function createAgentChatService(args: { path: process.env.PATH ?? "", ...(adeMcpLaunch ? { adeMcpLaunch } : {}), }); - const proc = spawn("codex", ["app-server"], { + const codexExecutable = resolveCodexExecutable().path; + 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..1f890b22f --- /dev/null +++ b/apps/desktop/src/main/services/devTools/devToolsService.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Logger } from "../logging/logger"; + +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: `${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: "/opt/homebrew/bin/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/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 7b6d3e76f..e591856a0 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -852,6 +852,10 @@ function createEmptyMemoryHealthStats() { model: { modelId: "Xenova/all-MiniLM-L6-v2", state: "idle" as const, + activity: "idle" as const, + installState: "missing" as const, + cacheDir: null, + installPath: null, progress: null, loaded: null, total: null, @@ -909,6 +913,10 @@ function createEmptyMemoryHealthStats() { model: { modelId: string; state: "idle" | "loading" | "ready" | "unavailable"; + activity: "idle" | "loading-local" | "downloading" | "ready" | "error"; + installState: "missing" | "partial" | "installed"; + cacheDir: string | null; + installPath: string | null; progress: number | null; loaded: number | null; total: number | null; @@ -991,6 +999,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 +5463,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..f1465519e 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,206 @@ 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(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", + })); + }); }); diff --git a/apps/desktop/src/main/services/memory/embeddingService.ts b/apps/desktop/src/main/services/memory/embeddingService.ts index 536804132..ac08ef4ab 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,59 @@ function resolveCacheDir(cacheDir?: string): string { return path.resolve(path.join(app.getPath("userData"), "transformers-cache")); } +function resolveInstallPath(cacheDir: string, modelId: string): string { + return path.join(cacheDir, ...modelId.split("/").filter(Boolean)); +} + +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 +176,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 +201,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 +213,25 @@ 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; function getStatus(): EmbeddingServiceStatus { + const install = inspectInstallPath(installPath); return { modelId, cacheDir, + installPath, + installState: install.installState, state, + activity: deriveReportedActivity({ + state, + activity, + installState: install.installState, + }), progress, loaded, total, @@ -176,6 +260,13 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { } function handleProgress(event: EmbeddingProgressEvent) { + // 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 +280,14 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { emitStatus(); } - async function ensureExtractor(forceRetry = false): Promise { + async function ensureExtractor(opts: { forceRetry?: boolean; localFilesOnly?: boolean } = {}): Promise { + const forceRetry = opts.forceRetry === true; + const localFilesOnly = opts.localFilesOnly === true; if (extractor) return extractor; if (extractorPromise) return extractorPromise; if (forceRetry) { state = "idle"; + activity = "idle"; lastError = null; progress = null; loaded = null; @@ -204,7 +298,9 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { throw new EmbeddingUnavailableError(lastError); } + const install = inspectInstallPath(installPath); state = "loading"; + activity = localFilesOnly || install.installState === "installed" ? "loading-local" : "downloading"; progress = 0; loaded = null; total = null; @@ -217,15 +313,18 @@ 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, + local_files_only: localFilesOnly, }); + await runExtractorSmokeTest(nextExtractor); extractor = nextExtractor; state = "ready"; + activity = "ready"; progress = 100; emitStatus(); @@ -241,7 +340,12 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { extractorPromise = null; extractor = null; state = "unavailable"; - lastError = getErrorMessage(error); + activity = "error"; + lastError = normalizeLoadError({ + message: getErrorMessage(error), + installState: install.installState, + localFilesOnly, + }); progress = null; loaded = null; total = null; @@ -293,8 +397,8 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { } } - 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 +409,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 = inspectInstallPath(installPath); + 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 }); } 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..53a22edd0 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -113,7 +113,8 @@ 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]); + const lookupShell = process.env.SHELL || "/bin/zsh"; + 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 { diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 7f54e47d0..c60d3492f 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, @@ -1695,6 +1699,10 @@ if (typeof window !== "undefined" && !(window as any).ade) { model: { modelId: "Xenova/all-MiniLM-L6-v2", state: "ready", + activity: "ready", + installState: "installed", + cacheDir: "/tmp/mock-transformers-cache", + installPath: "/tmp/mock-transformers-cache/Xenova/all-MiniLM-L6-v2", progress: 100, loaded: 1, total: 1, 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..81fb25365 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,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { setProject(null); setProjectMissing(false); setShowWelcome(true); + } finally { + if (cancelled) return; + setProjectHydrated(true); } }; @@ -208,7 +213,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 +644,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..bbfda98bc --- /dev/null +++ b/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.test.tsx @@ -0,0 +1,174 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, 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(); + expect(screen.getByRole("button", { name: /verify model/i })).toBeTruthy(); + }); + + 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..d02759ee7 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,17 @@ 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; +} + export function EmbeddingsSection() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -38,7 +50,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 +69,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 +126,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 @@ -156,10 +260,21 @@ export function EmbeddingsSection() { {bytesLabel ? {bytesLabel} : null}
+ ) : visualState === "partial" ? ( +
+
+ ADE found a partial model download on this machine. Repair it to finish enabling semantic search. +
+
+ +
+
) : (
)} @@ -181,7 +296,10 @@ export function EmbeddingsSection() {
{/* How it works explainer */} -
+
How it works
@@ -189,9 +307,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..1395c4b15 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"; @@ -185,8 +191,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); @@ -267,7 +271,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 +400,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 +523,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..a5c861ede 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 = { @@ -283,9 +287,9 @@ 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. +
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.
@@ -299,10 +303,10 @@ export function AiFeaturesSection() { borderBottom: `1px solid ${COLORS.border}`, }} > -
ON
-
FEATURE
-
MODEL
-
TODAY
+
ON
+
FEATURE
+
MODEL
+
TODAY
{FEATURES.map((feature, index) => { @@ -311,54 +315,72 @@ export function AiFeaturesSection() { const dailyUsage = row?.dailyUsage ?? 0; const selectedModel = featureModels[feature.key] ?? ""; const needsModelSelection = enabled && !selectedModel; + const IconComponent = feature.icon; return (
{ e.currentTarget.style.background = COLORS.hoverBg; }} + onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }} > handleToggle(feature.key, value)} /> -
-
+ - {feature.label} -
-
- {feature.description} -
- {needsModelSelection ? ( + /> +
- Select a model to enable this feature. + {feature.label}
- ) : null} +
+ {feature.subtitle} +
+ {needsModelSelection ? ( +
+ Select a model to enable this feature. +
+ ) : null} +
@@ -390,52 +412,69 @@ export function AiFeaturesSection() { {/* Auto-name chat tabs */}
{ e.currentTarget.style.background = COLORS.hoverBg; }} + onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }} > void saveChatTitleSettings({ autoTitleEnabled: value })} /> -
-
+ - Auto-name chat tabs -
-
- Generate a title from chat content + /> +
+
+ Auto-name chat tabs +
+
+ Tabs automatically get descriptive names based on conversation content +
+
-
diff --git a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx index cfa7df098..911f622f8 100644 --- a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx @@ -1,6 +1,6 @@ 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 { COLORS, MONO_FONT, SANS_FONT, cardStyle, LABEL_STYLE, inlineBadge, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; const REQUIRED_SCOPES = ["repo", "workflow", "read:org"]; @@ -110,16 +110,17 @@ 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", }; const scopeRowStyle = (present: boolean): CSSProperties => ({ @@ -147,10 +148,12 @@ export function GitHubSection() { {saveNotice ?
{saveNotice}
: null} {actionError ?
{actionError}
: null} -
+
- + GitHub connection @@ -233,41 +236,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
+
{REQUIRED_SCOPES.map((scope) => ( -
- ● {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 +293,19 @@ export function GitHubSection() { "Workflows: Read & Write", "Members (org): Read", ].map((perm) => ( -
- ● {perm} -
+ + {perm} + ))}
@@ -312,21 +340,40 @@ 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. +
+
+
+ + + 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.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.tsx index 516b8a7f3..4912f8ef8 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -1,98 +1,402 @@ -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, useMemo, useState, type CSSProperties } from "react"; +import { + ArrowsLeftRight, + ArrowsClockwise, + ArrowSquareOut, + CheckCircle, + CircleNotch, + Key, + Lightning, + Link as LinkIcon, + 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"; 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 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 () => { + if (!window.ade?.cto) return; + try { + setProjects(await window.ade.cto.getLinearProjects()); + } catch { + setProjects([]); + } + }, []); + + const loadStatus = useCallback(async () => { + if (!window.ade?.cto) return; + try { + const status = await window.ade.cto.getLinearConnectionStatus(); + setConnection(status); + if (status.connected) void loadProjects(); + else setProjects([]); + } catch { + setConnection(null); + setProjects([]); + } + }, [loadProjects]); + + /* ── Initial load ── */ + useState(() => { void loadStatus(); }); + + /* ── OAuth polling ── */ + useState(() => { + if (!oauthSessionId) return; + let active = true; + const poll = async () => { + try { + const session = await window.ade.cto!.getLinearOAuthSession({ sessionId: oauthSessionId }); + if (!active) return; + if (session.status === "completed") { + setOauthSessionId(null); + setOauthStarting(false); + setConnection(session.connection ?? null); + setError(null); + if (session.connection?.connected) void loadProjects(); + else void loadStatus(); + return; + } + if (session.status === "failed" || session.status === "expired") { + setOauthSessionId(null); + setOauthStarting(false); + setError(session.error ?? "OAuth failed."); + } + } catch (err) { + if (!active) return; + setOauthSessionId(null); + setOauthStarting(false); + setError(err instanceof Error ? err.message : "OAuth failed."); + } + }; + void poll(); + const timer = window.setInterval(() => void poll(), 1500); + const timeout = window.setTimeout(() => { + if (!active) return; + setOauthSessionId(null); + setOauthStarting(false); + setError("OAuth timed out. Please try again."); + }, 5 * 60 * 1000); + return () => { active = false; clearInterval(timer); clearTimeout(timeout); }; + }); + + /* ── Handlers ── */ + const handleValidate = useCallback(async () => { + if (!window.ade?.cto || !tokenInput.trim()) return; + setValidating(true); + setError(null); + try { + const status = await window.ade.cto.setLinearToken({ token: tokenInput.trim() }); + setConnection(status); + if (status.connected) void loadProjects(); + else setError(status.message ?? "Token validation failed."); + } catch (err) { + setError(err instanceof Error ? err.message : "Validation failed."); + } finally { + setValidating(false); + } + }, [loadProjects, tokenInput]); + + const handleStartOAuth = useCallback(async () => { + if (!window.ade?.cto) return; + setOauthStarting(true); + setError(null); + try { + const session = await window.ade.cto.startLinearOAuth(); + setOauthSessionId(session.sessionId); + if (window.ade.app?.openExternal) { + await window.ade.app.openExternal(session.authUrl); + } + } catch (err) { + setOauthStarting(false); + setError(err instanceof Error ? err.message : "Unable to start OAuth."); + } + }, []); + + const handleDisconnect = useCallback(async () => { + if (!window.ade?.cto) return; + const status = await window.ade.cto.clearLinearToken(); + setConnection(status); + setProjects([]); + setTokenInput(""); + setError(null); + setOauthSessionId(null); + setOauthStarting(false); + }, []); + + /* ── Feature preview cards ── */ + 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" }, + ]; 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") 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; }} + /> + +
+
+ Get one at linear.app/settings/api +
-
+ + )} -
- - - 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/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; From 20d0d7cf953f4a364689c6d99780dfc954de31de Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:19:01 -0400 Subject: [PATCH 2/6] Fix AI CLI resolution, defaults, and UI behaviors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple fixes and improvements across AI, memory, CLI resolution, and UI: - Ensure PATH updates only when augmentation returns a value (main, authDetector). Change default fallback shell to /bin/sh in helpers and whichCommand. - Return augmented PATH from augmentProcessPathWithShellAndKnownCliDirs instead of unconditionally mutating env.PATH. - Add DEFAULT_AI_FEATURE_FLAGS and preserve legacy defaults for missing AI toggles; update tests accordingly. - Codex executable: prefer CODEX_EXECUTABLE / CODEX_EXECUTABLE_PATH env overrides before PATH lookup and adapt tests to mock resolver; log and surface errors when resolving/calling Codex in agentChatService. - Automation planner: quote command preview pieces to safely show spaces. - Embeddings/memory: improve model load error handling (dispose extractor on smoke-test failure) and centralize mock memory health stats in browser mock; add ProgressBar UI component and update tests to trigger download action. - Memory health stats: provide typed default model status and a proper createEmptyMemoryHealthStats return type. - App/Onboarding/Settings: various UI/UX fixes—avoid setting hydrated state after cancellation, add error logging in ProjectSetupPage, ensure finish/setup button disable logic uses consistent gitInstalled check, refine AI features section layout and interactions (hover CSS, model selectors, chat auto-title controls), GitHub token focus styling and explanatory copy, and Linear integration: switch polling/use-once effects to useEffect, improve OAuth polling lifecycle and error handling on disconnect. - Tests: adjust platform mocking in auth/cli tests and other test updates to reflect behavior changes. These changes address robustness for PATH/shell detection, default AI behavior, safer subprocess usage, improved error handling, and several UI/UX and test updates. --- apps/desktop/src/main/main.ts | 5 +- .../services/ai/aiIntegrationService.test.ts | 7 +- .../main/services/ai/aiIntegrationService.ts | 17 +- .../src/main/services/ai/authDetector.test.ts | 10 + .../src/main/services/ai/authDetector.ts | 5 +- .../main/services/ai/cliExecutableResolver.ts | 8 +- .../ai/cliExecutableShellPath.test.ts | 5 +- .../main/services/ai/codexExecutable.test.ts | 31 +- .../src/main/services/ai/codexExecutable.ts | 8 +- .../automations/automationPlannerService.ts | 3 +- .../main/services/chat/agentChatService.ts | 15 +- .../src/main/services/ipc/registerIpc.ts | 98 +---- .../main/services/memory/embeddingService.ts | 31 +- .../desktop/src/main/services/shared/utils.ts | 2 +- apps/desktop/src/renderer/browserMock.ts | 47 +-- .../src/renderer/components/app/AppShell.tsx | 3 +- .../onboarding/EmbeddingsSection.test.tsx | 5 +- .../onboarding/EmbeddingsSection.tsx | 60 ++- .../onboarding/ProjectSetupPage.tsx | 25 +- .../components/settings/AiFeaturesSection.tsx | 376 +++++++++--------- .../components/settings/GitHubSection.tsx | 17 +- .../components/settings/LinearSection.tsx | 52 ++- 22 files changed, 469 insertions(+), 361 deletions(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 30285ce98..05368516a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -113,11 +113,14 @@ import type { Logger } from "./services/logging/logger"; * the AI SDK can locate the CLI. */ function fixElectronShellPath(): void { - augmentProcessPathWithShellAndKnownCliDirs({ + const nextPath = augmentProcessPathWithShellAndKnownCliDirs({ env: process.env, includeInteractiveShell: true, timeoutMs: 1_500, }); + if (nextPath) { + process.env.PATH = nextPath; + } } // Must run before any service or child process is created. diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index 23fe72bbb..c4a5aaf51 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -178,7 +178,7 @@ describe("aiIntegrationService", () => { expect(usageInsertCalls(runCalls)).toHaveLength(1); }); - it("treats all features as opt-in until explicitly enabled", () => { + it("preserves legacy defaults for missing AI feature toggles", () => { const { service } = makeService(); const { service: enabledService } = makeService({ aiConfig: { @@ -191,8 +191,9 @@ describe("aiIntegrationService", () => { }); expect(service.getFeatureFlag("commit_messages")).toBe(false); - expect(service.getFeatureFlag("terminal_summaries")).toBe(false); - expect(service.getFeatureFlag("pr_descriptions")).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); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 40ec8d736..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 false; - } - 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 1f1832888..26416a9ef 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -52,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; @@ -75,6 +84,7 @@ describe("authDetector", () => { afterEach(() => { process.env = { ...originalEnv }; + setPlatform(originalPlatform); vi.unstubAllGlobals(); if (tempHomeDir) { fs.rmSync(tempHomeDir, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index e632ff3ac..4c9c4dafe 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -161,11 +161,14 @@ async function commandPath(command: string): Promise { } async function refreshProcessPathFromShell(): Promise { - augmentProcessPathWithShellAndKnownCliDirs({ + const nextPath = augmentProcessPathWithShellAndKnownCliDirs({ env: process.env, includeInteractiveShell: true, timeoutMs: 2_000, }); + if (nextPath) { + process.env.PATH = nextPath; + } } /** JSON fields that indicate a positive login state across CLI versions. */ diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index 6d3252cd0..1852561fa 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -175,21 +175,17 @@ export function augmentProcessPathWithShellAndKnownCliDirs(args?: { } const env = args?.env ?? process.env; - const shellPath = env.SHELL?.trim() || "/bin/zsh"; + const shellPath = env.SHELL?.trim() || "/bin/sh"; const timeoutMs = args?.timeoutMs ?? 1_000; const loginPath = readShellPath(shellPath, "-lc", timeoutMs); const interactivePath = args?.includeInteractiveShell ? readShellPath(shellPath, "-ic", timeoutMs) : null; - const nextPath = augmentPathWithKnownCliDirs( + return augmentPathWithKnownCliDirs( mergePathEntries(env.PATH, loginPath, interactivePath), env, ); - if (nextPath.length > 0) { - env.PATH = nextPath; - } - return env.PATH ?? ""; } export function resolveExecutableFromKnownLocations( diff --git a/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts b/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts index d61f0425e..197a2fe52 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts @@ -25,6 +25,7 @@ describe("augmentProcessPathWithShellAndKnownCliDirs", () => { beforeEach(async () => { vi.resetModules(); execFileSyncMock.mockReset(); + setPlatform("darwin"); ({ augmentProcessPathWithShellAndKnownCliDirs } = await import("./cliExecutableResolver")); }); @@ -33,7 +34,6 @@ describe("augmentProcessPathWithShellAndKnownCliDirs", () => { }); it("merges login and interactive shell PATH entries on macOS", () => { - setPlatform("darwin"); 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__"; @@ -60,6 +60,7 @@ describe("augmentProcessPathWithShellAndKnownCliDirs", () => { 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(nextPath); + expect(env.PATH).toBe("/usr/bin:/bin"); + expect(nextPath).not.toBe(env.PATH); }); }); diff --git a/apps/desktop/src/main/services/ai/codexExecutable.test.ts b/apps/desktop/src/main/services/ai/codexExecutable.test.ts index faf57d822..785b257c9 100644 --- a/apps/desktop/src/main/services/ai/codexExecutable.test.ts +++ b/apps/desktop/src/main/services/ai/codexExecutable.test.ts @@ -1,8 +1,19 @@ -import { describe, expect, it } from "vitest"; +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: [ @@ -22,5 +33,23 @@ describe("resolveCodexExecutable", () => { 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 index d9d705607..9a5efca3e 100644 --- a/apps/desktop/src/main/services/ai/codexExecutable.ts +++ b/apps/desktop/src/main/services/ai/codexExecutable.ts @@ -19,12 +19,18 @@ 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 resolved = resolveExecutableFromKnownLocations("codex", args?.env); + 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, diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index 9bed65ed5..dc5fb9a8f 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -371,7 +371,8 @@ async function runCodexExec(args: { cliArgs.push(args.prompt); const codexExecutable = resolveCodexExecutable().path; - const commandPreview = [codexExecutable, ...cliArgs.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a))].join(" "); + const quoteIfNeeded = (value: string) => (/\s/.test(value) ? JSON.stringify(value) : value); + const commandPreview = [quoteIfNeeded(codexExecutable), ...cliArgs.map(quoteIfNeeded)].join(" "); const child = spawn(codexExecutable, cliArgs, { cwd: args.cwd, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 62664202b..ceede8d07 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6572,7 +6572,20 @@ export function createAgentChatService(args: { path: process.env.PATH ?? "", ...(adeMcpLaunch ? { adeMcpLaunch } : {}), }); - const codexExecutable = resolveCodexExecutable().path; + 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/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e591856a0..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,81 +864,8 @@ function createEmptyMemoryHealthStats() { cacheHits: 0, cacheMisses: 0, cacheHitRate: 0, - model: { - modelId: "Xenova/all-MiniLM-L6-v2", - state: "idle" as const, - activity: "idle" as const, - installState: "missing" as const, - cacheDir: null, - installPath: null, - 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"; - activity: "idle" | "loading-local" | "downloading" | "ready" | "error"; - installState: "missing" | "partial" | "installed"; - cacheDir: string | null; - installPath: string | null; - progress: number | null; - loaded: number | null; - total: number | null; - file: string | null; - error: string | null; - }; - }; }; } diff --git a/apps/desktop/src/main/services/memory/embeddingService.ts b/apps/desktop/src/main/services/memory/embeddingService.ts index ac08ef4ab..ae57021a4 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.ts @@ -316,13 +316,30 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { runtime.env.allowRemoteModels = !localFilesOnly; runtime.env.useFSCache = true; - const nextExtractor = await runtime.pipeline(DEFAULT_EMBEDDING_TASK, modelId, { - progress_callback: handleProgress, - local_files_only: localFilesOnly, - }); - - await runExtractorSmokeTest(nextExtractor); - extractor = nextExtractor; + let nextExtractor: EmbeddingExtractor | null = null; + try { + const loadedExtractor = await runtime.pipeline(DEFAULT_EMBEDDING_TASK, modelId, { + progress_callback: handleProgress, + local_files_only: localFilesOnly, + }); + nextExtractor = loadedExtractor; + + await runExtractorSmokeTest(loadedExtractor); + extractor = loadedExtractor; + } catch (error) { + if (nextExtractor?.dispose) { + try { + await nextExtractor.dispose(); + } catch (disposeError) { + logger.warn("memory.embedding.dispose_failed_after_smoke_test", { + modelId, + cacheDir, + error: getErrorMessage(disposeError), + }); + } + } + throw error; + } state = "ready"; activity = "ready"; progress = 100; diff --git a/apps/desktop/src/main/services/shared/utils.ts b/apps/desktop/src/main/services/shared/utils.ts index 53a22edd0..e0562f188 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -113,7 +113,7 @@ export async function whichCommand(command: string): Promise { const line = firstLine(res.stdout ?? ""); return line.length ? line : null; } - const lookupShell = process.env.SHELL || "/bin/zsh"; + const lookupShell = process.env.SHELL || "/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; diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index c60d3492f..525c0ba22 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -741,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: { @@ -1684,33 +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", - activity: "ready", - installState: "installed", - cacheDir: "/tmp/mock-transformers-cache", - installPath: "/tmp/mock-transformers-cache/Xenova/all-MiniLM-L6-v2", - 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/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 81fb25365..bc9d55a92 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -204,8 +204,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { setProjectMissing(false); setShowWelcome(true); } finally { - if (cancelled) return; - setProjectHydrated(true); + if (!cancelled) setProjectHydrated(true); } }; diff --git a/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.test.tsx b/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.test.tsx index bbfda98bc..6ec5704a5 100644 --- a/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.test.tsx +++ b/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { EmbeddingsSection } from "./EmbeddingsSection"; function createHealthStats(overrides: Partial = {}) { @@ -97,7 +97,8 @@ describe("EmbeddingsSection", () => { 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(); - expect(screen.getByRole("button", { name: /verify model/i })).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 () => { diff --git a/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.tsx b/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.tsx index d02759ee7..c495ce7a5 100644 --- a/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.tsx +++ b/apps/desktop/src/renderer/components/onboarding/EmbeddingsSection.tsx @@ -29,6 +29,47 @@ function getVisualState(model: MemoryHealthStats["embeddings"]["model"] | null | 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); @@ -244,17 +285,14 @@ export function EmbeddingsSection() { ? <>Downloading {model.file}... : "Downloading model files..."}
-
-
-
+
{downloadPct}% {bytesLabel ? {bytesLabel} : null} diff --git a/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx b/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx index 1395c4b15..50e42dbb7 100644 --- a/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx +++ b/apps/desktop/src/renderer/components/onboarding/ProjectSetupPage.tsx @@ -142,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 }); }); @@ -151,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?.() @@ -165,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; }; }, []); @@ -175,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); @@ -242,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); @@ -262,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]); @@ -555,7 +566,7 @@ export function ProjectSetupPage() { diff --git a/apps/desktop/src/shared/githubScopes.test.ts b/apps/desktop/src/shared/githubScopes.test.ts new file mode 100644 index 000000000..3764b290f --- /dev/null +++ b/apps/desktop/src/shared/githubScopes.test.ts @@ -0,0 +1,53 @@ +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("merges OAuth and accepted scope headers case-insensitively", () => { + const scopes = parseGitHubScopeHeaders(createHeaders({ + "x-oauth-scopes": "repo, workflow", + "x-accepted-oauth-scopes": "Read:Org, workflow", + })); + + expect(scopes).toEqual(expect.arrayContaining(["repo", "workflow", "read:org"])); + }); + + 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..29cd34848 --- /dev/null +++ b/apps/desktop/src/shared/githubScopes.ts @@ -0,0 +1,119 @@ +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}=`) + || (candidate !== "read:org" && candidate !== "admin:org" && scope.startsWith(`${candidate}:`)) + )); +} + +function hasAnyScopeLike(normalizedScopes: Set, candidates: readonly string[]): boolean { + return candidates.some((candidate) => hasScopeLike(normalizedScopes, candidate)); +} + +export function parseGitHubScopeHeaders(headers: Pick): string[] { + const merged = new Set([ + ...splitHeaderScopes(headers.get("x-oauth-scopes")), + ...splitHeaderScopes(headers.get("x-accepted-oauth-scopes")), + ...splitHeaderScopes(headers.get("x-accepted-scopes")), + ]); + return [...merged]; +} + +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 = [...normalizedScopes].some((scope) => ( + FINE_GRAINED_PERMISSION_PREFIXES.some((candidate) => ( + scope === candidate + || scope.startsWith(`${candidate}=`) + || (candidate !== "read:org" && candidate !== "admin:org" && scope.startsWith(`${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 }, + }, + }; +} From 4d135b48c522753bda05edce161750b876059370 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 05:14:53 -0400 Subject: [PATCH 4/6] Improve embedding caching, Claude exec, and scopes Multiple related fixes and refinements across services and UI: - embeddingService: cache install inspection results and add refreshCachedInstall(); pass optional installInspection into ensureExtractor to avoid redundant inspection; update state handling when loading/disposing (reset status and emit); ensure probeCache reuses the inspected install and forwards it to ensureExtractor; update when cachedInstall is refreshed after a successful local load. - automations: use resolveClaudeCodeExecutable() for claude invocations and reuse shared quoteIfNeeded helper for command previews (removed duplicate local helper). - shared utils: add quoteIfNeeded helper. - githubScopes: parseGitHubScopeHeaders now returns only OAuth-granted scopes (x-oauth-scopes) and tighten fine-grained-scope detection logic to avoid treating classic sub-scopes as top-level scopes; tests updated accordingly and a new test asserts classic sub-scopes don't satisfy top-level requirements. - devTools tests: use path.basename for mocked stdout and adjust typing for importActual to avoid type issues. - embeddingService tests: add assertions for pipeline args and a new test ensuring the service remains idle when an in-flight load finishes after dispose (verifies extractor disposal and final idle state). - LinearSection UI: extract features/constants, linkify the Linear API settings URL with openExternal handling, and use the FEATURES constant for rendering. These changes improve reliability of embedding install detection and loading, make command previews robust for executables with spaces, tighten GitHub scope handling, and add tests and UI polish. --- .../automations/automationPlannerService.ts | 9 +-- .../services/devTools/devToolsService.test.ts | 8 ++- .../services/memory/embeddingService.test.ts | 59 +++++++++++++++++++ .../main/services/memory/embeddingService.ts | 43 ++++++++++---- .../desktop/src/main/services/shared/utils.ts | 4 ++ .../components/settings/LinearSection.tsx | 30 +++++++--- apps/desktop/src/shared/githubScopes.test.ts | 17 +++++- apps/desktop/src/shared/githubScopes.ts | 18 ++---- 8 files changed, 146 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index 0504da3b1..cc60d0486 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -27,10 +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 { getErrorMessage, resolvePathWithinRoot } from "../shared/utils"; +import { getErrorMessage, quoteIfNeeded, resolvePathWithinRoot } from "../shared/utils"; function resolveAutomationCwdBase( projectRoot: string, @@ -384,7 +385,6 @@ async function runCodexExec(args: { }); throw error; } - const quoteIfNeeded = (value: string) => (/\s/.test(value) ? JSON.stringify(value) : value); const commandPreview = [quoteIfNeeded(codexExecutable), ...cliArgs.map(quoteIfNeeded)].join(" "); const child = spawn(codexExecutable, cliArgs, { @@ -465,9 +465,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, diff --git a/apps/desktop/src/main/services/devTools/devToolsService.test.ts b/apps/desktop/src/main/services/devTools/devToolsService.test.ts index 09f68b770..05452fd65 100644 --- a/apps/desktop/src/main/services/devTools/devToolsService.test.ts +++ b/apps/desktop/src/main/services/devTools/devToolsService.test.ts @@ -1,5 +1,7 @@ +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, @@ -12,7 +14,7 @@ const { })); vi.mock("../shared/utils", async () => { - const actual = await vi.importActual("../shared/utils"); + const actual = await vi.importActual("../shared/utils"); return { ...actual, spawnAsync: spawnAsyncMock, @@ -50,7 +52,7 @@ describe("devToolsService", () => { }); spawnAsyncMock.mockImplementation(async (command: string) => ({ status: 0, - stdout: `${command} version 1.0.0\n`, + stdout: `${path.basename(command)} version 1.0.0\n`, stderr: "", })); @@ -61,7 +63,7 @@ describe("devToolsService", () => { expect(gh).toMatchObject({ installed: true, detectedPath: "/opt/homebrew/bin/gh", - detectedVersion: "/opt/homebrew/bin/gh version 1.0.0", + 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/memory/embeddingService.test.ts b/apps/desktop/src/main/services/memory/embeddingService.test.ts index 7e653688e..41c7f8733 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.test.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.test.ts @@ -252,6 +252,8 @@ describe("embeddingService", () => { 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, @@ -470,6 +472,63 @@ describe("embeddingService", () => { await secondAttempt; }); + it("keeps the service idle when an in-flight load finishes after dispose", 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 preloadPromise; + + 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(); diff --git a/apps/desktop/src/main/services/memory/embeddingService.ts b/apps/desktop/src/main/services/memory/embeddingService.ts index 958d99068..1455a8d03 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.ts @@ -236,20 +236,25 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { 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 { - const install = inspectInstallPath(installPath); return { modelId, cacheDir, installPath, - installState: install.installState, + installState: cachedInstall.installState, state, activity: deriveReportedActivity({ state, activity, - installState: install.installState, + installState: cachedInstall.installState, }), progress, loaded, @@ -306,7 +311,11 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { emitStatus(); } - async function ensureExtractor(opts: { forceRetry?: boolean; localFilesOnly?: boolean } = {}): Promise { + 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; @@ -329,7 +338,7 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { loadAttemptId += 1; } const attemptId = loadAttemptId; - const install = inspectInstallPath(installPath); + const install = opts.installInspection ?? refreshCachedInstall(); state = "loading"; activity = localFilesOnly || install.installState === "installed" ? "loading-local" : "downloading"; progress = 0; @@ -349,13 +358,18 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { let nextExtractor: EmbeddingExtractor | null = null; try { - const loadedExtractor = await runtime.pipeline(DEFAULT_EMBEDDING_TASK, modelId, { + 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)) { if (loadedExtractor.dispose) { await loadedExtractor.dispose(); @@ -401,7 +415,7 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { extractor = null; state = "unavailable"; activity = "error"; - const freshInstall = inspectInstallPath(installPath); + const freshInstall = refreshCachedInstall(); lastError = normalizeLoadError({ message: getErrorMessage(error), installState: freshInstall.installState, @@ -450,9 +464,18 @@ 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(); } @@ -470,7 +493,7 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { async function probeCache(): Promise { if (state === "ready" || state === "loading") return; try { - const install = inspectInstallPath(installPath); + const install = refreshCachedInstall(); if (install.installState !== "installed") { logger.info("memory.embedding.probe_cache_skipped", { modelId, @@ -481,7 +504,7 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { return; } logger.info("memory.embedding.probe_cache", { modelId, cacheDir, installPath }); - await ensureExtractor({ localFilesOnly: true }); + 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 a05732f2c..84408a44b 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -626,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/components/settings/LinearSection.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.tsx index 40bbdf10d..204d23bf2 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -15,6 +15,12 @@ import { COLORS, SANS_FONT, MONO_FONT, LABEL_STYLE } from "../lanes/laneDesignTo 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); @@ -207,13 +213,6 @@ export function LinearSection() { } }, [setOauthSessionIdState, setOauthStartingState, setValidatingState]); - /* ── Feature preview cards ── */ - 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" }, - ]; - return (
@@ -411,7 +410,20 @@ export function LinearSection() {
@@ -442,7 +454,7 @@ export function LinearSection() { WHAT LINEAR INTEGRATION ENABLES
- {features.map(({ icon: Icon, title, desc }) => ( + {FEATURES.map(({ icon: Icon, title, desc }) => (
): Pick { } describe("githubScopes", () => { - it("merges OAuth and accepted scope headers case-insensitively", () => { + 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(expect.arrayContaining(["repo", "workflow", "read:org"])); + 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", () => { diff --git a/apps/desktop/src/shared/githubScopes.ts b/apps/desktop/src/shared/githubScopes.ts index 29cd34848..c647827e6 100644 --- a/apps/desktop/src/shared/githubScopes.ts +++ b/apps/desktop/src/shared/githubScopes.ts @@ -46,7 +46,6 @@ function hasScopeLike(normalizedScopes: Set, candidate: string): boolean return [...normalizedScopes].some((scope) => ( scope === candidate || scope.startsWith(`${candidate}=`) - || (candidate !== "read:org" && candidate !== "admin:org" && scope.startsWith(`${candidate}:`)) )); } @@ -55,12 +54,7 @@ function hasAnyScopeLike(normalizedScopes: Set, candidates: readonly str } export function parseGitHubScopeHeaders(headers: Pick): string[] { - const merged = new Set([ - ...splitHeaderScopes(headers.get("x-oauth-scopes")), - ...splitHeaderScopes(headers.get("x-accepted-oauth-scopes")), - ...splitHeaderScopes(headers.get("x-accepted-scopes")), - ]); - return [...merged]; + return splitHeaderScopes(headers.get("x-oauth-scopes")); } export function getGitHubTokenAccessState(scopes: Iterable): GitHubTokenAccessState { @@ -75,13 +69,9 @@ export function getGitHubTokenAccessState(scopes: Iterable): GitHubToken const workflowPresent = hasAnyScopeLike(normalizedScopes, WORKFLOW_FINE_GRAINED_PERMISSIONS); const orgPresent = hasAnyScopeLike(normalizedScopes, ORG_FINE_GRAINED_PERMISSIONS); - const usesFineGrainedPermissions = [...normalizedScopes].some((scope) => ( - FINE_GRAINED_PERMISSION_PREFIXES.some((candidate) => ( - scope === candidate - || scope.startsWith(`${candidate}=`) - || (candidate !== "read:org" && candidate !== "admin:org" && scope.startsWith(`${candidate}:`)) - )) - )); + const usesFineGrainedPermissions = FINE_GRAINED_PERMISSION_PREFIXES.some((candidate) => + hasScopeLike(normalizedScopes, candidate), + ); const missingClassicScopes = REQUIRED_GITHUB_CLASSIC_SCOPES.filter((scope) => { switch (scope) { From f4dbb555b2d6b261f54b1b7121ce1466d55bf773 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:40:43 -0400 Subject: [PATCH 5/6] Fix stale embedding loads and race conditions Prevent stale embedding extractor loads from lingering by introducing createStaleLoadError and disposeExtractorSafely; ensureExtractor now disposes safely and throws when a load becomes stale. Update embedding tests to expect rejection for stale loads. Add requestEpoch-based guards to LinearSection to avoid async race conditions (loads, status, token validation, OAuth/disconnect flows) and skip browser OAuth when openExternal is unavailable; add aria-label to the API key input and accompanying tests. Also tighten codex executable resolution error handling in automationPlannerService. --- .../automations/automationPlannerService.ts | 7 +- .../services/memory/embeddingService.test.ts | 4 +- .../main/services/memory/embeddingService.ts | 45 ++++--- .../settings/LinearSection.test.tsx | 117 ++++++++++++++++++ .../components/settings/LinearSection.tsx | 84 ++++++++++--- 5 files changed, 218 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/src/renderer/components/settings/LinearSection.test.tsx diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index cc60d0486..eb5c32ab6 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -374,10 +374,11 @@ async function runCodexExec(args: { let codexExecutable: string; try { - codexExecutable = resolveCodexExecutable().path; - if (!codexExecutable) { - throw new Error("Codex executable path was empty."); + 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, diff --git a/apps/desktop/src/main/services/memory/embeddingService.test.ts b/apps/desktop/src/main/services/memory/embeddingService.test.ts index 41c7f8733..8faf18f6f 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.test.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.test.ts @@ -472,7 +472,7 @@ describe("embeddingService", () => { await secondAttempt; }); - it("keeps the service idle when an in-flight load finishes after dispose", async () => { + 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; @@ -517,7 +517,7 @@ describe("embeddingService", () => { expect(releasePipeline).toBeTypeOf("function"); releasePipeline!(); - await preloadPromise; + await expect(preloadPromise).rejects.toThrow("Embedding extractor load became stale."); expect(extractor.dispose).toHaveBeenCalledTimes(1); expect(service.isAvailable()).toBe(false); diff --git a/apps/desktop/src/main/services/memory/embeddingService.ts b/apps/desktop/src/main/services/memory/embeddingService.ts index 1455a8d03..57ecfad8b 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.ts @@ -311,6 +311,26 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { emitStatus(); } + 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; @@ -371,28 +391,21 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { await runExtractorSmokeTest(loadedExtractor); cachedInstall = localFilesOnly ? install : refreshCachedInstall(); if (!isCurrentLoadAttempt(attemptId)) { - if (loadedExtractor.dispose) { - await loadedExtractor.dispose(); - } - return loadedExtractor; + await disposeExtractorSafely(loadedExtractor, "memory.embedding.dispose_failed_after_stale_load"); + nextExtractor = null; + throw createStaleLoadError(); } extractor = loadedExtractor; } catch (error) { - if (nextExtractor?.dispose) { - try { - await nextExtractor.dispose(); - } catch (disposeError) { - logger.warn("memory.embedding.dispose_failed_after_smoke_test", { - modelId, - cacheDir, - error: getErrorMessage(disposeError), - }); - } - } + await disposeExtractorSafely(nextExtractor, "memory.embedding.dispose_failed_after_smoke_test"); throw error; } if (!isCurrentLoadAttempt(attemptId)) { - return nextExtractor; + if (extractor === nextExtractor) { + extractor = null; + } + await disposeExtractorSafely(nextExtractor, "memory.embedding.dispose_failed_after_stale_load"); + throw createStaleLoadError(); } state = "ready"; activity = "ready"; 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 204d23bf2..e1e24e1b7 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -33,6 +33,14 @@ export function LinearSection() { 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; @@ -45,9 +53,12 @@ export function LinearSection() { }, []); const setOauthSessionIdState = useCallback((value: string | null) => { + if (oauthSessionIdRef.current !== value) { + invalidateLoadRequests(); + } oauthSessionIdRef.current = value; setOauthSessionId(value); - }, []); + }, [invalidateLoadRequests]); const isConnected = Boolean(connection?.connected); const authModeLabel = useMemo(() => { @@ -56,27 +67,39 @@ export function LinearSection() { }, [connection?.authMode]); /* ── Load helpers ── */ - const loadProjects = useCallback(async () => { + const loadProjects = useCallback(async (requestIdArg?: number) => { if (!window.ade?.cto) return; + const requestId = requestIdArg ?? invalidateLoadRequests(); try { - setProjects(await window.ade.cto.getLinearProjects()); + 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) void loadProjects(); - else setProjects([]); + if (status.connected) { + if (isCurrentLoadRequest(requestId)) { + void loadProjects(requestId); + } + } else { + setProjects([]); + } } catch { + if (!isCurrentLoadRequest(requestId)) return; setConnection(null); setProjects([]); } - }, [loadProjects]); + }, [invalidateLoadRequests, isCurrentLoadRequest, loadProjects]); /* ── Initial load ── */ useEffect(() => { @@ -151,27 +174,42 @@ export function LinearSection() { ) { 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) return; + if ( + !validatingRef.current + || oauthStartingRef.current + || oauthSessionIdRef.current + || !isCurrentLoadRequest(requestId) + ) { + return; + } setConnection(status); if (status.connected) { - void loadProjects(); + void loadProjects(requestId); setTokenInput(""); } else { setError(status.message ?? "Token validation failed."); } } catch (err) { - if (!validatingRef.current || oauthStartingRef.current || oauthSessionIdRef.current) return; + if ( + !validatingRef.current + || oauthStartingRef.current + || oauthSessionIdRef.current + || !isCurrentLoadRequest(requestId) + ) { + return; + } setError(err instanceof Error ? err.message : "Validation failed."); } finally { if (validatingRef.current) { setValidatingState(false); } } - }, [loadProjects, setValidatingState, tokenInput]); + }, [invalidateLoadRequests, isCurrentLoadRequest, loadProjects, setValidatingState, tokenInput]); const handleStartOAuth = useCallback(async () => { if (oauthSessionIdRef.current) { @@ -179,14 +217,22 @@ export function LinearSection() { setOauthStartingState(false); return; } - if (!window.ade?.cto || validatingRef.current || oauthStartingRef.current) 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 window.ade.cto.startLinearOAuth(); - if (window.ade.app?.openExternal) { - await window.ade.app.openExternal(session.authUrl); - } + 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) { @@ -194,10 +240,11 @@ export function LinearSection() { setOauthStartingState(false); setError(err instanceof Error ? err.message : "Unable to start OAuth."); } - }, [setOauthSessionIdState, setOauthStartingState]); + }, [invalidateLoadRequests, setOauthSessionIdState, setOauthStartingState]); const handleDisconnect = useCallback(async () => { if (!window.ade?.cto) return; + invalidateLoadRequests(); try { const status = await window.ade.cto.clearLinearToken(); setConnection(status); @@ -211,7 +258,7 @@ export function LinearSection() { setValidatingState(false); setOauthStartingState(false); } - }, [setOauthSessionIdState, setOauthStartingState, setValidatingState]); + }, [invalidateLoadRequests, setOauthSessionIdState, setOauthStartingState, setValidatingState]); return (
@@ -380,6 +427,7 @@ export function LinearSection() {
setTokenInput(e.target.value)} From 070c2881da46ceb4b80f468e5e5f8a0a959576b0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:56:39 -0400 Subject: [PATCH 6/6] Add defensive guard and tighten openExternal checks [skip ci] embeddingService: Add a defensive comment and guard around isCurrentLoadAttempt to ensure disposeExtractorSafely runs if a future async refactor makes the load attempt stale, preventing leaked extractors. LinearSection: Use optional chaining and a local openExternal reference (window.ade?.app?.openExternal) and check it before use in the click handler to avoid runtime errors from missing properties and reduce repeated deep property access. --- apps/desktop/src/main/services/memory/embeddingService.ts | 1 + .../src/renderer/components/settings/LinearSection.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/memory/embeddingService.ts b/apps/desktop/src/main/services/memory/embeddingService.ts index 57ecfad8b..f1d1cad2d 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.ts @@ -400,6 +400,7 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { 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; diff --git a/apps/desktop/src/renderer/components/settings/LinearSection.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.tsx index e1e24e1b7..f6b176feb 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -218,7 +218,7 @@ export function LinearSection() { return; } const cto = window.ade?.cto; - const openExternal = window.ade.app?.openExternal; + const openExternal = window.ade?.app?.openExternal; if (!cto || validatingRef.current || oauthStartingRef.current) return; if (!openExternal) { setOauthSessionIdState(null); @@ -464,9 +464,10 @@ export function LinearSection() { target="_blank" rel="noopener noreferrer" onClick={(event) => { - if (!window.ade.app?.openExternal) return; + const openExternal = window.ade?.app?.openExternal; + if (!openExternal) return; event.preventDefault(); - void window.ade.app.openExternal(LINEAR_API_SETTINGS_URL); + void openExternal(LINEAR_API_SETTINGS_URL); }} style={{ color: COLORS.textMuted }} >