refactor(web): move page components into route files, add data preloading

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

inline all page components from src/pages/ into their route files
in src/routes/, following TanStack Router conventions

add Apollo preloadQuery integration for router-level data loading:
- create preloadQuery helper via createQueryPreloader(client)
- preload repos query in / route loader
- preload bug detail query in /$repo/issues/$id loader
- preload commit query in /$repo/commit/$hash loader
- preload refs query in /$repo/ (code page) loader
- use useReadQuery() in components to read preloaded data

move loading skeletons to route-level pendingComponent:
- BugDetailSkeleton, CommitPageSkeleton, CodePageSkeleton

delete src/pages/ directory β€” all UI now lives in src/routes/

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

Change summary

webui2/src/lib/apollo.ts                   |   5 
webui2/src/pages/BugDetailPage.tsx         | 139 --------
webui2/src/pages/BugListPage.tsx           | 388 -----------------------
webui2/src/pages/CodePage.tsx              | 275 ----------------
webui2/src/pages/CommitPage.tsx            | 174 ----------
webui2/src/pages/ErrorPage.tsx             |  34 --
webui2/src/pages/IdentitySelectPage.tsx    | 131 --------
webui2/src/pages/NewBugPage.tsx            | 118 -------
webui2/src/pages/RepoPickerPage.tsx        |  79 ----
webui2/src/pages/UserProfilePage.tsx       | 292 -----------------
webui2/src/routes/$repo/commit/$hash.tsx   | 170 ++++++++++
webui2/src/routes/$repo/index.tsx          | 287 +++++++++++++++++
webui2/src/routes/$repo/issues/$id.tsx     | 136 ++++++++
webui2/src/routes/$repo/issues/index.tsx   | 390 +++++++++++++++++++++++
webui2/src/routes/$repo/issues/new.tsx     | 121 +++++++
webui2/src/routes/$repo/user/$id.tsx       | 295 +++++++++++++++++
webui2/src/routes/__root.tsx               |  33 +
webui2/src/routes/auth/select-identity.tsx | 133 ++++++++
webui2/src/routes/index.tsx                |  79 ++++
19 files changed, 1,625 insertions(+), 1,654 deletions(-)

Detailed changes

webui2/src/lib/apollo.ts πŸ”—

@@ -1,4 +1,5 @@
 import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
+import { createQueryPreloader } from "@apollo/client/react";
 
 const httpLink = new HttpLink({
   uri: "/graphql",
@@ -18,3 +19,7 @@ export const client = new ApolloClient({
     },
   }),
 });
+
+// Preloader for use in TanStack Router loaders. Returns a QueryRef
+// that components read with useReadQuery() for suspense-based rendering.
+export const preloadQuery = createQueryPreloader(client);

webui2/src/pages/BugDetailPage.tsx πŸ”—

@@ -1,139 +0,0 @@
-import { useParams, Link } from "@tanstack/react-router";
-import { formatDistanceToNow } from "date-fns";
-import { ArrowLeft } from "lucide-react";
-
-import { useBugDetailQuery } from "@/__generated__/graphql";
-import { CommentBox } from "@/components/bugs/CommentBox";
-import { LabelEditor } from "@/components/bugs/LabelEditor";
-import { StatusBadge } from "@/components/bugs/StatusBadge";
-import { Timeline } from "@/components/bugs/Timeline";
-import { TitleEditor } from "@/components/bugs/TitleEditor";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Separator } from "@/components/ui/separator";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-
-// Issue detail page (/:repo/issues/:id). Shows title, status, timeline of
-// comments and events, and a sidebar with labels and participants.
-export function BugDetailPage() {
-  const { id } = useParams({ strict: false });
-  const repo = useRepo();
-  const { data, loading, error } = useBugDetailQuery({
-    variables: { ref: repo, prefix: id! },
-  });
-
-  if (error) {
-    return (
-      <div className="text-destructive py-16 text-center text-sm">
-        Failed to load issue: {error.message}
-      </div>
-    );
-  }
-
-  if (loading && !data) {
-    return <BugDetailSkeleton />;
-  }
-
-  const bug = data?.repository?.bug;
-  if (!bug) {
-    return <div className="text-muted-foreground py-16 text-center text-sm">Issue not found.</div>;
-  }
-
-  return (
-    <div>
-      <Link
-        to="/$repo/issues"
-        params={{ repo: repo! }}
-        className="text-muted-foreground hover:text-foreground mb-4 flex items-center gap-1.5 text-sm"
-      >
-        <ArrowLeft className="size-3.5" />
-        Back to issues
-      </Link>
-
-      {/* Title row β€” hover reveals edit button when logged in */}
-      <div className="mb-3">
-        <TitleEditor bugPrefix={bug.humanId} title={bug.title} humanId={bug.humanId} ref_={repo} />
-      </div>
-
-      <div className="text-muted-foreground mb-6 flex flex-wrap items-center gap-3 text-sm">
-        <StatusBadge status={bug.status} />
-        <span>
-          <Link
-            to="/$repo/user/$id"
-            params={{ repo: repo!, id: bug.author.humanId }}
-            className="text-foreground font-medium hover:underline"
-          >
-            {bug.author.displayName}
-          </Link>{" "}
-          opened this issue {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
-        </span>
-      </div>
-
-      <Separator className="mb-6" />
-
-      <div className="flex gap-8">
-        {/* Timeline + comment box */}
-        <div className="min-w-0 flex-1 space-y-4">
-          <Timeline bugPrefix={bug.humanId} items={bug.timeline.nodes} />
-          <CommentBox bugPrefix={bug.humanId} bugStatus={bug.status} ref_={repo} />
-        </div>
-
-        {/* Sidebar */}
-        <aside className="w-56 shrink-0 space-y-6">
-          <LabelEditor bugPrefix={bug.humanId} currentLabels={bug.labels} ref_={repo} />
-
-          <Separator />
-
-          <div>
-            <h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
-              Participants
-            </h3>
-            <div className="flex flex-wrap gap-1.5">
-              {bug.participants.nodes.map((p) => {
-                return (
-                  <Link
-                    key={p.id}
-                    to="/$repo/user/$id"
-                    params={{ repo: repo!, id: p.humanId }}
-                    title={p.displayName}
-                  >
-                    <Avatar className="size-6">
-                      <AvatarImage src={p.avatarUrl ?? undefined} alt={p.displayName} />
-                      <AvatarFallback className="text-[10px]">
-                        {p.displayName.slice(0, 2).toUpperCase()}
-                      </AvatarFallback>
-                    </Avatar>
-                  </Link>
-                );
-              })}
-            </div>
-          </div>
-        </aside>
-      </div>
-    </div>
-  );
-}
-
-function BugDetailSkeleton() {
-  return (
-    <div className="space-y-4">
-      <Skeleton className="h-8 w-2/3" />
-      <Skeleton className="h-4 w-1/3" />
-      <Separator />
-      <div className="flex gap-8">
-        <div className="flex-1 space-y-4">
-          {Array.from({ length: 3 }).map((_, i) => (
-            <div key={i} className="border-border rounded-md border p-4">
-              <Skeleton className="mb-3 h-4 w-1/4" />
-              <Skeleton className="h-16 w-full" />
-            </div>
-          ))}
-        </div>
-        <div className="w-56 space-y-3">
-          <Skeleton className="h-4 w-full" />
-          <Skeleton className="h-4 w-3/4" />
-        </div>
-      </div>
-    </div>
-  );
-}

webui2/src/pages/BugListPage.tsx πŸ”—

@@ -1,388 +0,0 @@
-import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
-import { useState, useEffect } from "react";
-
-import { useBugListQuery } from "@/__generated__/graphql";
-import { BugRow } from "@/components/bugs/BugRow";
-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 { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-import { cn } from "@/lib/utils";
-
-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.
-export function BugListPage() {
-  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 openQuery = `status:open ${baseQuery}`.trim();
-  const closedQuery = `status:closed ${baseQuery}`.trim();
-  const listQuery = buildQueryString(
-    statusFilter,
-    selectedLabels,
-    selectedAuthorQuery,
-    freeText,
-    sort,
-  );
-
-  const { data, loading, error } = useBugListQuery({
-    variables: {
-      ref: repo,
-      openQuery,
-      closedQuery,
-      listQuery,
-      first: PAGE_SIZE,
-      after: cursors[page],
-    },
-  });
-
-  const openCount = data?.repository?.openCount.totalCount ?? 0;
-  const closedCount = data?.repository?.closedCount.totalCount ?? 0;
-  const bugs = data?.repository?.bugs;
-  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;
-
-  // Reset to page 1 whenever the list query changes.
-  useEffect(() => {
-    setCursors([undefined]);
-  }, [listQuery]);
-
-  // Apply all filters at once, keeping draft in sync with the structured state.
-  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));
-  }
-
-  // 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.
-  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]);
-  }
-
-  function goPrev() {
-    setCursors((prev) => prev.slice(0, -1));
-  }
-
-  return (
-    <div>
-      {/* Search bar */}
-      <form onSubmit={handleSearch} className="mb-4 flex gap-2">
-        <QueryInput
-          value={draft}
-          onChange={setDraft}
-          onSubmit={handleSearch}
-          placeholder="status:open author:… label:…"
-        />
-        <Button type="submit">Search</Button>
-      </form>
-
-      {/* List container */}
-      <div className="border-border rounded-md border">
-        {/* 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,
-                )
-              }
-              className={cn(
-                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
-                statusFilter === "open"
-                  ? "bg-accent text-accent-foreground"
-                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
-              )}
-            >
-              <CircleDot
-                className={cn(
-                  "size-4",
-                  statusFilter === "open" && "text-green-600 dark:text-green-400",
-                )}
-              />
-              Open
-              <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
-                {openCount}
-              </span>
-            </button>
-
-            <button
-              onClick={() =>
-                applyFilters(
-                  "closed",
-                  selectedLabels,
-                  selectedAuthorId,
-                  selectedAuthorQuery,
-                  freeText,
-                )
-              }
-              className={cn(
-                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
-                statusFilter === "closed"
-                  ? "bg-accent text-accent-foreground"
-                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
-              )}
-            >
-              <CircleCheck
-                className={cn(
-                  "size-4",
-                  statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
-                )}
-              />
-              Closed
-              <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
-                {closedCount}
-              </span>
-            </button>
-          </div>
-
-          <div className="ml-auto">
-            <IssueFilters
-              selectedLabels={selectedLabels}
-              onLabelsChange={(labels) =>
-                applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)
-              }
-              selectedAuthorId={selectedAuthorId}
-              onAuthorChange={(id, qv) =>
-                applyFilters(statusFilter, selectedLabels, id, qv, freeText)
-              }
-              recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
-              sort={sort}
-              onSortChange={(s) =>
-                applyFilters(
-                  statusFilter,
-                  selectedLabels,
-                  selectedAuthorId,
-                  selectedAuthorQuery,
-                  freeText,
-                  s,
-                )
-              }
-            />
-          </div>
-        </div>
-
-        {/* Bug rows */}
-        {error && (
-          <p className="text-destructive px-4 py-8 text-center text-sm">
-            Failed to load issues: {error.message}
-          </p>
-        )}
-
-        {loading && !data && <BugListSkeleton />}
-
-        {bugs?.nodes.length === 0 && (
-          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
-            No {statusFilter} issues found.
-          </p>
-        )}
-
-        {bugs?.nodes.map((bug) => (
-          <BugRow
-            key={bug.id}
-            id={bug.id}
-            humanId={bug.humanId}
-            status={bug.status}
-            title={bug.title}
-            labels={bug.labels}
-            author={bug.author}
-            createdAt={bug.createdAt}
-            commentCount={bug.comments.totalCount}
-            repo={repo}
-            onLabelClick={(name) => {
-              if (!selectedLabels.includes(name)) {
-                applyFilters(
-                  statusFilter,
-                  [...selectedLabels, name],
-                  selectedAuthorId,
-                  selectedAuthorQuery,
-                  freeText,
-                );
-              }
-            }}
-          />
-        ))}
-
-        {totalPages > 1 && (
-          <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
-            <Button
-              variant="ghost"
-              size="sm"
-              onClick={goPrev}
-              disabled={!hasPrev || loading}
-              className="text-muted-foreground gap-1"
-            >
-              <ChevronLeft className="size-4" />
-              Previous
-            </Button>
-            <span className="text-muted-foreground text-sm">
-              Page {page + 1} of {totalPages}
-            </span>
-            <Button
-              variant="ghost"
-              size="sm"
-              onClick={goNext}
-              disabled={!hasNext || loading}
-              className="text-muted-foreground gap-1"
-            >
-              Next
-              <ChevronRight className="size-4" />
-            </Button>
-          </div>
-        )}
-      </div>
-    </div>
-  );
-}
-
-// buildBaseQuery returns the filter parts (labels, author, freeText) without
-// the status prefix, so it can be combined with "status:open" / "status:closed".
-function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
-  const parts: string[] = [];
-  for (const label of labels) {
-    parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
-  }
-  if (author) {
-    parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
-  }
-  if (freeText.trim()) parts.push(freeText.trim());
-  return parts.join(" ");
-}
-
-// 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[],
-  author: string | null,
-  freeText: string,
-  sort: SortValue = "creation-desc",
-): string {
-  const parts = [`status:${status}`];
-  const base = buildBaseQuery(labels, author, freeText);
-  if (base) parts.push(base);
-  if (sort !== "creation-desc") parts.push(`sort:${sort}`);
-  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.
-function tokenizeQuery(input: string): string[] {
-  const tokens: string[] = [];
-  let current = "";
-  let inQuote = false;
-  for (const ch of input.trim()) {
-    if (ch === '"') {
-      inQuote = !inQuote;
-      current += ch;
-    } else if (ch === " " && !inQuote) {
-      if (current) {
-        tokens.push(current);
-        current = "";
-      }
-    } else current += ch;
-  }
-  if (current) tokens.push(current);
-  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.
-const VALID_SORTS = new Set<SortValue>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
-
-function parseQueryString(input: string): {
-  status: StatusFilter;
-  labels: string[];
-  author: string | null;
-  freeText: string;
-  sort: SortValue;
-} {
-  let status: StatusFilter = "open";
-  const labels: string[] = [];
-  let author: string | null = null;
-  let sort: SortValue = "creation-desc";
-  const free: string[] = [];
-
-  for (const token of tokenizeQuery(input)) {
-    if (token === "status:open") status = "open";
-    else if (token === "status:closed") status = "closed";
-    else if (token.startsWith("label:")) labels.push(token.slice(6));
-    else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
-    else if (token.startsWith("sort:")) {
-      const v = token.slice(5) as SortValue;
-      if (VALID_SORTS.has(v)) sort = v;
-    } else free.push(token);
-  }
-
-  return { status, labels, author, freeText: free.join(" "), sort };
-}
-
-function BugListSkeleton() {
-  return (
-    <div className="divide-border divide-y">
-      {Array.from({ length: 8 }).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>
-  );
-}

webui2/src/pages/CodePage.tsx πŸ”—

@@ -1,275 +0,0 @@
-// Code browser page. Switches between tree view, file viewer, and commit
-// history via ?type= search param. Ref is selected via ?ref=.
-
-import { gql } from "@apollo/client";
-import { useQuery } from "@apollo/client/react";
-import { useNavigate, useSearch } from "@tanstack/react-router";
-import { AlertCircle, GitCommit } from "lucide-react";
-import { useEffect } from "react";
-
-import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from "@/__generated__/graphql";
-import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
-import { CommitList } from "@/components/code/CommitList";
-import { FileTree } from "@/components/code/FileTree";
-import type { TreeEntryWithCommit } from "@/components/code/FileTree";
-import { FileViewer } from "@/components/code/FileViewer";
-import { RefSelector } from "@/components/code/RefSelector";
-import { Markdown } from "@/components/content/Markdown";
-import { ButtonLink } from "@/components/ui/button-link";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-
-const REFS_QUERY = gql`
-  query CodePageRefs($repo: String) {
-    repository(ref: $repo) {
-      name
-      refs {
-        nodes {
-          name
-          shortName
-          type
-          hash
-          isDefault
-        }
-      }
-    }
-  }
-`;
-
-const TREE_QUERY = gql`
-  query CodePageTree($repo: String, $ref: String!, $path: String) {
-    repository(ref: $repo) {
-      tree(ref: $ref, path: $path) {
-        name
-        type
-        hash
-      }
-    }
-  }
-`;
-
-const LAST_COMMITS_QUERY = gql`
-  query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
-    repository(ref: $repo) {
-      lastCommits(ref: $ref, path: $path, names: $names) {
-        name
-        commit {
-          hash
-          shortHash
-          message
-          date
-        }
-      }
-    }
-  }
-`;
-
-const BLOB_QUERY = gql`
-  query CodePageBlob($repo: String, $ref: String!, $path: String!) {
-    repository(ref: $repo) {
-      blob(ref: $ref, path: $path) {
-        path
-        hash
-        text
-        size
-        isBinary
-        isTruncated
-      }
-    }
-  }
-`;
-
-interface RefsQueryData {
-  repository: {
-    name: string;
-    refs: { nodes: GitRef[] } | null;
-  } | null;
-}
-
-interface TreeQueryData {
-  repository: {
-    tree: GitTreeEntry[] | null;
-  } | null;
-}
-
-interface LastCommitsQueryData {
-  repository: {
-    lastCommits: GitLastCommit[] | null;
-  } | null;
-}
-
-interface BlobQueryData {
-  repository: {
-    blob: GitBlob | null;
-  } | null;
-}
-
-import type { CodePageSearch } from "@/routes/$repo/index";
-
-type ViewMode = CodePageSearch["type"];
-
-export function CodePage() {
-  const repo = useRepo();
-  const navigate = useNavigate({ from: "/$repo/" });
-  const { ref: currentRef, path: currentPath, type: viewMode } = useSearch({ from: "/$repo/" });
-
-  const {
-    data: refsData,
-    loading: refsLoading,
-    error: refsError,
-  } = useQuery<RefsQueryData>(REFS_QUERY, {
-    variables: { repo },
-  });
-  const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? [];
-
-  // Set default ref from query result once loaded
-  useEffect(() => {
-    if (refsLoading || refs.length === 0 || currentRef) return;
-    const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
-    if (defaultRef) {
-      void navigate({
-        search: (prev) => ({ ...prev, ref: defaultRef.shortName }),
-        replace: true,
-      });
-    }
-  }, [refsLoading, refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
-
-  const inTreeMode = viewMode === "tree" && !!currentRef;
-  const inBlobMode = viewMode === "blob" && !!currentRef && !!currentPath;
-
-  const { data: treeData, loading: treeLoading } = useQuery<TreeQueryData>(TREE_QUERY, {
-    variables: { repo, ref: currentRef, path: currentPath || null },
-    skip: !inTreeMode,
-  });
-  const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
-
-  const entryNames = entries.map((e: GitTreeEntry) => e.name);
-  const { data: lastCommitsData } = useQuery<LastCommitsQueryData>(LAST_COMMITS_QUERY, {
-    variables: { repo, ref: currentRef, path: currentPath || null, names: entryNames },
-    skip: !inTreeMode || entryNames.length === 0,
-  });
-  const lastCommitsByName = new Map<string, GitLastCommit>(
-    (lastCommitsData?.repository?.lastCommits ?? []).map((lc: GitLastCommit) => [lc.name, lc]),
-  );
-  const entriesWithCommits: TreeEntryWithCommit[] = entries.map((e: GitTreeEntry) => ({
-    ...e,
-    lastCommit: lastCommitsByName.get(e.name)?.commit ?? undefined,
-  }));
-
-  const { data: blobData, loading: blobLoading } = useQuery<BlobQueryData>(BLOB_QUERY, {
-    variables: { repo, ref: currentRef, path: currentPath },
-    skip: !inBlobMode,
-  });
-  const blob: GitBlob | null = blobData?.repository?.blob ?? null;
-
-  const readmeEntry = entries.find(
-    (e: GitTreeEntry) => e.type === "BLOB" && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
-  );
-  const readmePath = readmeEntry
-    ? currentPath
-      ? `${currentPath}/${readmeEntry.name}`
-      : readmeEntry.name
-    : null;
-  const { data: readmeBlobData } = useQuery<BlobQueryData>(BLOB_QUERY, {
-    variables: { repo, ref: currentRef, path: readmePath },
-    skip: !inTreeMode || !readmePath,
-  });
-  const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
-
-  const repoName = refsData?.repository?.name ?? repo ?? "default-repo";
-
-  function navigateTo(path: string, type: ViewMode = "tree") {
-    void navigate({ search: (prev) => ({ ...prev, path, type }) });
-  }
-
-  function handleEntryClick(entry: TreeEntryWithCommit) {
-    const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
-    navigateTo(newPath, entry.type === "BLOB" ? "blob" : "tree");
-  }
-
-  function handleNavigateUp() {
-    const parts = currentPath.split("/").filter(Boolean);
-    parts.pop();
-    navigateTo(parts.join("/"), "tree");
-  }
-
-  function handleRefSelect(ref: GitRef) {
-    void navigate({ search: { ref: ref.shortName, path: "", type: "tree" } });
-  }
-
-  if (refsError) {
-    return (
-      <div className="flex flex-col items-center gap-3 py-16 text-center">
-        <AlertCircle className="text-muted-foreground size-8" />
-        <p className="text-sm font-medium">Code browser unavailable</p>
-        <p className="text-muted-foreground max-w-sm text-xs">{refsError.message}</p>
-      </div>
-    );
-  }
-
-  return (
-    <div className="space-y-4">
-      <div className="flex flex-wrap items-center justify-between gap-3">
-        {refsLoading ? (
-          <Skeleton className="h-5 w-48" />
-        ) : (
-          <CodeBreadcrumb
-            repoName={repoName}
-            ref={currentRef}
-            path={currentPath}
-            onNavigate={(p) => navigateTo(p, "tree")}
-          />
-        )}
-        <div className="flex items-center gap-2">
-          {!refsLoading && (
-            <ButtonLink
-              to="/$repo"
-              params={{ repo: repo! }}
-              search={{
-                ref: currentRef,
-                path: currentPath,
-                type: viewMode === "commits" ? "tree" : "commits",
-              }}
-              variant={viewMode === "commits" ? "secondary" : "outline"}
-              size="sm"
-            >
-              <GitCommit className="size-3.5" />
-              History
-            </ButtonLink>
-          )}
-          {refsLoading ? (
-            <Skeleton className="h-8 w-28" />
-          ) : (
-            <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
-          )}
-        </div>
-      </div>
-
-      {viewMode === "commits" ? (
-        <CommitList ref_={currentRef} path={currentPath || undefined} />
-      ) : viewMode === "tree" || !blob ? (
-        <>
-          <FileTree
-            entries={entriesWithCommits}
-            path={currentPath}
-            loading={treeLoading}
-            onNavigate={handleEntryClick}
-            onNavigateUp={handleNavigateUp}
-          />
-          {readme && (
-            <div className="rounded-md border">
-              <div className="text-muted-foreground border-b px-4 py-2 text-xs font-medium">
-                README
-              </div>
-              <div className="px-6 py-4">
-                <Markdown content={readme} />
-              </div>
-            </div>
-          )}
-        </>
-      ) : (
-        <FileViewer blob={blob} loading={blobLoading} />
-      )}
-    </div>
-  );
-}

webui2/src/pages/CommitPage.tsx πŸ”—

@@ -1,174 +0,0 @@
-// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full
-// message, parent links, and changed files with lazy diffs.
-
-import { gql } from "@apollo/client";
-import { useQuery } from "@apollo/client/react";
-import { Link, useParams } from "@tanstack/react-router";
-import { format } from "date-fns";
-import { ArrowLeft, GitCommit } from "lucide-react";
-
-import { FileDiffView } from "@/components/code/FileDiffView";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-
-const COMMIT_QUERY = gql`
-  query CommitPageDetail($repo: String, $hash: String!) {
-    repository(ref: $repo) {
-      commit(hash: $hash) {
-        hash
-        shortHash
-        message
-        fullMessage
-        authorName
-        authorEmail
-        date
-        parents
-        files {
-          nodes {
-            path
-            oldPath
-            status
-          }
-        }
-      }
-    }
-  }
-`;
-
-interface CommitQueryData {
-  repository: {
-    commit: {
-      hash: string;
-      shortHash: string;
-      message: string;
-      fullMessage: string;
-      authorName: string;
-      authorEmail: string | null;
-      date: string;
-      parents: string[];
-      files: {
-        nodes: { path: string; oldPath: string | null; status: string }[];
-      } | null;
-    } | null;
-  } | null;
-}
-
-export function CommitPage() {
-  const { hash } = useParams({ strict: false });
-  const repo = useRepo();
-
-  const { data, loading, error } = useQuery<CommitQueryData>(COMMIT_QUERY, {
-    variables: { repo, hash },
-    skip: !hash,
-  });
-
-  if (loading) return <CommitPageSkeleton />;
-
-  if (error) {
-    return (
-      <div className="text-destructive py-16 text-center text-sm">
-        Failed to load commit: {error.message}
-      </div>
-    );
-  }
-
-  const commit = data?.repository?.commit;
-  if (!commit) return null;
-
-  const date = new Date(commit.date);
-  const files = commit.files?.nodes ?? [];
-
-  return (
-    <div>
-      <button
-        onClick={() => {
-          window.history.back();
-        }}
-        className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
-      >
-        <ArrowLeft className="size-3.5" />
-        Back
-      </button>
-
-      <div className="border-border mb-6 rounded-md border p-5">
-        <div className="mb-1 flex items-start gap-3">
-          <GitCommit className="text-muted-foreground mt-1 size-5 shrink-0" />
-          <h1 className="text-lg leading-snug font-semibold">{commit.message}</h1>
-        </div>
-
-        {commit.fullMessage.includes("\n") && (
-          <pre className="text-muted-foreground mt-3 mb-4 ml-8 font-sans text-sm whitespace-pre-wrap">
-            {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
-          </pre>
-        )}
-
-        <div className="text-muted-foreground mt-3 ml-8 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
-          <span>
-            <span className="text-foreground font-medium">{commit.authorName}</span>
-            {commit.authorEmail && <span> &lt;{commit.authorEmail}&gt;</span>}
-          </span>
-          <span title={date.toISOString()}>{format(date, "PPP")}</span>
-        </div>
-
-        <div className="mt-3 ml-8 flex flex-wrap gap-3 text-xs">
-          <span className="text-muted-foreground">
-            commit <code className="text-foreground font-mono">{commit.hash}</code>
-          </span>
-          {commit.parents.map((p: string) => (
-            <span key={p} className="text-muted-foreground">
-              parent{" "}
-              <Link
-                to="/$repo/commit/$hash"
-                params={{ repo: repo!, hash: p }}
-                className="text-foreground font-mono hover:underline"
-              >
-                {p.slice(0, 7)}
-              </Link>
-            </span>
-          ))}
-        </div>
-      </div>
-
-      <div>
-        <h2 className="text-muted-foreground mb-3 text-sm font-semibold">
-          {files.length} file{files.length !== 1 ? "s" : ""} changed
-        </h2>
-        <div className="divide-border border-border divide-y overflow-hidden rounded-md border">
-          {files.length === 0 && (
-            <p className="text-muted-foreground px-4 py-4 text-sm">No file changes.</p>
-          )}
-          {files.map((file: { path: string; oldPath?: string | null; status: string }) => (
-            <FileDiffView
-              key={file.path}
-              hash={commit.hash}
-              path={file.path}
-              oldPath={file.oldPath ?? undefined}
-              status={file.status}
-            />
-          ))}
-        </div>
-      </div>
-    </div>
-  );
-}
-
-function CommitPageSkeleton() {
-  return (
-    <div className="space-y-6">
-      <Skeleton className="h-4 w-24" />
-      <div className="border-border space-y-3 rounded-md border p-5">
-        <Skeleton className="h-6 w-3/4" />
-        <Skeleton className="h-4 w-1/3" />
-        <Skeleton className="h-3 w-1/2" />
-      </div>
-      <div className="divide-border border-border divide-y rounded-md border">
-        {Array.from({ length: 5 }).map((_, i) => (
-          <div key={i} className="flex items-center gap-3 px-4 py-2.5">
-            <Skeleton className="size-4" />
-            <Skeleton className="h-4 flex-1" />
-          </div>
-        ))}
-      </div>
-    </div>
-  );
-}

webui2/src/pages/ErrorPage.tsx πŸ”—

@@ -1,34 +0,0 @@
-// Global error boundary page. Rendered by TanStack Router when a route throws.
-
-import { useRouter } from "@tanstack/react-router";
-import { AlertTriangle } from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-import { ButtonLink } from "@/components/ui/button-link";
-
-export function ErrorPage({ error }: { error?: Error }) {
-  const router = useRouter();
-
-  const message = error?.message ?? "An unexpected error occurred.";
-
-  return (
-    <div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
-      <AlertTriangle className="text-muted-foreground size-10" />
-      <p className="text-muted-foreground text-sm">{message}</p>
-      <div className="flex gap-2">
-        <Button
-          variant="outline"
-          size="sm"
-          onClick={() => {
-            void router.invalidate();
-          }}
-        >
-          Try again
-        </Button>
-        <ButtonLink to="/" variant="outline" size="sm">
-          Go home
-        </ButtonLink>
-      </div>
-    </div>
-  );
-}

webui2/src/pages/IdentitySelectPage.tsx πŸ”—

@@ -1,131 +0,0 @@
-// Identity selection page (/auth/select-identity).
-//
-// Reached after a successful OAuth login when no existing git-bug identity
-// could be matched automatically (via provider metadata set by the bridge).
-// The user can either adopt an existing identity β€” which links it to their
-// OAuth account for future logins β€” or create a fresh one from their OAuth
-// profile.
-
-import { UserCircle, Plus, AlertCircle } from "lucide-react";
-import { useEffect, useState } from "react";
-
-import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
-
-interface IdentityItem {
-  repoSlug: string;
-  id: string;
-  humanId: string;
-  displayName: string;
-  login?: string;
-  avatarUrl?: string;
-}
-
-export function IdentitySelectPage() {
-  const [identities, setIdentities] = useState<IdentityItem[] | null>(null);
-  const [error, setError] = useState<string | null>(null);
-  const [working, setWorking] = useState(false);
-
-  useEffect(() => {
-    async function loadIdentities() {
-      try {
-        const res = await fetch("/auth/identities", { credentials: "include" });
-        if (!res.ok) throw new Error(`unexpected status ${res.status}`);
-        const data: IdentityItem[] = await res.json();
-        setIdentities(data);
-      } catch (e) {
-        setError(String(e));
-      }
-    }
-    void loadIdentities();
-  }, []);
-
-  async function adopt(identityId: string | null) {
-    setWorking(true);
-    try {
-      const res = await fetch("/auth/adopt", {
-        method: "POST",
-        credentials: "include",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify(identityId ? { identityId } : {}),
-      });
-      if (!res.ok) throw new Error(`adopt failed: ${res.status}`);
-      // Full page reload to reset Apollo cache and auth state cleanly.
-      window.location.assign("/");
-    } catch (e) {
-      setError(String(e));
-      setWorking(false);
-    }
-  }
-
-  return (
-    <div className="mx-auto max-w-lg py-12">
-      <div className="mb-2 flex items-center gap-3">
-        <UserCircle className="text-muted-foreground size-6" />
-        <h1 className="text-xl font-semibold">Choose your identity</h1>
-      </div>
-      <p className="text-muted-foreground mb-8 text-sm">
-        No git-bug identity was found linked to your account. Select an existing identity to link
-        it, or create a new one from your profile.
-      </p>
-
-      {error && (
-        <div className="border-destructive/30 bg-destructive/10 text-destructive mb-4 flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
-          <AlertCircle className="size-4 shrink-0" />
-          {error}
-        </div>
-      )}
-
-      {!identities && !error && (
-        <div className="space-y-2">
-          {Array.from({ length: 3 }).map((_, i) => (
-            <Skeleton key={i} className="h-14 w-full rounded-md" />
-          ))}
-        </div>
-      )}
-
-      <div className="divide-border border-border divide-y rounded-md border">
-        {identities?.map((id) => (
-          <div key={id.id} className="flex items-center gap-3 px-4 py-3">
-            <div className="min-w-0 flex-1">
-              <p className="font-medium">{id.displayName}</p>
-              <p className="text-muted-foreground text-xs">
-                {id.login ? `@${id.login} Β· ` : ""}
-                {id.repoSlug} Β· {id.humanId}
-              </p>
-            </div>
-            <Button
-              size="sm"
-              disabled={working}
-              onClick={() => {
-                void adopt(id.id);
-              }}
-            >
-              Adopt
-            </Button>
-          </div>
-        ))}
-
-        {/* Always offer to create a new identity */}
-        <div className="flex items-center gap-3 px-4 py-3">
-          <div className="min-w-0 flex-1">
-            <p className="font-medium">Create new identity</p>
-            <p className="text-muted-foreground text-xs">
-              A fresh git-bug identity will be created from your OAuth profile.
-            </p>
-          </div>
-          <Button
-            size="sm"
-            disabled={working}
-            onClick={() => {
-              void adopt(null);
-            }}
-          >
-            <Plus className="size-4" />
-            Create
-          </Button>
-        </div>
-      </div>
-    </div>
-  );
-}

webui2/src/pages/NewBugPage.tsx πŸ”—

@@ -1,118 +0,0 @@
-import { Link, useNavigate } from "@tanstack/react-router";
-import { ArrowLeft } from "lucide-react";
-import { useState } from "react";
-
-import { useBugCreateMutation } from "@/__generated__/graphql";
-import { Markdown } from "@/components/content/Markdown";
-import { Button } from "@/components/ui/button";
-import { ButtonLink } from "@/components/ui/button-link";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { useRepo } from "@/lib/repo";
-
-// New issue form (/:repo/issues/new). Title + body with write/preview tabs.
-export function NewBugPage() {
-  const navigate = useNavigate();
-  const repo = useRepo();
-  const [title, setTitle] = useState("");
-  const [message, setMessage] = useState("");
-  const [preview, setPreview] = useState(false);
-  const [createBug, { loading, error }] = useBugCreateMutation();
-
-  async function handleSubmit(e: React.FormEvent) {
-    e.preventDefault();
-    const result = await createBug({
-      variables: { input: { title: title.trim(), message: message.trim() } },
-    });
-    const humanId = result.data?.bugCreate.bug.humanId;
-    if (humanId) {
-      void navigate({
-        to: "/$repo/issues/$id",
-        params: { repo: repo!, id: humanId },
-      });
-    }
-  }
-
-  return (
-    <div className="mx-auto max-w-3xl">
-      <Link
-        to="/$repo/issues"
-        params={{ repo: repo! }}
-        className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
-      >
-        <ArrowLeft className="size-3.5" />
-        Back to issues
-      </Link>
-
-      <h1 className="mb-6 text-xl font-semibold">New issue</h1>
-
-      <form
-        onSubmit={(e) => {
-          void handleSubmit(e);
-        }}
-        className="space-y-4"
-      >
-        <Input
-          placeholder="Title"
-          value={title}
-          onChange={(e) => setTitle(e.target.value)}
-          disabled={loading}
-          autoFocus
-        />
-
-        <div>
-          <div className="mb-2">
-            <div className="flex gap-2">
-              <button
-                type="button"
-                onClick={() => setPreview(false)}
-                className={`rounded-sm px-2 py-0.5 transition-colors ${
-                  !preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
-                }`}
-              >
-                Write
-              </button>
-              <button
-                type="button"
-                onClick={() => setPreview(true)}
-                disabled={!message.trim()}
-                className={`rounded-sm px-2 py-0.5 transition-colors disabled:opacity-40 ${
-                  preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
-                }`}
-              >
-                Preview
-              </button>
-            </div>
-          </div>
-
-          {preview ? (
-            <div className="border-input min-h-[200px] rounded-md border px-3 py-2">
-              <Markdown content={message} />
-            </div>
-          ) : (
-            <Textarea
-              placeholder="Describe the issue in detail…"
-              className="min-h-[200px]"
-              value={message}
-              onChange={(e) => setMessage(e.target.value)}
-              disabled={loading}
-            />
-          )}
-        </div>
-
-        {error && (
-          <p className="text-destructive text-sm">Failed to create issue: {error.message}</p>
-        )}
-
-        <div className="flex justify-end gap-2">
-          <ButtonLink to="/$repo/issues" params={{ repo: repo! }} variant="ghost">
-            Cancel
-          </ButtonLink>
-          <Button type="submit" disabled={!title.trim() || loading}>
-            {loading ? "Creating…" : "Submit new issue"}
-          </Button>
-        </div>
-      </form>
-    </div>
-  );
-}

webui2/src/pages/RepoPickerPage.tsx πŸ”—

@@ -1,79 +0,0 @@
-// Repository picker page (/). Auto-redirects when there is exactly one repo.
-// Shows a list when multiple repos are registered.
-
-import { Link, useNavigate } from "@tanstack/react-router";
-import { GitFork, FolderOpen, AlertCircle } from "lucide-react";
-import { useEffect } from "react";
-
-import { useRepositoriesQuery } from "@/__generated__/graphql";
-import { Skeleton } from "@/components/ui/skeleton";
-
-function repoSlug(name: string | null | undefined): string {
-  return name ?? "_";
-}
-
-function repoLabel(name: string | null | undefined): string {
-  return name ?? "default";
-}
-
-export function RepoPickerPage() {
-  const { data, loading, error } = useRepositoriesQuery();
-  const navigate = useNavigate();
-
-  // Auto-redirect when there is exactly one repo β€” no need to pick.
-  useEffect(() => {
-    if (data?.repositories.nodes.length === 1) {
-      void navigate({
-        to: "/$repo",
-        params: { repo: repoSlug(data.repositories.nodes[0].name) },
-        search: { ref: "", path: "", type: "tree" as const },
-        replace: true,
-      });
-    }
-  }, [data, navigate]);
-
-  return (
-    <div className="mx-auto max-w-lg py-12">
-      <div className="mb-8 flex items-center gap-3">
-        <GitFork className="text-muted-foreground size-6" />
-        <h1 className="text-xl font-semibold">Repositories</h1>
-      </div>
-
-      {error && (
-        <div className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
-          <AlertCircle className="size-4 shrink-0" />
-          Failed to load repositories: {error.message}
-        </div>
-      )}
-
-      {loading && !data && (
-        <div className="space-y-2">
-          {Array.from({ length: 3 }).map((_, i) => (
-            <Skeleton key={i} className="h-16 w-full rounded-md" />
-          ))}
-        </div>
-      )}
-
-      <div className="divide-border border-border divide-y rounded-md border">
-        {data?.repositories.nodes.map((repo) => (
-          <Link
-            key={repoSlug(repo.name)}
-            to="/$repo"
-            params={{ repo: repoSlug(repo.name) }}
-            search={{ ref: "", path: "", type: "tree" as const }}
-            className="hover:bg-muted/50 flex items-center gap-3 px-4 py-4 transition-colors"
-          >
-            <FolderOpen className="text-muted-foreground size-5 shrink-0" />
-            <p className="text-foreground font-medium">{repoLabel(repo.name)}</p>
-          </Link>
-        ))}
-
-        {data?.repositories.totalCount === 0 && (
-          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
-            No repositories found.
-          </p>
-        )}
-      </div>
-    </div>
-  );
-}

webui2/src/pages/UserProfilePage.tsx πŸ”—

@@ -1,292 +0,0 @@
-// 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.
-
-import { useParams, Link } from "@tanstack/react-router";
-import { formatDistanceToNow } from "date-fns";
-import {
-  ArrowLeft,
-  MessageSquare,
-  CircleDot,
-  CircleCheck,
-  ShieldCheck,
-  ChevronLeft,
-  ChevronRight,
-} from "lucide-react";
-import { useState } from "react";
-
-import { Status, useUserProfileQuery } from "@/__generated__/graphql";
-import { LabelBadge } from "@/components/bugs/LabelBadge";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-import { cn } from "@/lib/utils";
-
-const PAGE_SIZE = 25;
-
-export function UserProfilePage() {
-  const { id } = useParams({ strict: false });
-  const repo = useRepo();
-  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: repo,
-      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>
-    );
-  }
-
-  if (loading && !data) return <ProfileSkeleton />;
-
-  const identity = data?.repository?.identity;
-  if (!identity) {
-    return <div className="text-muted-foreground py-16 text-center text-sm">User not found.</div>;
-  }
-
-  const openCount = data?.repository?.openCount.totalCount ?? 0;
-  const closedCount = data?.repository?.closedCount.totalCount ?? 0;
-
-  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));
-  }
-
-  return (
-    <div>
-      <Link
-        to="/$repo/issues"
-        params={{ repo: repo! }}
-        className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
-      >
-        <ArrowLeft className="size-3.5" />
-        Back to issues
-      </Link>
-
-      {/* ── Profile header ─────────────────────────────────────────────── */}
-      <div className="mb-8 flex items-start gap-5">
-        <Avatar className="size-20">
-          <AvatarImage src={identity.avatarUrl ?? undefined} alt={identity.displayName} />
-          <AvatarFallback className="text-2xl">
-            {identity.displayName.slice(0, 2).toUpperCase()}
-          </AvatarFallback>
-        </Avatar>
-
-        <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" />
-              </span>
-            )}
-          </div>
-          <div className="text-muted-foreground mt-1 space-y-0.5 text-sm">
-            {identity.login && <p>@{identity.login}</p>}
-            {identity.email && <p>{identity.email}</p>}
-            <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" />
-              <span className="text-foreground font-medium">{openCount}</span> open
-            </span>
-            <span className="text-muted-foreground flex items-center gap-1">
-              <CircleCheck className="size-3.5 text-purple-600 dark:text-purple-400" />
-              <span className="text-foreground font-medium">{closedCount}</span> closed
-            </span>
-          </div>
-        </div>
-      </div>
-
-      {/* ── Issue list ─────────────────────────────────────────────────── */}
-      <div className="border-border rounded-md border">
-        {/* Open / Closed toggle β€” mirrors BugListPage style */}
-        <div className="border-border flex items-center gap-1 border-b px-4 py-2">
-          <button
-            onClick={() => switchStatus("open")}
-            className={cn(
-              "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
-              statusFilter === "open"
-                ? "bg-accent text-accent-foreground"
-                : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
-            )}
-          >
-            <CircleDot
-              className={cn(
-                "size-4",
-                statusFilter === "open" && "text-green-600 dark:text-green-400",
-              )}
-            />
-            Open
-            <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
-              {openCount}
-            </span>
-          </button>
-
-          <button
-            onClick={() => switchStatus("closed")}
-            className={cn(
-              "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
-              statusFilter === "closed"
-                ? "bg-accent text-accent-foreground"
-                : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
-            )}
-          >
-            <CircleCheck
-              className={cn(
-                "size-4",
-                statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
-              )}
-            />
-            Closed
-            <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
-              {closedCount}
-            </span>
-          </button>
-        </div>
-
-        {bugs?.nodes.length === 0 && (
-          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
-            No {statusFilter} issues.
-          </p>
-        )}
-
-        {bugs?.nodes.map((bug) => {
-          const isOpen = bug.status === Status.Open;
-          const StatusIcon = isOpen ? CircleDot : CircleCheck;
-          return (
-            <div
-              key={bug.id}
-              className="border-border flex items-start gap-3 border-b px-4 py-3 last:border-0"
-            >
-              <StatusIcon
-                className={cn(
-                  "mt-0.5 size-4 shrink-0",
-                  isOpen
-                    ? "text-green-600 dark:text-green-400"
-                    : "text-purple-600 dark:text-purple-400",
-                )}
-              />
-              <div className="min-w-0 flex-1">
-                <div className="flex flex-wrap items-baseline gap-2">
-                  <Link
-                    to="/$repo/issues/$id"
-                    params={{ repo: repo!, id: bug.humanId }}
-                    className="text-foreground hover:text-primary font-medium hover:underline"
-                  >
-                    {bug.title}
-                  </Link>
-                  {bug.labels.map((label) => (
-                    <LabelBadge key={label.name} name={label.name} color={label.color} />
-                  ))}
-                </div>
-                <p className="text-muted-foreground mt-0.5 text-xs">
-                  #{bug.humanId} opened{" "}
-                  {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
-                </p>
-              </div>
-              {bug.comments.totalCount > 0 && (
-                <div className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs">
-                  <MessageSquare className="size-3.5" />
-                  {bug.comments.totalCount}
-                </div>
-              )}
-            </div>
-          );
-        })}
-
-        {/* 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
-              variant="ghost"
-              size="sm"
-              onClick={goPrev}
-              disabled={!hasPrev || loading}
-              className="text-muted-foreground gap-1"
-            >
-              <ChevronLeft className="size-4" />
-              Previous
-            </Button>
-            <span className="text-muted-foreground text-sm">
-              Page {page + 1} of {totalPages}
-            </span>
-            <Button
-              variant="ghost"
-              size="sm"
-              onClick={goNext}
-              disabled={!hasNext || loading}
-              className="text-muted-foreground gap-1"
-            >
-              Next
-              <ChevronRight className="size-4" />
-            </Button>
-          </div>
-        )}
-      </div>
-    </div>
-  );
-}
-
-function ProfileSkeleton() {
-  return (
-    <div className="space-y-6">
-      <div className="flex items-start gap-5">
-        <Skeleton className="size-20 rounded-full" />
-        <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" />
-        </div>
-      </div>
-      <div className="space-y-2">
-        {Array.from({ length: 4 }).map((_, i) => (
-          <Skeleton key={i} className="h-14 w-full" />
-        ))}
-      </div>
-    </div>
-  );
-}

webui2/src/routes/$repo/commit/$hash.tsx πŸ”—

@@ -1,7 +1,171 @@
-import { createFileRoute } from "@tanstack/react-router";
+// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full
+// message, parent links, and changed files with lazy diffs.
 
-import { CommitPage } from "@/pages/CommitPage";
+import { gql } from "@apollo/client";
+import { useReadQuery } from "@apollo/client/react";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { format } from "date-fns";
+import { ArrowLeft, GitCommit } from "lucide-react";
+
+import { FileDiffView } from "@/components/code/FileDiffView";
+import { Skeleton } from "@/components/ui/skeleton";
+import { preloadQuery } from "@/lib/apollo";
+import { useRepo } from "@/lib/repo";
+
+const COMMIT_QUERY = gql`
+  query CommitPageDetail($repo: String, $hash: String!) {
+    repository(ref: $repo) {
+      commit(hash: $hash) {
+        hash
+        shortHash
+        message
+        fullMessage
+        authorName
+        authorEmail
+        date
+        parents
+        files {
+          nodes {
+            path
+            oldPath
+            status
+          }
+        }
+      }
+    }
+  }
+`;
+
+interface CommitQueryData {
+  repository: {
+    commit: {
+      hash: string;
+      shortHash: string;
+      message: string;
+      fullMessage: string;
+      authorName: string;
+      authorEmail: string | null;
+      date: string;
+      parents: string[];
+      files: {
+        nodes: { path: string; oldPath: string | null; status: string }[];
+      } | null;
+    } | null;
+  } | null;
+}
 
 export const Route = createFileRoute("/$repo/commit/$hash")({
-  component: CommitPage,
+  component: RouteComponent,
+  pendingComponent: CommitPageSkeleton,
+  loader: ({ params: { repo, hash } }) => ({
+    commitRef: preloadQuery<CommitQueryData>(COMMIT_QUERY, {
+      variables: { repo: repo === "_" ? null : repo, hash },
+    }),
+  }),
 });
+
+function RouteComponent() {
+  const repo = useRepo();
+  const { commitRef } = Route.useLoaderData();
+  const { data } = useReadQuery(commitRef);
+
+  const commit = data?.repository?.commit;
+  if (!commit) return null;
+
+  const date = new Date(commit.date);
+  const files = commit.files?.nodes ?? [];
+
+  return (
+    <div>
+      <button
+        onClick={() => {
+          window.history.back();
+        }}
+        className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
+      >
+        <ArrowLeft className="size-3.5" />
+        Back
+      </button>
+
+      <div className="border-border mb-6 rounded-md border p-5">
+        <div className="mb-1 flex items-start gap-3">
+          <GitCommit className="text-muted-foreground mt-1 size-5 shrink-0" />
+          <h1 className="text-lg leading-snug font-semibold">{commit.message}</h1>
+        </div>
+
+        {commit.fullMessage.includes("\n") && (
+          <pre className="text-muted-foreground mt-3 mb-4 ml-8 font-sans text-sm whitespace-pre-wrap">
+            {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
+          </pre>
+        )}
+
+        <div className="text-muted-foreground mt-3 ml-8 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
+          <span>
+            <span className="text-foreground font-medium">{commit.authorName}</span>
+            {commit.authorEmail && <span> &lt;{commit.authorEmail}&gt;</span>}
+          </span>
+          <span title={date.toISOString()}>{format(date, "PPP")}</span>
+        </div>
+
+        <div className="mt-3 ml-8 flex flex-wrap gap-3 text-xs">
+          <span className="text-muted-foreground">
+            commit <code className="text-foreground font-mono">{commit.hash}</code>
+          </span>
+          {commit.parents.map((p: string) => (
+            <span key={p} className="text-muted-foreground">
+              parent{" "}
+              <Link
+                to="/$repo/commit/$hash"
+                params={{ repo: repo!, hash: p }}
+                className="text-foreground font-mono hover:underline"
+              >
+                {p.slice(0, 7)}
+              </Link>
+            </span>
+          ))}
+        </div>
+      </div>
+
+      <div>
+        <h2 className="text-muted-foreground mb-3 text-sm font-semibold">
+          {files.length} file{files.length !== 1 ? "s" : ""} changed
+        </h2>
+        <div className="divide-border border-border divide-y overflow-hidden rounded-md border">
+          {files.length === 0 && (
+            <p className="text-muted-foreground px-4 py-4 text-sm">No file changes.</p>
+          )}
+          {files.map((file: { path: string; oldPath?: string | null; status: string }) => (
+            <FileDiffView
+              key={file.path}
+              hash={commit.hash}
+              path={file.path}
+              oldPath={file.oldPath ?? undefined}
+              status={file.status}
+            />
+          ))}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function CommitPageSkeleton() {
+  return (
+    <div className="space-y-6">
+      <Skeleton className="h-4 w-24" />
+      <div className="border-border space-y-3 rounded-md border p-5">
+        <Skeleton className="h-6 w-3/4" />
+        <Skeleton className="h-4 w-1/3" />
+        <Skeleton className="h-3 w-1/2" />
+      </div>
+      <div className="divide-border border-border divide-y rounded-md border">
+        {Array.from({ length: 5 }).map((_, i) => (
+          <div key={i} className="flex items-center gap-3 px-4 py-2.5">
+            <Skeleton className="size-4" />
+            <Skeleton className="h-4 flex-1" />
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

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

@@ -1,6 +1,109 @@
-import { createFileRoute } from "@tanstack/react-router";
+// Code browser page. Switches between tree view, file viewer, and commit
+// history via ?type= search param. Ref is selected via ?ref=.
 
-import { CodePage } from "@/pages/CodePage";
+import { gql } from "@apollo/client";
+import { useQuery, useReadQuery } from "@apollo/client/react";
+import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
+import { AlertCircle, GitCommit } from "lucide-react";
+import { useEffect } from "react";
+
+import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from "@/__generated__/graphql";
+import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
+import { CommitList } from "@/components/code/CommitList";
+import { FileTree } from "@/components/code/FileTree";
+import type { TreeEntryWithCommit } from "@/components/code/FileTree";
+import { FileViewer } from "@/components/code/FileViewer";
+import { RefSelector } from "@/components/code/RefSelector";
+import { Markdown } from "@/components/content/Markdown";
+import { ButtonLink } from "@/components/ui/button-link";
+import { Skeleton } from "@/components/ui/skeleton";
+import { preloadQuery } from "@/lib/apollo";
+import { useRepo } from "@/lib/repo";
+
+const REFS_QUERY = gql`
+  query CodePageRefs($repo: String) {
+    repository(ref: $repo) {
+      name
+      refs {
+        nodes {
+          name
+          shortName
+          type
+          hash
+          isDefault
+        }
+      }
+    }
+  }
+`;
+
+const TREE_QUERY = gql`
+  query CodePageTree($repo: String, $ref: String!, $path: String) {
+    repository(ref: $repo) {
+      tree(ref: $ref, path: $path) {
+        name
+        type
+        hash
+      }
+    }
+  }
+`;
+
+const LAST_COMMITS_QUERY = gql`
+  query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
+    repository(ref: $repo) {
+      lastCommits(ref: $ref, path: $path, names: $names) {
+        name
+        commit {
+          hash
+          shortHash
+          message
+          date
+        }
+      }
+    }
+  }
+`;
+
+const BLOB_QUERY = gql`
+  query CodePageBlob($repo: String, $ref: String!, $path: String!) {
+    repository(ref: $repo) {
+      blob(ref: $ref, path: $path) {
+        path
+        hash
+        text
+        size
+        isBinary
+        isTruncated
+      }
+    }
+  }
+`;
+
+interface RefsQueryData {
+  repository: {
+    name: string;
+    refs: { nodes: GitRef[] } | null;
+  } | null;
+}
+
+interface TreeQueryData {
+  repository: {
+    tree: GitTreeEntry[] | null;
+  } | null;
+}
+
+interface LastCommitsQueryData {
+  repository: {
+    lastCommits: GitLastCommit[] | null;
+  } | null;
+}
+
+interface BlobQueryData {
+  repository: {
+    blob: GitBlob | null;
+  } | null;
+}
 
 export type CodePageSearch = {
   ref: string;
@@ -8,8 +111,11 @@ export type CodePageSearch = {
   type: "tree" | "blob" | "commits";
 };
 
+type ViewMode = CodePageSearch["type"];
+
 export const Route = createFileRoute("/$repo/")({
-  component: CodePage,
+  component: RouteComponent,
+  pendingComponent: CodePageSkeleton,
   validateSearch: (search: Record<string, unknown>): CodePageSearch => ({
     ref: (search.ref as string) ?? "",
     path: (search.path as string) ?? "",
@@ -17,4 +123,179 @@ export const Route = createFileRoute("/$repo/")({
       ? (search.type as CodePageSearch["type"])
       : "tree",
   }),
+  loader: ({ params: { repo } }) => ({
+    refsRef: preloadQuery<RefsQueryData>(REFS_QUERY, {
+      variables: { repo: repo === "_" ? null : repo },
+    }),
+  }),
 });
+
+function RouteComponent() {
+  const repo = useRepo();
+  const navigate = useNavigate({ from: "/$repo/" });
+  const { ref: currentRef, path: currentPath, type: viewMode } = useSearch({ from: "/$repo/" });
+
+  const { refsRef } = Route.useLoaderData();
+  const { data: refsData, error: refsError } = useReadQuery(refsRef);
+  const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? [];
+
+  // Set default ref once loaded
+  useEffect(() => {
+    if (refs.length === 0 || currentRef) return;
+    const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
+    if (defaultRef) {
+      void navigate({
+        search: (prev) => ({ ...prev, ref: defaultRef.shortName }),
+        replace: true,
+      });
+    }
+  }, [refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  const inTreeMode = viewMode === "tree" && !!currentRef;
+  const inBlobMode = viewMode === "blob" && !!currentRef && !!currentPath;
+
+  const { data: treeData, loading: treeLoading } = useQuery<TreeQueryData>(TREE_QUERY, {
+    variables: { repo, ref: currentRef, path: currentPath || null },
+    skip: !inTreeMode,
+  });
+  const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
+
+  const entryNames = entries.map((e: GitTreeEntry) => e.name);
+  const { data: lastCommitsData } = useQuery<LastCommitsQueryData>(LAST_COMMITS_QUERY, {
+    variables: { repo, ref: currentRef, path: currentPath || null, names: entryNames },
+    skip: !inTreeMode || entryNames.length === 0,
+  });
+  const lastCommitsByName = new Map<string, GitLastCommit>(
+    (lastCommitsData?.repository?.lastCommits ?? []).map((lc: GitLastCommit) => [lc.name, lc]),
+  );
+  const entriesWithCommits: TreeEntryWithCommit[] = entries.map((e: GitTreeEntry) => ({
+    ...e,
+    lastCommit: lastCommitsByName.get(e.name)?.commit ?? undefined,
+  }));
+
+  const { data: blobData, loading: blobLoading } = useQuery<BlobQueryData>(BLOB_QUERY, {
+    variables: { repo, ref: currentRef, path: currentPath },
+    skip: !inBlobMode,
+  });
+  const blob: GitBlob | null = blobData?.repository?.blob ?? null;
+
+  const readmeEntry = entries.find(
+    (e: GitTreeEntry) => e.type === "BLOB" && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
+  );
+  const readmePath = readmeEntry
+    ? currentPath
+      ? `${currentPath}/${readmeEntry.name}`
+      : readmeEntry.name
+    : null;
+  const { data: readmeBlobData } = useQuery<BlobQueryData>(BLOB_QUERY, {
+    variables: { repo, ref: currentRef, path: readmePath },
+    skip: !inTreeMode || !readmePath,
+  });
+  const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
+
+  const repoName = refsData?.repository?.name ?? repo ?? "default-repo";
+
+  function navigateTo(path: string, type: ViewMode = "tree") {
+    void navigate({ search: (prev) => ({ ...prev, path, type }) });
+  }
+
+  function handleEntryClick(entry: TreeEntryWithCommit) {
+    const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+    navigateTo(newPath, entry.type === "BLOB" ? "blob" : "tree");
+  }
+
+  function handleNavigateUp() {
+    const parts = currentPath.split("/").filter(Boolean);
+    parts.pop();
+    navigateTo(parts.join("/"), "tree");
+  }
+
+  function handleRefSelect(ref: GitRef) {
+    void navigate({ search: { ref: ref.shortName, path: "", type: "tree" } });
+  }
+
+  if (refsError) {
+    return (
+      <div className="flex flex-col items-center gap-3 py-16 text-center">
+        <AlertCircle className="text-muted-foreground size-8" />
+        <p className="text-sm font-medium">Code browser unavailable</p>
+        <p className="text-muted-foreground max-w-sm text-xs">{refsError.message}</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="flex flex-wrap items-center justify-between gap-3">
+        <CodeBreadcrumb
+          repoName={repoName}
+          ref={currentRef}
+          path={currentPath}
+          onNavigate={(p) => navigateTo(p, "tree")}
+        />
+        <div className="flex items-center gap-2">
+          <ButtonLink
+            to="/$repo"
+            params={{ repo: repo! }}
+            search={{
+              ref: currentRef,
+              path: currentPath,
+              type: viewMode === "commits" ? "tree" : "commits",
+            }}
+            variant={viewMode === "commits" ? "secondary" : "outline"}
+            size="sm"
+          >
+            <GitCommit className="size-3.5" />
+            History
+          </ButtonLink>
+          <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
+        </div>
+      </div>
+
+      {viewMode === "commits" ? (
+        <CommitList ref_={currentRef} path={currentPath || undefined} />
+      ) : viewMode === "tree" || !blob ? (
+        <>
+          <FileTree
+            entries={entriesWithCommits}
+            path={currentPath}
+            loading={treeLoading}
+            onNavigate={handleEntryClick}
+            onNavigateUp={handleNavigateUp}
+          />
+          {readme && (
+            <div className="rounded-md border">
+              <div className="text-muted-foreground border-b px-4 py-2 text-xs font-medium">
+                README
+              </div>
+              <div className="px-6 py-4">
+                <Markdown content={readme} />
+              </div>
+            </div>
+          )}
+        </>
+      ) : (
+        <FileViewer blob={blob} loading={blobLoading} />
+      )}
+    </div>
+  );
+}
+
+function CodePageSkeleton() {
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <Skeleton className="h-5 w-48" />
+        <Skeleton className="h-8 w-28" />
+      </div>
+      <div className="divide-border border-border divide-y rounded-md border">
+        {Array.from({ length: 8 }).map((_, i) => (
+          <div key={i} className="flex items-center gap-3 px-4 py-2">
+            <Skeleton className="size-4 rounded-sm" />
+            <Skeleton className="h-4 w-32" />
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

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

@@ -1,7 +1,137 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { useReadQuery } from "@apollo/client/react";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { formatDistanceToNow } from "date-fns";
+import { ArrowLeft } from "lucide-react";
 
-import { BugDetailPage } from "@/pages/BugDetailPage";
+import { type BugDetailQuery, BugDetailDocument } from "@/__generated__/graphql";
+import { CommentBox } from "@/components/bugs/CommentBox";
+import { LabelEditor } from "@/components/bugs/LabelEditor";
+import { StatusBadge } from "@/components/bugs/StatusBadge";
+import { Timeline } from "@/components/bugs/Timeline";
+import { TitleEditor } from "@/components/bugs/TitleEditor";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { preloadQuery } from "@/lib/apollo";
+import { useRepo } from "@/lib/repo";
 
 export const Route = createFileRoute("/$repo/issues/$id")({
-  component: BugDetailPage,
+  component: RouteComponent,
+  pendingComponent: BugDetailSkeleton,
+  loader: ({ params: { repo, id } }) => ({
+    bugDetailRef: preloadQuery<BugDetailQuery>(BugDetailDocument, {
+      variables: { ref: repo === "_" ? null : repo, prefix: id },
+    }),
+  }),
 });
+
+// Issue detail page (/:repo/issues/:id). Shows title, status, timeline of
+// comments and events, and a sidebar with labels and participants.
+function RouteComponent() {
+  const repo = useRepo();
+  const { bugDetailRef } = Route.useLoaderData();
+  const { data } = useReadQuery(bugDetailRef);
+
+  const bug = data?.repository?.bug;
+  if (!bug) {
+    return <div className="text-muted-foreground py-16 text-center text-sm">Issue not found.</div>;
+  }
+
+  return (
+    <div>
+      <Link
+        to="/$repo/issues"
+        params={{ repo: repo! }}
+        className="text-muted-foreground hover:text-foreground mb-4 flex items-center gap-1.5 text-sm"
+      >
+        <ArrowLeft className="size-3.5" />
+        Back to issues
+      </Link>
+
+      {/* Title row β€” hover reveals edit button when logged in */}
+      <div className="mb-3">
+        <TitleEditor bugPrefix={bug.humanId} title={bug.title} humanId={bug.humanId} ref_={repo} />
+      </div>
+
+      <div className="text-muted-foreground mb-6 flex flex-wrap items-center gap-3 text-sm">
+        <StatusBadge status={bug.status} />
+        <span>
+          <Link
+            to="/$repo/user/$id"
+            params={{ repo: repo!, id: bug.author.humanId }}
+            className="text-foreground font-medium hover:underline"
+          >
+            {bug.author.displayName}
+          </Link>{" "}
+          opened this issue {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
+        </span>
+      </div>
+
+      <Separator className="mb-6" />
+
+      <div className="flex gap-8">
+        {/* Timeline + comment box */}
+        <div className="min-w-0 flex-1 space-y-4">
+          <Timeline bugPrefix={bug.humanId} items={bug.timeline.nodes} />
+          <CommentBox bugPrefix={bug.humanId} bugStatus={bug.status} ref_={repo} />
+        </div>
+
+        {/* Sidebar */}
+        <aside className="w-56 shrink-0 space-y-6">
+          <LabelEditor bugPrefix={bug.humanId} currentLabels={bug.labels} ref_={repo} />
+
+          <Separator />
+
+          <div>
+            <h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
+              Participants
+            </h3>
+            <div className="flex flex-wrap gap-1.5">
+              {bug.participants.nodes.map((p) => {
+                return (
+                  <Link
+                    key={p.id}
+                    to="/$repo/user/$id"
+                    params={{ repo: repo!, id: p.humanId }}
+                    title={p.displayName}
+                  >
+                    <Avatar className="size-6">
+                      <AvatarImage src={p.avatarUrl ?? undefined} alt={p.displayName} />
+                      <AvatarFallback className="text-[10px]">
+                        {p.displayName.slice(0, 2).toUpperCase()}
+                      </AvatarFallback>
+                    </Avatar>
+                  </Link>
+                );
+              })}
+            </div>
+          </div>
+        </aside>
+      </div>
+    </div>
+  );
+}
+
+function BugDetailSkeleton() {
+  return (
+    <div className="space-y-4">
+      <Skeleton className="h-8 w-2/3" />
+      <Skeleton className="h-4 w-1/3" />
+      <Separator />
+      <div className="flex gap-8">
+        <div className="flex-1 space-y-4">
+          {Array.from({ length: 3 }).map((_, i) => (
+            <div key={i} className="border-border rounded-md border p-4">
+              <Skeleton className="mb-3 h-4 w-1/4" />
+              <Skeleton className="h-16 w-full" />
+            </div>
+          ))}
+        </div>
+        <div className="w-56 space-y-3">
+          <Skeleton className="h-4 w-full" />
+          <Skeleton className="h-4 w-3/4" />
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -1,7 +1,393 @@
 import { createFileRoute } from "@tanstack/react-router";
+import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
+import { useState, useEffect } from "react";
 
-import { BugListPage } from "@/pages/BugListPage";
+import { useBugListQuery } from "@/__generated__/graphql";
+import { BugRow } from "@/components/bugs/BugRow";
+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 { Skeleton } from "@/components/ui/skeleton";
+import { useRepo } from "@/lib/repo";
+import { cn } from "@/lib/utils";
 
 export const Route = createFileRoute("/$repo/issues/")({
-  component: BugListPage,
+  component: RouteComponent,
 });
+
+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 openQuery = `status:open ${baseQuery}`.trim();
+  const closedQuery = `status:closed ${baseQuery}`.trim();
+  const listQuery = buildQueryString(
+    statusFilter,
+    selectedLabels,
+    selectedAuthorQuery,
+    freeText,
+    sort,
+  );
+
+  const { data, loading, error } = useBugListQuery({
+    variables: {
+      ref: repo,
+      openQuery,
+      closedQuery,
+      listQuery,
+      first: PAGE_SIZE,
+      after: cursors[page],
+    },
+  });
+
+  const openCount = data?.repository?.openCount.totalCount ?? 0;
+  const closedCount = data?.repository?.closedCount.totalCount ?? 0;
+  const bugs = data?.repository?.bugs;
+  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;
+
+  // Reset to page 1 whenever the list query changes.
+  useEffect(() => {
+    setCursors([undefined]);
+  }, [listQuery]);
+
+  // Apply all filters at once, keeping draft in sync with the structured state.
+  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));
+  }
+
+  // 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.
+  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]);
+  }
+
+  function goPrev() {
+    setCursors((prev) => prev.slice(0, -1));
+  }
+
+  return (
+    <div>
+      {/* Search bar */}
+      <form onSubmit={handleSearch} className="mb-4 flex gap-2">
+        <QueryInput
+          value={draft}
+          onChange={setDraft}
+          onSubmit={handleSearch}
+          placeholder="status:open author:… label:…"
+        />
+        <Button type="submit">Search</Button>
+      </form>
+
+      {/* List container */}
+      <div className="border-border rounded-md border">
+        {/* 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,
+                )
+              }
+              className={cn(
+                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+                statusFilter === "open"
+                  ? "bg-accent text-accent-foreground"
+                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+              )}
+            >
+              <CircleDot
+                className={cn(
+                  "size-4",
+                  statusFilter === "open" && "text-green-600 dark:text-green-400",
+                )}
+              />
+              Open
+              <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
+                {openCount}
+              </span>
+            </button>
+
+            <button
+              onClick={() =>
+                applyFilters(
+                  "closed",
+                  selectedLabels,
+                  selectedAuthorId,
+                  selectedAuthorQuery,
+                  freeText,
+                )
+              }
+              className={cn(
+                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+                statusFilter === "closed"
+                  ? "bg-accent text-accent-foreground"
+                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+              )}
+            >
+              <CircleCheck
+                className={cn(
+                  "size-4",
+                  statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
+                )}
+              />
+              Closed
+              <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
+                {closedCount}
+              </span>
+            </button>
+          </div>
+
+          <div className="ml-auto">
+            <IssueFilters
+              selectedLabels={selectedLabels}
+              onLabelsChange={(labels) =>
+                applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)
+              }
+              selectedAuthorId={selectedAuthorId}
+              onAuthorChange={(id, qv) =>
+                applyFilters(statusFilter, selectedLabels, id, qv, freeText)
+              }
+              recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
+              sort={sort}
+              onSortChange={(s) =>
+                applyFilters(
+                  statusFilter,
+                  selectedLabels,
+                  selectedAuthorId,
+                  selectedAuthorQuery,
+                  freeText,
+                  s,
+                )
+              }
+            />
+          </div>
+        </div>
+
+        {/* Bug rows */}
+        {error && (
+          <p className="text-destructive px-4 py-8 text-center text-sm">
+            Failed to load issues: {error.message}
+          </p>
+        )}
+
+        {loading && !data && <BugListSkeleton />}
+
+        {bugs?.nodes.length === 0 && (
+          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
+            No {statusFilter} issues found.
+          </p>
+        )}
+
+        {bugs?.nodes.map((bug) => (
+          <BugRow
+            key={bug.id}
+            id={bug.id}
+            humanId={bug.humanId}
+            status={bug.status}
+            title={bug.title}
+            labels={bug.labels}
+            author={bug.author}
+            createdAt={bug.createdAt}
+            commentCount={bug.comments.totalCount}
+            repo={repo}
+            onLabelClick={(name) => {
+              if (!selectedLabels.includes(name)) {
+                applyFilters(
+                  statusFilter,
+                  [...selectedLabels, name],
+                  selectedAuthorId,
+                  selectedAuthorQuery,
+                  freeText,
+                );
+              }
+            }}
+          />
+        ))}
+
+        {totalPages > 1 && (
+          <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={goPrev}
+              disabled={!hasPrev || loading}
+              className="text-muted-foreground gap-1"
+            >
+              <ChevronLeft className="size-4" />
+              Previous
+            </Button>
+            <span className="text-muted-foreground text-sm">
+              Page {page + 1} of {totalPages}
+            </span>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={goNext}
+              disabled={!hasNext || loading}
+              className="text-muted-foreground gap-1"
+            >
+              Next
+              <ChevronRight className="size-4" />
+            </Button>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
+// buildBaseQuery returns the filter parts (labels, author, freeText) without
+// the status prefix, so it can be combined with "status:open" / "status:closed".
+function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
+  const parts: string[] = [];
+  for (const label of labels) {
+    parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
+  }
+  if (author) {
+    parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
+  }
+  if (freeText.trim()) parts.push(freeText.trim());
+  return parts.join(" ");
+}
+
+// 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[],
+  author: string | null,
+  freeText: string,
+  sort: SortValue = "creation-desc",
+): string {
+  const parts = [`status:${status}`];
+  const base = buildBaseQuery(labels, author, freeText);
+  if (base) parts.push(base);
+  if (sort !== "creation-desc") parts.push(`sort:${sort}`);
+  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.
+function tokenizeQuery(input: string): string[] {
+  const tokens: string[] = [];
+  let current = "";
+  let inQuote = false;
+  for (const ch of input.trim()) {
+    if (ch === '"') {
+      inQuote = !inQuote;
+      current += ch;
+    } else if (ch === " " && !inQuote) {
+      if (current) {
+        tokens.push(current);
+        current = "";
+      }
+    } else current += ch;
+  }
+  if (current) tokens.push(current);
+  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.
+const VALID_SORTS = new Set<SortValue>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
+
+function parseQueryString(input: string): {
+  status: StatusFilter;
+  labels: string[];
+  author: string | null;
+  freeText: string;
+  sort: SortValue;
+} {
+  let status: StatusFilter = "open";
+  const labels: string[] = [];
+  let author: string | null = null;
+  let sort: SortValue = "creation-desc";
+  const free: string[] = [];
+
+  for (const token of tokenizeQuery(input)) {
+    if (token === "status:open") status = "open";
+    else if (token === "status:closed") status = "closed";
+    else if (token.startsWith("label:")) labels.push(token.slice(6));
+    else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
+    else if (token.startsWith("sort:")) {
+      const v = token.slice(5) as SortValue;
+      if (VALID_SORTS.has(v)) sort = v;
+    } else free.push(token);
+  }
+
+  return { status, labels, author, freeText: free.join(" "), sort };
+}
+
+function BugListSkeleton() {
+  return (
+    <div className="divide-border divide-y">
+      {Array.from({ length: 8 }).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>
+  );
+}

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

@@ -1,7 +1,122 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
+import { ArrowLeft } from "lucide-react";
+import { useState } from "react";
 
-import { NewBugPage } from "@/pages/NewBugPage";
+import { useBugCreateMutation } from "@/__generated__/graphql";
+import { Markdown } from "@/components/content/Markdown";
+import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { useRepo } from "@/lib/repo";
 
 export const Route = createFileRoute("/$repo/issues/new")({
-  component: NewBugPage,
+  component: RouteComponent,
 });
+
+// New issue form (/:repo/issues/new). Title + body with write/preview tabs.
+function RouteComponent() {
+  const navigate = useNavigate();
+  const repo = useRepo();
+  const [title, setTitle] = useState("");
+  const [message, setMessage] = useState("");
+  const [preview, setPreview] = useState(false);
+  const [createBug, { loading, error }] = useBugCreateMutation();
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    const result = await createBug({
+      variables: { input: { title: title.trim(), message: message.trim() } },
+    });
+    const humanId = result.data?.bugCreate.bug.humanId;
+    if (humanId) {
+      void navigate({
+        to: "/$repo/issues/$id",
+        params: { repo: repo!, id: humanId },
+      });
+    }
+  }
+
+  return (
+    <div className="mx-auto max-w-3xl">
+      <Link
+        to="/$repo/issues"
+        params={{ repo: repo! }}
+        className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
+      >
+        <ArrowLeft className="size-3.5" />
+        Back to issues
+      </Link>
+
+      <h1 className="mb-6 text-xl font-semibold">New issue</h1>
+
+      <form
+        onSubmit={(e) => {
+          void handleSubmit(e);
+        }}
+        className="space-y-4"
+      >
+        <Input
+          placeholder="Title"
+          value={title}
+          onChange={(e) => setTitle(e.target.value)}
+          disabled={loading}
+          autoFocus
+        />
+
+        <div>
+          <div className="mb-2">
+            <div className="flex gap-2">
+              <button
+                type="button"
+                onClick={() => setPreview(false)}
+                className={`rounded-sm px-2 py-0.5 transition-colors ${
+                  !preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
+                }`}
+              >
+                Write
+              </button>
+              <button
+                type="button"
+                onClick={() => setPreview(true)}
+                disabled={!message.trim()}
+                className={`rounded-sm px-2 py-0.5 transition-colors disabled:opacity-40 ${
+                  preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
+                }`}
+              >
+                Preview
+              </button>
+            </div>
+          </div>
+
+          {preview ? (
+            <div className="border-input min-h-[200px] rounded-md border px-3 py-2">
+              <Markdown content={message} />
+            </div>
+          ) : (
+            <Textarea
+              placeholder="Describe the issue in detail…"
+              className="min-h-[200px]"
+              value={message}
+              onChange={(e) => setMessage(e.target.value)}
+              disabled={loading}
+            />
+          )}
+        </div>
+
+        {error && (
+          <p className="text-destructive text-sm">Failed to create issue: {error.message}</p>
+        )}
+
+        <div className="flex justify-end gap-2">
+          <ButtonLink to="/$repo/issues" params={{ repo: repo! }} variant="ghost">
+            Cancel
+          </ButtonLink>
+          <Button type="submit" disabled={!title.trim() || loading}>
+            {loading ? "Creating…" : "Submit new issue"}
+          </Button>
+        </div>
+      </form>
+    </div>
+  );
+}

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

@@ -1,7 +1,296 @@
-import { createFileRoute } from "@tanstack/react-router";
+// 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.
 
-import { UserProfilePage } from "@/pages/UserProfilePage";
+import { createFileRoute, useParams, Link } from "@tanstack/react-router";
+import { formatDistanceToNow } from "date-fns";
+import {
+  ArrowLeft,
+  MessageSquare,
+  CircleDot,
+  CircleCheck,
+  ShieldCheck,
+  ChevronLeft,
+  ChevronRight,
+} from "lucide-react";
+import { useState } from "react";
+
+import { Status, useUserProfileQuery } from "@/__generated__/graphql";
+import { LabelBadge } from "@/components/bugs/LabelBadge";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRepo } from "@/lib/repo";
+import { cn } from "@/lib/utils";
 
 export const Route = createFileRoute("/$repo/user/$id")({
-  component: UserProfilePage,
+  component: RouteComponent,
 });
+
+const PAGE_SIZE = 25;
+
+function RouteComponent() {
+  const { id } = useParams({ strict: false });
+  const repo = useRepo();
+  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: repo,
+      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>
+    );
+  }
+
+  if (loading && !data) return <ProfileSkeleton />;
+
+  const identity = data?.repository?.identity;
+  if (!identity) {
+    return <div className="text-muted-foreground py-16 text-center text-sm">User not found.</div>;
+  }
+
+  const openCount = data?.repository?.openCount.totalCount ?? 0;
+  const closedCount = data?.repository?.closedCount.totalCount ?? 0;
+
+  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));
+  }
+
+  return (
+    <div>
+      <Link
+        to="/$repo/issues"
+        params={{ repo: repo! }}
+        className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
+      >
+        <ArrowLeft className="size-3.5" />
+        Back to issues
+      </Link>
+
+      {/* ── Profile header ─────────────────────────────────────────────── */}
+      <div className="mb-8 flex items-start gap-5">
+        <Avatar className="size-20">
+          <AvatarImage src={identity.avatarUrl ?? undefined} alt={identity.displayName} />
+          <AvatarFallback className="text-2xl">
+            {identity.displayName.slice(0, 2).toUpperCase()}
+          </AvatarFallback>
+        </Avatar>
+
+        <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" />
+              </span>
+            )}
+          </div>
+          <div className="text-muted-foreground mt-1 space-y-0.5 text-sm">
+            {identity.login && <p>@{identity.login}</p>}
+            {identity.email && <p>{identity.email}</p>}
+            <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" />
+              <span className="text-foreground font-medium">{openCount}</span> open
+            </span>
+            <span className="text-muted-foreground flex items-center gap-1">
+              <CircleCheck className="size-3.5 text-purple-600 dark:text-purple-400" />
+              <span className="text-foreground font-medium">{closedCount}</span> closed
+            </span>
+          </div>
+        </div>
+      </div>
+
+      {/* ── Issue list ─────────────────────────────────────────────────── */}
+      <div className="border-border rounded-md border">
+        {/* Open / Closed toggle β€” mirrors BugListPage style */}
+        <div className="border-border flex items-center gap-1 border-b px-4 py-2">
+          <button
+            onClick={() => switchStatus("open")}
+            className={cn(
+              "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+              statusFilter === "open"
+                ? "bg-accent text-accent-foreground"
+                : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+            )}
+          >
+            <CircleDot
+              className={cn(
+                "size-4",
+                statusFilter === "open" && "text-green-600 dark:text-green-400",
+              )}
+            />
+            Open
+            <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
+              {openCount}
+            </span>
+          </button>
+
+          <button
+            onClick={() => switchStatus("closed")}
+            className={cn(
+              "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+              statusFilter === "closed"
+                ? "bg-accent text-accent-foreground"
+                : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+            )}
+          >
+            <CircleCheck
+              className={cn(
+                "size-4",
+                statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
+              )}
+            />
+            Closed
+            <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
+              {closedCount}
+            </span>
+          </button>
+        </div>
+
+        {bugs?.nodes.length === 0 && (
+          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
+            No {statusFilter} issues.
+          </p>
+        )}
+
+        {bugs?.nodes.map((bug) => {
+          const isOpen = bug.status === Status.Open;
+          const StatusIcon = isOpen ? CircleDot : CircleCheck;
+          return (
+            <div
+              key={bug.id}
+              className="border-border flex items-start gap-3 border-b px-4 py-3 last:border-0"
+            >
+              <StatusIcon
+                className={cn(
+                  "mt-0.5 size-4 shrink-0",
+                  isOpen
+                    ? "text-green-600 dark:text-green-400"
+                    : "text-purple-600 dark:text-purple-400",
+                )}
+              />
+              <div className="min-w-0 flex-1">
+                <div className="flex flex-wrap items-baseline gap-2">
+                  <Link
+                    to="/$repo/issues/$id"
+                    params={{ repo: repo!, id: bug.humanId }}
+                    className="text-foreground hover:text-primary font-medium hover:underline"
+                  >
+                    {bug.title}
+                  </Link>
+                  {bug.labels.map((label) => (
+                    <LabelBadge key={label.name} name={label.name} color={label.color} />
+                  ))}
+                </div>
+                <p className="text-muted-foreground mt-0.5 text-xs">
+                  #{bug.humanId} opened{" "}
+                  {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
+                </p>
+              </div>
+              {bug.comments.totalCount > 0 && (
+                <div className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs">
+                  <MessageSquare className="size-3.5" />
+                  {bug.comments.totalCount}
+                </div>
+              )}
+            </div>
+          );
+        })}
+
+        {/* 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
+              variant="ghost"
+              size="sm"
+              onClick={goPrev}
+              disabled={!hasPrev || loading}
+              className="text-muted-foreground gap-1"
+            >
+              <ChevronLeft className="size-4" />
+              Previous
+            </Button>
+            <span className="text-muted-foreground text-sm">
+              Page {page + 1} of {totalPages}
+            </span>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={goNext}
+              disabled={!hasNext || loading}
+              className="text-muted-foreground gap-1"
+            >
+              Next
+              <ChevronRight className="size-4" />
+            </Button>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
+function ProfileSkeleton() {
+  return (
+    <div className="space-y-6">
+      <div className="flex items-start gap-5">
+        <Skeleton className="size-20 rounded-full" />
+        <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" />
+        </div>
+      </div>
+      <div className="space-y-2">
+        {Array.from({ length: 4 }).map((_, i) => (
+          <Skeleton key={i} className="h-14 w-full" />
+        ))}
+      </div>
+    </div>
+  );
+}

webui2/src/routes/__root.tsx πŸ”—

@@ -1,9 +1,38 @@
-import { createRootRoute } from "@tanstack/react-router";
+import { createRootRoute, useRouter } from "@tanstack/react-router";
+import { AlertTriangle } from "lucide-react";
 
 import { Shell } from "@/components/layout/Shell";
-import { ErrorPage } from "@/pages/ErrorPage";
+import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
 
 export const Route = createRootRoute({
   component: Shell,
   errorComponent: ErrorPage,
 });
+
+function ErrorPage({ error }: { error?: Error }) {
+  const router = useRouter();
+
+  const message = error?.message ?? "An unexpected error occurred.";
+
+  return (
+    <div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
+      <AlertTriangle className="text-muted-foreground size-10" />
+      <p className="text-muted-foreground text-sm">{message}</p>
+      <div className="flex gap-2">
+        <Button
+          variant="outline"
+          size="sm"
+          onClick={() => {
+            void router.invalidate();
+          }}
+        >
+          Try again
+        </Button>
+        <ButtonLink to="/" variant="outline" size="sm">
+          Go home
+        </ButtonLink>
+      </div>
+    </div>
+  );
+}

webui2/src/routes/auth/select-identity.tsx πŸ”—

@@ -1,7 +1,136 @@
+// Identity selection page (/auth/select-identity).
+//
+// Reached after a successful OAuth login when no existing git-bug identity
+// could be matched automatically (via provider metadata set by the bridge).
+// The user can either adopt an existing identity β€” which links it to their
+// OAuth account for future logins β€” or create a fresh one from their OAuth
+// profile.
+
 import { createFileRoute } from "@tanstack/react-router";
+import { UserCircle, Plus, AlertCircle } from "lucide-react";
+import { useEffect, useState } from "react";
 
-import { IdentitySelectPage } from "@/pages/IdentitySelectPage";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
 
 export const Route = createFileRoute("/auth/select-identity")({
-  component: IdentitySelectPage,
+  component: RouteComponent,
 });
+
+interface IdentityItem {
+  repoSlug: string;
+  id: string;
+  humanId: string;
+  displayName: string;
+  login?: string;
+  avatarUrl?: string;
+}
+
+function RouteComponent() {
+  const [identities, setIdentities] = useState<IdentityItem[] | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [working, setWorking] = useState(false);
+
+  useEffect(() => {
+    async function loadIdentities() {
+      try {
+        const res = await fetch("/auth/identities", { credentials: "include" });
+        if (!res.ok) throw new Error(`unexpected status ${res.status}`);
+        const data: IdentityItem[] = await res.json();
+        setIdentities(data);
+      } catch (e) {
+        setError(String(e));
+      }
+    }
+    void loadIdentities();
+  }, []);
+
+  async function adopt(identityId: string | null) {
+    setWorking(true);
+    try {
+      const res = await fetch("/auth/adopt", {
+        method: "POST",
+        credentials: "include",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(identityId ? { identityId } : {}),
+      });
+      if (!res.ok) throw new Error(`adopt failed: ${res.status}`);
+      // Full page reload to reset Apollo cache and auth state cleanly.
+      window.location.assign("/");
+    } catch (e) {
+      setError(String(e));
+      setWorking(false);
+    }
+  }
+
+  return (
+    <div className="mx-auto max-w-lg py-12">
+      <div className="mb-2 flex items-center gap-3">
+        <UserCircle className="text-muted-foreground size-6" />
+        <h1 className="text-xl font-semibold">Choose your identity</h1>
+      </div>
+      <p className="text-muted-foreground mb-8 text-sm">
+        No git-bug identity was found linked to your account. Select an existing identity to link
+        it, or create a new one from your profile.
+      </p>
+
+      {error && (
+        <div className="border-destructive/30 bg-destructive/10 text-destructive mb-4 flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
+          <AlertCircle className="size-4 shrink-0" />
+          {error}
+        </div>
+      )}
+
+      {!identities && !error && (
+        <div className="space-y-2">
+          {Array.from({ length: 3 }).map((_, i) => (
+            <Skeleton key={i} className="h-14 w-full rounded-md" />
+          ))}
+        </div>
+      )}
+
+      <div className="divide-border border-border divide-y rounded-md border">
+        {identities?.map((id) => (
+          <div key={id.id} className="flex items-center gap-3 px-4 py-3">
+            <div className="min-w-0 flex-1">
+              <p className="font-medium">{id.displayName}</p>
+              <p className="text-muted-foreground text-xs">
+                {id.login ? `@${id.login} Β· ` : ""}
+                {id.repoSlug} Β· {id.humanId}
+              </p>
+            </div>
+            <Button
+              size="sm"
+              disabled={working}
+              onClick={() => {
+                void adopt(id.id);
+              }}
+            >
+              Adopt
+            </Button>
+          </div>
+        ))}
+
+        {/* Always offer to create a new identity */}
+        <div className="flex items-center gap-3 px-4 py-3">
+          <div className="min-w-0 flex-1">
+            <p className="font-medium">Create new identity</p>
+            <p className="text-muted-foreground text-xs">
+              A fresh git-bug identity will be created from your OAuth profile.
+            </p>
+          </div>
+          <Button
+            size="sm"
+            disabled={working}
+            onClick={() => {
+              void adopt(null);
+            }}
+          >
+            <Plus className="size-4" />
+            Create
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

webui2/src/routes/index.tsx πŸ”—

@@ -1,7 +1,80 @@
-import { createFileRoute } from "@tanstack/react-router";
+// Repository picker page (/). Auto-redirects when there is exactly one repo.
+// Shows a list when multiple repos are registered.
 
-import { RepoPickerPage } from "@/pages/RepoPickerPage";
+import { useReadQuery } from "@apollo/client/react";
+import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
+import { GitFork, FolderOpen, AlertCircle } from "lucide-react";
+import { useEffect } from "react";
+
+import { type RepositoriesQuery, RepositoriesDocument } from "@/__generated__/graphql";
+import { preloadQuery } from "@/lib/apollo";
 
 export const Route = createFileRoute("/")({
-  component: RepoPickerPage,
+  component: RouteComponent,
+  loader: () => ({
+    repositoriesRef: preloadQuery<RepositoriesQuery>(RepositoriesDocument),
+  }),
 });
+
+function repoSlug(name: string | null | undefined): string {
+  return name ?? "_";
+}
+
+function repoLabel(name: string | null | undefined): string {
+  return name ?? "default";
+}
+
+function RouteComponent() {
+  const { repositoriesRef } = Route.useLoaderData();
+  const { data, error } = useReadQuery(repositoriesRef);
+  const navigate = useNavigate();
+
+  // Auto-redirect when there is exactly one repo β€” no need to pick.
+  useEffect(() => {
+    if (data?.repositories.nodes.length === 1) {
+      void navigate({
+        to: "/$repo",
+        params: { repo: repoSlug(data.repositories.nodes[0].name) },
+        search: { ref: "", path: "", type: "tree" as const },
+        replace: true,
+      });
+    }
+  }, [data, navigate]);
+
+  return (
+    <div className="mx-auto max-w-lg py-12">
+      <div className="mb-8 flex items-center gap-3">
+        <GitFork className="text-muted-foreground size-6" />
+        <h1 className="text-xl font-semibold">Repositories</h1>
+      </div>
+
+      {error && (
+        <div className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
+          <AlertCircle className="size-4 shrink-0" />
+          Failed to load repositories: {error.message}
+        </div>
+      )}
+
+      <div className="divide-border border-border divide-y rounded-md border">
+        {data?.repositories.nodes.map((repo) => (
+          <Link
+            key={repoSlug(repo.name)}
+            to="/$repo"
+            params={{ repo: repoSlug(repo.name) }}
+            search={{ ref: "", path: "", type: "tree" as const }}
+            className="hover:bg-muted/50 flex items-center gap-3 px-4 py-4 transition-colors"
+          >
+            <FolderOpen className="text-muted-foreground size-5 shrink-0" />
+            <p className="text-foreground font-medium">{repoLabel(repo.name)}</p>
+          </Link>
+        ))}
+
+        {data?.repositories.totalCount === 0 && (
+          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
+            No repositories found.
+          </p>
+        )}
+      </div>
+    </div>
+  );
+}