Skip to content

Commit bcda0a7

Browse files
feat(ui): persistent 'Reset filters' control in the Agent Runs filter bar (#6818)
The Agent Runs filter bar (Status/Kind chips + search) had no reset affordance while filters were active — a 'Clear filters' button existed ONLY inside the `filtered.length === 0` empty state, which is unreachable whenever the active filters still match at least one run. An operator narrowing to e.g. status=ready then had no way back to all runs short of editing the URL. Add a persistent 'Reset filters' control in the bar itself, always rendered and enabled only once some filter is away from its default — mirroring the always-present Reset in AuditFilters (components/site/audit-feed.tsx). Both it and the empty-state 'Clear filters' button now call one shared `resetFilters` handler (clears status/kind/search in a single navigation + toasts), so the two paths can't drift. The filter bar is extracted into an exported, prop-driven `RunsFilterBar` (like the existing `DrawerSurface`) so the reset behavior is unit-testable without mounting the routed page. Adds regression tests: the reset renders in the bar (not only the empty state), is disabled at defaults, enables on any non-default filter, and clears through the shared handler. Closes #6818
1 parent 363a8b5 commit bcda0a7

2 files changed

Lines changed: 159 additions & 45 deletions

File tree

apps/loopover-ui/src/routes/app.runs.test.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
55
const { success, error } = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
66
vi.mock("sonner", () => ({ toast: { success, error } }));
77

8-
import { DrawerSurface } from "./app.runs";
8+
import { DrawerSurface, RunsFilterBar } from "./app.runs";
99

1010
const run = {
1111
id: "run_1",
@@ -113,3 +113,57 @@ describe("run drawer Inputs copy button", () => {
113113
expect(success).toHaveBeenCalledWith("Permalink copied", expect.anything());
114114
});
115115
});
116+
117+
// #6818: the filter bar previously had NO reset affordance — a "Clear filters" button existed only inside the
118+
// `filtered.length === 0` empty state, so an operator whose filters still matched at least one run had no way to
119+
// clear them without hand-editing the URL. These lock in the persistent control in the bar itself.
120+
describe("Agent Runs filter bar persistent reset (#6818)", () => {
121+
const noop = () => undefined;
122+
const renderBar = (over: Partial<Parameters<typeof RunsFilterBar>[0]> = {}) =>
123+
render(
124+
<RunsFilterBar
125+
status="all"
126+
kind="all"
127+
q=""
128+
hasActiveFilters={false}
129+
onStatusChange={noop}
130+
onKindChange={noop}
131+
onQChange={noop}
132+
onReset={noop}
133+
{...over}
134+
/>,
135+
);
136+
137+
it("renders the reset control in the bar itself, not only in the zero-results empty state", () => {
138+
renderBar();
139+
expect(screen.getByRole("button", { name: "Reset filters" })).toBeTruthy();
140+
});
141+
142+
it("disables the reset while every filter is still at its default", () => {
143+
renderBar();
144+
expect(
145+
(screen.getByRole("button", { name: "Reset filters" }) as HTMLButtonElement).disabled,
146+
).toBe(true);
147+
});
148+
149+
it("enables the reset as soon as any filter is non-default", () => {
150+
for (const active of [
151+
{ status: "ready" as const },
152+
{ kind: "plan-next-work" as const },
153+
{ q: "acme" },
154+
]) {
155+
const { unmount } = renderBar({ hasActiveFilters: true, ...active });
156+
expect(
157+
(screen.getByRole("button", { name: "Reset filters" }) as HTMLButtonElement).disabled,
158+
).toBe(false);
159+
unmount();
160+
}
161+
});
162+
163+
it("clears every filter through one shared handler when clicked", () => {
164+
const onReset = vi.fn();
165+
renderBar({ hasActiveFilters: true, status: "ready", onReset });
166+
fireEvent.click(screen.getByRole("button", { name: "Reset filters" }));
167+
expect(onReset).toHaveBeenCalledTimes(1);
168+
});
169+
});

apps/loopover-ui/src/routes/app.runs.tsx

Lines changed: 104 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
} from "@/components/site/control-primitives";
2626
import { useApiResource } from "@/lib/api/use-api-resource";
2727
import { useSession } from "@/lib/api/session";
28-
import { EmptyState, StateBoundary } from "@/components/site/state-views";
28+
import { EmptyState, StateActionButton, StateBoundary } from "@/components/site/state-views";
2929
import { RefreshMeta } from "@/components/site/refresh-meta";
3030
import { Skeleton } from "@/components/ui/skeleton";
3131
import { useLocalStorage } from "@/lib/use-local-storage";
@@ -112,6 +112,77 @@ export const Route = createFileRoute("/app/runs")({
112112
component: AgentRuns,
113113
});
114114

115+
/**
116+
* The Agent Runs filter bar: Status/Kind chips, the search box, and the persistent "Reset filters" control
117+
* (#6818). Before that control existed, the ONLY way to clear filters was the button inside the zero-results
118+
* empty state — unreachable while the active filters still matched at least one run. The reset is always
119+
* rendered here (mirroring `AuditFilters`' always-present Reset in components/site/audit-feed.tsx) and enabled
120+
* only once some filter is away from its default.
121+
*
122+
* Exported and fully prop-driven — like `DrawerSurface` — so the filter/reset behavior is unit-testable without
123+
* mounting the routed page (and its router/session/API context).
124+
*/
125+
export function RunsFilterBar({
126+
status,
127+
kind,
128+
q,
129+
hasActiveFilters,
130+
onStatusChange,
131+
onKindChange,
132+
onQChange,
133+
onReset,
134+
}: {
135+
status: StatusFilter;
136+
kind: KindFilter;
137+
q: string;
138+
hasActiveFilters: boolean;
139+
onStatusChange: (value: StatusFilter) => void;
140+
onKindChange: (value: KindFilter) => void;
141+
onQChange: (value: string) => void;
142+
onReset: () => void;
143+
}) {
144+
return (
145+
<div className="rounded-token border border-border bg-transparent p-3">
146+
<div className="flex flex-wrap items-center gap-2">
147+
<div className="inline-flex items-center gap-1.5 font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
148+
<Filter className="size-3.5" />
149+
Status
150+
</div>
151+
<div className="flex flex-wrap gap-1">
152+
{STATUS_FILTERS.map((s) => (
153+
<Chip key={s} active={status === s} onClick={() => onStatusChange(s)}>
154+
{s}
155+
</Chip>
156+
))}
157+
</div>
158+
<span aria-hidden className="ml-2 hidden accent-divider-v-tall sm:inline-block" />
159+
<div className="inline-flex items-center gap-1.5 font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
160+
Kind
161+
</div>
162+
<div className="flex flex-wrap gap-1">
163+
{KIND_FILTERS.map((k) => (
164+
<Chip key={k} active={kind === k} onClick={() => onKindChange(k)}>
165+
{k}
166+
</Chip>
167+
))}
168+
</div>
169+
<div className="ml-auto inline-flex items-center gap-2 rounded-token border border-border bg-background/40 px-2">
170+
<Search className="size-3.5 text-muted-foreground" />
171+
<input
172+
value={q}
173+
onChange={(e) => onQChange(e.target.value)}
174+
placeholder="Search runs…"
175+
className="w-40 border-0 bg-transparent py-1 text-token-sm outline-none placeholder:text-muted-foreground"
176+
/>
177+
</div>
178+
<StateActionButton onClick={onReset} disabled={!hasActiveFilters}>
179+
Reset filters
180+
</StateActionButton>
181+
</div>
182+
</div>
183+
);
184+
}
185+
115186
function AgentRuns() {
116187
const search = Route.useSearch();
117188
const navigate = useNavigate({ from: Route.fullPath });
@@ -174,6 +245,27 @@ function AgentRuns() {
174245
replace: true,
175246
});
176247

248+
/** Any filter away from its default ("all"/"all"/empty search) — drives the filter bar's persistent Reset. */
249+
const hasActiveFilters = status !== "all" || kind !== "all" || q !== "";
250+
251+
/** Clear every filter in a single navigation (#6818). Shared by the filter bar's persistent Reset control and
252+
* the zero-results empty state's "Clear filters" button, so the two can never drift apart — mirroring
253+
* AuditFilters' `resetFilters` in components/site/audit-feed.tsx. */
254+
const resetFilters = () => {
255+
navigate({
256+
search: (p: z.infer<typeof searchSchema>) => ({
257+
...p,
258+
status: undefined,
259+
kind: undefined,
260+
q: undefined,
261+
}),
262+
replace: true,
263+
});
264+
toast("Filters cleared", {
265+
description: "Showing all available agent runs again.",
266+
});
267+
};
268+
177269
const filtered = useMemo(() => {
178270
const term = q.trim().toLowerCase();
179271
return runs.filter((r) => {
@@ -253,41 +345,16 @@ function AgentRuns() {
253345
loadingSkeleton={<RunsListSkeleton />}
254346
>
255347
<div className="space-y-5">
256-
<div className="rounded-token border border-border bg-transparent p-3">
257-
<div className="flex flex-wrap items-center gap-2">
258-
<div className="inline-flex items-center gap-1.5 font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
259-
<Filter className="size-3.5" />
260-
Status
261-
</div>
262-
<div className="flex flex-wrap gap-1">
263-
{STATUS_FILTERS.map((s) => (
264-
<Chip key={s} active={status === s} onClick={() => setStatus(s)}>
265-
{s}
266-
</Chip>
267-
))}
268-
</div>
269-
<span aria-hidden className="ml-2 hidden accent-divider-v-tall sm:inline-block" />
270-
<div className="inline-flex items-center gap-1.5 font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
271-
Kind
272-
</div>
273-
<div className="flex flex-wrap gap-1">
274-
{KIND_FILTERS.map((k) => (
275-
<Chip key={k} active={kind === k} onClick={() => setKind(k)}>
276-
{k}
277-
</Chip>
278-
))}
279-
</div>
280-
<div className="ml-auto inline-flex items-center gap-2 rounded-token border border-border bg-background/40 px-2">
281-
<Search className="size-3.5 text-muted-foreground" />
282-
<input
283-
value={q}
284-
onChange={(e) => setQ(e.target.value)}
285-
placeholder="Search runs…"
286-
className="w-40 border-0 bg-transparent py-1 text-token-sm outline-none placeholder:text-muted-foreground"
287-
/>
288-
</div>
289-
</div>
290-
</div>
348+
<RunsFilterBar
349+
status={status}
350+
kind={kind}
351+
q={q}
352+
hasActiveFilters={hasActiveFilters}
353+
onStatusChange={setStatus}
354+
onKindChange={setKind}
355+
onQChange={setQ}
356+
onReset={resetFilters}
357+
/>
291358

292359
<SavedViews
293360
current={{ status, kind, q }}
@@ -316,14 +383,7 @@ function AgentRuns() {
316383
action={
317384
<button
318385
type="button"
319-
onClick={() => {
320-
setStatus("all");
321-
setKind("all");
322-
setQ("");
323-
toast("Filters cleared", {
324-
description: "Showing all available agent runs again.",
325-
});
326-
}}
386+
onClick={resetFilters}
327387
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]"
328388
>
329389
Clear filters

0 commit comments

Comments
 (0)