refactor(web): move issue list filters to URL search params

Quentin Gliech and Claude Opus 4.6 (1M context) created

replace useState-based filter state with URL search params on the
/$repo/issues route so that:
- open/closed toggle is a proper Link (preloadable, shareable)
- filter state is preserved in the URL and works with back/forward
- pagination cursor is in the URL via `after` param
- the query string `q` holds the full structured query

add search={{ q, after }} to all links targeting /$repo/issues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/src/components/layout/Header.tsx  |   6 
webui2/src/routes/$repo/issues/$id.tsx   |   1 
webui2/src/routes/$repo/issues/index.tsx | 183 ++++++++++---------------
webui2/src/routes/$repo/issues/new.tsx   |   8 
webui2/src/routes/$repo/user/$id.tsx     |   1 
5 files changed, 88 insertions(+), 111 deletions(-)

Detailed changes

webui2/src/components/layout/Header.tsx πŸ”—

@@ -61,7 +61,11 @@ export function Header() {
             >
               Code
             </NavLink>
-            <NavLink to="/$repo/issues" params={{ repo: effectiveRepo }}>
+            <NavLink
+              to="/$repo/issues"
+              params={{ repo: effectiveRepo }}
+              search={{ q: "status:open", after: "" }}
+            >
               Issues
             </NavLink>
           </nav>

webui2/src/routes/$repo/issues/$id.tsx πŸ”—

@@ -42,6 +42,7 @@ function RouteComponent() {
       <Link
         to="/$repo/issues"
         params={{ repo: repo! }}
+        search={{ q: "status:open", after: "" }}
         className="text-muted-foreground hover:text-foreground mb-4 flex items-center gap-1.5 text-sm"
       >
         <ArrowLeft className="size-3.5" />

webui2/src/routes/$repo/issues/index.tsx πŸ”—

@@ -1,6 +1,6 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
 import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
-import { useState, useEffect } from "react";
+import { useState } from "react";
 
 import { useBugListQuery } from "@/__generated__/graphql";
 import { BugRow } from "@/components/bugs/BugRow";
@@ -8,49 +8,52 @@ import { IssueFilters } from "@/components/bugs/IssueFilters";
 import type { SortValue } from "@/components/bugs/IssueFilters";
 import { QueryInput } from "@/components/bugs/QueryInput";
 import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
 import { Skeleton } from "@/components/ui/skeleton";
 import { useRepo } from "@/lib/repo";
 import { cn } from "@/lib/utils";
 
+type IssuesSearch = {
+  q: string;
+  after: string;
+};
+
 export const Route = createFileRoute("/$repo/issues/")({
   component: RouteComponent,
+  validateSearch: (search: Record<string, unknown>): IssuesSearch => ({
+    q: (search.q as string) ?? "status:open",
+    after: (search.after as string) ?? "",
+  }),
 });
 
 const PAGE_SIZE = 25;
 
 type StatusFilter = "open" | "closed";
 
-// Issue list page (/:repo/issues). Search bar with structured query, open/closed toggle,
-// label+author filter dropdowns, and paginated bug rows.
 function RouteComponent() {
   const repo = useRepo();
-  const [statusFilter, setStatusFilter] = useState<StatusFilter>("open");
-  const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
-  // humanId β€” uniquely identifies the selection for the dropdown UI
-  const [selectedAuthorId, setSelectedAuthorId] = useState<string | null>(null);
-  // query value (login/name) β€” what goes into author:... in the query string
-  const [selectedAuthorQuery, setSelectedAuthorQuery] = useState<string | null>(null);
-  const [freeText, setFreeText] = useState("");
-  const [sort, setSort] = useState<SortValue>("creation-desc");
-  const [draft, setDraft] = useState(() => buildQueryString("open", [], null, "", "creation-desc"));
-
-  // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
-  // cursors[0] is always undefined (first page needs no cursor).
-  const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]);
-  const page = cursors.length - 1; // 0-indexed current page
-
-  // Build separate query strings: two for the always-visible counts (open/closed),
-  // one for the paginated list. The count queries share all filters except status.
-  const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, freeText);
+  const navigate = useNavigate({ from: "/$repo/issues/" });
+  const { q, after } = Route.useSearch();
+
+  // Parse the URL query into structured filter state for the dropdowns
+  const parsed = parseQueryString(q);
+  const {
+    status: statusFilter,
+    labels: selectedLabels,
+    author: selectedAuthorQuery,
+    sort,
+  } = parsed;
+  // We don't have the humanId from URL β€” the dropdown will match by query value
+  const selectedAuthorId: string | null = null;
+
+  // Draft is the text input value β€” starts from URL, only committed on submit
+  const [draft, setDraft] = useState(q);
+
+  // Build the three query variants from the URL state
+  const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, parsed.freeText);
   const openQuery = `status:open ${baseQuery}`.trim();
   const closedQuery = `status:closed ${baseQuery}`.trim();
-  const listQuery = buildQueryString(
-    statusFilter,
-    selectedLabels,
-    selectedAuthorQuery,
-    freeText,
-    sort,
-  );
+  const listQuery = q;
 
   const { data, loading, error } = useBugListQuery({
     variables: {
@@ -59,7 +62,7 @@ function RouteComponent() {
       closedQuery,
       listQuery,
       first: PAGE_SIZE,
-      after: cursors[page],
+      after: after || undefined,
     },
   });
 
@@ -69,49 +72,34 @@ function RouteComponent() {
   const totalCount = bugs?.totalCount ?? 0;
   const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
   const hasNext = bugs?.pageInfo.hasNextPage ?? false;
-  const hasPrev = page > 0;
+  const hasPrev = !!after;
 
-  // Reset to page 1 whenever the list query changes.
-  useEffect(() => {
-    setCursors([undefined]);
-  }, [listQuery]);
+  // Navigate to new search params (resets pagination)
+  function setSearch(newQ: string) {
+    setDraft(newQ);
+    void navigate({ search: { q: newQ, after: "" } });
+  }
 
-  // Apply all filters at once, keeping draft in sync with the structured state.
+  // Apply structured filters β†’ build query string β†’ navigate
   function applyFilters(
     status: StatusFilter,
     labels: string[],
-    authorId: string | null,
     authorQuery: string | null,
     text: string,
     sortVal: SortValue = sort,
   ) {
-    setStatusFilter(status);
-    setSelectedLabels(labels);
-    setSelectedAuthorId(authorId);
-    setSelectedAuthorQuery(authorQuery);
-    setFreeText(text);
-    setSort(sortVal);
-    setDraft(buildQueryString(status, labels, authorQuery, text, sortVal));
+    setSearch(buildQueryString(status, labels, authorQuery, text, sortVal));
   }
 
-  // Parse the draft text box on submit so manual edits update the dropdowns too.
-  // When parsing we don't know the humanId β€” clear it so the dropdown resets.
-  // Called both from the <form> onSubmit (with event) and from QueryInput's
-  // Enter-key handler (without event), so e is optional.
+  // Parse the draft text box on submit
   function handleSearch(e?: React.FormEvent) {
     e?.preventDefault();
-    const p = parseQueryString(draft);
-    applyFilters(p.status, p.labels, null, p.author, p.freeText, p.sort);
-  }
-
-  function goNext() {
-    const endCursor = bugs?.pageInfo.endCursor;
-    if (!endCursor) return;
-    setCursors((prev) => [...prev, endCursor]);
+    setSearch(draft);
   }
 
-  function goPrev() {
-    setCursors((prev) => prev.slice(0, -1));
+  // Build query string with toggled status
+  function queryWithStatus(status: StatusFilter): string {
+    return buildQueryString(status, selectedLabels, selectedAuthorQuery, parsed.freeText, sort);
   }
 
   return (
@@ -132,16 +120,10 @@ function RouteComponent() {
         {/* Open / Closed toggle + filter dropdowns */}
         <div className="border-border flex items-center gap-2 overflow-x-auto border-b px-4 py-2">
           <div className="flex shrink-0 items-center gap-1">
-            <button
-              onClick={() =>
-                applyFilters(
-                  "open",
-                  selectedLabels,
-                  selectedAuthorId,
-                  selectedAuthorQuery,
-                  freeText,
-                )
-              }
+            <Link
+              to="/$repo/issues"
+              params={{ repo: repo! }}
+              search={{ q: queryWithStatus("open"), after: "" }}
               className={cn(
                 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
                 statusFilter === "open"
@@ -159,18 +141,12 @@ function RouteComponent() {
               <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
                 {openCount}
               </span>
-            </button>
+            </Link>
 
-            <button
-              onClick={() =>
-                applyFilters(
-                  "closed",
-                  selectedLabels,
-                  selectedAuthorId,
-                  selectedAuthorQuery,
-                  freeText,
-                )
-              }
+            <Link
+              to="/$repo/issues"
+              params={{ repo: repo! }}
+              search={{ q: queryWithStatus("closed"), after: "" }}
               className={cn(
                 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
                 statusFilter === "closed"
@@ -188,30 +164,23 @@ function RouteComponent() {
               <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
                 {closedCount}
               </span>
-            </button>
+            </Link>
           </div>
 
           <div className="ml-auto">
             <IssueFilters
               selectedLabels={selectedLabels}
               onLabelsChange={(labels) =>
-                applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)
+                applyFilters(statusFilter, labels, selectedAuthorQuery, parsed.freeText)
               }
               selectedAuthorId={selectedAuthorId}
-              onAuthorChange={(id, qv) =>
-                applyFilters(statusFilter, selectedLabels, id, qv, freeText)
+              onAuthorChange={(_id, qv) =>
+                applyFilters(statusFilter, selectedLabels, qv, parsed.freeText)
               }
               recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
               sort={sort}
               onSortChange={(s) =>
-                applyFilters(
-                  statusFilter,
-                  selectedLabels,
-                  selectedAuthorId,
-                  selectedAuthorQuery,
-                  freeText,
-                  s,
-                )
+                applyFilters(statusFilter, selectedLabels, selectedAuthorQuery, parsed.freeText, s)
               }
             />
           </div>
@@ -249,9 +218,8 @@ function RouteComponent() {
                 applyFilters(
                   statusFilter,
                   [...selectedLabels, name],
-                  selectedAuthorId,
                   selectedAuthorQuery,
-                  freeText,
+                  parsed.freeText,
                 );
               }
             }}
@@ -260,29 +228,33 @@ function RouteComponent() {
 
         {totalPages > 1 && (
           <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
-            <Button
+            <ButtonLink
+              to="/$repo/issues"
+              params={{ repo: repo! }}
+              search={{ q, after: "" }}
               variant="ghost"
               size="sm"
-              onClick={goPrev}
               disabled={!hasPrev || loading}
               className="text-muted-foreground gap-1"
             >
               <ChevronLeft className="size-4" />
               Previous
-            </Button>
+            </ButtonLink>
             <span className="text-muted-foreground text-sm">
-              Page {page + 1} of {totalPages}
+              Page {after ? 2 : 1} of {totalPages}
             </span>
-            <Button
+            <ButtonLink
+              to="/$repo/issues"
+              params={{ repo: repo! }}
+              search={{ q, after: bugs?.pageInfo.endCursor ?? "" }}
               variant="ghost"
               size="sm"
-              onClick={goNext}
               disabled={!hasNext || loading}
               className="text-muted-foreground gap-1"
             >
               Next
               <ChevronRight className="size-4" />
-            </Button>
+            </ButtonLink>
           </div>
         )}
       </div>
@@ -305,8 +277,6 @@ function buildBaseQuery(labels: string[], author: string | null, freeText: strin
 }
 
 // Build the structured query string sent to the GraphQL allBugs(query:) argument.
-// Multi-word label/author values are wrapped in quotes so the backend parser
-// treats them as a single token (e.g. label:"my label" vs label:my label).
 function buildQueryString(
   status: StatusFilter,
   labels: string[],
@@ -321,9 +291,7 @@ function buildQueryString(
   return parts.join(" ");
 }
 
-// Tokenize a query string, keeping quoted spans (e.g. author:"RenΓ© Descartes")
-// as single tokens. Quotes are preserved in the output so callers can strip them
-// when extracting values.
+// Tokenize a query string, keeping quoted spans as single tokens.
 function tokenizeQuery(input: string): string[] {
   const tokens: string[] = [];
   let current = "";
@@ -343,10 +311,7 @@ function tokenizeQuery(input: string): string[] {
   return tokens;
 }
 
-// Parse a free-text query string back into structured filter state so that
-// manual edits to the search box are reflected in the dropdown UI on submit.
-// Strips surrounding quotes from values (they're an encoding detail, not part
-// of the value itself). Unknown tokens fall through to freeText.
+// Parse a query string back into structured filter state.
 const VALID_SORTS = new Set<SortValue>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
 
 function parseQueryString(input: string): {

webui2/src/routes/$repo/issues/new.tsx πŸ”—

@@ -42,6 +42,7 @@ function RouteComponent() {
       <Link
         to="/$repo/issues"
         params={{ repo: repo! }}
+        search={{ q: "status:open", after: "" }}
         className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
       >
         <ArrowLeft className="size-3.5" />
@@ -109,7 +110,12 @@ function RouteComponent() {
         )}
 
         <div className="flex justify-end gap-2">
-          <ButtonLink to="/$repo/issues" params={{ repo: repo! }} variant="ghost">
+          <ButtonLink
+            to="/$repo/issues"
+            params={{ repo: repo! }}
+            search={{ q: "status:open", after: "" }}
+            variant="ghost"
+          >
             Cancel
           </ButtonLink>
           <Button type="submit" disabled={!title.trim() || loading}>

webui2/src/routes/$repo/user/$id.tsx πŸ”—

@@ -100,6 +100,7 @@ function RouteComponent() {
       <Link
         to="/$repo/issues"
         params={{ repo: repo! }}
+        search={{ q: "status:open", after: "" }}
         className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
       >
         <ArrowLeft className="size-3.5" />