diff --git a/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx new file mode 100644 index 000000000000..b22a4b870269 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx @@ -0,0 +1,96 @@ +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"; +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 the + * thread. + */ +export function PullRequestResolveThreadButton({ + environmentId, + reference, + detail, + thread, + className, + onRefresh, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + detail: PullRequestDetailView; + thread: PullRequestReviewThread; + className?: string | undefined; + onRefresh: () => void; +}) { + // 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, + }); + if (!detail.capabilities.review.resolve || !canResolvePullRequestThread(detail, thread)) + return null; + + const toggle = async () => { + if (pending) return; + const resolved = !thread.isResolved; + setRequested({ resolved, sent: null }); + const result = await setThreadResolution({ + environmentId, + input: { ...reference, threadId: thread.id, resolved }, + }); + if (result._tag === "Failure") { + setRequested(null); + toastManager.add({ type: "error", title: "The conversation could not be updated" }); + return; + } + setRequested({ resolved, sent: thread }); + 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 bfda1209f9cb..15b22ee37d7d 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 69421fefbbce..43722a3c60f9 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}