refactor(web): switch to codegen useFragment, fix storybook + types

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

- Replace Apollo's useSuspenseFragment with codegen's useFragment
  (zero-cost cast) for all fragment-consuming components. Apollo's
  useSuspenseFragment can be used selectively later for @defer support.
- Set dataMasking: false β€” fragment colocation is enforced at the type
  level via codegen's $fragmentRefs branding (inlineFragmentTypes: "mask").
- Add apollo-client.d.ts with GraphQLCodegenDataMasking TypeOverrides.
- Add withApollo + withCachedFragments storybook decorators for stories
  that use Apollo hooks (useAuth, useMutation).
- Use makeFragmentData in stories for proper fragment type branding.
- Mock useSuspenseFragment as passthrough in snapshot test setup.
- Add timeline and title-editor stories + snapshot tests.
- Fix useAuth query to spread ...IdentitySummary alongside direct fields.
- Fix lint: route property order, storybook meta component/title.

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

Change summary

webui2/.storybook/decorators.tsx                                    |  75 
webui2/.storybook/vitest.setup.ts                                   |  14 
webui2/COMPONENTS.md                                                |  89 
webui2/codegen.ts                                                   |   5 
webui2/src/__generated__/gql.ts                                     |  18 
webui2/src/__generated__/graphql.ts                                 | 117 
webui2/src/apollo-client.d.ts                                       |   6 
webui2/src/components/bugs/__snapshots__/timeline.test.tsx.snap     | 637 
webui2/src/components/bugs/__snapshots__/title-editor.test.tsx.snap |  91 
webui2/src/components/bugs/label-editor.stories.tsx                 |  70 
webui2/src/components/bugs/label-editor.tsx                         |   2 
webui2/src/components/bugs/timeline.stories.tsx                     | 173 
webui2/src/components/bugs/timeline.test.tsx                        |  13 
webui2/src/components/bugs/timeline.tsx                             | 119 
webui2/src/components/bugs/title-editor.stories.tsx                 |  29 
webui2/src/components/bugs/title-editor.test.tsx                    |  13 
webui2/src/components/code/__snapshots__/file-viewer.test.tsx.snap  | 108 
webui2/src/components/code/file-viewer.stories.tsx                  | 119 
webui2/src/components/code/file-viewer.tsx                          |   8 
webui2/src/components/code/ref-selector.stories.tsx                 |  48 
webui2/src/components/code/ref-selector.tsx                         |   5 
webui2/src/components/shared/comment-card.stories.tsx               |  54 
webui2/src/components/shared/comment-card.tsx                       |   5 
webui2/src/components/shared/issue-filters.stories.tsx              |  45 
webui2/src/components/shared/issue-filters.tsx                      |   2 
webui2/src/components/shared/issue-row.stories.tsx                  |  43 
webui2/src/components/shared/label-badge.stories.tsx                |  53 
webui2/src/components/shared/label-badge.tsx                        |   9 
webui2/src/components/ui/listbox.stories.tsx                        |   6 
webui2/src/lib/apollo.ts                                            |   7 
webui2/src/lib/auth.tsx                                             |  17 
webui2/src/routes/$repo/_code/commits/$ref.tsx                      |   4 
32 files changed, 1,722 insertions(+), 282 deletions(-)

Detailed changes

webui2/.storybook/decorators.tsx πŸ”—

@@ -1,3 +1,7 @@
+import { ApolloClient, ApolloLink, Observable } from "@apollo/client/core";
+import { ApolloProvider } from "@apollo/client/react";
+import { InMemoryCache } from "@apollo/client/cache";
+import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
 import {
   createMemoryHistory,
   createRootRoute,
@@ -6,6 +10,7 @@ import {
   RouterProvider,
 } from "@tanstack/react-router";
 import type { Decorator } from "@storybook/react-vite";
+import { Suspense } from "react";
 
 // Catch-all route so any <Link to="..."> resolves without errors.
 const rootRoute = createRootRoute();
@@ -24,3 +29,73 @@ const router = createRouter({
 export const withRouter: Decorator = (Story) => (
   <RouterProvider router={router} defaultComponent={() => <Story />} />
 );
+
+// Mock Apollo client for stories.
+// - useSuspenseFragment reads from this cache
+// - useSuspenseQuery for useAuth() hits the mock link
+const mockApolloClient = new ApolloClient({
+  link: new ApolloLink(
+    (operation) => new Observable((observer) => {
+      const data: Record<string, unknown> = {};
+      // Provide mock data for the UserIdentity query used by useAuth()
+      if (operation.operationName === "UserIdentity") {
+        data.repository = {
+          __typename: "Repository",
+          userIdentity: {
+            __typename: "Identity",
+            id: "mock-user",
+            humanId: "mock1",
+            name: "Mock User",
+            displayName: "Mock User",
+            avatarUrl: null,
+            email: null,
+            login: null,
+          },
+        };
+      }
+      observer.next({ data });
+      observer.complete();
+    }),
+  ),
+  cache: new InMemoryCache({
+    typePolicies: {
+      // Types without `id` need explicit keyFields so useSuspenseFragment
+      // can normalize and cache the mock data passed via `from`.
+      Label: { keyFields: ["name"] },
+      GitBlob: { keyFields: ["hash"] },
+      GitRefConnection: { keyFields: [] },
+      BugTimelineItemConnection: { keyFields: [] },
+    },
+  }),
+  dataMasking: false,
+});
+
+// Wraps a story in an ApolloProvider. Components using useSuspenseFragment
+// need their data pre-written to the cache via withCachedFragments().
+export const withApollo: Decorator = (Story) => (
+  <ApolloProvider client={mockApolloClient}>
+    <Suspense fallback={<div style={{ padding: 16, color: "orange" }}>Loading…</div>}>
+      <Story />
+    </Suspense>
+  </ApolloProvider>
+);
+
+// Pre-writes fragment data into the Apollo cache so that useSuspenseFragment
+// can read it without suspending. Call this in the story's decorators list
+// AFTER withApollo.
+//
+// Usage:
+//   decorators: [withApollo, withCachedFragments(
+//     [MY_FRAGMENT, "MyFragment", myMockData],
+//     [MY_FRAGMENT, "MyFragment", anotherMockData],
+//   )]
+export function withCachedFragments(
+  ...entries: Array<[fragment: TypedDocumentNode, fragmentName: string, data: Record<string, unknown>]>
+): Decorator {
+  return (Story) => {
+    for (const [fragment, fragmentName, data] of entries) {
+      mockApolloClient.cache.writeFragment({ fragment, fragmentName, data });
+    }
+    return <Story />;
+  };
+}

webui2/.storybook/vitest.setup.ts πŸ”—

@@ -1,5 +1,5 @@
 import { setProjectAnnotations } from "@storybook/react-vite";
-import { beforeAll } from "vitest";
+import { beforeAll, vi } from "vitest";
 
 import * as previewAnnotations from "./preview";
 
@@ -8,3 +8,15 @@ import * as previewAnnotations from "./preview";
 const annotations = setProjectAnnotations([previewAnnotations]);
 
 beforeAll(annotations.beforeAll);
+
+// Make useSuspenseFragment a passthrough in happy-dom snapshot tests.
+// The real hook reads from the Apollo cache, which requires data to be
+// written via a query first. In stories we pass mock objects directly,
+// so we just return the `from` data as-is.
+vi.mock("@apollo/client/react", async (importOriginal) => {
+  const mod = await importOriginal<typeof import("@apollo/client/react")>();
+  return {
+    ...mod,
+    useSuspenseFragment: ({ from }: { from: unknown }) => ({ data: from }),
+  };
+});

webui2/COMPONENTS.md πŸ”—

@@ -0,0 +1,89 @@
+# Component Status Tracker
+
+Status legend: done / partial / todo / n/a
+
+## Shared Components (`src/components/shared/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| comment-card | done (`IdentitySummary`) | done (Root/AuthorAvatar/Card/CardHeader/CardBody) | done | n/a (display only) | done | Uses `withApollo` decorator |
+| empty-state | n/a | n/a (simple wrapper) | done | n/a | done | |
+| issue-filters | n/a (callbacks only) | todo (single export, complex) | done | todo (dropdowns, search, keyboard nav) | todo | Has complex floating-ui interactions, needs `withApollo` |
+| issue-row | done (`BugSummary`, spreads `IdentitySummary`+`LabelFields`) | done (Root/StatusIcon/TitleArea/Meta/CommentCount) | done | n/a (display only) | done | Uses `withApollo` + `withRouter` |
+| label-badge | done (`LabelFields`) | n/a | done | n/a | done | Uses `withApollo` decorator |
+| pagination | n/a | done (Root/Info/Previous/Next) | done | n/a (links only) | done | |
+| query-input | n/a | done (Root/Icon/Input/Completions) | done | partial (1 play function) | done | Complex autocomplete |
+| section-heading | n/a | n/a | done | n/a | done | |
+| status-badge | n/a | n/a | done | n/a | done | |
+| status-tabs | n/a | done (Root/Tab/OpenIndicator/ClosedIndicator/Count) | done | n/a (links only) | done | |
+| write-preview | n/a | done (Root/Tabs/WriteSlot/PreviewSlot) | done | partial (1 play function) | done | |
+
+## Bug Components (`src/components/bugs/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| timeline | done (5 sub-fragments + connection) | done (internal sub-components use `useSuspenseFragment`) | done | todo (comment editing) | done | 4 stories: FullTimeline, CreateOnly, EmptyMessage, StatusReopen |
+| comment-box | n/a (mutations only) | n/a | todo | todo (textarea, submit, status toggle) | todo | Uses `useAuth`, needs Apollo mock |
+| title-editor | n/a (mutation only) | n/a | done | todo (inline edit, save/cancel, keyboard) | done | Uses `useAuth` |
+| label-editor | partial (uses `LabelFields` via LabelBadge) | n/a | done | todo (dropdown, checkbox toggling) | todo | Demo story with local state |
+
+## Code Components (`src/components/code/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| ref-selector | done (`RefSelectorRefs` on connection) | n/a | done | todo (dropdown, search, keyboard nav) | done | Uses `withApollo` decorator |
+| file-viewer | done (`FileViewerBlob`) | n/a | done | todo (copy, line select, shift-click range) | done | Shiki WASM excluded from browser tests |
+| file-tree | n/a (data from 2 queries, local interface) | n/a | done | n/a (links only) | done | |
+| file-diff-view | todo (owns `DIFF_QUERY`, no fragment) | todo (Hunk is internal) | todo | todo (collapsible sections) | todo | |
+| commit-list | todo (owns `COMMITS_QUERY`, no fragment) | todo (CommitRow is internal) | todo | todo (load more button) | todo | |
+| code-breadcrumb | n/a | n/a | done | n/a (links only) | done | |
+
+## Content Components (`src/components/content/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| markdown | n/a | n/a | done | n/a | done | |
+
+## Layout Components (`src/components/layout/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| header | n/a | todo (RepoNav is internal) | todo | todo (theme toggle) | todo | Uses `useAuth` + router |
+| shell | n/a | n/a | n/a (layout wrapper) | n/a | n/a | |
+
+## UI Primitives (`src/components/ui/`)
+
+All built on @base-ui/react. Fragment/split columns are n/a for these.
+
+| Component | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|---------|-------------------|---------------|-------|
+| avatar | done | n/a | done | Compound (Avatar/Image/Fallback/Badge/Group) |
+| back-link | todo | n/a | todo | |
+| badge | done | n/a | done | |
+| button | done | n/a | done | |
+| button-link | todo | n/a | todo | TanStack Router wrapper |
+| input | done | n/a | done | |
+| listbox | done | n/a | todo | Compound (Content/ScrollArea/Search/Group/Item/Empty) |
+| popover | todo | n/a | todo | |
+| separator | done | n/a | done | |
+| skeleton | done | n/a | done | |
+| textarea | done | n/a | done | |
+
+## Route Pages (no stories expected, but may need component extraction)
+
+| Route | Fragments | Extractable UI | Notes |
+|-------|-----------|----------------|-------|
+| `/$repo/_issues/issues/$id` | done (spreads BugSummary, IdentitySummary, TimelineItems) | todo: participants list | Accesses participant fields directly |
+| `/$repo/_issues/issues/index` | done (spreads BugSummary) | todo: completion providers | Large file, complex query parsing |
+| `/$repo/_issues/user/$id` | partial (spreads BugSummary, IdentitySummary) | todo: profile header | Accesses identity fields directly |
+| `/$repo/_code/tree/$ref/$` | todo | n/a | Accesses tree/commit fields directly, data from 2 queries |
+| `/$repo/commit/$hash` | todo | todo: commit header | Accesses all commit fields directly |
+| `/$repo/_code` | done (uses RefSelectorRefs via preload) | n/a | Layout route |
+| `/$repo/_issues` | done (preloads labels with LabelFields) | n/a | Layout route |
+| `/$repo` | done (REFS_QUERY with RefSelectorRefs) | n/a | Layout route |
+
+## Infrastructure
+
+- **Apollo mock decorator** (`withApollo`): Provides mock `ApolloClient` with `dataMasking: false`, `keyFields` for Label/GitBlob/connections, and mock UserIdentity data for `useAuth()`
+- **Router decorator** (`withRouter`): Provides catch-all TanStack Router for `<Link>` components
+- **Snapshot setup** (`vitest.setup.ts`): Mocks `useSuspenseFragment` as passthrough for happy-dom

webui2/codegen.ts πŸ”—

@@ -20,6 +20,11 @@ const config: CodegenConfig = {
         defaultScalarType: "unknown",
         nonOptionalTypename: true,
         skipTypeNameForRoot: true,
+        // Generate masked inline fragment types for Apollo's data masking
+        inlineFragmentTypes: "mask",
+        customDirectives: {
+          apolloUnmask: true,
+        },
         scalars: {
           Time: "string",
           Hash: "string",

webui2/src/__generated__/gql.ts πŸ”—

@@ -20,8 +20,8 @@ type Documents = {
     "\n  mutation BugStatusOpen($input: BugStatusOpenInput!) {\n    bugStatusOpen(input: $input) {\n      bug {\n        id\n        status\n      }\n    }\n  }\n": typeof types.BugStatusOpenDocument,
     "\n  mutation BugStatusClose($input: BugStatusCloseInput!) {\n    bugStatusClose(input: $input) {\n      bug {\n        id\n        status\n      }\n    }\n  }\n": typeof types.BugStatusCloseDocument,
     "\n  mutation BugChangeLabels($input: BugChangeLabelInput) {\n    bugChangeLabels(input: $input) {\n      bug {\n        id\n        labels {\n          name\n          ...LabelFields\n        }\n      }\n    }\n  }\n": typeof types.BugChangeLabelsDocument,
-    "\n  fragment BugCreateCommentFields on BugCreateTimelineItem {\n    author {\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n": typeof types.BugCreateCommentFieldsFragmentDoc,
-    "\n  fragment BugAddCommentFields on BugAddCommentTimelineItem {\n    author {\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n": typeof types.BugAddCommentFieldsFragmentDoc,
+    "\n  fragment BugCreateCommentFields on BugCreateTimelineItem {\n    id\n    author {\n      id\n      humanId\n      displayName\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n": typeof types.BugCreateCommentFieldsFragmentDoc,
+    "\n  fragment BugAddCommentFields on BugAddCommentTimelineItem {\n    id\n    author {\n      id\n      humanId\n      displayName\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n": typeof types.BugAddCommentFieldsFragmentDoc,
     "\n  fragment LabelChangeFields on BugLabelChangeTimelineItem {\n    author {\n      humanId\n      displayName\n    }\n    date\n    added {\n      ...LabelFields\n    }\n    removed {\n      ...LabelFields\n    }\n  }\n": typeof types.LabelChangeFieldsFragmentDoc,
     "\n  fragment StatusChangeFields on BugSetStatusTimelineItem {\n    author {\n      humanId\n      displayName\n    }\n    date\n    status\n  }\n": typeof types.StatusChangeFieldsFragmentDoc,
     "\n  fragment TitleChangeFields on BugSetTitleTimelineItem {\n    author {\n      humanId\n      displayName\n    }\n    date\n    title\n    was\n  }\n": typeof types.TitleChangeFieldsFragmentDoc,
@@ -35,7 +35,7 @@ type Documents = {
     "\n  fragment IdentitySummary on Identity {\n    id\n    humanId\n    displayName\n    avatarUrl\n  }\n": typeof types.IdentitySummaryFragmentDoc,
     "\n  fragment BugSummary on Bug {\n    id\n    humanId\n    status\n    title\n    labels {\n      name\n      ...LabelFields\n    }\n    author {\n      ...IdentitySummary\n    }\n    createdAt\n    comments {\n      totalCount\n    }\n  }\n": typeof types.BugSummaryFragmentDoc,
     "\n  fragment LabelFields on Label {\n    name\n    color {\n      R\n      G\n      B\n    }\n  }\n": typeof types.LabelFieldsFragmentDoc,
-    "\n  query UserIdentity {\n    repository {\n      userIdentity {\n        id\n        humanId\n        name\n        displayName\n        avatarUrl\n        email\n        login\n      }\n    }\n  }\n": typeof types.UserIdentityDocument,
+    "\n  query UserIdentity {\n    repository {\n      userIdentity {\n        ...IdentitySummary\n        id\n        humanId\n        displayName\n        avatarUrl\n        name\n        email\n        login\n      }\n    }\n  }\n": typeof types.UserIdentityDocument,
     "\n  query CodePageRefs($repo: String) {\n    repository(ref: $repo) {\n      name\n      head {\n        shortName\n      }\n      refs {\n        ...RefSelectorRefs\n      }\n    }\n  }\n": typeof types.CodePageRefsDocument,
     "\n  query CodePageBlob($repo: String, $ref: String!, $path: String!) {\n    repository(ref: $repo) {\n      blob(ref: $ref, path: $path) {\n        ...FileViewerBlob\n      }\n    }\n  }\n": typeof types.CodePageBlobDocument,
     "\n  query CodePageTree($repo: String, $ref: String!, $path: String) {\n    repository(ref: $repo) {\n      tree(ref: $ref, path: $path) {\n        name\n        type\n        hash\n      }\n    }\n  }\n": typeof types.CodePageTreeDocument,
@@ -57,8 +57,8 @@ const documents: Documents = {
     "\n  mutation BugStatusOpen($input: BugStatusOpenInput!) {\n    bugStatusOpen(input: $input) {\n      bug {\n        id\n        status\n      }\n    }\n  }\n": types.BugStatusOpenDocument,
     "\n  mutation BugStatusClose($input: BugStatusCloseInput!) {\n    bugStatusClose(input: $input) {\n      bug {\n        id\n        status\n      }\n    }\n  }\n": types.BugStatusCloseDocument,
     "\n  mutation BugChangeLabels($input: BugChangeLabelInput) {\n    bugChangeLabels(input: $input) {\n      bug {\n        id\n        labels {\n          name\n          ...LabelFields\n        }\n      }\n    }\n  }\n": types.BugChangeLabelsDocument,
-    "\n  fragment BugCreateCommentFields on BugCreateTimelineItem {\n    author {\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n": types.BugCreateCommentFieldsFragmentDoc,
-    "\n  fragment BugAddCommentFields on BugAddCommentTimelineItem {\n    author {\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n": types.BugAddCommentFieldsFragmentDoc,
+    "\n  fragment BugCreateCommentFields on BugCreateTimelineItem {\n    id\n    author {\n      id\n      humanId\n      displayName\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n": types.BugCreateCommentFieldsFragmentDoc,
+    "\n  fragment BugAddCommentFields on BugAddCommentTimelineItem {\n    id\n    author {\n      id\n      humanId\n      displayName\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n": types.BugAddCommentFieldsFragmentDoc,
     "\n  fragment LabelChangeFields on BugLabelChangeTimelineItem {\n    author {\n      humanId\n      displayName\n    }\n    date\n    added {\n      ...LabelFields\n    }\n    removed {\n      ...LabelFields\n    }\n  }\n": types.LabelChangeFieldsFragmentDoc,
     "\n  fragment StatusChangeFields on BugSetStatusTimelineItem {\n    author {\n      humanId\n      displayName\n    }\n    date\n    status\n  }\n": types.StatusChangeFieldsFragmentDoc,
     "\n  fragment TitleChangeFields on BugSetTitleTimelineItem {\n    author {\n      humanId\n      displayName\n    }\n    date\n    title\n    was\n  }\n": types.TitleChangeFieldsFragmentDoc,
@@ -72,7 +72,7 @@ const documents: Documents = {
     "\n  fragment IdentitySummary on Identity {\n    id\n    humanId\n    displayName\n    avatarUrl\n  }\n": types.IdentitySummaryFragmentDoc,
     "\n  fragment BugSummary on Bug {\n    id\n    humanId\n    status\n    title\n    labels {\n      name\n      ...LabelFields\n    }\n    author {\n      ...IdentitySummary\n    }\n    createdAt\n    comments {\n      totalCount\n    }\n  }\n": types.BugSummaryFragmentDoc,
     "\n  fragment LabelFields on Label {\n    name\n    color {\n      R\n      G\n      B\n    }\n  }\n": types.LabelFieldsFragmentDoc,
-    "\n  query UserIdentity {\n    repository {\n      userIdentity {\n        id\n        humanId\n        name\n        displayName\n        avatarUrl\n        email\n        login\n      }\n    }\n  }\n": types.UserIdentityDocument,
+    "\n  query UserIdentity {\n    repository {\n      userIdentity {\n        ...IdentitySummary\n        id\n        humanId\n        displayName\n        avatarUrl\n        name\n        email\n        login\n      }\n    }\n  }\n": types.UserIdentityDocument,
     "\n  query CodePageRefs($repo: String) {\n    repository(ref: $repo) {\n      name\n      head {\n        shortName\n      }\n      refs {\n        ...RefSelectorRefs\n      }\n    }\n  }\n": types.CodePageRefsDocument,
     "\n  query CodePageBlob($repo: String, $ref: String!, $path: String!) {\n    repository(ref: $repo) {\n      blob(ref: $ref, path: $path) {\n        ...FileViewerBlob\n      }\n    }\n  }\n": types.CodePageBlobDocument,
     "\n  query CodePageTree($repo: String, $ref: String!, $path: String) {\n    repository(ref: $repo) {\n      tree(ref: $ref, path: $path) {\n        name\n        type\n        hash\n      }\n    }\n  }\n": types.CodePageTreeDocument,
@@ -129,11 +129,11 @@ export function graphql(source: "\n  mutation BugChangeLabels($input: BugChangeL
 /**
  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
  */
-export function graphql(source: "\n  fragment BugCreateCommentFields on BugCreateTimelineItem {\n    author {\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n"): (typeof documents)["\n  fragment BugCreateCommentFields on BugCreateTimelineItem {\n    author {\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n"];
+export function graphql(source: "\n  fragment BugCreateCommentFields on BugCreateTimelineItem {\n    id\n    author {\n      id\n      humanId\n      displayName\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n"): (typeof documents)["\n  fragment BugCreateCommentFields on BugCreateTimelineItem {\n    id\n    author {\n      id\n      humanId\n      displayName\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n"];
 /**
  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
  */
-export function graphql(source: "\n  fragment BugAddCommentFields on BugAddCommentTimelineItem {\n    author {\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n"): (typeof documents)["\n  fragment BugAddCommentFields on BugAddCommentTimelineItem {\n    author {\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n"];
+export function graphql(source: "\n  fragment BugAddCommentFields on BugAddCommentTimelineItem {\n    id\n    author {\n      id\n      humanId\n      displayName\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n"): (typeof documents)["\n  fragment BugAddCommentFields on BugAddCommentTimelineItem {\n    id\n    author {\n      id\n      humanId\n      displayName\n      ...IdentitySummary\n    }\n    message\n    createdAt\n    lastEdit\n    edited\n  }\n"];
 /**
  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
  */
@@ -189,7 +189,7 @@ export function graphql(source: "\n  fragment LabelFields on Label {\n    name\n
 /**
  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
  */
-export function graphql(source: "\n  query UserIdentity {\n    repository {\n      userIdentity {\n        id\n        humanId\n        name\n        displayName\n        avatarUrl\n        email\n        login\n      }\n    }\n  }\n"): (typeof documents)["\n  query UserIdentity {\n    repository {\n      userIdentity {\n        id\n        humanId\n        name\n        displayName\n        avatarUrl\n        email\n        login\n      }\n    }\n  }\n"];
+export function graphql(source: "\n  query UserIdentity {\n    repository {\n      userIdentity {\n        ...IdentitySummary\n        id\n        humanId\n        displayName\n        avatarUrl\n        name\n        email\n        login\n      }\n    }\n  }\n"): (typeof documents)["\n  query UserIdentity {\n    repository {\n      userIdentity {\n        ...IdentitySummary\n        id\n        humanId\n        displayName\n        avatarUrl\n        name\n        email\n        login\n      }\n    }\n  }\n"];
 /**
  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
  */

webui2/src/__generated__/graphql.ts πŸ”—

@@ -1199,25 +1199,55 @@ export type BugChangeLabelsMutationVariables = Exact<{
 }>;
 
 
-export type BugChangeLabelsMutation = { bugChangeLabels: { __typename: 'BugChangeLabelPayload', bug: { __typename: 'Bug', id: string, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> } } };
+export type BugChangeLabelsMutation = { bugChangeLabels: { __typename: 'BugChangeLabelPayload', bug: { __typename: 'Bug', id: string, labels: Array<(
+        { __typename: 'Label', name: string }
+        & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+      )> } } };
 
-export type BugCreateCommentFieldsFragment = { __typename: 'BugCreateTimelineItem', message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } };
+export type BugCreateCommentFieldsFragment = { __typename: 'BugCreateTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: (
+    { __typename: 'Identity', id: string, humanId: string, displayName: string }
+    & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+  ) } & { ' $fragmentName'?: 'BugCreateCommentFieldsFragment' };
 
-export type BugAddCommentFieldsFragment = { __typename: 'BugAddCommentTimelineItem', message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } };
+export type BugAddCommentFieldsFragment = { __typename: 'BugAddCommentTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: (
+    { __typename: 'Identity', id: string, humanId: string, displayName: string }
+    & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+  ) } & { ' $fragmentName'?: 'BugAddCommentFieldsFragment' };
 
-export type LabelChangeFieldsFragment = { __typename: 'BugLabelChangeTimelineItem', date: string, author: { __typename: 'Identity', humanId: string, displayName: string }, added: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, removed: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> };
+export type LabelChangeFieldsFragment = { __typename: 'BugLabelChangeTimelineItem', date: string, author: { __typename: 'Identity', humanId: string, displayName: string }, added: Array<(
+    { __typename: 'Label' }
+    & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+  )>, removed: Array<(
+    { __typename: 'Label' }
+    & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+  )> } & { ' $fragmentName'?: 'LabelChangeFieldsFragment' };
 
-export type StatusChangeFieldsFragment = { __typename: 'BugSetStatusTimelineItem', date: string, status: Status, author: { __typename: 'Identity', humanId: string, displayName: string } };
+export type StatusChangeFieldsFragment = { __typename: 'BugSetStatusTimelineItem', date: string, status: Status, author: { __typename: 'Identity', humanId: string, displayName: string } } & { ' $fragmentName'?: 'StatusChangeFieldsFragment' };
 
-export type TitleChangeFieldsFragment = { __typename: 'BugSetTitleTimelineItem', date: string, title: string, was: string, author: { __typename: 'Identity', humanId: string, displayName: string } };
+export type TitleChangeFieldsFragment = { __typename: 'BugSetTitleTimelineItem', date: string, title: string, was: string, author: { __typename: 'Identity', humanId: string, displayName: string } } & { ' $fragmentName'?: 'TitleChangeFieldsFragment' };
 
 export type TimelineItemsFragment = { __typename: 'BugTimelineItemConnection', nodes: Array<
-    | { __typename: 'BugAddCommentTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } }
-    | { __typename: 'BugCreateTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } }
-    | { __typename: 'BugLabelChangeTimelineItem', id: string, date: string, author: { __typename: 'Identity', humanId: string, displayName: string }, added: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, removed: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> }
-    | { __typename: 'BugSetStatusTimelineItem', id: string, date: string, status: Status, author: { __typename: 'Identity', humanId: string, displayName: string } }
-    | { __typename: 'BugSetTitleTimelineItem', id: string, date: string, title: string, was: string, author: { __typename: 'Identity', humanId: string, displayName: string } }
-  > };
+    | (
+      { __typename: 'BugAddCommentTimelineItem', id: string }
+      & { ' $fragmentRefs'?: { 'BugAddCommentFieldsFragment': BugAddCommentFieldsFragment } }
+    )
+    | (
+      { __typename: 'BugCreateTimelineItem', id: string }
+      & { ' $fragmentRefs'?: { 'BugCreateCommentFieldsFragment': BugCreateCommentFieldsFragment } }
+    )
+    | (
+      { __typename: 'BugLabelChangeTimelineItem', id: string }
+      & { ' $fragmentRefs'?: { 'LabelChangeFieldsFragment': LabelChangeFieldsFragment } }
+    )
+    | (
+      { __typename: 'BugSetStatusTimelineItem', id: string }
+      & { ' $fragmentRefs'?: { 'StatusChangeFieldsFragment': StatusChangeFieldsFragment } }
+    )
+    | (
+      { __typename: 'BugSetTitleTimelineItem', id: string }
+      & { ' $fragmentRefs'?: { 'TitleChangeFieldsFragment': TitleChangeFieldsFragment } }
+    )
+  > } & { ' $fragmentName'?: 'TimelineItemsFragment' };
 
 export type BugEditCommentMutationVariables = Exact<{
   input: BugEditCommentInput;
@@ -1253,27 +1283,39 @@ export type FileDiffQueryVariables = Exact<{
 
 export type FileDiffQuery = { repository: { __typename: 'Repository', commit: { __typename: 'GitCommit', diff: { __typename: 'GitFileDiff', path: string, oldPath: string | null, isBinary: boolean, isNew: boolean, isDelete: boolean, hunks: Array<{ __typename: 'GitDiffHunk', oldStart: number, oldLines: number, newStart: number, newLines: number, lines: Array<{ __typename: 'GitDiffLine', type: GitDiffLineType, content: string, oldLine: number, newLine: number }> }> } | null } | null } | null };
 
-export type FileViewerBlobFragment = { __typename: 'GitBlob', path: string, hash: string, text: string | null, size: number, isBinary: boolean, isTruncated: boolean };
+export type FileViewerBlobFragment = { __typename: 'GitBlob', path: string, hash: string, text: string | null, size: number, isBinary: boolean, isTruncated: boolean } & { ' $fragmentName'?: 'FileViewerBlobFragment' };
 
-export type RefSelectorRefsFragment = { __typename: 'GitRefConnection', nodes: Array<{ __typename: 'GitRef', name: string, shortName: string, type: GitRefType }> };
+export type RefSelectorRefsFragment = { __typename: 'GitRefConnection', nodes: Array<{ __typename: 'GitRef', name: string, shortName: string, type: GitRefType }> } & { ' $fragmentName'?: 'RefSelectorRefsFragment' };
 
-export type IdentitySummaryFragment = { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null };
+export type IdentitySummaryFragment = { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } & { ' $fragmentName'?: 'IdentitySummaryFragment' };
 
-export type BugSummaryFragment = { __typename: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }, comments: { __typename: 'BugCommentConnection', totalCount: number } };
+export type BugSummaryFragment = { __typename: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<(
+    { __typename: 'Label', name: string }
+    & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+  )>, author: (
+    { __typename: 'Identity' }
+    & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+  ), comments: { __typename: 'BugCommentConnection', totalCount: number } } & { ' $fragmentName'?: 'BugSummaryFragment' };
 
-export type LabelFieldsFragment = { __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } };
+export type LabelFieldsFragment = { __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } } & { ' $fragmentName'?: 'LabelFieldsFragment' };
 
 export type UserIdentityQueryVariables = Exact<{ [key: string]: never; }>;
 
 
-export type UserIdentityQuery = { repository: { __typename: 'Repository', userIdentity: { __typename: 'Identity', id: string, humanId: string, name: string | null, displayName: string, avatarUrl: string | null, email: string | null, login: string | null } | null } | null };
+export type UserIdentityQuery = { repository: { __typename: 'Repository', userIdentity: (
+      { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null, name: string | null, email: string | null, login: string | null }
+      & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+    ) | null } | null };
 
 export type CodePageRefsQueryVariables = Exact<{
   repo?: InputMaybe<Scalars['String']['input']>;
 }>;
 
 
-export type CodePageRefsQuery = { repository: { __typename: 'Repository', name: string | null, head: { __typename: 'GitRef', shortName: string } | null, refs: { __typename: 'GitRefConnection', nodes: Array<{ __typename: 'GitRef', name: string, shortName: string, type: GitRefType }> } } | null };
+export type CodePageRefsQuery = { repository: { __typename: 'Repository', name: string | null, head: { __typename: 'GitRef', shortName: string } | null, refs: (
+      { __typename: 'GitRefConnection' }
+      & { ' $fragmentRefs'?: { 'RefSelectorRefsFragment': RefSelectorRefsFragment } }
+    ) } | null };
 
 export type CodePageBlobQueryVariables = Exact<{
   repo?: InputMaybe<Scalars['String']['input']>;
@@ -1282,7 +1324,10 @@ export type CodePageBlobQueryVariables = Exact<{
 }>;
 
 
-export type CodePageBlobQuery = { repository: { __typename: 'Repository', blob: { __typename: 'GitBlob', path: string, hash: string, text: string | null, size: number, isBinary: boolean, isTruncated: boolean } | null } | null };
+export type CodePageBlobQuery = { repository: { __typename: 'Repository', blob: (
+      { __typename: 'GitBlob' }
+      & { ' $fragmentRefs'?: { 'FileViewerBlobFragment': FileViewerBlobFragment } }
+    ) | null } | null };
 
 export type CodePageTreeQueryVariables = Exact<{
   repo?: InputMaybe<Scalars['String']['input']>;
@@ -1324,7 +1369,10 @@ export type ValidLabelsQueryVariables = Exact<{
 }>;
 
 
-export type ValidLabelsQuery = { repository: { __typename: 'Repository', validLabels: { __typename: 'LabelConnection', nodes: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> } } | null };
+export type ValidLabelsQuery = { repository: { __typename: 'Repository', validLabels: { __typename: 'LabelConnection', nodes: Array<(
+        { __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }
+        & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+      )> } } | null };
 
 export type BugDetailQueryVariables = Exact<{
   ref?: InputMaybe<Scalars['String']['input']>;
@@ -1332,13 +1380,16 @@ export type BugDetailQueryVariables = Exact<{
 }>;
 
 
-export type BugDetailQuery = { repository: { __typename: 'Repository', bug: { __typename: 'Bug', lastEdit: string, id: string, humanId: string, status: Status, title: string, createdAt: string, participants: { __typename: 'IdentityConnection', nodes: Array<{ __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }> }, timeline: { __typename: 'BugTimelineItemConnection', nodes: Array<
-          | { __typename: 'BugAddCommentTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } }
-          | { __typename: 'BugCreateTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } }
-          | { __typename: 'BugLabelChangeTimelineItem', id: string, date: string, author: { __typename: 'Identity', humanId: string, displayName: string }, added: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, removed: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> }
-          | { __typename: 'BugSetStatusTimelineItem', id: string, date: string, status: Status, author: { __typename: 'Identity', humanId: string, displayName: string } }
-          | { __typename: 'BugSetTitleTimelineItem', id: string, date: string, title: string, was: string, author: { __typename: 'Identity', humanId: string, displayName: string } }
-        > }, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }, comments: { __typename: 'BugCommentConnection', totalCount: number } } | null } | null };
+export type BugDetailQuery = { repository: { __typename: 'Repository', bug: (
+      { __typename: 'Bug', lastEdit: string, participants: { __typename: 'IdentityConnection', nodes: Array<(
+          { __typename: 'Identity' }
+          & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+        )> }, timeline: (
+        { __typename: 'BugTimelineItemConnection' }
+        & { ' $fragmentRefs'?: { 'TimelineItemsFragment': TimelineItemsFragment } }
+      ) }
+      & { ' $fragmentRefs'?: { 'BugSummaryFragment': BugSummaryFragment } }
+    ) | null } | null };
 
 export type BugListQueryVariables = Exact<{
   ref?: InputMaybe<Scalars['String']['input']>;
@@ -1350,7 +1401,10 @@ export type BugListQueryVariables = Exact<{
 }>;
 
 
-export type BugListQuery = { repository: { __typename: 'Repository', openCount: { __typename: 'BugConnection', totalCount: number }, closedCount: { __typename: 'BugConnection', totalCount: number }, bugs: { __typename: 'BugConnection', totalCount: number, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<{ __typename: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }, comments: { __typename: 'BugCommentConnection', totalCount: number } }> } } | null };
+export type BugListQuery = { repository: { __typename: 'Repository', openCount: { __typename: 'BugConnection', totalCount: number }, closedCount: { __typename: 'BugConnection', totalCount: number }, bugs: { __typename: 'BugConnection', totalCount: number, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<(
+        { __typename: 'Bug' }
+        & { ' $fragmentRefs'?: { 'BugSummaryFragment': BugSummaryFragment } }
+      )> } } | null };
 
 export type BugCreateMutationVariables = Exact<{
   input: BugCreateInput;
@@ -1369,7 +1423,10 @@ export type UserProfileQueryVariables = Exact<{
 }>;
 
 
-export type UserProfileQuery = { repository: { __typename: 'Repository', identity: { __typename: 'Identity', id: string, humanId: string, name: string | null, email: string | null, login: string | null, displayName: string, avatarUrl: string | null, isProtected: boolean } | null, openCount: { __typename: 'BugConnection', totalCount: number }, closedCount: { __typename: 'BugConnection', totalCount: number }, bugs: { __typename: 'BugConnection', totalCount: number, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<{ __typename: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }, comments: { __typename: 'BugCommentConnection', totalCount: number } }> } } | null };
+export type UserProfileQuery = { repository: { __typename: 'Repository', identity: { __typename: 'Identity', id: string, humanId: string, name: string | null, email: string | null, login: string | null, displayName: string, avatarUrl: string | null, isProtected: boolean } | null, openCount: { __typename: 'BugConnection', totalCount: number }, closedCount: { __typename: 'BugConnection', totalCount: number }, bugs: { __typename: 'BugConnection', totalCount: number, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<(
+        { __typename: 'Bug' }
+        & { ' $fragmentRefs'?: { 'BugSummaryFragment': BugSummaryFragment } }
+      )> } } | null };
 
 export type CommitPageDetailQueryVariables = Exact<{
   repo?: InputMaybe<Scalars['String']['input']>;
@@ -1385,13 +1442,13 @@ export type RepositoriesQueryVariables = Exact<{ [key: string]: never; }>;
 export type RepositoriesQuery = { repositories: { __typename: 'RepositoryConnection', totalCount: number, nodes: Array<{ __typename: 'Repository', name: string | null }> } };
 
 export const IdentitySummaryFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"IdentitySummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Identity"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"humanId"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]} as unknown as DocumentNode<IdentitySummaryFragment, unknown>;

webui2/src/apollo-client.d.ts πŸ”—

@@ -0,0 +1,6 @@
+import "@apollo/client";
+import type { GraphQLCodegenDataMasking } from "@apollo/client/masking";
+
+declare module "@apollo/client" {
+  interface TypeOverrides extends GraphQLCodegenDataMasking.TypeOverrides {}
+}

webui2/src/components/bugs/__snapshots__/timeline.test.tsx.snap πŸ”—

@@ -0,0 +1,637 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Timeline/CreateOnly matches snapshot 1`] = `
+<div>
+  <div
+    class="space-y-4"
+  >
+    <div
+      class="flex gap-3"
+    >
+      <span
+        class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+        data-size="default"
+        data-slot="avatar"
+      >
+        <span
+          class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+          data-slot="avatar-fallback"
+        >
+          JA
+        </span>
+      </span>
+      <div
+        class="border-border min-w-0 flex-1 rounded-md border"
+      >
+        <div
+          class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+        >
+          <a
+            class="text-foreground font-medium hover:underline"
+            href="/_/user/jane1?status=open&after="
+          >
+            Jane Doe
+          </a>
+          <span
+            class="text-muted-foreground"
+          >
+            1 day ago
+          </span>
+        </div>
+        <div
+          class="px-4 py-3"
+        >
+          <div
+            class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+          >
+            <p>
+              This is the 
+              <strong>
+                initial bug report
+              </strong>
+               with some markdown.
+            </p>
+            
+
+            <ul>
+              
+
+              <li>
+                Item 1
+              </li>
+              
+
+              <li>
+                Item 2
+              </li>
+              
+
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Timeline/EmptyMessage matches snapshot 1`] = `
+<div>
+  <div
+    class="space-y-4"
+  >
+    <div
+      class="flex gap-3"
+    >
+      <span
+        class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+        data-size="default"
+        data-slot="avatar"
+      >
+        <span
+          class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+          data-slot="avatar-fallback"
+        >
+          JA
+        </span>
+      </span>
+      <div
+        class="border-border min-w-0 flex-1 rounded-md border"
+      >
+        <div
+          class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+        >
+          <a
+            class="text-foreground font-medium hover:underline"
+            href="/_/user/jane1?status=open&after="
+          >
+            Jane Doe
+          </a>
+          <span
+            class="text-muted-foreground"
+          >
+            less than a minute ago
+          </span>
+        </div>
+        <div
+          class="px-4 py-3"
+        >
+          <p
+            class="text-muted-foreground text-sm italic"
+          >
+            No description provided.
+          </p>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Timeline/FullTimeline matches snapshot 1`] = `
+<div>
+  <div
+    class="space-y-4"
+  >
+    <div
+      class="flex gap-3"
+    >
+      <span
+        class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+        data-size="default"
+        data-slot="avatar"
+      >
+        <span
+          class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+          data-slot="avatar-fallback"
+        >
+          JA
+        </span>
+      </span>
+      <div
+        class="border-border min-w-0 flex-1 rounded-md border"
+      >
+        <div
+          class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+        >
+          <a
+            class="text-foreground font-medium hover:underline"
+            href="/_/user/jane1?status=open&after="
+          >
+            Jane Doe
+          </a>
+          <span
+            class="text-muted-foreground"
+          >
+            1 day ago
+          </span>
+        </div>
+        <div
+          class="px-4 py-3"
+        >
+          <div
+            class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+          >
+            <p>
+              This is the 
+              <strong>
+                initial bug report
+              </strong>
+               with some markdown.
+            </p>
+            
+
+            <ul>
+              
+
+              <li>
+                Item 1
+              </li>
+              
+
+              <li>
+                Item 2
+              </li>
+              
+
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div
+      class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+    >
+      <span
+        class="flex size-8 shrink-0 items-center justify-center"
+      >
+        <svg
+          aria-hidden="true"
+          class="lucide lucide-tag size-4"
+          fill="none"
+          height="24"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+          width="24"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <path
+            d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"
+          />
+          <circle
+            cx="7.5"
+            cy="7.5"
+            fill="currentColor"
+            r=".5"
+          />
+        </svg>
+      </span>
+      <span>
+        <a
+          class="text-foreground font-medium hover:underline"
+          href="/_/user/bob1?status=open&after="
+        >
+          Bob Smith
+        </a>
+         
+        added
+         
+        <span
+          class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium "
+          style="background-color: rgb(252, 41, 41); color: rgba(255, 255, 255, 0.9);"
+        >
+          bug
+        </span>
+         
+        about 12 hours ago
+      </span>
+    </div>
+    <div
+      class="flex gap-3"
+    >
+      <span
+        class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+        data-size="default"
+        data-slot="avatar"
+      >
+        <span
+          class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+          data-slot="avatar-fallback"
+        >
+          BO
+        </span>
+      </span>
+      <div
+        class="border-border min-w-0 flex-1 rounded-md border"
+      >
+        <div
+          class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+        >
+          <a
+            class="text-foreground font-medium hover:underline"
+            href="/_/user/bob1?status=open&after="
+          >
+            Bob Smith
+          </a>
+          <span
+            class="text-muted-foreground"
+          >
+            about 6 hours ago
+          </span>
+        </div>
+        <div
+          class="px-4 py-3"
+        >
+          <div
+            class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+          >
+            <p>
+              I can reproduce this. The issue is in the login handler.
+            </p>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div
+      class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+    >
+      <span
+        class="flex size-8 shrink-0 items-center justify-center"
+      >
+        <svg
+          aria-hidden="true"
+          class="lucide lucide-pencil size-4"
+          fill="none"
+          height="24"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+          width="24"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <path
+            d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
+          />
+          <path
+            d="m15 5 4 4"
+          />
+        </svg>
+      </span>
+      <span>
+        <a
+          class="text-foreground font-medium hover:underline"
+          href="/_/user/jane1?status=open&after="
+        >
+          Jane Doe
+        </a>
+         
+        changed the title from 
+        <span
+          class="line-through"
+        >
+          Login page crash
+        </span>
+         to
+         
+        <span
+          class="text-foreground font-medium"
+        >
+          Login page crash on empty email input
+        </span>
+         
+        about 2 hours ago
+      </span>
+    </div>
+    <div
+      class="flex gap-3"
+    >
+      <span
+        class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+        data-size="default"
+        data-slot="avatar"
+      >
+        <span
+          class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+          data-slot="avatar-fallback"
+        >
+          JA
+        </span>
+      </span>
+      <div
+        class="border-border min-w-0 flex-1 rounded-md border"
+      >
+        <div
+          class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+        >
+          <a
+            class="text-foreground font-medium hover:underline"
+            href="/_/user/jane1?status=open&after="
+          >
+            Jane Doe
+          </a>
+          <span
+            class="text-muted-foreground"
+          >
+            about 1 hour ago
+          </span>
+          <span
+            class="text-muted-foreground text-xs"
+          >
+            edited
+          </span>
+        </div>
+        <div
+          class="px-4 py-3"
+        >
+          <div
+            class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+          >
+            <p>
+              Fixed in commit abc123. The email validator was not handling empty strings.
+            </p>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div
+      class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+    >
+      <span
+        class="flex size-8 shrink-0 items-center justify-center"
+      >
+        <svg
+          aria-hidden="true"
+          class="lucide lucide-git-pull-request-closed size-4 text-purple-600 dark:text-purple-400"
+          fill="none"
+          height="24"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+          width="24"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <circle
+            cx="6"
+            cy="6"
+            r="3"
+          />
+          <path
+            d="M6 9v12"
+          />
+          <path
+            d="m21 3-6 6"
+          />
+          <path
+            d="m21 9-6-6"
+          />
+          <path
+            d="M18 11.5V15"
+          />
+          <circle
+            cx="18"
+            cy="18"
+            r="3"
+          />
+        </svg>
+      </span>
+      <span>
+        <a
+          class="text-foreground font-medium hover:underline"
+          href="/_/user/jane1?status=open&after="
+        >
+          Jane Doe
+        </a>
+         
+        closed
+         this
+         
+        about 1 hour ago
+      </span>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Timeline/StatusReopen matches snapshot 1`] = `
+<div>
+  <div
+    class="space-y-4"
+  >
+    <div
+      class="flex gap-3"
+    >
+      <span
+        class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+        data-size="default"
+        data-slot="avatar"
+      >
+        <span
+          class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+          data-slot="avatar-fallback"
+        >
+          JA
+        </span>
+      </span>
+      <div
+        class="border-border min-w-0 flex-1 rounded-md border"
+      >
+        <div
+          class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+        >
+          <a
+            class="text-foreground font-medium hover:underline"
+            href="/_/user/jane1?status=open&after="
+          >
+            Jane Doe
+          </a>
+          <span
+            class="text-muted-foreground"
+          >
+            1 day ago
+          </span>
+        </div>
+        <div
+          class="px-4 py-3"
+        >
+          <div
+            class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+          >
+            <p>
+              This is the 
+              <strong>
+                initial bug report
+              </strong>
+               with some markdown.
+            </p>
+            
+
+            <ul>
+              
+
+              <li>
+                Item 1
+              </li>
+              
+
+              <li>
+                Item 2
+              </li>
+              
+
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div
+      class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+    >
+      <span
+        class="flex size-8 shrink-0 items-center justify-center"
+      >
+        <svg
+          aria-hidden="true"
+          class="lucide lucide-git-pull-request-closed size-4 text-purple-600 dark:text-purple-400"
+          fill="none"
+          height="24"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+          width="24"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <circle
+            cx="6"
+            cy="6"
+            r="3"
+          />
+          <path
+            d="M6 9v12"
+          />
+          <path
+            d="m21 3-6 6"
+          />
+          <path
+            d="m21 9-6-6"
+          />
+          <path
+            d="M18 11.5V15"
+          />
+          <circle
+            cx="18"
+            cy="18"
+            r="3"
+          />
+        </svg>
+      </span>
+      <span>
+        <a
+          class="text-foreground font-medium hover:underline"
+          href="/_/user/bob1?status=open&after="
+        >
+          Bob Smith
+        </a>
+         
+        closed
+         this
+         
+        about 2 hours ago
+      </span>
+    </div>
+    <div
+      class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+    >
+      <span
+        class="flex size-8 shrink-0 items-center justify-center"
+      >
+        <svg
+          aria-hidden="true"
+          class="lucide lucide-circle-dot size-4 text-green-600 dark:text-green-400"
+          fill="none"
+          height="24"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+          width="24"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <circle
+            cx="12"
+            cy="12"
+            r="10"
+          />
+          <circle
+            cx="12"
+            cy="12"
+            r="1"
+          />
+        </svg>
+      </span>
+      <span>
+        <a
+          class="text-foreground font-medium hover:underline"
+          href="/_/user/jane1?status=open&after="
+        >
+          Jane Doe
+        </a>
+         
+        reopened
+         this
+         
+        about 1 hour ago
+      </span>
+    </div>
+  </div>
+</div>
+`;

webui2/src/components/bugs/__snapshots__/title-editor.test.tsx.snap πŸ”—

@@ -0,0 +1,91 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`TitleEditor/Default matches snapshot 1`] = `
+<div>
+  <div
+    class="group flex items-start gap-2"
+  >
+    <h1
+      class="text-foreground flex-1 text-2xl leading-tight font-semibold"
+    >
+      Fix login page crash on empty email
+      <span
+        class="text-muted-foreground ml-2 text-xl font-normal"
+      >
+        #
+        a1b2c3
+      </span>
+    </h1>
+    <button
+      class="text-muted-foreground hover:text-foreground mt-1 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
+      title="Edit title"
+    >
+      <svg
+        aria-hidden="true"
+        class="lucide lucide-pencil size-4"
+        fill="none"
+        height="24"
+        stroke="currentColor"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        stroke-width="2"
+        viewBox="0 0 24 24"
+        width="24"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
+        />
+        <path
+          d="m15 5 4 4"
+        />
+      </svg>
+    </button>
+  </div>
+</div>
+`;
+
+exports[`TitleEditor/LongTitle matches snapshot 1`] = `
+<div>
+  <div
+    class="group flex items-start gap-2"
+  >
+    <h1
+      class="text-foreground flex-1 text-2xl leading-tight font-semibold"
+    >
+      Very long issue title that spans multiple lines and tests how the component handles overflow in the layout
+      <span
+        class="text-muted-foreground ml-2 text-xl font-normal"
+      >
+        #
+        d4e5f6
+      </span>
+    </h1>
+    <button
+      class="text-muted-foreground hover:text-foreground mt-1 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
+      title="Edit title"
+    >
+      <svg
+        aria-hidden="true"
+        class="lucide lucide-pencil size-4"
+        fill="none"
+        height="24"
+        stroke="currentColor"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        stroke-width="2"
+        viewBox="0 0 24 24"
+        width="24"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
+        />
+        <path
+          d="m15 5 4 4"
+        />
+      </svg>
+    </button>
+  </div>
+</div>
+`;

webui2/src/components/bugs/label-editor.stories.tsx πŸ”—

@@ -1,7 +1,9 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 import { useState } from "react";
 
-import { LabelBadge } from "@/components/shared/label-badge";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
+import { LabelBadge, LABEL_FIELDS_FRAGMENT } from "@/components/shared/label-badge";
 import { SectionHeading } from "@/components/shared/section-heading";
 import * as Listbox from "@/components/ui/listbox";
 import {
@@ -22,30 +24,38 @@ import { useRef } from "react";
 // The real LabelEditor depends on GraphQL mutations. For stories, we build a
 // self-contained version with the same UI but local state instead of mutations.
 
-const allLabels = [
-  { name: "bug", color: { R: 252, G: 41, B: 41 } },
-  { name: "enhancement", color: { R: 0, G: 150, B: 255 } },
-  { name: "documentation", color: { R: 0, G: 180, B: 80 } },
-  { name: "help wanted", color: { R: 255, G: 152, B: 0 } },
-  { name: "good first issue", color: { R: 124, G: 58, B: 237 } },
+const allLabelsData = [
+  { __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } },
+  { __typename: "Label" as const, name: "enhancement", color: { R: 0, G: 150, B: 255 } },
+  { __typename: "Label" as const, name: "documentation", color: { R: 0, G: 180, B: 80 } },
+  { __typename: "Label" as const, name: "help wanted", color: { R: 255, G: 152, B: 0 } },
+  { __typename: "Label" as const, name: "good first issue", color: { R: 124, G: 58, B: 237 } },
 ];
 
+const allLabels = allLabelsData.map(
+  (l) => ({ ...l, ...makeFragmentData(l, LABEL_FIELDS_FRAGMENT) }),
+);
+
 type LabelColor = { R: number; G: number; B: number };
+type BrandedLabel = (typeof allLabels)[number];
 
 function LabelEditorDemo() {
-  const [current, setCurrent] = useState<Array<{ name: string; color: LabelColor }>>([
-    allLabels[0]!,
-    allLabels[2]!,
-  ]);
-
-  const currentNames = new Set(current.map((l) => l.name));
+  const [currentNames, setCurrentNames] = useState<Set<string>>(
+    new Set([allLabelsData[0]!.name, allLabelsData[2]!.name]),
+  );
 
-  function toggleLabel(label: { name: string; color: LabelColor }) {
-    if (currentNames.has(label.name)) {
-      setCurrent((prev) => prev.filter((l) => l.name !== label.name));
-    } else {
-      setCurrent((prev) => [...prev, label]);
-    }
+  const currentLabels = allLabels.filter((l) => currentNames.has(l.name));
+
+  function toggleLabel(label: { name: string }) {
+    setCurrentNames((prev) => {
+      const next = new Set(prev);
+      if (next.has(label.name)) {
+        next.delete(label.name);
+      } else {
+        next.add(label.name);
+      }
+      return next;
+    });
   }
 
   const [open, setOpen] = useState(false);
@@ -120,7 +130,7 @@ function LabelEditorDemo() {
                             : {}
                         }
                       />
-                      <LabelBadge name={label.name} color={label.color} />
+                      <LabelBadge label={label} />
                     </Listbox.Item>
                   );
                 })}
@@ -130,12 +140,12 @@ function LabelEditorDemo() {
         </FloatingPortal>
       )}
 
-      {current.length === 0 ? (
+      {currentLabels.length === 0 ? (
         <p className="text-muted-foreground text-sm">None yet</p>
       ) : (
         <div className="flex flex-wrap gap-1">
-          {current.map((label) => (
-            <LabelBadge key={label.name} name={label.name} color={label.color} />
+          {currentLabels.map((label) => (
+            <LabelBadge key={label.name} label={label} />
           ))}
         </div>
       )}
@@ -144,13 +154,17 @@ function LabelEditorDemo() {
 }
 
 const meta = {
-  title: "bugs/LabelEditor",
+  component: LabelEditorDemo,
+  decorators: [
+    withApollo,
+    withCachedFragments(
+      ...allLabelsData.map((l) => [LABEL_FIELDS_FRAGMENT, "LabelFields", l] as const),
+    ),
+  ],
   parameters: { layout: "centered", a11y: { disable: true } },
-} satisfies Meta;
+} satisfies Meta<typeof LabelEditorDemo>;
 
 export default meta;
 type Story = StoryObj<typeof meta>;
 
-export const Default: Story = {
-  render: () => <LabelEditorDemo />,
-};
+export const Default: Story = {};

webui2/src/components/bugs/label-editor.tsx πŸ”—

@@ -14,7 +14,7 @@ import {
 import { Settings2 } from "lucide-react";
 import { useRef, useState } from "react";
 
-import type { FragmentType } from "@apollo/client/masking";
+import type { FragmentType } from "@/__generated__/fragment-masking";
 import { BugDetailDocument } from "@/__generated__/graphql";
 import { graphql } from "@/__generated__/gql";
 import * as Listbox from "@/components/ui/listbox";

webui2/src/components/bugs/timeline.stories.tsx πŸ”—

@@ -0,0 +1,173 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { Status } from "@/__generated__/graphql";
+import { withApollo, withCachedFragments, withRouter } from "@/../.storybook/decorators";
+
+import { Timeline, TIMELINE_ITEMS_FRAGMENT } from "./timeline";
+
+const meta = {
+  component: Timeline,
+  decorators: [withRouter, withApollo],
+  parameters: { a11y: { disable: true } },
+} satisfies Meta<typeof Timeline>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const jane = {
+  __typename: "Identity" as const,
+  id: "u1",
+  humanId: "jane1",
+  displayName: "Jane Doe",
+  avatarUrl: null,
+};
+
+const bob = {
+  __typename: "Identity" as const,
+  id: "u2",
+  humanId: "bob1",
+  displayName: "Bob Smith",
+  avatarUrl: "https://github.com/shadcn.png",
+};
+
+const baseTimelineData = {
+  __typename: "BugTimelineItemConnection" as const,
+  nodes: [
+    {
+      __typename: "BugCreateTimelineItem" as const,
+      id: "create-1",
+      author: jane,
+      message: "This is the **initial bug report** with some markdown.\n\n- Item 1\n- Item 2",
+      createdAt: new Date(Date.now() - 86400 * 1000).toISOString(),
+      lastEdit: new Date(Date.now() - 86400 * 1000).toISOString(),
+      edited: false,
+    },
+    {
+      __typename: "BugLabelChangeTimelineItem" as const,
+      id: "label-1",
+      author: { humanId: "bob1", displayName: "Bob Smith" },
+      date: new Date(Date.now() - 43200 * 1000).toISOString(),
+      added: [{ __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } }],
+      removed: [],
+    },
+    {
+      __typename: "BugAddCommentTimelineItem" as const,
+      id: "comment-1",
+      author: bob,
+      message: "I can reproduce this. The issue is in the login handler.",
+      createdAt: new Date(Date.now() - 21600 * 1000).toISOString(),
+      lastEdit: new Date(Date.now() - 21600 * 1000).toISOString(),
+      edited: false,
+    },
+    {
+      __typename: "BugSetTitleTimelineItem" as const,
+      id: "title-1",
+      author: { humanId: "jane1", displayName: "Jane Doe" },
+      date: new Date(Date.now() - 7200 * 1000).toISOString(),
+      title: "Login page crash on empty email input",
+      was: "Login page crash",
+    },
+    {
+      __typename: "BugAddCommentTimelineItem" as const,
+      id: "comment-2",
+      author: jane,
+      message: "Fixed in commit abc123. The email validator was not handling empty strings.",
+      createdAt: new Date(Date.now() - 3600 * 1000).toISOString(),
+      lastEdit: new Date(Date.now() - 1800 * 1000).toISOString(),
+      edited: true,
+    },
+    {
+      __typename: "BugSetStatusTimelineItem" as const,
+      id: "status-1",
+      author: { humanId: "jane1", displayName: "Jane Doe" },
+      date: new Date(Date.now() - 3600 * 1000).toISOString(),
+      status: Status.Closed,
+    },
+  ],
+};
+
+const baseTimeline = makeFragmentData(baseTimelineData, TIMELINE_ITEMS_FRAGMENT);
+
+export const FullTimeline: Story = {
+  decorators: [withCachedFragments([TIMELINE_ITEMS_FRAGMENT, "TimelineItems", baseTimelineData])],
+  args: {
+    repo: "_",
+    bugPrefix: "abc123",
+    timeline: baseTimeline,
+  },
+};
+
+const createOnlyTimelineData = {
+  __typename: "BugTimelineItemConnection" as const,
+  nodes: [baseTimelineData.nodes[0]],
+};
+
+const createOnlyTimeline = makeFragmentData(createOnlyTimelineData, TIMELINE_ITEMS_FRAGMENT);
+
+export const CreateOnly: Story = {
+  decorators: [withCachedFragments([TIMELINE_ITEMS_FRAGMENT, "TimelineItems", createOnlyTimelineData])],
+  args: {
+    repo: "_",
+    bugPrefix: "abc123",
+    timeline: createOnlyTimeline,
+  },
+};
+
+const emptyMessageTimelineData = {
+  __typename: "BugTimelineItemConnection" as const,
+  nodes: [
+    {
+      __typename: "BugCreateTimelineItem" as const,
+      id: "create-empty",
+      author: jane,
+      message: "",
+      createdAt: new Date().toISOString(),
+      lastEdit: new Date().toISOString(),
+      edited: false,
+    },
+  ],
+};
+
+const emptyMessageTimeline = makeFragmentData(emptyMessageTimelineData, TIMELINE_ITEMS_FRAGMENT);
+
+export const EmptyMessage: Story = {
+  decorators: [withCachedFragments([TIMELINE_ITEMS_FRAGMENT, "TimelineItems", emptyMessageTimelineData])],
+  args: {
+    repo: "_",
+    bugPrefix: "abc123",
+    timeline: emptyMessageTimeline,
+  },
+};
+
+const statusReopenTimelineData = {
+  __typename: "BugTimelineItemConnection" as const,
+  nodes: [
+    baseTimelineData.nodes[0],
+    {
+      __typename: "BugSetStatusTimelineItem" as const,
+      id: "status-close",
+      author: { humanId: "bob1", displayName: "Bob Smith" },
+      date: new Date(Date.now() - 7200 * 1000).toISOString(),
+      status: Status.Closed,
+    },
+    {
+      __typename: "BugSetStatusTimelineItem" as const,
+      id: "status-reopen",
+      author: { humanId: "jane1", displayName: "Jane Doe" },
+      date: new Date(Date.now() - 3600 * 1000).toISOString(),
+      status: Status.Open,
+    },
+  ],
+};
+
+const statusReopenTimeline = makeFragmentData(statusReopenTimelineData, TIMELINE_ITEMS_FRAGMENT);
+
+export const StatusReopen: Story = {
+  decorators: [withCachedFragments([TIMELINE_ITEMS_FRAGMENT, "TimelineItems", statusReopenTimelineData])],
+  args: {
+    repo: "_",
+    bugPrefix: "abc123",
+    timeline: statusReopenTimeline,
+  },
+};

webui2/src/components/bugs/timeline.test.tsx πŸ”—

@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./timeline.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+  test(`Timeline/${name} matches snapshot`, async () => {
+    await Story.run();
+    expect(document.body.firstChild).toMatchSnapshot();
+  });
+}

webui2/src/components/bugs/timeline.tsx πŸ”—

@@ -1,5 +1,6 @@
-import { useMutation, useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useMutation } from "@apollo/client/react";
+import type { ResultOf } from "@graphql-typed-document-node/core";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
 import { Link } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
 import { Tag, GitPullRequestClosed, Pencil, CircleDot } from "lucide-react";
@@ -14,9 +15,15 @@ import { LabelBadge } from "@/components/shared/label-badge";
 import { Textarea } from "@/components/ui/textarea";
 import { useAuth } from "@/lib/auth";
 
-graphql(`
+// ── Sub-fragments ────────────────────────────────────────────────────────────
+
+const BUG_CREATE_COMMENT_FRAGMENT = graphql(`
   fragment BugCreateCommentFields on BugCreateTimelineItem {
+    id
     author {
+      id
+      humanId
+      displayName
       ...IdentitySummary
     }
     message
@@ -26,9 +33,13 @@ graphql(`
   }
 `);
 
-graphql(`
+const BUG_ADD_COMMENT_FRAGMENT = graphql(`
   fragment BugAddCommentFields on BugAddCommentTimelineItem {
+    id
     author {
+      id
+      humanId
+      displayName
       ...IdentitySummary
     }
     message
@@ -38,7 +49,7 @@ graphql(`
   }
 `);
 
-graphql(`
+const LABEL_CHANGE_FRAGMENT = graphql(`
   fragment LabelChangeFields on BugLabelChangeTimelineItem {
     author {
       humanId
@@ -54,7 +65,7 @@ graphql(`
   }
 `);
 
-graphql(`
+const STATUS_CHANGE_FRAGMENT = graphql(`
   fragment StatusChangeFields on BugSetStatusTimelineItem {
     author {
       humanId
@@ -65,7 +76,7 @@ graphql(`
   }
 `);
 
-graphql(`
+const TITLE_CHANGE_FRAGMENT = graphql(`
   fragment TitleChangeFields on BugSetTitleTimelineItem {
     author {
       humanId
@@ -77,6 +88,8 @@ graphql(`
   }
 `);
 
+// ── Connection-level fragment ────────────────────────────────────────────────
+
 export const TIMELINE_ITEMS_FRAGMENT = graphql(`
   fragment TimelineItems on BugTimelineItemConnection {
     nodes {
@@ -101,6 +114,8 @@ export const TIMELINE_ITEMS_FRAGMENT = graphql(`
   }
 `);
 
+// ── Mutation ─────────────────────────────────────────────────────────────────
+
 const BUG_EDIT_COMMENT_MUTATION = graphql(`
   mutation BugEditComment($input: BugEditCommentInput!) {
     bugEditComment(input: $input) {
@@ -111,11 +126,13 @@ const BUG_EDIT_COMMENT_MUTATION = graphql(`
   }
 `);
 
-type TimelineData = ReturnType<
-  typeof useSuspenseFragment<typeof TIMELINE_ITEMS_FRAGMENT>
->["data"];
+// ── Type helpers ─────────────────────────────────────────────────────────────
+
+type TimelineData = ResultOf<typeof TIMELINE_ITEMS_FRAGMENT>;
 type TimelineNode = TimelineData["nodes"][number];
 
+// ── Timeline ─────────────────────────────────────────────────────────────────
+
 interface TimelineProps {
   repo: string | null;
   bugPrefix: string;
@@ -126,18 +143,16 @@ interface TimelineProps {
 // inline events (label changes, status changes, title edits). Comment items
 // support inline editing for the logged-in user.
 export function Timeline({ repo, bugPrefix, timeline }: TimelineProps) {
-  const { data } = useSuspenseFragment({
-    fragment: TIMELINE_ITEMS_FRAGMENT,
-    from: timeline,
-  });
+  const data = useFragment(TIMELINE_ITEMS_FRAGMENT, timeline);
 
   return (
     <div className="space-y-4">
       {data.nodes.map((item) => {
         switch (item.__typename) {
           case "BugCreateTimelineItem":
+            return <CreateCommentItem key={item.id} item={item} bugPrefix={bugPrefix} repo={repo} />;
           case "BugAddCommentTimelineItem":
-            return <CommentItem key={item.id} item={item} bugPrefix={bugPrefix} repo={repo} />;
+            return <AddCommentItem key={item.id} item={item} bugPrefix={bugPrefix} repo={repo} />;
           case "BugLabelChangeTimelineItem":
             return <LabelChangeItem key={item.id} item={item} repo={repo} />;
           case "BugSetStatusTimelineItem":
@@ -152,19 +167,28 @@ export function Timeline({ repo, bugPrefix, timeline }: TimelineProps) {
   );
 }
 
-// ── Comment (create or add-comment) ──────────────────────────────────────────
+// ── Comment items ────────────────────────────────────────────────────────────
 
-type CommentItem = Extract<
-  TimelineNode,
-  { __typename: "BugCreateTimelineItem" | "BugAddCommentTimelineItem" }
->;
+type CreateNode = Extract<TimelineNode, { __typename: "BugCreateTimelineItem" }>;
+type AddCommentNode = Extract<TimelineNode, { __typename: "BugAddCommentTimelineItem" }>;
+type CommentData = ResultOf<typeof BUG_CREATE_COMMENT_FRAGMENT>;
+
+function CreateCommentItem({ item, bugPrefix, repo }: { item: CreateNode; bugPrefix: string; repo: string | null }) {
+  const data = useFragment(BUG_CREATE_COMMENT_FRAGMENT, item);
+  return <CommentBody data={data} bugPrefix={bugPrefix} repo={repo} />;
+}
+
+function AddCommentItem({ item, bugPrefix, repo }: { item: AddCommentNode; bugPrefix: string; repo: string | null }) {
+  const data = useFragment(BUG_ADD_COMMENT_FRAGMENT, item);
+  return <CommentBody data={data} bugPrefix={bugPrefix} repo={repo} />;
+}
 
-function CommentItem({
-  item,
+function CommentBody({
+  data: item,
   bugPrefix,
   repo,
 }: {
-  item: CommentItem;
+  data: CommentData;
   bugPrefix: string;
   repo: string | null;
 }) {
@@ -258,11 +282,11 @@ function CommentItem({
   );
 }
 
-// ── Inline events ─────────────────────────────────────────────────────────────
+// ── Inline events ────────────────────────────────────────────────────────────
 
-type LabelChangeItem = Extract<TimelineNode, { __typename: "BugLabelChangeTimelineItem" }>;
-type StatusChangeItem = Extract<TimelineNode, { __typename: "BugSetStatusTimelineItem" }>;
-type TitleChangeItem = Extract<TimelineNode, { __typename: "BugSetTitleTimelineItem" }>;
+type LabelChangeNode = Extract<TimelineNode, { __typename: "BugLabelChangeTimelineItem" }>;
+type StatusChangeNode = Extract<TimelineNode, { __typename: "BugSetStatusTimelineItem" }>;
+type TitleChangeNode = Extract<TimelineNode, { __typename: "BugSetTitleTimelineItem" }>;
 
 function EventRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
   return (
@@ -273,42 +297,44 @@ function EventRow({ icon, children }: { icon: React.ReactNode; children: React.R
   );
 }
 
-function LabelChangeItem({ item, repo }: { item: LabelChangeItem; repo: string | null }) {
+function LabelChangeItem({ item, repo }: { item: LabelChangeNode; repo: string | null }) {
+  const data = useFragment(LABEL_CHANGE_FRAGMENT, item);
   return (
     <EventRow icon={<Tag className="size-4" />}>
       <span>
         <Link
           to="/$repo/user/$id"
-          params={{ repo: repo!, id: item.author.humanId }}
+          params={{ repo: repo!, id: data.author.humanId }}
           search={{ status: "open" as const, after: "" }}
           className="text-foreground font-medium hover:underline"
         >
-          {item.author.displayName}
+          {data.author.displayName}
         </Link>{" "}
-        {item.added.length > 0 && (
+        {data.added.length > 0 && (
           <>
             added{" "}
-            {item.added.map((l, i) => (
+            {data.added.map((l, i) => (
               <LabelBadge key={i} label={l} />
             ))}{" "}
           </>
         )}
-        {item.removed.length > 0 && (
+        {data.removed.length > 0 && (
           <>
             removed{" "}
-            {item.removed.map((l, i) => (
+            {data.removed.map((l, i) => (
               <LabelBadge key={i} label={l} />
             ))}{" "}
           </>
         )}
-        {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
+        {formatDistanceToNow(new Date(data.date), { addSuffix: true })}
       </span>
     </EventRow>
   );
 }
 
-function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string | null }) {
-  const isOpen = item.status === Status.Open;
+function StatusChangeItem({ item, repo }: { item: StatusChangeNode; repo: string | null }) {
+  const data = useFragment(STATUS_CHANGE_FRAGMENT, item);
+  const isOpen = data.status === Status.Open;
   return (
     <EventRow
       icon={
@@ -322,34 +348,35 @@ function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string
       <span>
         <Link
           to="/$repo/user/$id"
-          params={{ repo: repo!, id: item.author.humanId }}
+          params={{ repo: repo!, id: data.author.humanId }}
           search={{ status: "open" as const, after: "" }}
           className="text-foreground font-medium hover:underline"
         >
-          {item.author.displayName}
+          {data.author.displayName}
         </Link>{" "}
         {isOpen ? "reopened" : "closed"} this{" "}
-        {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
+        {formatDistanceToNow(new Date(data.date), { addSuffix: true })}
       </span>
     </EventRow>
   );
 }
 
-function TitleChangeItem({ item, repo }: { item: TitleChangeItem; repo: string | null }) {
+function TitleChangeItem({ item, repo }: { item: TitleChangeNode; repo: string | null }) {
+  const data = useFragment(TITLE_CHANGE_FRAGMENT, item);
   return (
     <EventRow icon={<Pencil className="size-4" />}>
       <span>
         <Link
           to="/$repo/user/$id"
-          params={{ repo: repo!, id: item.author.humanId }}
+          params={{ repo: repo!, id: data.author.humanId }}
           search={{ status: "open" as const, after: "" }}
           className="text-foreground font-medium hover:underline"
         >
-          {item.author.displayName}
+          {data.author.displayName}
         </Link>{" "}
-        changed the title from <span className="line-through">{item.was}</span> to{" "}
-        <span className="text-foreground font-medium">{item.title}</span>{" "}
-        {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
+        changed the title from <span className="line-through">{data.was}</span> to{" "}
+        <span className="text-foreground font-medium">{data.title}</span>{" "}
+        {formatDistanceToNow(new Date(data.date), { addSuffix: true })}
       </span>
     </EventRow>
   );

webui2/src/components/bugs/title-editor.stories.tsx πŸ”—

@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { withApollo } from "@/../.storybook/decorators";
+
+import { TitleEditor } from "./title-editor";
+
+const meta = {
+  component: TitleEditor,
+  decorators: [withApollo],
+} satisfies Meta<typeof TitleEditor>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: {
+    bugPrefix: "abc123",
+    title: "Fix login page crash on empty email",
+    humanId: "a1b2c3",
+  },
+};
+
+export const LongTitle: Story = {
+  args: {
+    bugPrefix: "def456",
+    title: "Very long issue title that spans multiple lines and tests how the component handles overflow in the layout",
+    humanId: "d4e5f6",
+  },
+};

webui2/src/components/bugs/title-editor.test.tsx πŸ”—

@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./title-editor.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+  test(`TitleEditor/${name} matches snapshot`, async () => {
+    await Story.run();
+    expect(document.body.firstChild).toMatchSnapshot();
+  });
+}

webui2/src/components/code/__snapshots__/file-viewer.test.tsx.snap πŸ”—

@@ -60,23 +60,117 @@ exports[`FileViewer/BinaryFile matches snapshot 1`] = `
 exports[`FileViewer/Loading matches snapshot 1`] = `
 <div>
   <div
-    class="divide-border border-border divide-y rounded-md border"
+    class="border-border overflow-hidden rounded-md border"
   >
     <div
-      class="flex items-center gap-2 px-4 py-2"
+      class="bg-muted/40 border-border flex items-center justify-between border-b px-4 py-2"
     >
       <div
-        class="animate-pulse rounded-md bg-muted h-4 w-48"
+        class="animate-pulse rounded-md bg-muted h-4 w-32"
         data-slot="skeleton"
       />
     </div>
     <div
-      class="p-4"
+      class="flex"
     >
       <div
-        class="animate-pulse rounded-md bg-muted h-64 w-full"
-        data-slot="skeleton"
-      />
+        class="flex flex-col items-end gap-1 px-3 py-3"
+      >
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+          data-slot="skeleton"
+        />
+      </div>
+      <div
+        class="flex flex-1 flex-col gap-1 py-3 pr-4"
+      >
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 30%;"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 77%;"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 64%;"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 51%;"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 38%;"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 85%;"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 72%;"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 59%;"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 46%;"
+        />
+        <div
+          class="animate-pulse rounded-md bg-muted h-3.5"
+          data-slot="skeleton"
+          style="width: 33%;"
+        />
+      </div>
     </div>
   </div>
 </div>

webui2/src/components/code/file-viewer.stories.tsx πŸ”—

@@ -1,21 +1,14 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 
-import { FileViewer } from "./file-viewer";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
+import { Skeleton } from "@/components/ui/skeleton";
 
-const meta = {
-  component: FileViewer,
-  // Skip browser tests β€” Shiki's WASM engine doesn't load in Vitest browser mode.
-  // Snapshot tests (happy-dom) still cover this component.
-  tags: ["!test"],
-} satisfies Meta<typeof FileViewer>;
+import { FileViewer, FILE_VIEWER_BLOB_FRAGMENT } from "./file-viewer";
 
-export default meta;
-type Story = StoryObj<typeof meta>;
-
-export const TypeScriptFile: Story = {
-  args: {
-    blob: {
-      text: `import { useState } from "react";
+const typescriptBlobData = {
+  __typename: "GitBlob" as const,
+  text: `import { useState } from "react";
 
 interface CounterProps {
   initial?: number;
@@ -33,43 +26,91 @@ export function Counter({ initial = 0 }: CounterProps) {
     </div>
   );
 }`,
-      hash: "abc123",
-      path: "src/Counter.tsx",
-      size: 312,
-      isBinary: false,
-      isTruncated: false,
-    },
+  hash: "abc123",
+  path: "src/Counter.tsx",
+  size: 312,
+  isBinary: false,
+  isTruncated: false,
+};
+
+const binaryBlobData = {
+  __typename: "GitBlob" as const,
+  text: null,
+  hash: "def456",
+  path: "logo.png",
+  size: 24576,
+  isBinary: true,
+  isTruncated: false,
+};
+
+const truncatedBlobData = {
+  __typename: "GitBlob" as const,
+  text: "line 1\nline 2\nline 3\n... (truncated)",
+  hash: "ghi789",
+  path: "large-file.log",
+  size: 1048576,
+  isBinary: false,
+  isTruncated: true,
+};
+
+const meta = {
+  component: FileViewer,
+  decorators: [
+    withApollo,
+    withCachedFragments(
+      [FILE_VIEWER_BLOB_FRAGMENT, "FileViewerBlob", typescriptBlobData],
+      [FILE_VIEWER_BLOB_FRAGMENT, "FileViewerBlob", binaryBlobData],
+      [FILE_VIEWER_BLOB_FRAGMENT, "FileViewerBlob", truncatedBlobData],
+    ),
+  ],
+  // Skip browser tests β€” Shiki's WASM engine doesn't load in Vitest browser mode.
+  // Snapshot tests (happy-dom) still cover this component.
+  tags: ["!test"],
+} satisfies Meta<typeof FileViewer>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const typescriptBlob = makeFragmentData(typescriptBlobData, FILE_VIEWER_BLOB_FRAGMENT);
+const binaryBlob = makeFragmentData(binaryBlobData, FILE_VIEWER_BLOB_FRAGMENT);
+const truncatedBlob = makeFragmentData(truncatedBlobData, FILE_VIEWER_BLOB_FRAGMENT);
+
+export const TypeScriptFile: Story = {
+  args: {
+    blob: typescriptBlob,
   },
 };
 
 export const BinaryFile: Story = {
   args: {
-    blob: {
-      text: null,
-      hash: "def456",
-      path: "logo.png",
-      size: 24576,
-      isBinary: true,
-      isTruncated: false,
-    },
+    blob: binaryBlob,
   },
 };
 
 export const TruncatedFile: Story = {
   args: {
-    blob: {
-      text: "line 1\nline 2\nline 3\n... (truncated)",
-      hash: "ghi789",
-      path: "large-file.log",
-      size: 1048576,
-      isBinary: false,
-      isTruncated: true,
-    },
+    blob: truncatedBlob,
   },
 };
 
 export const Loading: Story = {
-  args: {
-    blob: null,
-  },
+  render: () => (
+    <div className="border-border overflow-hidden rounded-md border">
+      <div className="bg-muted/40 border-border flex items-center justify-between border-b px-4 py-2">
+        <Skeleton className="h-4 w-32" />
+      </div>
+      <div className="flex">
+        <div className="flex flex-col items-end gap-1 px-3 py-3">
+          {Array.from({ length: 10 }).map((_, i) => (
+            <Skeleton key={i} className="h-3.5 w-6" />
+          ))}
+        </div>
+        <div className="flex flex-1 flex-col gap-1 py-3 pr-4">
+          {Array.from({ length: 10 }).map((_, i) => (
+            <Skeleton key={i} className="h-3.5" style={{ width: `${30 + ((i * 47) % 60)}%` }} />
+          ))}
+        </div>
+      </div>
+    </div>
+  ),
 };

webui2/src/components/code/file-viewer.tsx πŸ”—

@@ -8,8 +8,7 @@ import { useState, useEffect, useCallback, Fragment, type ReactNode } from "reac
 import { jsx, jsxs } from "react/jsx-runtime";
 import type { ShikiTransformer } from "shiki/core";
 
-import { useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
 
 import { graphql } from "@/__generated__/gql";
 import { Button } from "@/components/ui/button";
@@ -142,10 +141,7 @@ interface FileViewerProps {
 }
 
 export function FileViewer({ blob: blobProp }: FileViewerProps) {
-  const { data: blob } = useSuspenseFragment({
-    fragment: FILE_VIEWER_BLOB_FRAGMENT,
-    from: blobProp,
-  });
+  const blob = useFragment(FILE_VIEWER_BLOB_FRAGMENT, blobProp);
 
   const [highlighted, setHighlighted] = useState<{ node: ReactNode; lineCount: number } | null>(
     null,

webui2/src/components/code/ref-selector.stories.tsx πŸ”—

@@ -1,31 +1,51 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 import { fn } from "storybook/test";
 
+import { makeFragmentData } from "@/__generated__/fragment-masking";
 import { GitRefType } from "@/__generated__/graphql";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
 
-import { RefSelector } from "./ref-selector";
+import { RefSelector, REF_SELECTOR_REFS_FRAGMENT } from "./ref-selector";
+
+const sampleRefsData = {
+  __typename: "GitRefConnection" as const,
+  nodes: [
+    { name: "refs/heads/main", shortName: "main", type: GitRefType.Branch },
+    { name: "refs/heads/develop", shortName: "develop", type: GitRefType.Branch },
+    { name: "refs/heads/feature/auth", shortName: "feature/auth", type: GitRefType.Branch },
+    { name: "refs/heads/fix/login", shortName: "fix/login", type: GitRefType.Branch },
+    { name: "refs/tags/v1.0.0", shortName: "v1.0.0", type: GitRefType.Tag },
+    { name: "refs/tags/v1.1.0", shortName: "v1.1.0", type: GitRefType.Tag },
+    { name: "refs/tags/v2.0.0-rc1", shortName: "v2.0.0-rc1", type: GitRefType.Tag },
+  ],
+};
+
+const branchesOnlyData = {
+  __typename: "GitRefConnection" as const,
+  nodes: sampleRefsData.nodes.filter((r) => r.type === GitRefType.Branch),
+};
+
+const sampleRefs = makeFragmentData(sampleRefsData, REF_SELECTOR_REFS_FRAGMENT);
+const branchesOnly = makeFragmentData(branchesOnlyData, REF_SELECTOR_REFS_FRAGMENT);
 
 const meta = {
   component: RefSelector,
+  decorators: [
+    withApollo,
+    withCachedFragments(
+      [REF_SELECTOR_REFS_FRAGMENT, "RefSelectorRefs", sampleRefsData],
+      [REF_SELECTOR_REFS_FRAGMENT, "RefSelectorRefs", branchesOnlyData],
+    ),
+  ],
   parameters: { a11y: { disable: true } },
 } satisfies Meta<typeof RefSelector>;
 
 export default meta;
 type Story = StoryObj<typeof meta>;
 
-const sampleRefs = [
-  { name: "refs/heads/main", shortName: "main", type: GitRefType.Branch, hash: "abc1" },
-  { name: "refs/heads/develop", shortName: "develop", type: GitRefType.Branch, hash: "abc2" },
-  { name: "refs/heads/feature/auth", shortName: "feature/auth", type: GitRefType.Branch, hash: "abc3" },
-  { name: "refs/heads/fix/login", shortName: "fix/login", type: GitRefType.Branch, hash: "abc4" },
-  { name: "refs/tags/v1.0.0", shortName: "v1.0.0", type: GitRefType.Tag, hash: "abc5" },
-  { name: "refs/tags/v1.1.0", shortName: "v1.1.0", type: GitRefType.Tag, hash: "abc6" },
-  { name: "refs/tags/v2.0.0-rc1", shortName: "v2.0.0-rc1", type: GitRefType.Tag, hash: "abc7" },
-];
-
 export const Default: Story = {
   args: {
-    gitRefs: sampleRefs,
+    refs: sampleRefs,
     currentRef: "main",
     onSelect: fn(),
   },
@@ -33,7 +53,7 @@ export const Default: Story = {
 
 export const OnTag: Story = {
   args: {
-    gitRefs: sampleRefs,
+    refs: sampleRefs,
     currentRef: "v1.1.0",
     onSelect: fn(),
   },
@@ -41,7 +61,7 @@ export const OnTag: Story = {
 
 export const BranchesOnly: Story = {
   args: {
-    gitRefs: sampleRefs.filter((r) => r.type === GitRefType.Branch),
+    refs: branchesOnly,
     currentRef: "develop",
     onSelect: fn(),
   },

webui2/src/components/code/ref-selector.tsx πŸ”—

@@ -1,5 +1,4 @@
-import { useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
 import {
   useFloating,
   useClick,
@@ -40,7 +39,7 @@ interface RefSelectorProps {
 // Branch / tag selector dropdown for the code browser. Shown in two groups
 // (branches, tags) with an inline search filter.
 export function RefSelector({ refs: refsProp, currentRef, onSelect }: RefSelectorProps) {
-  const { data } = useSuspenseFragment({ fragment: REF_SELECTOR_REFS_FRAGMENT, from: refsProp });
+  const data = useFragment(REF_SELECTOR_REFS_FRAGMENT, refsProp);
   const gitRefs = data.nodes;
 
   const [open, setOpen] = useState(false);

webui2/src/components/shared/comment-card.stories.tsx πŸ”—

@@ -1,47 +1,63 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 
-import type { IdentitySummaryFragment } from "@/__generated__/graphql";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
 
 import * as CommentCard from "./comment-card";
+import { IDENTITY_SUMMARY_FRAGMENT } from "./comment-card";
 
-const meta = {
-  component: CommentCard.Root,
-  parameters: { a11y: { disable: true } },
-} satisfies Meta<typeof CommentCard.Root>;
-
-export default meta;
-type Story = StoryObj<typeof meta>;
-
-// Mock data shaped like IdentitySummaryFragment
-const jane: IdentitySummaryFragment = {
+const janeData = {
+  __typename: "Identity" as const,
   id: "1",
   humanId: "jane1",
   displayName: "Jane Doe",
   avatarUrl: null,
 };
 
-const bob: IdentitySummaryFragment = {
+const bobData = {
+  __typename: "Identity" as const,
   id: "2",
   humanId: "bob1",
   displayName: "Bob Smith",
   avatarUrl: "https://github.com/shadcn.png",
 };
 
-const alice: IdentitySummaryFragment = {
+const aliceData = {
+  __typename: "Identity" as const,
   id: "3",
   humanId: "alice1",
   displayName: "Alice Wu",
   avatarUrl: null,
 };
 
+const jane = makeFragmentData(janeData, IDENTITY_SUMMARY_FRAGMENT);
+const bob = makeFragmentData(bobData, IDENTITY_SUMMARY_FRAGMENT);
+const alice = makeFragmentData(aliceData, IDENTITY_SUMMARY_FRAGMENT);
+
+const meta = {
+  component: CommentCard.Root,
+  decorators: [
+    withApollo,
+    withCachedFragments(
+      [IDENTITY_SUMMARY_FRAGMENT, "IdentitySummary", janeData],
+      [IDENTITY_SUMMARY_FRAGMENT, "IdentitySummary", bobData],
+      [IDENTITY_SUMMARY_FRAGMENT, "IdentitySummary", aliceData],
+    ),
+  ],
+  parameters: { a11y: { disable: true } },
+} satisfies Meta<typeof CommentCard.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
 export const Default: Story = {
   args: { children: null },
   render: () => (
     <CommentCard.Root>
-      <CommentCard.AuthorAvatar displayName={jane.displayName} avatarUrl={jane.avatarUrl} />
+      <CommentCard.AuthorAvatar author={jane} />
       <CommentCard.Card>
         <CommentCard.CardHeader>
-          <span className="text-foreground font-medium">{jane.displayName}</span>
+          <span className="text-foreground font-medium">{janeData.displayName}</span>
           <span className="text-muted-foreground">2 hours ago</span>
         </CommentCard.CardHeader>
         <CommentCard.CardBody>
@@ -56,10 +72,10 @@ export const WithEditButton: Story = {
   args: { children: null },
   render: () => (
     <CommentCard.Root>
-      <CommentCard.AuthorAvatar displayName={bob.displayName} avatarUrl={bob.avatarUrl} />
+      <CommentCard.AuthorAvatar author={bob} />
       <CommentCard.Card>
         <CommentCard.CardHeader>
-          <span className="text-foreground font-medium">{bob.displayName}</span>
+          <span className="text-foreground font-medium">{bobData.displayName}</span>
           <span className="text-muted-foreground">1 day ago</span>
           <span className="text-muted-foreground text-xs">edited</span>
           <button className="text-muted-foreground hover:bg-muted ml-auto rounded-sm px-1.5 py-0.5 text-xs">
@@ -78,10 +94,10 @@ export const EmptyBody: Story = {
   args: { children: null },
   render: () => (
     <CommentCard.Root>
-      <CommentCard.AuthorAvatar displayName={alice.displayName} avatarUrl={alice.avatarUrl} />
+      <CommentCard.AuthorAvatar author={alice} />
       <CommentCard.Card>
         <CommentCard.CardHeader>
-          <span className="text-foreground font-medium">{alice.displayName}</span>
+          <span className="text-foreground font-medium">{aliceData.displayName}</span>
           <span className="text-muted-foreground">just now</span>
         </CommentCard.CardHeader>
         <CommentCard.CardBody>

webui2/src/components/shared/comment-card.tsx πŸ”—

@@ -1,5 +1,4 @@
-import { useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
 
 import { graphql } from "@/__generated__/gql";
 import { cn } from "@/lib/utils";
@@ -30,7 +29,7 @@ interface AuthorAvatarProps {
 }
 
 export function AuthorAvatar({ author, className }: AuthorAvatarProps) {
-  const { data } = useSuspenseFragment({ fragment: IDENTITY_SUMMARY_FRAGMENT, from: author });
+  const data = useFragment(IDENTITY_SUMMARY_FRAGMENT, author);
 
   return (
     <Avatar className={cn("mt-1 size-8 shrink-0", className)}>

webui2/src/components/shared/issue-filters.stories.tsx πŸ”—

@@ -2,28 +2,27 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
 import { fn } from "storybook/test";
 import { useState } from "react";
 
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
 import type { SortValue } from "@/lib/query-utils";
 
 import { IssueFilters, type LabelItem, type IdentityItem } from "./issue-filters";
+import { LABEL_FIELDS_FRAGMENT } from "./label-badge";
 
-const meta = {
-  component: IssueFilters,
-  parameters: { layout: "centered", a11y: { disable: true } },
-} satisfies Meta<typeof IssueFilters>;
-
-export default meta;
-type Story = StoryObj<typeof meta>;
-
-const sampleLabels: LabelItem[] = [
-  { name: "bug", color: { R: 252, G: 41, B: 41 } },
-  { name: "enhancement", color: { R: 0, G: 150, B: 255 } },
-  { name: "documentation", color: { R: 0, G: 180, B: 80 } },
-  { name: "help wanted", color: { R: 255, G: 152, B: 0 } },
-  { name: "good first issue", color: { R: 124, G: 58, B: 237 } },
-  { name: "duplicate", color: { R: 120, G: 120, B: 120 } },
-  { name: "wontfix", color: { R: 180, G: 180, B: 180 } },
+const sampleLabelsData = [
+  { __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } },
+  { __typename: "Label" as const, name: "enhancement", color: { R: 0, G: 150, B: 255 } },
+  { __typename: "Label" as const, name: "documentation", color: { R: 0, G: 180, B: 80 } },
+  { __typename: "Label" as const, name: "help wanted", color: { R: 255, G: 152, B: 0 } },
+  { __typename: "Label" as const, name: "good first issue", color: { R: 124, G: 58, B: 237 } },
+  { __typename: "Label" as const, name: "duplicate", color: { R: 120, G: 120, B: 120 } },
+  { __typename: "Label" as const, name: "wontfix", color: { R: 180, G: 180, B: 180 } },
 ];
 
+const sampleLabels: LabelItem[] = sampleLabelsData.map(
+  (l) => ({ ...l, ...makeFragmentData(l, LABEL_FIELDS_FRAGMENT) }),
+);
+
 const sampleIdentities: IdentityItem[] = [
   { id: "u1", humanId: "abc1", displayName: "Jane Doe", login: "janedoe", name: "Jane Doe", email: "jane@example.com", avatarUrl: null },
   { id: "u2", humanId: "abc2", displayName: "John Smith", login: "jsmith", name: "John Smith", email: "john@example.com", avatarUrl: null },
@@ -32,6 +31,20 @@ const sampleIdentities: IdentityItem[] = [
   { id: "u5", humanId: "abc5", displayName: "Carol Tester", login: "carol", name: "Carol Tester", email: "carol@example.com", avatarUrl: null },
 ];
 
+const meta = {
+  component: IssueFilters,
+  decorators: [
+    withApollo,
+    withCachedFragments(
+      ...sampleLabelsData.map((l) => [LABEL_FIELDS_FRAGMENT, "LabelFields", l] as const),
+    ),
+  ],
+  parameters: { layout: "centered", a11y: { disable: true } },
+} satisfies Meta<typeof IssueFilters>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
 export const Default: Story = {
   args: {
     labels: sampleLabels,

webui2/src/components/shared/issue-filters.tsx πŸ”—

@@ -14,7 +14,7 @@ import {
 import { ArrowUpDown, ChevronDown, Tag, User, X } from "lucide-react";
 import { useMemo, useRef, useState, useCallback, useEffect } from "react";
 
-import type { FragmentType } from "@apollo/client/masking";
+import type { FragmentType } from "@/__generated__/fragment-masking";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import * as Listbox from "@/components/ui/listbox";
 import { useAuth } from "@/lib/auth";

webui2/src/components/shared/issue-row.stories.tsx πŸ”—

@@ -1,20 +1,19 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 import { formatDistanceToNow } from "date-fns";
 
+import { makeFragmentData } from "@/__generated__/fragment-masking";
 import type { BugSummaryFragment } from "@/__generated__/graphql";
 import { Status } from "@/__generated__/graphql";
-import { withRouter } from "@/../.storybook/decorators";
+import { withApollo, withCachedFragments, withRouter } from "@/../.storybook/decorators";
 
 import * as IssueRow from "./issue-row";
-import { LabelBadge } from "./label-badge";
+import { LabelBadge, LABEL_FIELDS_FRAGMENT } from "./label-badge";
 
-const meta = {
-  component: IssueRow.Root,
-  decorators: [withRouter],
-} satisfies Meta<typeof IssueRow.Root>;
+const bugLabel = { __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } };
+const priorityLabel = { __typename: "Label" as const, name: "priority", color: { R: 255, G: 152, B: 0 } };
+const enhancementLabel = { __typename: "Label" as const, name: "enhancement", color: { R: 163, G: 230, B: 53 } };
 
-export default meta;
-type Story = StoryObj<typeof meta>;
+const allLabelsData = [bugLabel, priorityLabel, enhancementLabel];
 
 // Mock data shaped like BugSummaryFragment from GraphQL
 const openBug: BugSummaryFragment = {
@@ -23,10 +22,10 @@ const openBug: BugSummaryFragment = {
   status: Status.Open,
   title: "Fix login page crash on empty email",
   labels: [
-    { name: "bug", color: { R: 252, G: 41, B: 41 } },
-    { name: "priority", color: { R: 255, G: 152, B: 0 } },
+    makeFragmentData(bugLabel, LABEL_FIELDS_FRAGMENT),
+    makeFragmentData(priorityLabel, LABEL_FIELDS_FRAGMENT),
   ],
-  author: { id: "u1", humanId: "user1", displayName: "Jane Doe", avatarUrl: null },
+  author: { __typename: "Identity", id: "u1", humanId: "user1", displayName: "Jane Doe", avatarUrl: null },
   createdAt: new Date(Date.now() - 3600 * 1000).toISOString(),
   comments: { totalCount: 3 },
 };
@@ -36,8 +35,8 @@ const closedBug: BugSummaryFragment = {
   humanId: "d4e5f6",
   status: Status.Closed,
   title: "Add dark mode support",
-  labels: [{ name: "enhancement", color: { R: 163, G: 230, B: 53 } }],
-  author: { id: "u2", humanId: "user2", displayName: "Bob Smith", avatarUrl: null },
+  labels: [makeFragmentData(enhancementLabel, LABEL_FIELDS_FRAGMENT)],
+  author: { __typename: "Identity", id: "u2", humanId: "user2", displayName: "Bob Smith", avatarUrl: null },
   createdAt: new Date(Date.now() - 86400 * 1000).toISOString(),
   comments: { totalCount: 12 },
 };
@@ -48,11 +47,25 @@ const noLabelsBug: BugSummaryFragment = {
   status: Status.Open,
   title: "Simple issue with no labels",
   labels: [],
-  author: { id: "u3", humanId: "user3", displayName: "Alice Wu", avatarUrl: null },
+  author: { __typename: "Identity", id: "u3", humanId: "user3", displayName: "Alice Wu", avatarUrl: null },
   createdAt: new Date(Date.now() - 7200 * 1000).toISOString(),
   comments: { totalCount: 0 },
 };
 
+const meta = {
+  component: IssueRow.Root,
+  decorators: [
+    withRouter,
+    withApollo,
+    withCachedFragments(
+      ...allLabelsData.map((l) => [LABEL_FIELDS_FRAGMENT, "LabelFields", l] as const),
+    ),
+  ],
+} satisfies Meta<typeof IssueRow.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
 function BugRow({ bug }: { bug: BugSummaryFragment }) {
   const ago = formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true });
   return (
@@ -64,7 +77,7 @@ function BugRow({ bug }: { bug: BugSummaryFragment }) {
             {bug.title}
           </a>
           {bug.labels.map((l) => (
-            <LabelBadge key={l.name} {...l} />
+            <LabelBadge key={l.name} label={l} />
           ))}
         </IssueRow.TitleArea>
         <IssueRow.Meta>

webui2/src/components/shared/label-badge.stories.tsx πŸ”—

@@ -1,52 +1,67 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 import { fn } from "storybook/test";
 
-import type { LabelFieldsFragment } from "@/__generated__/graphql";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
 
-import { LabelBadge } from "./label-badge";
+import { LabelBadge, LABEL_FIELDS_FRAGMENT } from "./label-badge";
+
+const bugData = { __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } };
+const enhancementData = { __typename: "Label" as const, name: "enhancement", color: { R: 163, G: 230, B: 53 } };
+const documentationData = { __typename: "Label" as const, name: "documentation", color: { R: 30, G: 80, B: 160 } };
+const helpWantedData = { __typename: "Label" as const, name: "help wanted", color: { R: 0, G: 150, B: 136 } };
+const wontfixData = { __typename: "Label" as const, name: "wontfix", color: { R: 200, G: 200, B: 200 } };
+const priorityData = { __typename: "Label" as const, name: "priority", color: { R: 255, G: 152, B: 0 } };
+
+const allLabelsData = [bugData, enhancementData, documentationData, helpWantedData, wontfixData, priorityData];
+
+const bug = makeFragmentData(bugData, LABEL_FIELDS_FRAGMENT);
+const enhancement = makeFragmentData(enhancementData, LABEL_FIELDS_FRAGMENT);
+const documentation = makeFragmentData(documentationData, LABEL_FIELDS_FRAGMENT);
+const helpWanted = makeFragmentData(helpWantedData, LABEL_FIELDS_FRAGMENT);
+const wontfix = makeFragmentData(wontfixData, LABEL_FIELDS_FRAGMENT);
+const priority = makeFragmentData(priorityData, LABEL_FIELDS_FRAGMENT);
+
+const allLabels = [bug, enhancement, documentation, helpWanted, wontfix, priority];
 
 const meta = {
   component: LabelBadge,
+  decorators: [
+    withApollo,
+    withCachedFragments(
+      ...allLabelsData.map((l) => [LABEL_FIELDS_FRAGMENT, "LabelFields", l] as const),
+    ),
+  ],
 } satisfies Meta<typeof LabelBadge>;
 
 export default meta;
 type Story = StoryObj<typeof meta>;
 
-// Mock data shaped like LabelFieldsFragment from GraphQL
-const bug: LabelFieldsFragment = { name: "bug", color: { R: 252, G: 41, B: 41 } };
-const enhancement: LabelFieldsFragment = { name: "enhancement", color: { R: 163, G: 230, B: 53 } };
-const documentation: LabelFieldsFragment = { name: "documentation", color: { R: 30, G: 80, B: 160 } };
-const helpWanted: LabelFieldsFragment = { name: "help wanted", color: { R: 0, G: 150, B: 136 } };
-const wontfix: LabelFieldsFragment = { name: "wontfix", color: { R: 200, G: 200, B: 200 } };
-const priority: LabelFieldsFragment = { name: "priority", color: { R: 255, G: 152, B: 0 } };
-
-const allLabels = [bug, enhancement, documentation, helpWanted, wontfix, priority];
-
 export const Default: Story = {
   parameters: { a11y: { disable: true } },
-  args: bug,
+  args: { label: bug },
 };
 
 export const LightBackground: Story = {
-  args: enhancement,
+  args: { label: enhancement },
 };
 
 export const DarkBackground: Story = {
-  args: documentation,
+  args: { label: documentation },
 };
 
 export const Clickable: Story = {
   parameters: { a11y: { disable: true } },
-  args: { ...helpWanted, onClick: fn() },
+  args: { label: helpWanted, onClick: fn() },
 };
 
 export const AllColors: Story = {
   parameters: { a11y: { disable: true } },
-  args: bug,
+  args: { label: bug },
   render: () => (
     <div className="flex flex-wrap gap-2">
-      {allLabels.map((label) => (
-        <LabelBadge key={label.name} {...label} />
+      {allLabelsData.map((data, i) => (
+        <LabelBadge key={data.name} label={allLabels[i]!} />
       ))}
     </div>
   ),

webui2/src/components/shared/label-badge.tsx πŸ”—

@@ -1,5 +1,4 @@
-import { useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
 import { createLink, type LinkComponent } from "@tanstack/react-router";
 import * as React from "react";
 
@@ -32,7 +31,7 @@ const LabelBadge = React.forwardRef<
   HTMLSpanElement,
   LabelBadgeProps & Omit<React.HTMLAttributes<HTMLSpanElement>, "color">
 >(({ label, className, ...props }, ref) => {
-  const { data } = useSuspenseFragment({ fragment: LABEL_FIELDS_FRAGMENT, from: label });
+  const data = useFragment(LABEL_FIELDS_FRAGMENT, label);
   const bg = `rgb(${data.color.R},${data.color.G},${data.color.B})`;
   const text = contrastColor(data.color.R, data.color.G, data.color.B);
 
@@ -54,8 +53,8 @@ const CreatedLabelBadgeLink = createLink(
   React.forwardRef<
     HTMLAnchorElement,
     LabelBadgeProps & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "color">
-  >(({ from, className, ...props }, ref) => {
-    const { data } = useSuspenseFragment({ fragment: LABEL_FIELDS_FRAGMENT, from });
+  >(({ label, className, ...props }, ref) => {
+    const data = useFragment(LABEL_FIELDS_FRAGMENT, label);
     const bg = `rgb(${data.color.R},${data.color.G},${data.color.B})`;
     const text = contrastColor(data.color.R, data.color.G, data.color.B);
 

webui2/src/components/ui/listbox.stories.tsx πŸ”—

@@ -18,12 +18,10 @@ import { useRef, useState, useEffect } from "react";
 import { Button } from "./button";
 import * as Listbox from "./listbox";
 
-// We can't use `component:` for a namespace import, so we target Content as
-// the "primary" component just to give Storybook a title.
 const meta = {
-  title: "ui/Listbox",
+  component: Listbox.Content,
   parameters: { layout: "centered", a11y: { disable: true } },
-} satisfies Meta;
+} satisfies Meta<typeof Listbox.Content>;
 
 export default meta;
 type Story = StoryObj<typeof meta>;

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

@@ -9,7 +9,12 @@ const httpLink = new HttpLink({
 
 export const client = new ApolloClient({
   link: httpLink,
-  dataMasking: true,
+  // Data masking is off: fragment colocation is enforced at the type level
+  // via codegen's $fragmentRefs branding (inlineFragmentTypes: "mask").
+  // Components use codegen's useFragment (a zero-cost cast) to unmask.
+  // When @defer is needed, individual components can switch to Apollo's
+  // useSuspenseFragment β€” it works without dataMasking.
+  dataMasking: false,
 
   cache: new InMemoryCache({
     typePolicies: {

webui2/src/lib/auth.tsx πŸ”—

@@ -11,11 +11,12 @@ export const USER_IDENTITY_QUERY = graphql(`
   query UserIdentity {
     repository {
       userIdentity {
+        ...IdentitySummary
         id
         humanId
-        name
         displayName
         avatarUrl
+        name
         email
         login
       }
@@ -23,17 +24,7 @@ export const USER_IDENTITY_QUERY = graphql(`
   }
 `);
 
-export interface AuthUser {
-  id: string;
-  humanId: string;
-  name: string | null;
-  displayName: string;
-  avatarUrl: string | null;
-  email: string | null;
-  login: string | null;
-}
-
-export function useAuth(): { user: AuthUser } {
+export function useAuth() {
   const { data } = useSuspenseQuery(USER_IDENTITY_QUERY);
-  return { user: data.repository!.userIdentity! as AuthUser };
+  return { user: data.repository!.userIdentity! };
 }

webui2/src/routes/$repo/_code/commits/$ref.tsx πŸ”—

@@ -10,9 +10,9 @@ const commitsSearchSchema = v.object({
 });
 
 export const Route = createFileRoute("/$repo/_code/commits/$ref")({
-  component: CommitsView,
-  beforeLoad: () => ({ viewMode: "commits" as const }),
   validateSearch: (search) => v.parse(commitsSearchSchema, search),
+  beforeLoad: () => ({ viewMode: "commits" as const }),
+  component: CommitsView,
 });
 
 function CommitsView() {