From 9d472916af4144763f281932eea8a633d2e1b903 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:07:52 +0200 Subject: [PATCH 1/4] feat(web): resolve review threads from the Summary and Timeline tabs Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PullRequestResolveThreadButton.tsx | 76 +++++++++++++++++++ .../pullRequest/PullRequestSummaryTab.tsx | 30 ++++++++ .../pullRequest/PullRequestTimelineTab.tsx | 29 +++++++ 3 files changed, 135 insertions(+) create mode 100644 apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx new file mode 100644 index 0000000000000..1cfd50ffbd165 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx @@ -0,0 +1,76 @@ +import type { + EnvironmentId, + PullRequestDetailView, + PullRequestRef, + PullRequestReviewThread, +} from "@t3tools/contracts"; +import { useState } from "react"; + +import { useAtomCommand } from "~/state/use-atom-command"; +import { pullRequestEnvironment } from "~/state/pullRequests"; + +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; + +/** + * Resolve or unresolve a review thread from a conversation entry rather than from its line in the + * Code tab. Callers render it on the thread's first comment only, so a thread with five replies + * offers one control, not five. Renders nothing where the host or this account cannot resolve. + */ +export function PullRequestResolveThreadButton({ + environmentId, + reference, + detail, + thread, + className, + onRefresh, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + detail: PullRequestDetailView; + thread: PullRequestReviewThread; + className?: string | undefined; + onRefresh: () => void; +}) { + const [pending, setPending] = useState(false); + const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, { + reportFailure: false, + }); + if (!detail.capabilities.review.resolve || !detail.viewerPermissions.resolve) return null; + + const toggle = async () => { + if (pending) return; + setPending(true); + const result = await setThreadResolution({ + environmentId, + input: { ...reference, threadId: thread.id, resolved: !thread.isResolved }, + }); + setPending(false); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "The conversation could not be updated" }); + return; + } + onRefresh(); + }; + + return ( + + ); +} + +/** The thread a comment opens, or null for replies and for remarks on no thread at all. */ +export function pullRequestThreadOpenedBy( + threadByCommentId: ReadonlyMap, + commentId: string, +): PullRequestReviewThread | null { + const thread = threadByCommentId.get(commentId); + return thread?.comments[0]?.id === commentId ? thread : null; +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index bfda1209f9cbe..15b22ee37d7d4 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -68,6 +68,10 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestCommentBody } from "./PullRequestCommentBody"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; +import { + PullRequestResolveThreadButton, + pullRequestThreadOpenedBy, +} from "./PullRequestResolveThreadButton"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; @@ -207,6 +211,7 @@ function CollapsedComment({ label, body, reactionBar, + resolveButton, detail, thread, }: { @@ -216,6 +221,7 @@ function CollapsedComment({ /** Null where the remark is nothing but its verdict, which a dismissal usually is. */ body: string | null; reactionBar: ReactNode; + resolveButton: ReactNode; detail: PullRequestDetailView; thread: PullRequestReviewThread | undefined; }) { @@ -237,6 +243,7 @@ function CollapsedComment({ className={cn("size-3.5 transition-transform", open && "rotate-180")} /> + {resolveButton} {reactionBar} @@ -562,6 +569,7 @@ export function PullRequestSummaryTab({ const renderComment = (comment: PullRequestComment) => { const thread = threadByCommentId.get(comment.id); + const openedThread = pullRequestThreadOpenedBy(threadByCommentId, comment.id); const body = visibleBody(comment.body); if (isFinished(comment)) { return ( @@ -573,6 +581,18 @@ export function PullRequestSummaryTab({ thread={thread} label={thread?.isResolved ? "Resolved" : "Review dismissed"} body={body} + resolveButton={ + openedThread ? ( + + ) : null + } reactionBar={ ) : null} + {openedThread ? ( + + ) : null} {reactionBar}
diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 69421fefbbcec..43722a3c60f99 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -38,6 +38,10 @@ import { import { canEditPullRequestComment } from "./pullRequestEditing.logic"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; +import { + PullRequestResolveThreadButton, + pullRequestThreadOpenedBy, +} from "./PullRequestResolveThreadButton"; import { PullRequestReactionBar } from "./PullRequestReactions"; import { PullRequestActorAvatar, @@ -177,6 +181,7 @@ function OpenOnHostButton({ url, onOpen }: { url: string | null; onOpen: (url: s function ConversationCard({ event, editable, + resolveButton, cwd, onOpen, reactions, @@ -184,6 +189,7 @@ function ConversationCard({ event: PullRequestTimelineEvent; /** The remark behind this entry, only where this reader may rewrite it. */ editable: PullRequestComment | null; + resolveButton: ReactNode; cwd: string; onOpen: (url: string) => void; reactions: ReactionSurface; @@ -239,6 +245,7 @@ function ConversationCard({ onClick={() => setEditing(true)} /> ) : null} + {resolveButton} {reactions.canReact || event.reactions.length > 0 ? ( ; editable: ReadonlyMap; + renderResolveButton: (commentId: string) => ReactNode; cwd: string; onOpen: (url: string) => void; reactions: ReactionSurface; @@ -352,6 +361,7 @@ function ConversationGroup({ key={`${reactions.reference.projectId}#${reactions.reference.number}:${event.id}`} event={event} editable={editable.get(event.id) ?? null} + resolveButton={renderResolveButton(event.id)} cwd={cwd} onOpen={onOpen} reactions={reactions} @@ -570,6 +580,24 @@ export function PullRequestTimelineTab({ .filter((comment) => canEditPullRequestComment(detail, comment)) .map((comment) => [comment.id, comment] as const), ); + const threadByCommentId = new Map( + detail.reviewThreads.flatMap((thread) => + thread.comments.map((comment) => [comment.id, thread] as const), + ), + ); + const renderResolveButton = (commentId: string) => { + const thread = pullRequestThreadOpenedBy(threadByCommentId, commentId); + return thread ? ( + + ) : null; + }; const orderedEvents = order === "newest" ? events : events.toReversed(); const rows = groupPullRequestTimelineConversations(orderedEvents); const openOnHost = (url: string) => { @@ -588,6 +616,7 @@ export function PullRequestTimelineTab({ key={`comments:${row.events[0]?.id ?? "empty"}`} events={row.events} editable={editable} + renderResolveButton={renderResolveButton} cwd={detail.workspaceRoot} onOpen={openOnHost} reactions={reactions} From 510a7123f4e5b6b55667f58c02c24df8a4415b2b Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:29:39 +0200 Subject: [PATCH 2/4] fix(web): resolve button stays disabled until the thread shows its new state Co-Authored-By: Claude Opus 5.5 (1M context) --- .../pullRequest/PullRequestResolveThreadButton.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx index 1cfd50ffbd165..f704af2759e67 100644 --- a/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx +++ b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx @@ -32,7 +32,11 @@ export function PullRequestResolveThreadButton({ className?: string | undefined; onRefresh: () => void; }) { - const [pending, setPending] = useState(false); + // The state asked for, held until the refreshed thread shows it, so the button stays disabled + // through the refetch instead of re-enabling on the old label. + const [requested, setRequested] = useState(null); + if (requested !== null && thread.isResolved === requested) setRequested(null); + const pending = requested !== null; const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, { reportFailure: false, }); @@ -40,13 +44,14 @@ export function PullRequestResolveThreadButton({ const toggle = async () => { if (pending) return; - setPending(true); + const resolved = !thread.isResolved; + setRequested(resolved); const result = await setThreadResolution({ environmentId, - input: { ...reference, threadId: thread.id, resolved: !thread.isResolved }, + input: { ...reference, threadId: thread.id, resolved }, }); - setPending(false); if (result._tag === "Failure") { + setRequested(null); toastManager.add({ type: "error", title: "The conversation could not be updated" }); return; } From a0811b20c0153837e3f830ed195706ddf32a56ea Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:04:43 +0200 Subject: [PATCH 3/4] fix(web): a stale refresh no longer leaves the resolve button disabled Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PullRequestResolveThreadButton.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx index f704af2759e67..e103eb6864957 100644 --- a/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx +++ b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx @@ -32,10 +32,21 @@ export function PullRequestResolveThreadButton({ className?: string | undefined; onRefresh: () => void; }) { - // The state asked for, held until the refreshed thread shows it, so the button stays disabled - // through the refetch instead of re-enabling on the old label. - const [requested, setRequested] = useState(null); - if (requested !== null && thread.isResolved === requested) setRequested(null); + // The state asked for, held until the thread shows it so the button stays disabled through the + // refetch instead of re-enabling on the old label. `sent` is the thread as it was when the update + // succeeded: any refreshed copy also ends the wait, so a refetch that still reports the old + // state cannot leave the button disabled. + const [requested, setRequested] = useState<{ + readonly resolved: boolean; + readonly sent: PullRequestReviewThread | null; + } | null>(null); + if ( + requested !== null && + (thread.isResolved === requested.resolved || + (requested.sent !== null && thread !== requested.sent)) + ) { + setRequested(null); + } const pending = requested !== null; const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, { reportFailure: false, @@ -45,7 +56,7 @@ export function PullRequestResolveThreadButton({ const toggle = async () => { if (pending) return; const resolved = !thread.isResolved; - setRequested(resolved); + setRequested({ resolved, sent: null }); const result = await setThreadResolution({ environmentId, input: { ...reference, threadId: thread.id, resolved }, @@ -55,6 +66,7 @@ export function PullRequestResolveThreadButton({ toastManager.add({ type: "error", title: "The conversation could not be updated" }); return; } + setRequested({ resolved, sent: thread }); onRefresh(); }; From b57340289ce2a53b3b78a8d12b768535dd8105e0 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:35:07 +0200 Subject: [PATCH 4/4] fix(web): resolve button respects per-thread resolve permission Co-Authored-By: Claude Opus 5.5 --- .../pullRequest/PullRequestResolveThreadButton.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx index e103eb6864957..b22a4b870269e 100644 --- a/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx +++ b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx @@ -11,11 +11,13 @@ import { pullRequestEnvironment } from "~/state/pullRequests"; import { Button } from "../ui/button"; import { toastManager } from "../ui/toast"; +import { canResolvePullRequestThread } from "./pullRequestEditing.logic"; /** * Resolve or unresolve a review thread from a conversation entry rather than from its line in the * Code tab. Callers render it on the thread's first comment only, so a thread with five replies - * offers one control, not five. Renders nothing where the host or this account cannot resolve. + * offers one control, not five. Renders nothing where the host or this account cannot resolve the + * thread. */ export function PullRequestResolveThreadButton({ environmentId, @@ -51,7 +53,8 @@ export function PullRequestResolveThreadButton({ const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, { reportFailure: false, }); - if (!detail.capabilities.review.resolve || !detail.viewerPermissions.resolve) return null; + if (!detail.capabilities.review.resolve || !canResolvePullRequestThread(detail, thread)) + return null; const toggle = async () => { if (pending) return;