From d5daf546297227b126dd1865d157c9ce894c09af Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 16 Jul 2026 11:18:36 -0500 Subject: [PATCH 01/11] feat(ui): maintainer dashboard panel for the existing @loopover chat Q&A surface Adds a Chat Q&A section to maintainer-panel.tsx exposing the existing generateChatQaAnswer service (#4595) for a maintainer's own PRs, per #6230's scope decision: read-only, no new LLM-routing path, no write/action capability. The new POST /v1/repos/:owner/:repo/pulls/:number/chat-qa route is a thin wrapper -- it builds the same AgentRunBundle grounding the PR-comment command builds (planNextWork) and hands it unchanged to generateChatQaAnswer. Per-command rate limiting reuses the exact same COMMAND_RATE_LIMIT_EVENT_TYPE counter the PR-comment `@loopover chat` command uses, keyed by (actor, targetKey), rather than a second budget. The panel renders nothing at all for a repo that hasn't opted into advisoryAiRouting.chatQa (config-as-code only, resolved from the repo's .loopover.yml), and surfaces every ChatQaResult status with its own UI state rather than a generic error. --- .../site/app-panels/chat-qa-panel.test.tsx | 130 ++++++++++ .../site/app-panels/chat-qa-panel.tsx | 230 ++++++++++++++++++ .../site/app-panels/maintainer-panel.tsx | 5 + .../src/lib/maintainer-settings-preview.ts | 11 + src/api/routes.ts | 94 ++++++- src/queue/processors.ts | 7 +- test/integration/api.test.ts | 6 +- test/unit/maintainer-chat-qa.test.ts | 202 +++++++++++++++ .../maintainer-settings-preview-ui.test.ts | 14 ++ 9 files changed, 693 insertions(+), 6 deletions(-) create mode 100644 apps/loopover-ui/src/components/site/app-panels/chat-qa-panel.test.tsx create mode 100644 apps/loopover-ui/src/components/site/app-panels/chat-qa-panel.tsx create mode 100644 test/unit/maintainer-chat-qa.test.ts diff --git a/apps/loopover-ui/src/components/site/app-panels/chat-qa-panel.test.tsx b/apps/loopover-ui/src/components/site/app-panels/chat-qa-panel.test.tsx new file mode 100644 index 0000000000..da2f25a667 --- /dev/null +++ b/apps/loopover-ui/src/components/site/app-panels/chat-qa-panel.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the API layer so the component never touches the network. +const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() })); +vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); +vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" })); + +import { ChatQaPanel } from "@/components/site/app-panels/chat-qa-panel"; + +const ELIGIBLE = [ + { pr: "acme/widgets#1", title: "Add cursor pagination", chatQaEnabled: true }, + { pr: "acme/widgets#2", title: "Fix flaky test", chatQaEnabled: false }, +]; + +async function askQuestion(question = "Why is this blocked?") { + fireEvent.change(screen.getByPlaceholderText(/why is this pr blocked/i), { target: { value: question } }); + fireEvent.click(screen.getByRole("button", { name: /ask/i })); +} + +describe("ChatQaPanel (#6489)", () => { + beforeEach(() => { + apiFetch.mockReset(); + }); + + it("renders nothing at all when no PR in view has chatQa enabled (not a disabled-looking version of the panel)", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it("renders nothing when the reviewability list is empty", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("only lists chatQa-enabled PRs in the selector", () => { + render(); + expect(screen.getByText(/acme\/widgets#1 — Add cursor pagination/)).toBeTruthy(); + expect(screen.queryByText(/acme\/widgets#2/)).toBeNull(); + }); + + it("posts the question to the selected PR's chat-qa route and renders an 'ok' answer", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { status: "ok", model: "test-model", estimatedNeurons: 12, text: "This PR is blocked on a failing check." } }); + render(); + await askQuestion("Why is this blocked?"); + + await waitFor(() => expect(screen.getByText("This PR is blocked on a failing check.")).toBeTruthy()); + expect(screen.getByText("answered")).toBeTruthy(); + expect(screen.getByText("test-model")).toBeTruthy(); + expect(apiFetch).toHaveBeenCalledWith( + "https://api.test/v1/repos/acme/widgets/pulls/1/chat-qa", + expect.objectContaining({ method: "POST", label: "Chat Q&A", body: JSON.stringify({ question: "Why is this blocked?" }) }), + ); + }); + + it("renders the 'disabled' status distinctly", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { status: "disabled", reason: "Chat Q&A is not enabled on this instance (settings.advisoryAiRouting.chatQa is off)." } }); + render(); + await askQuestion(); + await waitFor(() => expect(screen.getByText("Not enabled")).toBeTruthy()); + expect(screen.getByText(/settings\.advisoryAiRouting\.chatQa is off/)).toBeTruthy(); + }); + + it("renders the 'unavailable' status distinctly", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { status: "unavailable", reason: "Local advisory inference (env.AI_ADVISORY) is not configured." } }); + render(); + await askQuestion(); + await waitFor(() => expect(screen.getByText("Unavailable")).toBeTruthy()); + expect(screen.getByText(/env\.AI_ADVISORY.*not configured/)).toBeTruthy(); + }); + + it("renders the 'declined' status with its fallback command suggestion", async () => { + apiFetch.mockResolvedValue({ + ok: true, + data: { status: "declined", reason: "No cached deterministic facts are available.", suggestion: "Run `@loopover preflight` or `@loopover blockers` for the deterministic readiness facts." }, + }); + render(); + await askQuestion(); + await waitFor(() => expect(screen.getByText("Declined")).toBeTruthy()); + expect(screen.getByText("@loopover preflight")).toBeTruthy(); + }); + + it("renders the 'quota_exceeded' status with the remaining budget", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { status: "quota_exceeded", model: "test-model", estimatedNeurons: 900, remainingBudget: 0 } }); + render(); + await askQuestion(); + await waitFor(() => expect(screen.getByText("Daily AI budget reached")).toBeTruthy()); + expect(screen.getByText(/Remaining budget: 0 neurons/)).toBeTruthy(); + }); + + it("renders the 'unsafe' status distinctly", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { status: "unsafe", model: "test-model", estimatedNeurons: 20, reason: "chat answer failed public sanitizer" } }); + render(); + await askQuestion(); + await waitFor(() => expect(screen.getByText("Answer withheld")).toBeTruthy()); + }); + + it("renders the 'error' status distinctly", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { status: "error", model: "test-model", estimatedNeurons: 0, reason: "empty_chat_answer" } }); + render(); + await askQuestion(); + await waitFor(() => expect(screen.getByText("Answer failed")).toBeTruthy()); + expect(screen.getByText(/empty_chat_answer/)).toBeTruthy(); + }); + + it("renders the route-local 'rate_limited' status distinctly", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { status: "rate_limited", reason: "The chat command has reached its rate limit (2 within 24h), shared with the @loopover chat PR-comment command." } }); + render(); + await askQuestion(); + await waitFor(() => expect(screen.getByText("Rate limited")).toBeTruthy()); + expect(screen.getByText(/shared with the @loopover chat PR-comment command/)).toBeTruthy(); + }); + + it("shows the request-level error message when the fetch itself fails", async () => { + apiFetch.mockResolvedValue({ ok: false, message: "503 Service Unavailable" }); + render(); + await askQuestion(); + await waitFor(() => expect(screen.getByText("503 Service Unavailable")).toBeTruthy()); + }); + + it("disables the Ask button until a question is entered", () => { + render(); + expect((screen.getByRole("button", { name: /ask/i }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.change(screen.getByPlaceholderText(/why is this pr blocked/i), { target: { value: "Why?" } }); + expect((screen.getByRole("button", { name: /ask/i }) as HTMLButtonElement).disabled).toBe(false); + }); +}); diff --git a/apps/loopover-ui/src/components/site/app-panels/chat-qa-panel.tsx b/apps/loopover-ui/src/components/site/app-panels/chat-qa-panel.tsx new file mode 100644 index 0000000000..e0d52ee192 --- /dev/null +++ b/apps/loopover-ui/src/components/site/app-panels/chat-qa-panel.tsx @@ -0,0 +1,230 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { MessageCircle, RefreshCw, Send } from "lucide-react"; + +import { StatusPill, type Status } from "@/components/site/control-primitives"; +import { apiFetch } from "@/lib/api/request"; +import { getApiOrigin } from "@/lib/api/origin"; +import { splitReviewabilityPr } from "@/lib/maintainer-settings-preview"; + +/** Mirrors src/services/ai-chat-qa.ts's ChatQaResult, plus a route-local "rate_limited" state for the + * shared per-command invocation counter (#6489) -- generateChatQaAnswer itself is never modified. */ +type ChatQaResult = + | { status: "disabled"; reason: string } + | { status: "unavailable"; reason: string } + | { status: "declined"; reason: string; suggestion: string } + | { status: "quota_exceeded"; model: string; estimatedNeurons: number; remainingBudget: number } + | { status: "unsafe"; model: string; estimatedNeurons: number; reason: string } + | { status: "error"; model: string; estimatedNeurons: number; reason: string } + | { status: "ok"; model: string; estimatedNeurons: number; text: string } + | { status: "rate_limited"; reason: string }; + +type ReviewabilityRow = { pr: string; title: string; chatQaEnabled: boolean }; + +/** + * Maintainer dashboard panel for the existing `@loopover chat ` Q&A surface (#6489, per #6230's + * scope decision). Read-only: calls POST /v1/repos/:owner/:repo/pulls/:number/chat-qa, which is a thin + * wrapper around the unmodified generateChatQaAnswer service -- no new LLM-routing path, no write/action + * capability. Renders nothing at all when no PR in view has advisoryAiRouting.chatQa enabled, rather than a + * disabled-looking version of the panel. + */ +export function ChatQaPanel({ reviewability }: { reviewability: ReviewabilityRow[] }) { + const eligible = useMemo(() => reviewability.filter((row) => row.chatQaEnabled), [reviewability]); + const [selectedPr, setSelectedPr] = useState(eligible[0]?.pr ?? ""); + const [question, setQuestion] = useState(""); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!selectedPr && eligible[0]) setSelectedPr(eligible[0].pr); + }, [selectedPr, eligible]); + + if (eligible.length === 0) return null; + + async function ask() { + const target = splitReviewabilityPr(selectedPr); + if (!target) { + setResult(null); + setError("Select a pull request to ask about."); + return; + } + const trimmed = question.trim(); + if (!trimmed) { + setResult(null); + setError("Enter a question."); + return; + } + setBusy(true); + setError(null); + const response = await apiFetch( + `${getApiOrigin().replace(/\/$/, "")}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/pulls/${target.number}/chat-qa`, + { + method: "POST", + label: "Chat Q&A", + credentials: "include", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ question: trimmed }), + }, + ); + setBusy(false); + if (response.ok) { + setResult(response.data); + return; + } + setResult(null); + setError(response.message); + } + + return ( +
+
+
+

+ Chat Q&A +

+

+ Ask a grounded question about a PR's review/gate state — the same{" "} + @loopover chat surface as the PR-comment command. +

+
+ +
+ +
+ +
+ +