From d86e1cabef5a11f5b3b4afa1c9b617c01129b8c6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:33:12 -0400 Subject: [PATCH 1/7] Handle Claude chat logout recovery --- .../services/chat/agentChatService.test.ts | 68 +++++++ .../main/services/chat/agentChatService.ts | 57 +++++- .../chat/AgentChatMessageList.test.tsx | 30 ++++ .../components/chat/AgentChatMessageList.tsx | 29 +++ .../components/chat/AgentChatPane.test.tsx | 61 +++++++ .../components/chat/AgentChatPane.tsx | 106 +++++++++++ .../components/chat/AgentCliAuthCard.test.tsx | 59 ++++++- .../components/chat/AgentCliAuthCard.tsx | 166 ++++++++++++++++-- .../src/renderer/lib/claudeAuthPrompt.test.ts | 10 ++ .../src/renderer/lib/claudeAuthPrompt.ts | 5 + docs/features/chat/README.md | 36 +++- 11 files changed, 604 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 31fb759d9..e10ecb558 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1878,6 +1878,74 @@ describe("createAgentChatService", () => { expect((doneEvent!.event as any).modelId).toBe("anthropic/claude-opus-4-8"); }); + it("fast-fails a logged-out Claude turn into the inline re-login card", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + // Startup warmup stream — healthy. + yield { type: "system", subtype: "init", session_id: "sdk-auth", model: "claude-opus-4-8", slash_commands: [] }; + yield { type: "result", subtype: "success", is_error: false, session_id: "sdk-auth" }; + return; + } + // The SDK reports a logged-out session as an assistant message carrying + // error="authentication_failed", with the 401 surfaced as plain text. + yield { type: "system", subtype: "init", session_id: "sdk-auth", model: "claude-opus-4-8", slash_commands: [] }; + yield { + type: "assistant", + error: "authentication_failed", + message: { + model: "claude-opus-4-8", + content: [{ type: "text", text: "Failed to authenticate. API Error: 401 Invalid authentication credentials" }], + }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-auth", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-opus-4-8", + modelId: "anthropic/claude-opus-4-8", + }); + + await service.runSessionTurn({ sessionId: session.id, text: "use context skill" }); + + // The raw 401 is not surfaced as a plain assistant bubble. + const authText = events.find( + (event) => event.event.type === "text" + && /invalid authentication credentials/i.test((event.event as any).text ?? ""), + ); + expect(authText).toBeUndefined(); + + // A single "logged out" notice replaces the "retry 1/10 … 10/10" storm. + const notice = events.find( + (event) => event.event.type === "system_notice" + && /logged out/i.test((event.event as any).message ?? ""), + ); + expect(notice).toBeTruthy(); + + // The error carries the agentCli signal that renders the inline re-login card. + const errorEvent = events.find( + (event) => event.event.type === "error" + && (event.event as any).errorInfo?.agentCli?.category === "unauthenticated", + ); + expect(errorEvent).toBeTruthy(); + expect((errorEvent!.event as any).errorInfo.agentCli.agent).toBe("claude"); + + const failedDone = events.filter((event) => event.event.type === "done").at(-1); + expect((failedDone!.event as any).status).toBe("failed"); + }); + it("honors an explicit initial chat title", async () => { const { service, sessionService } = createService(); const session = await service.createSession({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ed563cc84..f728ab8a3 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -802,6 +802,9 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ const CODEX_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CODEX_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name))); const CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CLAUDE_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name))); const CLAUDE_LOGIN_NOT_SDK_COMMAND = "ADE Claude chat is hosted through the Claude Agent SDK, and /login is not an SDK-dispatchable command. Run `claude auth login` in a terminal or configure ANTHROPIC_API_KEY, then refresh AI settings."; +// Terse one-liner shown when a turn fast-fails on a logout — paired with the +// fuller CLAUDE_RUNTIME_AUTH_ERROR card body emitted by the catch path. +const CLAUDE_AUTH_STOPPED_NOTICE = "Claude is logged out — stopped retrying."; const CLAUDE_SESSION_DISABLED_PLUGINS: Record = { "learning-output-style@claude-code-plugins": false, "learning-output-style@claude-plugins-official": false, @@ -12123,6 +12126,28 @@ export function createAgentChatService(args: { } }; + // Fast-fail on a definitive Claude auth failure (401 / logged out). A 401 + // is not transient, so rather than letting the SDK grind through its retry + // budget ("retry 1/10 … 10/10"), we stop on the first auth signal: emit a + // single "logged out" notice and throw an auth error. The catch block below + // recognises it via isClaudeRuntimeAuthError, closes the query (halting + // further retries), reports the runtime auth failure, and emits a decorated + // error event that renders the inline re-login card on every chat surface. + let claudeAuthStopNoticeEmitted = false; + const failClaudeTurnUnauthenticated = (): never => { + if (!claudeAuthStopNoticeEmitted) { + claudeAuthStopNoticeEmitted = true; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "auth", + severity: "warning", + message: CLAUDE_AUTH_STOPPED_NOTICE, + turnId, + }); + } + throw new Error(CLAUDE_RUNTIME_AUTH_ERROR); + }; + while (true) { const nextMessage = await readNextClaudeTurnMessage(); if (!nextMessage) { @@ -12313,13 +12338,8 @@ export function createAgentChatService(args: { if (msg.type === "auth_status") { const authMsg = msg as any; if (authMsg.error) { - reportProviderRuntimeAuthFailure("claude", CLAUDE_RUNTIME_AUTH_ERROR); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "auth", - message: CLAUDE_RUNTIME_AUTH_ERROR, - turnId, - }); + // Definitive logged-out signal — fast-fail into the re-login card. + failClaudeTurnUnauthenticated(); } else if (authMsg.isAuthenticating) { emitChatEvent(managed, { type: "system_notice", @@ -12386,6 +12406,17 @@ export function createAgentChatService(args: { if (msg.type === "system" && (msg as any).subtype === "api_retry") { const retryMsg = msg as any; const error = typeof retryMsg.error === "string" ? retryMsg.error : "transient_error"; + // A 401/403 (logged-out) retry will never recover on its own. Stop the + // retry storm on the first auth attempt instead of surfacing + // "retry 1/10 … 10/10" — rate-limit/overloaded retries still proceed. + if ( + retryMsg.error_status === 401 + || retryMsg.error_status === 403 + || error === "authentication_failed" + || isClaudeRuntimeAuthError(error) + ) { + failClaudeTurnUnauthenticated(); + } const retryDelayMs = typeof retryMsg.retry_delay_ms === "number" ? retryMsg.retry_delay_ms : null; const retryDelay = retryDelayMs != null ? Math.max(0, Math.round(retryDelayMs / 1000)) : null; emitChatEvent(managed, { @@ -12743,6 +12774,12 @@ export function createAgentChatService(args: { // assistant message — process content blocks if (msg.type === "assistant") { const assistantMsg = msg as any; + // The SDK reports a logged-out turn as an assistant message carrying + // error="authentication_failed" (its text otherwise renders as a plain + // bubble with no recovery affordance). Fast-fail into the re-login card. + if (assistantMsg.error === "authentication_failed") { + failClaudeTurnUnauthenticated(); + } const betaMessage = assistantMsg.message; const assistantMessageId = typeof betaMessage?.id === "string" ? betaMessage.id : null; // If the snapshot has no id, advance the id-less boundary once the @@ -13097,6 +13134,12 @@ export function createAgentChatService(args: { costUsd = resultMsg.total_cost_usd; } if (resultMsg.is_error && resultMsg.errors?.length) { + // A logged-out result surfaces here as 401 / "invalid authentication + // credentials" errors — route them into the re-login card rather than + // dumping raw API error text. + if (isClaudeRuntimeAuthError(resultMsg.errors.map(String).join(" "))) { + failClaudeTurnUnauthenticated(); + } for (const err of resultMsg.errors) { emitChatEvent(managed, { type: "error", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 6d85e2bf2..099cd39b8 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -661,6 +661,36 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.getAllByRole("button")).toHaveLength(2); }); + it("renders unauthenticated agent CLI errors as a re-login card", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "error", + message: "Authentication failed for Claude Sonnet 4.6.", + detail: "API Error: 401 Invalid authentication credentials", + errorInfo: { + category: "agent_cli_auth", + provider: "Claude Code", + agentCli: { + agent: "claude", + displayName: "Claude Code", + category: "unauthenticated", + installCommand: "npm install -g @anthropic-ai/claude-code", + authCommand: "claude auth login", + }, + }, + }, + }, + ], { sessionId: "session-1" }); + + expect(screen.getByText("Claude Code is logged out")).toBeTruthy(); + expect(screen.getByRole("button", { name: /retry turn/i })).toBeTruthy(); + expect(screen.getByText("Details")).toBeTruthy(); + expect(screen.queryByText("Error")).toBeNull(); + }); + it("renders Claude plan usage warning as a compact non-error notice", () => { renderMessageList([ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index de10e3c32..71372cf37 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -3167,6 +3167,35 @@ function renderEvent( const errorCopyValue = event.detail?.trim().length ? `${event.message}\n\n${event.detail}` : event.message; + // A logged-out runtime is recoverable, not a crash — lead with the calm + // re-login card and tuck the raw 401 behind a Details disclosure instead of + // the loud red error chrome. (The "missing CLI" card keeps the red frame.) + if (agentCliInfo?.category === "unauthenticated") { + return ( +
+
+ +
+ + Details + +
+
+ {errorCopyValue} +
+ +
+
+
+
+ ); + } return (
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 9410ac561..43bde7934 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -43,6 +43,7 @@ import { shouldPromoteSessionForComputerUse, type AgentChatSessionCreatedOptions, } from "./AgentChatPane"; +import { CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; vi.mock("../terminals/TerminalView", () => { const ReactMod = require("react") as typeof React; @@ -1389,6 +1390,66 @@ describe("AgentChatPane companion drawers", () => { }); describe("AgentChatPane submit recovery", () => { + it("resends the latest user message for the selected session after auth retry", async () => { + const session = buildSession("session-1", { + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + status: "idle", + }); + const transcript = [ + { + sessionId: session.sessionId, + timestamp: "2026-03-24T05:57:45.700Z", + event: { + type: "user_message", + text: "First prompt", + turnId: "turn-1", + }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-03-24T05:57:46.700Z", + event: { + type: "text", + text: "First answer", + turnId: "turn-1", + }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-03-24T05:57:47.700Z", + event: { + type: "user_message", + text: "Retry this exact prompt", + turnId: "turn-2", + }, + }, + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"; + const { send } = installAdeMocks({ + sessions: [session], + transcript, + includeClaudeModel: true, + }); + seedDrawerStore(); + + renderPane(session); + + expect(await screen.findByText("Retry this exact prompt")).toBeTruthy(); + + fireEvent(window, new CustomEvent(CHAT_RETRY_AUTH_TURN_EVENT, { + detail: { sessionId: session.sessionId }, + })); + + await waitFor(() => { + expect(send).toHaveBeenCalledWith({ + sessionId: session.sessionId, + text: "Retry this exact prompt", + displayText: "Retry this exact prompt", + }); + }); + }); + it("uses the model override as the constrained draft picker list", async () => { installAdeMocks({ sessions: [] }); seedRuntimeModelCatalog(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 7c95f312b..682dee969 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -128,6 +128,7 @@ import { CodexPlanCard } from "./codex/CodexPlanCard"; import { ChatPrPane } from "./ChatPrPane"; import { useChatPrAutoPop } from "./useChatPrAutoPop"; import { ClaudeLoginPromptButton } from "../work/ClaudeLoginPromptButton"; +import { CHAT_AUTH_RECOVERED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; import { rootAppStoreApi, selectActiveProjectRoot, useAppStore, useRootAppStore } from "../../state/appStore"; import { setLaneNaming } from "../../state/laneNamingStore"; import { buildChatAppearanceRootStyle } from "./chatAppearance"; @@ -3507,6 +3508,11 @@ export function AgentChatPane({ }; return [...displayEvents.slice(0, insertAt), synthetic, ...displayEvents.slice(insertAt)]; }, [optimisticOutgoingMessage, selectedEvents, selectedSession?.cursorCloudAgentId, selectedSession?.cursorPromotedTurnId, selectedSessionId]); + // Fresh snapshot of the visible transcript for the auth-retry/recovery handlers + // below, which run from window-event listeners (stale-closure-safe). + const selectedEventsForDisplayRef = useRef(selectedEventsForDisplay); + selectedEventsForDisplayRef.current = selectedEventsForDisplay; + const dispatchedAuthRecoveryRef = useRef>(new Set()); const selectedCodexGoal = useMemo(() => { let goalFromEvents: CodexThreadGoal | null = null; let sawGoalEvent = false; @@ -6273,6 +6279,87 @@ export function AgentChatPane({ insertComposerDraft, ]); + // Resend the most recent user message for a session that fast-failed on a + // Claude logout — fired by the inline re-login card's "Retry turn" button. The + // re-check is implicit: if Claude is still logged out the new turn fast-fails + // again and a fresh card appears. + const resendLastUserMessageForAuthRetry = useCallback(async (sessionId: string) => { + if (submitInFlightRef.current) return; + const events = selectedEventsForDisplayRef.current; + let text: string | null = null; + for (let index = events.length - 1; index >= 0; index -= 1) { + const evt = events[index]?.event; + if (evt?.type === "user_message" && typeof evt.text === "string" && evt.text.trim().length > 0) { + text = evt.text; + break; + } + } + if (!text) return; + try { + submitInFlightRef.current = true; + setBusy(true); + setError(null); + touchSession(sessionId); + await window.ade.agentChat.send({ sessionId, text, displayText: text }); + void refreshSessions().catch(() => {}); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + submitInFlightRef.current = false; + setBusy(false); + } + }, [refreshSessions, touchSession]); + + // The inline re-login card dispatches CHAT_RETRY_AUTH_TURN_EVENT on "Retry + // turn"; only the pane that owns the session resends. + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ sessionId?: string | null }>).detail; + const sessionId = detail?.sessionId; + if (typeof sessionId !== "string" || sessionId !== selectedSessionIdRef.current) return; + void resendLastUserMessageForAuthRetry(sessionId); + }; + window.addEventListener(CHAT_RETRY_AUTH_TURN_EVENT, handler); + return () => window.removeEventListener(CHAT_RETRY_AUTH_TURN_EVENT, handler); + }, [resendLastUserMessageForAuthRetry]); + + // When a turn succeeds after a logout, tell visible re-login cards for this + // session to collapse into a quiet "Reconnected" confirmation. + useEffect(() => { + const sessionId = selectedSessionId; + if (!sessionId) return; + const events = selectedEventsForDisplay; + let lastAuthErrorIndex = -1; + for (let index = events.length - 1; index >= 0; index -= 1) { + const evt = events[index]?.event; + if ( + evt?.type === "error" + && typeof evt.errorInfo === "object" + && evt.errorInfo?.agentCli?.category === "unauthenticated" + ) { + lastAuthErrorIndex = index; + break; + } + } + if (lastAuthErrorIndex === -1) return; + let recovered = false; + for (let index = lastAuthErrorIndex + 1; index < events.length; index += 1) { + const evt = events[index]?.event; + if (!evt) continue; + if (evt.type === "done" && evt.status === "completed") { recovered = true; break; } + if (evt.type === "text" && typeof evt.text === "string" && evt.text.trim().length > 0) { recovered = true; break; } + if (evt.type === "tool_call") { recovered = true; break; } + } + if (!recovered) return; + const key = `${sessionId}:${lastAuthErrorIndex}`; + if (dispatchedAuthRecoveryRef.current.has(key)) return; + const recoveryDispatchTimer = window.setTimeout(() => { + dispatchedAuthRecoveryRef.current.add(key); + window.dispatchEvent(new CustomEvent(CHAT_AUTH_RECOVERED_EVENT, { detail: { sessionId } })); + }, 0); + return () => window.clearTimeout(recoveryDispatchTimer); + }, [selectedEventsForDisplay, selectedSessionId]); + const removeAttachment = useCallback((attachmentPath: string) => { linkedIosAttachmentPathsRef.current.delete(attachmentPath); linkedAppControlAttachmentPathsRef.current.delete(attachmentPath); @@ -9566,6 +9653,23 @@ export function AgentChatPane({ const isOrchestratorLead = selectedSession?.interactionMode === "orchestrator-lead"; const isOrchestratorDraft = forceDraft && orchestratorEnabled && selectedSessionId == null; + // While Claude is logged out, keep a re-login affordance pinned just above the + // composer so it stays reachable even when the inline transcript card has + // scrolled out of view. Reuses the self-contained login pill (styled + own + // dismiss); it hides itself once the session reconnects. Only shown when the + // chat header login pill is absent so the two never double up. + const authStickyBar = showClaudeLoginPrompt && selectedSessionId && !chatTerminalVisible ? ( +
+ +
+ ) : null; + const composerElement = ( ); })} + {authStickyBar} {composerElement}
); @@ -10434,6 +10539,7 @@ export function AgentChatPane({ ) : null} {appPanelOpen ? (
+ {authStickyBar} {composerElement}
) : null} diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx index 6b8d0b028..9cc54c6eb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx @@ -3,7 +3,12 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { AgentCliAuthCard, type AgentCliAuthCardInfo } from "./AgentCliAuthCard"; +import { + AgentCliAuthCard, + CHAT_AUTH_RECOVERED_EVENT, + CHAT_RETRY_AUTH_TURN_EVENT, + type AgentCliAuthCardInfo, +} from "./AgentCliAuthCard"; const originalAde = globalThis.window.ade; @@ -20,6 +25,14 @@ const unauthenticatedCli: AgentCliAuthCardInfo = { category: "unauthenticated", }; +const unauthenticatedClaude: AgentCliAuthCardInfo = { + agent: "claude", + displayName: "Claude Code", + category: "unauthenticated", + installCommand: "npm install -g @anthropic-ai/claude-code", + authCommand: "claude auth login", +}; + function installAdeStub() { globalThis.window.ade = { pty: { @@ -120,6 +133,50 @@ describe("AgentCliAuthCard", () => { expect(screen.getByText(/Install the CLI on Mac Studio/i)).toBeTruthy(); }); + it("labels the Claude login action and surfaces a Retry that resends the turn", () => { + const retrySpy = vi.fn(); + window.addEventListener(CHAT_RETRY_AUTH_TURN_EVENT, retrySpy); + try { + render(); + + expect(screen.getByText("Claude Code is logged out")).toBeTruthy(); + expect(screen.getByRole("button", { name: /log in to claude/i })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /retry turn/i })); + + expect(retrySpy).toHaveBeenCalledTimes(1); + const event = retrySpy.mock.calls[0][0] as CustomEvent<{ sessionId?: string }>; + expect(event.detail.sessionId).toBe("chat-7"); + } finally { + window.removeEventListener(CHAT_RETRY_AUTH_TURN_EVENT, retrySpy); + } + }); + + it("does not offer Retry without a session to resend into", () => { + render(); + expect(screen.queryByRole("button", { name: /retry turn/i })).toBeNull(); + }); + + it("collapses to a reconnected confirmation when the session recovers", () => { + render(); + + expect(screen.getByText("Claude Code is logged out")).toBeTruthy(); + + fireEvent(window, new CustomEvent(CHAT_AUTH_RECOVERED_EVENT, { detail: { sessionId: "chat-7" } })); + + expect(screen.queryByText("Claude Code is logged out")).toBeNull(); + expect(screen.getByText(/Reconnected to Claude Code/i)).toBeTruthy(); + }); + + it("ignores reconnected events for a different session", () => { + render(); + + fireEvent(window, new CustomEvent(CHAT_AUTH_RECOVERED_EVENT, { detail: { sessionId: "other-session" } })); + + expect(screen.getByText("Claude Code is logged out")).toBeTruthy(); + expect(screen.queryByText(/Reconnected to Claude Code/i)).toBeNull(); + }); + it("uses the project default lane when no chat context is available", async () => { render(); diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx index fd2b84ed8..3c68518ea 100644 --- a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx @@ -1,5 +1,5 @@ -import { useCallback, useState } from "react"; -import { CopySimple, Play, Terminal, Warning } from "@phosphor-icons/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ArrowClockwise, CheckCircle, CopySimple, Play, Terminal, Warning } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; export type AgentCliAuthCardInfo = { @@ -10,6 +10,49 @@ export type AgentCliAuthCardInfo = { authCommand: string; }; +/** + * Dispatched when the user clicks "Retry turn" on a logged-out card — the chat + * pane that owns the session listens for it and resends the last user message. + */ +export const CHAT_RETRY_AUTH_TURN_EVENT = "ade:chat:retry-auth-turn"; +/** + * Dispatched by the chat pane when a turn succeeds again — visible logged-out + * cards for that session collapse into a quiet "Reconnected" confirmation. + */ +export const CHAT_AUTH_RECOVERED_EVENT = "ade:chat:auth-recovered"; + +// Claude logs out far more often than the other CLIs, so its recovery card wears +// Claude's terracotta rather than the generic amber — it reads as "Claude", not a +// random error. Other agents keep the amber treatment. +type AccentTokens = { + cardBorder: string; + cardBg: string; + iconChip: string; + title: string; + label: string; + runButton: string; +}; + +const AMBER_ACCENT: AccentTokens = { + cardBorder: "border-amber-300/14", + cardBg: "bg-amber-300/[0.045]", + iconChip: "border-amber-300/15 bg-amber-300/[0.08] text-amber-200", + title: "text-amber-100/90", + label: "text-muted-fg/45", + runButton: + "border-amber-300/20 bg-amber-300/[0.08] text-amber-100/82 hover:border-amber-300/35 hover:bg-amber-300/[0.12]", +}; + +const CLAUDE_ACCENT: AccentTokens = { + cardBorder: "border-[#d97757]/16", + cardBg: "bg-[#d97757]/[0.06]", + iconChip: "border-[#d97757]/22 bg-[#d97757]/[0.12] text-[#f3b79b]", + title: "text-[#f5cbb6]", + label: "text-[#d97757]/65", + runButton: + "border-[#d97757]/28 bg-[#d97757]/[0.12] text-[#ffd9c6] hover:border-[#d97757]/45 hover:bg-[#d97757]/[0.18]", +}; + function CommandCopyButton({ command, label }: { command: string; label: string }) { const [copied, setCopied] = useState(false); @@ -41,13 +84,17 @@ function ShellRunButton({ label, laneId, chatSessionId, + accent, onRevealTerminal, + onLaunched, }: { command: string; label: string; laneId?: string | null; chatSessionId?: string | null; + accent: AccentTokens; onRevealTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; + onLaunched?: () => void; }) { const [running, setRunning] = useState(false); const [error, setError] = useState(null); @@ -57,7 +104,7 @@ function ShellRunButton({ if (disabled) return; setRunning(true); setError(null); - const terminalLabel = label.replace(/^Run\s+/i, ""); + const terminalLabel = label.replace(/^Run\s+/i, "").replace(/^Log in to\s+/i, "") || label; void (async () => { const resolvedLaneId = laneId ?? (await window.ade.lanes.list({ includeArchived: false, @@ -83,12 +130,13 @@ function ShellRunButton({ ptyId: created.ptyId, label: terminalLabel, }); + onLaunched?.(); }) .catch((err: unknown) => { setError(err instanceof Error ? err.message : String(err)); }) .finally(() => setRunning(false)); - }, [chatSessionId, command, disabled, label, laneId, onRevealTerminal]); + }, [chatSessionId, command, disabled, label, laneId, onLaunched, onRevealTerminal]); return (
@@ -96,7 +144,10 @@ function ShellRunButton({ type="button" onClick={handleRun} disabled={disabled} - className="inline-flex items-center gap-1.5 rounded-md border border-amber-300/20 bg-amber-300/[0.08] px-2 py-1 font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-bold uppercase tracking-[0.14em] text-amber-100/82 transition-colors hover:border-amber-300/35 hover:bg-amber-300/[0.12] disabled:pointer-events-none disabled:opacity-45" + className={cn( + "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-bold uppercase tracking-[0.14em] transition-colors disabled:pointer-events-none disabled:opacity-45", + accent.runButton, + )} title={!laneId && !window.ade?.lanes?.list ? "Open a project to run this command" : label} > @@ -125,22 +176,84 @@ export function AgentCliAuthCard({ onRevealTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; }) { const missing = agentCli.category === "missing"; + const accent = agentCli.agent === "claude" ? CLAUDE_ACCENT : AMBER_ACCENT; + // Retry only makes sense for the logged-out variant (not the "missing CLI" + // variant) and only when there's a session to resend the last message into. + const canRetry = !missing && Boolean(chatSessionId); + + const [loginStarted, setLoginStarted] = useState(false); + const [retrying, setRetrying] = useState(false); + const [resolved, setResolved] = useState(false); + const retryResetTimerRef = useRef(null); + + useEffect(() => () => { + if (retryResetTimerRef.current != null) { + window.clearTimeout(retryResetTimerRef.current); + } + }, []); + + // A later turn for this session succeeded — collapse to a quiet confirmation. + useEffect(() => { + if (!chatSessionId) return undefined; + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ sessionId?: string | null }>).detail; + if (detail?.sessionId && detail.sessionId === chatSessionId) { + if (retryResetTimerRef.current != null) { + window.clearTimeout(retryResetTimerRef.current); + retryResetTimerRef.current = null; + } + setRetrying(false); + setResolved(true); + } + }; + window.addEventListener(CHAT_AUTH_RECOVERED_EVENT, handler); + return () => window.removeEventListener(CHAT_AUTH_RECOVERED_EVENT, handler); + }, [chatSessionId]); + + const handleRetry = useCallback(() => { + if (!chatSessionId || retrying) return; + setRetrying(true); + window.dispatchEvent(new CustomEvent(CHAT_RETRY_AUTH_TURN_EVENT, { + detail: { sessionId: chatSessionId }, + })); + // The card unmounts/collapses once the resend produces fresh events; clear the + // spinner defensively in case the resend is rejected (still logged out). + if (retryResetTimerRef.current != null) { + window.clearTimeout(retryResetTimerRef.current); + } + retryResetTimerRef.current = window.setTimeout(() => { + retryResetTimerRef.current = null; + setRetrying(false); + }, 4_000); + }, [chatSessionId, retrying]); + + // Once a subsequent turn succeeds, the logged-out state is history — collapse + // the whole card into a quiet confirmation instead of leaving a scary panel. + if (resolved) { + return ( +
+ + Reconnected to {agentCli.displayName} +
+ ); + } + const installLocation = runtimeName?.trim() ? runtimeName.trim() : "this machine"; const title = missing ? `${agentCli.displayName} is not installed` - : `${agentCli.displayName} needs authentication`; + : `${agentCli.displayName} is logged out`; const body = missing ? `Install the CLI on ${installLocation}, authenticate it, then retry the chat.` - : `Authenticate the CLI on ${installLocation}, then retry the chat.`; + : `Log back in on ${installLocation} to continue this chat.`; return ( -
+
-
+
{missing ? : }
-
+
{title}
@@ -149,7 +262,7 @@ export function AgentCliAuthCard({
{missing ? (
-
+
Install
@@ -161,6 +274,7 @@ export function AgentCliAuthCard({ label="Run install" laneId={laneId} chatSessionId={chatSessionId} + accent={accent} onRevealTerminal={onRevealTerminal} /> @@ -168,7 +282,7 @@ export function AgentCliAuthCard({
) : null}
-
+
Authenticate
@@ -177,14 +291,40 @@ export function AgentCliAuthCard({ setLoginStarted(true)} />
+ {canRetry ? ( +
+ {loginStarted ? ( + + Logged in? Resend your message. + + ) : null} + +
+ ) : null}
diff --git a/apps/desktop/src/renderer/lib/claudeAuthPrompt.test.ts b/apps/desktop/src/renderer/lib/claudeAuthPrompt.test.ts index b06dd9a1d..ab0b42d8b 100644 --- a/apps/desktop/src/renderer/lib/claudeAuthPrompt.test.ts +++ b/apps/desktop/src/renderer/lib/claudeAuthPrompt.test.ts @@ -50,6 +50,16 @@ describe("claude auth prompt helpers", () => { expect(textHasClaudeAuthError("Please run /login · API Error: 401 Invalid authentication credentials")).toBe(true); }); + it("detects ADE's own classified auth message despite reversed word order", () => { + // "Authentication failed" precedes "Claude" here, so the claude-first + // patterns miss it — the dedicated "failed for" pattern catches it. + expect(textHasClaudeAuthError("Authentication failed for Claude Sonnet 4.6. Check your API key in Settings.")).toBe(true); + }); + + it("does not treat other service auth failures as Claude login failures", () => { + expect(textHasClaudeAuthError("Authentication failed for GitHub. Check your token in Settings.")).toBe(false); + }); + it("shows for the latest Claude chat auth error", () => { expect(shouldShowClaudeChatLoginPrompt({ provider: "claude", diff --git a/apps/desktop/src/renderer/lib/claudeAuthPrompt.ts b/apps/desktop/src/renderer/lib/claudeAuthPrompt.ts index db49bb92f..15f4a046e 100644 --- a/apps/desktop/src/renderer/lib/claudeAuthPrompt.ts +++ b/apps/desktop/src/renderer/lib/claudeAuthPrompt.ts @@ -12,6 +12,11 @@ const CLAUDE_AUTH_ERROR_PATTERNS: RegExp[] = [ /\bplease\s+run\s+\/login\b/i, ...CLAUDE_INVALID_CREDENTIALS_PATTERNS, /\bclaude\b.*\bauthentication[_\s-]*failed\b/i, + // ADE's own classified message reads "Authentication failed for . Check + // your API key in Settings." — "claude" follows the keyword there, so the + // claude-first patterns above miss it. Keep the match Claude-specific so tool + // auth failures in a Claude chat do not trigger the Claude login CTA. + /\bauthentication\s+failed\s+for\b.*\bclaude\b/i, /\bclaude\b.*\bfailed\s+to\s+authenticate\b/i, /\bclaude\b.*\b(not\s+logged\s+in|not\s+authenticated|unauthorized|authentication\s+failed|login\s+required)\b/i, /\brun\s+[`'"]?claude\s+auth\s+login[`'"]?/i, diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 3cb55d981..c630e67f2 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -51,14 +51,16 @@ machinery layered on top. | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, `isDraftLaunchJobStale`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. Also owns the launch durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout(promise, label)` (rejects a launch step whose runtime call never settles; the underlying IPC is not cancellable, so on timeout it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Shared renderer helper for in-flight chat handoff placeholders. Defines the handoff job DTO, scope keying, status labels (`preparing-summary` -> `creating-chat` -> `sending-handoff`), search matching, and the stable placeholder id used by the Work session sidebar. | | `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled` and `launchPromptClipboardNoticeEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` (+ `setDraftLaunchJobs`) for Work draft launch status strips plus `handoffLaunchJobsByScope` (+ `setHandoffLaunchJobs`) for Work sidebar handoff placeholders. These live in the **root** store (not the per-project store) on purpose: in-flight launches must survive a remote project switch that destroys the originating per-project store; `AgentChatPane` reads them via `useRootAppStore` / `rootAppStoreApi.getState()`. | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens or toggles that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). When the chat PR pane or compact PR menu opens, it asks `prReadCache.refreshLinkedPrCoalesced` for a targeted `prs.refresh({ prIds })` so the badge picks up merged/closed/check transitions without broad GitHub polling. | | `apps/desktop/src/renderer/components/chat/ChatPrPane.tsx` | Left floating PR pane for Work chat. Renders cached lane PR details immediately, then performs the same cooldown-bound targeted PR refresh as the toolbar before settling the state. Terminal PRs hide stale running-check labels so merged/closed PRs do not keep showing in-progress CI from an old cache row. | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, and attachment context into prompt text. | | `apps/desktop/src/renderer/components/chat/RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Claude file-rewind confirmation surface. `rewindFilesPreview.ts` maps the selected user message to turn diff summaries and per-file SHA ranges; the dialog lists every restored file, expands rows into `AdeDiffViewer`, and confirms the SDK `rewindFiles` call without using browser-native confirm UI. | | `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx`, `codex/CodexGoalCard.tsx` | Subagent drawer content plus the Codex chat goal card. The goal card sits above the plan/subagent roster, exposes edit/clear affordances through typed ADE goal APIs, and shows usage context as tokens/time only; provider token budgets are hidden and cleared so ADE goals stay unlimited. | | `apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx` | Renderer panel for the in-app browser. Renders the address bar, tabs strip, navigation controls, an inspect/select toolbar, and a `BuiltInBrowserStatus`-derived empty/error state, then asks the main process to position the underlying `WebContentsView` over the panel's bounding rect through `ade.builtInBrowser.setBounds`. Because native `WebContentsView` content sits above the renderer, the panel hides it while ADE overlays, dialogs, menus, or popovers overlap the browser surface so ADE chrome remains reachable. Mounted by `WorkSidebar` under the `browser` tab and (indirectly) by any renderer code that calls `openUrlInAdeBrowser()` — the helper opens the sidebar Browser tab and dispatches the URL into a fresh tab. Selections committed through inspect-mode hit-testing fan out via the `onAddContext` callback as `BuiltInBrowserContextItem` payloads. | -| `apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx`, `ClaudeLoginPromptButton.tsx` | Shared Work surface header chrome for chat and CLI surfaces: title, lane chip, Claude cache badge, git toolbar, caller-provided trailing actions, and the dismissible Claude login CTA that starts `claude auth login` in a tracked PTY. | +| `apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx`, `ClaudeLoginPromptButton.tsx` | Shared Work surface header chrome for chat and CLI surfaces: title, lane chip, Claude cache badge, git toolbar, caller-provided trailing actions, and the dismissible Claude login CTA that starts `claude auth login` in a tracked PTY. `AgentChatPane` also reuses `ClaudeLoginPromptButton` as a sticky bar above the composer (keyed `composer-auth:`) while a Claude session is logged out, but only when the chat header pill is absent so the two never double up. | +| `apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx` | Inline install / re-login card for missing or unauthenticated agent CLIs, rendered in the transcript from a decorated `error` event's `errorInfo.agentCli` payload. Copy chips + a tracked-PTY Run button (`window.ade.pty.create`) for the install / auth command. The logged-out (`category: "unauthenticated"`) variant is terracotta-toned for Claude (amber for other agents), retitles to "<Provider> is logged out", and adds an always-on **Retry turn** button that resends the last user message via the `CHAT_RETRY_AUTH_TURN_EVENT` (`ade:chat:retry-auth-turn`) window event; it collapses to a "Reconnected" confirmation when `AgentChatPane` fires `CHAT_AUTH_RECOVERED_EVENT` (`ade:chat:auth-recovered`) after a later turn succeeds. The "missing CLI" variant keeps the red-free amber install card. | +| `apps/desktop/src/renderer/lib/claudeAuthPrompt.ts` | Renderer-side classifier for Claude logged-out / `/login`-required error text. Drives the header and sticky login CTAs; matches both Claude-first wording and ADE's own "Authentication failed for <model>" classified message. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | | `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images read bytes through `ade.app.readClipboardImage` then write them through `ade.agentChat.saveTempAttachment` so remote-bound chats receive a runtime-readable attachment path. The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. | | `apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx` | Pending-input card used when ADE asks the user to choose a model for a new or rerouted agent. It renders the agent briefing, touched files, run-after dependencies, provider/model controls, cancel/confirm states, and leaves the model unset until the user chooses one. | @@ -188,6 +190,36 @@ render them, but neither one *runs* them. chat terminal drawer and runs `claude auth login` in the same lane/chat context. See [Agents](../agents/README.md#agent-cli-install--auth-from-chat). +- **Claude logged-out (401) fast-fail and recovery.** A 401 is not a + transient error, so the Claude adapter does **not** let the SDK grind + through its retry budget ("retry 1/10 … 10/10"). On the first + definitive auth signal — an `auth_status` error, an `assistant` + message with `error: "authentication_failed"`, an `api_retry` whose + `error_status` is 401/403 (or whose error reads as auth), or a + `result` whose errors look like invalid credentials + (`isClaudeRuntimeAuthError`) — `failClaudeTurnUnauthenticated()` + emits one terse `system_notice` (`noticeKind: "auth"`, "Claude is + logged out — stopped retrying.") and throws `CLAUDE_RUNTIME_AUTH_ERROR`. + The catch path recognises it, closes the query (halting further + retries), reports the runtime auth failure, and emits a decorated + `error` event carrying `errorInfo.agentCli` (category + `"unauthenticated"`). Rate-limit / overloaded retries still proceed + normally. `AgentChatMessageList` renders that decorated error as the + calm `AgentCliAuthCard` (terracotta-toned for Claude) instead of the + red error block, tucking the raw 401 text behind a `Details` + disclosure. The card is always-on recoverable: a **Retry turn** button + resends the last user message (via the `ade:chat:retry-auth-turn` + window event that `AgentChatPane` listens for and dispatches into + `ade.agentChat.send`); if Claude is still logged out the new turn + fast-fails again and a fresh card appears. When a later turn succeeds, + `AgentChatPane` dispatches `ade:chat:auth-recovered` and the card + collapses into a quiet "Reconnected" confirmation. While the session + stays logged out, the pane also pins a sticky `ClaudeLoginPromptButton` + bar just above the composer when the chat header login pill is absent, + so the re-login affordance + stays reachable after the inline card scrolls away. The renderer-side + classifier `claudeAuthPrompt.ts` matches the logged-out wording + (including ADE's own "Authentication failed for <model>" message). - **Work draft launches.** From an empty embedded Work composer, the user can auto-create a lane for a single foreground/background chat or CLI session, or enable parallel mode, select two or more From 62dcb5422dc4d1d9c5ff0332860a501daacc8899 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:54:04 -0400 Subject: [PATCH 2/7] Address Claude auth retry review --- .../components/chat/AgentChatMessageList.tsx | 36 +++--- .../components/chat/AgentChatPane.test.tsx | 104 +++++++++++++++++- .../components/chat/AgentChatPane.tsx | 7 +- 3 files changed, 128 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 71372cf37..47443ee34 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -3167,20 +3167,30 @@ function renderEvent( const errorCopyValue = event.detail?.trim().length ? `${event.message}\n\n${event.detail}` : event.message; + const renderAgentCliAuthCard = () => agentCliInfo ? ( + + ) : null; // A logged-out runtime is recoverable, not a crash — lead with the calm // re-login card and tuck the raw 401 behind a Details disclosure instead of // the loud red error chrome. (The "missing CLI" card keeps the red frame.) if (agentCliInfo?.category === "unauthenticated") { return ( -
+
- + {renderAgentCliAuthCard()}
Details @@ -3220,15 +3230,7 @@ function renderEvent( {event.detail}
) : null} - {agentCliInfo ? ( - - ) : null} + {renderAgentCliAuthCard()} {event.errorInfo && !agentCliInfo ? (
{typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 43bde7934..1f53d4015 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -43,7 +43,7 @@ import { shouldPromoteSessionForComputerUse, type AgentChatSessionCreatedOptions, } from "./AgentChatPane"; -import { CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; +import { CHAT_AUTH_RECOVERED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; vi.mock("../terminals/TerminalView", () => { const ReactMod = require("react") as typeof React; @@ -1450,6 +1450,108 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("steers the auth retry when a turn becomes active before resend", async () => { + const session = buildSession("session-1", { + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + status: "idle", + }); + const transcript = `${JSON.stringify({ + sessionId: session.sessionId, + timestamp: "2026-03-24T05:57:47.700Z", + event: { + type: "user_message", + text: "Retry into active turn", + turnId: "turn-2", + }, + })}\n`; + const { send, steer } = installAdeMocks({ + sessions: [session], + transcript, + includeClaudeModel: true, + }); + send.mockRejectedValueOnce(new Error("turn is already active")); + seedDrawerStore(); + + renderPane(session); + + expect(await screen.findByText("Retry into active turn")).toBeTruthy(); + + fireEvent(window, new CustomEvent(CHAT_RETRY_AUTH_TURN_EVENT, { + detail: { sessionId: session.sessionId }, + })); + + await waitFor(() => { + expect(steer).toHaveBeenCalledWith({ + sessionId: session.sessionId, + text: "Retry into active turn", + }); + }); + }); + + it("dispatches auth recovery once after a later successful turn", async () => { + const session = buildSession("session-1", { + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + status: "idle", + }); + const transcript = [ + { + sessionId: session.sessionId, + timestamp: "2026-03-24T05:57:45.700Z", + event: { + type: "error", + message: "Authentication failed for Claude Sonnet 4.6.", + turnId: "turn-1", + errorInfo: { + category: "agent_cli_auth", + agentCli: { + agent: "claude", + displayName: "Claude Code", + category: "unauthenticated", + installCommand: "npm install -g @anthropic-ai/claude-code", + authCommand: "claude auth login", + }, + }, + }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-03-24T05:57:46.700Z", + event: { + type: "done", + status: "completed", + turnId: "turn-2", + }, + }, + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"; + const recoverySpy = vi.fn(); + window.addEventListener(CHAT_AUTH_RECOVERED_EVENT, recoverySpy); + try { + installAdeMocks({ + sessions: [session], + transcript, + includeClaudeModel: true, + }); + seedDrawerStore(); + + renderPane(session); + + await waitFor(() => { + expect(recoverySpy).toHaveBeenCalledTimes(1); + }); + const event = recoverySpy.mock.calls[0][0] as CustomEvent<{ sessionId?: string }>; + expect(event.detail.sessionId).toBe(session.sessionId); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(recoverySpy).toHaveBeenCalledTimes(1); + } finally { + window.removeEventListener(CHAT_AUTH_RECOVERED_EVENT, recoverySpy); + } + }); + it("uses the model override as the constrained draft picker list", async () => { installAdeMocks({ sessions: [] }); seedRuntimeModelCatalog(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 682dee969..0c462c8d0 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -6300,7 +6300,12 @@ export function AgentChatPane({ setBusy(true); setError(null); touchSession(sessionId); - await window.ade.agentChat.send({ sessionId, text, displayText: text }); + try { + await window.ade.agentChat.send({ sessionId, text, displayText: text }); + } catch (sendError) { + if (!isTurnAlreadyActiveError(sendError)) throw sendError; + await window.ade.agentChat.steer({ sessionId, text }); + } void refreshSessions().catch(() => {}); } catch (err) { setError(err instanceof Error ? err.message : String(err)); From 2b5ffa6a83eb1609b4a8aaa9f02d396c9c2bdc34 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:57:46 -0400 Subject: [PATCH 3/7] Harden Claude auth retry recovery --- .../main/services/chat/agentChatService.ts | 7 ++-- .../components/chat/AgentChatPane.tsx | 25 +++++++++++--- .../components/chat/AgentCliAuthCard.test.tsx | 12 +++++++ .../components/chat/AgentCliAuthCard.tsx | 33 +++++++++++++++---- docs/features/chat/README.md | 2 +- 5 files changed, 64 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f728ab8a3..2af2f0519 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -12406,12 +12406,13 @@ export function createAgentChatService(args: { if (msg.type === "system" && (msg as any).subtype === "api_retry") { const retryMsg = msg as any; const error = typeof retryMsg.error === "string" ? retryMsg.error : "transient_error"; - // A 401/403 (logged-out) retry will never recover on its own. Stop the - // retry storm on the first auth attempt instead of surfacing + // A logged-out/auth retry will never recover on its own. Stop the retry + // storm on the first auth attempt instead of surfacing // "retry 1/10 … 10/10" — rate-limit/overloaded retries still proceed. + // Do not treat every bare 403 as logout: Anthropic can use 403 for + // org/model access restrictions, which should not render a login card. if ( retryMsg.error_status === 401 - || retryMsg.error_status === 403 || error === "authentication_failed" || isClaudeRuntimeAuthError(error) ) { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 0c462c8d0..4769caf4a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -128,7 +128,7 @@ import { CodexPlanCard } from "./codex/CodexPlanCard"; import { ChatPrPane } from "./ChatPrPane"; import { useChatPrAutoPop } from "./useChatPrAutoPop"; import { ClaudeLoginPromptButton } from "../work/ClaudeLoginPromptButton"; -import { CHAT_AUTH_RECOVERED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; +import { CHAT_AUTH_RECOVERED_EVENT, CHAT_AUTH_RETRY_REJECTED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; import { rootAppStoreApi, selectActiveProjectRoot, useAppStore, useRootAppStore } from "../../state/appStore"; import { setLaneNaming } from "../../state/laneNamingStore"; import { buildChatAppearanceRootStyle } from "./chatAppearance"; @@ -6283,8 +6283,15 @@ export function AgentChatPane({ // Claude logout — fired by the inline re-login card's "Retry turn" button. The // re-check is implicit: if Claude is still logged out the new turn fast-fails // again and a fresh card appears. + const rejectAuthRetry = useCallback((sessionId: string) => { + window.dispatchEvent(new CustomEvent(CHAT_AUTH_RETRY_REJECTED_EVENT, { detail: { sessionId } })); + }, []); + const resendLastUserMessageForAuthRetry = useCallback(async (sessionId: string) => { - if (submitInFlightRef.current) return; + if (submitInFlightRef.current) { + rejectAuthRetry(sessionId); + return; + } const events = selectedEventsForDisplayRef.current; let text: string | null = null; for (let index = events.length - 1; index >= 0; index -= 1) { @@ -6294,7 +6301,10 @@ export function AgentChatPane({ break; } } - if (!text) return; + if (!text) { + rejectAuthRetry(sessionId); + return; + } try { submitInFlightRef.current = true; setBusy(true); @@ -6313,7 +6323,7 @@ export function AgentChatPane({ submitInFlightRef.current = false; setBusy(false); } - }, [refreshSessions, touchSession]); + }, [refreshSessions, rejectAuthRetry, touchSession]); // The inline re-login card dispatches CHAT_RETRY_AUTH_TURN_EVENT on "Retry // turn"; only the pane that owns the session resends. @@ -6335,7 +6345,9 @@ export function AgentChatPane({ if (!sessionId) return; const events = selectedEventsForDisplay; let lastAuthErrorIndex = -1; + let lastAuthErrorKey: string | null = null; for (let index = events.length - 1; index >= 0; index -= 1) { + const envelope = events[index]; const evt = events[index]?.event; if ( evt?.type === "error" @@ -6343,6 +6355,9 @@ export function AgentChatPane({ && evt.errorInfo?.agentCli?.category === "unauthenticated" ) { lastAuthErrorIndex = index; + const turnId = typeof evt.turnId === "string" && evt.turnId.trim().length ? evt.turnId.trim() : null; + const itemId = typeof evt.itemId === "string" && evt.itemId.trim().length ? evt.itemId.trim() : null; + lastAuthErrorKey = `${sessionId}:${turnId ?? itemId ?? envelope?.timestamp ?? evt.message}`; break; } } @@ -6356,7 +6371,7 @@ export function AgentChatPane({ if (evt.type === "tool_call") { recovered = true; break; } } if (!recovered) return; - const key = `${sessionId}:${lastAuthErrorIndex}`; + const key = lastAuthErrorKey ?? `${sessionId}:auth-error`; if (dispatchedAuthRecoveryRef.current.has(key)) return; const recoveryDispatchTimer = window.setTimeout(() => { dispatchedAuthRecoveryRef.current.add(key); diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx index 9cc54c6eb..61072728a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { AgentCliAuthCard, + CHAT_AUTH_RETRY_REJECTED_EVENT, CHAT_AUTH_RECOVERED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT, type AgentCliAuthCardInfo, @@ -157,6 +158,17 @@ describe("AgentCliAuthCard", () => { expect(screen.queryByRole("button", { name: /retry turn/i })).toBeNull(); }); + it("clears the retry spinner when the pane rejects the retry", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /retry turn/i })); + expect(screen.getByRole("button", { name: /retrying/i })).toBeTruthy(); + + fireEvent(window, new CustomEvent(CHAT_AUTH_RETRY_REJECTED_EVENT, { detail: { sessionId: "chat-7" } })); + + expect(screen.getByRole("button", { name: /retry turn/i })).toBeTruthy(); + }); + it("collapses to a reconnected confirmation when the session recovers", () => { render(); diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx index 3c68518ea..8bef4cfc7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx @@ -20,6 +20,11 @@ export const CHAT_RETRY_AUTH_TURN_EVENT = "ade:chat:retry-auth-turn"; * cards for that session collapse into a quiet "Reconnected" confirmation. */ export const CHAT_AUTH_RECOVERED_EVENT = "ade:chat:auth-recovered"; +/** + * Dispatched by the chat pane when a retry click is ignored locally (for example + * another send is already in flight or there is no user message to resend). + */ +export const CHAT_AUTH_RETRY_REJECTED_EVENT = "ade:chat:retry-auth-rejected"; // Claude logs out far more often than the other CLIs, so its recovery card wears // Claude's terracotta rather than the generic amber — it reads as "Claude", not a @@ -192,23 +197,39 @@ export function AgentCliAuthCard({ } }, []); + const clearRetrying = useCallback(() => { + if (retryResetTimerRef.current != null) { + window.clearTimeout(retryResetTimerRef.current); + retryResetTimerRef.current = null; + } + setRetrying(false); + }, []); + // A later turn for this session succeeded — collapse to a quiet confirmation. useEffect(() => { if (!chatSessionId) return undefined; const handler = (event: Event) => { const detail = (event as CustomEvent<{ sessionId?: string | null }>).detail; if (detail?.sessionId && detail.sessionId === chatSessionId) { - if (retryResetTimerRef.current != null) { - window.clearTimeout(retryResetTimerRef.current); - retryResetTimerRef.current = null; - } - setRetrying(false); + clearRetrying(); setResolved(true); } }; window.addEventListener(CHAT_AUTH_RECOVERED_EVENT, handler); return () => window.removeEventListener(CHAT_AUTH_RECOVERED_EVENT, handler); - }, [chatSessionId]); + }, [chatSessionId, clearRetrying]); + + useEffect(() => { + if (!chatSessionId) return undefined; + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ sessionId?: string | null }>).detail; + if (detail?.sessionId && detail.sessionId === chatSessionId) { + clearRetrying(); + } + }; + window.addEventListener(CHAT_AUTH_RETRY_REJECTED_EVENT, handler); + return () => window.removeEventListener(CHAT_AUTH_RETRY_REJECTED_EVENT, handler); + }, [chatSessionId, clearRetrying]); const handleRetry = useCallback(() => { if (!chatSessionId || retrying) return; diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index c630e67f2..32a8a953c 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -195,7 +195,7 @@ render them, but neither one *runs* them. through its retry budget ("retry 1/10 … 10/10"). On the first definitive auth signal — an `auth_status` error, an `assistant` message with `error: "authentication_failed"`, an `api_retry` whose - `error_status` is 401/403 (or whose error reads as auth), or a + `error_status` is 401 (or whose error reads as auth), or a `result` whose errors look like invalid credentials (`isClaudeRuntimeAuthError`) — `failClaudeTurnUnauthenticated()` emits one terse `system_notice` (`noticeKind: "auth"`, "Claude is From 2fefa6f387d55b8629f7f69712701efcee1abc57 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:16:00 -0400 Subject: [PATCH 4/7] Preserve auth retry context --- .../components/chat/AgentChatPane.test.tsx | 58 ++++++++++++++++++- .../components/chat/AgentChatPane.tsx | 20 +++++-- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 1f53d4015..94edc5917 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -1421,7 +1421,21 @@ describe("AgentChatPane submit recovery", () => { timestamp: "2026-03-24T05:57:47.700Z", event: { type: "user_message", - text: "Retry this exact prompt", + text: "Retry this exact prompt\n\nUse docs/auth.md and the selected plan note.", + displayText: "Retry this exact prompt", + attachments: [{ path: "docs/auth.md", type: "file" }], + contextAttachments: [{ + type: "orchestration_annotation", + item: { + type: "orchestration_annotation", + runId: "run-auth", + anchor: { kind: "plan_step", id: "step-auth", preview: "login recovery" }, + selectionExcerpt: "login recovery", + comment: "Use this selected plan note.", + capturedAt: "2026-03-24T05:57:47.500Z", + }, + }], + metadata: { source: "auth-retry-test" }, turnId: "turn-2", }, }, @@ -1444,8 +1458,21 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(send).toHaveBeenCalledWith({ sessionId: session.sessionId, - text: "Retry this exact prompt", + text: "Retry this exact prompt\n\nUse docs/auth.md and the selected plan note.", displayText: "Retry this exact prompt", + attachments: [{ path: "docs/auth.md", type: "file" }], + contextAttachments: [{ + type: "orchestration_annotation", + item: { + type: "orchestration_annotation", + runId: "run-auth", + anchor: { kind: "plan_step", id: "step-auth", preview: "login recovery" }, + selectionExcerpt: "login recovery", + comment: "Use this selected plan note.", + capturedAt: "2026-03-24T05:57:47.500Z", + }, + }], + metadata: { source: "auth-retry-test" }, }); }); }); @@ -1463,6 +1490,20 @@ describe("AgentChatPane submit recovery", () => { event: { type: "user_message", text: "Retry into active turn", + displayText: "Retry into active turn", + attachments: [{ path: "docs/race.md", type: "file" }], + contextAttachments: [{ + type: "orchestration_annotation", + item: { + type: "orchestration_annotation", + runId: "run-race", + anchor: { kind: "plan_step", id: "step-race", preview: "active turn fallback" }, + selectionExcerpt: "active turn fallback", + comment: "Retry with this fallback context.", + capturedAt: "2026-03-24T05:57:47.500Z", + }, + }], + metadata: { source: "auth-retry-steer-test" }, turnId: "turn-2", }, })}\n`; @@ -1486,6 +1527,19 @@ describe("AgentChatPane submit recovery", () => { expect(steer).toHaveBeenCalledWith({ sessionId: session.sessionId, text: "Retry into active turn", + attachments: [{ path: "docs/race.md", type: "file" }], + contextAttachments: [{ + type: "orchestration_annotation", + item: { + type: "orchestration_annotation", + runId: "run-race", + anchor: { kind: "plan_step", id: "step-race", preview: "active turn fallback" }, + selectionExcerpt: "active turn fallback", + comment: "Retry with this fallback context.", + capturedAt: "2026-03-24T05:57:47.500Z", + }, + }], + metadata: { source: "auth-retry-steer-test" }, }); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 4769caf4a..007fe5701 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -6293,28 +6293,38 @@ export function AgentChatPane({ return; } const events = selectedEventsForDisplayRef.current; - let text: string | null = null; + let userEvent: Extract | null = null; for (let index = events.length - 1; index >= 0; index -= 1) { const evt = events[index]?.event; if (evt?.type === "user_message" && typeof evt.text === "string" && evt.text.trim().length > 0) { - text = evt.text; + userEvent = evt; break; } } - if (!text) { + if (!userEvent) { rejectAuthRetry(sessionId); return; } + const text = userEvent.text; + const displayText = typeof userEvent.displayText === "string" ? userEvent.displayText : text; + const attachments = Array.isArray(userEvent.attachments) ? userEvent.attachments : []; + const contextAttachments = Array.isArray(userEvent.contextAttachments) ? userEvent.contextAttachments : []; + const metadata = userEvent.metadata; + const replayContext = { + ...(attachments.length ? { attachments } : {}), + ...(contextAttachments.length ? { contextAttachments } : {}), + ...(metadata !== undefined ? { metadata } : {}), + }; try { submitInFlightRef.current = true; setBusy(true); setError(null); touchSession(sessionId); try { - await window.ade.agentChat.send({ sessionId, text, displayText: text }); + await window.ade.agentChat.send({ sessionId, text, displayText, ...replayContext }); } catch (sendError) { if (!isTurnAlreadyActiveError(sendError)) throw sendError; - await window.ade.agentChat.steer({ sessionId, text }); + await window.ade.agentChat.steer({ sessionId, text, ...replayContext }); } void refreshSessions().catch(() => {}); } catch (err) { From fb2425f42440e8f95f04aeae16291d740bf56ae9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:38:06 -0400 Subject: [PATCH 5/7] Reject active auth retries --- .../components/chat/AgentChatPane.test.tsx | 43 ++++++++----------- .../components/chat/AgentChatPane.tsx | 3 +- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 94edc5917..280b7aff5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -43,7 +43,7 @@ import { shouldPromoteSessionForComputerUse, type AgentChatSessionCreatedOptions, } from "./AgentChatPane"; -import { CHAT_AUTH_RECOVERED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; +import { CHAT_AUTH_RECOVERED_EVENT, CHAT_AUTH_RETRY_REJECTED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; vi.mock("../terminals/TerminalView", () => { const ReactMod = require("react") as typeof React; @@ -1477,7 +1477,7 @@ describe("AgentChatPane submit recovery", () => { }); }); - it("steers the auth retry when a turn becomes active before resend", async () => { + it("rejects the auth retry when a turn becomes active before resend", async () => { const session = buildSession("session-1", { provider: "claude", model: "claude-sonnet-4-6", @@ -1514,34 +1514,29 @@ describe("AgentChatPane submit recovery", () => { }); send.mockRejectedValueOnce(new Error("turn is already active")); seedDrawerStore(); + const rejectedEvents: Array> = []; + const onRejected = (event: Event) => { + rejectedEvents.push(event as CustomEvent<{ sessionId?: string }>); + }; + window.addEventListener(CHAT_AUTH_RETRY_REJECTED_EVENT, onRejected); renderPane(session); - expect(await screen.findByText("Retry into active turn")).toBeTruthy(); + try { + expect(await screen.findByText("Retry into active turn")).toBeTruthy(); - fireEvent(window, new CustomEvent(CHAT_RETRY_AUTH_TURN_EVENT, { - detail: { sessionId: session.sessionId }, - })); + fireEvent(window, new CustomEvent(CHAT_RETRY_AUTH_TURN_EVENT, { + detail: { sessionId: session.sessionId }, + })); - await waitFor(() => { - expect(steer).toHaveBeenCalledWith({ - sessionId: session.sessionId, - text: "Retry into active turn", - attachments: [{ path: "docs/race.md", type: "file" }], - contextAttachments: [{ - type: "orchestration_annotation", - item: { - type: "orchestration_annotation", - runId: "run-race", - anchor: { kind: "plan_step", id: "step-race", preview: "active turn fallback" }, - selectionExcerpt: "active turn fallback", - comment: "Retry with this fallback context.", - capturedAt: "2026-03-24T05:57:47.500Z", - }, - }], - metadata: { source: "auth-retry-steer-test" }, + await waitFor(() => { + expect(rejectedEvents).toHaveLength(1); }); - }); + expect(rejectedEvents[0]?.detail).toEqual({ sessionId: session.sessionId }); + expect(steer).not.toHaveBeenCalled(); + } finally { + window.removeEventListener(CHAT_AUTH_RETRY_REJECTED_EVENT, onRejected); + } }); it("dispatches auth recovery once after a later successful turn", async () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 007fe5701..a141922bf 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -6324,7 +6324,8 @@ export function AgentChatPane({ await window.ade.agentChat.send({ sessionId, text, displayText, ...replayContext }); } catch (sendError) { if (!isTurnAlreadyActiveError(sendError)) throw sendError; - await window.ade.agentChat.steer({ sessionId, text, ...replayContext }); + rejectAuthRetry(sessionId); + return; } void refreshSessions().catch(() => {}); } catch (err) { From a961e1cc8f4240feec1059c3354e60fc0449e423 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:46:26 -0400 Subject: [PATCH 6/7] Refresh Claude auth before retry --- .../components/chat/AgentChatPane.test.tsx | 29 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 10 ++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 280b7aff5..cd60296c5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -1445,11 +1445,40 @@ describe("AgentChatPane submit recovery", () => { transcript, includeClaudeModel: true, }); + const getStatus = vi.fn().mockResolvedValue({ + mode: "subscription", + availableProviders: { + claude: { + binary: { present: true, source: "path", path: "/usr/local/bin/claude" }, + auth: { ready: true, mode: "subscription", detail: null }, + }, + codex: true, + cursor: false, + droid: false, + }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + detectedAuth: [ + { type: "cli-subscription", cli: "claude", authenticated: true }, + ], + availableModelIds: ["anthropic/claude-sonnet-4-6"], + }); + window.ade.ai.getStatus = getStatus as any; seedDrawerStore(); renderPane(session); expect(await screen.findByText("Retry this exact prompt")).toBeTruthy(); + await waitFor(() => { + expect(getStatus).toHaveBeenCalled(); + }); + getStatus.mockClear(); + send.mockImplementationOnce(async () => { + expect(getStatus).toHaveBeenCalledWith({ + force: true, + refreshOpenCodeInventory: false, + }); + }); fireEvent(window, new CustomEvent(CHAT_RETRY_AUTH_TURN_EVENT, { detail: { sessionId: session.sessionId }, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index a141922bf..b7a49749f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -6280,9 +6280,10 @@ export function AgentChatPane({ ]); // Resend the most recent user message for a session that fast-failed on a - // Claude logout — fired by the inline re-login card's "Retry turn" button. The - // re-check is implicit: if Claude is still logged out the new turn fast-fails - // again and a fresh card appears. + // Claude logout — fired by the inline re-login card's "Retry turn" button. A + // forced provider refresh clears cached auth-failed runtime health first; if + // Claude is still logged out the new turn fast-fails again and a fresh card + // appears. const rejectAuthRetry = useCallback((sessionId: string) => { window.dispatchEvent(new CustomEvent(CHAT_AUTH_RETRY_REJECTED_EVENT, { detail: { sessionId } })); }, []); @@ -6320,6 +6321,7 @@ export function AgentChatPane({ setBusy(true); setError(null); touchSession(sessionId); + await refreshAvailableModels({ force: true }); try { await window.ade.agentChat.send({ sessionId, text, displayText, ...replayContext }); } catch (sendError) { @@ -6334,7 +6336,7 @@ export function AgentChatPane({ submitInFlightRef.current = false; setBusy(false); } - }, [refreshSessions, rejectAuthRetry, touchSession]); + }, [refreshAvailableModels, refreshSessions, rejectAuthRetry, touchSession]); // The inline re-login card dispatches CHAT_RETRY_AUTH_TURN_EVENT on "Retry // turn"; only the pane that owns the session resends. From 58859443813183f1354ec2711a4b79caad35da30 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:31:11 -0400 Subject: [PATCH 7/7] Show auth retry CTA in compact chats --- .../components/chat/AgentChatPane.test.tsx | 47 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 3 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index cd60296c5..df5b929d2 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -6014,6 +6014,53 @@ describe("AgentChatPane submit recovery", () => { expect(readTranscriptTail).toHaveBeenCalledWith(expect.objectContaining({ sessionId: session.sessionId })); }); + it("keeps the Claude login prompt pinned in compact grid tiles", async () => { + const session = buildSession("grid-claude-auth", { + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + status: "idle", + title: "Claude auth grid tile", + }); + const transcript = `${JSON.stringify({ + sessionId: session.sessionId, + timestamp: "2026-03-24T06:00:00.000Z", + sequence: 1, + event: { + type: "error", + message: "Authentication failed for Claude Sonnet 4.6.", + turnId: "turn-grid-auth", + errorInfo: { + category: "agent_cli_auth", + agentCli: { + agent: "claude", + displayName: "Claude Code", + category: "unauthenticated", + installCommand: "npm install -g @anthropic-ai/claude-code", + authCommand: "claude auth login", + }, + }, + }, + })}\n`; + installAdeMocks({ sessions: [session], transcript, includeClaudeModel: true }); + + render( + + + , + ); + + expect(await screen.findByRole("button", { name: "Login to Claude" })).toBeTruthy(); + }); + it("streams live events into visible inactive grid tiles without requiring focus", async () => { const session = buildSession("grid-live-chat", { title: "Grid live chat", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index b7a49749f..1431a175b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -9691,7 +9691,8 @@ export function AgentChatPane({ // scrolled out of view. Reuses the self-contained login pill (styled + own // dismiss); it hides itself once the session reconnects. Only shown when the // chat header login pill is absent so the two never double up. - const authStickyBar = showClaudeLoginPrompt && selectedSessionId && !chatTerminalVisible ? ( + const chatHeaderLoginPromptVisible = !compactShell && chatTerminalVisible && Boolean(selectedSessionId); + const authStickyBar = showClaudeLoginPrompt && selectedSessionId && !chatHeaderLoginPromptVisible ? (