diff --git a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx index cf0ff1ab468c2..cfe02c07c2319 100644 --- a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx @@ -17,6 +17,9 @@ import { import { pullRequestReviewKey } from "./pullRequestReviewStore"; import { useInlineActionConfirm } from "./useInlineActionConfirm"; +/** A fresh object each time, so quoting the same comment twice still lands. */ +export type PullRequestQuoteRequest = { readonly text: string }; + export function PullRequestCommentForm({ environmentId, active, @@ -24,9 +27,11 @@ export function PullRequestCommentForm({ detail, actionPending, textareaRef, + quoteRequest, onCommentAction, onCommented, onClose, + onQuoted, }: { environmentId: EnvironmentId; /** False while the form stays mounted but hidden; dictation is cancelled then. */ @@ -35,16 +40,45 @@ export function PullRequestCommentForm({ detail: PullRequestDetailView; actionPending: boolean; textareaRef: RefObject; + quoteRequest?: PullRequestQuoteRequest | undefined; onCommentAction: ( body: string, action: "close" | "reopen", ) => Promise<{ readonly commentPosted: boolean }>; onCommented: () => void; onClose: () => void; + /** A quote deferred past a successful post lands after `onClose`, so the form asks to be shown again. */ + onQuoted: () => void; }) { const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); const locked = submitting !== null || actionPending; + const [answeredQuoteRequest, setAnsweredQuoteRequest] = useState(quoteRequest); + const [pendingQuotes, setPendingQuotes] = useState>([]); + const [caretAfterQuote, setCaretAfterQuote] = useState(false); + let quotes = pendingQuotes; + if (quoteRequest !== answeredQuoteRequest) { + setAnsweredQuoteRequest(quoteRequest); + if (quoteRequest) quotes = [...quotes, quoteRequest.text]; + } + // A successful post clears the draft, so quotes wait until posting settles. + if (quotes.length > 0 && !locked) { + setPendingQuotes([]); + setBody((draft) => [draft.trimEnd(), ...quotes].filter((part) => part.length > 0).join("\n\n")); + setCaretAfterQuote(true); + } else if (quotes !== pendingQuotes) { + setPendingQuotes(quotes); + } + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals the composer and moves the DOM caret after a quote lands + useEffect(() => { + const textarea = textareaRef.current; + if (!caretAfterQuote || !textarea) return; + setCaretAfterQuote(false); + onQuoted(); + textarea.focus({ preventScroll: true }); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.scrollTop = textarea.scrollHeight; + }, [caretAfterQuote, textareaRef, onQuoted]); const speech = useTextFieldSpeech({ environmentId, projectId: reference.projectId, diff --git a/apps/web/src/components/pullRequest/PullRequestComposer.tsx b/apps/web/src/components/pullRequest/PullRequestComposer.tsx index f954c2b262dee..f604a0695d3bd 100644 --- a/apps/web/src/components/pullRequest/PullRequestComposer.tsx +++ b/apps/web/src/components/pullRequest/PullRequestComposer.tsx @@ -9,7 +9,7 @@ import { useRef, useState } from "react"; import { Button } from "../ui/button"; import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; -import { PullRequestCommentForm } from "./PullRequestCommentForm"; +import { PullRequestCommentForm, type PullRequestQuoteRequest } from "./PullRequestCommentForm"; import { PullRequestReviewForm } from "./PullRequestReviewForm"; import { pullRequestReviewKey, @@ -27,6 +27,7 @@ export function PullRequestComposer({ onCommented, onReviewSubmitted, openReviewRequest, + quoteRequest, }: { environmentId: EnvironmentId; reference: PullRequestRef; @@ -39,6 +40,7 @@ export function PullRequestComposer({ onCommented: () => void; onReviewSubmitted: () => void; openReviewRequest?: number | undefined; + quoteRequest?: PullRequestQuoteRequest | undefined; }) { const term = sourceControlClients.get(detail.provider).changeRequest.singular; const [open, setOpen] = useState(false); @@ -60,6 +62,12 @@ export function PullRequestComposer({ setRequestedMode("review"); setOpen(true); } + const [answeredQuoteRequest, setAnsweredQuoteRequest] = useState(quoteRequest); + if (quoteRequest !== answeredQuoteRequest) { + setAnsweredQuoteRequest(quoteRequest); + setRequestedMode("comment"); + setOpen(true); + } const canComment = detail.capabilities.comment && detail.viewerPermissions.comment; const verdicts = detail.capabilities.review.verdicts.filter((verdict) => @@ -180,9 +188,14 @@ export function PullRequestComposer({ detail={detail} actionPending={actionPending} textareaRef={commentRef} + quoteRequest={quoteRequest} onCommentAction={onCommentAction} onCommented={onCommented} onClose={() => setOpen(false)} + onQuoted={() => { + setRequestedMode("comment"); + setOpen(true); + }} /> ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 6e4a2bb6fb6a0..e49a3203fa627 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -114,6 +114,7 @@ import { openOnHostLabel } from "./pullRequestLinkContextMenu"; import { PullRequestMarkdownContext } from "./PullRequestMarkdown"; import { pullRequestMarkdownPlugins } from "./pullRequestMarkdown.logic"; import { PullRequestComposer } from "./PullRequestComposer"; +import type { PullRequestQuoteRequest } from "./PullRequestCommentForm"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; import { @@ -376,6 +377,7 @@ export function PullRequestDetailPanel({ useEnvironmentQuery(pullRequestEnvironment.changeBreakdown({ environmentId, input: reference })); const [threadPickerOpen, setThreadPickerOpen] = useState(false); const [openReviewRequest, setOpenReviewRequest] = useState(0); + const [quoteRequest, setQuoteRequest] = useState(); const { canWriteSourceControl, detailQuery, @@ -1889,6 +1891,9 @@ export function PullRequestDetailPanel({ fixCheckLabel={handoffLabels.fixCheck} {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} {...(attachTarget === null ? {} : { onAddToChat: addCommentToChat })} + {...(detail.capabilities.comment && detail.viewerPermissions.comment + ? { onQuoteReply: (text: string) => setQuoteRequest({ text }) } + : {})} onRefresh={refreshDetail} onRefreshChecks={refreshFromHost} /> @@ -2005,6 +2010,7 @@ export function PullRequestDetailPanel({ onCommented={refreshDetail} onReviewSubmitted={refreshDetail} openReviewRequest={openReviewRequest} + quoteRequest={quoteRequest} /> ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 1b95a1a563aab..955d72f0fcf18 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -36,7 +36,11 @@ import { PullRequestActorAvatar, PullRequestActorLabel } from "./pullRequestPres import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; -import type { PendingReviewComment } from "./pullRequestReviewStore"; +import { + pullRequestThreadReplyKey, + usePullRequestReviewStore, + type PendingReviewComment, +} from "./pullRequestReviewStore"; import { PullRequestMarkdownTextarea, type PullRequestCompletionScope, @@ -225,12 +229,8 @@ export function ReviewThreadCard({ hidden?: boolean; }) { const [expanded, setExpanded] = useState(hidden !== undefined || !thread.isResolved); - const [replying, setReplying] = useState(false); - const [reply, setReply] = useState(""); const [editingId, setEditingId] = useState(null); const [savingEdit, setSavingEdit] = useState(false); - const sendingRef = useRef(false); - const replyRef = useRef(null); const [loadedPage, setLoadedPage] = useState< (PullRequestThreadCommentsResult & { readonly threadId: string }) | null >(null); @@ -259,15 +259,8 @@ export function ReviewThreadCard({ } }; - const send = async (text: string) => { - const trimmed = text.trim(); - if (trimmed.length === 0 || pending || sendingRef.current) return; - sendingRef.current = true; - const result = await settlePromise(() => onReply(trimmed)); - sendingRef.current = false; - if (result._tag === "Failure") throw squashAtomCommandFailure(result); - if (!result.value) return; - // Replies return no comment; retain loaded data and reopen pagination for a later read. + // Replies return no comment; retain loaded data and reopen pagination for a later read. + const keepLoadedPage = () => setLoadedPage((previous) => previous?.threadId === thread.id ? { @@ -276,9 +269,6 @@ export function ReviewThreadCard({ } : previous, ); - setReply(""); - setReplying(false); - }; const loadMore = async () => { if (nextCommentsCursor === null || loadingMore) return; setLoadingMore(true); @@ -420,67 +410,127 @@ export function ReviewThreadCard({ ) : null} - - {canReply ? ( - replying ? ( - - {(speech) => ( -
- speech.submitAfterDictation((text) => void send(text)), - onCancel: () => - speech.state.phase === "idle" ? setReplying(false) : speech.cancel(), - })} - /> -
- - - -
-
- )} -
- ) : ( - - ) - ) : null} ) : null} + {/* Stays mounted while collapsed so an unsent reply survives. */} + {canReply ? ( + + ) : null} ); } + +export function PullRequestThreadReplyForm({ + environmentId, + reference, + threadId, + completionScope, + pending, + className, + onReply, + onSent, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + threadId: string; + completionScope: PullRequestCompletionScope | null; + pending: boolean; + className?: string; + onReply: (body: string) => Promise; + onSent?: () => void; +}) { + // Resolving, reopening, or filtering remounts the form, so the draft lives in the store. + const replyKey = pullRequestThreadReplyKey(reference, threadId); + const reply = usePullRequestReviewStore((store) => store.replies[replyKey] ?? ""); + const storeReply = usePullRequestReviewStore((store) => store.setReply); + const clearReply = usePullRequestReviewStore((store) => store.clearReply); + const setReply = (next: string) => storeReply(replyKey, next); + const [replying, setReplying] = useState(reply.length > 0); + const sendingRef = useRef(false); + const replyRef = useRef(null); + + const send = async (text: string) => { + const trimmed = text.trim(); + if (trimmed.length === 0 || pending || sendingRef.current) return; + sendingRef.current = true; + const result = await settlePromise(() => onReply(trimmed)); + sendingRef.current = false; + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + if (!result.value) return; + onSent?.(); + clearReply(replyKey, trimmed); + setReplying(false); + }; + + if (!replying) { + return ( +
+ +
+ ); + } + return ( + + {(speech) => ( +
+ speech.submitAfterDictation((text) => void send(text)), + onCancel: () => + speech.state.phase === "idle" ? setReplying(false) : speech.cancel(), + })} + /> +
+ + + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index 8ecb158a8f2d0..22d7b3dfa2805 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -4,6 +4,7 @@ import { type ClientSettings, } from "@t3tools/contracts"; import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts"; +import { createRef } from "react"; import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; @@ -54,6 +55,7 @@ function setup() { } import { act, render as renderUi } from "../../../test/render"; +import { PullRequestCommentForm, type PullRequestQuoteRequest } from "./PullRequestCommentForm"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; const detail: PullRequestDetailView = { @@ -202,6 +204,52 @@ it("leaves description checkboxes read-only without the right to edit it", async ); }); +it("keeps an unsent reply when a resolved conversation is collapsed and reopened", async () => { + setup(); + const note = { + author: { login: "reviewer", name: null, avatarUrl: null }, + createdAt: "2026-09-01T00:00:00Z", + url: null, + }; + const body = "Rename this helper"; + const filterKey = "t3code:pull-request-comment-filter:v1"; + localStorage.setItem(filterKey, JSON.stringify({ bots: false, resolved: true })); + using _filter = { [Symbol.dispose]: () => localStorage.removeItem(filterKey) }; + await using screen = await render({ + ...detail, + capabilities: { + ...detail.capabilities, + review: { ...detail.capabilities.review, reply: true }, + }, + comments: [ + { ...note, id: "10", kind: "review-comment", body, path: "src/a.ts", reviewState: null }, + ], + reviewThreads: [ + { + id: "thread", + path: "src/a.ts", + line: 3, + side: "right", + isResolved: true, + isOutdated: false, + comments: [{ ...note, id: "10", body }], + }, + ], + }); + const status = screen.getByRole("button", { name: "Resolved" }); + + await userEvent.click(status); + await userEvent.click(screen.getByRole("button", { name: "Reply" })); + await userEvent.type(screen.getByLabelText("Reply to this conversation"), "Unsent reply"); + await userEvent.click(status); + await expect.element(screen.getByLabelText("Reply to this conversation")).not.toBeVisible(); + await userEvent.click(status); + + await expect + .element(screen.getByLabelText("Reply to this conversation")) + .toHaveValue("Unsent reply"); +}); + it("fixes an overview thread as its opening remark, since it has no line to attach", async () => { setup(); const author = { login: "reviewer", name: null, avatarUrl: null }; @@ -270,3 +318,26 @@ it("shows a comment's exact time in the chosen 24-hour clock", async () => { expect(document.querySelector('[data-slot="tooltip-popup"]')?.textContent).toBe(expected), ); }); + +it("adds a quote that arrives as posting settles along with the ones queued before it", async () => { + setup(); + const form = (actionPending: boolean, quoteRequest: PullRequestQuoteRequest) => ( + + ); + await using screen = await renderUi(form(true, { text: "> first" })); + await screen.rerender(form(true, { text: "> second" })); + await screen.rerender(form(false, { text: "> third" })); + await expect.element(screen.getByRole("textbox")).toHaveValue("> second\n\n> third"); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 15e0769a700da..bccc8a1b9457e 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -23,6 +23,7 @@ import { HammerIcon, ListFilterIcon, MessageSquarePlusIcon, + ReplyIcon, TagIcon, UsersIcon, } from "lucide-react"; @@ -73,6 +74,7 @@ import { pullRequestConversationItemComments, pullRequestConversationItems, pullRequestFindingKey, + pullRequestQuoteReply, pullRequestReviewOutcome, visibleBody, type PullRequestChatSubject, @@ -99,6 +101,7 @@ import { import { PullRequestReactionBar } from "./PullRequestReactions"; import { canReactPullRequestComment } from "@t3tools/client-runtime/pull-request-reactions"; import { PullRequestResolveThreadButton } from "./PullRequestResolveThreadButton"; +import { PullRequestThreadReplyForm } from "./PullRequestReviewAnnotation"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; @@ -270,6 +273,7 @@ function CollapsedComment({ replyCount = 0, replies = null, moreReplies = null, + replyForm = null, onAddToChat, }: { comment: PullRequestComment; @@ -283,6 +287,7 @@ function CollapsedComment({ replyCount?: number; replies?: ReactNode; moreReplies?: ReactNode; + replyForm?: ReactNode; onAddToChat?: (() => void) | undefined; }) { const [open, setOpen] = useState(false); @@ -333,6 +338,12 @@ function CollapsedComment({ ) : null} + {/* Stays mounted while collapsed so an unsent reply survives. */} + {replyForm ? ( + + ) : null} {moreReplies ?
{moreReplies}
: null} @@ -455,6 +466,7 @@ export function PullRequestSummaryTab({ fixCheckLabel = "Fix", onFixFinding, onAddToChat, + onQuoteReply, onRefresh, onRefreshChecks = onRefresh, }: { @@ -475,6 +487,8 @@ export function PullRequestSummaryTab({ onFixFinding?: (finding: PullRequestFinding) => void; /** Absent where there is no composer beside the panel to add a remark to. */ onAddToChat?: (subject: PullRequestChatSubject) => void; + /** Absent when the viewer cannot comment; opens the composer with the comment quoted. */ + onQuoteReply?: (quote: string) => void; onRefresh: () => void; onRefreshChecks?: () => void; }) { @@ -665,6 +679,37 @@ export function PullRequestSummaryTab({ }; const completionScope = pullRequestCompletionScope(detail, environmentId, reference); + const replyToThread = useAtomCommand(pullRequestEnvironment.replyToThread, { + reportFailure: false, + }); + const [threadReplyPending, setThreadReplyPending] = useState(false); + const canReplyToThreads = detail.capabilities.review.reply && detail.viewerPermissions.comment; + const sendThreadReply = async (threadId: string, body: string) => { + if (threadReplyPending) return false; + setThreadReplyPending(true); + const result = await replyToThread({ environmentId, input: { ...reference, threadId, body } }); + setThreadReplyPending(false); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "Reply could not be posted" }); + return false; + } + onRefresh(); + return true; + }; + const threadReplyForm = (thread: PullRequestConversationThread, className?: string) => + canReplyToThreads ? ( + sendThreadReply(thread.id, body)} + /> + ) : null; const commentEditing: CommentEditing = { cwd: detail.workspaceRoot, environmentId, @@ -734,6 +779,8 @@ export function PullRequestSummaryTab({ : { kind: "thread", thread }; // A reply goes with its conversation, from the card that heads it. const chat = reply ? null : chatSubject(comment, thread); + // Review threads take real replies; anything else can only be answered by quoting it. + const canQuote = onQuoteReply && !reply && thread === undefined && body !== null; return (
onAddToChat(chat)} /> ) : null} + {onQuoteReply && canQuote ? ( + + ) : null} {reactionBarFor(comment)} {reply ? null : ( @@ -809,6 +881,7 @@ export function PullRequestSummaryTab({ replyCount={item.kind === "thread" ? item.replies.length + item.moreReplies : 0} replies={replies} moreReplies={moreReplies} + replyForm={thread ? threadReplyForm(thread, "mt-0") : null} onAddToChat={onAddToChat && chat !== null ? () => onAddToChat(chat) : undefined} reactionBar={reactionBarFor(comment)} resolveButton={resolveButtonFor(thread)} @@ -821,6 +894,7 @@ export function PullRequestSummaryTab({ {renderCommentCard(comment, thread)} {replies} {moreReplies} + {thread ? threadReplyForm(thread, "mt-0") : null} ); }; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index b7c31f9a1c3c1..009487ab9eb73 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -53,6 +53,7 @@ import { pullRequestLinkSubject, writePullRequestDetailSnapshot, readableFailure, + pullRequestQuoteReply, } from "./pullRequestDetail.logic"; import type { ReviewCommentContext } from "~/reviewCommentContext"; @@ -545,6 +546,39 @@ describe("review thread conversations", () => { comments: [lastReply, { ...finding, body: "Stale finding" }, reply], }; + it("quotes a comment without its hidden markup and mentions a bot by its handle", () => { + expect( + pullRequestQuoteReply({ + ...finding, + body: "\r\nFirst line\r\n\r\nSecond line", + }), + ).toBe("> First line\n>\n> Second line\n\n@review "); + expect(pullRequestQuoteReply({ ...loose, author: null, body: "Hi" })).toBe("> Hi\n\n"); + expect(pullRequestQuoteReply({ ...loose, author: null }, "\n picked\n\tlines \n")).toBe( + "> \u00a0\u00a0picked\n> \u00a0\u00a0\u00a0\u00a0lines\n\n", + ); + expect(pullRequestQuoteReply({ ...loose, author: null }, "©")).toBe("> \\©\n\n"); + expect(pullRequestQuoteReply({ ...loose, author: null }, "")).toBe( + "> \\<\\!\\-\\- keep \\-\\-\\>\n\n", + ); + expect(pullRequestQuoteReply({ ...loose, author: null }, "# Heading")).toBe( + "> \\# Heading\n\n", + ); + expect( + pullRequestQuoteReply({ + ...loose, + author: null, + body: "Use ``:\n```html\n\n```", + }), + ).toBe("> Use ``:\n> ```html\n> \n> ```\n\n"); + expect( + pullRequestQuoteReply({ ...loose, author: null, body: "~~~html\n\n~~~" }), + ).toBe("> ~~~html\n> \n> ~~~\n\n"); + expect( + pullRequestQuoteReply({ ...loose, author: null, body: "Not code \\`\\`" }), + ).toBe("> Not code \\`\\`\n\n"); + }); + it("keeps a timeline finding and its replies in one row at the finding time", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 569d987fe3080..fe329992f6f08 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -529,6 +529,31 @@ export function visibleBody(body: string): string | null { return body.replace(//gu, "").trim().length === 0 ? null : body.trim(); } +/** Quotes `selection` when the reader picked part of the comment, else the whole body. */ +export function pullRequestQuoteReply(comment: PullRequestComment, selection?: string): string { + // Selected text is what the reader saw, so it is escaped to render literally. Blockquotes + // drop leading spaces, so its indentation becomes non-breaking spaces. + const selected = selection + ?.replace(/^\s*\n|\s+$/gu, "") + .replace(/[\\`*_[\]{}()#+.!|~<>&-]/gu, "\\$&") + .replace(/^[ \t]+/gmu, (indent) => indent.replace(/\t/gu, " ").replace(/ /gu, "\u00a0")); + const body = + selected || + comment.body + .replace( + /(\\[\s\S]|```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)|/gu, + (_, kept?: string) => kept ?? "", + ) + .trim(); + const quote = body + .split(/\r?\n/u) + .map((line) => (line.trim().length === 0 ? ">" : `> ${line}`)) + .join("\n"); + // GitHub bot logins carry a "[bot]" suffix that does not resolve as a mention. + const login = comment.author?.login.replace(/\[bot\]$/u, ""); + return `${quote}\n\n${login ? `@${login} ` : ""}`; +} + function commentTimelineEvent(comment: PullRequestComment): PullRequestTimelineEvent { return { comment, diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts index 2ba71f34eb080..9772ed785fdd2 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts @@ -54,12 +54,18 @@ describe("pull request review drafts", () => { expect(usePullRequestReviewStore.getState().summaries[publicKey]).toBe("Public review"); }); - it("does not clear a summary revised while submission is in flight", () => { + it("does not clear a summary or reply revised while submission is in flight", () => { const store = usePullRequestReviewStore.getState(); store.setSummary("review-a", "Submitted body"); usePullRequestReviewStore.getState().setSummary("review-a", "Revised body"); usePullRequestReviewStore.getState().clearSummary("review-a", "Submitted body"); expect(usePullRequestReviewStore.getState().summaries["review-a"]).toBe("Revised body"); + + store.setReply("reply-a", "Revised reply"); + store.clearReply("reply-a", "Sent reply"); + expect(usePullRequestReviewStore.getState().replies["reply-a"]).toBe("Revised reply"); + store.clearReply("reply-a", "Revised reply"); + expect(usePullRequestReviewStore.getState().replies["reply-a"]).toBeUndefined(); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts index d48695ed8f5a2..03e0dc4ede877 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts @@ -23,12 +23,16 @@ export function pullRequestReviewKey(reference: PullRequestRef): string { interface PullRequestReviewStoreState { readonly drafts: Readonly>>; readonly summaries: Readonly>; + /** Unsent thread replies, so a reply form that remounts elsewhere reads the live draft. */ + readonly replies: Readonly>; readonly addComment: (key: string, comment: PendingReviewComment) => void; readonly removeComment: (key: string, commentId: string) => void; readonly removeComments: (key: string, commentIds: ReadonlyArray) => void; readonly clear: (key: string) => void; readonly setSummary: (key: string, body: string) => void; readonly clearSummary: (key: string, submittedBody: string) => void; + readonly setReply: (key: string, body: string) => void; + readonly clearReply: (key: string, submittedBody: string) => void; } const EMPTY: ReadonlyArray = []; @@ -36,6 +40,7 @@ const EMPTY: ReadonlyArray = []; export const usePullRequestReviewStore = create()((set) => ({ drafts: {}, summaries: {}, + replies: {}, addComment: (key, comment) => set((state) => ({ drafts: { ...state.drafts, [key]: [...(state.drafts[key] ?? EMPTY), comment] }, @@ -68,8 +73,19 @@ export const usePullRequestReviewStore = create()(( const { [key]: _removed, ...rest } = state.summaries; return { summaries: rest }; }), + setReply: (key, body) => set((state) => ({ replies: { ...state.replies, [key]: body } })), + clearReply: (key, submittedBody) => + set((state) => { + if (state.replies[key]?.trim() !== submittedBody) return state; + const { [key]: _removed, ...rest } = state.replies; + return { replies: rest }; + }), })); +export function pullRequestThreadReplyKey(reference: PullRequestRef, threadId: string): string { + return JSON.stringify([pullRequestReviewKey(reference), threadId]); +} + export function usePendingReviewComments( reference: PullRequestRef, ): ReadonlyArray {