-
- {/* Only when the whole thread is idle, so opening this reply's editor can't
- discard another in-progress edit or reply (matches the root comment). */}
- {idle && }
-
- {isEditing ? (
-
- ) : (
-
- )}
-
- );
-}
-
-function DeleteDialog({
- target,
- onCancel,
- onConfirm,
-}: {
- target: DeleteTarget | null;
- onCancel: () => void;
- onConfirm: () => void;
-}) {
- const isThreadDelete = target?.kind === "thread" && target.hasReplies;
- return (
- {
- if (!open) onCancel();
- }}
- >
-
-
- {isThreadDelete ? "Delete thread" : "Delete comment"}
-
- {isThreadDelete
- ? "This deletes the whole conversation, including replies. This can't be undone."
- : "This deletes the comment. This can't be undone."}
-
-
-
- Cancel
-
-
-
-
- );
-}
diff --git a/packages/web/src/components/comments/review-thread.tsx b/packages/web/src/components/comments/review-thread.tsx
new file mode 100644
index 0000000..6fb0d0f
--- /dev/null
+++ b/packages/web/src/components/comments/review-thread.tsx
@@ -0,0 +1,468 @@
+import {
+ COMMENT_STATE,
+ type ReviewComment,
+ type ReviewThread,
+ THREAD_SOURCE,
+} from "@stagereview/types/review";
+import {
+ ChevronRight,
+ Circle,
+ CircleCheck,
+ GitPullRequestArrow,
+ MessageSquare,
+ User,
+} from "lucide-react";
+import { useState } from "react";
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Markdown } from "@/components/ui/markdown";
+import { toast } from "@/components/ui/sonner";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { formatTimeAgo } from "@/lib/format";
+import { useReviewContext } from "@/lib/review-context";
+import { GITHUB_REVIEW_STATUS } from "@/lib/use-review";
+import { useViewer } from "@/lib/use-viewer";
+import { cn } from "@/lib/utils";
+import { CommentActions } from "./comment-actions";
+import { CommentForm } from "./comment-form";
+
+function errorMessage(err: unknown, fallback: string): string {
+ return err instanceof Error ? err.message : fallback;
+}
+
+const PENDING_BADGE_CN =
+ "border-yellow-500/50 bg-yellow-50 text-yellow-800 dark:bg-yellow-950/20 dark:text-yellow-200";
+
+// A pending comment is editable (it's the viewer's own draft); a local comment is
+// editable too. Submitted comments live on GitHub and are read-only here.
+function canActOn(comment: ReviewComment): boolean {
+ return comment.state !== COMMENT_STATE.SUBMITTED;
+}
+
+function StateBadge({ state }: { state: ReviewComment["state"] }) {
+ if (state === COMMENT_STATE.PENDING) {
+ return (
+
+ Pending
+
+ );
+ }
+ if (state === COMMENT_STATE.LOCAL) {
+ return (
+
+ Local
+
+ );
+ }
+ return null;
+}
+
+export function ReviewThreadView({ thread }: { thread: ReviewThread }) {
+ const review = useReviewContext();
+ const isGitHub = thread.source === THREAD_SOURCE.GITHUB;
+ const githubAvailable = review.github === GITHUB_REVIEW_STATUS.AVAILABLE;
+ const canPushToReview = review.canPushToReview;
+
+ const [isOpen, setIsOpen] = useState(!thread.isResolved);
+ const [isReplying, setIsReplying] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [error, setError] = useState(null);
+
+ const root = thread.comments[0];
+ if (!root) return null;
+ const replies = thread.comments.slice(1);
+ const idle = !isReplying && editingId === null;
+
+ function setOpenError(message: string | null) {
+ setError(message);
+ }
+
+ async function handleResolveToggle() {
+ const next = !thread.isResolved;
+ const wasOpen = isOpen;
+ const hasActiveForm = isReplying || editingId !== null || deleteTarget !== null;
+ if (!next || !hasActiveForm) setIsOpen(!next);
+ try {
+ if (isGitHub && thread.threadNodeId) {
+ await review.resolveGitHub({ threadNodeId: thread.threadNodeId, resolved: next });
+ } else {
+ await review.resolveLocalThread({ threadId: thread.id, resolved: next });
+ }
+ } catch (err) {
+ setIsOpen(wasOpen);
+ toastError(err, "Failed to update resolved state");
+ }
+ }
+
+ function handleOpenChange(open: boolean) {
+ if (!open && (isReplying || editingId !== null || deleteTarget !== null)) return;
+ setIsOpen(open);
+ }
+
+ async function submitReply(body: string, startReview: boolean) {
+ setOpenError(null);
+ try {
+ if (isGitHub && thread.threadNodeId) {
+ // "Start a review" → add the reply to the pending review; otherwise post it now.
+ await review.replyGitHub({ threadNodeId: thread.threadNodeId, body, pending: startReview });
+ } else {
+ await review.replyLocal({ threadId: thread.id, body });
+ }
+ setIsReplying(false);
+ } catch (err) {
+ setOpenError(errorMessage(err, "Failed to add reply"));
+ throw err;
+ }
+ }
+
+ async function submitEdit(comment: ReviewComment, body: string) {
+ setOpenError(null);
+ try {
+ if (comment.state === COMMENT_STATE.LOCAL) {
+ await review.editLocalComment({ commentId: comment.id, body });
+ } else if (comment.nodeId) {
+ await review.editGitHubComment({ nodeId: comment.nodeId, body });
+ }
+ setEditingId(null);
+ } catch (err) {
+ setOpenError(errorMessage(err, "Failed to update comment"));
+ throw err;
+ }
+ }
+
+ async function confirmDelete() {
+ const comment = deleteTarget;
+ setDeleteTarget(null);
+ if (!comment) return;
+ try {
+ if (comment.state === COMMENT_STATE.LOCAL) {
+ // Deleting a local root removes the whole thread; a reply removes just itself.
+ if (comment.id === root?.id) await review.deleteLocalThread(thread.id);
+ else await review.deleteLocalComment(comment.id);
+ } else if (comment.nodeId) {
+ await review.deleteGitHubComment(comment.nodeId);
+ }
+ } catch (err) {
+ toastError(err, "Failed to delete comment");
+ }
+ }
+
+ async function handleAddToReview() {
+ try {
+ await review.addToReview(thread.id);
+ } catch (err) {
+ toastError(err, "Failed to add to review");
+ }
+ }
+
+ const rootIsDeletableThread = root.state === COMMENT_STATE.LOCAL && replies.length > 0;
+
+ return (
+
+
+
+
+ );
+}
+
+const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
+
+/**
+ * The review tray: submit the viewer's pending GitHub review (Comment / Approve /
+ * Request changes) or discard it. Only shown when the run targets a reachable PR;
+ * the badge counts the viewer's draft comments and the list shows what will publish.
+ */
+export function ReviewPanel() {
+ const review = useReviewContext();
+ const [open, setOpen] = useState(false);
+ const [body, setBody] = useState("");
+ const [selected, setSelected] = useState(REVIEW_EVENT.COMMENT);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [showDiscard, setShowDiscard] = useState(false);
+ const textareaRef = useRef(null);
+
+ const { pendingCommentCount, hasPendingReview, isOwnPullRequest } = review;
+ const pendingByFile = useMemo(() => collectPendingByFile(review.threads), [review.threads]);
+
+ if (review.github !== GITHUB_REVIEW_STATUS.AVAILABLE) return null;
+
+ const hasContent = body.trim().length > 0;
+ // On your own PR only "Comment" is allowed; coerce the effective event so a stale
+ // Approve/Request-changes selection can never be submitted (the radios are disabled,
+ // but the prior `selected` state would otherwise persist).
+ const effectiveEvent =
+ isOwnPullRequest && selected !== REVIEW_EVENT.COMMENT ? REVIEW_EVENT.COMMENT : selected;
+ // A bare "Comment" submit with neither body nor pending comments is a no-op.
+ const canSubmit =
+ !isSubmitting &&
+ (effectiveEvent !== REVIEW_EVENT.COMMENT || hasContent || pendingCommentCount > 0);
+
+ function selectAction(event: ReviewEvent) {
+ if (isOwnPullRequest && event !== REVIEW_EVENT.COMMENT) return;
+ setSelected(event);
+ }
+
+ async function handleSubmit() {
+ if (!canSubmit) return;
+ setIsSubmitting(true);
+ try {
+ await review.submitReview({ event: effectiveEvent, body: body.trim() });
+ setBody("");
+ setOpen(false);
+ toast.success("Review submitted");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to submit review");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ async function handleDiscard() {
+ setIsSubmitting(true);
+ try {
+ await review.discardReview();
+ setShowDiscard(false);
+ setOpen(false);
+ toast.success("Pending review discarded");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to discard review");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ void handleSubmit();
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Submit your review
+
+
+
Finish your review
+
+ {pendingCommentCount > 0
+ ? `${pendingCommentCount} pending comment${pendingCommentCount === 1 ? "" : "s"} will be published.`
+ : "No pending comments yet — add comments to your review from the diff."}
+
+
+
+
+
+
+
+
+
+
+
+ {
+ if (!v && !isSubmitting) setShowDiscard(false);
+ }}
+ >
+
+
+ Discard review
+
+ This deletes all your pending comments on the PR. This can't be undone.
+
+
+
+ Cancel
+
+
+
+
+ >
+ );
+}
diff --git a/packages/web/src/lib/__tests__/comment-drafts.test.ts b/packages/web/src/lib/__tests__/comment-drafts.test.ts
index f755893..e903cb7 100644
--- a/packages/web/src/lib/__tests__/comment-drafts.test.ts
+++ b/packages/web/src/lib/__tests__/comment-drafts.test.ts
@@ -11,18 +11,18 @@ import {
upsertDraft,
writeDraftBody,
} from "../comment-drafts";
-import type { CommentThread } from "../use-comment-threads";
+import type { ReviewThread as CommentThread } from "../use-review";
function makeThread(
over: Partial & Pick,
): CommentThread {
return {
id: `t-${over.side}-${over.endLine}`,
+ source: "local",
+ threadNodeId: null,
filePath: "a.ts",
startLine: over.endLine,
- resolvedAt: null,
- createdAt: "2026-06-08T00:00:00.000Z",
- updatedAt: "2026-06-08T00:00:00.000Z",
+ isResolved: false,
comments: [],
...over,
};
diff --git a/packages/web/src/lib/__tests__/comment-threads-context.test.tsx b/packages/web/src/lib/__tests__/comment-threads-context.test.tsx
deleted file mode 100644
index 8deb9b0..0000000
--- a/packages/web/src/lib/__tests__/comment-threads-context.test.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-// @vitest-environment happy-dom
-
-import { act, render, waitFor } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { toast } from "@/components/ui/sonner";
-import { CommentThreadsProvider } from "../comment-threads-context";
-import { makeWrapper } from "./fixtures";
-
-vi.mock("@/components/ui/sonner", () => ({ toast: { error: vi.fn(), dismiss: vi.fn() } }));
-
-afterEach(() => {
- vi.unstubAllGlobals();
- vi.clearAllMocks();
-});
-
-function stubFetch(status: number, body: string): void {
- vi.stubGlobal(
- "fetch",
- vi.fn(
- async () => new Response(body, { status, headers: { "Content-Type": "application/json" } }),
- ),
- );
-}
-
-describe("CommentThreadsProvider", () => {
- it("surfaces a failed threads fetch as a toast so it isn't mistaken for no comments", async () => {
- stubFetch(500, "boom");
- const { Wrapper } = makeWrapper();
-
- render(
-
- diff
- ,
- { wrapper: Wrapper },
- );
-
- await waitFor(() =>
- expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
- "Couldn't load comments",
- expect.objectContaining({ id: "comment-threads-error" }),
- ),
- );
- });
-
- it("does not toast when the fetch succeeds with no comments", async () => {
- stubFetch(200, "[]");
- const { Wrapper } = makeWrapper();
-
- render(
-
- diff
- ,
- { wrapper: Wrapper },
- );
-
- await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1));
- expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
- });
-
- it("dismisses the error toast once a later fetch recovers", async () => {
- let calls = 0;
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => {
- calls += 1;
- return calls === 1
- ? new Response("boom", { status: 500 })
- : new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } });
- }),
- );
- const { client, Wrapper } = makeWrapper();
-
- render(
-
- diff
- ,
- { wrapper: Wrapper },
- );
-
- await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled());
- // Ignore the no-op dismiss that runs before any error appears.
- vi.mocked(toast.dismiss).mockClear();
-
- await act(async () => {
- await client.refetchQueries();
- });
-
- await waitFor(() =>
- expect(vi.mocked(toast.dismiss)).toHaveBeenCalledWith("comment-threads-error"),
- );
- });
-});
diff --git a/packages/web/src/lib/comment-drafts.ts b/packages/web/src/lib/comment-drafts.ts
index 210672a..2242c8c 100644
--- a/packages/web/src/lib/comment-drafts.ts
+++ b/packages/web/src/lib/comment-drafts.ts
@@ -1,6 +1,6 @@
import type { DiffLineAnnotation } from "@pierre/diffs";
import type { DiffSide } from "@/lib/diff-types";
-import type { CommentThread } from "@/lib/use-comment-threads";
+import type { ReviewThread as CommentThread } from "@/lib/use-review";
/** An in-progress comment the reviewer is composing, anchored to a line range. */
export interface CommentDraft {
diff --git a/packages/web/src/lib/comment-threads-context.tsx b/packages/web/src/lib/comment-threads-context.tsx
deleted file mode 100644
index 42d2862..0000000
--- a/packages/web/src/lib/comment-threads-context.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { createContext, type ReactNode, useContext, useEffect } from "react";
-import { toast } from "@/components/ui/sonner";
-import { type UseCommentThreadsResult, useCommentThreads } from "./use-comment-threads";
-
-const CommentThreadsContext = createContext(null);
-
-const LOAD_ERROR_TOAST_ID = "comment-threads-error";
-
-/**
- * Provides the run's comment threads + mutations to the diff tree without
- * prop-drilling through FileDiffList. Mounted once at the run layout.
- */
-export function CommentThreadsProvider({
- runId,
- children,
-}: {
- runId: string;
- children: ReactNode;
-}) {
- const value = useCommentThreads(runId);
-
- // A failed threads fetch is otherwise indistinguishable from "no comments" —
- // the diff still renders, but the overlay is silently empty. Surface it as a
- // toast (React Query only sets `error` once its retries are exhausted), and
- // dismiss it once a later fetch recovers so a stale message doesn't linger.
- useEffect(() => {
- if (!value.error) {
- toast.dismiss(LOAD_ERROR_TOAST_ID);
- return;
- }
- // Stable id so a re-fire (StrictMode double-mount, remount with a cached error,
- // refetch failing with a new error reference) updates one toast instead of stacking.
- toast.error("Couldn't load comments", {
- id: LOAD_ERROR_TOAST_ID,
- description: value.error instanceof Error ? value.error.message : undefined,
- });
- }, [value.error]);
-
- return {children};
-}
-
-export function useCommentThreadsContext(): UseCommentThreadsResult {
- const ctx = useContext(CommentThreadsContext);
- if (!ctx) {
- throw new Error("useCommentThreadsContext must be used within a CommentThreadsProvider");
- }
- return ctx;
-}
diff --git a/packages/web/src/lib/review-context.tsx b/packages/web/src/lib/review-context.tsx
new file mode 100644
index 0000000..8dbdcf8
--- /dev/null
+++ b/packages/web/src/lib/review-context.tsx
@@ -0,0 +1,37 @@
+import { createContext, type ReactNode, useContext, useEffect } from "react";
+import { toast } from "@/components/ui/sonner";
+import { type UseReviewResult, useReview } from "./use-review";
+
+const ReviewContext = createContext(null);
+
+const LOAD_ERROR_TOAST_ID = "review-error";
+
+/**
+ * Provides the run's merged review (local + GitHub threads) and its mutations to
+ * the diff tree without prop-drilling. Mounted once at the run layout.
+ */
+export function ReviewProvider({ runId, children }: { runId: string; children: ReactNode }) {
+ const value = useReview(runId);
+
+ // A failed review fetch is otherwise indistinguishable from "no comments" — the
+ // diff still renders but the overlay is silently empty. Surface it as a toast,
+ // and dismiss it once a later fetch recovers so a stale message doesn't linger.
+ useEffect(() => {
+ if (!value.error) {
+ toast.dismiss(LOAD_ERROR_TOAST_ID);
+ return;
+ }
+ toast.error("Couldn't load review comments", {
+ id: LOAD_ERROR_TOAST_ID,
+ description: value.error instanceof Error ? value.error.message : undefined,
+ });
+ }, [value.error]);
+
+ return {children};
+}
+
+export function useReviewContext(): UseReviewResult {
+ const ctx = useContext(ReviewContext);
+ if (!ctx) throw new Error("useReviewContext must be used within a ReviewProvider");
+ return ctx;
+}
diff --git a/packages/web/src/lib/use-comment-threads.ts b/packages/web/src/lib/use-comment-threads.ts
deleted file mode 100644
index 972e3f9..0000000
--- a/packages/web/src/lib/use-comment-threads.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-import {
- type Comment,
- type CommentThread,
- CommentThreadsResponseSchema,
- type CreateCommentThreadBody,
-} from "@stagereview/types/comments";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useMemo } from "react";
-import { jsonFetch } from "./use-view-state";
-
-export type { Comment, CommentThread, CreateCommentThreadBody };
-
-const COMMENT_THREADS_ROOT = "comment-threads";
-
-export function commentThreadsQueryKey(runId: string): readonly unknown[] {
- return [COMMENT_THREADS_ROOT, runId];
-}
-
-async function fetchCommentThreads(runId: string): Promise {
- // Parse at the boundary so server-side schema drift surfaces as a query error
- // here, not as a render crash deeper in the diff.
- const raw = await jsonFetch(`/api/runs/${encodeURIComponent(runId)}/comment-threads`);
- return CommentThreadsResponseSchema.parse(raw);
-}
-
-const jsonRequest = (method: string, body?: unknown): RequestInit => ({
- method,
- headers: { "Content-Type": "application/json" },
- body: body === undefined ? undefined : JSON.stringify(body),
-});
-
-export interface UseCommentThreadsResult {
- threads: CommentThread[];
- /** Stable reference; rebuilt only when the underlying query data changes. */
- threadsByFile: ReadonlyMap;
- isLoading: boolean;
- error: unknown;
- createThread: (input: CreateCommentThreadBody) => Promise;
- replyToThread: (input: { threadId: string; body: string }) => Promise;
- setThreadResolved: (input: { threadId: string; resolved: boolean }) => Promise;
- editComment: (input: { commentId: string; body: string }) => Promise;
- deleteThread: (threadId: string) => Promise;
- deleteComment: (commentId: string) => Promise;
-}
-
-/**
- * Loads the comment threads anchored to a run's diff scope and exposes the
- * thread/comment mutations. The local server commits synchronously, so each
- * mutation simply invalidates the query rather than maintaining optimistic
- * caches — the refetch round-trip is effectively instant.
- */
-export function useCommentThreads(runId: string): UseCommentThreadsResult {
- const queryClient = useQueryClient();
- const queryKey = useMemo(() => commentThreadsQueryKey(runId), [runId]);
-
- const { data, isLoading, error } = useQuery({
- queryKey,
- queryFn: () => fetchCommentThreads(runId),
- enabled: runId !== "",
- });
-
- const threads = useMemo(() => data ?? [], [data]);
- const threadsByFile = useMemo(() => groupByFile(threads), [threads]);
-
- const invalidate = () => queryClient.invalidateQueries({ queryKey });
-
- const createMutation = useMutation({
- mutationFn: (input: CreateCommentThreadBody) =>
- jsonFetch(
- `/api/runs/${encodeURIComponent(runId)}/comment-threads`,
- jsonRequest("POST", input),
- ),
- onSuccess: invalidate,
- });
-
- const replyMutation = useMutation({
- mutationFn: async ({ threadId, body }: { threadId: string; body: string }) => {
- await jsonFetch(
- `/api/comment-threads/${encodeURIComponent(threadId)}/replies`,
- jsonRequest("POST", { body }),
- );
- },
- onSuccess: invalidate,
- });
-
- const resolveMutation = useMutation({
- mutationFn: async ({ threadId, resolved }: { threadId: string; resolved: boolean }) => {
- await jsonFetch(
- `/api/comment-threads/${encodeURIComponent(threadId)}`,
- jsonRequest("PATCH", { resolved }),
- );
- },
- onSuccess: invalidate,
- });
-
- const editMutation = useMutation({
- mutationFn: async ({ commentId, body }: { commentId: string; body: string }) => {
- await jsonFetch(
- `/api/comments/${encodeURIComponent(commentId)}`,
- jsonRequest("PATCH", { body }),
- );
- },
- onSuccess: invalidate,
- });
-
- const deleteThreadMutation = useMutation({
- mutationFn: async (threadId: string) => {
- await jsonFetch(
- `/api/comment-threads/${encodeURIComponent(threadId)}`,
- jsonRequest("DELETE"),
- );
- },
- onSuccess: invalidate,
- });
-
- const deleteCommentMutation = useMutation({
- mutationFn: async (commentId: string) => {
- await jsonFetch(`/api/comments/${encodeURIComponent(commentId)}`, jsonRequest("DELETE"));
- },
- onSuccess: invalidate,
- });
-
- return useMemo(
- () => ({
- threads,
- threadsByFile,
- isLoading,
- error,
- createThread: createMutation.mutateAsync,
- replyToThread: replyMutation.mutateAsync,
- setThreadResolved: resolveMutation.mutateAsync,
- editComment: editMutation.mutateAsync,
- deleteThread: deleteThreadMutation.mutateAsync,
- deleteComment: deleteCommentMutation.mutateAsync,
- }),
- [
- threads,
- threadsByFile,
- isLoading,
- error,
- createMutation.mutateAsync,
- replyMutation.mutateAsync,
- resolveMutation.mutateAsync,
- editMutation.mutateAsync,
- deleteThreadMutation.mutateAsync,
- deleteCommentMutation.mutateAsync,
- ],
- );
-}
-
-function groupByFile(threads: CommentThread[]): ReadonlyMap {
- const map = new Map();
- for (const thread of threads) {
- const list = map.get(thread.filePath);
- if (list) list.push(thread);
- else map.set(thread.filePath, [thread]);
- }
- return map;
-}
diff --git a/packages/web/src/lib/use-review.ts b/packages/web/src/lib/use-review.ts
new file mode 100644
index 0000000..84920bb
--- /dev/null
+++ b/packages/web/src/lib/use-review.ts
@@ -0,0 +1,206 @@
+import type { CreateCommentThreadBody } from "@stagereview/types/comments";
+import {
+ GITHUB_REVIEW_STATUS,
+ type GitHubReviewStatus,
+ type ReviewEvent,
+ type ReviewResponse,
+ ReviewResponseSchema,
+ type ReviewThread,
+} from "@stagereview/types/review";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { jsonFetch } from "./use-view-state";
+
+export type { CreateCommentThreadBody, GitHubReviewStatus, ReviewEvent, ReviewThread };
+export { GITHUB_REVIEW_STATUS };
+
+const REVIEW_ROOT = "review";
+
+export function reviewQueryKey(runId: string): readonly unknown[] {
+ return [REVIEW_ROOT, runId];
+}
+
+async function fetchReview(runId: string): Promise {
+ const raw = await jsonFetch(`/api/runs/${encodeURIComponent(runId)}/review`);
+ return ReviewResponseSchema.parse(raw);
+}
+
+const jsonRequest = (method: string, body?: unknown): RequestInit => ({
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: body === undefined ? undefined : JSON.stringify(body),
+});
+
+export interface UseReviewResult {
+ threads: ReviewThread[];
+ threadsByFile: ReadonlyMap;
+ github: GitHubReviewStatus;
+ pendingCommentCount: number;
+ hasPendingReview: boolean;
+ isOwnPullRequest: boolean;
+ canPushToReview: boolean;
+ isLoading: boolean;
+ error: unknown;
+ // Local comments (CLI-only, work offline).
+ createLocalThread: (input: CreateCommentThreadBody) => Promise;
+ // Create a comment directly on the PR as a pending review comment.
+ createPendingComment: (input: CreateCommentThreadBody) => Promise;
+ replyLocal: (input: { threadId: string; body: string }) => Promise;
+ editLocalComment: (input: { commentId: string; body: string }) => Promise;
+ deleteLocalThread: (threadId: string) => Promise;
+ deleteLocalComment: (commentId: string) => Promise;
+ resolveLocalThread: (input: { threadId: string; resolved: boolean }) => Promise;
+ // GitHub review actions.
+ addToReview: (localThreadId: string) => Promise;
+ submitReview: (input: { event: ReviewEvent; body: string }) => Promise;
+ discardReview: () => Promise;
+ replyGitHub: (input: { threadNodeId: string; body: string; pending: boolean }) => Promise;
+ editGitHubComment: (input: { nodeId: string; body: string }) => Promise;
+ deleteGitHubComment: (nodeId: string) => Promise;
+ resolveGitHub: (input: { threadNodeId: string; resolved: boolean }) => Promise;
+}
+
+/**
+ * The run's merged review — local threads plus the PR's live pending/submitted
+ * GitHub threads — and the mutations that act on each. Every mutation invalidates
+ * the review query so the merged view refetches (the local server commits
+ * synchronously and GitHub round-trips are quick), keeping local and GitHub in step.
+ */
+export function useReview(runId: string): UseReviewResult {
+ const queryClient = useQueryClient();
+ const queryKey = useMemo(() => reviewQueryKey(runId), [runId]);
+
+ const { data, isLoading, error } = useQuery({
+ queryKey,
+ queryFn: () => fetchReview(runId),
+ enabled: runId !== "",
+ });
+
+ const threads = useMemo(() => data?.threads ?? [], [data]);
+ const threadsByFile = useMemo(() => groupByFile(threads), [threads]);
+ const invalidate = () => queryClient.invalidateQueries({ queryKey });
+ // GitHub-affecting actions (submit/resolve/reply/promote) change PR-level state —
+ // reviewer decisions, the merge button — that lives behind separate, infinitely-
+ // stale query keys. Refresh those too so the PR header doesn't go stale until reload.
+ const invalidateGitHub = () => {
+ invalidate();
+ queryClient.invalidateQueries({ queryKey: ["pull-request-reviews", runId] });
+ queryClient.invalidateQueries({ queryKey: ["pull-request-merge-status", runId] });
+ };
+
+ const runPath = (suffix: string) => `/api/runs/${encodeURIComponent(runId)}${suffix}`;
+
+ const m = {
+ createLocalThread: useMutation({
+ mutationFn: (input: CreateCommentThreadBody) =>
+ jsonFetch(runPath("/comment-threads"), jsonRequest("POST", input)),
+ onSuccess: invalidate,
+ }),
+ createPendingComment: useMutation({
+ mutationFn: (input: CreateCommentThreadBody) =>
+ jsonFetch(runPath("/review/comment"), jsonRequest("POST", input)),
+ onSuccess: invalidateGitHub,
+ }),
+ replyLocal: useMutation({
+ mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
+ jsonFetch(
+ `/api/comment-threads/${encodeURIComponent(threadId)}/replies`,
+ jsonRequest("POST", { body }),
+ ),
+ onSuccess: invalidate,
+ }),
+ editLocalComment: useMutation({
+ mutationFn: ({ commentId, body }: { commentId: string; body: string }) =>
+ jsonFetch(`/api/comments/${encodeURIComponent(commentId)}`, jsonRequest("PATCH", { body })),
+ onSuccess: invalidate,
+ }),
+ deleteLocalThread: useMutation({
+ mutationFn: (threadId: string) =>
+ jsonFetch(`/api/comment-threads/${encodeURIComponent(threadId)}`, jsonRequest("DELETE")),
+ onSuccess: invalidate,
+ }),
+ deleteLocalComment: useMutation({
+ mutationFn: (commentId: string) =>
+ jsonFetch(`/api/comments/${encodeURIComponent(commentId)}`, jsonRequest("DELETE")),
+ onSuccess: invalidate,
+ }),
+ resolveLocalThread: useMutation({
+ mutationFn: ({ threadId, resolved }: { threadId: string; resolved: boolean }) =>
+ jsonFetch(
+ `/api/comment-threads/${encodeURIComponent(threadId)}`,
+ jsonRequest("PATCH", { resolved }),
+ ),
+ onSuccess: invalidate,
+ }),
+ addToReview: useMutation({
+ mutationFn: (localThreadId: string) =>
+ jsonFetch(runPath("/review/add"), jsonRequest("POST", { localThreadId })),
+ onSuccess: invalidateGitHub,
+ }),
+ submitReview: useMutation({
+ mutationFn: (input: { event: ReviewEvent; body: string }) =>
+ jsonFetch(runPath("/review/submit"), jsonRequest("POST", input)),
+ onSuccess: invalidateGitHub,
+ }),
+ discardReview: useMutation({
+ mutationFn: () => jsonFetch(runPath("/review/discard"), jsonRequest("POST")),
+ onSuccess: invalidateGitHub,
+ }),
+ replyGitHub: useMutation({
+ mutationFn: (input: { threadNodeId: string; body: string; pending: boolean }) =>
+ jsonFetch(runPath("/review/reply"), jsonRequest("POST", input)),
+ onSuccess: invalidateGitHub,
+ }),
+ editGitHubComment: useMutation({
+ mutationFn: (input: { nodeId: string; body: string }) =>
+ jsonFetch(runPath("/review/comment/edit"), jsonRequest("POST", input)),
+ onSuccess: invalidateGitHub,
+ }),
+ deleteGitHubComment: useMutation({
+ mutationFn: (nodeId: string) =>
+ jsonFetch(runPath("/review/comment/delete"), jsonRequest("POST", { nodeId })),
+ onSuccess: invalidateGitHub,
+ }),
+ resolveGitHub: useMutation({
+ mutationFn: (input: { threadNodeId: string; resolved: boolean }) =>
+ jsonFetch(runPath("/review/resolve"), jsonRequest("POST", input)),
+ onSuccess: invalidateGitHub,
+ }),
+ };
+
+ return {
+ threads,
+ threadsByFile,
+ github: data?.github ?? GITHUB_REVIEW_STATUS.NONE,
+ pendingCommentCount: data?.pendingCommentCount ?? 0,
+ hasPendingReview: data?.hasPendingReview ?? false,
+ isOwnPullRequest: data?.isOwnPullRequest ?? false,
+ canPushToReview: data?.canPushToReview ?? false,
+ isLoading,
+ error,
+ createLocalThread: m.createLocalThread.mutateAsync,
+ createPendingComment: async (i) => void (await m.createPendingComment.mutateAsync(i)),
+ replyLocal: async (i) => void (await m.replyLocal.mutateAsync(i)),
+ editLocalComment: async (i) => void (await m.editLocalComment.mutateAsync(i)),
+ deleteLocalThread: async (id) => void (await m.deleteLocalThread.mutateAsync(id)),
+ deleteLocalComment: async (id) => void (await m.deleteLocalComment.mutateAsync(id)),
+ resolveLocalThread: async (i) => void (await m.resolveLocalThread.mutateAsync(i)),
+ addToReview: async (id) => void (await m.addToReview.mutateAsync(id)),
+ submitReview: async (i) => void (await m.submitReview.mutateAsync(i)),
+ discardReview: async () => void (await m.discardReview.mutateAsync()),
+ replyGitHub: async (i) => void (await m.replyGitHub.mutateAsync(i)),
+ editGitHubComment: async (i) => void (await m.editGitHubComment.mutateAsync(i)),
+ deleteGitHubComment: async (id) => void (await m.deleteGitHubComment.mutateAsync(id)),
+ resolveGitHub: async (i) => void (await m.resolveGitHub.mutateAsync(i)),
+ };
+}
+
+function groupByFile(threads: ReviewThread[]): ReadonlyMap {
+ const map = new Map();
+ for (const thread of threads) {
+ const list = map.get(thread.filePath);
+ if (list) list.push(thread);
+ else map.set(thread.filePath, [thread]);
+ }
+ return map;
+}
diff --git a/packages/web/src/lib/use-view-state.ts b/packages/web/src/lib/use-view-state.ts
index fa4e402..7cdab78 100644
--- a/packages/web/src/lib/use-view-state.ts
+++ b/packages/web/src/lib/use-view-state.ts
@@ -26,15 +26,33 @@ export function viewStateQueryKey(runId: string): readonly unknown[] {
export async function jsonFetch(url: string, init?: RequestInit): Promise {
const res = await fetch(url, init);
+ // POST/DELETE handlers (and error responses) can return an empty body — read as
+ // text first so JSON.parse doesn't throw SyntaxError on `""`.
+ const text = await res.text();
if (!res.ok) {
- throw new Error(`${init?.method ?? "GET"} ${url} failed: ${res.status}`);
+ // Surface the server's `{ error }` message verbatim (the review/write paths
+ // carry actionable reasons), but tolerate a non-JSON error body (e.g. an HTML
+ // proxy error) — fall back to the status code rather than throwing SyntaxError.
+ throw new Error(
+ errorBodyMessage(text) ?? `${init?.method ?? "GET"} ${url} failed: ${res.status}`,
+ );
}
- // POST/DELETE handlers can return an empty body — read as text first so
- // JSON.parse doesn't throw SyntaxError on `""`.
- const text = await res.text();
return (text ? JSON.parse(text) : {}) as T;
}
+function errorBodyMessage(text: string): string | null {
+ if (!text) return null;
+ try {
+ const parsed: unknown = JSON.parse(text);
+ if (typeof parsed === "object" && parsed !== null && "error" in parsed) {
+ return String((parsed as { error: unknown }).error);
+ }
+ } catch {
+ // Non-JSON error body — let the caller fall back to the status code.
+ }
+ return null;
+}
+
async function fetchViewState(runId: string): Promise {
// Parse at the boundary so a server-side schema drift surfaces as a query
// error here, not as a render crash deeper in the component tree.
diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx
index 5b3fad2..37b4697 100644
--- a/packages/web/src/routes/pull-request-layout.tsx
+++ b/packages/web/src/routes/pull-request-layout.tsx
@@ -4,6 +4,7 @@ import { type CSSProperties, useCallback, useMemo, useRef, useState } from "reac
import { DiffSettingsForm } from "@/components/diff/diff-settings-form";
import { PullRequestHeader } from "@/components/pull-request/pull-request-header";
import { PullRequestHeaderSkeleton } from "@/components/pull-request/pull-request-header-skeleton";
+import { ReviewPanel } from "@/components/pull-request/review-panel";
import { SectionLabel } from "@/components/pull-request/section-label";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -279,6 +280,7 @@ export function PullRequestLayout({ runId }: { runId: string }) {