Skip to content
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
2 changes: 2 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,7 @@ export function createPrService({
primaryBranchRef: primaryLane?.branchRef ?? null,
});
const dirty = lane.status?.dirty === true;
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 +5525,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
73 changes: 63 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 @@ -480,6 +486,11 @@ export function AppShell({ children }: { children: React.ReactNode }) {
setProjectMissing(false);
}, [project?.rootPath]);

useEffect(() => {

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]

// apps/desktop/src/renderer/components/app/AppShell.tsx
useEffect(() => {
  setDismissedMissingAiBannerRoots({});
  setDismissedGithubBannerRoots({});
}, [project?.rootPath]);

This effect clears all dismissed-banner entries every time the active project.rootPath changes, so dismissing either banner in project A is forgotten as soon as the user opens project B. Because AppShell stays mounted across project changes and the state is already keyed by rootPath, switching back to project A makes the banner reappear even though the UI promises “Dismiss for this session.” Remove this reset effect (matching the existing dismissedContextBannerRoots behavior) or otherwise preserve per-root entries until the app session actually ends.

setDismissedMissingAiBannerRoots({});
setDismissedGithubBannerRoots({});
}, [project?.rootPath]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

useEffect(() => {
const previousProjectRoot = previousProjectRootRef.current;
const nextProjectRoot = project?.rootPath ?? null;
Expand Down Expand Up @@ -603,6 +614,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 +808,30 @@ 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"
>
×
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
) : null}

Expand All @@ -805,12 +840,30 @@ 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"
>
×
</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
46 changes: 44 additions & 2 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ import { deriveChatSubagentSnapshots, deriveTodoItems, deriveTurnDiffSummaries }
import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput";
import { ProviderModelSelector } from "../shared/ProviderModelSelector";
import { useClickOutside } from "../../hooks/useClickOutside";
import { useAppStore } from "../../state/appStore";
import { DEFAULT_CHAT_FONT_SIZE_PX, useAppStore } from "../../state/appStore";
import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge";
import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl";
import { getAgentChatModelsCached, getAiStatusCached } from "../../lib/aiDiscoveryCache";
import { invalidateSessionListCache } from "../../lib/sessionListCache";
import { playAgentTurnCompletionSound } from "../../lib/agentTurnCompletionSound";

const LAST_MODEL_ID_KEY = "ade.chat.lastModelId";
const LAST_REASONING_KEY_PREFIX = "ade.chat.lastReasoningEffort";
Expand Down Expand Up @@ -710,6 +711,10 @@ export function AgentChatPane({
onLaneChange?: (laneId: string) => void;
}) {
const projectRoot = useAppStore((s) => s.project?.rootPath ?? null);
const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound);
const chatFontSizePx = useAppStore((s) => s.chatFontSizePx);
const chatUiScale = chatFontSizePx / DEFAULT_CHAT_FONT_SIZE_PX;
const chatSurfaceZoomStyle = { zoom: chatUiScale } as const;
const navigate = useNavigate();
const openAiProvidersSettings = useCallback(() => {
navigate("/settings?tab=ai#ai-providers");
Expand Down Expand Up @@ -777,6 +782,8 @@ export function AgentChatPane({
const shellRef = useRef<HTMLElement | null>(null);
const composerMaxHeightPx = layoutVariant === "grid-tile" ? 144 : null;
const sessionsRef = useRef<AgentChatSessionSummary[]>(sessions);
const completionSoundPrevTurnActiveRef = useRef(false);
const completionSoundArmedRef = useRef(true);

const appliedInitialSessionIdRef = useRef<string | null>(initialSessionId ?? null);
const loadedHistoryRef = useRef<Set<string>>(new Set());
Expand Down Expand Up @@ -824,6 +831,40 @@ export function AgentChatPane({
const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null;
const selectedSessionAwaitingInput = Boolean(pendingInput) || selectedSession?.awaitingInput === true;
const turnActive = selectedSessionId ? (turnActiveBySession[selectedSessionId] ?? false) : false;

useEffect(() => {
completionSoundPrevTurnActiveRef.current = false;
completionSoundArmedRef.current = true;
}, [selectedSessionId]);

useEffect(() => {
if (agentTurnCompletionSound === "off") {
completionSoundPrevTurnActiveRef.current = turnActive;
return;
}
if (turnActive) {
completionSoundArmedRef.current = true;
}
const sessionEnded = selectedSession?.status === "ended";
const settled =

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 completion check treats any transition from turnActive=true to false as a successful finish as long as the session is not ended and not awaiting input:

// apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
const settled =
  Boolean(selectedSessionId)
  && !selectedSessionAwaitingInput
  && !sessionEnded;
const becameIdle = settled && prevTurn && !turnActive;

This is too broad. I verified in @apps/desktop/src/renderer/components/chat/AgentChatPane.tsx:216-250 that deriveRuntimeState() clears turnActive for every done event, and in @apps/desktop/src/main/services/chat/agentChatService.ts:5178-5199 plus the interrupt/failure paths around 7913-7919, 7966-8003, and 12493-12498 that interrupted/failed turns are left in status = "idle", not ended. That means cancelling a run or hitting a provider error satisfies settled and still calls playAgentTurnCompletionSound(...), which contradicts the setting copy in @apps/desktop/src/renderer/components/settings/AppearanceSection.tsx that says the sound plays when the assistant "finishes a turn". Gate the sound on a verified successful turn outcome (for example the latest done.status === "completed" / status.turnStatus === "completed") instead of any idle transition.

Boolean(selectedSessionId)
&& !selectedSessionAwaitingInput
&& !sessionEnded;
const prevTurn = completionSoundPrevTurnActiveRef.current;
const becameIdle = settled && prevTurn && !turnActive;
completionSoundPrevTurnActiveRef.current = turnActive;
if (becameIdle && completionSoundArmedRef.current) {

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 idle-transition effect treats any turnActivefalse transition as a successful completion and plays the notification immediately:

// apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
const prevTurn = completionSoundPrevTurnActiveRef.current;
const becameIdle = settled && prevTurn && !turnActive;
completionSoundPrevTurnActiveRef.current = turnActive;
if (becameIdle && completionSoundArmedRef.current) {
  completionSoundArmedRef.current = false;
  playAgentTurnCompletionSound(agentTurnCompletionSound);
}

That also matches the existing interrupt() path in this same component, which optimistically does setTurnActiveBySession((prev) => ({ ...prev, [selectedSessionId]: false })) before awaiting the backend interrupt. In practice, clicking Stop will satisfy prevTurn && !turnActive and emit the new "completion" sound for an aborted turn. Suppress the next notification when the stop path initiated the transition, or gate playback on a confirmed non-interrupted terminal event instead of any idle transition.

completionSoundArmedRef.current = false;
playAgentTurnCompletionSound(agentTurnCompletionSound);
}
}, [
agentTurnCompletionSound,
selectedSessionId,
selectedSession?.status,
selectedSessionAwaitingInput,
turnActive,
]);

const activeProviderConnection = selectedSession?.provider === "claude"
? (providerConnections?.claude ?? null)
: selectedSession?.provider === "codex"
Expand Down Expand Up @@ -2497,7 +2538,7 @@ export function AgentChatPane({

if (!laneId) {
return (
<ChatSurfaceShell mode={surfaceMode} accentColor={presentation?.accentColor}>
<ChatSurfaceShell mode={surfaceMode} accentColor={presentation?.accentColor} extraSurfaceStyle={chatSurfaceZoomStyle}>
<div className="flex h-full items-center justify-center">
<span className="font-sans text-[12px] text-muted-fg/30">Select a lane to start chatting</span>
</div>
Expand Down Expand Up @@ -2923,6 +2964,7 @@ export function AgentChatPane({
containerRef={shellRef}
mode={surfaceMode}
accentColor={presentation?.accentColor ?? draftAccent}
extraSurfaceStyle={chatSurfaceZoomStyle}
className={compactShell ? cn("border-0 shadow-none rounded-none bg-transparent") : undefined}
header={compactShell ? undefined : shellHeader}
footer={isEmptyState ? undefined : composerElement}
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactNode, Ref } from "react";
import type { CSSProperties, ReactNode, Ref } from "react";
import type { ChatSurfaceMode } from "../../../shared/types";
import { cn } from "../ui/cn";
import { chatSurfaceVars } from "./chatSurfaceTheme";
Expand All @@ -16,6 +16,7 @@ export function ChatSurfaceShell({
bodyClassName,
footerClassName,
containerRef,
extraSurfaceStyle,
}: {
mode: ChatSurfaceMode;
accentColor?: string | null;
Expand All @@ -27,6 +28,8 @@ export function ChatSurfaceShell({
bodyClassName?: string;
footerClassName?: string;
containerRef?: Ref<HTMLElement>;
/** Merged into the outer section (e.g. chat font size + zoom from settings). */
extraSurfaceStyle?: CSSProperties;
}) {
const mobileChrome = layoutVariant === "mobile";

Expand All @@ -38,7 +41,11 @@ export function ChatSurfaceShell({
"relative flex h-full min-h-0 flex-col overflow-hidden",
className,
)}
style={{ ...chatSurfaceVars(mode, accentColor), background: "var(--color-bg)" }}
style={{
...chatSurfaceVars(mode, accentColor),
background: "var(--color-bg)",
...extraSurfaceStyle,
}}
>
{header ? (
<div
Expand Down
Loading
Loading