Skip to content

Commit 124358f

Browse files
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.
1 parent bc3b0f2 commit 124358f

9 files changed

Lines changed: 693 additions & 6 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
// Mock the API layer so the component never touches the network.
5+
const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
6+
vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
7+
vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" }));
8+
9+
import { ChatQaPanel } from "@/components/site/app-panels/chat-qa-panel";
10+
11+
const ELIGIBLE = [
12+
{ pr: "acme/widgets#1", title: "Add cursor pagination", chatQaEnabled: true },
13+
{ pr: "acme/widgets#2", title: "Fix flaky test", chatQaEnabled: false },
14+
];
15+
16+
async function askQuestion(question = "Why is this blocked?") {
17+
fireEvent.change(screen.getByPlaceholderText(/why is this pr blocked/i), { target: { value: question } });
18+
fireEvent.click(screen.getByRole("button", { name: /ask/i }));
19+
}
20+
21+
describe("ChatQaPanel (#6489)", () => {
22+
beforeEach(() => {
23+
apiFetch.mockReset();
24+
});
25+
26+
it("renders nothing at all when no PR in view has chatQa enabled (not a disabled-looking version of the panel)", () => {
27+
const { container } = render(
28+
<ChatQaPanel reviewability={[{ pr: "acme/widgets#2", title: "Fix flaky test", chatQaEnabled: false }]} />,
29+
);
30+
expect(container.firstChild).toBeNull();
31+
expect(apiFetch).not.toHaveBeenCalled();
32+
});
33+
34+
it("renders nothing when the reviewability list is empty", () => {
35+
const { container } = render(<ChatQaPanel reviewability={[]} />);
36+
expect(container.firstChild).toBeNull();
37+
});
38+
39+
it("only lists chatQa-enabled PRs in the selector", () => {
40+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
41+
expect(screen.getByText(/acme\/widgets#1 Add cursor pagination/)).toBeTruthy();
42+
expect(screen.queryByText(/acme\/widgets#2/)).toBeNull();
43+
});
44+
45+
it("posts the question to the selected PR's chat-qa route and renders an 'ok' answer", async () => {
46+
apiFetch.mockResolvedValue({ ok: true, data: { status: "ok", model: "test-model", estimatedNeurons: 12, text: "This PR is blocked on a failing check." } });
47+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
48+
await askQuestion("Why is this blocked?");
49+
50+
await waitFor(() => expect(screen.getByText("This PR is blocked on a failing check.")).toBeTruthy());
51+
expect(screen.getByText("answered")).toBeTruthy();
52+
expect(screen.getByText("test-model")).toBeTruthy();
53+
expect(apiFetch).toHaveBeenCalledWith(
54+
"https://api.test/v1/repos/acme/widgets/pulls/1/chat-qa",
55+
expect.objectContaining({ method: "POST", label: "Chat Q&A", body: JSON.stringify({ question: "Why is this blocked?" }) }),
56+
);
57+
});
58+
59+
it("renders the 'disabled' status distinctly", async () => {
60+
apiFetch.mockResolvedValue({ ok: true, data: { status: "disabled", reason: "Chat Q&A is not enabled on this instance (settings.advisoryAiRouting.chatQa is off)." } });
61+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
62+
await askQuestion();
63+
await waitFor(() => expect(screen.getByText("Not enabled")).toBeTruthy());
64+
expect(screen.getByText(/settings\.advisoryAiRouting\.chatQa is off/)).toBeTruthy();
65+
});
66+
67+
it("renders the 'unavailable' status distinctly", async () => {
68+
apiFetch.mockResolvedValue({ ok: true, data: { status: "unavailable", reason: "Local advisory inference (env.AI_ADVISORY) is not configured." } });
69+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
70+
await askQuestion();
71+
await waitFor(() => expect(screen.getByText("Unavailable")).toBeTruthy());
72+
expect(screen.getByText(/env\.AI_ADVISORY.*not configured/)).toBeTruthy();
73+
});
74+
75+
it("renders the 'declined' status with its fallback command suggestion", async () => {
76+
apiFetch.mockResolvedValue({
77+
ok: true,
78+
data: { status: "declined", reason: "No cached deterministic facts are available.", suggestion: "Run `@loopover preflight` or `@loopover blockers` for the deterministic readiness facts." },
79+
});
80+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
81+
await askQuestion();
82+
await waitFor(() => expect(screen.getByText("Declined")).toBeTruthy());
83+
expect(screen.getByText("@loopover preflight")).toBeTruthy();
84+
});
85+
86+
it("renders the 'quota_exceeded' status with the remaining budget", async () => {
87+
apiFetch.mockResolvedValue({ ok: true, data: { status: "quota_exceeded", model: "test-model", estimatedNeurons: 900, remainingBudget: 0 } });
88+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
89+
await askQuestion();
90+
await waitFor(() => expect(screen.getByText("Daily AI budget reached")).toBeTruthy());
91+
expect(screen.getByText(/Remaining budget: 0 neurons/)).toBeTruthy();
92+
});
93+
94+
it("renders the 'unsafe' status distinctly", async () => {
95+
apiFetch.mockResolvedValue({ ok: true, data: { status: "unsafe", model: "test-model", estimatedNeurons: 20, reason: "chat answer failed public sanitizer" } });
96+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
97+
await askQuestion();
98+
await waitFor(() => expect(screen.getByText("Answer withheld")).toBeTruthy());
99+
});
100+
101+
it("renders the 'error' status distinctly", async () => {
102+
apiFetch.mockResolvedValue({ ok: true, data: { status: "error", model: "test-model", estimatedNeurons: 0, reason: "empty_chat_answer" } });
103+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
104+
await askQuestion();
105+
await waitFor(() => expect(screen.getByText("Answer failed")).toBeTruthy());
106+
expect(screen.getByText(/empty_chat_answer/)).toBeTruthy();
107+
});
108+
109+
it("renders the route-local 'rate_limited' status distinctly", async () => {
110+
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." } });
111+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
112+
await askQuestion();
113+
await waitFor(() => expect(screen.getByText("Rate limited")).toBeTruthy());
114+
expect(screen.getByText(/shared with the @loopover chat PR-comment command/)).toBeTruthy();
115+
});
116+
117+
it("shows the request-level error message when the fetch itself fails", async () => {
118+
apiFetch.mockResolvedValue({ ok: false, message: "503 Service Unavailable" });
119+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
120+
await askQuestion();
121+
await waitFor(() => expect(screen.getByText("503 Service Unavailable")).toBeTruthy());
122+
});
123+
124+
it("disables the Ask button until a question is entered", () => {
125+
render(<ChatQaPanel reviewability={ELIGIBLE} />);
126+
expect((screen.getByRole("button", { name: /ask/i }) as HTMLButtonElement).disabled).toBe(true);
127+
fireEvent.change(screen.getByPlaceholderText(/why is this pr blocked/i), { target: { value: "Why?" } });
128+
expect((screen.getByRole("button", { name: /ask/i }) as HTMLButtonElement).disabled).toBe(false);
129+
});
130+
});
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { useEffect, useMemo, useState, type ReactNode } from "react";
2+
import { MessageCircle, RefreshCw, Send } from "lucide-react";
3+
4+
import { StatusPill, type Status } from "@/components/site/control-primitives";
5+
import { apiFetch } from "@/lib/api/request";
6+
import { getApiOrigin } from "@/lib/api/origin";
7+
import { splitReviewabilityPr } from "@/lib/maintainer-settings-preview";
8+
9+
/** Mirrors src/services/ai-chat-qa.ts's ChatQaResult, plus a route-local "rate_limited" state for the
10+
* shared per-command invocation counter (#6489) -- generateChatQaAnswer itself is never modified. */
11+
type ChatQaResult =
12+
| { status: "disabled"; reason: string }
13+
| { status: "unavailable"; reason: string }
14+
| { status: "declined"; reason: string; suggestion: string }
15+
| { status: "quota_exceeded"; model: string; estimatedNeurons: number; remainingBudget: number }
16+
| { status: "unsafe"; model: string; estimatedNeurons: number; reason: string }
17+
| { status: "error"; model: string; estimatedNeurons: number; reason: string }
18+
| { status: "ok"; model: string; estimatedNeurons: number; text: string }
19+
| { status: "rate_limited"; reason: string };
20+
21+
type ReviewabilityRow = { pr: string; title: string; chatQaEnabled: boolean };
22+
23+
/**
24+
* Maintainer dashboard panel for the existing `@loopover chat <question>` Q&A surface (#6489, per #6230's
25+
* scope decision). Read-only: calls POST /v1/repos/:owner/:repo/pulls/:number/chat-qa, which is a thin
26+
* wrapper around the unmodified generateChatQaAnswer service -- no new LLM-routing path, no write/action
27+
* capability. Renders nothing at all when no PR in view has advisoryAiRouting.chatQa enabled, rather than a
28+
* disabled-looking version of the panel.
29+
*/
30+
export function ChatQaPanel({ reviewability }: { reviewability: ReviewabilityRow[] }) {
31+
const eligible = useMemo(() => reviewability.filter((row) => row.chatQaEnabled), [reviewability]);
32+
const [selectedPr, setSelectedPr] = useState(eligible[0]?.pr ?? "");
33+
const [question, setQuestion] = useState("");
34+
const [result, setResult] = useState<ChatQaResult | null>(null);
35+
const [error, setError] = useState<string | null>(null);
36+
const [busy, setBusy] = useState(false);
37+
38+
useEffect(() => {
39+
if (!selectedPr && eligible[0]) setSelectedPr(eligible[0].pr);
40+
}, [selectedPr, eligible]);
41+
42+
if (eligible.length === 0) return null;
43+
44+
async function ask() {
45+
const target = splitReviewabilityPr(selectedPr);
46+
if (!target) {
47+
setResult(null);
48+
setError("Select a pull request to ask about.");
49+
return;
50+
}
51+
const trimmed = question.trim();
52+
if (!trimmed) {
53+
setResult(null);
54+
setError("Enter a question.");
55+
return;
56+
}
57+
setBusy(true);
58+
setError(null);
59+
const response = await apiFetch<ChatQaResult>(
60+
`${getApiOrigin().replace(/\/$/, "")}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/pulls/${target.number}/chat-qa`,
61+
{
62+
method: "POST",
63+
label: "Chat Q&A",
64+
credentials: "include",
65+
headers: { Accept: "application/json", "Content-Type": "application/json" },
66+
body: JSON.stringify({ question: trimmed }),
67+
},
68+
);
69+
setBusy(false);
70+
if (response.ok) {
71+
setResult(response.data);
72+
return;
73+
}
74+
setResult(null);
75+
setError(response.message);
76+
}
77+
78+
return (
79+
<section className="rounded-token border-hairline bg-card p-5" aria-labelledby="chat-qa-title">
80+
<div className="flex flex-wrap items-center justify-between gap-3">
81+
<div>
82+
<h2 id="chat-qa-title" className="font-display text-token-lg font-semibold">
83+
Chat Q&A
84+
</h2>
85+
<p className="mt-1 text-token-xs text-muted-foreground">
86+
Ask a grounded question about a PR&apos;s review/gate state — the same{" "}
87+
<code className="font-mono">@loopover chat</code> surface as the PR-comment command.
88+
</p>
89+
</div>
90+
<MessageCircle className="size-5 text-muted-foreground" aria-hidden />
91+
</div>
92+
93+
<div className="mt-4 grid gap-3 sm:grid-cols-[1fr_auto]">
94+
<label className="block">
95+
<span className="font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
96+
Pull request
97+
</span>
98+
<select
99+
value={selectedPr}
100+
onChange={(event) => {
101+
setSelectedPr(event.target.value);
102+
setResult(null);
103+
setError(null);
104+
}}
105+
className="mt-1 min-h-10 w-full rounded-token border border-border bg-background/70 px-3 py-2 font-mono text-token-sm text-foreground outline-none transition-colors focus:border-mint"
106+
>
107+
{eligible.map((row) => (
108+
<option key={row.pr} value={row.pr}>
109+
{row.pr}{row.title}
110+
</option>
111+
))}
112+
</select>
113+
</label>
114+
</div>
115+
116+
<label className="mt-3 block">
117+
<span className="font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
118+
Question
119+
</span>
120+
<textarea
121+
value={question}
122+
onChange={(event) => {
123+
setQuestion(event.target.value);
124+
setResult(null);
125+
setError(null);
126+
}}
127+
rows={2}
128+
placeholder="Why is this PR blocked?"
129+
className="mt-1 w-full resize-y rounded-token border border-border bg-background/70 px-3 py-2 text-token-sm text-foreground outline-none transition-colors focus:border-mint"
130+
/>
131+
</label>
132+
133+
<div className="mt-3 flex items-center justify-between gap-3">
134+
<button
135+
type="button"
136+
disabled={busy || !selectedPr || question.trim().length === 0}
137+
onClick={() => void ask()}
138+
className="inline-flex items-center gap-2 rounded-token border border-mint/40 bg-mint px-3 py-2 text-token-xs font-medium text-primary-foreground transition-all hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
139+
>
140+
{busy ? <RefreshCw className="size-3.5 animate-spin" /> : <Send className="size-3.5" />}
141+
{busy ? "Asking" : "Ask"}
142+
</button>
143+
</div>
144+
145+
{error ? <p className="mt-3 text-token-xs text-warning">{error}</p> : null}
146+
{result ? <ChatQaResultView result={result} /> : null}
147+
</section>
148+
);
149+
}
150+
151+
function ChatQaResultView({ result }: { result: ChatQaResult }) {
152+
switch (result.status) {
153+
case "ok":
154+
return (
155+
<div className="mt-4 rounded-token border-hairline bg-background/40 p-4">
156+
<div className="flex items-center justify-between gap-2">
157+
<StatusPill status="ready">answered</StatusPill>
158+
<span className="font-mono text-token-2xs text-muted-foreground">{result.model}</span>
159+
</div>
160+
<p className="mt-2 whitespace-pre-wrap text-token-sm text-foreground">{result.text}</p>
161+
</div>
162+
);
163+
case "declined":
164+
return (
165+
<ChatQaStatusNote status="info" title="Declined">
166+
{result.reason} Try{" "}
167+
<code className="font-mono">
168+
{result.suggestion.match(/`([^`]+)`/)?.[1] ?? result.suggestion}
169+
</code>
170+
.
171+
</ChatQaStatusNote>
172+
);
173+
case "disabled":
174+
return (
175+
<ChatQaStatusNote status="info" title="Not enabled">
176+
{result.reason}
177+
</ChatQaStatusNote>
178+
);
179+
case "unavailable":
180+
return (
181+
<ChatQaStatusNote status="info" title="Unavailable">
182+
{result.reason}
183+
</ChatQaStatusNote>
184+
);
185+
case "quota_exceeded":
186+
return (
187+
<ChatQaStatusNote status="warn" title="Daily AI budget reached">
188+
Remaining budget: {result.remainingBudget} neurons ({result.model}).
189+
</ChatQaStatusNote>
190+
);
191+
case "rate_limited":
192+
return (
193+
<ChatQaStatusNote status="warn" title="Rate limited">
194+
{result.reason}
195+
</ChatQaStatusNote>
196+
);
197+
case "unsafe":
198+
return (
199+
<ChatQaStatusNote status="blocked" title="Answer withheld">
200+
The generated answer did not pass the public-safety filter and was withheld (
201+
{result.model}).
202+
</ChatQaStatusNote>
203+
);
204+
case "error":
205+
return (
206+
<ChatQaStatusNote status="blocked" title="Answer failed">
207+
{result.reason} ({result.model})
208+
</ChatQaStatusNote>
209+
);
210+
}
211+
}
212+
213+
function ChatQaStatusNote({
214+
status,
215+
title,
216+
children,
217+
}: {
218+
status: Status;
219+
title: string;
220+
children: ReactNode;
221+
}) {
222+
return (
223+
<div className="mt-4 rounded-token border-hairline bg-background/40 p-4">
224+
<div className="flex items-center gap-2">
225+
<StatusPill status={status}>{title}</StatusPill>
226+
</div>
227+
<p className="mt-2 text-token-sm text-muted-foreground">{children}</p>
228+
</div>
229+
);
230+
}

apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from "@/components/site/control-primitives";
2020
import { ActivationPreview } from "@/components/site/app-panels/activation-preview";
2121
import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings";
22+
import { ChatQaPanel } from "@/components/site/app-panels/chat-qa-panel";
2223
import { ContributorQualityTable } from "@/components/site/app-panels/contributor-quality-table";
2324
import type { MaintainerTopContributor } from "@/components/site/app-panels/contributor-quality-table-model";
2425
import { GateOutcomeCard } from "@/components/site/app-panels/gate-outcome-card";
@@ -90,6 +91,8 @@ type MaintainerDashboard = {
9091
bucket: string;
9192
reason: string;
9293
slop?: { risk: number; band: string } | null;
94+
/** Whether this PR's repo has opted into the grounded @loopover chat Q&A surface (#6489). */
95+
chatQaEnabled: boolean;
9396
}>;
9497
settingsPreview: { removed: string[]; added: string[] };
9598
qualityDashboard: {
@@ -422,6 +425,8 @@ function MaintainerDashboardView({
422425

423426
<GateRampControl reviewability={data.reviewability} />
424427

428+
<ChatQaPanel reviewability={data.reviewability} />
429+
425430
<SurfacePreview
426431
reviewability={data.reviewability}
427432
initialRepoFullName={initialRepoFullName}

0 commit comments

Comments
 (0)