refactor(web): migrate consumers to EmptyState, SectionHeading, Pagination, CommentCard

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

- issues/index.tsx: EmptyState + Pagination (remove ButtonLink, ChevronLeft/Right)
- user/$id.tsx: EmptyState + Pagination (remove ButtonLink, ChevronLeft/Right)
- issues/$id.tsx: EmptyState + SectionHeading
- LabelEditor.tsx: SectionHeading
- Timeline.tsx: CommentCard (Root/AuthorAvatar/Card/CardHeader/CardBody)
- CommentBox.tsx: CommentCard (Root/AuthorAvatar/Card)

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

Change summary

webui2/src/components/bugs/CommentBox.tsx                         | 18 
webui2/src/components/bugs/LabelEditor.tsx                        |  5 
webui2/src/components/bugs/Timeline.tsx                           | 27 
webui2/src/components/code/__snapshots__/FileViewer.test.tsx.snap | 40 
webui2/src/routes/$repo/_issues/issues/$id.tsx                    |  8 
webui2/src/routes/$repo/_issues/issues/index.tsx                  | 41 
webui2/src/routes/$repo/_issues/user/$id.tsx                      | 37 
7 files changed, 65 insertions(+), 111 deletions(-)

Detailed changes

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

@@ -10,8 +10,8 @@ import {
   BugDetailDocument,
 } from "@/__generated__/graphql";
 import { Markdown } from "@/components/content/Markdown";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Button } from "@/components/ui/button";
+import * as CommentCard from "@/components/ui/comment-card";
 import { Textarea } from "@/components/ui/textarea";
 import * as WritePreview from "@/components/ui/write-preview";
 import { useAuth } from "@/lib/auth";
@@ -73,15 +73,9 @@ export function CommentBox({ bugPrefix, bugStatus, ref_ }: CommentBoxProps) {
   if (!user) return null;
 
   return (
-    <div className="flex gap-3">
-      <Avatar className="mt-1 size-8 shrink-0">
-        <AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />
-        <AvatarFallback className="text-xs">
-          {user.displayName.slice(0, 2).toUpperCase()}
-        </AvatarFallback>
-      </Avatar>
-
-      <div className="border-border min-w-0 flex-1 rounded-md border">
+    <CommentCard.Root>
+      <CommentCard.AuthorAvatar src={user.avatarUrl} name={user.displayName} />
+      <CommentCard.Card>
         <WritePreview.Root hasContent={hasMessage} preview={preview} onPreviewChange={setPreview}>
           <WritePreview.Tabs className="border-border border-b px-4 py-2" />
           <WritePreview.WriteSlot>
@@ -122,7 +116,7 @@ export function CommentBox({ bugPrefix, bugStatus, ref_ }: CommentBoxProps) {
             Comment
           </Button>
         </div>
-      </div>
-    </div>
+      </CommentCard.Card>
+    </CommentCard.Root>
   );
 }

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

@@ -2,6 +2,7 @@ import { Settings2 } from "lucide-react";
 
 import { useBugChangeLabelsMutation, BugDetailDocument } from "@/__generated__/graphql";
 import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { SectionHeading } from "@/components/ui/section-heading";
 import { useAuth } from "@/lib/auth";
 
 import { LabelBadge } from "./LabelBadge";
@@ -42,9 +43,7 @@ export function LabelEditor({ bugPrefix, currentLabels, ref_, validLabels }: Lab
   return (
     <div>
       <div className="mb-2 flex items-center justify-between">
-        <h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
-          Labels
-        </h3>
+        <SectionHeading className="mb-0">Labels</SectionHeading>
         {user && validLabels.length > 0 && (
           <Popover>
             <PopoverTrigger asChild>

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

@@ -10,8 +10,8 @@ import {
   BugDetailDocument,
 } from "@/__generated__/graphql";
 import { Markdown } from "@/components/content/Markdown";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Button } from "@/components/ui/button";
+import * as CommentCard from "@/components/ui/comment-card";
 import { Textarea } from "@/components/ui/textarea";
 import { useAuth } from "@/lib/auth";
 
@@ -94,16 +94,10 @@ function CommentItem({
   const canEdit = user !== null && user.id === item.author.id;
 
   return (
-    <div className="flex gap-3">
-      <Avatar className="mt-1 size-8 shrink-0">
-        <AvatarImage src={item.author.avatarUrl ?? undefined} alt={item.author.displayName} />
-        <AvatarFallback className="text-xs">
-          {item.author.displayName.slice(0, 2).toUpperCase()}
-        </AvatarFallback>
-      </Avatar>
-
-      <div className="border-border min-w-0 flex-1 rounded-md border">
-        <div className="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm">
+    <CommentCard.Root>
+      <CommentCard.AuthorAvatar src={item.author.avatarUrl} name={item.author.displayName} />
+      <CommentCard.Card>
+        <CommentCard.CardHeader>
           <Link
             to="/$repo/user/$id"
             params={{ repo: repo!, id: item.author.humanId }}
@@ -124,11 +118,10 @@ function CommentItem({
               Edit
             </button>
           )}
-        </div>
+        </CommentCard.CardHeader>
 
         {editing ? (
           <div className="space-y-2 p-3">
-            {/* Ctrl/Cmd+Enter saves; Escape cancels — standard editor shortcuts */}
             <Textarea
               value={editValue}
               onChange={(e) => setEditValue(e.target.value)}
@@ -152,16 +145,16 @@ function CommentItem({
             </div>
           </div>
         ) : (
-          <div className="px-4 py-3">
+          <CommentCard.CardBody>
             {item.message ? (
               <Markdown content={item.message} />
             ) : (
               <p className="text-muted-foreground text-sm italic">No description provided.</p>
             )}
-          </div>
+          </CommentCard.CardBody>
         )}
-      </div>
-    </div>
+      </CommentCard.Card>
+    </CommentCard.Root>
   );
 }
 

webui2/src/components/code/__snapshots__/FileViewer.test.tsx.snap 🔗

@@ -261,83 +261,83 @@ exports[`FileViewer/TypeScriptFile matches snapshot 1`] = `
       >
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 38.89277215488839%;"
+          style="width: 71.50143759316606%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 30.59424948464735%;"
+          style="width: 60.6708169292491%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 88.8718618920598%;"
+          style="width: 73.2183590812158%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 76.71451982934077%;"
+          style="width: 34.976257221082484%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 83.74909292446992%;"
+          style="width: 61.48920656229181%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 30.204986115614368%;"
+          style="width: 49.69450792480475%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 43.630255749606825%;"
+          style="width: 54.37099249060006%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 65.07221950291687%;"
+          style="width: 76.59901111896043%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 52.442063845559616%;"
+          style="width: 44.36460693491388%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 65.54311299024468%;"
+          style="width: 42.028325445555126%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 82.36906592981144%;"
+          style="width: 37.66834389435642%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 83.59354501082503%;"
+          style="width: 57.91664167237783%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 48.94220651251563%;"
+          style="width: 46.78288981850762%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 59.74254935831376%;"
+          style="width: 63.98546670165045%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 58.093030580736645%;"
+          style="width: 75.9276300696755%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 40.43812857099223%;"
+          style="width: 47.38236735204427%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 78.25416747608861%;"
+          style="width: 77.0934909916055%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 57.060810833798755%;"
+          style="width: 48.844870253597435%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 56.43045048200637%;"
+          style="width: 43.285422031290835%;"
         />
         <div
           class="animate-pulse rounded-md bg-primary/10 h-3.5"
-          style="width: 73.65573925757732%;"
+          style="width: 55.99529760322575%;"
         />
       </div>
     </div>

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

@@ -10,6 +10,8 @@ import { Timeline } from "@/components/bugs/Timeline";
 import { TitleEditor } from "@/components/bugs/TitleEditor";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { BackLink } from "@/components/ui/back-link";
+import { EmptyState } from "@/components/ui/empty-state";
+import { SectionHeading } from "@/components/ui/section-heading";
 import { Separator } from "@/components/ui/separator";
 import { Skeleton } from "@/components/ui/skeleton";
 
@@ -37,7 +39,7 @@ function RouteComponent() {
 
   const bug = data?.repository?.bug;
   if (!bug) {
-    return <div className="text-muted-foreground py-16 text-center text-sm">Issue not found.</div>;
+    return <EmptyState className="py-16">Issue not found.</EmptyState>;
   }
 
   return (
@@ -87,9 +89,7 @@ function RouteComponent() {
           <Separator />
 
           <div>
-            <h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
-              Participants
-            </h3>
+            <SectionHeading>Participants</SectionHeading>
             <div className="flex flex-wrap gap-1.5">
               {bug.participants.nodes.map((p) => {
                 return (

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

@@ -1,7 +1,7 @@
 import { useReadQuery } from "@apollo/client/react";
 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
-import { CircleDot, CircleCheck, ChevronLeft, ChevronRight, Search } from "lucide-react";
+import { CircleDot, CircleCheck, Search } from "lucide-react";
 import { useMemo, useState } from "react";
 import * as v from "valibot";
 
@@ -11,7 +11,8 @@ import type { SortValue } from "@/components/bugs/IssueFilters";
 import * as IssueRow from "@/components/bugs/IssueRow";
 import { LabelBadgeLink } from "@/components/bugs/LabelBadge";
 import { Button } from "@/components/ui/button";
-import { ButtonLink } from "@/components/ui/button-link";
+import { EmptyState } from "@/components/ui/empty-state";
+import * as Pagination from "@/components/ui/pagination";
 import * as QueryInput from "@/components/ui/query-input";
 import type { CompletionProvider } from "@/components/ui/query-input";
 import { Skeleton } from "@/components/ui/skeleton";
@@ -253,9 +254,7 @@ function RouteComponent() {
 
         {/* Bug rows */}
         {bugs?.nodes.length === 0 && (
-          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
-            No {statusFilter} issues found.
-          </p>
+          <EmptyState>No {statusFilter} issues found.</EmptyState>
         )}
 
         {bugs?.nodes.map((bug) => (
@@ -311,35 +310,21 @@ function RouteComponent() {
         ))}
 
         {totalPages > 1 && (
-          <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
-            <ButtonLink
+          <Pagination.Root>
+            <Pagination.Previous
               to="/$repo/issues"
-              params={{ repo: repo }}
+              params={{ repo }}
               search={{ q, after: "" }}
-              variant="ghost"
-              size="sm"
               disabled={!hasPrev}
-              className="text-muted-foreground gap-1"
-            >
-              <ChevronLeft className="size-4" />
-              Previous
-            </ButtonLink>
-            <span className="text-muted-foreground text-sm">
-              Page {after ? 2 : 1} of {totalPages}
-            </span>
-            <ButtonLink
+            />
+            <Pagination.Info>Page {after ? 2 : 1} of {totalPages}</Pagination.Info>
+            <Pagination.Next
               to="/$repo/issues"
-              params={{ repo: repo }}
+              params={{ repo }}
               search={{ q, after: bugs?.pageInfo.endCursor ?? "" }}
-              variant="ghost"
-              size="sm"
               disabled={!hasNext}
-              className="text-muted-foreground gap-1"
-            >
-              Next
-              <ChevronRight className="size-4" />
-            </ButtonLink>
-          </div>
+            />
+          </Pagination.Root>
         )}
       </div>
     </div>

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

@@ -10,8 +10,6 @@ import {
   CircleDot,
   CircleCheck,
   ShieldCheck,
-  ChevronLeft,
-  ChevronRight,
 } from "lucide-react";
 import * as v from "valibot";
 
@@ -20,7 +18,8 @@ import * as IssueRow from "@/components/bugs/IssueRow";
 import { LabelBadge } from "@/components/bugs/LabelBadge";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { BackLink } from "@/components/ui/back-link";
-import { ButtonLink } from "@/components/ui/button-link";
+import { EmptyState } from "@/components/ui/empty-state";
+import * as Pagination from "@/components/ui/pagination";
 import { Skeleton } from "@/components/ui/skeleton";
 import { cn } from "@/lib/utils";
 
@@ -165,9 +164,7 @@ function RouteComponent() {
         </div>
 
         {bugs?.nodes.length === 0 && (
-          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
-            No {statusFilter} issues.
-          </p>
+          <EmptyState>No {statusFilter} issues.</EmptyState>
         )}
 
         {bugs?.nodes.map((bug) => (
@@ -196,35 +193,21 @@ function RouteComponent() {
         ))}
 
         {totalPages > 1 && (
-          <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
-            <ButtonLink
+          <Pagination.Root>
+            <Pagination.Previous
               to="/$repo/user/$id"
               params={{ repo, id }}
               search={{ status: statusFilter, after: "" }}
-              variant="ghost"
-              size="sm"
               disabled={!hasPrev}
-              className="text-muted-foreground gap-1"
-            >
-              <ChevronLeft className="size-4" />
-              Previous
-            </ButtonLink>
-            <span className="text-muted-foreground text-sm">
-              Page {after ? 2 : 1} of {totalPages}
-            </span>
-            <ButtonLink
+            />
+            <Pagination.Info>Page {after ? 2 : 1} of {totalPages}</Pagination.Info>
+            <Pagination.Next
               to="/$repo/user/$id"
               params={{ repo, id }}
               search={{ status: statusFilter, after: bugs?.pageInfo.endCursor ?? "" }}
-              variant="ghost"
-              size="sm"
               disabled={!hasNext}
-              className="text-muted-foreground gap-1"
-            >
-              Next
-              <ChevronRight className="size-4" />
-            </ButtonLink>
-          </div>
+            />
+          </Pagination.Root>
         )}
       </div>
     </div>