Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
40 changes: 35 additions & 5 deletions src/renderer/components/thread/ThreadAuthRequiredDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ async function refreshAgentStatus(status: AgentStatus): Promise<void> {
});
}

/**
* Keep focus on whatever the user had focused before they mouse-clicked the
* dock's action button. Without this, clicking the button takes focus, the
* `isDisabled` swap during the in-flight action then releases focus, and
* react-aria restores focus to the composer for one frame before the dock
* finally unmounts — producing a visible border blink on the composer.
*/
function preventFocusSteal(event: React.MouseEvent<HTMLElement>): void {
event.preventDefault();
}

export function ThreadAuthRequiredDock(props: { agentStatus: AgentStatus; project?: Project }) {
const { agentStatus, project } = props;
const [pendingAction, setPendingAction] = useState<"login" | "refresh" | undefined>();
Expand Down Expand Up @@ -60,12 +71,28 @@ export function ThreadAuthRequiredDock(props: { agentStatus: AgentStatus; projec
}

if (agentStatus.loginCommand) {
runAgentLoginCommand({
setPendingAction("login");
const opened = runAgentLoginCommand({
label: agentStatus.label,
command: agentStatus.loginCommand,
...(terminalAuthMethod?.env ? { env: terminalAuthMethod.env } : {}),
...(project ? { project } : {}),
onCommandComplete: (exitCode) => {
void refreshAgentStatus(agentStatus)
.then(() => {
if (exitCode === 0) toast.success(`${agentStatus.label} authenticated.`);
})
.catch((error: unknown) => {
toast.danger(
error instanceof Error
? error.message
: `Unable to refresh ${agentStatus.label} status.`,
);
})
.finally(() => setPendingAction(undefined));
},
});
if (!opened) setPendingAction(undefined);
}
}

Expand Down Expand Up @@ -94,10 +121,11 @@ export function ThreadAuthRequiredDock(props: { agentStatus: AgentStatus; projec
{hasDirectLogin ? (
<Button
size="sm"
variant="secondary"
className="h-6 min-w-0 px-2 text-xs"
variant="ghost"
className="h-6 min-w-0 px-2 text-xs text-foreground"
isDisabled={pendingAction !== undefined}
isPending={pendingAction === "login"}
onMouseDown={preventFocusSteal}
onPress={() => void handleLogin()}
>
<LogIn className="size-3.5" />
Expand All @@ -106,8 +134,9 @@ export function ThreadAuthRequiredDock(props: { agentStatus: AgentStatus; projec
) : (
<Button
size="sm"
variant="secondary"
className="h-6 min-w-0 px-2 text-xs"
variant="ghost"
className="h-6 min-w-0 px-2 text-xs text-foreground"
onMouseDown={preventFocusSteal}
onPress={openSettings}
>
<Settings className="size-3.5" />
Expand All @@ -122,6 +151,7 @@ export function ThreadAuthRequiredDock(props: { agentStatus: AgentStatus; projec
className="h-6 w-6 min-w-0 text-muted"
isDisabled={pendingAction !== undefined}
isPending={pendingAction === "refresh"}
onMouseDown={preventFocusSteal}
onPress={() => void handleRefresh()}
>
<RefreshCw className="size-3.5" />
Expand Down
14 changes: 11 additions & 3 deletions src/renderer/components/thread/ThreadComposerSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
resolveLocalSlashCommandAction,
} from "./threadSlashCommands";
import { handleComposerControlShortcut } from "./threadComposerShortcuts";
import type { ThreadErrorDockState } from "./threadErrorState";
import { isAuthErrorMessage, type ThreadErrorDockState } from "./threadErrorState";
import type { ThreadGoalDockState } from "./threadGoalState";
import type { ThreadTodoDockState } from "./threadTodoState";
import type { TerminalPaneHandle } from "./TerminalPane";
Expand Down Expand Up @@ -350,7 +350,14 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread
);
const filteredCommands = filterSlashCommands(availableCommands, slashQuery);
const showCommandPanel = filteredCommands.length > 0;
const authRequired = agentStatus?.authState === "missing";
// A stale runtime auth error (e.g. a 401 from before the user signed in)
// must not keep the auth dock visible once detection confirms the agent is
// authenticated again — otherwise the dock sticks until the user sends a
// new message and the error item scrolls off `selectThreadLatestErrorItem`.
const isAgentAuthenticated = agentStatus?.authState === "authenticated";
const hasRuntimeAuthError =
!isAgentAuthenticated && errorDockState !== null && isAuthErrorMessage(errorDockState.message);
const authRequired = agentStatus?.authState === "missing" || hasRuntimeAuthError;
const isServerControlled =
agentStatus?.capabilities.liveInputMode === "server" || !usesTerminalPresentation;
const isTerminalInput = agentStatus?.capabilities.liveInputMode === "terminal";
Expand Down Expand Up @@ -381,7 +388,8 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread
const showTodoInComposer =
!usesTerminalPresentation && todoDockState !== null && todoDockPlacement === "composer";
const showGoalInComposer = !usesTerminalPresentation && goalDockState !== null;
const showErrorInComposer = !usesTerminalPresentation && errorDockState !== null;
const showErrorInComposer =
!usesTerminalPresentation && errorDockState !== null && !hasRuntimeAuthError;
const hasActiveSubAgent = useAppStore(
(s) => !usesTerminalPresentation && selectActiveSubAgentParentItemIds(s, thread.id).length > 0,
);
Expand Down
24 changes: 23 additions & 1 deletion src/renderer/components/thread/threadErrorState.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { RuntimeChatItem } from "@/renderer/state/slices/runtimeEventSlice";
import { getThreadErrorDockStateForItem } from "./threadErrorState";
import { getThreadErrorDockStateForItem, isAuthErrorMessage } from "./threadErrorState";

function errorItem(message: string): RuntimeChatItem {
return {
Expand All @@ -25,3 +25,25 @@ describe("threadErrorState", () => {
});
});
});

describe("isAuthErrorMessage", () => {
it.each([
"Failed to authenticate. API Error: 401 Invalid authentication credentials",
"Not logged in · Please run /login",
"Session expired. Please run /login to sign in again.",
"authentication_failed",
"API Error: 401",
"oauth_org_not_allowed",
])("recognizes %q as an auth error", (msg) => {
expect(isAuthErrorMessage(msg)).toBe(true);
});

it.each([
"Network error: request failed",
"Rate limit exceeded",
"Internal server error",
"Claude turn failed.",
])("does not flag %q as an auth error", (msg) => {
expect(isAuthErrorMessage(msg)).toBe(false);
});
});
24 changes: 24 additions & 0 deletions src/renderer/components/thread/threadErrorState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,27 @@ export function getThreadErrorDockStateForItem(item: RuntimeChatItem): ThreadErr
function isAbortOnlyErrorMessage(message: string): boolean {
return /^(?:error:\s*)?(?:aborterror:\s*)?aborted\.?$/i.test(message.trim());
}

/**
* Heuristic for runtime errors that indicate the agent is unauthenticated.
* Covers strings emitted by the Claude binary ("Failed to authenticate. API
* Error: 401 …", "Please run /login", "Session expired …") and the
* Anthropic-SDK error codes the agent SDK surfaces ("authentication_failed",
* "oauth_org_not_allowed"). When this returns true, the composer should
* render the auth-required dock (with its Login button) rather than the
* generic error dock — `/login` itself isn't reachable through the SDK
* transport, so the user needs the terminal-login affordance.
*/
export function isAuthErrorMessage(message: string): boolean {
const m = message.toLowerCase();
return (
m.includes("failed to authenticate") ||
m.includes("invalid authentication credentials") ||
m.includes("api error: 401") ||
m.includes("please run /login") ||
m.includes("session expired") ||
m.includes("authentication_failed") ||
m.includes("oauth_org_not_allowed") ||
/\bnot logged in\b/.test(m)
);
}
16 changes: 16 additions & 0 deletions src/renderer/rendererGlobalErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isIgnorableRejection,
isIgnorableWindowError,
isResizeObserverLoopError,
isViewTransitionInvalidStateError,
isViewTransitionSkippedError,
} from "./rendererGlobalErrors";

Expand Down Expand Up @@ -44,4 +45,19 @@ describe("rendererGlobalErrors", () => {
expect(isIgnorableRejection(new Error("Transition was skipped"))).toBe(false);
expect(isIgnorableRejection("Transition was skipped")).toBe(false);
});

it("recognizes view-transition invalid-state InvalidStateError rejections", () => {
const err = new DOMException(
"Transition was aborted because of invalid state",
"InvalidStateError",
);
expect(isViewTransitionInvalidStateError(err)).toBe(true);
expect(isIgnorableRejection(err)).toBe(true);
});

it("does not ignore unrelated InvalidStateErrors", () => {
const err = new DOMException("IndexedDB transaction inactive", "InvalidStateError");
expect(isViewTransitionInvalidStateError(err)).toBe(false);
expect(isIgnorableRejection(err)).toBe(false);
});
});
16 changes: 15 additions & 1 deletion src/renderer/rendererGlobalErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,24 @@ export function isViewTransitionSkippedError(error: unknown): boolean {
return message === "Transition was skipped";
}

// Sibling of `isViewTransitionSkippedError`: when the document state changes
// in a way that invalidates the snapshot mid-transition (e.g. the Settings
// overlay closes while a button-press transition is mid-flight, or the
// terminal panel opens and reflows the tree), Chromium rejects the
// ViewTransition promises with InvalidStateError "Transition was aborted
// because of invalid state" instead of the AbortError variant. Same root
// cause, same benign treatment — the DOM update still completed.
export function isViewTransitionInvalidStateError(error: unknown): boolean {
const name = readErrorName(error);
if (name !== "InvalidStateError") return false;
const message = readErrorMessage(error);
return message === "Transition was aborted because of invalid state";
}

export function isIgnorableWindowError(event: ErrorEvent): boolean {
return isResizeObserverLoopError(event.error) || isResizeObserverLoopError(event.message);
}

export function isIgnorableRejection(reason: unknown): boolean {
return isViewTransitionSkippedError(reason);
return isViewTransitionSkippedError(reason) || isViewTransitionInvalidStateError(reason);
}
16 changes: 16 additions & 0 deletions src/supervisor/agents/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,22 @@ export function detectProbeLocation(ctx: AgentEnvContext | undefined): ProjectLo
return { kind: "posix", path: homedir() };
}

/**
* Adapter helper for CLIs that expose logout as a shell subcommand
* (`claude auth logout`, `codex logout`, `cursor-agent logout`). Delegates
* to `buildAgentCommand` so posix, Windows, and WSL share the same shell
* wrapping the agent uses in production.
*/
export function buildAgentLogoutCommand(
binary: string,
args: string[],
): (ctx?: AgentEnvContext) => Promise<CommandSpec> {
return async (ctx) => {
const location = detectProbeLocation(ctx);
return buildAgentCommand(location, binary, args, resolveAgentBinaryPath(location, binary));
};
}

async function resolveDetectedBinary(
ctx: AgentEnvContext | undefined,
binary: string,
Expand Down
12 changes: 12 additions & 0 deletions src/supervisor/agents/claude/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ describe("createClaudeAdapter structured sessions", () => {
});
});

describe("createClaudeAdapter buildAcpLogoutCommand", () => {
it("returns `claude auth logout` so the Settings logout button can drive it", async () => {
const adapter = createClaudeAdapter();
const command = await adapter.buildAcpLogoutCommand?.();
expect(command).toBeDefined();
// `buildAgentCommand` wraps the argv in a login shell (`sh -c "exec
// 'claude' 'auth' 'logout'"`) on posix and in `wsl.exe -d <distro> --` on
// WSL — assert on the substring so both platforms pass.
expect(command?.args.join(" ")).toContain("'claude' 'auth' 'logout'");
});
});

describe("parseClaudeAuthStatusJson", () => {
it("extracts account metadata from Claude's auth-status JSON", () => {
expect(
Expand Down
2 changes: 2 additions & 0 deletions src/supervisor/agents/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import type { PromptSegment } from "@/shared/contracts";
import {
brailleSpinnerOscTitleHint,
buildAgentLogoutCommand,
createKnownSessionRef,
detectAgentInstall,
iterm2ProgressOscHint,
Expand Down Expand Up @@ -87,6 +88,7 @@ export function createClaudeAdapter(): AgentAdapter {
if (input.presentationMode !== "gui") return undefined;
return ClaudeSdkSession.create(input);
},
buildAcpLogoutCommand: buildAgentLogoutCommand("claude", ["auth", "logout"]),
buildDirectInput(prompt, segments) {
const attachmentCount = segments?.filter((s) => s.kind === "attachment").length ?? 0;
const wait = attachmentCount > 0 ? 800 + (attachmentCount - 1) * 150 : 60;
Expand Down
29 changes: 24 additions & 5 deletions src/supervisor/agents/claude/probe.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import type { AgentCapability } from "@/shared/contracts";
import type { AgentAuthMethod, AgentCapability } from "@/shared/contracts";
import type { SDKUserMessage, SlashCommand } from "@anthropic-ai/claude-agent-sdk";
import { readWslLoginShellCommandOutputAsync, type DetectProbeCtx } from "../base";
import {
readWslLoginShellCommandOutputAsync,
type CapabilitiesProbeResult,
type DetectProbeCtx,
} from "../base";

const CLAUDE_TERMINAL_AUTH_METHOD: AgentAuthMethod = {
type: "terminal",
id: "claude-login",
name: "Claude login",
args: ["auth", "login"],
};

const MIN_CLAUDE_OPUS_47_CLI = [2, 1, 111] as const;
const OPUS_47_MODEL_ID = "claude-opus-4-7";
Expand Down Expand Up @@ -198,7 +209,7 @@ async function probeClaudeSdkPartialWsl(

export async function probeClaudeCapabilities(
ctx: DetectProbeCtx,
): Promise<Partial<AgentCapability> | undefined> {
): Promise<CapabilitiesProbeResult | undefined> {
if (!ctx.executablePath) return undefined;

const timeoutMs = process.platform === "win32" ? 12_000 : 10_000;
Expand All @@ -209,6 +220,14 @@ export async function probeClaudeCapabilities(

const versionPartial = claudeCapabilitiesFromCliVersion(ctx.version);

if (!sdkPartial && !versionPartial) return undefined;
return { ...(sdkPartial ?? {}), ...(versionPartial ?? {}) };
// Always advertise the terminal login + `claude auth logout` capabilities
// when the binary is installed — the Settings UI gates the Login/Logout
// controls on these fields, and the supervisor's logout dispatcher uses
// the adapter's `buildAcpLogoutCommand` to invoke `claude auth logout`.
return {
...(sdkPartial ?? {}),
...(versionPartial ?? {}),
authMethods: [CLAUDE_TERMINAL_AUTH_METHOD],
authLogoutSupported: true,
};
}
29 changes: 29 additions & 0 deletions src/supervisor/agents/claude/sdkCanonicalMapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,35 @@ describe("sdkCanonicalMapping — turn completion", () => {
{ type: "turn.completed", threadId: "thread-1", turnId: "turn-1", state: "completed" },
]);
});

it("maps a success-subtype result with is_error to a failed turn and surfaces the API message", () => {
const state = createClaudeMapperState("thread-1");
startClaudeTurn(state, "turn-auth", "hi", undefined);

const events = mapClaudeSdkMessage(
{
type: "result",
subtype: "success",
is_error: true,
api_error_status: 401,
result: "Failed to authenticate. API Error: 401 Invalid authentication credentials",
session_id: "claude-session",
} as unknown as SDKMessage,
state,
);

expect(events).toContainEqual({
type: "error",
threadId: "thread-1",
message: "Failed to authenticate. API Error: 401 Invalid authentication credentials",
});
expect(events).toContainEqual({
type: "turn.completed",
threadId: "thread-1",
turnId: "turn-auth",
state: "failed",
});
});
});

describe("sdkCanonicalMapping — requests", () => {
Expand Down
Loading