diff --git a/apps/loopover-ui/src/routes/app.runs.test.tsx b/apps/loopover-ui/src/routes/app.runs.test.tsx index d01ae0ec56..8776b0e454 100644 --- a/apps/loopover-ui/src/routes/app.runs.test.tsx +++ b/apps/loopover-ui/src/routes/app.runs.test.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { success, error } = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); vi.mock("sonner", () => ({ toast: { success, error } })); -import { DrawerSurface } from "./app.runs"; +import { DrawerSurface, RunsFilterBar } from "./app.runs"; const run = { id: "run_1", @@ -113,3 +113,57 @@ describe("run drawer Inputs copy button", () => { expect(success).toHaveBeenCalledWith("Permalink copied", expect.anything()); }); }); + +// #6818: the filter bar previously had NO reset affordance — a "Clear filters" button existed only inside the +// `filtered.length === 0` empty state, so an operator whose filters still matched at least one run had no way to +// clear them without hand-editing the URL. These lock in the persistent control in the bar itself. +describe("Agent Runs filter bar persistent reset (#6818)", () => { + const noop = () => undefined; + const renderBar = (over: Partial[0]> = {}) => + render( + , + ); + + it("renders the reset control in the bar itself, not only in the zero-results empty state", () => { + renderBar(); + expect(screen.getByRole("button", { name: "Reset filters" })).toBeTruthy(); + }); + + it("disables the reset while every filter is still at its default", () => { + renderBar(); + expect( + (screen.getByRole("button", { name: "Reset filters" }) as HTMLButtonElement).disabled, + ).toBe(true); + }); + + it("enables the reset as soon as any filter is non-default", () => { + for (const active of [ + { status: "ready" as const }, + { kind: "plan-next-work" as const }, + { q: "acme" }, + ]) { + const { unmount } = renderBar({ hasActiveFilters: true, ...active }); + expect( + (screen.getByRole("button", { name: "Reset filters" }) as HTMLButtonElement).disabled, + ).toBe(false); + unmount(); + } + }); + + it("clears every filter through one shared handler when clicked", () => { + const onReset = vi.fn(); + renderBar({ hasActiveFilters: true, status: "ready", onReset }); + fireEvent.click(screen.getByRole("button", { name: "Reset filters" })); + expect(onReset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/loopover-ui/src/routes/app.runs.tsx b/apps/loopover-ui/src/routes/app.runs.tsx index 00eefe6295..5fe30e5ca3 100644 --- a/apps/loopover-ui/src/routes/app.runs.tsx +++ b/apps/loopover-ui/src/routes/app.runs.tsx @@ -25,7 +25,7 @@ import { } from "@/components/site/control-primitives"; import { useApiResource } from "@/lib/api/use-api-resource"; import { useSession } from "@/lib/api/session"; -import { EmptyState, StateBoundary } from "@/components/site/state-views"; +import { EmptyState, StateActionButton, StateBoundary } from "@/components/site/state-views"; import { RefreshMeta } from "@/components/site/refresh-meta"; import { Skeleton } from "@/components/ui/skeleton"; import { useLocalStorage } from "@/lib/use-local-storage"; @@ -112,6 +112,77 @@ export const Route = createFileRoute("/app/runs")({ component: AgentRuns, }); +/** + * The Agent Runs filter bar: Status/Kind chips, the search box, and the persistent "Reset filters" control + * (#6818). Before that control existed, the ONLY way to clear filters was the button inside the zero-results + * empty state — unreachable while the active filters still matched at least one run. The reset is always + * rendered here (mirroring `AuditFilters`' always-present Reset in components/site/audit-feed.tsx) and enabled + * only once some filter is away from its default. + * + * Exported and fully prop-driven — like `DrawerSurface` — so the filter/reset behavior is unit-testable without + * mounting the routed page (and its router/session/API context). + */ +export function RunsFilterBar({ + status, + kind, + q, + hasActiveFilters, + onStatusChange, + onKindChange, + onQChange, + onReset, +}: { + status: StatusFilter; + kind: KindFilter; + q: string; + hasActiveFilters: boolean; + onStatusChange: (value: StatusFilter) => void; + onKindChange: (value: KindFilter) => void; + onQChange: (value: string) => void; + onReset: () => void; +}) { + return ( +
+
+
+ + Status +
+
+ {STATUS_FILTERS.map((s) => ( + onStatusChange(s)}> + {s} + + ))} +
+ +
+ Kind +
+
+ {KIND_FILTERS.map((k) => ( + onKindChange(k)}> + {k} + + ))} +
+
+ + onQChange(e.target.value)} + placeholder="Search runs…" + className="w-40 border-0 bg-transparent py-1 text-token-sm outline-none placeholder:text-muted-foreground" + /> +
+ + Reset filters + +
+
+ ); +} + function AgentRuns() { const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); @@ -174,6 +245,27 @@ function AgentRuns() { replace: true, }); + /** Any filter away from its default ("all"/"all"/empty search) — drives the filter bar's persistent Reset. */ + const hasActiveFilters = status !== "all" || kind !== "all" || q !== ""; + + /** Clear every filter in a single navigation (#6818). Shared by the filter bar's persistent Reset control and + * the zero-results empty state's "Clear filters" button, so the two can never drift apart — mirroring + * AuditFilters' `resetFilters` in components/site/audit-feed.tsx. */ + const resetFilters = () => { + navigate({ + search: (p: z.infer) => ({ + ...p, + status: undefined, + kind: undefined, + q: undefined, + }), + replace: true, + }); + toast("Filters cleared", { + description: "Showing all available agent runs again.", + }); + }; + const filtered = useMemo(() => { const term = q.trim().toLowerCase(); return runs.filter((r) => { @@ -253,41 +345,16 @@ function AgentRuns() { loadingSkeleton={} >
-
-
-
- - Status -
-
- {STATUS_FILTERS.map((s) => ( - setStatus(s)}> - {s} - - ))} -
- -
- Kind -
-
- {KIND_FILTERS.map((k) => ( - setKind(k)}> - {k} - - ))} -
-
- - setQ(e.target.value)} - placeholder="Search runs…" - className="w-40 border-0 bg-transparent py-1 text-token-sm outline-none placeholder:text-muted-foreground" - /> -
-
-
+ { - setStatus("all"); - setKind("all"); - setQ(""); - toast("Filters cleared", { - description: "Showing all available agent runs again.", - }); - }} + onClick={resetFilters} className="inline-flex min-w-0 items-center justify-center rounded-token border border-border bg-transparent px-3 py-1.5 text-center text-token-xs font-medium text-foreground transition-all duration-150 hover:bg-accent focus-ring motion-reduce:transition-none motion-reduce:active:scale-100 active:scale-[0.98]" > Clear filters