Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,85 @@
/* @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",
});
});

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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useCallback, useEffect, useState } from "react";
import { SpinnerGap, Terminal, X } from "@phosphor-icons/react";
import { CLAUDE_AUTH_LOGIN_COMMAND } from "../../lib/claudeAuthPrompt";
import { cn } from "../ui/cn";

type RevealTerminalRequest = {
terminalId: string;
ptyId: string;
label: string;
};

function dismissedKey(storageKey: string): string {
return `ade.claudeLoginPrompt.dismissed.v1:${storageKey}`;
}

function readDismissed(storageKey: string): boolean {
try {
return window.sessionStorage.getItem(dismissedKey(storageKey)) === "1";
} catch {
return false;
}
}

function writeDismissed(storageKey: string): void {
try {
window.sessionStorage.setItem(dismissedKey(storageKey), "1");
} catch {
// Best-effort per-window dismissal.
}
}

export function ClaudeLoginPromptButton({
visible,
storageKey,
laneId,
chatSessionId,
onRevealTerminal,
className,
}: {
visible: boolean;
storageKey: string;
laneId?: string | null;
chatSessionId?: string | null;
onRevealTerminal?: (terminal: RevealTerminalRequest) => void;
className?: string;
}) {
const [dismissed, setDismissed] = useState(() => readDismissed(storageKey));
const [opening, setOpening] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
setDismissed(readDismissed(storageKey));
setError(null);
}, [storageKey]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const dismiss = useCallback(() => {
writeDismissed(storageKey);
setDismissed(true);
}, [storageKey]);

const handleLogin = useCallback(() => {
if (opening || !window.ade?.pty?.create) return;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
setOpening(true);
setError(null);
void (async () => {
const resolvedLaneId = laneId ?? (await window.ade.lanes.list({
includeArchived: false,
includeStatus: false,
}))[0]?.id ?? null;
if (!resolvedLaneId) {
throw new Error("No active lane is available for this project.");
}

const created = await window.ade.pty.create({
laneId: resolvedLaneId,
...(chatSessionId ? { chatSessionId } : {}),
cols: 100,
rows: 28,
title: "Claude login",
tracked: true,
toolType: "shell",
startupCommand: CLAUDE_AUTH_LOGIN_COMMAND,
});

const reveal = {
terminalId: created.sessionId,
ptyId: created.ptyId,
label: "Claude login",
};
onRevealTerminal?.(reveal);
if (!chatSessionId && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("ade:work:select-session", {
detail: { sessionId: created.sessionId, laneId: resolvedLaneId },
}));
}
})()
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => setOpening(false));
}, [chatSessionId, laneId, onRevealTerminal, opening]);

if (!visible || dismissed) return null;

return (
<div className={cn("relative inline-flex shrink-0 items-center", className)}>
<div className="inline-flex h-6 overflow-hidden rounded-md border border-[#d97757]/35 bg-[#d97757]/15 shadow-[0_0_18px_rgba(217,119,87,0.12)]">
<button
type="button"
onClick={handleLogin}
disabled={opening || !window.ade?.pty?.create}
className="inline-flex h-full items-center gap-1.5 px-2 font-sans text-[10px] font-semibold text-[#ffd7c2] transition-colors hover:bg-[#d97757]/18 disabled:cursor-not-allowed disabled:opacity-55"
title={error ?? "Open a terminal here and run claude auth login"}
aria-label="Login to Claude"
>
{opening ? <SpinnerGap size={12} className="animate-spin" aria-hidden /> : <Terminal size={12} weight="bold" aria-hidden />}
<span className="whitespace-nowrap">Login to Claude</span>
</button>
<button
type="button"
onClick={dismiss}
className="inline-flex h-full w-5 items-center justify-center border-l border-[#d97757]/25 text-[#ffd7c2]/65 transition-colors hover:bg-[#d97757]/18 hover:text-[#ffe4d5]"
aria-label="Dismiss Claude login prompt"
title="Dismiss"
>
<X size={10} weight="bold" aria-hidden />
</button>
</div>
{error ? (
<span className="absolute right-0 top-[calc(100%+4px)] z-20 max-w-[16rem] rounded-md border border-red-400/20 bg-red-950/90 px-2 py-1 text-right text-[10px] text-red-100 shadow-lg">
{error}
</span>
) : null}
</div>
);
}
Loading