diff --git a/webui2/src/components/code/__snapshots__/FileViewer.test.tsx.snap b/webui2/src/components/code/__snapshots__/FileViewer.test.tsx.snap
index 0e53868cd9037a08ed4ba537ab24e690495d81b2..72bab0c906d484c02570e30a10a24100e47a0f4a 100644
--- a/webui2/src/components/code/__snapshots__/FileViewer.test.tsx.snap
+++ b/webui2/src/components/code/__snapshots__/FileViewer.test.tsx.snap
@@ -261,83 +261,83 @@ exports[`FileViewer/TypeScriptFile matches snapshot 1`] = `
>
diff --git a/webui2/src/components/ui/__snapshots__/comment-card.test.tsx.snap b/webui2/src/components/ui/__snapshots__/comment-card.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..6447a4a870737a21f0a03463d7cf4bff6e7bf4b0
--- /dev/null
+++ b/webui2/src/components/ui/__snapshots__/comment-card.test.tsx.snap
@@ -0,0 +1,146 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`CommentCard/Default matches snapshot 1`] = `
+
+
+
+
+ JA
+
+
+
+
+
+ Jane Doe
+
+
+ 2 hours ago
+
+
+
+
+ This is a comment body with some text content.
+
+
+
+
+
+`;
+
+exports[`CommentCard/EmptyBody matches snapshot 1`] = `
+
+
+
+
+ AL
+
+
+
+
+
+ Alice Wu
+
+
+ just now
+
+
+
+
+ No description provided.
+
+
+
+
+
+`;
+
+exports[`CommentCard/WithEditButton matches snapshot 1`] = `
+
+
+
+
+ BO
+
+
+
+
+
+ Bob Smith
+
+
+ 1 day ago
+
+
+ edited
+
+
+
+
+
+ Updated the configuration to fix the build issue.
+
+
+
+
+
+`;
diff --git a/webui2/src/components/ui/__snapshots__/empty-state.test.tsx.snap b/webui2/src/components/ui/__snapshots__/empty-state.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..c4a7103c13a3a19419e7bf5323d923717c989b51
--- /dev/null
+++ b/webui2/src/components/ui/__snapshots__/empty-state.test.tsx.snap
@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`EmptyState/Default matches snapshot 1`] = `
+
+
+ No open issues found.
+
+
+`;
+
+exports[`EmptyState/NotFound matches snapshot 1`] = `
+
+`;
diff --git a/webui2/src/components/ui/__snapshots__/pagination.test.tsx.snap b/webui2/src/components/ui/__snapshots__/pagination.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..5a02f0b58a61f8a200728fea93a94cc1fb9c60ee
--- /dev/null
+++ b/webui2/src/components/ui/__snapshots__/pagination.test.tsx.snap
@@ -0,0 +1,206 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Pagination/Default matches snapshot 1`] = `
+
+
+
+ Content above pagination
+
+
+
+
+`;
+
+exports[`Pagination/LastPage matches snapshot 1`] = `
+
+`;
+
+exports[`Pagination/MiddlePage matches snapshot 1`] = `
+
+`;
diff --git a/webui2/src/components/ui/__snapshots__/section-heading.test.tsx.snap b/webui2/src/components/ui/__snapshots__/section-heading.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..e3f9b04b7017fb433c7147f71e59fb3b1ef17c89
--- /dev/null
+++ b/webui2/src/components/ui/__snapshots__/section-heading.test.tsx.snap
@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SectionHeading/Default matches snapshot 1`] = `
+
+
+ Participants
+
+
+`;
+
+exports[`SectionHeading/Labels matches snapshot 1`] = `
+
+
+ Labels
+
+
+`;
diff --git a/webui2/src/components/ui/comment-card.stories.tsx b/webui2/src/components/ui/comment-card.stories.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..03b0490d1362eeb8f666250b2b3d47b4619bf3ea
--- /dev/null
+++ b/webui2/src/components/ui/comment-card.stories.tsx
@@ -0,0 +1,68 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import * as CommentCard from "./comment-card";
+
+const meta = {
+ component: CommentCard.Root,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: { children: null },
+ render: () => (
+
+
+
+
+ Jane Doe
+ 2 hours ago
+
+
+ This is a comment body with some text content.
+
+
+
+ ),
+};
+
+export const WithEditButton: Story = {
+ args: { children: null },
+ render: () => (
+
+
+
+
+ Bob Smith
+ 1 day ago
+ edited
+
+
+
+ Updated the configuration to fix the build issue.
+
+
+
+ ),
+};
+
+export const EmptyBody: Story = {
+ args: { children: null },
+ render: () => (
+
+
+
+
+ Alice Wu
+ just now
+
+
+ No description provided.
+
+
+
+ ),
+};
diff --git a/webui2/src/components/ui/comment-card.test.tsx b/webui2/src/components/ui/comment-card.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3eb83ee37ff9ba6c6e12eb61f8144ca21b0d9713
--- /dev/null
+++ b/webui2/src/components/ui/comment-card.test.tsx
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./comment-card.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`CommentCard/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
diff --git a/webui2/src/components/ui/comment-card.tsx b/webui2/src/components/ui/comment-card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..61060b25fea262948309bc1e248b5c2b84cec394
--- /dev/null
+++ b/webui2/src/components/ui/comment-card.tsx
@@ -0,0 +1,69 @@
+import { cn } from "@/lib/utils";
+
+import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
+
+interface RootProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Root({ children, className }: RootProps) {
+ return {children}
;
+}
+
+interface AuthorAvatarProps {
+ src?: string | null;
+ name: string;
+ className?: string;
+}
+
+export function AuthorAvatar({ src, name, className }: AuthorAvatarProps) {
+ return (
+
+
+
+ {name.slice(0, 2).toUpperCase()}
+
+
+ );
+}
+
+interface CardProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Card({ children, className }: CardProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface CardHeaderProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function CardHeader({ children, className }: CardHeaderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface CardBodyProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function CardBody({ children, className }: CardBodyProps) {
+ return {children}
;
+}
diff --git a/webui2/src/components/ui/empty-state.stories.tsx b/webui2/src/components/ui/empty-state.stories.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..411d598da83651a535c53ecb368775d3f41ce5cb
--- /dev/null
+++ b/webui2/src/components/ui/empty-state.stories.tsx
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { EmptyState } from "./empty-state";
+
+const meta = {
+ component: EmptyState,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: { children: "No open issues found." },
+};
+
+export const NotFound: Story = {
+ args: { children: "Issue not found." },
+};
diff --git a/webui2/src/components/ui/empty-state.test.tsx b/webui2/src/components/ui/empty-state.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bf91e792dd2b322d3cf7234e4fe98f045435cbff
--- /dev/null
+++ b/webui2/src/components/ui/empty-state.test.tsx
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./empty-state.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`EmptyState/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
diff --git a/webui2/src/components/ui/empty-state.tsx b/webui2/src/components/ui/empty-state.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..808c8d06e110d880d17c764601c7953fd568c0cd
--- /dev/null
+++ b/webui2/src/components/ui/empty-state.tsx
@@ -0,0 +1,14 @@
+import { cn } from "@/lib/utils";
+
+interface EmptyStateProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function EmptyState({ children, className }: EmptyStateProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/webui2/src/components/ui/pagination.stories.tsx b/webui2/src/components/ui/pagination.stories.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..11520ee9f7020b7a4f135c3fc042df6eef282c1e
--- /dev/null
+++ b/webui2/src/components/ui/pagination.stories.tsx
@@ -0,0 +1,49 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { withRouter } from "@/../.storybook/decorators";
+
+import * as Pagination from "./pagination";
+
+const meta = {
+ component: Pagination.Root,
+ decorators: [withRouter],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: { children: null },
+ render: () => (
+
+
Content above pagination
+
+
+ Page 1 of 5
+
+
+
+ ),
+};
+
+export const MiddlePage: Story = {
+ args: { children: null },
+ render: () => (
+
+
+ Page 3 of 5
+
+
+ ),
+};
+
+export const LastPage: Story = {
+ args: { children: null },
+ render: () => (
+
+
+ Page 5 of 5
+
+
+ ),
+};
diff --git a/webui2/src/components/ui/pagination.test.tsx b/webui2/src/components/ui/pagination.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b0baa076c20eaab58e4049c9a69be76d5429f541
--- /dev/null
+++ b/webui2/src/components/ui/pagination.test.tsx
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./pagination.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`Pagination/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
diff --git a/webui2/src/components/ui/pagination.tsx b/webui2/src/components/ui/pagination.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4644ef5e26efa349e09faab07421c964501fbf12
--- /dev/null
+++ b/webui2/src/components/ui/pagination.tsx
@@ -0,0 +1,82 @@
+import { createLink, type LinkComponent } from "@tanstack/react-router";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+import { buttonVariants } from "./button";
+
+interface RootProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Root({ children, className }: RootProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface InfoProps {
+ children: React.ReactNode;
+}
+
+export function Info({ children }: InfoProps) {
+ return {children};
+}
+
+// Previous/Next are createLink-wrapped so they work as router links.
+const PreviousComponent = React.forwardRef<
+ HTMLAnchorElement,
+ { className?: string; children?: React.ReactNode; disabled?: boolean } & React.AnchorHTMLAttributes
+>(({ className, children, ...props }, ref) => (
+
+
+ {children ?? "Previous"}
+
+));
+PreviousComponent.displayName = "PreviousComponent";
+
+const CreatedPrevious = createLink(PreviousComponent);
+export const Previous: LinkComponent = (props) => (
+
+);
+
+const NextComponent = React.forwardRef<
+ HTMLAnchorElement,
+ { className?: string; children?: React.ReactNode; disabled?: boolean } & React.AnchorHTMLAttributes
+>(({ className, children, ...props }, ref) => (
+
+ {children ?? "Next"}
+
+
+));
+NextComponent.displayName = "NextComponent";
+
+const CreatedNext = createLink(NextComponent);
+export const Next: LinkComponent = (props) => (
+
+);
diff --git a/webui2/src/components/ui/section-heading.stories.tsx b/webui2/src/components/ui/section-heading.stories.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..53b54fa8c30cae2e90c26dae06a5de3c9222d4b2
--- /dev/null
+++ b/webui2/src/components/ui/section-heading.stories.tsx
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { SectionHeading } from "./section-heading";
+
+const meta = {
+ component: SectionHeading,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: { children: "Participants" },
+};
+
+export const Labels: Story = {
+ args: { children: "Labels" },
+};
diff --git a/webui2/src/components/ui/section-heading.test.tsx b/webui2/src/components/ui/section-heading.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b1a0e5b1b0d6d99f28b0ad5aae69cfe6228e24f3
--- /dev/null
+++ b/webui2/src/components/ui/section-heading.test.tsx
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./section-heading.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`SectionHeading/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
diff --git a/webui2/src/components/ui/section-heading.tsx b/webui2/src/components/ui/section-heading.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fa3e6c7a0057c15c7dab57593bec3e9c4968ee5b
--- /dev/null
+++ b/webui2/src/components/ui/section-heading.tsx
@@ -0,0 +1,14 @@
+import { cn } from "@/lib/utils";
+
+interface SectionHeadingProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function SectionHeading({ children, className }: SectionHeadingProps) {
+ return (
+
+ {children}
+
+ );
+}