diff --git a/desktop/src-tauri/src/commands/app.rs b/desktop/src-tauri/src/commands/app.rs
index 126a9d0..cb24353 100644
--- a/desktop/src-tauri/src/commands/app.rs
+++ b/desktop/src-tauri/src/commands/app.rs
@@ -988,35 +988,60 @@ struct PandaExchangeResponse {
/// POST the one-time `code` + PKCE `verifier` to the Panda exchange endpoint and
/// return `(key, base_url)`. Maps the documented 400 error codes to friendly
/// copy.
+///
+/// Retries only on **transient** failures — a transport error (no response) or
+/// an HTTP 5xx/429 — with a short exponential backoff. Terminal responses (the
+/// documented 4xx codes like `code_expired` / `invalid_or_used_code`) are NOT
+/// retried: the `code` is single-use, so re-sending it after the server has
+/// already judged it is pointless and could only ever fail the same way. The
+/// same code is reused across transient retries because a 5xx/transport error
+/// means the server never consumed it.
async fn exchange_code_for_key(code: &str, verifier: &str) -> IpcResult<(String, String)> {
+ const MAX_ATTEMPTS: u32 = 3;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(3))
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| IpcError::new("PANDA_EXCHANGE_FAILED", e.to_string()))?;
- let resp = client
- .post(format!("{PANDA_API_URL}/api/auth/exchange"))
- .json(&serde_json::json!({ "code": code, "code_verifier": verifier }))
- .send()
- .await
- .map_err(|e| {
- IpcError::new(
- "PANDA_EXCHANGE_FAILED",
- format!("could not reach Panda sign-in: {e}"),
- )
- })?;
- let status = resp.status();
- let body = resp.text().await.unwrap_or_default();
- if !status.is_success() {
- return Err(IpcError::new("PANDA_EXCHANGE_FAILED", map_exchange_error(&body)));
- }
- let parsed: PandaExchangeResponse = serde_json::from_str(&body).map_err(|e| {
- IpcError::new(
- "PANDA_EXCHANGE_FAILED",
- format!("unexpected sign-in response: {e}"),
- )
- })?;
- Ok((parsed.key, parsed.base_url))
+ let body = serde_json::json!({ "code": code, "code_verifier": verifier });
+ let url = format!("{PANDA_API_URL}/api/auth/exchange");
+
+ let mut attempt = 0;
+ loop {
+ attempt += 1;
+ let transient_err: String = match client.post(&url).json(&body).send().await {
+ Ok(resp) => {
+ let status = resp.status();
+ let text = resp.text().await.unwrap_or_default();
+ if status.is_success() {
+ let parsed: PandaExchangeResponse =
+ serde_json::from_str(&text).map_err(|e| {
+ IpcError::new(
+ "PANDA_EXCHANGE_FAILED",
+ format!("unexpected sign-in response: {e}"),
+ )
+ })?;
+ return Ok((parsed.key, parsed.base_url));
+ }
+ // 5xx / 429 → transient; any other non-2xx (e.g. the 400 codes)
+ // is terminal and surfaced immediately.
+ let transient = status.is_server_error()
+ || status == reqwest::StatusCode::TOO_MANY_REQUESTS;
+ if !transient {
+ return Err(IpcError::new("PANDA_EXCHANGE_FAILED", map_exchange_error(&text)));
+ }
+ format!("Panda sign-in service returned HTTP {status}")
+ }
+ Err(e) => format!("could not reach Panda sign-in: {e}"),
+ };
+
+ if attempt >= MAX_ATTEMPTS {
+ return Err(IpcError::new("PANDA_EXCHANGE_FAILED", transient_err));
+ }
+ // Exponential backoff: 300ms, then 600ms.
+ let delay = std::time::Duration::from_millis(300 * 2u64.pow(attempt - 1));
+ tokio::time::sleep(delay).await;
+ }
}
/// Friendly copy for the documented exchange error codes. The body looks like
diff --git a/desktop/src-tauri/src/commands/claude_driver.rs b/desktop/src-tauri/src/commands/claude_driver.rs
index e4351c0..71efee8 100644
--- a/desktop/src-tauri/src/commands/claude_driver.rs
+++ b/desktop/src-tauri/src/commands/claude_driver.rs
@@ -336,6 +336,25 @@ pub fn build_env(cfg: &ClaudeRunConfig) -> Vec<(String, String)> {
env
}
+/// Heuristic: does this `claude` stderr look like an API authentication
+/// failure? Used only on the Panda proxy path to distinguish a revoked/expired
+/// key (BE returns 401, Anthropic-style `authentication_error` body) from other
+/// silent failures, so the UI can offer a re-login. Matched case-insensitively
+/// against the substrings Anthropic/the proxy emit; the proxy mode gating keeps
+/// false positives from mislabelling a non-auth failure.
+pub fn looks_like_auth_failure(stderr: &str) -> bool {
+ let s = stderr.to_ascii_lowercase();
+ s.contains("authentication_error")
+ || s.contains("invalid api key")
+ || s.contains("invalid x-api-key")
+ || s.contains("invalid bearer token")
+ || s.contains("permission_error")
+ || s.contains("401 unauthorized")
+ || s.contains("status 401")
+ || s.contains("http 401")
+ || s.contains("oauth token has expired")
+}
+
/// Has Claude Code already persisted a session JSONL for this UUID?
///
/// Claude Code stores sessions at
@@ -1185,10 +1204,19 @@ where
.unwrap_or_else(|| {
format!("claude exited without output ({status:?})")
});
- on_event(ChatEvent::Error {
- turn_id: turn_id.to_string(),
- message: format!("claude produced no response: {detail}"),
- });
+ // On the Panda proxy path, a revoked/expired key surfaces as an auth
+ // error here (the BE returns 401). Emit a dedicated event the chat UI
+ // turns into a "Sign in again" action instead of a cryptic message.
+ if cfg.use_panda_cloud && looks_like_auth_failure(&detail) {
+ on_event(ChatEvent::AuthExpired {
+ turn_id: turn_id.to_string(),
+ });
+ } else {
+ on_event(ChatEvent::Error {
+ turn_id: turn_id.to_string(),
+ message: format!("claude produced no response: {detail}"),
+ });
+ }
}
// Post-turn workspace diff. Emit artifact_changed for everything
@@ -1744,6 +1772,22 @@ mod tests {
);
}
+ #[test]
+ fn looks_like_auth_failure_flags_proxy_401() {
+ // Anthropic-style 401 body the proxy returns for a revoked key.
+ assert!(looks_like_auth_failure(
+ "API Error: 401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"invalid x-api-key\"}}"
+ ));
+ assert!(looks_like_auth_failure("Error: oauth token has expired"));
+ assert!(looks_like_auth_failure("request failed: HTTP 401"));
+ // Non-auth failures must NOT be flagged (they get the generic error).
+ assert!(!looks_like_auth_failure(
+ "Session ID already in use: 1234"
+ ));
+ assert!(!looks_like_auth_failure("spawn node ENOENT"));
+ assert!(!looks_like_auth_failure("overloaded_error: 529"));
+ }
+
#[test]
fn build_env_default_disables_autoupdater_only() {
let cfg = ClaudeRunConfig {
diff --git a/desktop/src-tauri/src/ipc/types.rs b/desktop/src-tauri/src/ipc/types.rs
index c44252b..0fc378e 100644
--- a/desktop/src-tauri/src/ipc/types.rs
+++ b/desktop/src-tauri/src/ipc/types.rs
@@ -303,6 +303,14 @@ pub enum ChatEvent {
turn_id: String,
message: String,
},
+ /// The Panda proxy rejected the turn's auth (revoked/expired `ccr-` key →
+ /// the BE returns 401). Emitted instead of a generic `Error` when
+ /// `use_panda_cloud` is on and the failure looks like an auth error, so the
+ /// chat UI can offer a "Sign in again" action rather than a cryptic message.
+ /// Ends the turn like `Error` does.
+ AuthExpired {
+ turn_id: String,
+ },
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
diff --git a/viewer/src/client/components/chat/ChatSidebar.jsx b/viewer/src/client/components/chat/ChatSidebar.jsx
index 3788bb4..2a3691c 100644
--- a/viewer/src/client/components/chat/ChatSidebar.jsx
+++ b/viewer/src/client/components/chat/ChatSidebar.jsx
@@ -7,6 +7,7 @@ import ChatHistory from "./ChatHistory";
import ChatInput from "./ChatInput";
// import ActionButtons from "./ActionButtons";
import AuthModeControl from "./AuthModeControl";
+import PandaReauthBanner from "./PandaReauthBanner";
import { MessageSquare } from "lucide-react";
const SIDEBAR_WIDTH = 440;
@@ -67,6 +68,7 @@ export default function ChatSidebar({
className,
}) {
const lastError = useChatStore((state) => state.lastError);
+ const needsPandaReauth = useChatStore((state) => state.needsPandaReauth);
const history = useChatStore((state) => state.history);
const projectId = useChatStore((state) => state.currentProjectId);
const currentProjectName = useProjectsStore((state) => {
@@ -215,7 +217,9 @@ export default function ChatSidebar({
)}
- {lastError ? (
+