From e787698a767a6c12592971ea1e4ca07caf5e061d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 13:06:55 +0100 Subject: [PATCH] Expandable CI monitor with per-check runs and open-comment count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI chip in the chat work panel is now a dropdown: the summary row keeps the aggregate state plus a chevron, and expanding it lists each check run with its own pass/fail/pending icon, followed by the two automation checkboxes (moved out of their standalone chip). The comment count now shows review threads still awaiting the PR author's reply instead of the raw total — REST can't see GitHub's resolved bit, so replying is what clears a thread. New fetch_github_pr_comment_state command groups review comments into threads and counts the ones whose last word isn't the author's; the address-comments automation nudges on rises in that count and re-arms once it returns to zero. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/commands/link_preview.rs | 132 +++++++++++- desktop/src-tauri/src/lib.rs | 1 + .../src/features/chats/ui/ChatWorkPanel.tsx | 201 ++++++++++-------- desktop/src/shared/lib/githubPullRequest.ts | 34 +++ desktop/src/testing/e2eBridge.ts | 15 +- desktop/tests/e2e/chats-first-message.spec.ts | 11 +- 6 files changed, 299 insertions(+), 95 deletions(-) diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index ea015d877..1da7734e9 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -42,6 +42,27 @@ pub struct GithubCheckSummary { pub pending: i64, pub failed: i64, pub succeeded: i64, + /// Individual runs for the expanded view (name + coarse state). + pub runs: Vec, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubCheckRun { + pub name: String, + /// `pending` | `success` | `failure`. + pub state: String, +} + +/// Review-thread attention state for a PR: how many threads still await a +/// reply from the PR author. REST cannot see GitHub's "resolved" bit (that +/// is GraphQL-only), so a thread counts as open while its latest comment is +/// from someone other than the PR author — replying clears it, which +/// matches the agent workflow the chat panel automates. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubCommentState { + pub open_threads: i64, } /// Fetch live PR details from the GitHub REST API. @@ -155,28 +176,123 @@ pub async fn fetch_github_check_summary( .await .map_err(|error| format!("github response parse failed: {error}"))?; - let runs = body["check_runs"].as_array().cloned().unwrap_or_default(); + let raw_runs = body["check_runs"].as_array().cloned().unwrap_or_default(); let mut pending = 0; let mut failed = 0; let mut succeeded = 0; - for run in &runs { - match run["status"].as_str().unwrap_or_default() { + let mut runs = Vec::with_capacity(raw_runs.len()); + for run in &raw_runs { + let state = match run["status"].as_str().unwrap_or_default() { "completed" => match run["conclusion"].as_str().unwrap_or_default() { - "success" | "neutral" | "skipped" => succeeded += 1, - _ => failed += 1, + "success" | "neutral" | "skipped" => { + succeeded += 1; + "success" + } + _ => { + failed += 1; + "failure" + } }, - _ => pending += 1, - } + _ => { + pending += 1; + "pending" + } + }; + runs.push(GithubCheckRun { + name: run["name"].as_str().unwrap_or("check").to_string(), + state: state.to_string(), + }); } Ok(Some(GithubCheckSummary { - total: runs.len() as i64, + total: raw_runs.len() as i64, pending, failed, succeeded, + runs, })) } +/// Count review threads still awaiting the PR author's reply. See +/// [`GithubCommentState`] for semantics and the REST limitation. +#[tauri::command] +pub async fn fetch_github_pr_comment_state( + owner: String, + repo: String, + number: u64, +) -> Result, String> { + if !is_valid_github_name(&owner) || !is_valid_github_name(&repo) { + return Err("invalid GitHub repository reference".to_string()); + } + + let client = reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .build() + .map_err(|error| format!("github client failed: {error}"))?; + + let base = format!("https://api.github.com/repos/{owner}/{repo}"); + let build = |url: String| { + let mut request = client + .get(url) + .timeout(GITHUB_API_TIMEOUT) + .header(ACCEPT, "application/vnd.github+json") + .header(USER_AGENT, "Buzz Desktop link preview") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = ambient_github_token() { + request = request.header(AUTHORIZATION, format!("Bearer {token}")); + } + request + }; + + let pr_response = build(format!("{base}/pulls/{number}")) + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if !pr_response.status().is_success() { + return Ok(None); + } + let pr: serde_json::Value = pr_response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + let author = pr["user"]["login"].as_str().unwrap_or_default().to_string(); + + let comments_response = build(format!( + "{base}/pulls/{number}/comments?per_page=100&sort=created&direction=asc" + )) + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if !comments_response.status().is_success() { + return Ok(None); + } + let comments: serde_json::Value = comments_response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + + // Group into threads by root comment id; the latest comment (list is + // created-ascending) decides whether the thread still needs the author. + let mut last_author_by_thread: std::collections::HashMap = + std::collections::HashMap::new(); + for comment in comments.as_array().cloned().unwrap_or_default() { + let id = comment["id"].as_i64().unwrap_or_default(); + let root = comment["in_reply_to_id"].as_i64().unwrap_or(id); + let login = comment["user"]["login"] + .as_str() + .unwrap_or_default() + .to_string(); + last_author_by_thread.insert(root, login); + } + let open_threads = last_author_by_thread + .values() + .filter(|login| !author.is_empty() && **login != author) + .count() as i64; + + Ok(Some(GithubCommentState { open_threads })) +} + fn ambient_github_token() -> Option { ["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| { std::env::var(name) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 579acfadf..fa42960c7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -461,6 +461,7 @@ pub fn run() { fetch_link_preview_title, fetch_github_pull_request, fetch_github_check_summary, + fetch_github_pr_comment_state, discover_acp_providers, install_acp_runtime, discover_managed_agent_prereqs, diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index d854da93a..1ac9f3a56 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { + ChevronDown, CircleCheck, CircleDashed, CircleX, @@ -13,8 +14,10 @@ import { useChatWorkAutomation, } from "@/features/chats/lib/chatWorkAutomation"; import { + type GithubCheckSummary, parseGithubPullRequestRef, useGithubCheckSummaryQuery, + useGithubCommentStateQuery, useGithubPullRequestQuery, } from "@/shared/lib/githubPullRequest"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; @@ -26,11 +29,11 @@ const CHIP_CLASS = "flex items-center gap-1.5 rounded-2xl border border-border/70 bg-muted/30 px-3 py-2.5 text-xs"; /** - * Right-hand work drawer for a chat: branch, live PR card, CI monitor, and - * automation toggles once the agent has produced a pull request; an empty - * state before that. When automation is armed, CI failures and new comments - * prompt the chat's agent automatically (deduped per head sha / comment - * count). + * Right-hand work drawer for a chat: branch, live PR card, and a CI monitor + * once the agent has produced a pull request; an empty state before that. + * The monitor expands to the individual check runs and the automation + * toggles — when armed, CI failures and newly-open review threads prompt the + * chat's agent automatically (deduped per head sha / open-thread watermark). */ export function ChatWorkPanel({ chatId, @@ -49,11 +52,14 @@ export function ChatWorkPanel({ 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); - const commentTotal = pr ? pr.comments + pr.reviewComments : 0; - // Automation: prompt the agent on CI failure / new comments. Watermarks in - // storage keep this to one nudge per failing sha and per comment increase. + // Automation: prompt the agent on CI failure / newly-open review threads. + // Watermarks in storage keep this to one nudge per failing sha and per + // rise in open threads; the thread watermark re-arms once everything has + // been replied to (count back at zero). React.useEffect(() => { if (!onAutomationPrompt || !prHref || !pr) { return; @@ -70,25 +76,16 @@ export function ChatWorkPanel({ `CI is failing on ${prHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`, ); } - if ( - automation.addressComments && - commentTotal > 0 && - (automation.lastCommentNudgeCount ?? 0) < commentTotal - ) { - updateChatWorkAutomation(chatId, { lastCommentNudgeCount: commentTotal }); + const threadWatermark = automation.lastCommentNudgeCount ?? 0; + if (openThreads === 0 && threadWatermark !== 0) { + updateChatWorkAutomation(chatId, { lastCommentNudgeCount: 0 }); + } else if (automation.addressComments && openThreads > threadWatermark) { + updateChatWorkAutomation(chatId, { lastCommentNudgeCount: openThreads }); onAutomationPrompt( - `There are review comments on ${prHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, + `There are unanswered review comments on ${prHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, ); } - }, [ - automation, - chatId, - checks, - commentTotal, - onAutomationPrompt, - pr, - prHref, - ]); + }, [automation, chatId, checks, onAutomationPrompt, openThreads, pr, prHref]); return (