Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ade-cli/src/services/agentRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe("classifyAgentCliError", () => {
agent: "claude",
displayName: "Claude Code",
category: "unauthenticated",
authCommand: "claude /login",
authCommand: "claude auth login",
});
});
});
3 changes: 2 additions & 1 deletion apps/ade-cli/src/services/agentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [
displayName: "Claude Code",
binaryNames: ["claude"],
installCommand: npmGlobalInstallCommand("@anthropic-ai/claude-code"),
authCommand: "claude /login",
authCommand: "claude auth login",
missingErrorPatterns: [
/\bclaude\b.*\b(command not found|not recognized|not found|enoent)\b/i,
/\bspawn\s+claude\s+enoent\b/i,
],
notAuthErrorPatterns: [
/\bclaude\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i,
/\brun\s+[`'"]?claude\s+auth\s+login[`'"]?/i,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/\brun\s+[`'"]?claude\s+\/login[`'"]?/i,
],
},
Expand Down
35 changes: 30 additions & 5 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,11 @@ import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs";
import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawerPanel";
import { CodexPlanCard } from "./codex/CodexPlanCard";
import { ChatPrPane } from "./ChatPrPane";
import { ClaudeLoginPromptButton } from "../work/ClaudeLoginPromptButton";
import { rootAppStoreApi, selectActiveProjectRoot, useAppStore, useRootAppStore } from "../../state/appStore";
import { buildChatAppearanceRootStyle } from "./chatAppearance";
import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard";
import { shouldShowClaudeChatLoginPrompt } from "../../lib/claudeAuthPrompt";
import { LaneAccentDot } from "../lanes/LaneAccentDot";
import {
effectiveNewLaneBaseSource,
Expand Down Expand Up @@ -3143,6 +3145,10 @@ export function AgentChatPane({
nonce: number;
} | null>(null);
const terminalRevealNonceRef = useRef(0);
const revealChatTerminal = useCallback((terminal: { terminalId: string; ptyId: string; label: string }) => {
setTerminalDrawerOpen(true);
setTerminalRevealRequest({ ...terminal, nonce: ++terminalRevealNonceRef.current });
}, []);
const [rightPaneSplit, setRightPaneSplit] = useState<number>(() => {
try {
const raw = window.sessionStorage.getItem("ade.chat.rightPaneSplit");
Expand Down Expand Up @@ -4198,6 +4204,18 @@ export function AgentChatPane({
if (selectedSession && !modelSelectionDiffersFromSession) return selectedSession.provider;
return resolveChatRuntimeProvider(resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId));
}, [selectedSession, modelSelectionDiffersFromSession, modelId]);
const showClaudeLoginPrompt = useMemo(() => shouldShowClaudeChatLoginPrompt({
provider: selectedSession?.provider ?? sessionProvider,
events: selectedEventsForDisplay,
turnActive,
authAvailable: providerConnections?.claude?.authAvailable === true,
}), [
providerConnections?.claude?.authAvailable,
selectedEventsForDisplay,
selectedSession?.provider,
sessionProvider,
turnActive,
]);
// Provider-agnostic context-usage for the composer dial. Codex pushes a live
// CodexThreadTokenUsage (with modelContextWindow); the other runtimes report a
// 4-field breakdown on the terminal `done`/`tokens` events. We take the
Expand Down Expand Up @@ -8970,8 +8988,7 @@ export function AgentChatPane({
onAddAttachment={addAttachment}
onInsertDraft={insertComposerDraft}
onShowTerminal={(terminal) => {
setTerminalDrawerOpen(true);
setTerminalRevealRequest({ ...terminal, nonce: ++terminalRevealNonceRef.current });
revealChatTerminal(terminal);
}}
onAddContext={addAppControlContext}
/>
Expand All @@ -8980,7 +8997,16 @@ export function AgentChatPane({
);
const chatHeaderTrailingActions = (
<>
{laneToolsVisible && iosSimulatorAvailable ? (
{chatTerminalVisible && selectedSessionId ? (
<ClaudeLoginPromptButton
visible={showClaudeLoginPrompt}
storageKey={`chat:${selectedSessionId}`}
laneId={laneId}
chatSessionId={selectedSessionId}
onRevealTerminal={revealChatTerminal}
/>
) : null}
{laneToolsVisible && iosSimulatorAvailable ? (
<SmartTooltip
content={{
label: iosSimulatorOpen ? "Close iOS simulator" : "Open iOS simulator",
Expand Down Expand Up @@ -10130,8 +10156,7 @@ export function AgentChatPane({
sessionId={selectedSessionId}
onInsertDraft={insertComposerDraft}
onRevealChatTerminal={(terminal) => {
setTerminalDrawerOpen(true);
setTerminalRevealRequest({ ...terminal, nonce: ++terminalRevealNonceRef.current });
revealChatTerminal(terminal);
}}
onRewindFiles={selectedSession?.provider === "claude" ? rewindFilesFromMessage : undefined}
onApproval={(itemId, decision, responseText, answers) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ describe("CliSessionWorkSurfaceHeader", () => {
expect(screen.queryByTestId("cache-badge")).toBeNull();
});

it("renders Claude login prompt when the Claude CLI session preview reports auth failure", () => {
renderHeader({
status: "failed",
runtimeState: "exited",
ptyId: null,
lastOutputPreview: "Please run /login · API Error: 401 Invalid authentication credentials",
});

expect(screen.getByRole("button", { name: "Login to Claude" })).toBeTruthy();
});

it("fires onInfoClick when the Info button is pressed", () => {
const onInfoClick = vi.fn();
renderHeader({}, { onInfoClick });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
WORK_SURFACE_HEADER_ACTION_IDLE,
WorkSurfaceHeader,
} from "../work/WorkSurfaceHeader";
import { ClaudeLoginPromptButton } from "../work/ClaudeLoginPromptButton";
import { shouldShowClaudeCliLoginPrompt } from "../../lib/claudeAuthPrompt";

type SessionMouseHandler = (
session: TerminalSessionSummary,
Expand Down Expand Up @@ -152,6 +154,16 @@ function SessionActionsButton({
);
}

function ClaudeLoginHeaderAction({ session }: { session: TerminalSessionSummary }) {
return (
<ClaudeLoginPromptButton
visible={shouldShowClaudeCliLoginPrompt(session)}
storageKey={`cli:${session.id}`}
laneId={session.laneId}
/>
);
}

/**
* Trailing-action set rendered to the right of every CLI session header. Kept
* intentionally light: Stop, a lane-scoped Run dropdown, an Info button, and the
Expand Down Expand Up @@ -294,6 +306,7 @@ export function CliSessionWorkSurfaceHeader({
toolsPaneOpen={toolsPaneOpen}
trailingActions={
<>
<ClaudeLoginHeaderAction session={session} />
<SessionStatusDot session={session} />
<CliSurfaceTrailingActions
session={session}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/* @vitest-environment jsdom */

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ClaudeLoginPromptButton } from "./ClaudeLoginPromptButton";

const originalAde = globalThis.window.ade;

function installAdeStub() {
globalThis.window.ade = {
pty: {
create: vi.fn().mockResolvedValue({
sessionId: "terminal-claude-login",
ptyId: "pty-claude-login",
pid: 123,
}),
},
lanes: {
list: vi.fn().mockResolvedValue([{ id: "lane-default", name: "Primary" }]),
},
} as any;
}

describe("ClaudeLoginPromptButton", () => {
beforeEach(() => {
installAdeStub();
window.sessionStorage.clear();
});

afterEach(() => {
cleanup();
window.sessionStorage.clear();
if (originalAde === undefined) {
delete (globalThis.window as any).ade;
} else {
globalThis.window.ade = originalAde;
}
});

it("opens claude auth login in the owning chat terminal", async () => {
const onRevealTerminal = vi.fn();

render(
<ClaudeLoginPromptButton
visible
storageKey="chat:chat-1"
laneId="lane-1"
chatSessionId="chat-1"
onRevealTerminal={onRevealTerminal}
/>,
);

fireEvent.click(screen.getByRole("button", { name: "Login to Claude" }));

await waitFor(() => {
expect(window.ade.pty.create).toHaveBeenCalledWith({
laneId: "lane-1",
chatSessionId: "chat-1",
cols: 100,
rows: 28,
title: "Claude login",
tracked: true,
toolType: "shell",
startupCommand: "claude auth login",
});
});
expect(onRevealTerminal).toHaveBeenCalledWith({
terminalId: "terminal-claude-login",
ptyId: "pty-claude-login",
label: "Claude login",
});
expect(window.ade.lanes.list).not.toHaveBeenCalled();
});

it("dismisses the prompt per storage key", () => {
const { rerender } = render(
<ClaudeLoginPromptButton visible storageKey="cli:term-1" laneId="lane-1" />,
);

fireEvent.click(screen.getByRole("button", { name: "Dismiss Claude login prompt" }));
expect(screen.queryByRole("button", { name: "Login to Claude" })).toBeNull();

rerender(<ClaudeLoginPromptButton visible storageKey="cli:term-1" laneId="lane-1" />);
expect(screen.queryByRole("button", { name: "Login to Claude" })).toBeNull();
});

it("allows a dismissed prompt to reappear after the auth condition clears", async () => {
const { rerender } = render(
<ClaudeLoginPromptButton visible storageKey="cli:term-1" laneId="lane-1" />,
);

fireEvent.click(screen.getByRole("button", { name: "Dismiss Claude login prompt" }));
expect(screen.queryByRole("button", { name: "Login to Claude" })).toBeNull();

rerender(<ClaudeLoginPromptButton visible={false} storageKey="cli:term-1" laneId="lane-1" />);

await waitFor(() => {
expect(window.sessionStorage.getItem("ade.claudeLoginPrompt.dismissed.v1:cli:term-1")).toBeNull();
});

rerender(<ClaudeLoginPromptButton visible storageKey="cli:term-1" laneId="lane-1" />);
expect(screen.getByRole("button", { name: "Login to Claude" })).toBeTruthy();
});

it("shows a clear error if no lane can be resolved", async () => {
delete (window.ade as any).lanes;

render(<ClaudeLoginPromptButton visible storageKey="cli:term-1" laneId={null} />);

fireEvent.click(screen.getByRole("button", { name: "Login to Claude" }));

await waitFor(() => {
expect(screen.getByText("No active lane is available for this project.")).toBeTruthy();
});
expect(window.ade.pty.create).not.toHaveBeenCalled();
});
});
Loading