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..fc205a7a0b --- /dev/null +++ b/apps/loopover-ui/src/components/site/app-panels/chat-qa-panel.test.tsx @@ -0,0 +1,201 @@ +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. +

+
+ +
+ +
+ +
+ +