Skip to content
36 changes: 26 additions & 10 deletions apps/desktop/src/main/services/ai/tools/grepSearch.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createGrepSearchTool } from "./grepSearch";
import {
__testResetRipgrepExecFile,
__testSetRipgrepExecFile,
createGrepSearchTool,
} from "./grepSearch";

const tmpDirs: string[] = [];
function makeTmpDir(prefix: string): string {
Expand All @@ -18,6 +23,7 @@ function writeFixtureFile(root: string, relativePath: string, content: string):
}

afterEach(() => {
__testResetRipgrepExecFile();
vi.restoreAllMocks();
for (const dir of tmpDirs) {
try {
Expand All @@ -29,22 +35,19 @@ afterEach(() => {
tmpDirs.length = 0;
});

// Helper to force JS fallback by making execFile reject for rg
// Force JS fallback by making ripgrep's exec path reject (matches real "rg missing" behavior).
function forceJsFallback(): void {
const cp = require("node:child_process");
const originalExecFile = cp.execFile;
vi.spyOn(cp, "execFile").mockImplementation(
(cmd: unknown, ...rest: unknown[]) => {
__testSetRipgrepExecFile(
((cmd: unknown, ...rest: unknown[]) => {
if (cmd === "rg") {
// Make the promisified version reject
const cb = rest[rest.length - 1];
if (typeof cb === "function") {
process.nextTick(() => cb(new Error("rg not available")));
process.nextTick(() => (cb as (err: Error) => void)(new Error("rg not available")));
return;
}
}
return originalExecFile(cmd, ...rest);
},
return (execFile as (typeof import("node:child_process"))["execFile"])(cmd as never, ...rest as never[]);
}) as typeof execFile,
);
}

Expand Down Expand Up @@ -199,6 +202,19 @@ describe("createGrepSearchTool", () => {
expect(result.matches[0].displayPath).toBe("src/app.ts");
});

it("repo-wide JS fallback skips root .ade but still searches .github", async () => {
const cwd = makeTmpDir("grep-hidden-root-");
writeFixtureFile(cwd, ".ade/secrets.txt", "SECRET_MARKER");
writeFixtureFile(cwd, ".github/workflows/ci.yml", "SECRET_MARKER");
writeFixtureFile(cwd, "src/app.ts", "SECRET_MARKER");
forceJsFallback();

const tool = createGrepSearchTool(cwd);
const result = await tool.execute({ pattern: "SECRET_MARKER", context: 0 });
const paths = result.matches.map((m) => m.displayPath).sort();
expect(paths).toEqual([".github/workflows/ci.yml", "src/app.ts"]);
});

it("handles brace expansion in file glob: *.{ts,tsx}", async () => {
const cwd = makeTmpDir("grep-brace-");
writeFixtureFile(cwd, "app.ts", "const val = 1;");
Expand Down
63 changes: 57 additions & 6 deletions apps/desktop/src/main/services/ai/tools/grepSearch.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import { executableTool as tool } from "./executableTool";
import { z } from "zod";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { getErrorMessage, resolvePathWithinRoot } from "../../shared/utils";

const execFileAsync = promisify(execFile);
/** Swappable for Vitest — defaults to Node's `execFile`. */
let execFileForRipgrep: typeof execFile = execFile;

/** @internal Used by grepSearch.test.ts to force the JS fallback path. */
export function __testSetRipgrepExecFile(fn: typeof execFile): void {
execFileForRipgrep = fn;
}

/** @internal */
export function __testResetRipgrepExecFile(): void {
execFileForRipgrep = execFile;
}

function execFileAsync(
file: string,
args: readonly string[] | null | undefined,
options: ExecFileOptionsWithStringEncoding,
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
execFileForRipgrep(file, args, options, (error, stdout, stderr) => {
if (error) reject(error);
else resolve({ stdout, stderr });
});
});
}

type GrepMatch = {
path: string;
Expand Down Expand Up @@ -128,7 +151,8 @@ function jsFallbackGrep(
): GrepMatch[] {
const regex = new RegExp(pattern);
const results: GrepMatch[] = [];
const files = collectFiles(target, fileGlob);
const searchWholeRepo = path.resolve(target) === path.resolve(root);
const files = collectFiles(target, fileGlob, searchWholeRepo);

for (const filePath of files) {
if (results.length >= 500) break;
Expand All @@ -151,16 +175,38 @@ function jsFallbackGrep(

const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage"]);

/** Hidden first-segment dirs under the repo root we still want repo-wide search to enter. */
const ALLOW_HIDDEN_ROOT_DIRS = new Set([".github"]);

function shouldSkipHiddenDirUnderRepoRoot(
rootReal: string,
parentAbs: string,
dirName: string,
searchWholeRepo: boolean,
): boolean {
if (!searchWholeRepo) return false;
if (!dirName.startsWith(".") || dirName === "." || dirName === "..") return false;
if (ALLOW_HIDDEN_ROOT_DIRS.has(dirName)) return false;
const childAbs = path.resolve(path.join(parentAbs, dirName));
const rel = path.relative(rootReal, childAbs);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
const first = rel.split(path.sep)[0] ?? "";
// Only skip direct children of the repo root (e.g. `.ade`, `.env`) — not `src/.cache`.
return first === dirName;
}

function collectFiles(
dir: string,
fileGlob: string | undefined,
searchWholeRepo: boolean,
maxFiles = 5000
): string[] {
const stat = fs.statSync(dir);
if (stat.isFile()) return [dir];

const files: string[] = [];
const globRegex = fileGlob ? globToRegex(fileGlob) : null;
const rootReal = fs.realpathSync(dir);

function walk(current: string): void {
if (files.length >= maxFiles) return;
Expand All @@ -173,9 +219,14 @@ function collectFiles(
for (const entry of entries) {
if (files.length >= maxFiles) return;
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
walk(path.join(current, entry.name));
if (SKIP_DIRS.has(entry.name)) continue;
const next = path.join(current, entry.name);
if (
shouldSkipHiddenDirUnderRepoRoot(rootReal, current, entry.name, searchWholeRepo)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This change only skips dot-directories when searchWholeRepo is true, so targeted directory searches no longer preserve the old hidden-dir guard. In the new code:

// apps/desktop/src/main/services/ai/tools/grepSearch.ts
if (entry.isDirectory()) {
  if (SKIP_DIRS.has(entry.name)) continue;
  const next = path.join(current, entry.name);
  if (shouldSkipHiddenDirUnderRepoRoot(rootReal, current, entry.name, searchWholeRepo)) {
    continue;
  }
  walk(next);
}

tool.execute({ path: "src", ... }) will now recurse into src/.cache, src/.storybook, etc., whereas the previous implementation skipped all entry.name.startsWith(".") directories and the PR note says targeted subdirectory searches are unchanged. That broadens the agent-visible search surface beyond the intended .github exception. Restore the old hidden-directory exclusion for non-root searches, and only special-case direct children of the repo root when searchWholeRepo is true.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new walker removed the old entry.name.startsWith(".") guard and now only skips hidden directories when they are direct children of the repo root in a repo-wide search:

// apps/desktop/src/main/services/ai/tools/grepSearch.ts
if (entry.isDirectory()) {
  if (SKIP_DIRS.has(entry.name)) continue;
  const next = path.join(current, entry.name);
  if (shouldSkipHiddenDirUnderRepoRoot(rootReal, current, entry.name, searchWholeRepo)) {

That changes fallback behavior from ripgrep’s default filtering (rg skips hidden files/directories unless explicitly opted in): a targeted search like path: "src" will now recurse into src/.cache, and a repo-wide search will also descend into nested hidden directories such as src/.storybook. Since this code path is used exactly when rg is unavailable, those environments now get noisier, inconsistent results that the new tests do not cover. Restore the general hidden-directory skip in collectFiles and add the .github exception only for direct children of the repo root (while still allowing an explicitly targeted hidden root to be searched).

) {
continue;
}
walk(next);
} else if (entry.isFile()) {
const fullPath = path.join(current, entry.name);
if (!globRegex || globRegex.test(entry.name)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ describe("prService.getMobileSnapshot", () => {
const eligibleEntry = snapshot.createCapabilities.lanes.find((lane) => lane.laneId === "lane-feat")!;
expect(eligibleEntry.canCreate).toBe(true);
expect(eligibleEntry.blockedReason).toBeNull();
expect(eligibleEntry.commitsAheadOfBase).toBe(0);
});

it("includes queue and rebase workflow cards and skips completed queues", async () => {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/services/prs/prService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5510,6 +5510,9 @@ export function createPrService({
primaryBranchRef: primaryLane?.branchRef ?? null,
});
const dirty = lane.status?.dirty === true;
// Same `ahead` count the lane list already shows vs the lane's configured base
// (`resolveStableLaneBaseBranch`); keep wording aligned with that UI signal.
const commitsAheadOfBase = Math.max(0, Number(lane.status?.ahead ?? 0) || 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This field is documented and displayed as "ahead of defaultBaseBranch", but the new value is taken from lane.status.ahead, which is not guaranteed to be measured against that branch. The changed code does:

// apps/desktop/src/main/services/prs/prService.ts
const defaultBaseBranch = resolveStableLaneBaseBranch({
  lane,
  parent,
  primaryBranchRef: primaryLane?.branchRef ?? null,
});
const dirty = lane.status?.dirty === true;
const commitsAheadOfBase = Math.max(0, Number(lane.status?.ahead ?? 0) || 0);

I verified computeLaneStatus() in @apps/desktop/src/main/services/lanes/laneService.ts computes ahead from git rev-list ${baseRef}...${branchRef}, while resolveStableLaneBaseBranch() in @apps/desktop/src/shared/laneBaseResolution.ts intentionally switches child lanes with non-primary parents to the parent branch. For stacked lanes where lane.baseRef is still the stored/base branch, mobile will report commits ahead of the parent while actually counting commits ahead of the older baseRef, overstating the hint by including parent commits. Compute this count against the same branch returned by resolveStableLaneBaseBranch, or rename the field/message so it matches the underlying metric.

const hasExistingPr = existingPr !== null && (existingPr.state === "open" || existingPr.state === "draft");
const canCreate = !hasExistingPr;
const blockedReason = hasExistingPr
Expand All @@ -5524,6 +5527,7 @@ export function createPrService({
defaultBaseBranch,
defaultTitle: lane.name,
dirty,
commitsAheadOfBase,
hasExistingPr,
canCreate,
blockedReason,
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/renderer/components/app/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,44 @@ describe("AppShell", () => {
expect(
screen.getByText(/No AI provider is configured yet/i),
).toBeTruthy();

fireEvent.click(screen.getByTestId("dismiss-missing-ai-banner"));

expect(
screen.queryByText(/No AI provider is configured yet/i),
).toBeNull();
} finally {
vi.useRealTimers();
}
});

it("dismisses the GitHub not connected banner for the current session", async () => {
vi.useFakeTimers();
try {
globalThis.window.ade.github.getStatus = vi.fn(async () => ({ tokenStored: false })) as any;

render(
<MemoryRouter initialEntries={["/work"]}>
<AppShell>
<div>child</div>
</AppShell>
</MemoryRouter>,
);

await act(async () => {
vi.advanceTimersByTime(1_000);
await Promise.resolve();
});

expect(
screen.getByText(/GitHub is not connected for this ADE app yet/i),
).toBeTruthy();

fireEvent.click(screen.getByTestId("dismiss-github-banner"));

expect(
screen.queryByText(/GitHub is not connected for this ADE app yet/i),
).toBeNull();
} finally {
vi.useRealTimers();
}
Expand Down
70 changes: 60 additions & 10 deletions apps/desktop/src/renderer/components/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
);
const [dismissedContextBannerRoots, setDismissedContextBannerRoots] =
useState<Record<string, true>>({});
/** Session dismiss for the “no AI provider” banner (per project root). */
const [dismissedMissingAiBannerRoots, setDismissedMissingAiBannerRoots] =
useState<Record<string, true>>({});
/** Session dismiss for the “GitHub not connected” banner (per project root). */
const [dismissedGithubBannerRoots, setDismissedGithubBannerRoots] =
useState<Record<string, true>>({});
const [projectMissing, setProjectMissing] = useState(false);
const [feedbackGenerating, setFeedbackGenerating] = useState(false);
const previousProjectRootRef = useRef<string | null | undefined>(undefined);
Expand Down Expand Up @@ -603,6 +609,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
[missingContextDocs],
);
const currentProjectRoot = project?.rootPath ?? null;
const missingAiBannerDismissed = Boolean(
currentProjectRoot && dismissedMissingAiBannerRoots[currentProjectRoot],
);
const githubBannerDismissed = Boolean(
currentProjectRoot && dismissedGithubBannerRoots[currentProjectRoot],
);
const contextBannerDismissed = Boolean(
currentProjectRoot && dismissedContextBannerRoots[currentProjectRoot],
);
Expand Down Expand Up @@ -791,12 +803,31 @@ export function AppShell({ children }: { children: React.ReactNode }) {
!showWelcome &&
aiStatusLoaded &&
aiStatus !== null &&
!hasAnyAiProvider ? (
!hasAnyAiProvider &&
!missingAiBannerDismissed ? (
<div className="shrink-0 mx-2 mt-1 rounded bg-amber-500/6 px-3 py-1.5 text-[11px] font-mono text-amber-800">
No AI provider is configured yet.{" "}
<Link to="/settings?tab=ai" className="underline">
Set up AI
</Link>
<span>
No AI provider is configured yet.{" "}
<Link to="/settings?tab=ai" className="underline">
Set up AI
</Link>
</span>
<button
type="button"
data-testid="dismiss-missing-ai-banner"
className="ml-2 text-amber-900/70 hover:text-amber-900"
onClick={() => {
if (!currentProjectRoot) return;
setDismissedMissingAiBannerRoots((prev) => ({
...prev,
[currentProjectRoot]: true,
}));
}}
title="Dismiss for this session"
aria-label="Dismiss missing AI provider banner for this session"
>
×
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</div>
) : null}

Expand All @@ -805,12 +836,31 @@ export function AppShell({ children }: { children: React.ReactNode }) {
!showWelcome &&
!isOnboardingRoute &&
githubStatus !== null &&
!githubStatus.tokenStored ? (
!githubStatus.tokenStored &&
!githubBannerDismissed ? (
<div className="shrink-0 mx-3 mt-1.5 rounded bg-amber-500/6 px-3 py-1.5 text-[11px] font-mono text-amber-800">
GitHub is not connected for this ADE app yet.{" "}
<Link to="/settings?tab=integrations" className="underline">
Connect GitHub
</Link>
<span>
GitHub is not connected for this ADE app yet.{" "}
<Link to="/settings?tab=integrations" className="underline">
Connect GitHub
</Link>
</span>
<button
type="button"
data-testid="dismiss-github-banner"
className="ml-2 text-amber-900/70 hover:text-amber-900"
onClick={() => {
if (!currentProjectRoot) return;
setDismissedGithubBannerRoots((prev) => ({
...prev,
[currentProjectRoot]: true,
}));
}}
title="Dismiss for this session"
aria-label="Dismiss GitHub not connected banner for this session"
>
×
</button>
</div>
) : null}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ export function CommandPalette({
{ id: "go-missions", title: "Go to Missions", shortcut: "G M", group: "Navigation", run: () => navigate("/missions") },
{ id: "go-automations", title: "Go to Automations", hint: "Automation rules and agent workflows", group: "Navigation", run: () => navigate("/automations") },
{ id: "go-settings", title: "Go to Settings", shortcut: "G S", group: "Navigation", run: () => navigate("/settings") },
{ id: "go-settings-general", title: "Go to General Settings", hint: "Theme, setup reminder, app info", group: "Settings", run: () => navigate("/settings?tab=general") },
{ id: "go-settings-general", title: "Go to General Settings", hint: "Setup reminder, app info", group: "Settings", run: () => navigate("/settings?tab=general") },
{ id: "go-settings-appearance", title: "Go to Appearance", hint: "Theme, chat font size, chat notifications", group: "Settings", run: () => navigate("/settings?tab=appearance") },
{ id: "go-settings-ai", title: "Go to AI Settings", hint: "Providers, models, AI defaults", group: "Settings", run: () => navigate("/settings?tab=ai") },
{ id: "go-settings-integrations", title: "Go to Integrations", hint: "GitHub, Linear, managed MCP, computer use", group: "Settings", run: () => navigate("/settings?tab=integrations") },
{ id: "go-settings-workspace", title: "Go to Workspace Settings", hint: "Project health and docs generation", group: "Settings", run: () => navigate("/settings?tab=workspace") },
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/renderer/components/app/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useState, useCallback, useEffect } from "react";
import { useSearchParams, useLocation } from "react-router-dom";
import { Brain, GearSix, Lightning, Stack, Database, FolderSimple, Plus, X, Plugs, DesktopTower } from "@phosphor-icons/react";
import { Brain, GearSix, Lightning, Stack, Database, FolderSimple, Plus, X, Plugs, DesktopTower, Palette } from "@phosphor-icons/react";
import { GeneralSection } from "../settings/GeneralSection";
import { AppearanceSection } from "../settings/AppearanceSection";
import { LaneTemplatesSection } from "../settings/LaneTemplatesSection";
import { LaneBehaviorSection } from "../settings/LaneBehaviorSection";
import { MemoryHealthTab } from "../settings/MemoryHealthTab";
Expand All @@ -17,6 +18,7 @@ import { PhaseCardEditor } from "../missions/PhaseCardEditor";

const SECTIONS = [
{ id: "general", label: "General", icon: GearSix },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "workspace", label: "Workspace", icon: FolderSimple },
{ id: "ai", label: "AI", icon: Brain },
{ id: "sync", label: "Sync", icon: DesktopTower },
Expand Down Expand Up @@ -556,6 +558,7 @@ export function SettingsPage() {
}}
>
{section === "general" && <GeneralSection />}
{section === "appearance" && <AppearanceSection />}
{section === "workspace" && <WorkspaceSettingsSection />}
{section === "ai" && <AiSettingsSection />}
{section === "sync" && <SyncDevicesSection />}
Expand Down
Loading
Loading