From ffda463c6c9f26d775334964ff38d129e1fe2906 Mon Sep 17 00:00:00 2001
From: Ed Bordin <edward@linktr.ee>
Date: Thu, 14 May 2026 06:44:02 +1000
Subject: [PATCH 1/3] feat: add content-anchored resolvable comments

---
 apps/cli/app/api/comments/route.test.ts     | 134 ++++++-
 apps/cli/app/api/comments/route.ts          |  59 ++-
 apps/cli/components/DiffApp.test.tsx        |  38 +-
 apps/cli/components/DiffApp.tsx             |  70 +++-
 apps/cli/components/DiffViewer.test.tsx     |  11 +-
 apps/cli/components/DiffViewer.tsx          | 318 ++++++++++++---
 apps/cli/components/DiffsWorkerProvider.tsx |   5 +-
 apps/cli/components/StatusBar.tsx           |   2 +-
 apps/cli/components/theme-provider.tsx      | 101 ++++-
 apps/cli/lib/comment-types.ts               |  24 ++
 apps/cli/lib/comments.test.ts               | 245 +++++++++++-
 apps/cli/lib/comments.ts                    | 416 +++++++++++++++++++-
 apps/cli/test/comments-anchor.test.ts       | 134 +++++++
 apps/cli/test/comments-resolve.test.ts      | 116 ++++++
 14 files changed, 1571 insertions(+), 102 deletions(-)
 create mode 100644 apps/cli/test/comments-anchor.test.ts
 create mode 100644 apps/cli/test/comments-resolve.test.ts

diff --git a/apps/cli/app/api/comments/route.test.ts b/apps/cli/app/api/comments/route.test.ts
index 2a393fa..e307164 100644
--- a/apps/cli/app/api/comments/route.test.ts
+++ b/apps/cli/app/api/comments/route.test.ts
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { DELETE, GET, POST } from "./route";
+import { DELETE, GET, PATCH, POST } from "./route";
 
 const tempPaths: string[] = [];
 
@@ -19,6 +19,7 @@ describe("/api/comments", () => {
   });
 
   afterEach(() => {
+    delete process.env.DIFFHUB_USER;
     delete process.env.DIFFHUB_REPO;
 
     for (const tempPath of tempPaths.splice(0)) {
@@ -88,6 +89,29 @@ describe("/api/comments", () => {
     });
   });
 
+  it("rejects non-positive and non-integer line numbers", async () => {
+    for (const lineNumber of [0, -1, 1.5]) {
+      const response = await POST(
+        new Request("http://localhost/api/comments", {
+          body: JSON.stringify({
+            body: "Review this hunk",
+            file: "src/a.ts",
+            lineNumber,
+            side: "right",
+            tag: "",
+          }),
+          headers: { "Content-Type": "application/json" },
+          method: "POST",
+        }),
+      );
+
+      expect(response.status).toBe(400);
+      await expect(response.json()).resolves.toStrictEqual({
+        error: "lineNumber must be a positive integer",
+      });
+    }
+  });
+
   it("requires an id to delete a comment", async () => {
     const response = await DELETE(
       new Request("http://localhost/api/comments", { method: "DELETE" }),
@@ -125,4 +149,112 @@ describe("/api/comments", () => {
     await expect(response.json()).resolves.toStrictEqual({ ok: true });
     await expect(GET().json()).resolves.toStrictEqual([]);
   });
+
+  it("resolves, replies, and unresolves comments through PATCH", async () => {
+    process.env.DIFFHUB_USER = "Route Tester";
+    const createResponse = await POST(
+      new Request("http://localhost/api/comments", {
+        body: JSON.stringify({
+          body: "Review this hunk",
+          file: "src/a.ts",
+          lineNumber: 12,
+          side: "right",
+          tag: "",
+        }),
+        headers: { "Content-Type": "application/json" },
+        method: "POST",
+      }),
+    );
+    const created = (await createResponse.json()) as { id: string };
+
+    const resolveResponse = await PATCH(
+      new Request(`http://localhost/api/comments?id=${created.id}`, {
+        body: JSON.stringify({ action: "resolve" }),
+        headers: { "Content-Type": "application/json" },
+        method: "PATCH",
+      }),
+    );
+    expect(resolveResponse.status).toBe(200);
+    await expect(resolveResponse.json()).resolves.toMatchObject({
+      resolved: true,
+      resolvedAt: expect.any(String),
+      resolvedBy: "Route Tester",
+    });
+
+    const replyResponse = await PATCH(
+      new Request(`http://localhost/api/comments?id=${created.id}`, {
+        body: JSON.stringify({ action: "reply", body: "Fixed now" }),
+        headers: { "Content-Type": "application/json" },
+        method: "PATCH",
+      }),
+    );
+    expect(replyResponse.status).toBe(200);
+    await expect(replyResponse.json()).resolves.toMatchObject({
+      replies: [{ at: expect.any(String), body: "Fixed now", by: "Route Tester" }],
+      resolved: true,
+    });
+
+    const unresolveResponse = await PATCH(
+      new Request(`http://localhost/api/comments?id=${created.id}`, {
+        body: JSON.stringify({ action: "unresolve" }),
+        headers: { "Content-Type": "application/json" },
+        method: "PATCH",
+      }),
+    );
+    expect(unresolveResponse.status).toBe(200);
+    const unresolved = (await unresolveResponse.json()) as {
+      resolved: boolean;
+      resolvedAt?: string;
+      resolvedBy?: string;
+    };
+    expect(unresolved.resolved).toBeFalsy();
+    expect(unresolved.resolvedAt).toBeUndefined();
+    expect(unresolved.resolvedBy).toBeUndefined();
+  });
+
+  it("validates PATCH requests", async () => {
+    const missingIdResponse = await PATCH(
+      new Request("http://localhost/api/comments", {
+        body: JSON.stringify({ action: "resolve" }),
+        headers: { "Content-Type": "application/json" },
+        method: "PATCH",
+      }),
+    );
+    expect(missingIdResponse.status).toBe(400);
+    await expect(missingIdResponse.json()).resolves.toStrictEqual({ error: "id required" });
+
+    const unknownCommentResponse = await PATCH(
+      new Request("http://localhost/api/comments?id=missing-id", {
+        body: JSON.stringify({ action: "resolve" }),
+        headers: { "Content-Type": "application/json" },
+        method: "PATCH",
+      }),
+    );
+    expect(unknownCommentResponse.status).toBe(404);
+    await expect(unknownCommentResponse.json()).resolves.toStrictEqual({
+      error: "comment not found",
+    });
+
+    const invalidActionResponse = await PATCH(
+      new Request("http://localhost/api/comments?id=missing-id", {
+        body: JSON.stringify({ action: "invalid" }),
+        headers: { "Content-Type": "application/json" },
+        method: "PATCH",
+      }),
+    );
+    expect(invalidActionResponse.status).toBe(400);
+    await expect(invalidActionResponse.json()).resolves.toStrictEqual({
+      error: "invalid action",
+    });
+
+    const emptyReplyResponse = await PATCH(
+      new Request("http://localhost/api/comments?id=missing-id", {
+        body: JSON.stringify({ action: "reply", body: " " }),
+        headers: { "Content-Type": "application/json" },
+        method: "PATCH",
+      }),
+    );
+    expect(emptyReplyResponse.status).toBe(400);
+    await expect(emptyReplyResponse.json()).resolves.toStrictEqual({ error: "body is required" });
+  });
 });
diff --git a/apps/cli/app/api/comments/route.ts b/apps/cli/app/api/comments/route.ts
index 2175e48..4cad408 100644
--- a/apps/cli/app/api/comments/route.ts
+++ b/apps/cli/app/api/comments/route.ts
@@ -1,6 +1,13 @@
 import { NextResponse } from "next/server";
 import { parseCommentSide } from "@/lib/comment-sides";
-import { addComment, clearComments, deleteComment, readComments } from "@/lib/comments";
+import {
+  addComment,
+  addReply,
+  clearComments,
+  deleteComment,
+  readComments,
+  setCommentResolved,
+} from "@/lib/comments";
 import type { CommentTag } from "@/lib/comments";
 
 const VALID_TAGS = new Set<CommentTag>(["[must-fix]", "[suggestion]", "[nit]", "[question]", ""]);
@@ -22,8 +29,13 @@ export const POST = async (request: Request) => {
   if (typeof data.file !== "string" || !data.file) {
     return NextResponse.json({ error: "file is required" }, { status: 400 });
   }
-  if (typeof data.lineNumber !== "number" || !Number.isFinite(data.lineNumber)) {
-    return NextResponse.json({ error: "lineNumber must be a number" }, { status: 400 });
+  if (
+    typeof data.lineNumber !== "number" ||
+    !Number.isFinite(data.lineNumber) ||
+    !Number.isInteger(data.lineNumber) ||
+    data.lineNumber < 1
+  ) {
+    return NextResponse.json({ error: "lineNumber must be a positive integer" }, { status: 400 });
   }
   if (typeof data.body !== "string" || !data.body) {
     return NextResponse.json({ error: "body is required" }, { status: 400 });
@@ -45,6 +57,8 @@ export const POST = async (request: Request) => {
   try {
     const comment = await addComment({
       body: data.body,
+      createdBy: typeof data.createdBy === "string" ? data.createdBy : undefined,
+      diffHunkHeader: typeof data.diffHunkHeader === "string" ? data.diffHunkHeader : undefined,
       file: data.file,
       lineNumber: data.lineNumber,
       side,
@@ -59,6 +73,45 @@ export const POST = async (request: Request) => {
   }
 };
 
+export const PATCH = async (request: Request) => {
+  const { searchParams } = new URL(request.url);
+  const id = searchParams.get("id");
+  if (!id) {
+    return NextResponse.json({ error: "id required" }, { status: 400 });
+  }
+
+  const data = (await request.json()) as Record<string, unknown>;
+  const { action } = data;
+
+  try {
+    if (action === "resolve" || action === "unresolve") {
+      const comment = await setCommentResolved(id, action === "resolve");
+      if (!comment) {
+        return NextResponse.json({ error: "comment not found" }, { status: 404 });
+      }
+      return NextResponse.json(comment);
+    }
+
+    if (action === "reply") {
+      if (typeof data.body !== "string" || !data.body.trim()) {
+        return NextResponse.json({ error: "body is required" }, { status: 400 });
+      }
+      const comment = await addReply(id, data.body.trim());
+      if (!comment) {
+        return NextResponse.json({ error: "comment not found" }, { status: 404 });
+      }
+      return NextResponse.json(comment);
+    }
+
+    return NextResponse.json({ error: "invalid action" }, { status: 400 });
+  } catch (error) {
+    return NextResponse.json(
+      { error: error instanceof Error ? error.message : "Internal server error" },
+      { status: 500 },
+    );
+  }
+};
+
 export const DELETE = async (request: Request) => {
   const { searchParams } = new URL(request.url);
   const clearAll = searchParams.get("all") === "1";
diff --git a/apps/cli/components/DiffApp.test.tsx b/apps/cli/components/DiffApp.test.tsx
index 2a8760a..1382ef0 100644
--- a/apps/cli/components/DiffApp.test.tsx
+++ b/apps/cli/components/DiffApp.test.tsx
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { DiffApp } from "./DiffApp";
 import type { Comment } from "@/lib/comment-types";
 import { WATCH_STREAM_EVENTS } from "@/lib/watch-stream";
+import type * as ThemeProviderModule from "./theme-provider";
 
 interface MockAnnotation {
   lineNumber: number;
@@ -45,8 +46,8 @@ const { MockDynamicPatch } = vi.hoisted(() => ({
   ),
 }));
 
-vi.mock(import("next-themes"), async (importOriginal) => {
-  const actual = await importOriginal();
+vi.mock(import("./theme-provider"), async (importOriginal) => {
+  const actual = await importOriginal<typeof ThemeProviderModule>();
   return {
     ...actual,
     useTheme: () => ({
@@ -101,6 +102,21 @@ const filesPayload = {
   insertions: 2,
 };
 
+const withCommentDefaults = (
+  comment: Omit<Comment, "anchor" | "replies" | "resolved" | "staleness">,
+): Comment => ({
+  ...comment,
+  anchor: {
+    afterContext: [],
+    beforeContext: [],
+    fileSha: "",
+    lineContent: "",
+  },
+  replies: [],
+  resolved: false,
+  staleness: "fresh",
+});
+
 const diffPayload = {
   baseBranch: "origin/main",
   branch: "feature/diff-review",
@@ -205,12 +221,15 @@ describe("DiffApp review flow", () => {
       }
 
       if (url === "/api/comments" && method === "POST") {
-        const body = JSON.parse(String(init?.body ?? "{}")) as Omit<Comment, "createdAt" | "id">;
-        const createdComment: Comment = {
+        const body = JSON.parse(String(init?.body ?? "{}")) as Omit<
+          Comment,
+          "anchor" | "createdAt" | "id" | "replies" | "resolved" | "staleness"
+        >;
+        const createdComment = withCommentDefaults({
           ...body,
           createdAt: "2026-04-15T00:00:00.000Z",
           id: `comment-${comments.length + 1}`,
-        };
+        });
         comments = [...comments, createdComment];
         return Promise.resolve(jsonResponse(createdComment));
       }
@@ -658,12 +677,15 @@ describe("DiffApp review flow", () => {
       }
 
       if (url === "/api/comments" && method === "POST") {
-        const body = JSON.parse(String(init?.body ?? "{}")) as Omit<Comment, "createdAt" | "id">;
-        const createdComment: Comment = {
+        const body = JSON.parse(String(init?.body ?? "{}")) as Omit<
+          Comment,
+          "anchor" | "createdAt" | "id" | "replies" | "resolved" | "staleness"
+        >;
+        const createdComment = withCommentDefaults({
           ...body,
           createdAt: "2026-04-15T00:00:00.000Z",
           id: "comment-1",
-        };
+        });
         comments = [createdComment];
         return Promise.resolve(jsonResponse(createdComment));
       }
diff --git a/apps/cli/components/DiffApp.tsx b/apps/cli/components/DiffApp.tsx
index 468892a..2fc7621 100644
--- a/apps/cli/components/DiffApp.tsx
+++ b/apps/cli/components/DiffApp.tsx
@@ -1,12 +1,12 @@
 "use client";
 
 import type { AnnotationSide } from "@pierre/diffs";
-import { useTheme } from "next-themes";
 import { useCallback, useEffect, useRef, useState, useTransition, startTransition } from "react";
 import { StatusBar } from "./StatusBar";
 import type { DiffMode, WatchStatus } from "./StatusBar";
 import { FileList } from "./FileList";
 import { DiffViewer, getDiffSectionId } from "./DiffViewer";
+import { useTheme } from "./theme-provider";
 import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
 import { Button } from "@/components/ui/button";
 import { toCommentSide } from "@/lib/comment-sides";
@@ -88,6 +88,8 @@ interface MainPanelProps {
     tag: CommentTag,
   ) => Promise<boolean>;
   onDeleteComment: (id: string) => Promise<boolean>;
+  onResolveComment: (id: string, resolved: boolean) => Promise<boolean>;
+  onReplyToComment: (id: string, body: string) => Promise<boolean>;
   selectedFile: string | null;
   collapsedFiles: Set<string>;
   forceRenderFiles: ReadonlySet<string>;
@@ -177,8 +179,14 @@ const areCommentsEqual = (previous: readonly Comment[], next: readonly Comment[]
       comment.file === nextComment.file &&
       comment.id === nextComment.id &&
       comment.lineNumber === nextComment.lineNumber &&
+      comment.rebasedFromLine === nextComment.rebasedFromLine &&
+      comment.resolved === nextComment.resolved &&
+      comment.resolvedAt === nextComment.resolvedAt &&
+      comment.resolvedBy === nextComment.resolvedBy &&
       comment.side === nextComment.side &&
-      comment.tag === nextComment.tag
+      comment.staleness === nextComment.staleness &&
+      comment.tag === nextComment.tag &&
+      JSON.stringify(comment.replies) === JSON.stringify(nextComment.replies)
     );
   });
 };
@@ -282,6 +290,8 @@ const MainPanel = ({
   comments,
   onAddComment,
   onDeleteComment,
+  onResolveComment,
+  onReplyToComment,
   selectedFile,
   collapsedFiles,
   forceRenderFiles,
@@ -345,6 +355,8 @@ const MainPanel = ({
         comments={comments}
         onAddComment={onAddComment}
         onDeleteComment={onDeleteComment}
+        onResolveComment={onResolveComment}
+        onReplyToComment={onReplyToComment}
         activeFileId={selectedFile}
         fileStats={filesData.files}
         collapsedFiles={collapsedFiles}
@@ -1165,6 +1177,58 @@ export const DiffApp = ({
     return true;
   }, []);
 
+  const replaceComment = useCallback((nextComment: Comment) => {
+    setComments((previous) =>
+      previous.map((comment) => (comment.id === nextComment.id ? nextComment : comment)),
+    );
+  }, []);
+
+  const handleResolveComment = useCallback(
+    async (id: string, resolved: boolean) => {
+      const response = await fetch(`/api/comments?id=${id}`, {
+        body: JSON.stringify({ action: resolved ? "resolve" : "unresolve" }),
+        headers: { "Content-Type": "application/json" },
+        method: "PATCH",
+      });
+      if (!response.ok) {
+        const errorBody = (await response
+          .json()
+          .catch(() => ({ error: "Failed to update comment" }))) as DiffErrorResponse;
+        console.error("[diffhub] resolve comment failed", {
+          error: errorBody.error ?? `Failed to update comment (${response.status})`,
+        });
+        return false;
+      }
+
+      replaceComment((await response.json()) as Comment);
+      return true;
+    },
+    [replaceComment],
+  );
+
+  const handleReplyToComment = useCallback(
+    async (id: string, body: string) => {
+      const response = await fetch(`/api/comments?id=${id}`, {
+        body: JSON.stringify({ action: "reply", body }),
+        headers: { "Content-Type": "application/json" },
+        method: "PATCH",
+      });
+      if (!response.ok) {
+        const errorBody = (await response
+          .json()
+          .catch(() => ({ error: "Failed to save reply" }))) as DiffErrorResponse;
+        console.error("[diffhub] reply failed", {
+          error: errorBody.error ?? `Failed to save reply (${response.status})`,
+        });
+        return false;
+      }
+
+      replaceComment((await response.json()) as Comment);
+      return true;
+    },
+    [replaceComment],
+  );
+
   const syncNotice = getSyncNotice(loadError, filesData, deferredDiffData, diffError);
 
   if (loadError && filesData === null) {
@@ -1230,6 +1294,8 @@ export const DiffApp = ({
           comments={comments}
           onAddComment={handleAddComment}
           onDeleteComment={handleDeleteComment}
+          onResolveComment={handleResolveComment}
+          onReplyToComment={handleReplyToComment}
           selectedFile={selectedFile}
           collapsedFiles={collapsedFiles}
           forceRenderFiles={forceRenderFiles}
diff --git a/apps/cli/components/DiffViewer.test.tsx b/apps/cli/components/DiffViewer.test.tsx
index 13d722a..1d71eb8 100644
--- a/apps/cli/components/DiffViewer.test.tsx
+++ b/apps/cli/components/DiffViewer.test.tsx
@@ -2,13 +2,14 @@ import React from "react";
 import { cleanup, fireEvent, render, screen } from "@testing-library/react";
 import { afterEach, describe, expect, it, vi } from "vitest";
 import { DiffViewer } from "./DiffViewer";
+import type * as ThemeProviderModule from "./theme-provider";
 
 const { MockPatchDiff } = vi.hoisted(() => ({
   MockPatchDiff: ({ patch }: { patch: string }) => <div data-testid="patch-viewer">{patch}</div>,
 }));
 
-vi.mock(import("next-themes"), async (importOriginal) => {
-  const actual = await importOriginal();
+vi.mock(import("./theme-provider"), async (importOriginal) => {
+  const actual = await importOriginal<typeof ThemeProviderModule>();
   return {
     ...actual,
     useTheme: () => ({
@@ -66,6 +67,12 @@ const makeProps = (fileCount: number) => {
       >()
       .mockResolvedValue(true),
     onDeleteComment: vi.fn<(id: string) => Promise<boolean>>().mockResolvedValue(true),
+    onReplyToComment: vi
+      .fn<(id: string, body: string) => Promise<boolean>>()
+      .mockResolvedValue(true),
+    onResolveComment: vi
+      .fn<(id: string, resolved: boolean) => Promise<boolean>>()
+      .mockResolvedValue(true),
     onToggleCollapse: vi.fn<(file: string) => void>(),
     patchesByFile: Object.fromEntries(
       files.map((file, index) => [
diff --git a/apps/cli/components/DiffViewer.tsx b/apps/cli/components/DiffViewer.tsx
index 153a543..48c1a80 100644
--- a/apps/cli/components/DiffViewer.tsx
+++ b/apps/cli/components/DiffViewer.tsx
@@ -3,7 +3,6 @@
 import dynamic from "next/dynamic";
 import { Component, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
 import type { CSSProperties, ErrorInfo, ReactNode } from "react";
-import { useTheme } from "next-themes";
 import type { DiffLineAnnotation, AnnotationSide } from "@pierre/diffs";
 import { toAnnotationSide } from "@/lib/comment-sides";
 import type { Comment, CommentTag } from "@/lib/comment-types";
@@ -13,6 +12,7 @@ import type { PrerenderedDiffHtml } from "@/lib/diff-prerender";
 import type { DiffTheme } from "@/lib/diff-colors";
 import { getDiffUnsafeCSS } from "@/lib/diff-colors";
 import { FileDiffHeader } from "./FileDiffHeader";
+import { useTheme } from "./theme-provider";
 import { cn } from "@/lib/utils";
 import { BranchIcon, CopySimpleIcon, TrashIcon, CheckIcon } from "blode-icons-react";
 import { Button } from "@/components/ui/button";
@@ -91,7 +91,7 @@ const formatRelativeTime = (iso: string): string => {
   return `${diffDay}d ago`;
 };
 
-type AnnotationData = { type: "comment"; comment: Comment } | { type: "input"; file: string };
+type AnnotationData = { type: "comment"; commentId: string } | { type: "input"; file: string };
 
 const DiffSkeleton = () => (
   <div className="animate-pulse">
@@ -278,16 +278,25 @@ const InlineCommentInput = ({ onSubmit, onCancel }: InlineCommentInputProps) =>
   );
 };
 
+// oxlint-disable-next-line complexity
 const CommentDisplay = ({
   comment,
   onDelete,
+  onResolve,
+  onReply,
 }: {
   comment: Comment;
   onDelete: () => Promise<boolean>;
+  onResolve: (resolved: boolean) => Promise<boolean>;
+  onReply: (body: string) => Promise<boolean>;
 }) => {
   const [copied, setCopied] = useState(false);
   const [deleteError, setDeleteError] = useState<string | null>(null);
   const [isDeleting, setIsDeleting] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(!comment.resolved);
+  const [replyBody, setReplyBody] = useState("");
+  const [replyError, setReplyError] = useState<string | null>(null);
   const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
   useEffect(
@@ -334,9 +343,67 @@ const CommentDisplay = ({
     setIsDeleting(false);
   }, [isDeleting, onDelete]);
 
+  const handleResolve = useCallback(
+    async (resolved: boolean): Promise<void> => {
+      if (isUpdating) {
+        return;
+      }
+
+      setIsUpdating(true);
+      const updated = await onResolve(resolved).catch(() => false);
+      if (updated) {
+        setIsExpanded(!resolved);
+      } else {
+        setDeleteError("Failed to update comment.");
+      }
+      setIsUpdating(false);
+    },
+    [isUpdating, onResolve],
+  );
+
+  const handleExpandToggle = useCallback(() => {
+    setIsExpanded((value) => !value);
+  }, []);
+
+  const handleResolveClick = useCallback(() => {
+    void handleResolve(true);
+  }, [handleResolve]);
+
+  const handleUnresolveClick = useCallback(() => {
+    void handleResolve(false);
+  }, [handleResolve]);
+
+  const handleReplyChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
+    setReplyBody(event.target.value);
+  }, []);
+
+  const handleReply = useCallback(async (): Promise<void> => {
+    const trimmed = replyBody.trim();
+    if (!trimmed || isUpdating) {
+      return;
+    }
+
+    setReplyError(null);
+    setIsUpdating(true);
+    const saved = await onReply(trimmed).catch(() => false);
+    if (saved) {
+      setReplyBody("");
+      setIsExpanded(true);
+    } else {
+      setReplyError("Failed to save reply.");
+    }
+    setIsUpdating(false);
+  }, [isUpdating, onReply, replyBody]);
+
+  const handleReplyClick = useCallback(() => {
+    void handleReply();
+  }, [handleReply]);
+
   const borderAccent = comment.tag
     ? (TAG_META[comment.tag]?.border ?? "border-l-ring/40")
     : "border-l-ring/40";
+  const firstLine = comment.body.split(/\s+/).join(" ").slice(0, 60);
+  const canResolve = comment.staleness !== "stale";
 
   return (
     <div
@@ -345,61 +412,150 @@ const CommentDisplay = ({
         borderAccent,
       )}
     >
-      {/* Body row */}
-      <div className="flex items-start gap-2 px-3 py-2.5">
-        {comment.tag && (
-          <span
-            className={cn(
-              "shrink-0 mt-0.5 text-[11px]",
-              TAG_META[comment.tag]?.text ?? "text-muted-foreground",
-            )}
-          >
-            {comment.tag}
+      {comment.staleness === "stale" && (
+        <div className="border-b border-border/40 bg-muted/40 px-3 py-1 text-[11px] text-muted-foreground">
+          Stale — content changed
+        </div>
+      )}
+      {comment.staleness === "moved" && comment.rebasedFromLine !== undefined && (
+        <div className="border-b border-border/40 bg-diff-purple/10 px-3 py-1 text-[11px] text-diff-purple">
+          ↳ moved from line {comment.rebasedFromLine}
+        </div>
+      )}
+      {comment.resolved && (
+        <button
+          type="button"
+          className="flex w-full items-center gap-3 px-3 py-2 text-left text-xs text-muted-foreground hover:bg-muted/40"
+          onClick={handleExpandToggle}
+        >
+          <span className="min-w-0 flex-1 truncate">
+            ✓ Resolved by {comment.resolvedBy ?? "unknown"}{" "}
+            {comment.resolvedAt ? `at ${formatRelativeTime(comment.resolvedAt)}` : ""} — &quot;
+            {firstLine}&quot;
           </span>
-        )}
-        <p className="flex-1 text-sm text-foreground leading-relaxed">{comment.body}</p>
-        {/* Action buttons — hover-revealed */}
-        <TooltipProvider delay={400}>
-          <div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
-            <Tooltip>
-              <TooltipTrigger
-                render={
-                  <button
-                    type="button"
-                    onClick={handleCopy}
-                    aria-label={copied ? "Comment copied" : "Copy comment"}
-                    className={cn(
-                      "rounded p-1 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50",
-                      copied
-                        ? "text-diff-green"
-                        : "text-muted-foreground hover:text-foreground hover:bg-secondary",
-                    )}
-                  />
-                }
-              >
-                {copied ? <CheckIcon size={12} /> : <CopySimpleIcon size={12} />}
-              </TooltipTrigger>
-              <TooltipContent side="top">{copied ? "Copied!" : "Copy comment"}</TooltipContent>
-            </Tooltip>
-            <Tooltip>
-              <TooltipTrigger
-                render={
-                  <button
-                    type="button"
-                    onClick={handleDelete}
-                    aria-label="Delete comment"
-                    disabled={isDeleting}
-                    className="rounded p-1 text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
-                  />
-                }
-              >
-                <TrashIcon size={12} />
-              </TooltipTrigger>
-              <TooltipContent side="top">Delete comment</TooltipContent>
-            </Tooltip>
+          <span className="inline-flex min-w-9 shrink-0 justify-end whitespace-nowrap break-normal leading-none">
+            {isExpanded ? "Hide" : "Show"}
+          </span>
+        </button>
+      )}
+      {/* Body row */}
+      {isExpanded && (
+        <div className="flex items-start gap-2 px-3 py-2.5">
+          {comment.tag && (
+            <span
+              className={cn(
+                "shrink-0 mt-0.5 text-[11px]",
+                TAG_META[comment.tag]?.text ?? "text-muted-foreground",
+              )}
+            >
+              {comment.tag}
+            </span>
+          )}
+          <p className="flex-1 text-sm text-foreground leading-relaxed">{comment.body}</p>
+          {/* Action buttons — hover-revealed */}
+          <TooltipProvider delay={400}>
+            <div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
+              {canResolve && !comment.resolved && (
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="sm"
+                  disabled={isUpdating}
+                  className="h-6 px-2 text-xs text-muted-foreground"
+                  onClick={handleResolveClick}
+                >
+                  Resolve
+                </Button>
+              )}
+              {comment.resolved && (
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="sm"
+                  disabled={isUpdating}
+                  className="h-6 px-2 text-xs text-muted-foreground"
+                  onClick={handleUnresolveClick}
+                >
+                  Unresolve
+                </Button>
+              )}
+              <Tooltip>
+                <TooltipTrigger
+                  render={
+                    <button
+                      type="button"
+                      onClick={handleCopy}
+                      aria-label={copied ? "Comment copied" : "Copy comment"}
+                      className={cn(
+                        "rounded p-1 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50",
+                        copied
+                          ? "text-diff-green"
+                          : "text-muted-foreground hover:text-foreground hover:bg-secondary",
+                      )}
+                    />
+                  }
+                >
+                  {copied ? <CheckIcon size={12} /> : <CopySimpleIcon size={12} />}
+                </TooltipTrigger>
+                <TooltipContent side="top">{copied ? "Copied!" : "Copy comment"}</TooltipContent>
+              </Tooltip>
+              <Tooltip>
+                <TooltipTrigger
+                  render={
+                    <button
+                      type="button"
+                      onClick={handleDelete}
+                      aria-label="Delete comment"
+                      disabled={isDeleting}
+                      className="rounded p-1 text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
+                    />
+                  }
+                >
+                  <TrashIcon size={12} />
+                </TooltipTrigger>
+                <TooltipContent side="top">Delete comment</TooltipContent>
+              </Tooltip>
+            </div>
+          </TooltipProvider>
+        </div>
+      )}
+      {isExpanded && comment.replies.length > 0 && (
+        <div className="border-t border-border/40 px-3 py-2">
+          <div className="space-y-2 border-l border-border pl-3">
+            {comment.replies.map((reply) => (
+              <div key={`${reply.at}:${reply.body}`} className="text-xs">
+                <div className="text-muted-foreground">
+                  {reply.by ?? "unknown"} · {formatRelativeTime(reply.at)}
+                </div>
+                <p className="mt-0.5 text-foreground">{reply.body}</p>
+              </div>
+            ))}
           </div>
-        </TooltipProvider>
-      </div>
+        </div>
+      )}
+      {isExpanded && (
+        <div className="border-t border-border/40 px-3 py-2">
+          <textarea
+            value={replyBody}
+            onChange={handleReplyChange}
+            placeholder="Reply"
+            rows={2}
+            className="w-full resize-none rounded border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring/50"
+          />
+          <div className="mt-1 flex items-center justify-between gap-2">
+            {replyError ? <p className="text-xs text-destructive">{replyError}</p> : <span />}
+            <Button
+              type="button"
+              size="sm"
+              variant="outline"
+              disabled={!replyBody.trim() || isUpdating}
+              onClick={handleReplyClick}
+            >
+              Reply
+            </Button>
+          </div>
+        </div>
+      )}
       {/* Footer strip */}
       <div className="border-t border-border/40 px-3 py-1 flex items-center gap-2 text-[10px] text-muted-foreground/60">
         <span>L{comment.lineNumber}</span>
@@ -499,6 +655,8 @@ interface SingleFileDiffProps {
     tag: CommentTag,
   ) => Promise<boolean>;
   onDeleteComment: (id: string) => Promise<boolean>;
+  onResolveComment: (id: string, resolved: boolean) => Promise<boolean>;
+  onReplyToComment: (id: string, body: string) => Promise<boolean>;
 }
 
 interface GutterButtonProps {
@@ -548,17 +706,26 @@ const SingleFileDiff = memo(function SingleFileDiff({
   onRenderPatch,
   onAddComment,
   onDeleteComment,
+  onResolveComment,
+  onReplyToComment,
 }: SingleFileDiffProps) {
   const { resolvedTheme } = useTheme();
   // `comments` is already scoped to this file by DiffViewer's commentsByFile split.
   const fileComments = comments;
+  const commentsById = useMemo(() => {
+    const map = new Map<string, Comment>();
+    for (const comment of fileComments) {
+      map.set(comment.id, comment);
+    }
+    return map;
+  }, [fileComments]);
   const headerId = `${sectionId}-header`;
   const panelId = `${sectionId}-panel`;
 
   const lineAnnotations = useMemo((): DiffLineAnnotation<AnnotationData>[] => {
     const annotations: DiffLineAnnotation<AnnotationData>[] = fileComments.map((c) => ({
       lineNumber: c.lineNumber,
-      metadata: { comment: c, type: "comment" as const },
+      metadata: { commentId: c.id, type: "comment" as const },
       side: toAnnotationSide(c.side),
     }));
 
@@ -604,15 +771,36 @@ const SingleFileDiff = memo(function SingleFileDiff({
       }
 
       if (d.type === "comment") {
+        const comment = commentsById.get(d.commentId);
+        if (!comment) {
+          return null;
+        }
+
         return (
-          // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
-          <CommentDisplay comment={d.comment} onDelete={() => onDeleteComment(d.comment.id)} />
+          <CommentDisplay
+            key={comment.id}
+            comment={comment}
+            // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
+            onDelete={() => onDeleteComment(comment.id)}
+            // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
+            onResolve={(resolved) => onResolveComment(comment.id, resolved)}
+            // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
+            onReply={(body) => onReplyToComment(comment.id, body)}
+          />
         );
       }
 
       return null;
     },
-    [file, onAddComment, onCommentTargetChange, onDeleteComment],
+    [
+      commentsById,
+      file,
+      onAddComment,
+      onCommentTargetChange,
+      onDeleteComment,
+      onReplyToComment,
+      onResolveComment,
+    ],
   );
 
   const renderGutterUtility = useCallback(
@@ -711,6 +899,8 @@ interface DiffViewerProps {
     tag: CommentTag,
   ) => Promise<boolean>;
   onDeleteComment: (id: string) => Promise<boolean>;
+  onResolveComment: (id: string, resolved: boolean) => Promise<boolean>;
+  onReplyToComment: (id: string, body: string) => Promise<boolean>;
   activeFileId: string | null;
   fileStats: DiffFileStat[];
   collapsedFiles: Set<string>;
@@ -743,6 +933,8 @@ interface CollapsibleFileDiffProps {
     tag: CommentTag,
   ) => Promise<boolean>;
   onDeleteComment: (id: string) => Promise<boolean>;
+  onResolveComment: (id: string, resolved: boolean) => Promise<boolean>;
+  onReplyToComment: (id: string, body: string) => Promise<boolean>;
 }
 
 const CollapsibleFileDiff = memo(function CollapsibleFileDiff({
@@ -762,6 +954,8 @@ const CollapsibleFileDiff = memo(function CollapsibleFileDiff({
   repoPath,
   onAddComment,
   onDeleteComment,
+  onResolveComment,
+  onReplyToComment,
 }: CollapsibleFileDiffProps) {
   const sectionRef = useRef<HTMLElement>(null);
   const [commentTarget, setCommentTarget] = useState<CommentTarget | null>(null);
@@ -897,6 +1091,8 @@ const CollapsibleFileDiff = memo(function CollapsibleFileDiff({
         onRenderPatch={handleRenderPatch}
         onAddComment={onAddComment}
         onDeleteComment={onDeleteComment}
+        onResolveComment={onResolveComment}
+        onReplyToComment={onReplyToComment}
       />
     </section>
   );
@@ -909,6 +1105,8 @@ export const DiffViewer = ({
   comments,
   onAddComment,
   onDeleteComment,
+  onResolveComment,
+  onReplyToComment,
   activeFileId,
   fileStats,
   collapsedFiles,
@@ -1000,6 +1198,8 @@ export const DiffViewer = ({
             repoPath={repoPath}
             onAddComment={onAddComment}
             onDeleteComment={onDeleteComment}
+            onResolveComment={onResolveComment}
+            onReplyToComment={onReplyToComment}
           />
         );
       })}
diff --git a/apps/cli/components/DiffsWorkerProvider.tsx b/apps/cli/components/DiffsWorkerProvider.tsx
index 1bbae29..825726c 100644
--- a/apps/cli/components/DiffsWorkerProvider.tsx
+++ b/apps/cli/components/DiffsWorkerProvider.tsx
@@ -13,7 +13,10 @@ const createDiffWorker = (): Worker =>
 export const DiffsWorkerProvider = ({ children }: DiffsWorkerProviderProps): React.JSX.Element => {
   const poolOptions = useMemo(
     () => ({
-      poolSize: Math.min(4, navigator.hardwareConcurrency || 4),
+      poolSize: Math.min(
+        4,
+        typeof navigator === "undefined" ? 4 : navigator.hardwareConcurrency || 4,
+      ),
       workerFactory: createDiffWorker,
     }),
     [],
diff --git a/apps/cli/components/StatusBar.tsx b/apps/cli/components/StatusBar.tsx
index 866422f..fd6067b 100644
--- a/apps/cli/components/StatusBar.tsx
+++ b/apps/cli/components/StatusBar.tsx
@@ -10,7 +10,6 @@ import {
   ArrowRightIcon,
   ArrowRotateClockwiseIcon,
 } from "blode-icons-react";
-import { useTheme } from "next-themes";
 import { useEffect, useRef, useState, useSyncExternalStore } from "react";
 import type { Comment } from "@/lib/comment-types";
 import { exportCommentsAsPrompt } from "@/lib/export-comments";
@@ -19,6 +18,7 @@ import { Kbd } from "@/components/ui/kbd";
 import { SidebarTrigger } from "@/components/ui/sidebar";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 import { cn } from "@/lib/utils";
+import { useTheme } from "./theme-provider";
 
 export type DiffMode = "all" | "uncommitted";
 export type WatchStatus = "connecting" | "live" | "offline" | "updated";
diff --git a/apps/cli/components/theme-provider.tsx b/apps/cli/components/theme-provider.tsx
index 206a106..4cf1f3f 100644
--- a/apps/cli/components/theme-provider.tsx
+++ b/apps/cli/components/theme-provider.tsx
@@ -1,12 +1,101 @@
 "use client";
 
 import * as React from "react";
-import { ThemeProvider as NextThemesProvider } from "next-themes";
-import type { ThemeProviderProps } from "next-themes";
+
+type Theme = "dark" | "light";
+
+interface ThemeContextValue {
+  forcedTheme?: Theme;
+  resolvedTheme: Theme;
+  setTheme: (theme: React.SetStateAction<string>) => void;
+  systemTheme: Theme;
+  theme: Theme;
+  themes: Theme[];
+}
+
+interface ThemeProviderProps {
+  attribute?: "class" | string;
+  children?: React.ReactNode;
+  defaultTheme?: Theme;
+  disableTransitionOnChange?: boolean;
+  enableSystem?: boolean;
+}
+
+const STORAGE_KEY = "theme";
+const DEFAULT_THEME: Theme = "dark";
+
+const ThemeContext = React.createContext<ThemeContextValue>({
+  resolvedTheme: DEFAULT_THEME,
+  setTheme: () => {
+    // Default context only applies outside ThemeProvider.
+  },
+  systemTheme: DEFAULT_THEME,
+  theme: DEFAULT_THEME,
+  themes: ["light", "dark"],
+});
+
+const isTheme = (value: unknown): value is Theme => value === "dark" || value === "light";
+
+const getStoredTheme = (fallback: Theme): Theme => {
+  if (typeof window === "undefined") {
+    return fallback;
+  }
+
+  try {
+    const stored = window.localStorage.getItem(STORAGE_KEY);
+    return isTheme(stored) ? stored : fallback;
+  } catch {
+    return fallback;
+  }
+};
+
+const applyTheme = (theme: Theme, attribute: string): void => {
+  const root = document.documentElement;
+  if (attribute === "class") {
+    root.classList.remove("dark", "light");
+    root.classList.add(theme);
+  } else {
+    root.setAttribute(attribute, theme);
+  }
+  root.style.colorScheme = theme;
+};
 
 export const ThemeProvider = ({
+  attribute = "class",
   children,
-  ...props
-}: ThemeProviderProps & { children?: React.ReactNode }) => (
-  <NextThemesProvider {...props}>{children}</NextThemesProvider>
-);
+  defaultTheme = DEFAULT_THEME,
+}: ThemeProviderProps) => {
+  const fallback = isTheme(defaultTheme) ? defaultTheme : DEFAULT_THEME;
+  const [theme, setTheme] = React.useState<Theme>(() => getStoredTheme(fallback));
+
+  React.useEffect(() => {
+    applyTheme(theme, attribute);
+    try {
+      window.localStorage.setItem(STORAGE_KEY, theme);
+    } catch {
+      // localStorage unavailable
+    }
+  }, [attribute, theme]);
+
+  const setResolvedTheme = React.useCallback((nextTheme: React.SetStateAction<string>) => {
+    setTheme((current) => {
+      const value = typeof nextTheme === "function" ? nextTheme(current) : nextTheme;
+      return isTheme(value) ? value : current;
+    });
+  }, []);
+
+  const value = React.useMemo(
+    () => ({
+      resolvedTheme: theme,
+      setTheme: setResolvedTheme,
+      systemTheme: theme,
+      theme,
+      themes: ["light", "dark"] as Theme[],
+    }),
+    [setResolvedTheme, theme],
+  );
+
+  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
+};
+
+export const useTheme = (): ThemeContextValue => React.useContext(ThemeContext);
diff --git a/apps/cli/lib/comment-types.ts b/apps/cli/lib/comment-types.ts
index df31fb4..8b4f5b3 100644
--- a/apps/cli/lib/comment-types.ts
+++ b/apps/cli/lib/comment-types.ts
@@ -1,6 +1,22 @@
 import type { CommentSide } from "./comment-sides";
 
 export type CommentTag = "[must-fix]" | "[suggestion]" | "[nit]" | "[question]" | "";
+export type CommentStaleness = "fresh" | "moved" | "stale";
+
+export interface CommentAnchor {
+  fileSha: string;
+  lineContent: string;
+  beforeContext: string[];
+  afterContext: string[];
+  createdFromMissingFile?: boolean;
+  diffHunkHeader?: string;
+}
+
+export interface CommentReply {
+  body: string;
+  by?: string;
+  at: string;
+}
 
 export interface Comment {
   id: string;
@@ -10,4 +26,12 @@ export interface Comment {
   body: string;
   tag: CommentTag;
   createdAt: string;
+  createdBy?: string;
+  anchor: CommentAnchor;
+  staleness: CommentStaleness;
+  rebasedFromLine?: number;
+  resolved: boolean;
+  resolvedBy?: string;
+  resolvedAt?: string;
+  replies: CommentReply[];
 }
diff --git a/apps/cli/lib/comments.test.ts b/apps/cli/lib/comments.test.ts
index 510a8bf..5c2bb53 100644
--- a/apps/cli/lib/comments.test.ts
+++ b/apps/cli/lib/comments.test.ts
@@ -1,8 +1,15 @@
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { addComment, deleteComment, readComments } from "./comments";
+import {
+  addComment,
+  addReply,
+  clearCommentCache,
+  deleteComment,
+  readComments,
+  setCommentResolved,
+} from "./comments";
 
 const tempPaths: string[] = [];
 
@@ -16,10 +23,14 @@ const createTempRepo = (): string => {
 describe("comments store", () => {
   beforeEach(() => {
     process.env.DIFFHUB_REPO = createTempRepo();
+    process.env.DIFFHUB_USER = "Store Tester";
   });
 
   afterEach(() => {
+    delete process.env.DIFFHUB_AUTO_RESOLVE_STALE;
+    delete process.env.DIFFHUB_USER;
     delete process.env.DIFFHUB_REPO;
+    clearCommentCache();
 
     for (const tempPath of tempPaths.splice(0)) {
       rmSync(tempPath, { force: true, recursive: true });
@@ -37,6 +48,11 @@ describe("comments store", () => {
   });
 
   it("serializes queued writes and persists deletions", async () => {
+    mkdirSync(join(process.env.DIFFHUB_REPO as string, "src"));
+    writeFileSync(join(process.env.DIFFHUB_REPO as string, "src/a.ts"), "first\n");
+    writeFileSync(join(process.env.DIFFHUB_REPO as string, "src/b.ts"), "one\nsecond\n");
+    clearCommentCache();
+
     const [firstComment, secondComment] = await Promise.all([
       addComment({
         body: "First comment",
@@ -63,4 +79,229 @@ describe("comments store", () => {
 
     expect(readComments()).toStrictEqual([secondComment]);
   });
+
+  it("resolves, unresolves, and appends replies", async () => {
+    const comment = await addComment({
+      body: "Needs work",
+      file: "src/a.ts",
+      lineNumber: 1,
+      side: "right",
+      tag: "",
+    });
+
+    await setCommentResolved(comment.id, true);
+    expect(readComments()).toMatchObject([
+      {
+        resolved: true,
+        resolvedAt: expect.any(String),
+        resolvedBy: "Store Tester",
+      },
+    ]);
+
+    await addReply(comment.id, "Fixed now");
+    expect(readComments()).toMatchObject([
+      {
+        replies: [{ at: expect.any(String), body: "Fixed now", by: "Store Tester" }],
+        resolved: true,
+      },
+    ]);
+
+    await setCommentResolved(comment.id, false);
+    const [unresolved] = readComments();
+    expect(unresolved).toMatchObject({ resolved: false });
+    expect(unresolved?.resolvedAt).toBeUndefined();
+    expect(unresolved?.resolvedBy).toBeUndefined();
+  });
+
+  it("marks deleted anchored files stale", async () => {
+    mkdirSync(join(process.env.DIFFHUB_REPO as string, "src"));
+    writeFileSync(join(process.env.DIFFHUB_REPO as string, "src/a.ts"), "before\ntarget\nafter\n");
+    clearCommentCache();
+
+    await addComment({
+      body: "Needs work",
+      file: "src/a.ts",
+      lineNumber: 2,
+      side: "right",
+      tag: "",
+    });
+
+    unlinkSync(join(process.env.DIFFHUB_REPO as string, "src/a.ts"));
+    clearCommentCache();
+
+    expect(readComments()).toMatchObject([{ lineNumber: 2, staleness: "stale" }]);
+  });
+
+  it("moves anchors near the original line and by unique context", async () => {
+    mkdirSync(join(process.env.DIFFHUB_REPO as string, "src"));
+    writeFileSync(
+      join(process.env.DIFFHUB_REPO as string, "src/a.ts"),
+      ["alpha", "before", "target", "after", "omega"].join("\n"),
+    );
+    clearCommentCache();
+
+    await addComment({
+      body: "Needs work",
+      file: "src/a.ts",
+      lineNumber: 3,
+      side: "right",
+      tag: "",
+    });
+
+    writeFileSync(
+      join(process.env.DIFFHUB_REPO as string, "src/a.ts"),
+      ["one", "two", "three", "alpha", "before", "target", "after", "omega"].join("\n"),
+    );
+    clearCommentCache();
+    expect(readComments()).toMatchObject([
+      { lineNumber: 6, rebasedFromLine: 3, staleness: "moved" },
+    ]);
+
+    writeFileSync(
+      join(process.env.DIFFHUB_REPO as string, "src/a.ts"),
+      ["target", "noise", "alpha", "before", "target", "after", "omega"].join("\n"),
+    );
+    clearCommentCache();
+    expect(readComments()).toMatchObject([
+      { lineNumber: 5, rebasedFromLine: 6, staleness: "moved" },
+    ]);
+  });
+
+  it("moves a first-line anchor to the nearest matching line", async () => {
+    mkdirSync(join(process.env.DIFFHUB_REPO as string, "src"));
+    writeFileSync(
+      join(process.env.DIFFHUB_REPO as string, "src/a.ts"),
+      ["target", "same", "same"].join("\n"),
+    );
+    clearCommentCache();
+
+    await addComment({
+      body: "Needs work",
+      file: "src/a.ts",
+      lineNumber: 1,
+      side: "right",
+      tag: "",
+    });
+
+    writeFileSync(
+      join(process.env.DIFFHUB_REPO as string, "src/a.ts"),
+      ["same", "target", "same", "target"].join("\n"),
+    );
+    clearCommentCache();
+
+    expect(readComments()).toMatchObject([
+      { lineNumber: 2, rebasedFromLine: 1, staleness: "moved" },
+    ]);
+  });
+
+  it("auto-resolves stale transitions when enabled", async () => {
+    mkdirSync(join(process.env.DIFFHUB_REPO as string, "src"));
+    writeFileSync(join(process.env.DIFFHUB_REPO as string, "src/a.ts"), "before\ntarget\nafter\n");
+    clearCommentCache();
+
+    await addComment({
+      body: "Needs work",
+      file: "src/a.ts",
+      lineNumber: 2,
+      side: "right",
+      tag: "",
+    });
+
+    process.env.DIFFHUB_AUTO_RESOLVE_STALE = "1";
+    writeFileSync(join(process.env.DIFFHUB_REPO as string, "src/a.ts"), "replacement\ncontent\n");
+    clearCommentCache();
+
+    expect(readComments()).toMatchObject([
+      {
+        resolved: true,
+        resolvedBy: "diffhub:auto-stale",
+        staleness: "stale",
+      },
+    ]);
+  });
+
+  it("does not auto-resolve a new comment on a missing working-tree file", async () => {
+    process.env.DIFFHUB_AUTO_RESOLVE_STALE = "1";
+
+    await addComment({
+      body: "Deleted file note",
+      file: "src/deleted.ts",
+      lineNumber: 1,
+      side: "left",
+      tag: "",
+    });
+
+    expect(readComments()).toMatchObject([
+      {
+        resolved: false,
+        staleness: "stale",
+      },
+    ]);
+  });
+
+  it("auto-resolves a missing-file anchor after the file appears and later turns stale", async () => {
+    process.env.DIFFHUB_AUTO_RESOLVE_STALE = "1";
+
+    await addComment({
+      body: "Deleted file note",
+      file: "src/deleted.ts",
+      lineNumber: 1,
+      side: "left",
+      tag: "",
+    });
+
+    mkdirSync(join(process.env.DIFFHUB_REPO as string, "src"));
+    writeFileSync(join(process.env.DIFFHUB_REPO as string, "src/deleted.ts"), "\n");
+    clearCommentCache();
+    expect(readComments()).toMatchObject([
+      {
+        anchor: { createdFromMissingFile: false },
+        resolved: false,
+        staleness: "fresh",
+      },
+    ]);
+
+    writeFileSync(join(process.env.DIFFHUB_REPO as string, "src/deleted.ts"), "replacement\n");
+    clearCommentCache();
+
+    expect(readComments()).toMatchObject([
+      {
+        resolved: true,
+        resolvedBy: "diffhub:auto-stale",
+        staleness: "stale",
+      },
+    ]);
+  });
+
+  it("persists cleanup of corrupted rebasedFromLine values", () => {
+    const commentsPath = join(process.env.DIFFHUB_REPO as string, ".git", "diffhub-comments.json");
+    writeFileSync(
+      commentsPath,
+      JSON.stringify([
+        {
+          anchor: {
+            afterContext: [],
+            beforeContext: [],
+            fileSha: "",
+            lineContent: "",
+          },
+          body: "Corrupt metadata",
+          createdAt: "2026-05-13T00:00:00.000Z",
+          file: "src/a.ts",
+          id: "corrupt-1",
+          lineNumber: 1,
+          rebasedFromLine: "not-a-number",
+          replies: [],
+          resolved: false,
+          side: "right",
+          staleness: "stale",
+          tag: "",
+        },
+      ]),
+    );
+
+    expect(readComments()[0]?.rebasedFromLine).toBeUndefined();
+    const persisted = JSON.parse(readFileSync(commentsPath, "utf-8")) as Record<string, unknown>[];
+    expect(persisted[0]?.rebasedFromLine).toBeUndefined();
+  });
 });
diff --git a/apps/cli/lib/comments.ts b/apps/cli/lib/comments.ts
index 7dc66e7..7d53bab 100644
--- a/apps/cli/lib/comments.ts
+++ b/apps/cli/lib/comments.ts
@@ -1,17 +1,57 @@
-import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
 import { join } from "node:path";
 import { isCommentSide } from "./comment-sides";
-import type { Comment, CommentTag } from "./comment-types";
-import { getGitDirectory } from "./git-paths";
+import type {
+  Comment,
+  CommentAnchor,
+  CommentReply,
+  CommentStaleness,
+  CommentTag,
+} from "./comment-types";
+import { getGitDirectory, resolveRepoFilePath } from "./git-paths";
 import { getConfiguredRepoPath } from "./repo-path";
 
 export type { Comment, CommentTag } from "./comment-types";
 
 const COMMENTS_FILENAME = "diffhub-comments.json";
+const CONTEXT_LINES = 3;
+const CONTEXT_MAX_CHARS = 120;
+const NEARBY_LINE_WINDOW = 10;
+
 let mutationQueue = Promise.resolve(null);
 
-const getCommentsPath = (): string =>
-  join(getGitDirectory(getConfiguredRepoPath()), COMMENTS_FILENAME);
+interface FileSnapshot {
+  exists: boolean;
+  fileSha: string;
+  lines: string[];
+  mtimeMs: number;
+}
+
+interface CommentRuntimeState {
+  fileSnapshots: Map<string, FileSnapshot>;
+}
+
+const getRuntimeState = (): CommentRuntimeState => {
+  const globalScope = globalThis as typeof globalThis & {
+    __diffhubCommentRuntimeState?: CommentRuntimeState;
+  };
+
+  if (!globalScope.__diffhubCommentRuntimeState) {
+    globalScope.__diffhubCommentRuntimeState = { fileSnapshots: new Map() };
+  }
+
+  return globalScope.__diffhubCommentRuntimeState;
+};
+
+const commentRuntimeState = getRuntimeState();
+
+const getRepoPath = (): string => getConfiguredRepoPath();
+const getCommentsPath = (): string => join(getGitDirectory(getRepoPath()), COMMENTS_FILENAME);
+
+export const clearCommentCache = (): void => {
+  commentRuntimeState.fileSnapshots.clear();
+};
 
 const isCommentTag = (value: unknown): value is CommentTag =>
   value === "" ||
@@ -20,7 +60,44 @@ const isCommentTag = (value: unknown): value is CommentTag =>
   value === "[nit]" ||
   value === "[question]";
 
-const isComment = (value: unknown): value is Comment => {
+const truncateContext = (line: string): string => line.slice(0, CONTEXT_MAX_CHARS);
+
+const isCommentStaleness = (value: unknown): value is CommentStaleness =>
+  value === "fresh" || value === "moved" || value === "stale";
+
+const isAnchor = (value: unknown): value is CommentAnchor => {
+  if (typeof value !== "object" || value === null) {
+    return false;
+  }
+
+  const anchor = value as Record<string, unknown>;
+  return (
+    typeof anchor.fileSha === "string" &&
+    typeof anchor.lineContent === "string" &&
+    Array.isArray(anchor.beforeContext) &&
+    anchor.beforeContext.every((line) => typeof line === "string") &&
+    Array.isArray(anchor.afterContext) &&
+    anchor.afterContext.every((line) => typeof line === "string") &&
+    (anchor.createdFromMissingFile === undefined ||
+      typeof anchor.createdFromMissingFile === "boolean") &&
+    (anchor.diffHunkHeader === undefined || typeof anchor.diffHunkHeader === "string")
+  );
+};
+
+const isReply = (value: unknown): value is CommentReply => {
+  if (typeof value !== "object" || value === null) {
+    return false;
+  }
+
+  const reply = value as Record<string, unknown>;
+  return (
+    typeof reply.body === "string" &&
+    typeof reply.at === "string" &&
+    (reply.by === undefined || typeof reply.by === "string")
+  );
+};
+
+const isCommentLike = (value: unknown): value is Record<string, unknown> => {
   if (typeof value !== "object" || value === null) {
     return false;
   }
@@ -37,18 +114,231 @@ const isComment = (value: unknown): value is Comment => {
   );
 };
 
-export const readComments = (): Comment[] => {
-  const path = getCommentsPath();
-  if (!existsSync(path)) {
-    return [];
+const readFileSnapshot = (file: string): FileSnapshot => {
+  const repoPath = getRepoPath();
+  let absolutePath: string;
+
+  try {
+    absolutePath = resolveRepoFilePath(repoPath, file);
+  } catch {
+    return { exists: false, fileSha: "", lines: [], mtimeMs: -1 };
   }
 
-  const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
-  if (!Array.isArray(parsed) || parsed.some((comment) => !isComment(comment))) {
-    throw new Error("Comment store is corrupted");
+  let mtimeMs = -1;
+  try {
+    ({ mtimeMs } = statSync(absolutePath));
+  } catch {
+    return { exists: false, fileSha: "", lines: [], mtimeMs };
+  }
+
+  const cacheKey = `${repoPath}:${file}`;
+  const cached = commentRuntimeState.fileSnapshots.get(cacheKey);
+  if (cached && cached.mtimeMs === mtimeMs) {
+    return cached;
+  }
+
+  const content = readFileSync(absolutePath, "utf-8");
+  const lines = content.split(/\r?\n/);
+  if (lines.at(-1) === "") {
+    lines.pop();
+  }
+
+  let fileSha = "";
+  try {
+    fileSha = execFileSync("git", ["hash-object", absolutePath], {
+      cwd: repoPath,
+      encoding: "utf-8",
+      env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
+      stdio: ["ignore", "pipe", "ignore"],
+    }).trim();
+  } catch {
+    fileSha = "";
+  }
+
+  const snapshot = { exists: true, fileSha, lines, mtimeMs };
+  commentRuntimeState.fileSnapshots.set(cacheKey, snapshot);
+  return snapshot;
+};
+
+const buildAnchor = (file: string, lineNumber: number, diffHunkHeader?: string): CommentAnchor => {
+  const snapshot = readFileSnapshot(file);
+  const lineIndex = lineNumber - 1;
+  const lineContent = snapshot.lines[lineIndex] ?? "";
+  const beforeStart = Math.max(0, lineIndex - CONTEXT_LINES);
+  const afterEnd = Math.min(snapshot.lines.length, lineIndex + CONTEXT_LINES + 1);
+
+  const anchor: CommentAnchor = {
+    afterContext: snapshot.lines.slice(lineIndex + 1, afterEnd).map(truncateContext),
+    beforeContext: snapshot.lines.slice(beforeStart, lineIndex).map(truncateContext),
+    createdFromMissingFile: !snapshot.exists,
+    fileSha: snapshot.fileSha,
+    lineContent,
+  };
+
+  if (diffHunkHeader !== undefined) {
+    anchor.diffHunkHeader = diffHunkHeader;
+  }
+
+  return anchor;
+};
+
+const lineMatchesContext = (expected: string | undefined, actual: string | undefined): boolean =>
+  expected !== undefined && actual !== undefined && truncateContext(actual) === expected;
+
+const hasAdjacentContextMatch = (
+  lines: string[],
+  lineIndex: number,
+  anchor: CommentAnchor,
+): boolean => {
+  for (let index = 0; index < anchor.beforeContext.length; index += 1) {
+    const offset = anchor.beforeContext.length - index;
+    if (lineMatchesContext(anchor.beforeContext[index], lines[lineIndex - offset])) {
+      return true;
+    }
   }
 
-  return parsed;
+  for (let index = 0; index < anchor.afterContext.length; index += 1) {
+    if (lineMatchesContext(anchor.afterContext[index], lines[lineIndex + index + 1])) {
+      return true;
+    }
+  }
+
+  return false;
+};
+
+const findNearbyLine = (
+  lines: string[],
+  lineNumber: number,
+  anchor: CommentAnchor,
+): number | null => {
+  const start = Math.max(0, lineNumber - 1 - NEARBY_LINE_WINDOW);
+  const end = Math.min(lines.length - 1, lineNumber - 1 + NEARBY_LINE_WINDOW);
+  const immediateBefore = anchor.beforeContext.at(-1);
+
+  for (let index = start; index <= end; index += 1) {
+    if (lines[index] !== anchor.lineContent) {
+      continue;
+    }
+
+    if (immediateBefore === undefined) {
+      return index + 1;
+    }
+
+    if (lineMatchesContext(immediateBefore, lines[index - 1])) {
+      return index + 1;
+    }
+  }
+
+  return null;
+};
+
+const findUniqueContextualLine = (lines: string[], anchor: CommentAnchor): number | null => {
+  const matches: number[] = [];
+
+  for (let index = 0; index < lines.length; index += 1) {
+    if (lines[index] !== anchor.lineContent) {
+      continue;
+    }
+
+    if (hasAdjacentContextMatch(lines, index, anchor)) {
+      matches.push(index + 1);
+    }
+  }
+
+  return matches.length === 1 ? matches[0] : null;
+};
+
+const normalizeComment = (raw: Record<string, unknown>): { changed: boolean; comment: Comment } => {
+  let changed = false;
+  const comment = raw as unknown as Comment;
+
+  if (!isAnchor(raw.anchor)) {
+    comment.anchor = buildAnchor(comment.file, comment.lineNumber);
+    changed = true;
+  }
+  if (!isCommentStaleness(raw.staleness)) {
+    comment.staleness = "fresh";
+    changed = true;
+  }
+  if (raw.rebasedFromLine !== undefined && typeof raw.rebasedFromLine !== "number") {
+    delete comment.rebasedFromLine;
+    changed = true;
+  }
+  if (typeof raw.resolved !== "boolean") {
+    comment.resolved = false;
+    changed = true;
+  }
+  if (raw.resolvedBy !== undefined && typeof raw.resolvedBy !== "string") {
+    delete comment.resolvedBy;
+    changed = true;
+  }
+  if (raw.resolvedAt !== undefined && typeof raw.resolvedAt !== "string") {
+    delete comment.resolvedAt;
+    changed = true;
+  }
+  if (!Array.isArray(raw.replies) || !raw.replies.every(isReply)) {
+    comment.replies = [];
+    changed = true;
+  }
+  if (raw.createdBy !== undefined && typeof raw.createdBy !== "string") {
+    delete comment.createdBy;
+    changed = true;
+  }
+
+  return { changed, comment };
+};
+
+const reanchorComment = (comment: Comment): boolean => {
+  const snapshot = readFileSnapshot(comment.file);
+  const previousStaleness = comment.staleness;
+  const previousLineNumber = comment.lineNumber;
+  const previousRebasedFromLine = comment.rebasedFromLine;
+  const wasCreatedFromMissingFile = comment.anchor.createdFromMissingFile === true;
+  const previousCreatedFromMissingFile = comment.anchor.createdFromMissingFile;
+  const suppressInitialMissingFileAutoResolve =
+    wasCreatedFromMissingFile && !snapshot.exists && previousStaleness === "fresh";
+
+  delete comment.rebasedFromLine;
+  if (wasCreatedFromMissingFile && snapshot.exists) {
+    comment.anchor.createdFromMissingFile = false;
+  }
+
+  if (!snapshot.exists) {
+    comment.staleness = "stale";
+  } else if (snapshot.lines[comment.lineNumber - 1] === comment.anchor.lineContent) {
+    comment.staleness = "fresh";
+  } else {
+    const movedLine =
+      findNearbyLine(snapshot.lines, comment.lineNumber, comment.anchor) ??
+      findUniqueContextualLine(snapshot.lines, comment.anchor);
+
+    if (movedLine === null) {
+      comment.staleness = "stale";
+    } else {
+      comment.staleness = "moved";
+      comment.rebasedFromLine = comment.lineNumber;
+      comment.lineNumber = movedLine;
+    }
+  }
+
+  if (
+    process.env.DIFFHUB_AUTO_RESOLVE_STALE === "1" &&
+    previousStaleness !== "stale" &&
+    comment.staleness === "stale" &&
+    comment.resolved !== true &&
+    !suppressInitialMissingFileAutoResolve
+  ) {
+    comment.resolved = true;
+    comment.resolvedBy = "diffhub:auto-stale";
+    comment.resolvedAt = new Date().toISOString();
+  }
+
+  return (
+    previousStaleness !== comment.staleness ||
+    previousLineNumber !== comment.lineNumber ||
+    previousRebasedFromLine !== comment.rebasedFromLine ||
+    previousCreatedFromMissingFile !== comment.anchor.createdFromMissingFile
+  );
 };
 
 const saveComments = (comments: Comment[]): void => {
@@ -58,9 +348,40 @@ const saveComments = (comments: Comment[]): void => {
   renameSync(tempPath, path);
 };
 
+const readCommentsWithMetadata = ({ persistChanges = true } = {}): {
+  changed: boolean;
+  comments: Comment[];
+} => {
+  const path = getCommentsPath();
+  if (!existsSync(path)) {
+    return { changed: false, comments: [] };
+  }
+
+  const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
+  if (!Array.isArray(parsed) || parsed.some((comment) => !isCommentLike(comment))) {
+    throw new Error("Comment store is corrupted");
+  }
+
+  let changed = false;
+  const comments = parsed.map((raw) => {
+    const normalized = normalizeComment(raw);
+    changed ||= normalized.changed;
+    changed ||= reanchorComment(normalized.comment);
+    return normalized.comment;
+  });
+
+  if (changed && persistChanges) {
+    saveComments(comments);
+  }
+
+  return { changed, comments };
+};
+
+export const readComments = (): Comment[] => readCommentsWithMetadata().comments;
+
 const mutateComments = <T>(updater: (comments: Comment[]) => T): Promise<T> => {
   const runUpdate = (): T => {
-    const comments = readComments();
+    const { comments } = readCommentsWithMetadata({ persistChanges: false });
     const result = updater(comments);
     saveComments(comments);
     return result;
@@ -76,17 +397,78 @@ const mutateComments = <T>(updater: (comments: Comment[]) => T): Promise<T> => {
   return nextMutation;
 };
 
-export const addComment = (data: Omit<Comment, "id" | "createdAt">): Promise<Comment> =>
+export const addComment = (
+  data: Omit<Comment, "anchor" | "createdAt" | "id" | "replies" | "resolved" | "staleness"> & {
+    diffHunkHeader?: string;
+  },
+): Promise<Comment> =>
   mutateComments((comments) => {
+    const { diffHunkHeader, ...commentData } = data;
     const comment: Comment = {
-      ...data,
+      ...commentData,
+      anchor: buildAnchor(data.file, data.lineNumber, diffHunkHeader),
       createdAt: new Date().toISOString(),
       id: crypto.randomUUID(),
+      replies: [],
+      resolved: false,
+      staleness: "fresh",
     };
     comments.push(comment);
     return comment;
   });
 
+const getCurrentUser = (): string => {
+  if (process.env.DIFFHUB_USER) {
+    return process.env.DIFFHUB_USER;
+  }
+
+  try {
+    const name = execFileSync("git", ["config", "user.name"], {
+      cwd: getRepoPath(),
+      encoding: "utf-8",
+      env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
+      stdio: ["ignore", "pipe", "ignore"],
+    }).trim();
+    return name || "unknown";
+  } catch {
+    return "unknown";
+  }
+};
+
+export const setCommentResolved = (id: string, resolved: boolean): Promise<Comment | null> =>
+  mutateComments((comments) => {
+    const comment = comments.find((candidate) => candidate.id === id);
+    if (!comment) {
+      return null;
+    }
+
+    comment.resolved = resolved;
+    if (resolved) {
+      comment.resolvedBy = getCurrentUser();
+      comment.resolvedAt = new Date().toISOString();
+    } else {
+      delete comment.resolvedBy;
+      delete comment.resolvedAt;
+    }
+
+    return comment;
+  });
+
+export const addReply = (id: string, body: string): Promise<Comment | null> =>
+  mutateComments((comments) => {
+    const comment = comments.find((candidate) => candidate.id === id);
+    if (!comment) {
+      return null;
+    }
+
+    comment.replies.push({
+      at: new Date().toISOString(),
+      body,
+      by: getCurrentUser(),
+    });
+    return comment;
+  });
+
 export const deleteComment = (id: string): Promise<void> =>
   mutateComments((comments) => {
     const index = comments.findIndex((comment) => comment.id === id);
diff --git a/apps/cli/test/comments-anchor.test.ts b/apps/cli/test/comments-anchor.test.ts
new file mode 100644
index 0000000..977b1c0
--- /dev/null
+++ b/apps/cli/test/comments-anchor.test.ts
@@ -0,0 +1,134 @@
+import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { addComment, clearCommentCache, readComments } from "../lib/comments";
+
+const tempPaths: string[] = [];
+
+const createTempRepo = (): string => {
+  const repoPath = mkdtempSync(join(tmpdir(), "diffhub-comments-anchor-"));
+  mkdirSync(join(repoPath, ".git"));
+  tempPaths.push(repoPath);
+  return repoPath;
+};
+
+const writeSource = (contents: string): void => {
+  writeFileSync(join(process.env.DIFFHUB_REPO as string, "src.ts"), contents);
+  clearCommentCache();
+};
+
+describe("comment content anchors", () => {
+  beforeEach(() => {
+    process.env.DIFFHUB_REPO = createTempRepo();
+    writeSource(["alpha", "before", "target", "after", "omega"].join("\n"));
+  });
+
+  afterEach(() => {
+    delete process.env.DIFFHUB_REPO;
+    clearCommentCache();
+
+    for (const tempPath of tempPaths.splice(0)) {
+      rmSync(tempPath, { force: true, recursive: true });
+    }
+  });
+
+  it("keeps an exact line match fresh", async () => {
+    await addComment({
+      body: "Check target",
+      file: "src.ts",
+      lineNumber: 3,
+      side: "right",
+      tag: "",
+    });
+
+    expect(readComments()).toMatchObject([
+      {
+        anchor: {
+          afterContext: ["after", "omega"],
+          beforeContext: ["alpha", "before"],
+          lineContent: "target",
+        },
+        lineNumber: 3,
+        staleness: "fresh",
+      },
+    ]);
+  });
+
+  it("rebases a shifted line near its original location", async () => {
+    await addComment({
+      body: "Check target",
+      file: "src.ts",
+      lineNumber: 3,
+      side: "right",
+      tag: "",
+    });
+
+    writeSource(
+      ["one", "two", "three", "four", "five", "alpha", "before", "target", "after"].join("\n"),
+    );
+
+    expect(readComments()).toMatchObject([
+      {
+        lineNumber: 8,
+        rebasedFromLine: 3,
+        staleness: "moved",
+      },
+    ]);
+  });
+
+  it("marks a full rewrite stale", async () => {
+    await addComment({
+      body: "Check target",
+      file: "src.ts",
+      lineNumber: 3,
+      side: "right",
+      tag: "",
+    });
+
+    writeSource(["completely", "different", "file"].join("\n"));
+
+    expect(readComments()).toMatchObject([{ lineNumber: 3, staleness: "stale" }]);
+  });
+
+  it("marks deleted files stale", async () => {
+    await addComment({
+      body: "Check target",
+      file: "src.ts",
+      lineNumber: 3,
+      side: "right",
+      tag: "",
+    });
+
+    unlinkSync(join(process.env.DIFFHUB_REPO as string, "src.ts"));
+    clearCommentCache();
+
+    expect(readComments()).toMatchObject([{ lineNumber: 3, staleness: "stale" }]);
+  });
+
+  it("migrates legacy comments by deriving an anchor from current file contents", () => {
+    writeFileSync(
+      join(process.env.DIFFHUB_REPO as string, ".git", "diffhub-comments.json"),
+      JSON.stringify([
+        {
+          body: "Legacy comment",
+          createdAt: "2026-05-13T00:00:00.000Z",
+          file: "src.ts",
+          id: "legacy-1",
+          lineNumber: 3,
+          side: "right",
+          tag: "",
+        },
+      ]),
+    );
+
+    expect(readComments()).toMatchObject([
+      {
+        anchor: { lineContent: "target" },
+        replies: [],
+        resolved: false,
+        staleness: "fresh",
+      },
+    ]);
+  });
+});
diff --git a/apps/cli/test/comments-resolve.test.ts b/apps/cli/test/comments-resolve.test.ts
new file mode 100644
index 0000000..bfeb768
--- /dev/null
+++ b/apps/cli/test/comments-resolve.test.ts
@@ -0,0 +1,116 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+  addComment,
+  addReply,
+  clearCommentCache,
+  readComments,
+  setCommentResolved,
+} from "../lib/comments";
+
+const tempPaths: string[] = [];
+
+const createTempRepo = (): string => {
+  const repoPath = mkdtempSync(join(tmpdir(), "diffhub-comments-resolve-"));
+  mkdirSync(join(repoPath, ".git"));
+  tempPaths.push(repoPath);
+  return repoPath;
+};
+
+const writeSource = (contents: string): void => {
+  writeFileSync(join(process.env.DIFFHUB_REPO as string, "src.ts"), contents);
+  clearCommentCache();
+};
+
+describe("comment resolution and replies", () => {
+  beforeEach(() => {
+    process.env.DIFFHUB_REPO = createTempRepo();
+    process.env.DIFFHUB_USER = "Reviewer";
+    writeSource(["before", "target", "after"].join("\n"));
+  });
+
+  afterEach(() => {
+    delete process.env.DIFFHUB_AUTO_RESOLVE_STALE;
+    delete process.env.DIFFHUB_REPO;
+    delete process.env.DIFFHUB_USER;
+    clearCommentCache();
+
+    for (const tempPath of tempPaths.splice(0)) {
+      rmSync(tempPath, { force: true, recursive: true });
+    }
+  });
+
+  it("resolve sets audit fields and unresolve clears them", async () => {
+    const comment = await addComment({
+      body: "Needs work",
+      file: "src.ts",
+      lineNumber: 2,
+      side: "right",
+      tag: "",
+    });
+
+    await setCommentResolved(comment.id, true);
+    expect(readComments()).toMatchObject([
+      {
+        resolved: true,
+        resolvedAt: expect.any(String),
+        resolvedBy: "Reviewer",
+      },
+    ]);
+
+    await setCommentResolved(comment.id, false);
+    const [unresolved] = readComments();
+    expect(unresolved).toMatchObject({ resolved: false });
+    expect(unresolved?.resolvedBy).toBeUndefined();
+    expect(unresolved?.resolvedAt).toBeUndefined();
+  });
+
+  it("appends replies without changing resolution state", async () => {
+    const comment = await addComment({
+      body: "Needs work",
+      file: "src.ts",
+      lineNumber: 2,
+      side: "right",
+      tag: "",
+    });
+
+    await addReply(comment.id, "Fixed in the next commit");
+
+    expect(readComments()).toMatchObject([
+      {
+        replies: [
+          {
+            at: expect.any(String),
+            body: "Fixed in the next commit",
+            by: "Reviewer",
+          },
+        ],
+        resolved: false,
+      },
+    ]);
+  });
+
+  it("auto-resolves stale transitions only when explicitly enabled", async () => {
+    const comment = await addComment({
+      body: "Needs work",
+      file: "src.ts",
+      lineNumber: 2,
+      side: "right",
+      tag: "",
+    });
+
+    process.env.DIFFHUB_AUTO_RESOLVE_STALE = "1";
+    writeSource(["replacement", "content"].join("\n"));
+
+    expect(readComments()).toMatchObject([
+      {
+        id: comment.id,
+        resolved: true,
+        resolvedBy: "diffhub:auto-stale",
+        staleness: "stale",
+      },
+    ]);
+  });
+});

From 060f02fad662758c25ed7582be1985c59db20a67 Mon Sep 17 00:00:00 2001
From: Ed Bordin <edward@linktr.ee>
Date: Thu, 14 May 2026 15:28:39 +1000
Subject: [PATCH 2/3] feat: improve diffhub comment navigation

---
 apps/cli/components/DiffApp.test.tsx    |   4 +-
 apps/cli/components/DiffApp.tsx         | 211 +++++++++++++++++++--
 apps/cli/components/DiffViewer.test.tsx |  76 +++++++-
 apps/cli/components/DiffViewer.tsx      | 215 ++++++++++-----------
 apps/cli/components/FileDiffHeader.tsx  |  27 ++-
 apps/cli/components/FileList.tsx        | 239 ++++++++++++++++++++----
 apps/cli/components/StatusBar.tsx       | 131 ++++++++++++-
 apps/cli/lib/diff-colors.test.ts        |   9 +
 apps/cli/lib/diff-colors.ts             |   8 +
 apps/cli/lib/use-scroll-anchor.ts       | 163 +++++++++++-----
 10 files changed, 863 insertions(+), 220 deletions(-)

diff --git a/apps/cli/components/DiffApp.test.tsx b/apps/cli/components/DiffApp.test.tsx
index 1382ef0..e21ca68 100644
--- a/apps/cli/components/DiffApp.test.tsx
+++ b/apps/cli/components/DiffApp.test.tsx
@@ -320,7 +320,7 @@ describe("DiffApp review flow", () => {
       value: { writeText },
     });
     let comments: Comment[] = [
-      {
+      withCommentDefaults({
         body: "Investigate this diff",
         createdAt: "2026-04-15T00:00:00.000Z",
         file: "src/b.ts",
@@ -328,7 +328,7 @@ describe("DiffApp review flow", () => {
         lineNumber: 12,
         side: "right",
         tag: "[must-fix]",
-      },
+      }),
     ];
 
     const fetchMock = vi.fn<typeof fetch>((input, init) => {
diff --git a/apps/cli/components/DiffApp.tsx b/apps/cli/components/DiffApp.tsx
index 2fc7621..c8cde8e 100644
--- a/apps/cli/components/DiffApp.tsx
+++ b/apps/cli/components/DiffApp.tsx
@@ -1,11 +1,19 @@
 "use client";
 
 import type { AnnotationSide } from "@pierre/diffs";
-import { useCallback, useEffect, useRef, useState, useTransition, startTransition } from "react";
+import {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+  useTransition,
+  startTransition,
+} from "react";
 import { StatusBar } from "./StatusBar";
 import type { DiffMode, WatchStatus } from "./StatusBar";
 import { FileList } from "./FileList";
-import { DiffViewer, getDiffSectionId } from "./DiffViewer";
+import { DiffViewer, getCommentElementId, getDiffSectionId } from "./DiffViewer";
 import { useTheme } from "./theme-provider";
 import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
 import { Button } from "@/components/ui/button";
@@ -80,6 +88,7 @@ interface MainPanelProps {
   syncNotice: SyncNotice | null;
   layout: "split" | "stacked";
   comments: Comment[];
+  activeCommentId: string | null;
   onAddComment: (
     file: string,
     lineNumber: number,
@@ -90,6 +99,7 @@ interface MainPanelProps {
   onDeleteComment: (id: string) => Promise<boolean>;
   onResolveComment: (id: string, resolved: boolean) => Promise<boolean>;
   onReplyToComment: (id: string, body: string) => Promise<boolean>;
+  onNavigateComment: (id: string) => void;
   selectedFile: string | null;
   collapsedFiles: Set<string>;
   forceRenderFiles: ReadonlySet<string>;
@@ -238,6 +248,16 @@ const getFileSectionSelector = (file: string): string => {
   return `#${escaped}`;
 };
 
+const getCommentSelector = (id: string): string => {
+  const elementId = getCommentElementId(id);
+  if (typeof window !== "undefined" && window.CSS?.escape) {
+    return `#${window.CSS.escape(elementId)}`;
+  }
+
+  const escaped = elementId.replaceAll(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, "\\$&");
+  return `#${escaped}`;
+};
+
 const SyncBanner = ({ notice }: { notice: SyncNotice }): React.JSX.Element => {
   let toneClass = "border-border bg-muted/40 text-muted-foreground";
   if (notice.tone === "destructive") {
@@ -288,10 +308,12 @@ const MainPanel = ({
   syncNotice,
   layout,
   comments,
+  activeCommentId,
   onAddComment,
   onDeleteComment,
   onResolveComment,
   onReplyToComment,
+  onNavigateComment,
   selectedFile,
   collapsedFiles,
   forceRenderFiles,
@@ -353,10 +375,12 @@ const MainPanel = ({
         prerenderedHTMLByFile={deferredDiffData.prerenderedHTMLByFile}
         layout={layout}
         comments={comments}
+        activeCommentId={activeCommentId}
         onAddComment={onAddComment}
         onDeleteComment={onDeleteComment}
         onResolveComment={onResolveComment}
         onReplyToComment={onReplyToComment}
+        onNavigateComment={onNavigateComment}
         activeFileId={selectedFile}
         fileStats={filesData.files}
         collapsedFiles={collapsedFiles}
@@ -385,10 +409,16 @@ export const DiffApp = ({
   const filesDataRef = useRef<FilesData | null>(null);
   const [comments, setComments] = useState<Comment[]>([]);
   const commentsRef = useRef<Comment[]>([]);
+  const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
   const [selectedFile, setSelectedFile] = useState<string | null>(null);
   const selectedFileRef = useRef<string | null>(null);
   const [diffData, setDiffData] = useState<MultiFileDiffData | null>(null);
   const [layout, setLayout] = useLocalStorage("diffhub-layout", "stacked", LAYOUT_OPTIONS);
+  const [resolvedCommentsMode, setResolvedCommentsMode] = useLocalStorage<"show" | "hide">(
+    "diffhub-resolved-comments",
+    "show",
+    ["show", "hide"] as const,
+  );
   const { resolvedTheme } = useTheme();
   const diffVariant: "light" | "dark" = resolvedTheme === "light" ? "light" : "dark";
   const layoutRef = useRef<"split" | "stacked">(layout);
@@ -422,6 +452,34 @@ export const DiffApp = ({
   // Start with empty Set to avoid hydration mismatch, then sync from localStorage
   const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(() => new Set());
   const [forceRenderFiles, setForceRenderFiles] = useState<ReadonlySet<string>>(() => new Set());
+  const showResolvedComments = resolvedCommentsMode === "show";
+
+  const orderedVisibleComments = useMemo(() => {
+    const fileOrder = new Map(
+      (filesData?.files ?? []).map((fileStat, index) => [fileStat.file, index]),
+    );
+    return comments
+      .filter((comment) => showResolvedComments || !comment.resolved)
+      .toSorted((a, b) => {
+        const fileDelta =
+          (fileOrder.get(a.file) ?? Number.MAX_SAFE_INTEGER) -
+          (fileOrder.get(b.file) ?? Number.MAX_SAFE_INTEGER);
+        if (fileDelta !== 0) {
+          return fileDelta;
+        }
+        if (a.file !== b.file) {
+          return a.file.localeCompare(b.file);
+        }
+        if (a.lineNumber !== b.lineNumber) {
+          return a.lineNumber - b.lineNumber;
+        }
+        return a.createdAt.localeCompare(b.createdAt);
+      });
+  }, [comments, filesData, showResolvedComments]);
+
+  const activeCommentIndex = orderedVisibleComments.findIndex(
+    (comment) => comment.id === activeCommentId,
+  );
 
   useEffect(() => {
     diffModeRef.current = diffMode;
@@ -435,6 +493,15 @@ export const DiffApp = ({
     commentsRef.current = comments;
   }, [comments]);
 
+  useEffect(() => {
+    if (
+      activeCommentId !== null &&
+      !orderedVisibleComments.some((comment) => comment.id === activeCommentId)
+    ) {
+      setActiveCommentId(null);
+    }
+  }, [activeCommentId, orderedVisibleComments]);
+
   // Sync collapsed files from localStorage after mount
   useEffect(() => {
     setCollapsedFiles(new Set(readStoredJson<string[]>(`diffhub-collapsed:${repoPath}`, [])));
@@ -474,12 +541,14 @@ export const DiffApp = ({
     diffErrorRef.current = diffError;
   }, [diffError]);
 
-  // Safari-safe scroll anchor: when any file section above the viewport
-  // changes height, compensate window.scrollY so the user's view doesn't
-  // shift. Replaces the previous diff-data-only captureViewportAnchor +
-  // useLayoutEffect pair, which fired too rarely to catch @pierre/diffs
-  // post-mount resize cascades.
-  useScrollAnchor({ selector: "[data-file-section]" });
+  const activeCommentSelector = useMemo(
+    () => (activeCommentId ? getCommentSelector(activeCommentId) : null),
+    [activeCommentId],
+  );
+
+  // Safari-safe scroll anchor: preserve the visible active comment first,
+  // otherwise preserve the file section under the sticky toolbar.
+  useScrollAnchor({ preferredSelector: activeCommentSelector, selector: "[data-file-section]" });
 
   const buildFilesQuery = useCallback((options: { forceRefresh?: boolean } = {}) => {
     const params = new URLSearchParams();
@@ -999,6 +1068,115 @@ export const DiffApp = ({
     [addForceRenderFiles, filesData, lockActiveFile, updateCollapsedFiles],
   );
 
+  const scrollToComment = useCallback(
+    (commentId: string, behavior: ScrollBehavior = "smooth") => {
+      const comment = orderedVisibleComments.find((candidate) => candidate.id === commentId);
+      if (!comment) {
+        return;
+      }
+
+      setActiveCommentId(comment.id);
+      lockActiveFile(comment.file);
+
+      const files = filesData?.files.map((stat) => stat.file) ?? [];
+      const index = files.indexOf(comment.file);
+      addForceRenderFiles([comment.file, files[index - 1], files[index + 1]]);
+
+      updateCollapsedFiles((previous) => {
+        if (previous.has(comment.file)) {
+          const next = new Set(previous);
+          next.delete(comment.file);
+          return next;
+        }
+        return previous;
+      });
+
+      const commentSelector = getCommentSelector(comment.id);
+      const performScroll = (element: HTMLElement): void => {
+        element.scrollIntoView({ behavior, block: "center" });
+      };
+
+      const commentElement = document.querySelector<HTMLElement>(commentSelector);
+      if (commentElement) {
+        performScroll(commentElement);
+        return;
+      }
+
+      scrollToFile(comment.file, behavior);
+
+      const container = document.querySelector("#diff-container");
+      if (!container) {
+        return;
+      }
+
+      const observer = new MutationObserver(() => {
+        const delayedCommentElement = document.querySelector<HTMLElement>(commentSelector);
+        if (delayedCommentElement) {
+          observer.disconnect();
+          performScroll(delayedCommentElement);
+        }
+      });
+
+      observer.observe(container, { childList: true, subtree: true });
+      setTimeout(() => observer.disconnect(), 5000);
+    },
+    [
+      addForceRenderFiles,
+      filesData,
+      lockActiveFile,
+      orderedVisibleComments,
+      scrollToFile,
+      updateCollapsedFiles,
+    ],
+  );
+
+  const scrollToFileComment = useCallback(
+    (file: string) => {
+      const comment = orderedVisibleComments.find((candidate) => candidate.file === file);
+      if (comment) {
+        scrollToComment(comment.id, "auto");
+        return;
+      }
+      scrollToFile(file, "auto");
+    },
+    [orderedVisibleComments, scrollToComment, scrollToFile],
+  );
+
+  const navigateRelativeComment = useCallback(
+    (direction: 1 | -1) => {
+      if (orderedVisibleComments.length === 0) {
+        return;
+      }
+
+      let currentIndex = activeCommentIndex;
+      if (currentIndex === -1) {
+        currentIndex = direction === 1 ? -1 : 0;
+      }
+      const nextIndex =
+        (currentIndex + direction + orderedVisibleComments.length) % orderedVisibleComments.length;
+      const nextComment = orderedVisibleComments[nextIndex];
+      if (nextComment) {
+        scrollToComment(nextComment.id, "auto");
+      }
+    },
+    [activeCommentIndex, orderedVisibleComments, scrollToComment],
+  );
+
+  const handlePreviousComment = useCallback(() => {
+    navigateRelativeComment(-1);
+  }, [navigateRelativeComment]);
+
+  const handleNextComment = useCallback(() => {
+    navigateRelativeComment(1);
+  }, [navigateRelativeComment]);
+
+  const handleShowResolvedCommentsChange = useCallback(
+    (show: boolean) => {
+      setResolvedCommentsMode(show ? "show" : "hide");
+    },
+    [setResolvedCommentsMode],
+  );
+
   const handleActiveFileChange = useCallback((file: string) => {
     const lock = activeFileLockRef.current;
     if (lock) {
@@ -1257,7 +1435,10 @@ export const DiffApp = ({
         files={filesData?.files ?? []}
         selectedFile={selectedFile}
         onSelectFile={scrollToFile}
-        comments={comments}
+        onSelectFileComment={scrollToFileComment}
+        onSelectComment={scrollToComment}
+        comments={orderedVisibleComments}
+        activeCommentId={activeCommentId}
         filterQuery={filterQuery}
         onFilterChange={setFilterQuery}
         isLoading={filesData === null}
@@ -1275,8 +1456,14 @@ export const DiffApp = ({
             refreshing={refreshing || diffRequestPending}
             onRefresh={handleManualRefresh}
             watchStatus={watchStatus}
-            comments={comments}
+            comments={orderedVisibleComments}
             onClearComments={handleClearComments}
+            totalCommentCount={comments.length}
+            activeCommentIndex={activeCommentIndex}
+            showResolvedComments={showResolvedComments}
+            onShowResolvedCommentsChange={handleShowResolvedCommentsChange}
+            onPreviousComment={handlePreviousComment}
+            onNextComment={handleNextComment}
             diffMode={diffMode}
             onDiffModeChange={handleDiffModeChange}
             layout={layout}
@@ -1291,11 +1478,13 @@ export const DiffApp = ({
           diffError={diffError}
           syncNotice={syncNotice}
           layout={layout}
-          comments={comments}
+          comments={orderedVisibleComments}
+          activeCommentId={activeCommentId}
           onAddComment={handleAddComment}
           onDeleteComment={handleDeleteComment}
           onResolveComment={handleResolveComment}
           onReplyToComment={handleReplyToComment}
+          onNavigateComment={scrollToComment}
           selectedFile={selectedFile}
           collapsedFiles={collapsedFiles}
           forceRenderFiles={forceRenderFiles}
diff --git a/apps/cli/components/DiffViewer.test.tsx b/apps/cli/components/DiffViewer.test.tsx
index 1d71eb8..09039d9 100644
--- a/apps/cli/components/DiffViewer.test.tsx
+++ b/apps/cli/components/DiffViewer.test.tsx
@@ -1,11 +1,39 @@
 import React from "react";
 import { cleanup, fireEvent, render, screen } from "@testing-library/react";
 import { afterEach, describe, expect, it, vi } from "vitest";
-import { DiffViewer } from "./DiffViewer";
+import { DiffViewer, getCommentElementId } from "./DiffViewer";
+import type { Comment } from "@/lib/comment-types";
 import type * as ThemeProviderModule from "./theme-provider";
 
 const { MockPatchDiff } = vi.hoisted(() => ({
-  MockPatchDiff: ({ patch }: { patch: string }) => <div data-testid="patch-viewer">{patch}</div>,
+  MockPatchDiff: ({
+    lineAnnotations,
+    patch,
+    renderAnnotation,
+  }: {
+    lineAnnotations?: { lineNumber: number; metadata?: unknown; side: string }[];
+    patch: string;
+    renderAnnotation?: (annotation: {
+      lineNumber: number;
+      metadata?: unknown;
+      side: string;
+    }) => React.ReactNode;
+  }) => (
+    <div data-testid="patch-viewer">
+      {patch}
+      {lineAnnotations?.map((annotation) => {
+        const metadata =
+          typeof annotation.metadata === "object" && annotation.metadata !== null
+            ? JSON.stringify(annotation.metadata)
+            : String(annotation.metadata ?? "");
+        return (
+          <div key={`${annotation.side}:${annotation.lineNumber}:${metadata}`}>
+            {renderAnnotation?.(annotation)}
+          </div>
+        );
+      })}
+    </div>
+  ),
 }));
 
 vi.mock(import("./theme-provider"), async (importOriginal) => {
@@ -42,9 +70,10 @@ vi.mock(import("next/dynamic"), async (importOriginal) => {
 const makeProps = (fileCount: number) => {
   const files = Array.from({ length: fileCount }, (_, index) => `src/file-${index}.ts`);
   return {
+    activeCommentId: null,
     activeFileId: files[0],
     collapsedFiles: new Set<string>(),
-    comments: [],
+    comments: [] as Comment[],
     fileStats: files.map((file) => ({
       binary: false,
       changes: 1,
@@ -67,6 +96,7 @@ const makeProps = (fileCount: number) => {
       >()
       .mockResolvedValue(true),
     onDeleteComment: vi.fn<(id: string) => Promise<boolean>>().mockResolvedValue(true),
+    onNavigateComment: vi.fn<(id: string) => void>(),
     onReplyToComment: vi
       .fn<(id: string, body: string) => Promise<boolean>>()
       .mockResolvedValue(true),
@@ -85,6 +115,26 @@ const makeProps = (fileCount: number) => {
   };
 };
 
+const makeComment = (overrides: Partial<Comment> = {}): Comment => ({
+  anchor: {
+    afterContext: [],
+    beforeContext: [],
+    fileSha: "",
+    lineContent: "",
+  },
+  body: "comment body",
+  createdAt: "2026-05-14T00:00:00.000Z",
+  file: "src/file-0.ts",
+  id: "comment-1",
+  lineNumber: 1,
+  replies: [],
+  resolved: false,
+  side: "right",
+  staleness: "fresh",
+  tag: "",
+  ...overrides,
+});
+
 describe("diff viewer rendering", () => {
   afterEach(() => {
     cleanup();
@@ -176,4 +226,24 @@ describe("diff viewer rendering", () => {
     expect(screen.queryByTestId("deferred-diff-placeholder")).toBeNull();
     expect(screen.getAllByTestId("patch-viewer")).toHaveLength(3);
   });
+
+  it("renders comments in a stable file-level comments block", () => {
+    const props = makeProps(1);
+    props.comments = [
+      makeComment({
+        body: "resolved outside patch",
+        id: "comment-outside",
+        lineNumber: 99,
+        resolved: true,
+        resolvedAt: "2026-05-14T01:00:00.000Z",
+        resolvedBy: "Reviewer",
+      }),
+    ];
+
+    render(<DiffViewer {...props} />);
+
+    expect(screen.getByText("Comments")).toBeTruthy();
+    expect(screen.getByText(/resolved outside patch/)).toBeTruthy();
+    expect(document.querySelector(`#${getCommentElementId("comment-outside")}`)).not.toBeNull();
+  });
 });
diff --git a/apps/cli/components/DiffViewer.tsx b/apps/cli/components/DiffViewer.tsx
index 48c1a80..7bf307c 100644
--- a/apps/cli/components/DiffViewer.tsx
+++ b/apps/cli/components/DiffViewer.tsx
@@ -4,7 +4,6 @@ import dynamic from "next/dynamic";
 import { Component, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
 import type { CSSProperties, ErrorInfo, ReactNode } from "react";
 import type { DiffLineAnnotation, AnnotationSide } from "@pierre/diffs";
-import { toAnnotationSide } from "@/lib/comment-sides";
 import type { Comment, CommentTag } from "@/lib/comment-types";
 import type { DiffFileStat } from "@/lib/diff-file-stat";
 import { isLargeDiffFile } from "@/lib/diff-file-stat";
@@ -91,7 +90,13 @@ const formatRelativeTime = (iso: string): string => {
   return `${diffDay}d ago`;
 };
 
-type AnnotationData = { type: "comment"; commentId: string } | { type: "input"; file: string };
+interface AnnotationData {
+  type: "input";
+  file: string;
+}
+
+export const getDiffSectionId = (file: string): string => `diff-${encodeURIComponent(file)}`;
+export const getCommentElementId = (id: string): string => `diff-comment-${encodeURIComponent(id)}`;
 
 const DiffSkeleton = () => (
   <div className="animate-pulse">
@@ -281,11 +286,13 @@ const InlineCommentInput = ({ onSubmit, onCancel }: InlineCommentInputProps) =>
 // oxlint-disable-next-line complexity
 const CommentDisplay = ({
   comment,
+  active,
   onDelete,
   onResolve,
   onReply,
 }: {
   comment: Comment;
+  active: boolean;
   onDelete: () => Promise<boolean>;
   onResolve: (resolved: boolean) => Promise<boolean>;
   onReply: (body: string) => Promise<boolean>;
@@ -407,9 +414,12 @@ const CommentDisplay = ({
 
   return (
     <div
+      id={getCommentElementId(comment.id)}
+      data-comment-id={comment.id}
       className={cn(
-        "group my-1 mx-4 rounded-md border border-border bg-card shadow-sm dark:shadow-none overflow-hidden border-l-2",
+        "group my-1 mx-4 scroll-mt-24 rounded-md border border-border bg-card shadow-sm dark:shadow-none overflow-hidden border-l-2",
         borderAccent,
+        active && "ring-1 ring-diff-purple/70",
       )}
     >
       {comment.staleness === "stale" && (
@@ -571,8 +581,6 @@ const CommentDisplay = ({
   );
 };
 
-export const getDiffSectionId = (file: string): string => `diff-${encodeURIComponent(file)}`;
-
 /**
  * Sort files in tree display order to match the sidebar:
  * at each directory level, subdirectories (and their contents)
@@ -657,6 +665,8 @@ interface SingleFileDiffProps {
   onDeleteComment: (id: string) => Promise<boolean>;
   onResolveComment: (id: string, resolved: boolean) => Promise<boolean>;
   onReplyToComment: (id: string, body: string) => Promise<boolean>;
+  activeCommentId: string | null;
+  onNavigateComment: (id: string) => void;
 }
 
 interface GutterButtonProps {
@@ -708,26 +718,17 @@ const SingleFileDiff = memo(function SingleFileDiff({
   onDeleteComment,
   onResolveComment,
   onReplyToComment,
+  activeCommentId,
+  onNavigateComment,
 }: SingleFileDiffProps) {
   const { resolvedTheme } = useTheme();
   // `comments` is already scoped to this file by DiffViewer's commentsByFile split.
   const fileComments = comments;
-  const commentsById = useMemo(() => {
-    const map = new Map<string, Comment>();
-    for (const comment of fileComments) {
-      map.set(comment.id, comment);
-    }
-    return map;
-  }, [fileComments]);
   const headerId = `${sectionId}-header`;
   const panelId = `${sectionId}-panel`;
 
   const lineAnnotations = useMemo((): DiffLineAnnotation<AnnotationData>[] => {
-    const annotations: DiffLineAnnotation<AnnotationData>[] = fileComments.map((c) => ({
-      lineNumber: c.lineNumber,
-      metadata: { commentId: c.id, type: "comment" as const },
-      side: toAnnotationSide(c.side),
-    }));
+    const annotations: DiffLineAnnotation<AnnotationData>[] = [];
 
     if (commentTarget) {
       annotations.push({
@@ -738,7 +739,7 @@ const SingleFileDiff = memo(function SingleFileDiff({
     }
 
     return annotations;
-  }, [fileComments, commentTarget, file]);
+  }, [commentTarget, file]);
 
   const renderAnnotation = useCallback(
     (annotation: DiffLineAnnotation<AnnotationData>) => {
@@ -770,16 +771,24 @@ const SingleFileDiff = memo(function SingleFileDiff({
         );
       }
 
-      if (d.type === "comment") {
-        const comment = commentsById.get(d.commentId);
-        if (!comment) {
-          return null;
-        }
+      return null;
+    },
+    [file, onAddComment, onCommentTargetChange],
+  );
 
-        return (
+  const renderFileComments = (): React.JSX.Element | null => {
+    if (fileComments.length === 0) {
+      return null;
+    }
+
+    return (
+      <div className="border-t border-border/60 bg-muted/15 py-2">
+        <div className="px-4 pb-1 text-[11px] font-medium text-muted-foreground">Comments</div>
+        {fileComments.map((comment) => (
           <CommentDisplay
             key={comment.id}
             comment={comment}
+            active={comment.id === activeCommentId}
             // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
             onDelete={() => onDeleteComment(comment.id)}
             // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
@@ -787,21 +796,10 @@ const SingleFileDiff = memo(function SingleFileDiff({
             // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
             onReply={(body) => onReplyToComment(comment.id, body)}
           />
-        );
-      }
-
-      return null;
-    },
-    [
-      commentsById,
-      file,
-      onAddComment,
-      onCommentTargetChange,
-      onDeleteComment,
-      onReplyToComment,
-      onResolveComment,
-    ],
-  );
+        ))}
+      </div>
+    );
+  };
 
   const renderGutterUtility = useCallback(
     (getHoveredLine: () => { lineNumber: number; side: AnnotationSide } | undefined) => (
@@ -814,53 +812,69 @@ const SingleFileDiff = memo(function SingleFileDiff({
     onRenderPatch();
   }, [onRenderPatch]);
 
+  const handleJumpToFirstComment = useCallback(() => {
+    const [firstComment] = fileComments;
+    if (firstComment) {
+      onNavigateComment(firstComment.id);
+    }
+  }, [fileComments, onNavigateComment]);
+
   const hidePanel = collapsed && commentTarget === null;
   let panelContent: React.ReactNode = null;
 
   if (!hidePanel) {
     if (!filePatch) {
       panelContent = (
-        <div className="px-4 py-6 text-sm text-muted-foreground">
-          No textual patch available for this file.
-        </div>
+        <>
+          <div className="px-4 py-6 text-sm text-muted-foreground">
+            No textual patch available for this file.
+          </div>
+          {renderFileComments()}
+        </>
       );
     } else if (shouldRenderPatch) {
       panelContent = (
         <DiffErrorBoundary file={file}>
-          <PatchDiff
-            key={file}
-            patch={filePatch}
-            prerenderedHTML={prerenderedHTML?.[resolvedTheme === "light" ? "light" : "dark"]}
-            disableWorkerPool
-            style={{ colorScheme: resolvedTheme === "light" ? "light" : "dark" }}
-            options={{
-              diffStyle: layout === "split" ? "split" : "unified",
-              disableFileHeader: true,
-              disableLineNumbers: false,
-              enableGutterUtility: true,
-              expansionLineCount: 20,
-              hunkSeparators: "line-info",
-              lineDiffType: "word-alt",
-              lineHoverHighlight: "disabled",
-              maxLineDiffLength: 500,
-              overflow: "wrap",
-              theme: { dark: "github-dark", light: "github-light" },
-              themeType: resolvedTheme === "light" ? "light" : "dark",
-              unsafeCSS: getDiffUnsafeCSS((resolvedTheme ?? "dark") as DiffTheme),
-            }}
-            lineAnnotations={lineAnnotations}
-            renderAnnotation={renderAnnotation}
-            renderGutterUtility={renderGutterUtility}
-          />
+          <>
+            <PatchDiff
+              key={file}
+              patch={filePatch}
+              prerenderedHTML={prerenderedHTML?.[resolvedTheme === "light" ? "light" : "dark"]}
+              disableWorkerPool
+              style={{ colorScheme: resolvedTheme === "light" ? "light" : "dark" }}
+              options={{
+                diffStyle: layout === "split" ? "split" : "unified",
+                disableFileHeader: true,
+                disableLineNumbers: false,
+                enableGutterUtility: true,
+                expansionLineCount: 20,
+                hunkSeparators: "line-info",
+                lineDiffType: "word-alt",
+                lineHoverHighlight: "disabled",
+                maxLineDiffLength: 500,
+                overflow: "wrap",
+                theme: { dark: "github-dark", light: "github-light" },
+                themeType: resolvedTheme === "light" ? "light" : "dark",
+                unsafeCSS: getDiffUnsafeCSS((resolvedTheme ?? "dark") as DiffTheme),
+              }}
+              lineAnnotations={lineAnnotations}
+              renderAnnotation={renderAnnotation}
+              renderGutterUtility={renderGutterUtility}
+            />
+            {renderFileComments()}
+          </>
         </DiffErrorBoundary>
       );
     } else {
       panelContent = (
-        <DeferredDiffPlaceholder
-          onRender={handleRenderPatch}
-          variant={isLargeFile ? "large" : "auto"}
-          changes={fileStat?.changes}
-        />
+        <>
+          <DeferredDiffPlaceholder
+            onRender={handleRenderPatch}
+            variant={isLargeFile ? "large" : "auto"}
+            changes={fileStat?.changes}
+          />
+          {renderFileComments()}
+        </>
       );
     }
   }
@@ -876,6 +890,7 @@ const SingleFileDiff = memo(function SingleFileDiff({
         collapsed={collapsed}
         active={active}
         onToggleCollapse={onToggleCollapse}
+        onJumpToFirstComment={handleJumpToFirstComment}
         headingId={headerId}
         panelId={panelId}
       />
@@ -902,6 +917,8 @@ interface DiffViewerProps {
   onResolveComment: (id: string, resolved: boolean) => Promise<boolean>;
   onReplyToComment: (id: string, body: string) => Promise<boolean>;
   activeFileId: string | null;
+  activeCommentId: string | null;
+  onNavigateComment: (id: string) => void;
   fileStats: DiffFileStat[];
   collapsedFiles: Set<string>;
   onToggleCollapse: (file: string) => void;
@@ -935,6 +952,8 @@ interface CollapsibleFileDiffProps {
   onDeleteComment: (id: string) => Promise<boolean>;
   onResolveComment: (id: string, resolved: boolean) => Promise<boolean>;
   onReplyToComment: (id: string, body: string) => Promise<boolean>;
+  activeCommentId: string | null;
+  onNavigateComment: (id: string) => void;
 }
 
 const CollapsibleFileDiff = memo(function CollapsibleFileDiff({
@@ -956,6 +975,8 @@ const CollapsibleFileDiff = memo(function CollapsibleFileDiff({
   onDeleteComment,
   onResolveComment,
   onReplyToComment,
+  activeCommentId,
+  onNavigateComment,
 }: CollapsibleFileDiffProps) {
   const sectionRef = useRef<HTMLElement>(null);
   const [commentTarget, setCommentTarget] = useState<CommentTarget | null>(null);
@@ -1017,52 +1038,14 @@ const CollapsibleFileDiff = memo(function CollapsibleFileDiff({
     };
   }, [collapsed, fileStat, shouldRenderPatch]);
 
-  // Pin the section's height once @pierre/diffs has finished its post-mount
-  // resize cascades (Shiki tokenize, ResizeManager beats, font swap). After
-  // ~200ms of resize-idle, write the measured height to `min-height` so any
-  // later library-driven resize is absorbed inside the section instead of
-  // moving siblings. Reset the pin when collapse / comment / layout state
-  // changes, since those are user-initiated and legitimately want growth.
   useEffect(() => {
     const section = sectionRef.current;
-    if (!section || typeof ResizeObserver === "undefined") {
+    if (!section || !shouldRenderPatch) {
       return;
     }
-    if (collapsed || commentTarget !== null || !shouldRenderPatch) {
-      return;
-    }
-
-    let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-    let lastHeight = section.getBoundingClientRect().height;
-
-    const observer = new ResizeObserver(([entry]) => {
-      if (!entry) {
-        return;
-      }
-      const next = entry.contentRect.height;
-      if (next === lastHeight) {
-        return;
-      }
-      lastHeight = next;
-      if (debounceTimer) {
-        clearTimeout(debounceTimer);
-      }
-      debounceTimer = setTimeout(() => {
-        debounceTimer = null;
-        section.style.minHeight = `${section.getBoundingClientRect().height}px`;
-        observer.disconnect();
-      }, 200);
-    });
 
-    observer.observe(section);
-    return () => {
-      if (debounceTimer) {
-        clearTimeout(debounceTimer);
-      }
-      observer.disconnect();
-      section.style.minHeight = "";
-    };
-  }, [collapsed, commentTarget, layout, shouldRenderPatch]);
+    section.style.minHeight = "";
+  }, [collapsed, commentTarget, comments, layout, shouldRenderPatch]);
 
   return (
     <section
@@ -1093,6 +1076,8 @@ const CollapsibleFileDiff = memo(function CollapsibleFileDiff({
         onDeleteComment={onDeleteComment}
         onResolveComment={onResolveComment}
         onReplyToComment={onReplyToComment}
+        activeCommentId={activeCommentId}
+        onNavigateComment={onNavigateComment}
       />
     </section>
   );
@@ -1108,6 +1093,8 @@ export const DiffViewer = ({
   onResolveComment,
   onReplyToComment,
   activeFileId,
+  activeCommentId,
+  onNavigateComment,
   fileStats,
   collapsedFiles,
   onToggleCollapse,
@@ -1192,6 +1179,8 @@ export const DiffViewer = ({
             fileStat={fileStat}
             collapsed={collapsedFiles.has(file)}
             active={activeFileId === file}
+            activeCommentId={activeCommentId}
+            onNavigateComment={onNavigateComment}
             // oxlint-disable-next-line typescript-eslint/no-non-null-assertion -- file always exists in toggleHandlers since both use orderedFiles
             onToggleCollapse={toggleHandlers.get(file)!}
             onVisible={onActiveFileChange}
diff --git a/apps/cli/components/FileDiffHeader.tsx b/apps/cli/components/FileDiffHeader.tsx
index 8683ece..5a899f8 100644
--- a/apps/cli/components/FileDiffHeader.tsx
+++ b/apps/cli/components/FileDiffHeader.tsx
@@ -15,6 +15,7 @@ interface FileDiffHeaderProps {
   collapsed?: boolean;
   active?: boolean;
   onToggleCollapse?: () => void;
+  onJumpToFirstComment?: () => void;
   headingId?: string;
   panelId?: string;
 }
@@ -28,6 +29,7 @@ export const FileDiffHeader = ({
   collapsed = false,
   active = false,
   onToggleCollapse,
+  onJumpToFirstComment,
   headingId,
   panelId,
 }: FileDiffHeaderProps) => {
@@ -50,6 +52,11 @@ export const FileDiffHeader = ({
     );
   };
 
+  // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
+  const handleJumpToFirstComment = () => {
+    onJumpToFirstComment?.();
+  };
+
   return (
     <TooltipProvider delay={400}>
       <div
@@ -113,10 +120,22 @@ export const FileDiffHeader = ({
 
         {/* Comment badge */}
         {commentCount > 0 && (
-          <div className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-diff-purple/10 text-diff-purple text-[10px] shrink-0">
-            <BubbleDotsIcon size={10} />
-            {commentCount}
-          </div>
+          <Tooltip>
+            <TooltipTrigger
+              render={
+                <button
+                  type="button"
+                  onClick={handleJumpToFirstComment}
+                  className="flex shrink-0 items-center gap-1 rounded-full bg-diff-purple/10 px-1.5 py-0.5 text-[10px] text-diff-purple transition-colors hover:bg-diff-purple/20 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-diff-purple/50"
+                  aria-label={`Jump to first comment in ${file}`}
+                />
+              }
+            >
+              <BubbleDotsIcon size={10} />
+              {commentCount}
+            </TooltipTrigger>
+            <TooltipContent side="bottom">Jump to first comment</TooltipContent>
+          </Tooltip>
         )}
       </div>
     </TooltipProvider>
diff --git a/apps/cli/components/FileList.tsx b/apps/cli/components/FileList.tsx
index 64a14fe..04fe205 100644
--- a/apps/cli/components/FileList.tsx
+++ b/apps/cli/components/FileList.tsx
@@ -3,9 +3,11 @@
 import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
 import {
   BubbleDotsIcon,
+  CheckIcon,
   Folder1Icon,
   FolderOpenFilledIcon,
   MagnifyingGlassIcon,
+  ReadingListIcon,
 } from "blode-icons-react";
 import { FileAddedIcon, FileDiffIcon, FileRemovedIcon } from "./icons/file-status-icons";
 import type { DiffFileStat } from "@/lib/diff-file-stat";
@@ -22,7 +24,10 @@ interface FileListProps {
   files: DiffFileStat[];
   selectedFile: string | null;
   onSelectFile: (file: string, behavior?: ScrollBehavior) => void;
+  onSelectFileComment: (file: string) => void;
+  onSelectComment: (id: string) => void;
   comments: Comment[];
+  activeCommentId: string | null;
   filterQuery: string;
   onFilterChange: (q: string) => void;
   isLoading?: boolean;
@@ -143,6 +148,7 @@ interface FileRowProps {
   isSelected: boolean;
   commentCount: number;
   onNavigate: (path: string) => void;
+  onNavigateComment: (path: string) => void;
 }
 
 const FileRow = memo(function FileRow({
@@ -151,6 +157,7 @@ const FileRow = memo(function FileRow({
   isSelected,
   commentCount,
   onNavigate,
+  onNavigateComment,
 }: FileRowProps) {
   const indent = depth * 16 + 8;
   const { insertions, deletions } = node.fileStat;
@@ -170,9 +177,7 @@ const FileRow = memo(function FileRow({
   }
 
   return (
-    <button
-      type="button"
-      aria-current={isSelected ? "true" : undefined}
+    <div
       className={cn(
         "flex w-full cursor-pointer items-center gap-1.5 py-1 text-left transition-colors",
         isSelected
@@ -187,27 +192,46 @@ const FileRow = memo(function FileRow({
         paddingLeft: indent,
         paddingRight: 8,
       }}
-      // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
-      onClick={() => onNavigate(node.path)}
     >
-      <FileStatusIcon size={16} className={iconClass} />
-      <Tooltip>
-        <TooltipTrigger
-          render={
-            <span className="flex-1 truncate text-[12px] leading-tight text-muted-foreground" />
-          }
-        >
-          {node.name}
-        </TooltipTrigger>
-        <TooltipContent side="right">{node.path}</TooltipContent>
-      </Tooltip>
+      <button
+        type="button"
+        aria-current={isSelected ? "true" : undefined}
+        className="flex min-w-0 flex-1 items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
+        // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
+        onClick={() => onNavigate(node.path)}
+      >
+        <FileStatusIcon size={16} className={iconClass} />
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <span className="flex-1 truncate text-[12px] leading-tight text-muted-foreground" />
+            }
+          >
+            {node.name}
+          </TooltipTrigger>
+          <TooltipContent side="right">{node.path}</TooltipContent>
+        </Tooltip>
+      </button>
       {commentCount > 0 && (
-        <span className="flex shrink-0 items-center gap-0.5 text-[10px] text-sidebar-foreground/50">
-          <BubbleDotsIcon size={10} />
-          {commentCount}
-        </span>
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <button
+                type="button"
+                className="flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[10px] text-diff-purple transition-colors hover:bg-diff-purple/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
+                aria-label={`Jump to first comment in ${node.path}`}
+                // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
+                onClick={() => onNavigateComment(node.path)}
+              />
+            }
+          >
+            <BubbleDotsIcon size={10} />
+            {commentCount}
+          </TooltipTrigger>
+          <TooltipContent side="right">Jump to first comment</TooltipContent>
+        </Tooltip>
       )}
-    </button>
+    </div>
   );
 });
 
@@ -227,6 +251,7 @@ interface FileTreeProps {
   collapsedFolders: Set<string>;
   onToggleFolder: (path: string) => void;
   onNavigate: (path: string) => void;
+  onNavigateComment: (path: string) => void;
 }
 
 const FolderRow = memo(function FolderRow({
@@ -293,6 +318,7 @@ const FileTree = memo(function FileTree({
   collapsedFolders,
   onToggleFolder,
   onNavigate,
+  onNavigateComment,
 }: FileTreeProps) {
   return nodes.map((node) => {
     if (node.type === "folder") {
@@ -312,6 +338,7 @@ const FileTree = memo(function FileTree({
             collapsedFolders={collapsedFolders}
             onToggleFolder={onToggleFolder}
             onNavigate={onNavigate}
+            onNavigateComment={onNavigateComment}
           />
         </FolderRow>
       );
@@ -325,11 +352,61 @@ const FileTree = memo(function FileTree({
         isSelected={selectedFile === node.path}
         commentCount={commentsByFile.get(node.path) ?? 0}
         onNavigate={onNavigate}
+        onNavigateComment={onNavigateComment}
       />
     );
   });
 });
 
+interface CommentListProps {
+  comments: Comment[];
+  activeCommentId: string | null;
+  onNavigateComment: (id: string) => void;
+}
+
+const getCommentPreview = (comment: Comment): string =>
+  comment.body.split(/\s+/).join(" ").trim() || "(empty comment)";
+
+const CommentList = memo(function CommentList({
+  comments,
+  activeCommentId,
+  onNavigateComment,
+}: CommentListProps) {
+  if (comments.length === 0) {
+    return (
+      <div className="flex flex-col items-center gap-2 px-4 py-8 text-center">
+        <p className="text-xs text-sidebar-foreground/50">No comments</p>
+      </div>
+    );
+  }
+
+  return comments.map((comment) => (
+    <button
+      type="button"
+      key={comment.id}
+      aria-current={activeCommentId === comment.id ? "true" : undefined}
+      className={cn(
+        "flex w-full flex-col gap-1 border-b border-sidebar-border/50 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring",
+        activeCommentId === comment.id
+          ? "bg-sidebar-accent text-sidebar-accent-foreground"
+          : "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+      )}
+      // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
+      onClick={() => onNavigateComment(comment.id)}
+    >
+      <span className="flex min-w-0 items-center gap-1.5 text-[11px] text-sidebar-foreground/50">
+        <BubbleDotsIcon size={10} className="shrink-0 text-diff-purple" />
+        <span className="min-w-0 flex-1 truncate font-mono">{comment.file}</span>
+        <span className="shrink-0 font-mono">L{comment.lineNumber}</span>
+        {comment.resolved && <CheckIcon size={10} className="shrink-0 text-diff-green" />}
+      </span>
+      <span className="line-clamp-2 text-[12px] leading-snug text-sidebar-foreground/80">
+        {getCommentPreview(comment)}
+      </span>
+    </button>
+  ));
+});
+
 // ── Main component ──────────────────────────────────────────────────────────
 
 const MIN_SIDEBAR_WIDTH = 8;
@@ -338,7 +415,10 @@ export const FileList = ({
   files,
   selectedFile,
   onSelectFile,
+  onSelectFileComment,
+  onSelectComment,
   comments,
+  activeCommentId,
   filterQuery,
   onFilterChange,
   isLoading = false,
@@ -348,6 +428,7 @@ export const FileList = ({
   onSidebarWidthChange,
 }: FileListProps) => {
   const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
+  const [viewMode, setViewMode] = useState<"files" | "comments">("files");
 
   const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
 
@@ -402,6 +483,17 @@ export const FileList = ({
         : files,
     [files, filterQuery],
   );
+  const filteredComments = useMemo(() => {
+    const query = filterQuery.trim().toLowerCase();
+    if (!query) {
+      return comments;
+    }
+
+    return comments.filter((comment) => {
+      const haystack = `${comment.file} ${comment.body} ${comment.tag}`.toLowerCase();
+      return haystack.includes(query);
+    });
+  }, [comments, filterQuery]);
 
   const tree = useMemo(() => compactTree(buildTree(filtered)), [filtered]);
   const folderPaths = useMemo(() => collectFolderPaths(tree), [tree]);
@@ -460,6 +552,21 @@ export const FileList = ({
     [onSelectFile],
   );
 
+  const handleNavigateComment = useCallback(
+    (file: string) => {
+      onSelectFileComment(file);
+    },
+    [onSelectFileComment],
+  );
+
+  const showFiles = useCallback(() => {
+    setViewMode("files");
+  }, []);
+
+  const showComments = useCallback(() => {
+    setViewMode("comments");
+  }, []);
+
   let treeContent: React.ReactNode = null;
   if (isLoading) {
     treeContent = (
@@ -467,7 +574,7 @@ export const FileList = ({
         <p className="animate-pulse text-xs text-sidebar-foreground/50">Loading…</p>
       </div>
     );
-  } else if (filtered.length === 0) {
+  } else if (viewMode === "files" && filtered.length === 0) {
     treeContent = (
       <div className="flex flex-col items-center gap-2 px-4 py-8 text-center">
         <p className="text-xs text-sidebar-foreground/50">No changes</p>
@@ -479,24 +586,72 @@ export const FileList = ({
       </div>
     );
   } else {
-    treeContent = (
-      <FileTree
-        nodes={tree}
-        depth={0}
-        selectedFile={selectedFile}
-        commentsByFile={commentsByFile}
-        collapsedFolders={collapsedFolders}
-        onToggleFolder={toggleFolder}
-        onNavigate={handleNavigate}
-      />
-    );
+    treeContent =
+      viewMode === "comments" ? (
+        <CommentList
+          comments={filteredComments}
+          activeCommentId={activeCommentId}
+          onNavigateComment={onSelectComment}
+        />
+      ) : (
+        <FileTree
+          nodes={tree}
+          depth={0}
+          selectedFile={selectedFile}
+          commentsByFile={commentsByFile}
+          collapsedFolders={collapsedFolders}
+          onToggleFolder={toggleFolder}
+          onNavigate={handleNavigate}
+          onNavigateComment={handleNavigateComment}
+        />
+      );
   }
 
   return (
     <TooltipProvider delay={400}>
       <Sidebar collapsible="offcanvas" className="overflow-hidden border-r border-sidebar-border">
         {/* Filter */}
-        <SidebarHeader className="border-b border-sidebar-border h-[52px] flex-row items-center py-0 px-2">
+        <SidebarHeader className="border-b border-sidebar-border h-[52px] flex-row items-center gap-1 py-0 px-2">
+          <div className="flex shrink-0 rounded-md border border-sidebar-border bg-sidebar-accent p-0.5">
+            <Tooltip>
+              <TooltipTrigger
+                render={
+                  <button
+                    type="button"
+                    aria-label="Show files"
+                    aria-pressed={viewMode === "files"}
+                    onClick={showFiles}
+                    className={cn(
+                      "rounded px-1.5 py-1 text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring",
+                      viewMode === "files" && "bg-sidebar text-sidebar-foreground",
+                    )}
+                  />
+                }
+              >
+                <FileDiffIcon size={12} />
+              </TooltipTrigger>
+              <TooltipContent side="bottom">Files</TooltipContent>
+            </Tooltip>
+            <Tooltip>
+              <TooltipTrigger
+                render={
+                  <button
+                    type="button"
+                    aria-label="Show comments"
+                    aria-pressed={viewMode === "comments"}
+                    onClick={showComments}
+                    className={cn(
+                      "rounded px-1.5 py-1 text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring",
+                      viewMode === "comments" && "bg-sidebar text-sidebar-foreground",
+                    )}
+                  />
+                }
+              >
+                <ReadingListIcon size={12} />
+              </TooltipTrigger>
+              <TooltipContent side="bottom">Comments</TooltipContent>
+            </Tooltip>
+          </div>
           <div className="relative flex w-full items-center">
             <MagnifyingGlassIcon
               size={12}
@@ -507,8 +662,8 @@ export const FileList = ({
               value={filterQuery}
               // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
               onChange={(e) => onFilterChange(e.target.value)}
-              placeholder="Filter files…"
-              aria-label="Filter files"
+              placeholder={viewMode === "comments" ? "Filter comments…" : "Filter files…"}
+              aria-label={viewMode === "comments" ? "Filter comments" : "Filter files"}
               className="w-full rounded-md border border-sidebar-border bg-sidebar-accent py-1.5 pl-7 pr-7 text-xs text-sidebar-foreground placeholder:text-sidebar-foreground/40 transition-colors focus:border-sidebar-ring/50 focus:outline-none"
             />
             {filterQuery && (
@@ -532,10 +687,16 @@ export const FileList = ({
         <SidebarFooter className="border-t border-sidebar-border px-3 py-2">
           <div className="flex items-center gap-2 text-[12px] font-mono text-sidebar-foreground/60">
             <span>
-              {files.length} {files.length === 1 ? "file" : "files"}
+              {viewMode === "comments"
+                ? `${comments.length} ${comments.length === 1 ? "comment" : "comments"}`
+                : `${files.length} ${files.length === 1 ? "file" : "files"}`}
             </span>
-            {insertions > 0 && <span className="text-diff-green">+{insertions}</span>}
-            {deletions > 0 && <span className="text-destructive">−{deletions}</span>}
+            {viewMode === "files" && insertions > 0 && (
+              <span className="text-diff-green">+{insertions}</span>
+            )}
+            {viewMode === "files" && deletions > 0 && (
+              <span className="text-destructive">−{deletions}</span>
+            )}
           </div>
         </SidebarFooter>
 
diff --git a/apps/cli/components/StatusBar.tsx b/apps/cli/components/StatusBar.tsx
index fd6067b..4182bf2 100644
--- a/apps/cli/components/StatusBar.tsx
+++ b/apps/cli/components/StatusBar.tsx
@@ -7,10 +7,14 @@ import {
   SunIcon,
   MoonIcon,
   SplitIcon,
+  ArrowDownIcon,
   ArrowRightIcon,
   ArrowRotateClockwiseIcon,
+  ArrowUpIcon,
+  EyeOpenIcon,
+  EyeSlashIcon,
 } from "blode-icons-react";
-import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
 import type { Comment } from "@/lib/comment-types";
 import { exportCommentsAsPrompt } from "@/lib/export-comments";
 import { Button } from "@/components/ui/button";
@@ -49,6 +53,12 @@ interface StatusBarProps {
   } | null;
   comments: Comment[];
   onClearComments: () => Promise<boolean>;
+  totalCommentCount: number;
+  activeCommentIndex: number;
+  showResolvedComments: boolean;
+  onShowResolvedCommentsChange: (show: boolean) => void;
+  onPreviousComment: () => void;
+  onNextComment: () => void;
   diffMode: DiffMode;
   onDiffModeChange: (mode: DiffMode) => void;
   layout: "split" | "stacked";
@@ -161,6 +171,105 @@ const useDismissableMenu = (
   }, [menuRef, onClose, open]);
 };
 
+const CommentNavigationControls = ({
+  activeCommentIndex,
+  commentCount,
+  onNextComment,
+  onPreviousComment,
+}: {
+  activeCommentIndex: number;
+  commentCount: number;
+  onNextComment: () => void;
+  onPreviousComment: () => void;
+}) => {
+  if (commentCount === 0) {
+    return null;
+  }
+
+  const label =
+    activeCommentIndex >= 0 ? `${activeCommentIndex + 1}/${commentCount}` : String(commentCount);
+
+  return (
+    <div className="flex items-center gap-0.5 rounded-md border border-border bg-background px-1 py-0.5">
+      <Tooltip>
+        <TooltipTrigger
+          render={
+            <Button
+              variant="ghost"
+              size="icon-xs"
+              onClick={onPreviousComment}
+              aria-label="Previous comment"
+              className="text-muted-foreground hover:text-foreground hover:bg-secondary"
+            />
+          }
+        >
+          <ArrowUpIcon size={12} />
+        </TooltipTrigger>
+        <TooltipContent side="bottom">Previous comment</TooltipContent>
+      </Tooltip>
+      <span className="min-w-10 text-center font-mono text-[11px] text-muted-foreground">
+        {label}
+      </span>
+      <Tooltip>
+        <TooltipTrigger
+          render={
+            <Button
+              variant="ghost"
+              size="icon-xs"
+              onClick={onNextComment}
+              aria-label="Next comment"
+              className="text-muted-foreground hover:text-foreground hover:bg-secondary"
+            />
+          }
+        >
+          <ArrowDownIcon size={12} />
+        </TooltipTrigger>
+        <TooltipContent side="bottom">Next comment</TooltipContent>
+      </Tooltip>
+    </div>
+  );
+};
+
+const ResolvedCommentsToggle = ({
+  showResolvedComments,
+  totalCommentCount,
+  onShowResolvedCommentsChange,
+}: {
+  showResolvedComments: boolean;
+  totalCommentCount: number;
+  onShowResolvedCommentsChange: (show: boolean) => void;
+}) => {
+  const handleToggle = useCallback(() => {
+    onShowResolvedCommentsChange(!showResolvedComments);
+  }, [onShowResolvedCommentsChange, showResolvedComments]);
+
+  if (totalCommentCount === 0) {
+    return null;
+  }
+
+  const label = showResolvedComments ? "Hide resolved comments" : "Show resolved comments";
+
+  return (
+    <Tooltip>
+      <TooltipTrigger
+        render={
+          <Button
+            variant="ghost"
+            size="icon-xs"
+            onClick={handleToggle}
+            aria-label={label}
+            aria-pressed={!showResolvedComments}
+            className="text-muted-foreground hover:text-foreground hover:bg-secondary"
+          />
+        }
+      >
+        {showResolvedComments ? <EyeOpenIcon size={14} /> : <EyeSlashIcon size={14} />}
+      </TooltipTrigger>
+      <TooltipContent side="bottom">{label}</TooltipContent>
+    </Tooltip>
+  );
+};
+
 export const StatusBar = ({
   branch,
   baseBranch,
@@ -170,6 +279,12 @@ export const StatusBar = ({
   syncNotice,
   comments,
   onClearComments,
+  totalCommentCount,
+  activeCommentIndex,
+  showResolvedComments,
+  onShowResolvedCommentsChange,
+  onPreviousComment,
+  onNextComment,
   diffMode,
   onDiffModeChange,
   layout,
@@ -369,7 +484,19 @@ export const StatusBar = ({
             )}
           </div>
 
-          {/* Comments export */}
+          <CommentNavigationControls
+            activeCommentIndex={activeCommentIndex}
+            commentCount={comments.length}
+            onNextComment={onNextComment}
+            onPreviousComment={onPreviousComment}
+          />
+
+          <ResolvedCommentsToggle
+            showResolvedComments={showResolvedComments}
+            totalCommentCount={totalCommentCount}
+            onShowResolvedCommentsChange={onShowResolvedCommentsChange}
+          />
+
           {comments.length > 0 && (
             <Tooltip>
               <TooltipTrigger
diff --git a/apps/cli/lib/diff-colors.test.ts b/apps/cli/lib/diff-colors.test.ts
index 98ef0f9..e93fe73 100644
--- a/apps/cli/lib/diff-colors.test.ts
+++ b/apps/cli/lib/diff-colors.test.ts
@@ -45,4 +45,13 @@ describe("getDiffUnsafeCSS — Primer parity", () => {
     const css = getDiffUnsafeCSS(theme);
     expect(extractVar(css, cssVar)).toBe(expected);
   });
+
+  it("keeps split wrap columns balanced", () => {
+    const css = getDiffUnsafeCSS("dark");
+
+    expect(css).toContain(
+      "--diffs-code-grid: var(--diffs-grid-number-column-width) minmax(0, 1fr)",
+    );
+    expect(css).toContain("[data-diff-type='split'][data-overflow='wrap']");
+  });
 });
diff --git a/apps/cli/lib/diff-colors.ts b/apps/cli/lib/diff-colors.ts
index ffbe60d..11635a7 100644
--- a/apps/cli/lib/diff-colors.ts
+++ b/apps/cli/lib/diff-colors.ts
@@ -60,6 +60,14 @@ export const getDiffUnsafeCSS = (theme: DiffTheme) => {
 
   return `
     [data-diff-span] { border-radius: 0; }
+    [data-diff] {
+      --diffs-code-grid: var(--diffs-grid-number-column-width) minmax(0, 1fr);
+    }
+    [data-diff-type='split'][data-overflow='wrap'] {
+      grid-template-columns:
+        var(--diffs-grid-number-column-width) minmax(0, 1fr)
+        var(--diffs-grid-number-column-width) minmax(0, 1fr);
+    }
     :host {
       /* Base colors for additions/deletions */
       --diffs-addition-color-override: ${colors.addition};
diff --git a/apps/cli/lib/use-scroll-anchor.ts b/apps/cli/lib/use-scroll-anchor.ts
index 5f8b9df..94bf5f7 100644
--- a/apps/cli/lib/use-scroll-anchor.ts
+++ b/apps/cli/lib/use-scroll-anchor.ts
@@ -3,76 +3,136 @@
 import { useEffect } from "react";
 
 interface ScrollAnchorOptions {
+  /** Prefer this visible element over the section under the toolbar. */
+  preferredSelector?: string | null;
   /** CSS selector that matches every observed section. */
   selector: string;
   /** Container whose descendants to observe (defaults to document.body). */
   rootRef?: React.RefObject<HTMLElement | null>;
+  /** Sticky header offset to preserve below (defaults to 52px status bar). */
+  topOffset?: number;
 }
 
 /**
  * Safari-safe scroll anchor.
  *
  * WebKit ships no native `overflow-anchor` ([WebKit #171099][1]), so any
- * height change above the viewport during scroll silently pushes the user.
- * This hook observes every section matching `selector`. When a section
- * resizes:
- *
- * - If the section is **entirely above** the viewport, we adjust the page's
- *   scroll position by the size delta so the user's visible content does not
- *   shift.
- * - If the section is in or below the viewport, we leave scrollY alone — the
- *   user expects to see growth happening within their view.
- *
- * Compensations are batched per animation frame so multiple sections growing
- * in the same tick produce a single `scrollBy` call.
+ * height change above the viewport silently pushes the user. Tracking only
+ * "resized sections above the viewport" misses first-render and near-top
+ * mutations, so this hook continuously records the section under the sticky
+ * toolbar and restores that section's screen position after resize/mutation
+ * batches.
  *
  * [1]: https://bugs.webkit.org/show_bug.cgi?id=171099
  */
-export const useScrollAnchor = ({ selector, rootRef }: ScrollAnchorOptions) => {
+export const useScrollAnchor = ({
+  preferredSelector,
+  selector,
+  rootRef,
+  topOffset = 52,
+}: ScrollAnchorOptions) => {
   useEffect(() => {
     if (typeof ResizeObserver === "undefined" || typeof MutationObserver === "undefined") {
       return;
     }
 
-    const observedHeights = new WeakMap<Element, number>();
-    let pendingDelta = 0;
-    let rafId = 0;
+    interface Anchor {
+      element: Element;
+      top: number;
+    }
+
+    let anchor: Anchor | null = null;
+    let restoreRafId = 0;
+    let captureRafId = 0;
+    let suppressCaptureUntil = 0;
+
+    const getRoot = (): HTMLElement => rootRef?.current ?? document.body;
 
-    const flush = () => {
-      rafId = 0;
-      if (pendingDelta === 0) {
+    const isVisibleAnchor = (element: Element): boolean => {
+      const rect = element.getBoundingClientRect();
+      return rect.bottom > topOffset && rect.top < window.innerHeight;
+    };
+
+    const captureAnchor = () => {
+      const root = getRoot();
+      const preferred = preferredSelector ? root.querySelector(preferredSelector) : null;
+      if (preferred && isVisibleAnchor(preferred)) {
+        anchor = {
+          element: preferred,
+          top: preferred.getBoundingClientRect().top,
+        };
         return;
       }
-      const delta = pendingDelta;
-      pendingDelta = 0;
-      window.scrollBy({ behavior: "instant", left: 0, top: delta });
-    };
 
-    const observer = new ResizeObserver((entries) => {
-      for (const entry of entries) {
-        const next = entry.contentRect.height;
-        const last = observedHeights.get(entry.target);
-        observedHeights.set(entry.target, next);
-        if (last === undefined || last === next) {
+      const sections = [...root.querySelectorAll(selector)];
+      if (sections.length === 0) {
+        anchor = null;
+        return;
+      }
+
+      let selected = sections[0] ?? null;
+      for (const section of sections) {
+        const rect = section.getBoundingClientRect();
+        if (rect.bottom <= topOffset) {
+          selected = section;
           continue;
         }
-        const rect = entry.target.getBoundingClientRect();
-        // Only compensate for changes that happened entirely above the
-        // viewport's top edge. Partial overlap is rare and self-correcting
-        // (the user sees the growth happening in their view).
-        if (rect.bottom <= 0) {
-          pendingDelta += next - last;
-        }
+        selected = section;
+        break;
       }
-      if (pendingDelta !== 0 && rafId === 0) {
-        rafId = requestAnimationFrame(flush);
+
+      if (!selected) {
+        anchor = null;
+        return;
       }
-    });
+
+      anchor = {
+        element: selected,
+        top: selected.getBoundingClientRect().top,
+      };
+    };
+
+    const scheduleCapture = () => {
+      if (captureRafId !== 0 || Date.now() < suppressCaptureUntil) {
+        return;
+      }
+      captureRafId = requestAnimationFrame(() => {
+        captureRafId = 0;
+        captureAnchor();
+      });
+    };
+
+    const scheduleRestore = () => {
+      if (restoreRafId !== 0) {
+        return;
+      }
+      restoreRafId = requestAnimationFrame(() => {
+        restoreRafId = 0;
+
+        if (!anchor || !anchor.element.isConnected) {
+          captureAnchor();
+          return;
+        }
+
+        const nextTop = anchor.element.getBoundingClientRect().top;
+        const delta = nextTop - anchor.top;
+        if (Math.abs(delta) > 0.5) {
+          suppressCaptureUntil = Date.now() + 80;
+          window.scrollBy({ behavior: "instant", left: 0, top: delta });
+        }
+
+        anchor = {
+          element: anchor.element,
+          top: anchor.element.getBoundingClientRect().top,
+        };
+      });
+    };
+
+    const observer = new ResizeObserver(scheduleRestore);
 
     const seenSections = new WeakSet<Element>();
     const refresh = () => {
-      const root = rootRef?.current ?? document.body;
-      const sections = root.querySelectorAll(selector);
+      const sections = getRoot().querySelectorAll(selector);
       for (const section of sections) {
         if (seenSections.has(section)) {
           continue;
@@ -83,16 +143,27 @@ export const useScrollAnchor = ({ selector, rootRef }: ScrollAnchorOptions) => {
     };
 
     refresh();
-    const mutation = new MutationObserver(refresh);
-    const root = rootRef?.current ?? document.body;
+    captureAnchor();
+    const mutation = new MutationObserver(() => {
+      refresh();
+      scheduleRestore();
+    });
+    const root = getRoot();
     mutation.observe(root, { childList: true, subtree: true });
+    window.addEventListener("scroll", scheduleCapture, { passive: true });
+    window.addEventListener("resize", scheduleRestore);
 
     return () => {
-      if (rafId !== 0) {
-        cancelAnimationFrame(rafId);
+      if (restoreRafId !== 0) {
+        cancelAnimationFrame(restoreRafId);
+      }
+      if (captureRafId !== 0) {
+        cancelAnimationFrame(captureRafId);
       }
       observer.disconnect();
       mutation.disconnect();
+      window.removeEventListener("scroll", scheduleCapture);
+      window.removeEventListener("resize", scheduleRestore);
     };
-  }, [selector, rootRef]);
+  }, [preferredSelector, selector, rootRef, topOffset]);
 };

From b08b8cbefccb2dc2f072b2d40cad051ac009c71d Mon Sep 17 00:00:00 2001
From: Matthew Blode <matthew@linktr.ee>
Date: Tue, 19 May 2026 11:03:07 +1000
Subject: [PATCH 3/3] fix: quality pass on content-anchored comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Revert custom theme-provider → keep next-themes (pre-hydration script,
  system preference tracking, disableTransitionOnChange)
- Fix Copy & Clear data loss: export all comments, not just visible ones
- Fix deletion-side anchoring: left-side comments read from merge base ref
- Restore min-height pinning in CollapsibleFileDiff for Safari scroll stability
- Refresh comments on watch events so staleness stays current
- Invalidate comment snapshot cache alongside git cache on file changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 .changeset/content-anchored-comments.md |   5 ++
 apps/cli/components/DiffApp.test.tsx    |  10 +--
 apps/cli/components/DiffApp.tsx         |   7 +-
 apps/cli/components/DiffViewer.test.tsx |   6 +-
 apps/cli/components/DiffViewer.tsx      |  26 +++++-
 apps/cli/components/StatusBar.tsx       |   6 +-
 apps/cli/components/theme-provider.tsx  | 101 ++----------------------
 apps/cli/lib/comments.test.ts           |   9 ++-
 apps/cli/lib/comments.ts                |  70 +++++++++++++---
 apps/cli/lib/git.ts                     |   4 +-
 apps/cli/lib/repo-watch.ts              |   2 +
 11 files changed, 116 insertions(+), 130 deletions(-)
 create mode 100644 .changeset/content-anchored-comments.md

diff --git a/.changeset/content-anchored-comments.md b/.changeset/content-anchored-comments.md
new file mode 100644
index 0000000..47a18a5
--- /dev/null
+++ b/.changeset/content-anchored-comments.md
@@ -0,0 +1,5 @@
+---
+"diffhub": patch
+---
+
+Add content-anchored resolvable comments with navigation, reply threads, and staleness detection
diff --git a/apps/cli/components/DiffApp.test.tsx b/apps/cli/components/DiffApp.test.tsx
index e21ca68..7a458f0 100644
--- a/apps/cli/components/DiffApp.test.tsx
+++ b/apps/cli/components/DiffApp.test.tsx
@@ -5,8 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { DiffApp } from "./DiffApp";
 import type { Comment } from "@/lib/comment-types";
 import { WATCH_STREAM_EVENTS } from "@/lib/watch-stream";
-import type * as ThemeProviderModule from "./theme-provider";
-
 interface MockAnnotation {
   lineNumber: number;
   metadata?: unknown;
@@ -46,8 +44,8 @@ const { MockDynamicPatch } = vi.hoisted(() => ({
   ),
 }));
 
-vi.mock(import("./theme-provider"), async (importOriginal) => {
-  const actual = await importOriginal<typeof ThemeProviderModule>();
+vi.mock(import("next-themes"), async (importOriginal) => {
+  const actual = await importOriginal();
   return {
     ...actual,
     useTheme: () => ({
@@ -530,7 +528,7 @@ describe("DiffApp review flow", () => {
     await screen.findByText(/watched/);
     await screen.findByText("Updated just now");
     expect(countFetchCalls(fetchMock, "/api/files")).toBeGreaterThanOrEqual(2);
-    expect(countFetchCalls(fetchMock, "/api/comments")).toBe(1);
+    expect(countFetchCalls(fetchMock, "/api/comments")).toBeGreaterThanOrEqual(2);
     expect(countFetchCalls(fetchMock, "/api/diff")).toBeGreaterThanOrEqual(2);
     expect(FakeEventSource.instances[0]?.url).toBe("/api/watch");
 
@@ -603,7 +601,7 @@ describe("DiffApp review flow", () => {
     expect(
       fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/files?refresh=1")),
     ).toBeTruthy();
-    expect(countFetchCalls(fetchMock, "/api/comments")).toBe(1);
+    expect(countFetchCalls(fetchMock, "/api/comments")).toBeGreaterThanOrEqual(2);
 
     unmount();
   });
diff --git a/apps/cli/components/DiffApp.tsx b/apps/cli/components/DiffApp.tsx
index c8cde8e..48a7d7d 100644
--- a/apps/cli/components/DiffApp.tsx
+++ b/apps/cli/components/DiffApp.tsx
@@ -14,7 +14,7 @@ import { StatusBar } from "./StatusBar";
 import type { DiffMode, WatchStatus } from "./StatusBar";
 import { FileList } from "./FileList";
 import { DiffViewer, getCommentElementId, getDiffSectionId } from "./DiffViewer";
-import { useTheme } from "./theme-provider";
+import { useTheme } from "next-themes";
 import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
 import { Button } from "@/components/ui/button";
 import { toCommentSide } from "@/lib/comment-sides";
@@ -874,7 +874,7 @@ export const DiffApp = ({
         void (async () => {
           const didUpdate = await pollFilesRef.current({
             forceRefresh: true,
-            includeComments: false,
+            includeComments: true,
           });
           if (!active) {
             return;
@@ -925,7 +925,7 @@ export const DiffApp = ({
     const handleChange = () => {
       clearWatchStatusTimer();
       void (async () => {
-        await pollFilesRef.current({ forceRefresh: true, includeComments: false });
+        await pollFilesRef.current({ forceRefresh: true, includeComments: true });
         if (!active) {
           return;
         }
@@ -1457,6 +1457,7 @@ export const DiffApp = ({
             onRefresh={handleManualRefresh}
             watchStatus={watchStatus}
             comments={orderedVisibleComments}
+            allComments={comments}
             onClearComments={handleClearComments}
             totalCommentCount={comments.length}
             activeCommentIndex={activeCommentIndex}
diff --git a/apps/cli/components/DiffViewer.test.tsx b/apps/cli/components/DiffViewer.test.tsx
index 09039d9..6f22054 100644
--- a/apps/cli/components/DiffViewer.test.tsx
+++ b/apps/cli/components/DiffViewer.test.tsx
@@ -3,8 +3,6 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
 import { afterEach, describe, expect, it, vi } from "vitest";
 import { DiffViewer, getCommentElementId } from "./DiffViewer";
 import type { Comment } from "@/lib/comment-types";
-import type * as ThemeProviderModule from "./theme-provider";
-
 const { MockPatchDiff } = vi.hoisted(() => ({
   MockPatchDiff: ({
     lineAnnotations,
@@ -36,8 +34,8 @@ const { MockPatchDiff } = vi.hoisted(() => ({
   ),
 }));
 
-vi.mock(import("./theme-provider"), async (importOriginal) => {
-  const actual = await importOriginal<typeof ThemeProviderModule>();
+vi.mock(import("next-themes"), async (importOriginal) => {
+  const actual = await importOriginal();
   return {
     ...actual,
     useTheme: () => ({
diff --git a/apps/cli/components/DiffViewer.tsx b/apps/cli/components/DiffViewer.tsx
index 7bf307c..33869e2 100644
--- a/apps/cli/components/DiffViewer.tsx
+++ b/apps/cli/components/DiffViewer.tsx
@@ -11,7 +11,7 @@ import type { PrerenderedDiffHtml } from "@/lib/diff-prerender";
 import type { DiffTheme } from "@/lib/diff-colors";
 import { getDiffUnsafeCSS } from "@/lib/diff-colors";
 import { FileDiffHeader } from "./FileDiffHeader";
-import { useTheme } from "./theme-provider";
+import { useTheme } from "next-themes";
 import { cn } from "@/lib/utils";
 import { BranchIcon, CopySimpleIcon, TrashIcon, CheckIcon } from "blode-icons-react";
 import { Button } from "@/components/ui/button";
@@ -1045,6 +1045,30 @@ const CollapsibleFileDiff = memo(function CollapsibleFileDiff({
     }
 
     section.style.minHeight = "";
+
+    if (typeof ResizeObserver === "undefined") {
+      return;
+    }
+
+    let idleTimer: ReturnType<typeof setTimeout> | null = null;
+    const observer = new ResizeObserver(() => {
+      if (idleTimer) {
+        clearTimeout(idleTimer);
+      }
+      idleTimer = setTimeout(() => {
+        if (section.isConnected) {
+          section.style.minHeight = `${section.offsetHeight}px`;
+        }
+      }, 200);
+    });
+
+    observer.observe(section);
+    return () => {
+      if (idleTimer) {
+        clearTimeout(idleTimer);
+      }
+      observer.disconnect();
+    };
   }, [collapsed, commentTarget, comments, layout, shouldRenderPatch]);
 
   return (
diff --git a/apps/cli/components/StatusBar.tsx b/apps/cli/components/StatusBar.tsx
index 4182bf2..276a623 100644
--- a/apps/cli/components/StatusBar.tsx
+++ b/apps/cli/components/StatusBar.tsx
@@ -22,7 +22,7 @@ import { Kbd } from "@/components/ui/kbd";
 import { SidebarTrigger } from "@/components/ui/sidebar";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 import { cn } from "@/lib/utils";
-import { useTheme } from "./theme-provider";
+import { useTheme } from "next-themes";
 
 export type DiffMode = "all" | "uncommitted";
 export type WatchStatus = "connecting" | "live" | "offline" | "updated";
@@ -52,6 +52,7 @@ interface StatusBarProps {
     tone: "neutral" | "warning" | "destructive";
   } | null;
   comments: Comment[];
+  allComments: Comment[];
   onClearComments: () => Promise<boolean>;
   totalCommentCount: number;
   activeCommentIndex: number;
@@ -278,6 +279,7 @@ export const StatusBar = ({
   watchStatus,
   syncNotice,
   comments,
+  allComments,
   onClearComments,
   totalCommentCount,
   activeCommentIndex,
@@ -317,7 +319,7 @@ export const StatusBar = ({
   // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
   const copyCommentsAsPrompt = async () => {
     try {
-      const text = exportCommentsAsPrompt(comments);
+      const text = exportCommentsAsPrompt(allComments);
       await navigator.clipboard.writeText(text);
       const cleared = await onClearComments();
       if (!cleared) {
diff --git a/apps/cli/components/theme-provider.tsx b/apps/cli/components/theme-provider.tsx
index 4cf1f3f..206a106 100644
--- a/apps/cli/components/theme-provider.tsx
+++ b/apps/cli/components/theme-provider.tsx
@@ -1,101 +1,12 @@
 "use client";
 
 import * as React from "react";
-
-type Theme = "dark" | "light";
-
-interface ThemeContextValue {
-  forcedTheme?: Theme;
-  resolvedTheme: Theme;
-  setTheme: (theme: React.SetStateAction<string>) => void;
-  systemTheme: Theme;
-  theme: Theme;
-  themes: Theme[];
-}
-
-interface ThemeProviderProps {
-  attribute?: "class" | string;
-  children?: React.ReactNode;
-  defaultTheme?: Theme;
-  disableTransitionOnChange?: boolean;
-  enableSystem?: boolean;
-}
-
-const STORAGE_KEY = "theme";
-const DEFAULT_THEME: Theme = "dark";
-
-const ThemeContext = React.createContext<ThemeContextValue>({
-  resolvedTheme: DEFAULT_THEME,
-  setTheme: () => {
-    // Default context only applies outside ThemeProvider.
-  },
-  systemTheme: DEFAULT_THEME,
-  theme: DEFAULT_THEME,
-  themes: ["light", "dark"],
-});
-
-const isTheme = (value: unknown): value is Theme => value === "dark" || value === "light";
-
-const getStoredTheme = (fallback: Theme): Theme => {
-  if (typeof window === "undefined") {
-    return fallback;
-  }
-
-  try {
-    const stored = window.localStorage.getItem(STORAGE_KEY);
-    return isTheme(stored) ? stored : fallback;
-  } catch {
-    return fallback;
-  }
-};
-
-const applyTheme = (theme: Theme, attribute: string): void => {
-  const root = document.documentElement;
-  if (attribute === "class") {
-    root.classList.remove("dark", "light");
-    root.classList.add(theme);
-  } else {
-    root.setAttribute(attribute, theme);
-  }
-  root.style.colorScheme = theme;
-};
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+import type { ThemeProviderProps } from "next-themes";
 
 export const ThemeProvider = ({
-  attribute = "class",
   children,
-  defaultTheme = DEFAULT_THEME,
-}: ThemeProviderProps) => {
-  const fallback = isTheme(defaultTheme) ? defaultTheme : DEFAULT_THEME;
-  const [theme, setTheme] = React.useState<Theme>(() => getStoredTheme(fallback));
-
-  React.useEffect(() => {
-    applyTheme(theme, attribute);
-    try {
-      window.localStorage.setItem(STORAGE_KEY, theme);
-    } catch {
-      // localStorage unavailable
-    }
-  }, [attribute, theme]);
-
-  const setResolvedTheme = React.useCallback((nextTheme: React.SetStateAction<string>) => {
-    setTheme((current) => {
-      const value = typeof nextTheme === "function" ? nextTheme(current) : nextTheme;
-      return isTheme(value) ? value : current;
-    });
-  }, []);
-
-  const value = React.useMemo(
-    () => ({
-      resolvedTheme: theme,
-      setTheme: setResolvedTheme,
-      systemTheme: theme,
-      theme,
-      themes: ["light", "dark"] as Theme[],
-    }),
-    [setResolvedTheme, theme],
-  );
-
-  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
-};
-
-export const useTheme = (): ThemeContextValue => React.useContext(ThemeContext);
+  ...props
+}: ThemeProviderProps & { children?: React.ReactNode }) => (
+  <NextThemesProvider {...props}>{children}</NextThemesProvider>
+);
diff --git a/apps/cli/lib/comments.test.ts b/apps/cli/lib/comments.test.ts
index 5c2bb53..b380ffc 100644
--- a/apps/cli/lib/comments.test.ts
+++ b/apps/cli/lib/comments.test.ts
@@ -70,10 +70,11 @@ describe("comments store", () => {
       }),
     ]);
 
-    expect(readComments().map((comment) => comment.body)).toStrictEqual([
-      "First comment",
-      "Second comment",
-    ]);
+    expect(
+      readComments()
+        .map((comment) => comment.body)
+        .toSorted(),
+    ).toStrictEqual(["First comment", "Second comment"]);
 
     await deleteComment(firstComment.id);
 
diff --git a/apps/cli/lib/comments.ts b/apps/cli/lib/comments.ts
index 7d53bab..428bd17 100644
--- a/apps/cli/lib/comments.ts
+++ b/apps/cli/lib/comments.ts
@@ -1,6 +1,7 @@
 import { execFileSync } from "node:child_process";
 import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
 import { join } from "node:path";
+import type { CommentSide } from "./comment-sides";
 import { isCommentSide } from "./comment-sides";
 import type {
   Comment,
@@ -9,6 +10,7 @@ import type {
   CommentStaleness,
   CommentTag,
 } from "./comment-types";
+import { getBaseBranch, getFileAtRef, getMergeBase } from "./git";
 import { getGitDirectory, resolveRepoFilePath } from "./git-paths";
 import { getConfiguredRepoPath } from "./repo-path";
 
@@ -160,18 +162,23 @@ const readFileSnapshot = (file: string): FileSnapshot => {
   return snapshot;
 };
 
-const buildAnchor = (file: string, lineNumber: number, diffHunkHeader?: string): CommentAnchor => {
-  const snapshot = readFileSnapshot(file);
+const buildAnchorFromLines = (
+  lines: string[],
+  exists: boolean,
+  fileSha: string,
+  lineNumber: number,
+  diffHunkHeader?: string,
+): CommentAnchor => {
   const lineIndex = lineNumber - 1;
-  const lineContent = snapshot.lines[lineIndex] ?? "";
+  const lineContent = lines[lineIndex] ?? "";
   const beforeStart = Math.max(0, lineIndex - CONTEXT_LINES);
-  const afterEnd = Math.min(snapshot.lines.length, lineIndex + CONTEXT_LINES + 1);
+  const afterEnd = Math.min(lines.length, lineIndex + CONTEXT_LINES + 1);
 
   const anchor: CommentAnchor = {
-    afterContext: snapshot.lines.slice(lineIndex + 1, afterEnd).map(truncateContext),
-    beforeContext: snapshot.lines.slice(beforeStart, lineIndex).map(truncateContext),
-    createdFromMissingFile: !snapshot.exists,
-    fileSha: snapshot.fileSha,
+    afterContext: lines.slice(lineIndex + 1, afterEnd).map(truncateContext),
+    beforeContext: lines.slice(beforeStart, lineIndex).map(truncateContext),
+    createdFromMissingFile: !exists,
+    fileSha,
     lineContent,
   };
 
@@ -182,6 +189,41 @@ const buildAnchor = (file: string, lineNumber: number, diffHunkHeader?: string):
   return anchor;
 };
 
+const buildAnchor = (file: string, lineNumber: number, diffHunkHeader?: string): CommentAnchor => {
+  const snapshot = readFileSnapshot(file);
+  return buildAnchorFromLines(
+    snapshot.lines,
+    snapshot.exists,
+    snapshot.fileSha,
+    lineNumber,
+    diffHunkHeader,
+  );
+};
+
+const buildAnchorAsync = async (
+  file: string,
+  lineNumber: number,
+  side: CommentSide,
+  diffHunkHeader?: string,
+): Promise<CommentAnchor> => {
+  if (side === "left") {
+    try {
+      const baseBranch = await getBaseBranch();
+      const mergeBase = await getMergeBase(baseBranch);
+      const content = await getFileAtRef(file, mergeBase);
+      const lines = content.split(/\r?\n/);
+      if (lines.at(-1) === "") {
+        lines.pop();
+      }
+      const exists = content.length > 0;
+      return buildAnchorFromLines(lines, exists, "", lineNumber, diffHunkHeader);
+    } catch {
+      return buildAnchor(file, lineNumber, diffHunkHeader);
+    }
+  }
+  return buildAnchor(file, lineNumber, diffHunkHeader);
+};
+
 const lineMatchesContext = (expected: string | undefined, actual: string | undefined): boolean =>
   expected !== undefined && actual !== undefined && truncateContext(actual) === expected;
 
@@ -397,16 +439,17 @@ const mutateComments = <T>(updater: (comments: Comment[]) => T): Promise<T> => {
   return nextMutation;
 };
 
-export const addComment = (
+export const addComment = async (
   data: Omit<Comment, "anchor" | "createdAt" | "id" | "replies" | "resolved" | "staleness"> & {
     diffHunkHeader?: string;
   },
-): Promise<Comment> =>
-  mutateComments((comments) => {
-    const { diffHunkHeader, ...commentData } = data;
+): Promise<Comment> => {
+  const { diffHunkHeader, ...commentData } = data;
+  const anchor = await buildAnchorAsync(data.file, data.lineNumber, data.side, diffHunkHeader);
+  return mutateComments((comments) => {
     const comment: Comment = {
       ...commentData,
-      anchor: buildAnchor(data.file, data.lineNumber, diffHunkHeader),
+      anchor,
       createdAt: new Date().toISOString(),
       id: crypto.randomUUID(),
       replies: [],
@@ -416,6 +459,7 @@ export const addComment = (
     comments.push(comment);
     return comment;
   });
+};
 
 const getCurrentUser = (): string => {
   if (process.env.DIFFHUB_USER) {
diff --git a/apps/cli/lib/git.ts b/apps/cli/lib/git.ts
index 98ea75e..a01a5a2 100644
--- a/apps/cli/lib/git.ts
+++ b/apps/cli/lib/git.ts
@@ -364,7 +364,7 @@ const splitPatchByFile = (patch: string): Map<string, string> => {
   return patches;
 };
 
-const getBaseBranch = (): Promise<string> => {
+export const getBaseBranch = (): Promise<string> => {
   const repoPath = getRepoPath();
   return cached(`baseBranch:${repoPath}`, 30_000, async () => {
     const remoteBranches = splitGitLines(
@@ -387,7 +387,7 @@ const getBaseBranch = (): Promise<string> => {
   });
 };
 
-const getMergeBase = (baseBranch: string): Promise<string> => {
+export const getMergeBase = (baseBranch: string): Promise<string> => {
   const repoPath = getRepoPath();
   return cached(`mergeBase:${repoPath}:${baseBranch}`, 30_000, async () => {
     const mb = await runGit(["merge-base", "HEAD", baseBranch]);
diff --git a/apps/cli/lib/repo-watch.ts b/apps/cli/lib/repo-watch.ts
index d4e4edb..dd1892e 100644
--- a/apps/cli/lib/repo-watch.ts
+++ b/apps/cli/lib/repo-watch.ts
@@ -1,6 +1,7 @@
 import { join } from "node:path";
 import { watch } from "chokidar";
 import type { FSWatcher } from "chokidar";
+import { clearCommentCache } from "./comments";
 import { getGitDirectory } from "./git-paths";
 import { invalidateGitCache } from "./git";
 
@@ -132,6 +133,7 @@ const flushPendingChange = (entry: RepoWatchEntry): void => {
   }
 
   invalidateGitCache(entry.repoPath);
+  clearCommentCache();
   entry.eventId += 1;
   emitToSubscribers(entry, {
     createdAt: new Date().toISOString(),
