refactor(web): move user profile filters to URL search params

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

replace useState-based statusFilter and cursor pagination with URL
search params (status, after) on the /$repo/user/$id route:

- add validateSearch with valibot schema for status + after
- preload query via loaderDeps (re-fetches on status/page change)
- use useReadQuery instead of useUserProfileQuery
- open/closed toggle is now a proper Link (preloadable, shareable)
- pagination prev/next are ButtonLinks
- add search={{ status, after }} to all Links targeting user/$id

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

Change summary

webui2/src/components/bugs/BugRow.tsx          |   1 
webui2/src/components/bugs/Timeline.tsx        |   4 
webui2/src/components/layout/Header.tsx        |   6 
webui2/src/routes/$repo/_issues/issues/$id.tsx |   2 
webui2/src/routes/$repo/_issues/user/$id.tsx   | 152 ++++++++-----------
5 files changed, 74 insertions(+), 91 deletions(-)

Detailed changes

webui2/src/components/bugs/BugRow.tsx 🔗

@@ -68,6 +68,7 @@ export function BugRow({
           <Link
             to="/$repo/user/$id"
             params={{ repo, id: author.humanId }}
+            search={{ status: "open" as const, after: "" }}
             className="hover:underline"
           >
             {author.displayName}

webui2/src/components/bugs/Timeline.tsx 🔗

@@ -107,6 +107,7 @@ function CommentItem({
           <Link
             to="/$repo/user/$id"
             params={{ repo: repo!, id: item.author.humanId }}
+            search={{ status: "open" as const, after: "" }}
             className="text-foreground font-medium hover:underline"
           >
             {item.author.displayName}
@@ -186,6 +187,7 @@ function LabelChangeItem({ item, repo }: { item: LabelChangeItem; repo: string |
         <Link
           to="/$repo/user/$id"
           params={{ repo: repo!, id: item.author.humanId }}
+          search={{ status: "open" as const, after: "" }}
           className="text-foreground font-medium hover:underline"
         >
           {item.author.displayName}
@@ -228,6 +230,7 @@ function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string
         <Link
           to="/$repo/user/$id"
           params={{ repo: repo!, id: item.author.humanId }}
+          search={{ status: "open" as const, after: "" }}
           className="text-foreground font-medium hover:underline"
         >
           {item.author.displayName}
@@ -246,6 +249,7 @@ function TitleChangeItem({ item, repo }: { item: TitleChangeItem; repo: string |
         <Link
           to="/$repo/user/$id"
           params={{ repo: repo!, id: item.author.humanId }}
+          search={{ status: "open" as const, after: "" }}
           className="text-foreground font-medium hover:underline"
         >
           {item.author.displayName}

webui2/src/components/layout/Header.tsx 🔗

@@ -80,7 +80,11 @@ export function Header() {
                 <Plus className="size-4" />
                 New issue
               </ButtonLink>
-              <Link to="/$repo/user/$id" params={{ repo: effectiveRepo, id: user.humanId }}>
+              <Link
+                to="/$repo/user/$id"
+                params={{ repo: effectiveRepo, id: user.humanId }}
+                search={{ status: "open" as const, after: "" }}
+              >
                 <Avatar className="size-7">
                   <AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />
                   <AvatarFallback className="text-xs">

webui2/src/routes/$repo/_issues/issues/$id.tsx 🔗

@@ -57,6 +57,7 @@ function RouteComponent() {
           <Link
             to="/$repo/user/$id"
             params={{ repo: repo, id: bug.author.humanId }}
+            search={{ status: "open" as const, after: "" }}
             className="text-foreground font-medium hover:underline"
           >
             {bug.author.displayName}
@@ -96,6 +97,7 @@ function RouteComponent() {
                     key={p.id}
                     to="/$repo/user/$id"
                     params={{ repo: repo, id: p.humanId }}
+                    search={{ status: "open" as const, after: "" }}
                     title={p.displayName}
                   >
                     <Avatar className="size-6">

webui2/src/routes/$repo/_issues/user/$id.tsx 🔗

@@ -1,11 +1,9 @@
 // User profile page (/user/:id). Fetches an identity by prefix and shows:
 //   - avatar, display name, login, email, humanId, protected badge
 //   - open/closed issue toggle with BOTH counts always visible
-//   - paginated list of that user's bugs (cursor-stack, same approach as BugListPage)
-//
-// The :id param is treated as a humanId prefix and passed directly to the
-// identity(prefix) and allBugs(query:"author:...") GraphQL arguments.
+//   - paginated list of that user's bugs
 
+import { useReadQuery } from "@apollo/client/react";
 import { createFileRoute, Link } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
 import {
@@ -16,81 +14,48 @@ import {
   ChevronLeft,
   ChevronRight,
 } from "lucide-react";
-import { useState } from "react";
+import * as v from "valibot";
 
-import {
-  Status,
-  useUserProfileQuery,
-  type UserProfileQuery,
-  UserProfileDocument,
-} from "@/__generated__/graphql";
+import { Status, type UserProfileQuery, UserProfileDocument } from "@/__generated__/graphql";
 import { LabelBadge } from "@/components/bugs/LabelBadge";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { BackLink } from "@/components/ui/back-link";
-import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
 import { Skeleton } from "@/components/ui/skeleton";
 import { cn } from "@/lib/utils";
 
+const profileSearchSchema = v.object({
+  status: v.fallback(v.picklist(["open", "closed"]), "open"),
+  after: v.fallback(v.string(), ""),
+});
+
+const PAGE_SIZE = 25;
+
 export const Route = createFileRoute("/$repo/_issues/user/$id")({
   component: RouteComponent,
   pendingComponent: ProfileSkeleton,
-  loader: async ({ context: { preloadQuery, ref }, params: { id } }) => {
-    // Preload the initial page (open issues, no cursor) so the router
-    // waits before transitioning. Subsequent pagination/filter changes
-    // use useQuery which hits the Apollo cache or fetches fresh.
+  validateSearch: (search) => v.parse(profileSearchSchema, search),
+  loaderDeps: ({ search: { status, after } }) => ({ status, after }),
+  loader: async ({ context: { preloadQuery, ref }, params: { id }, deps: { status, after } }) => {
     const profileRef = preloadQuery<UserProfileQuery>(UserProfileDocument, {
       variables: {
         ref,
         prefix: id,
         openQuery: `author:${id} status:open`,
         closedQuery: `author:${id} status:closed`,
-        listQuery: `author:${id} status:open`,
-        after: undefined,
+        listQuery: `author:${id} status:${status}`,
+        after: after || undefined,
       },
     });
     return { profileRef: await preloadQuery.toPromise(profileRef) };
   },
 });
 
-const PAGE_SIZE = 25;
-
 function RouteComponent() {
   const { id, repo } = Route.useParams();
-  const { ref } = Route.useRouteContext();
-  const [statusFilter, setStatusFilter] = useState<"open" | "closed">("open");
-
-  // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
-  // Resetting to [undefined] returns to page 1. Shared pattern with BugListPage.
-  const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]);
-  const page = cursors.length - 1;
-
-  // Three allBugs aliases in one round-trip:
-  //   openCount / closedCount — always fetched so both badge numbers are visible
-  //   bugs — paginated list for the selected tab
-  const { data, loading, error } = useUserProfileQuery({
-    variables: {
-      ref,
-      prefix: id,
-      openQuery: `author:${id} status:open`,
-      closedQuery: `author:${id} status:closed`,
-      listQuery: `author:${id} status:${statusFilter}`,
-      after: cursors[page],
-    },
-  });
-
-  function switchStatus(next: "open" | "closed") {
-    if (next === statusFilter) return;
-    setStatusFilter(next);
-    setCursors([undefined]); // reset to page 1 on tab change
-  }
-
-  if (error) {
-    return (
-      <div className="text-destructive py-16 text-center text-sm">
-        Failed to load profile: {error.message}
-      </div>
-    );
-  }
+  const { status: statusFilter, after } = Route.useSearch();
+  const { profileRef } = Route.useLoaderData();
+  const { data } = useReadQuery(profileRef);
 
   const identity = data?.repository?.identity;
   if (!identity) {
@@ -103,16 +68,7 @@ function RouteComponent() {
   const bugs = data?.repository?.bugs;
   const totalPages = Math.max(1, Math.ceil((bugs?.totalCount ?? 0) / PAGE_SIZE));
   const hasNext = bugs?.pageInfo.hasNextPage ?? false;
-  const hasPrev = page > 0;
-
-  function goNext() {
-    const cursor = bugs?.pageInfo.endCursor;
-    if (cursor) setCursors((prev) => [...prev, cursor]);
-  }
-
-  function goPrev() {
-    setCursors((prev) => prev.slice(0, -1));
-  }
+  const hasPrev = !!after;
 
   return (
     <div>
@@ -132,7 +88,6 @@ function RouteComponent() {
         <div className="pt-1">
           <div className="flex items-center gap-2">
             <h1 className="text-xl font-semibold">{identity.displayName}</h1>
-            {/* isProtected means this identity has been cryptographically signed */}
             {identity.isProtected && (
               <span title="Protected identity">
                 <ShieldCheck className="text-muted-foreground size-4" />
@@ -145,7 +100,6 @@ function RouteComponent() {
             <p className="font-mono text-xs">#{identity.humanId}</p>
           </div>
 
-          {/* Aggregate stats — always visible, independent of selected tab */}
           <div className="mt-3 flex items-center gap-4 text-sm">
             <span className="text-muted-foreground flex items-center gap-1">
               <CircleDot className="size-3.5 text-green-600 dark:text-green-400" />
@@ -161,10 +115,12 @@ function RouteComponent() {
 
       {/* ── Issue list ─────────────────────────────────────────────────── */}
       <div className="border-border rounded-md border">
-        {/* Open / Closed toggle — mirrors BugListPage style */}
+        {/* Open / Closed toggle */}
         <div className="border-border flex items-center gap-1 border-b px-4 py-2">
-          <button
-            onClick={() => switchStatus("open")}
+          <Link
+            to="/$repo/user/$id"
+            params={{ repo, id }}
+            search={{ status: "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"
@@ -182,10 +138,12 @@ function RouteComponent() {
             <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
               {openCount}
             </span>
-          </button>
+          </Link>
 
-          <button
-            onClick={() => switchStatus("closed")}
+          <Link
+            to="/$repo/user/$id"
+            params={{ repo, id }}
+            search={{ status: "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"
@@ -203,7 +161,7 @@ function RouteComponent() {
             <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
               {closedCount}
             </span>
-          </button>
+          </Link>
         </div>
 
         {bugs?.nodes.length === 0 && (
@@ -232,7 +190,7 @@ function RouteComponent() {
                 <div className="flex flex-wrap items-baseline gap-2">
                   <Link
                     to="/$repo/issues/$id"
-                    params={{ repo: repo, id: bug.humanId }}
+                    params={{ repo, id: bug.humanId }}
                     className="text-foreground hover:text-primary font-medium hover:underline"
                   >
                     {bug.title}
@@ -256,32 +214,35 @@ function RouteComponent() {
           );
         })}
 
-        {/* Pagination footer — only shown when there is more than one page */}
         {totalPages > 1 && (
           <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
-            <Button
+            <ButtonLink
+              to="/$repo/user/$id"
+              params={{ repo, id }}
+              search={{ status: statusFilter, after: "" }}
               variant="ghost"
               size="sm"
-              onClick={goPrev}
-              disabled={!hasPrev || loading}
+              disabled={!hasPrev}
               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/user/$id"
+              params={{ repo, id }}
+              search={{ status: statusFilter, after: bugs?.pageInfo.endCursor ?? "" }}
               variant="ghost"
               size="sm"
-              onClick={goNext}
-              disabled={!hasNext || loading}
+              disabled={!hasNext}
               className="text-muted-foreground gap-1"
             >
               Next
               <ChevronRight className="size-4" />
-            </Button>
+            </ButtonLink>
           </div>
         )}
       </div>
@@ -297,13 +258,24 @@ function ProfileSkeleton() {
         <div className="space-y-2 pt-1">
           <Skeleton className="h-6 w-40" />
           <Skeleton className="h-4 w-24" />
-          <Skeleton className="h-4 w-32" />
+          <Skeleton className="h-3 w-16" />
         </div>
       </div>
-      <div className="space-y-2">
-        {Array.from({ length: 4 }).map((_, i) => (
-          <Skeleton key={i} className="h-14 w-full" />
-        ))}
+      <div className="border-border rounded-md border">
+        <div className="border-border border-b px-4 py-2">
+          <Skeleton className="h-8 w-48" />
+        </div>
+        <div className="divide-border divide-y">
+          {Array.from({ length: 5 }).map((_, i) => (
+            <div key={i} className="flex items-start gap-3 px-4 py-3">
+              <Skeleton className="mt-0.5 size-4 rounded-full" />
+              <div className="flex-1 space-y-2">
+                <Skeleton className="h-4 w-2/3" />
+                <Skeleton className="h-3 w-1/3" />
+              </div>
+            </div>
+          ))}
+        </div>
       </div>
     </div>
   );