Skip to content
23 changes: 13 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
33 changes: 29 additions & 4 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 @@ -173,7 +196,9 @@ function collectFiles(
for (const entry of entries) {
if (files.length >= maxFiles) return;
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
// Skip only known bulky/tooling dirs — do not treat every dot-directory as
// ignorable so paths like `.github/` remain searchable when targeted.
if (!SKIP_DIRS.has(entry.name)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[🟠 High] [🔵 Bug]

Removing the dot-directory guard makes the auto-allowed grep tool recurse through every hidden subtree when the caller searches the repo root, including ADE’s machine-local .ade/secrets files. collectFiles() already walks an explicitly targeted hidden directory, so this change is not required to support path: ".github"; it only broadens default searches into sensitive metadata. Because grepSearch returns raw matching lines and has no redaction layer, an agent can now exfiltrate API keys / sync tokens with a normal repo-root grep. Restore the hidden-dir skip for broad walks and only descend into dot-directories when the requested target itself is inside one.

// apps/desktop/src/main/services/ai/tools/grepSearch.ts
if (entry.isDirectory()) {
  // Skip only known bulky/tooling dirs — do not treat every dot-directory as
  // ignorable so paths like `.github/` remain searchable when targeted.
  if (!SKIP_DIRS.has(entry.name)) {
    walk(path.join(current, entry.name));

walk(path.join(current, entry.name));
}
} else if (entry.isFile()) {
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.
</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