Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<Button
size="xs"
variant="ghost"
className={className}
disabled={pending}
onClick={() => void toggle()}
>
{thread.isResolved ? "Unresolve" : "Resolve"}
</Button>
);
}

/** The thread a comment opens, or null for replies and for remarks on no thread at all. */
export function pullRequestThreadOpenedBy(
threadByCommentId: ReadonlyMap<string, PullRequestReviewThread>,
commentId: string,
): PullRequestReviewThread | null {
const thread = threadByCommentId.get(commentId);
return thread?.comments[0]?.id === commentId ? thread : null;
}
30 changes: 30 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -207,6 +211,7 @@ function CollapsedComment({
label,
body,
reactionBar,
resolveButton,
detail,
thread,
}: {
Expand All @@ -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;
}) {
Expand All @@ -237,6 +243,7 @@ function CollapsedComment({
className={cn("size-3.5 transition-transform", open && "rotate-180")}
/>
</CollapsibleTrigger>
{resolveButton}
{reactionBar}
</div>
<CommentLocation comment={comment} thread={thread} />
Expand Down Expand Up @@ -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 (
Expand All @@ -573,6 +581,18 @@ export function PullRequestSummaryTab({
thread={thread}
label={thread?.isResolved ? "Resolved" : "Review dismissed"}
body={body}
resolveButton={
openedThread ? (
<PullRequestResolveThreadButton
environmentId={environmentId}
reference={reference}
detail={detail}
thread={openedThread}
className="shrink-0"
onRefresh={onRefresh}
/>
) : null
}
reactionBar={
<PullRequestReactionBar
className="ml-auto justify-end"
Expand Down Expand Up @@ -640,6 +660,16 @@ export function PullRequestSummaryTab({
{pendingFinding === pullRequestFindingKey(finding) ? "Preparing..." : fixFindingLabel}
</Button>
) : null}
{openedThread ? (
<PullRequestResolveThreadButton
environmentId={environmentId}
reference={reference}
detail={detail}
thread={openedThread}
className="-mt-1 shrink-0"
onRefresh={onRefresh}
/>
) : null}
{reactionBar}
</div>
<div className="px-3">
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -177,13 +181,15 @@ function OpenOnHostButton({ url, onOpen }: { url: string | null; onOpen: (url: s
function ConversationCard({
event,
editable,
resolveButton,
cwd,
onOpen,
reactions,
}: {
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;
Expand Down Expand Up @@ -239,6 +245,7 @@ function ConversationCard({
onClick={() => setEditing(true)}
/>
) : null}
{resolveButton}
{reactions.canReact || event.reactions.length > 0 ? (
<PullRequestReactionBar
className="ml-auto justify-end"
Expand Down Expand Up @@ -293,12 +300,14 @@ function uniqueConversationActors(events: ReadonlyArray<PullRequestTimelineEvent
function ConversationGroup({
events,
editable,
renderResolveButton,
cwd,
onOpen,
reactions,
}: {
events: ReadonlyArray<PullRequestTimelineEvent>;
editable: ReadonlyMap<string, PullRequestComment>;
renderResolveButton: (commentId: string) => ReactNode;
cwd: string;
onOpen: (url: string) => void;
reactions: ReactionSurface;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 ? (
<PullRequestResolveThreadButton
environmentId={environmentId}
reference={reference}
detail={detail}
thread={thread}
className="-mt-1"
onRefresh={onRefresh}
/>
) : null;
};
const orderedEvents = order === "newest" ? events : events.toReversed();
const rows = groupPullRequestTimelineConversations(orderedEvents);
const openOnHost = (url: string) => {
Expand All @@ -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}
Expand Down
Loading