diff --git a/services/hackbot-api/app/routers/runs.py b/services/hackbot-api/app/routers/runs.py
index 083f5b0f7f..e89e57d400 100644
--- a/services/hackbot-api/app/routers/runs.py
+++ b/services/hackbot-api/app/routers/runs.py
@@ -109,9 +109,13 @@ async def create_run(
@router.get("/runs", response_model=list[RunDoc])
async def list_runs(
limit: int = Query(default=50, ge=1, le=100),
+ agent: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
) -> list[RunDoc]:
- result = await db.execute(select(Run).order_by(Run.created_at.desc()).limit(limit))
+ query = select(Run).order_by(Run.created_at.desc())
+ if agent is not None:
+ query = query.where(Run.agent == agent)
+ result = await db.execute(query.limit(limit))
return [RunDoc.model_validate(r) for r in result.scalars()]
diff --git a/services/hackbot-ui/README.md b/services/hackbot-ui/README.md
index c14c2d4828..519ca1ea1c 100644
--- a/services/hackbot-ui/README.md
+++ b/services/hackbot-ui/README.md
@@ -12,8 +12,9 @@ It lets you:
artifacts written to the results bucket, each a **download link** (the browser
is redirected to a short-lived signed GCS URL).
-> This is a demo surface, not a system of record. It has no list-runs endpoint
-> upstream, so the browser remembers the runs you triggered in `localStorage`.
+> This is a demo surface, not a system of record. The recent-runs panel is
+> sourced entirely from the upstream `GET /runs` endpoint and filtered to the
+> agent selected in the trigger form (carried in the `?agent=` URL param).
## Architecture
diff --git a/services/hackbot-ui/app/api/runs/route.ts b/services/hackbot-ui/app/api/runs/route.ts
index 90ed1b6c06..97f781d61e 100644
--- a/services/hackbot-ui/app/api/runs/route.ts
+++ b/services/hackbot-ui/app/api/runs/route.ts
@@ -5,8 +5,8 @@ import { getAuthedEmail } from "@/lib/session";
export const dynamic = "force-dynamic";
-// GET /api/runs?limit=50
-// Returns the most recent runs from hackbot-api (no reconciliation).
+// GET /api/runs?limit=50&agent=bug-fix
+// Returns the most recent runs from hackbot-api, optionally filtered by agent.
export async function GET(req: NextRequest) {
if (!(await getAuthedEmail())) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -15,7 +15,8 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const raw = Number(searchParams.get("limit") ?? 50);
const limit = Number.isFinite(raw) && raw > 0 ? raw : 50;
- const runs = await listRuns(limit);
+ const agent = searchParams.get("agent") ?? undefined;
+ const runs = await listRuns(limit, agent);
return NextResponse.json(runs);
} catch (err) {
const status = err instanceof HackbotError ? err.status : 500;
diff --git a/services/hackbot-ui/app/page.tsx b/services/hackbot-ui/app/page.tsx
index bc2183bbb0..ccc3577909 100644
--- a/services/hackbot-ui/app/page.tsx
+++ b/services/hackbot-ui/app/page.tsx
@@ -15,7 +15,9 @@ export default function HomePage() {
Recent runs
-
+
+
+
>
);
diff --git a/services/hackbot-ui/components/RecentRuns.tsx b/services/hackbot-ui/components/RecentRuns.tsx
index dc29d03722..bfb7cbbeb0 100644
--- a/services/hackbot-ui/components/RecentRuns.tsx
+++ b/services/hackbot-ui/components/RecentRuns.tsx
@@ -1,27 +1,44 @@
"use client";
import Link from "next/link";
+import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
-import { isTerminal } from "@/lib/types";
-import { loadRuns, type TrackedRun, updateRunStatus } from "@/lib/store";
+import { parseAgent } from "@/lib/agents";
+import { isTerminal, type RunStatus } from "@/lib/types";
import { StatusBadge } from "./StatusBadge";
-// Polls the status of any non-terminal tracked runs so the dashboard stays live.
+// Polls while any run is non-terminal so the dashboard stays live.
const POLL_MS = 5000;
+interface RunRow {
+ run_id: string;
+ agent: string;
+ status: RunStatus;
+ // Human-readable summary of the inputs, e.g. "bug 1846789".
+ label: string;
+ created_at: string;
+}
+
function labelFromInputs(inputs: Record): string {
if (typeof inputs.bug_id === "number") return `bug ${inputs.bug_id}`;
+ if (typeof inputs.git_commit === "string") {
+ return `commit ${inputs.git_commit.slice(0, 12)}`;
+ }
+ if (typeof inputs.feature_name === "string") return inputs.feature_name;
return "inline report";
}
-async function fetchRuns(): Promise {
- const res = await fetch("/api/runs?limit=50");
+// The runs list is sourced entirely from hackbot-api (no localStorage), filtered
+// server-side to the agent selected in the trigger form (carried in `?agent=`).
+async function fetchRuns(agent: string): Promise {
+ const params = new URLSearchParams({ limit: "50", agent });
+ const res = await fetch(`/api/runs?${params.toString()}`);
if (!res.ok) return [];
const docs = (await res.json()) as Array<{
run_id: string;
agent: string;
- status: TrackedRun["status"];
+ status: RunStatus;
inputs: Record;
created_at: string;
}>;
@@ -34,53 +51,33 @@ async function fetchRuns(): Promise {
}));
}
-function mergeRuns(
- apiRuns: TrackedRun[],
- localRuns: TrackedRun[]
-): TrackedRun[] {
- const seen = new Set(apiRuns.map((r) => r.run_id));
- const extras = localRuns.filter((r) => !seen.has(r.run_id));
- return [...extras, ...apiRuns];
-}
-
export function RecentRuns() {
- const [runs, setRuns] = useState(null);
+ const params = useSearchParams();
+ const agent = parseAgent(params.get("agent"));
+ const [runs, setRuns] = useState(null);
useEffect(() => {
- fetchRuns().then((apiRuns) => {
- setRuns(mergeRuns(apiRuns, loadRuns()));
+ let cancelled = false;
+ setRuns(null);
+ fetchRuns(agent).then((rows) => {
+ if (!cancelled) setRuns(rows);
});
- }, []);
+ return () => {
+ cancelled = true;
+ };
+ }, [agent]);
useEffect(() => {
if (!runs) return;
- const active = runs.filter((r) => !isTerminal(r.status));
- if (active.length === 0) return;
+ if (!runs.some((r) => !isTerminal(r.status))) return;
const timer = setInterval(async () => {
- let changed = false;
- for (const r of active) {
- try {
- const res = await fetch(`/api/runs/${r.run_id}`);
- if (!res.ok) continue;
- const doc = await res.json();
- if (doc.status && doc.status !== r.status) {
- updateRunStatus(r.run_id, doc.status);
- changed = true;
- }
- } catch {
- // transient; try again next tick
- }
- }
- if (changed) {
- fetchRuns().then((apiRuns) => {
- setRuns(mergeRuns(apiRuns, loadRuns()));
- });
- }
+ const rows = await fetchRuns(agent);
+ setRuns(rows);
}, POLL_MS);
return () => clearInterval(timer);
- }, [runs]);
+ }, [runs, agent]);
if (runs === null) {
return
Loading…
;
@@ -89,7 +86,7 @@ export function RecentRuns() {
if (runs.length === 0) {
return (
- No runs yet. Use the form above to trigger an agent.
+ No runs yet for the {agent} agent. Use the form above to trigger one.
);
}
diff --git a/services/hackbot-ui/components/RunDetail.tsx b/services/hackbot-ui/components/RunDetail.tsx
index 1c4466b518..2f5b98be94 100644
--- a/services/hackbot-ui/components/RunDetail.tsx
+++ b/services/hackbot-ui/components/RunDetail.tsx
@@ -3,7 +3,6 @@
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
-import { updateRunStatus } from "@/lib/store";
import { isTerminal, type RunAction, type RunDoc } from "@/lib/types";
import { FindingsView } from "./FindingsView";
import { Markdown } from "./Markdown";
@@ -63,7 +62,6 @@ export function RunDetail({ runId }: { runId: string }) {
const doc = body as RunDoc;
setRun(doc);
setError(null);
- updateRunStatus(runId, doc.status);
if (isTerminal(doc.status)) {
setPolling(false);
return false;
diff --git a/services/hackbot-ui/components/TriggerForm.tsx b/services/hackbot-ui/components/TriggerForm.tsx
index 8e63a06c71..bed4e5b19f 100644
--- a/services/hackbot-ui/components/TriggerForm.tsx
+++ b/services/hackbot-ui/components/TriggerForm.tsx
@@ -3,25 +3,9 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
-import { saveRun } from "@/lib/store";
+import { AGENTS, type AgentValue, parseAgent } from "@/lib/agents";
import type { RunRef } from "@/lib/types";
-const AGENTS = [
- { value: "bug-fix", label: "bug-fix" },
- { value: "autowebcompat-repro", label: "autowebcompat-repro" },
- { value: "build-repair", label: "build-repair" },
- { value: "frontend-triage", label: "frontend-triage" },
- { value: "test-plan-generator", label: "test-plan-generator" },
-] as const;
-
-type AgentValue = (typeof AGENTS)[number]["value"];
-
-function parseAgent(value: string | null): AgentValue {
- return AGENTS.some((a) => a.value === value)
- ? (value as AgentValue)
- : "bug-fix";
-}
-
export function TriggerForm() {
const router = useRouter();
const params = useSearchParams();
@@ -54,6 +38,16 @@ export function TriggerForm() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
+ // Reflect the selected agent in the URL so the recent-runs panel can filter
+ // to it without any shared client state.
+ function selectAgent(value: AgentValue) {
+ setAgent(value);
+ setError(null);
+ const next = new URLSearchParams(params.toString());
+ next.set("agent", value);
+ router.replace(`?${next.toString()}`, { scroll: false });
+ }
+
const isReproAgent = agent === "autowebcompat-repro";
const isBuildRepairAgent = agent === "build-repair";
const isTestPlanAgent = agent === "test-plan-generator";
@@ -150,20 +144,6 @@ export function TriggerForm() {
throw new Error(body?.error ?? `Request failed (${res.status})`);
}
const run = body as RunRef;
- const label = isBuildRepairAgent
- ? `commit ${gitCommit.trim().slice(0, 12)}`
- : isTestPlanAgent
- ? featureName.trim()
- : hasBugId
- ? `bug ${parsedBugId}`
- : "inline report";
- saveRun({
- run_id: run.run_id,
- agent: run.agent,
- status: run.status,
- label,
- created_at: new Date().toISOString(),
- });
router.push(`/runs/${run.run_id}`);
} catch (err) {
setError((err as Error).message);
@@ -180,10 +160,7 @@ export function TriggerForm() {