CI monitor and automation toggles in the chat work panel

The work drawer becomes a PR monitor: a CI chip shows running (with
progress), failing (red, count), or passing (green) from the head
commit's check runs, alongside the PR's comment count — both polled
while the panel is mounted.

Two persisted per-chat checkboxes arm automation: "Auto-fix CI
failures" prompts the chat's agent to investigate and fix once a head
sha's checks settle red (one nudge per sha), and "Address comments &
resolve" prompts it to work through comments and replies, reply, and
resolve addressed threads whenever the comment count rises (watermarked
so it never repeats).

Adds fetch_github_check_summary and head sha/comment counts to the PR
fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-07 07:46:17 +01:00
co-authored by Claude Fable 5
parent e24f4a8c8a
commit 1ee49bce2f
8 changed files with 418 additions and 12 deletions
@@ -25,6 +25,23 @@ pub struct GithubPullRequestInfo {
pub changed_files: i64,
/// Source branch of the PR (`head.ref`).
pub head_ref: String,
/// Head commit sha — used to query check runs.
pub head_sha: String,
/// Issue-level comment count.
pub comments: i64,
/// Review (inline) comment count.
pub review_comments: i64,
}
/// Aggregate check-run state for a commit, for the chat work panel's CI
/// monitor.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GithubCheckSummary {
pub total: i64,
pub pending: i64,
pub failed: i64,
pub succeeded: i64,
}
/// Fetch live PR details from the GitHub REST API.
@@ -83,6 +100,80 @@ pub async fn fetch_github_pull_request(
deletions: body["deletions"].as_i64().unwrap_or(0),
changed_files: body["changed_files"].as_i64().unwrap_or(0),
head_ref: body["head"]["ref"].as_str().unwrap_or_default().to_string(),
head_sha: body["head"]["sha"].as_str().unwrap_or_default().to_string(),
comments: body["comments"].as_i64().unwrap_or(0),
review_comments: body["review_comments"].as_i64().unwrap_or(0),
}))
}
/// Fetch the check-run summary for a commit. Same auth/fallback behavior as
/// [`fetch_github_pull_request`]: `Ok(None)` on any non-success response.
#[tauri::command]
pub async fn fetch_github_check_summary(
owner: String,
repo: String,
sha: String,
) -> Result<Option<GithubCheckSummary>, String> {
if !is_valid_github_name(&owner)
|| !is_valid_github_name(&repo)
|| !sha.chars().all(|c| c.is_ascii_hexdigit())
|| sha.is_empty()
|| sha.len() > 64
{
return Err("invalid GitHub check 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 url = format!(
"https://api.github.com/repos/{owner}/{repo}/commits/{sha}/check-runs?per_page=100"
);
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}"));
}
let response = request
.send()
.await
.map_err(|error| format!("github request failed: {error}"))?;
if !response.status().is_success() {
return Ok(None);
}
let body: serde_json::Value = response
.json()
.await
.map_err(|error| format!("github response parse failed: {error}"))?;
let 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() {
"completed" => match run["conclusion"].as_str().unwrap_or_default() {
"success" | "neutral" | "skipped" => succeeded += 1,
_ => failed += 1,
},
_ => pending += 1,
}
}
Ok(Some(GithubCheckSummary {
total: runs.len() as i64,
pending,
failed,
succeeded,
}))
}
+1
View File
@@ -460,6 +460,7 @@ pub fn run() {
get_media_proxy_port,
fetch_link_preview_title,
fetch_github_pull_request,
fetch_github_check_summary,
discover_acp_providers,
install_acp_runtime,
discover_managed_agent_prereqs,
@@ -0,0 +1,90 @@
import * as React from "react";
// Per-chat automation preferences for the work panel, plus watermarks that
// keep the auto-prompts from repeating (one CI nudge per failing head sha,
// one comment nudge per count increase). Local state: the prompts are sent
// from this client into the chat, so they never need to sync.
const STORAGE_PREFIX = "buzz:chat-work-automation:v1";
const STORAGE_EVENT = "buzz:chat-work-automation-changed";
export type ChatWorkAutomation = {
autoFixCi: boolean;
addressComments: boolean;
/** Head sha of the last CI failure the agent was asked to fix. */
lastCiNudgeSha: string | null;
/** Comment total at the last address-comments nudge. */
lastCommentNudgeCount: number | null;
};
const DEFAULTS: ChatWorkAutomation = {
autoFixCi: false,
addressComments: false,
lastCiNudgeSha: null,
lastCommentNudgeCount: null,
};
function storageKey(chatId: string) {
return `${STORAGE_PREFIX}:${chatId}`;
}
export function readChatWorkAutomation(chatId: string): ChatWorkAutomation {
if (typeof window === "undefined") {
return DEFAULTS;
}
try {
const raw = window.localStorage.getItem(storageKey(chatId));
if (!raw) {
return DEFAULTS;
}
const parsed = JSON.parse(raw) as Partial<ChatWorkAutomation>;
return {
autoFixCi: Boolean(parsed.autoFixCi),
addressComments: Boolean(parsed.addressComments),
lastCiNudgeSha:
typeof parsed.lastCiNudgeSha === "string"
? parsed.lastCiNudgeSha
: null,
lastCommentNudgeCount:
typeof parsed.lastCommentNudgeCount === "number"
? parsed.lastCommentNudgeCount
: null,
};
} catch {
return DEFAULTS;
}
}
export function updateChatWorkAutomation(
chatId: string,
patch: Partial<ChatWorkAutomation>,
) {
if (typeof window === "undefined") {
return;
}
try {
const next = { ...readChatWorkAutomation(chatId), ...patch };
window.localStorage.setItem(storageKey(chatId), JSON.stringify(next));
window.dispatchEvent(new CustomEvent(STORAGE_EVENT));
} catch {
// Preferences are a convenience layer; ignore unavailable storage.
}
}
export function useChatWorkAutomation(chatId: string): ChatWorkAutomation {
const [state, setState] = React.useState(() =>
readChatWorkAutomation(chatId),
);
React.useEffect(() => {
const refresh = () => setState(readChatWorkAutomation(chatId));
refresh();
window.addEventListener(STORAGE_EVENT, refresh);
window.addEventListener("storage", refresh);
return () => {
window.removeEventListener(STORAGE_EVENT, refresh);
window.removeEventListener("storage", refresh);
};
}, [chatId]);
return state;
}
+6 -1
View File
@@ -624,7 +624,12 @@ export function ChatDetail({
/>
</div>
</div>
<ChatWorkPanel open={showWorkPanel} prHref={workPanelHref} />
<ChatWorkPanel
chatId={chat.id}
onAutomationPrompt={(content) => void onSend(content, [])}
open={showWorkPanel}
prHref={workPanelHref}
/>
</div>
</>
);
+175 -11
View File
@@ -1,30 +1,94 @@
import { GitBranch } from "lucide-react";
import * as React from "react";
import {
CircleCheck,
CircleDashed,
CircleX,
GitBranch,
LoaderCircle,
MessageSquareText,
} from "lucide-react";
import {
updateChatWorkAutomation,
useChatWorkAutomation,
} from "@/features/chats/lib/chatWorkAutomation";
import {
parseGithubPullRequestRef,
useGithubCheckSummaryQuery,
useGithubPullRequestQuery,
} from "@/shared/lib/githubPullRequest";
import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview";
import { cn } from "@/shared/lib/cn";
import { Checkbox } from "@/shared/ui/checkbox";
import { GithubPullRequestCard } from "@/shared/ui/link-preview-attachment";
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: the PR's source branch and the live PR
* card once the agent has produced a pull request, or an empty state until
* then. The drawer eases open/closed on its width so the conversation column
* and composer slide to make room.
* 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).
*/
export function ChatWorkPanel({
chatId,
onAutomationPrompt,
open = true,
prHref,
}: {
chatId: string;
onAutomationPrompt?: (content: string) => void;
open?: boolean;
prHref?: string | null;
}) {
const preview = prHref ? parseSupportedLinkPreview(prHref) : null;
const ref = prHref ? parseGithubPullRequestRef(prHref) : null;
const query = useGithubPullRequestQuery(ref);
const branch = query.data?.headRef?.trim();
const prQuery = useGithubPullRequestQuery(ref);
const pr = prQuery.data ?? null;
const checksQuery = useGithubCheckSummaryQuery(ref, pr?.headSha);
const checks = checksQuery.data ?? null;
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.
React.useEffect(() => {
if (!onAutomationPrompt || !prHref || !pr) {
return;
}
if (
automation.autoFixCi &&
checks &&
checks.failed > 0 &&
checks.pending === 0 &&
automation.lastCiNudgeSha !== pr.headSha
) {
updateChatWorkAutomation(chatId, { lastCiNudgeSha: pr.headSha });
onAutomationPrompt(
`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 });
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.`,
);
}
}, [
automation,
chatId,
checks,
commentTotal,
onAutomationPrompt,
pr,
prHref,
]);
return (
<aside
@@ -38,11 +102,12 @@ export function ChatWorkPanel({
{/* Fixed-width inner wrapper so content never reflows mid-slide. */}
<div className="w-96 overflow-y-auto py-4 pl-1 pr-4">
<div className="flex flex-col gap-2">
{/* Same attachment styling as the generic link chips. */}
<div className="flex items-center gap-1.5 rounded-2xl border border-border/70 bg-muted/30 px-3 py-2.5 text-xs">
<div className={CHIP_CLASS}>
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{branch ? (
<span className="min-w-0 truncate font-mono">{branch}</span>
{pr?.headRef?.trim() ? (
<span className="min-w-0 truncate font-mono">
{pr.headRef.trim()}
</span>
) : (
<span className="text-muted-foreground">No current branch</span>
)}
@@ -50,8 +115,107 @@ export function ChatWorkPanel({
{preview ? (
<GithubPullRequestCard className="w-full" preview={preview} />
) : null}
{preview ? (
<div className={CHIP_CLASS} data-testid="chat-ci-monitor">
<CiStatus checks={checks} />
<span
aria-hidden="true"
className="mx-0.5 text-muted-foreground/50"
>
·
</span>
<MessageSquareText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">
{commentTotal} comment{commentTotal === 1 ? "" : "s"}
</span>
</div>
) : null}
{preview ? (
<div className={cn(CHIP_CLASS, "flex-col items-stretch gap-2.5")}>
<label
className="flex cursor-pointer items-center gap-2"
htmlFor="automation-auto-fix-ci"
>
<Checkbox
checked={automation.autoFixCi}
data-testid="automation-auto-fix-ci"
id="automation-auto-fix-ci"
onCheckedChange={(checked) =>
updateChatWorkAutomation(chatId, {
autoFixCi: checked === true,
})
}
/>
<span>Auto-fix CI failures</span>
</label>
<label
className="flex cursor-pointer items-center gap-2"
htmlFor="automation-address-comments"
>
<Checkbox
checked={automation.addressComments}
data-testid="automation-address-comments"
id="automation-address-comments"
onCheckedChange={(checked) =>
updateChatWorkAutomation(chatId, {
addressComments: checked === true,
})
}
/>
<span>Address comments & resolve</span>
</label>
</div>
) : null}
</div>
</div>
</aside>
);
}
function CiStatus({
checks,
}: {
checks: {
total: number;
pending: number;
failed: number;
succeeded: number;
} | null;
}) {
if (!checks || checks.total === 0) {
return (
<>
<CircleDashed className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">No checks</span>
</>
);
}
if (checks.pending > 0) {
return (
<>
<LoaderCircle className="sprout-arc-spinner h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">
CI running ({checks.total - checks.pending}/{checks.total})
</span>
</>
);
}
if (checks.failed > 0) {
return (
<>
<CircleX className="h-3.5 w-3.5 shrink-0 text-[color:var(--status-deleted)]" />
<span className="font-medium text-[color:var(--status-deleted)]">
CI failing ({checks.failed})
</span>
</>
);
}
return (
<>
<CircleCheck className="h-3.5 w-3.5 shrink-0 text-[color:var(--status-added)]" />
<span className="font-medium text-[color:var(--status-added)]">
CI passing
</span>
</>
);
}
@@ -13,6 +13,19 @@ export type GithubPullRequestInfo = {
changedFiles: number;
/** Source branch of the PR (`head.ref`). */
headRef: string;
/** Head commit sha — used to query check runs. */
headSha: string;
/** Issue-level comment count. */
comments: number;
/** Review (inline) comment count. */
reviewComments: number;
};
export type GithubCheckSummary = {
total: number;
pending: number;
failed: number;
succeeded: number;
};
export type GithubPullRequestRef = {
@@ -69,6 +82,37 @@ export function useGithubPullRequestQuery(ref: GithubPullRequestRef | null) {
},
)) ?? null,
staleTime: 60_000,
// The work panel doubles as a PR monitor — keep state and comment
// counts fresh while mounted.
refetchInterval: 60_000,
retry: 1,
});
}
export function useGithubCheckSummaryQuery(
ref: GithubPullRequestRef | null,
sha: string | null | undefined,
) {
return useQuery({
enabled: ref !== null && Boolean(sha),
queryKey: [
"github-check-summary",
ref?.owner ?? "",
ref?.repo ?? "",
sha ?? "",
],
queryFn: async () =>
(await invokeTauri<GithubCheckSummary | null>(
"fetch_github_check_summary",
{
owner: ref?.owner ?? "",
repo: ref?.repo ?? "",
sha: sha ?? "",
},
)) ?? null,
staleTime: 30_000,
// CI flips fast while runs execute; poll while the panel is mounted.
refetchInterval: 45_000,
retry: 1,
});
}
+5
View File
@@ -8888,6 +8888,8 @@ export function maybeInstallE2eTauriMocks() {
);
case "get_media_proxy_port":
return MOCK_MEDIA_PROXY_PORT;
case "fetch_github_check_summary":
return { total: 4, pending: 0, failed: 0, succeeded: 4 };
case "fetch_github_pull_request": {
// Deterministic PR details so the rich GitHub card renders in mocks.
const prPayload = payload as { number?: number };
@@ -8900,6 +8902,9 @@ export function maybeInstallE2eTauriMocks() {
deletions: 96,
changedFiles: 24,
headRef: "kennylopez-chatmode",
headSha: "deadbeefcafe0000000000000000000000000000",
comments: 2,
reviewComments: 1,
number: prPayload.number ?? 0,
};
}
@@ -155,6 +155,12 @@ test("first message in a new chat is sent and rendered", async ({ page }) => {
workPanel.locator("[data-link-preview='github-pull-request']"),
).toBeVisible();
// CI monitor and automation toggles render alongside the card.
await expect(page.getByTestId("chat-ci-monitor")).toContainText("CI passing");
await expect(page.getByTestId("chat-ci-monitor")).toContainText("3 comments");
await expect(page.getByTestId("automation-auto-fix-ci")).toBeVisible();
await expect(page.getByTestId("automation-address-comments")).toBeVisible();
// The header's PR button toggles the panel.
await page.getByTestId("toggle-work-panel").click();
await expect(workPanel).not.toBeVisible();