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
155 changes: 113 additions & 42 deletions apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import {
HammerIcon,
MessageSquareIcon,
PencilIcon,
ReplyIcon,
SendIcon,
TagIcon,
UsersIcon,
} from "lucide-react";
import { useRef, useState, type ReactNode } from "react";
import { useRef, useState, type ReactNode, type Ref } from "react";

import { useAtomCommand } from "~/state/use-atom-command";
import { pullRequestEnvironment } from "~/state/pullRequests";
Expand All @@ -40,6 +41,7 @@ import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavai
import {
orderPullRequestComments,
pullRequestFindingKey,
quoteReplyDraft,
type PullRequestFinding,
} from "./pullRequestDetail.logic";
import {
Expand Down Expand Up @@ -96,10 +98,13 @@ interface CommentEditing {
function CommentBody({
comment,
editing,
quoteButton,
className,
}: {
comment: PullRequestComment;
editing: CommentEditing;
/** Built by the owner of the composer, so this stays a view over whatever actions it is handed. */
quoteButton?: ReactNode;
className?: string | undefined;
}) {
if (editing.editingId === comment.id) {
Expand All @@ -118,6 +123,7 @@ function CommentBody({
return (
<div className={cn("flex items-start gap-1", className)}>
<PullRequestMarkdown className="min-w-0 flex-1" text={comment.body} cwd={editing.cwd} />
{quoteButton}
{editing.canEdit(comment) ? (
<Button
size="icon-xs"
Expand All @@ -138,11 +144,13 @@ function CollapsedComment({
comment,
editing,
label,
quoteButton,
reactionBar,
}: {
comment: PullRequestComment;
editing: CommentEditing;
label: string;
quoteButton?: ReactNode;
reactionBar: ReactNode;
}) {
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -176,7 +184,12 @@ function CollapsedComment({
{comment.path}
</p>
) : null}
<CommentBody className="mt-2" comment={comment} editing={editing} />
<CommentBody
className="mt-2"
comment={comment}
editing={editing}
quoteButton={quoteButton}
/>
{reactionBar}
</div>
) : null}
Expand Down Expand Up @@ -280,58 +293,37 @@ function Section({
}

function CommentComposer({
environmentId,
detail,
onCommented,
body,
posting,
textareaRef,
onBodyChange,
onSubmit,
}: {
environmentId: EnvironmentId;
detail: PullRequestDetailView;
onCommented: () => void;
body: string;
posting: boolean;
textareaRef: Ref<HTMLTextAreaElement>;
onBodyChange: (body: string) => void;
onSubmit: () => void;
}) {
const [body, setBody] = useState("");
const [posting, setPosting] = useState(false);
const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });

const submit = async () => {
const trimmed = body.trim();
if (trimmed.length === 0 || posting) return;
setPosting(true);
const result = await postComment({
environmentId,
input: {
projectId: detail.projectId,
repository: detail.repository,
number: detail.number,
body: trimmed,
},
});
setPosting(false);
if (result._tag === "Failure") {
toastManager.add({ type: "error", title: "Could not post the comment" });
return;
}
setBody("");
onCommented();
};

return (
<div className="mt-3 space-y-2">
<Textarea
ref={textareaRef}
// Locked while posting: the body is cleared on success, which would otherwise throw
// away a new draft typed while the request was still in flight.
disabled={posting}
value={body}
rows={3}
placeholder="Leave a comment"
aria-label="Comment on this pull request"
onChange={(event) => setBody(event.target.value)}
onChange={(event) => onBodyChange(event.target.value)}
/>
<div className="flex justify-end">
<Button
size="xs"
variant="outline"
disabled={body.trim().length === 0 || posting}
onClick={() => void submit()}
onClick={onSubmit}
>
<SendIcon className="size-3.5" />
{posting ? "Posting..." : "Comment"}
Expand Down Expand Up @@ -382,6 +374,78 @@ export function PullRequestSummaryTab({
const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest");
const visibleComments = orderPullRequestComments(recentComments, commentOrder);

const canComment = detail.capabilities.comment && detail.viewerPermissions.comment;
// The draft is keyed by the pull request the same way the paging window is, so opening another
// one starts from an empty box rather than a half-written reply to a different conversation.
const [draft, setDraft] = useState({ url: detail.url, body: "" });
const draftBody = draft.url === detail.url ? draft.body : "";
const setDraftBody = (body: string) => setDraft({ url: detail.url, body });
const [posting, setPosting] = useState(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });

const submitComment = async () => {
const trimmed = draftBody.trim();
if (trimmed.length === 0 || posting) return;
setPosting(true);
const result = await postComment({
environmentId,
input: {
projectId: detail.projectId,
repository: detail.repository,
number: detail.number,
body: trimmed,
},
});
setPosting(false);
if (result._tag === "Failure") {
toastManager.add({ type: "error", title: "Could not post the comment" });
return;
}
// Cleared only if the draft still belongs to this pull request: by the time the request
// lands, the reader may already be drafting on another one.
setDraft((value) => (value.url === detail.url ? { url: value.url, body: "" } : value));
onRefresh();
};

const quoteReply = (commentBody: string) => {
setDraftBody(quoteReplyDraft(draftBody, commentBody));
// After React commits the merged draft: the cursor belongs at its end, below the quote,
// with the composer in view.
requestAnimationFrame(() => {
const element = composerRef.current;
if (element === null) return;
element.focus();
element.setSelectionRange(element.value.length, element.value.length);
element.scrollIntoView({ block: "nearest" });
});
};

// Rides beside the edit pencil on every remark, collapsed conversations included — but only
// where the composer below exists to receive the quote, and only for comments with something
// to quote: a review that is all verdict and no words would quote as an empty block. Gone
// rather than disabled while posting — the composer is locked and the success clear would eat
// the quote, and a disabled button's own opacity would defeat the hover reveal.
const quoteReplyButton = (comment: PullRequestComment): ReactNode =>
canComment && !posting && comment.body.trim().length > 0 ? (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="Quote reply"
size="icon-xs"
variant="ghost"
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => quoteReply(comment.body)}
/>
Comment thread
cursor[bot] marked this conversation as resolved.
}
>
<ReplyIcon className="size-3" />
</TooltipTrigger>
<TooltipPopup>Quote reply</TooltipPopup>
</Tooltip>
) : null;

// A comment that already lives on a review thread is that thread: the thread carries the line
// and side the bare comment has lost, and a resolved one is finished work nobody should be
// invited to fix again — the same call the whole-review hand-off makes.
Expand Down Expand Up @@ -699,6 +763,7 @@ export function PullRequestSummaryTab({
comment={comment}
editing={commentEditing}
label={thread?.isResolved ? "Resolved" : "Approval dismissed"}
quoteButton={quoteReplyButton(comment)}
reactionBar={
<PullRequestReactionBar
className="mt-2"
Expand Down Expand Up @@ -761,7 +826,12 @@ export function PullRequestSummaryTab({
{comment.path}
</p>
) : null}
<CommentBody className="mt-2" comment={comment} editing={commentEditing} />
<CommentBody
className="mt-2"
comment={comment}
editing={commentEditing}
quoteButton={quoteReplyButton(comment)}
/>
<PullRequestReactionBar
className="mt-2"
reactions={comment.reactions ?? []}
Expand All @@ -779,12 +849,13 @@ export function PullRequestSummaryTab({
</>
)}
{/* Posting is a core capability and remains usable even if the activity read failed. */}
{detail.capabilities.comment && detail.viewerPermissions.comment ? (
Comment thread
cursor[bot] marked this conversation as resolved.
{canComment ? (
<CommentComposer
key={`${environmentId}:${detail.projectId}/${detail.repository}#${detail.number}`}
environmentId={environmentId}
detail={detail}
onCommented={onRefresh}
body={draftBody}
posting={posting}
textareaRef={composerRef}
onBodyChange={setDraftBody}
onSubmit={() => void submitComment()}
/>
) : null}
</Section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
pullRequestActionNeedsHostRefresh,
pullRequestFindingKey,
pullRequestHandoffLabels,
quoteReplyDraft,
readableFailure,
resolveBaseFreshness,
buildPullRequestTimeline,
Expand Down Expand Up @@ -98,6 +99,28 @@ describe("ordering comments", () => {
});
});

describe("quote replying to a comment", () => {
it("prefixes every line and leaves an empty line for the reply to start on", () => {
expect(quoteReplyDraft("", "First point.\n\nSecond point.")).toBe(
"> First point.\n>\n> Second point.\n\n",
);
});

it("lands under an existing draft instead of replacing it", () => {
expect(quoteReplyDraft("What I typed so far.\n", "A remark.")).toBe(
"What I typed so far.\n\n> A remark.\n\n",
);
});

it("normalizes Windows line endings and surrounding whitespace from the host", () => {
expect(quoteReplyDraft("", "\r\nline one\r\nline two\r\n")).toBe("> line one\n> line two\n\n");
});

it("treats a whitespace-only draft as empty rather than stacking blank lines", () => {
expect(quoteReplyDraft(" \n", "A remark.")).toBe("> A remark.\n\n");
});
});

describe("pull request timeline", () => {
it("orders creation, commits and comments newest first", () => {
// What happened last is what the reader opening the tab is asking about.
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ export function orderPullRequestComments<T extends { readonly createdAt: string
return order === "newest" ? comments.toReversed() : comments;
}

/**
* A quote reply the way GitHub writes one: every line of the quoted comment behind "> ", then an
* empty line where the reply starts. Lands under whatever the reader already typed — quoting a
* second comment, or quoting after drafting, must never cost them the draft.
*/
export function quoteReplyDraft(draft: string, quoted: string): string {
const quote = quoted
.replace(/\r\n/gu, "\n")
.trim()
.split("\n")
.map((line) => (line.length === 0 ? ">" : `> ${line}`))
.join("\n");
return draft.trim().length === 0 ? `${quote}\n\n` : `${draft.trimEnd()}\n\n${quote}\n\n`;
}

export interface PullRequestTimelineEvent {
readonly id: string;
readonly at: string;
Expand Down
Loading