diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index e2d78f3099..ce255c2f5e 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -103,9 +103,14 @@ pub async fn fetch_github_pull_request( .send() .await .map_err(|error| format!("github request failed: {error}"))?; - if !response.status().is_success() { + if response.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } + if !response.status().is_success() { + // Rate limits and transient failures must NOT read as "no data" — + // the UI keeps its last good snapshot on error. + return Err(format!("github request failed: {}", response.status())); + } let body: serde_json::Value = response .json() @@ -167,9 +172,14 @@ pub async fn fetch_github_check_summary( .send() .await .map_err(|error| format!("github request failed: {error}"))?; - if !response.status().is_success() { + if response.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } + if !response.status().is_success() { + // Rate limits and transient failures must NOT read as "no data" — + // the UI keeps its last good snapshot on error. + return Err(format!("github request failed: {}", response.status())); + } let body: serde_json::Value = response .json() @@ -249,9 +259,12 @@ pub async fn fetch_github_pr_comment_state( .send() .await .map_err(|error| format!("github request failed: {error}"))?; - if !pr_response.status().is_success() { + if pr_response.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } + if !pr_response.status().is_success() { + return Err(format!("github request failed: {}", pr_response.status())); + } let pr: serde_json::Value = pr_response .json() .await @@ -264,9 +277,15 @@ pub async fn fetch_github_pr_comment_state( .send() .await .map_err(|error| format!("github request failed: {error}"))?; - if !comments_response.status().is_success() { + if comments_response.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } + if !comments_response.status().is_success() { + return Err(format!( + "github request failed: {}", + comments_response.status() + )); + } let comments: serde_json::Value = comments_response .json() .await @@ -417,12 +436,34 @@ fn parse_github_remote(remote: &str) -> Option<(String, String)> { } fn ambient_github_token() -> Option { - ["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| { - std::env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }) + static TOKEN: std::sync::OnceLock> = std::sync::OnceLock::new(); + TOKEN + .get_or_init(|| { + let env_token = ["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }); + if env_token.is_some() { + return env_token; + } + // Desktop apps rarely inherit a token env (launched from Finder or + // a clean shell), but the gh CLI credential is usually present — + // without it the anonymous 60 req/h limit starves the PR monitor + // within minutes. Resolved once per process. + let output = std::process::Command::new("gh") + .args(["auth", "token"]) + .stdin(std::process::Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!token.is_empty()).then_some(token) + }) + .clone() } fn is_valid_github_name(value: &str) -> bool { diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 2c1d688a3e..23f2d774d3 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -55,23 +55,32 @@ export function ChatWorkPanel({ projectPath?: string | null; }) { // No PR link in the chat yet? Discover it from the branch via the - // project's git remote (agents don't always paste the URL). + // project's git remote (agents don't always paste the URL). All GitHub + // polling pauses while the drawer is closed — a hidden panel burning the + // API rate limit is how the monitor went stale-empty. + const automation = useChatWorkAutomation(chatId); + // Armed automation keeps watching with the drawer closed; otherwise a + // hidden panel stops polling entirely. + const monitorActive = + open || automation.autoFixCi || automation.addressComments; const discoveredPrQuery = useGithubPrForBranchQuery( - prHref ? null : projectPath, + monitorActive && !prHref ? projectPath : null, branch, ); const effectiveHref = prHref ?? discoveredPrQuery.data ?? null; const preview = effectiveHref ? parseSupportedLinkPreview(effectiveHref) : null; - const ref = effectiveHref ? parseGithubPullRequestRef(effectiveHref) : null; + const parsedRef = effectiveHref + ? parseGithubPullRequestRef(effectiveHref) + : null; + const ref = monitorActive ? parsedRef : null; const prQuery = useGithubPullRequestQuery(ref); const pr = prQuery.data ?? null; const checksQuery = useGithubCheckSummaryQuery(ref, pr?.headSha); const checks = checksQuery.data ?? null; const commentStateQuery = useGithubCommentStateQuery(ref); const openThreads = commentStateQuery.data?.openThreads ?? 0; - const automation = useChatWorkAutomation(chatId); // Live activity wins over the PR's head ref: the agent may have moved to a // new worktree since opening the PR, and activity updates immediately. const currentBranch = branch?.trim() || pr?.headRef?.trim() || null;