From 09b4198e93593b28981600fa4a6124c9efe1ea3b Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:09:53 +0100 Subject: [PATCH 01/10] feat(web): reply to review threads and quote comments from the PR Summary tab Co-Authored-By: Claude Opus 5.5 --- .../pullRequest/PullRequestCommentForm.tsx | 27 +++ .../pullRequest/PullRequestComposer.tsx | 11 +- .../pullRequest/PullRequestDetailPanel.tsx | 6 + .../PullRequestReviewAnnotation.tsx | 186 +++++++++++------- .../pullRequest/PullRequestSummaryTab.tsx | 69 +++++++ .../pullRequestDetail.logic.test.ts | 14 ++ .../pullRequest/pullRequestDetail.logic.ts | 13 ++ 7 files changed, 253 insertions(+), 73 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx index cf0ff1ab468c2..e5b59e7771043 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,6 +27,7 @@ export function PullRequestCommentForm({ detail, actionPending, textareaRef, + quoteRequest, onCommentAction, onCommented, onClose, @@ -35,6 +39,7 @@ export function PullRequestCommentForm({ detail: PullRequestDetailView; actionPending: boolean; textareaRef: RefObject; + quoteRequest?: PullRequestQuoteRequest | undefined; onCommentAction: ( body: string, action: "close" | "reopen", @@ -43,6 +48,28 @@ export function PullRequestCommentForm({ onClose: () => void; }) { const [body, setBody] = useState(""); + const [answeredQuoteRequest, setAnsweredQuoteRequest] = useState(quoteRequest); + const [caretAfterQuote, setCaretAfterQuote] = useState(false); + if (quoteRequest !== answeredQuoteRequest) { + setAnsweredQuoteRequest(quoteRequest); + if (quoteRequest) { + setBody((draft) => + draft.trim().length === 0 + ? quoteRequest.text + : `${draft.trimEnd()}\n\n${quoteRequest.text}`, + ); + setCaretAfterQuote(true); + } + } + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves the DOM caret after a quote the textarea just received + useEffect(() => { + const textarea = textareaRef.current; + if (!caretAfterQuote || !textarea) return; + setCaretAfterQuote(false); + textarea.focus({ preventScroll: true }); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.scrollTop = textarea.scrollHeight; + }, [caretAfterQuote, textareaRef]); const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); const locked = submitting !== null || actionPending; const speech = useTextFieldSpeech({ diff --git a/apps/web/src/components/pullRequest/PullRequestComposer.tsx b/apps/web/src/components/pullRequest/PullRequestComposer.tsx index f954c2b262dee..f15347fc0456f 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,6 +188,7 @@ export function PullRequestComposer({ detail={detail} actionPending={actionPending} textareaRef={commentRef} + quoteRequest={quoteRequest} onCommentAction={onCommentAction} onCommented={onCommented} onClose={() => setOpen(false)} 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..f32868d8712a0 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -225,12 +225,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 +255,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 +265,6 @@ export function ReviewThreadCard({ } : previous, ); - setReply(""); - setReplying(false); - }; const loadMore = async () => { if (nextCommentsCursor === null || loadingMore) return; setLoadingMore(true); @@ -422,65 +408,121 @@ export function ReviewThreadCard({ ) : null} {canReply ? ( - replying ? ( - - {(speech) => ( -
- speech.submitAfterDictation((text) => void send(text)), - onCancel: () => - speech.state.phase === "idle" ? setReplying(false) : speech.cancel(), - })} - /> -
- - - -
-
- )} -
- ) : ( - - ) + ) : null} ) : 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; +}) { + const [replying, setReplying] = useState(false); + const [reply, setReply] = useState(""); + 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?.(); + setReply(""); + 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.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 15e0769a700da..f2e2094d9f1e6 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); @@ -330,6 +335,7 @@ function CollapsedComment({ )} {replies ?
{replies}
: null} + {replyForm} ) : null} @@ -455,6 +461,7 @@ export function PullRequestSummaryTab({ fixCheckLabel = "Fix", onFixFinding, onAddToChat, + onQuoteReply, onRefresh, onRefreshChecks = onRefresh, }: { @@ -475,6 +482,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 +674,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 +774,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 +876,7 @@ export function PullRequestSummaryTab({ replyCount={item.kind === "thread" ? item.replies.length + item.moreReplies : 0} replies={replies} moreReplies={moreReplies} + replyForm={thread ? threadReplyForm(thread) : null} onAddToChat={onAddToChat && chat !== null ? () => onAddToChat(chat) : undefined} reactionBar={reactionBarFor(comment)} resolveButton={resolveButtonFor(thread)} @@ -821,6 +889,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..3ea1c8d9c7b6a 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,19 @@ 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 }, " picked\nlines ")).toBe( + "> picked\n> lines\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..1101396d2d738 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -529,6 +529,19 @@ 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 { + const quote = (selection?.trim() || comment.body) + .replace(//gu, "") + .trim() + .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, From d65940627f32379b3afc74d02813abd403a0674c Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:22:09 +0100 Subject: [PATCH 02/10] fix(web): keep reply drafts across collapse and quotes made while posting Co-Authored-By: Claude Opus 5.5 --- .../pullRequest/PullRequestCommentForm.tsx | 7 ++--- .../PullRequestReviewAnnotation.tsx | 26 ++++++++++--------- .../pullRequest/PullRequestSummaryTab.tsx | 9 +++++-- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx index e5b59e7771043..04b999354018d 100644 --- a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx @@ -48,9 +48,12 @@ export function PullRequestCommentForm({ onClose: () => 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 [caretAfterQuote, setCaretAfterQuote] = useState(false); - if (quoteRequest !== answeredQuoteRequest) { + // A successful post clears the draft, so a quote waits until posting settles. + if (quoteRequest !== answeredQuoteRequest && !locked) { setAnsweredQuoteRequest(quoteRequest); if (quoteRequest) { setBody((draft) => @@ -70,8 +73,6 @@ export function PullRequestCommentForm({ textarea.setSelectionRange(textarea.value.length, textarea.value.length); textarea.scrollTop = textarea.scrollHeight; }, [caretAfterQuote, textareaRef]); - const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); - const locked = submitting !== null || actionPending; const speech = useTextFieldSpeech({ environmentId, projectId: reference.projectId, diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index f32868d8712a0..f3e4ae64471e0 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -406,20 +406,22 @@ export function ReviewThreadCard({ ) : null} - - {canReply ? ( - - ) : null} ) : null} + {/* Stays mounted while collapsed so an unsent reply survives. */} + {canReply ? ( + + ) : null} ); } diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index f2e2094d9f1e6..bccc8a1b9457e 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -335,10 +335,15 @@ function CollapsedComment({ )} {replies ?
{replies}
: null} - {replyForm} ) : null} + {/* Stays mounted while collapsed so an unsent reply survives. */} + {replyForm ? ( + + ) : null} {moreReplies ?
{moreReplies}
: null}
@@ -876,7 +881,7 @@ export function PullRequestSummaryTab({ replyCount={item.kind === "thread" ? item.replies.length + item.moreReplies : 0} replies={replies} moreReplies={moreReplies} - replyForm={thread ? threadReplyForm(thread) : null} + replyForm={thread ? threadReplyForm(thread, "mt-0") : null} onAddToChat={onAddToChat && chat !== null ? () => onAddToChat(chat) : undefined} reactionBar={reactionBarFor(comment)} resolveButton={resolveButtonFor(thread)} From c7e65355012cc21027996448abb9cdd839ae6191 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:28:13 +0100 Subject: [PATCH 03/10] test(web): cover an unsent PR reply surviving collapse Co-Authored-By: Claude Opus 5.5 --- .../PullRequestSummaryTab.browser.test.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index 8ecb158a8f2d0..c25d5fa19ddd2 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -202,6 +202,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 }; From fa38260a555436cbcc6f9c1db5f72b8918d660cb Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:30:20 +0100 Subject: [PATCH 04/10] fix(web): keep HTML comments written inside code when quoting Co-Authored-By: Claude Opus 5.5 --- .../pullRequest/PullRequestReviewAnnotation.tsx | 13 +++++-------- .../pullRequest/pullRequestDetail.logic.test.ts | 7 +++++++ .../pullRequest/pullRequestDetail.logic.ts | 3 ++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index f3e4ae64471e0..7591141b01ee7 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -465,14 +465,11 @@ export function PullRequestThreadReplyForm({ if (!replying) { return ( - +
+ +
); } return ( diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 3ea1c8d9c7b6a..50c84c768bc2f 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -557,6 +557,13 @@ describe("review thread conversations", () => { expect(pullRequestQuoteReply({ ...loose, author: null }, " picked\nlines ")).toBe( "> picked\n> lines\n\n", ); + expect( + pullRequestQuoteReply({ + ...loose, + author: null, + body: "Use ``:\n```html\n\n```", + }), + ).toBe("> Use ``:\n> ```html\n> \n> ```\n\n"); }); it("keeps a timeline finding and its replies in one row at the finding time", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 1101396d2d738..64ef503a511b3 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -532,7 +532,8 @@ export function visibleBody(body: string): string | null { /** Quotes `selection` when the reader picked part of the comment, else the whole body. */ export function pullRequestQuoteReply(comment: PullRequestComment, selection?: string): string { const quote = (selection?.trim() || comment.body) - .replace(//gu, "") + // Drops hidden HTML comments, but keeps ones written inside code as examples. + .replace(/(```[\s\S]*?```|`[^`\n]*`)|/gu, (_, code?: string) => code ?? "") .trim() .split(/\r?\n/u) .map((line) => (line.trim().length === 0 ? ">" : `> ${line}`)) From 5d5e5fefb61681915a68862b452c4378ee506dd6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 20:36:23 +0000 Subject: [PATCH 05/10] fix(web): preserve visible code in selected PR quotes --- .../pullRequest/pullRequestDetail.logic.test.ts | 3 +++ .../components/pullRequest/pullRequestDetail.logic.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 50c84c768bc2f..1ec0742d07e07 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -557,6 +557,9 @@ describe("review thread conversations", () => { expect(pullRequestQuoteReply({ ...loose, author: null }, " picked\nlines ")).toBe( "> picked\n> lines\n\n", ); + expect(pullRequestQuoteReply({ ...loose, author: null }, "")).toBe( + "> \n\n", + ); expect( pullRequestQuoteReply({ ...loose, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 64ef503a511b3..4d6dc26570b6a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -531,9 +531,13 @@ export function visibleBody(body: string): string | null { /** Quotes `selection` when the reader picked part of the comment, else the whole body. */ export function pullRequestQuoteReply(comment: PullRequestComment, selection?: string): string { - const quote = (selection?.trim() || comment.body) - // Drops hidden HTML comments, but keeps ones written inside code as examples. - .replace(/(```[\s\S]*?```|`[^`\n]*`)|/gu, (_, code?: string) => code ?? "") + const body = + selection?.trim() || + comment.body.replace( + /(```[\s\S]*?```|`[^`\n]*`)|/gu, + (_, code?: string) => code ?? "", + ); + const quote = body .trim() .split(/\r?\n/u) .map((line) => (line.trim().length === 0 ? ">" : `> ${line}`)) From 1d2d6337f9eaed20c1f68920fd80b61f15a7baa7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 20:55:28 +0000 Subject: [PATCH 06/10] fix(web): escape selected PR quote text for rendering --- .../components/pullRequest/pullRequestDetail.logic.test.ts | 5 ++++- .../src/components/pullRequest/pullRequestDetail.logic.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 1ec0742d07e07..5864393bf3e56 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -558,7 +558,10 @@ describe("review thread conversations", () => { "> picked\n> lines\n\n", ); expect(pullRequestQuoteReply({ ...loose, author: null }, "")).toBe( - "> \n\n", + "> \\<\\!\\-\\- keep \\-\\-\\>\n\n", + ); + expect(pullRequestQuoteReply({ ...loose, author: null }, "# Heading")).toBe( + "> \\# Heading\n\n", ); expect( pullRequestQuoteReply({ diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 4d6dc26570b6a..d827715bd7ce2 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -532,7 +532,7 @@ export function visibleBody(body: string): string | null { /** Quotes `selection` when the reader picked part of the comment, else the whole body. */ export function pullRequestQuoteReply(comment: PullRequestComment, selection?: string): string { const body = - selection?.trim() || + selection?.trim().replace(/[\\`*_[\]{}()#+.!|~<>-]/gu, "\\$&") || comment.body.replace( /(```[\s\S]*?```|`[^`\n]*`)|/gu, (_, code?: string) => code ?? "", From e954cadddedf8bd495f2be5cc885ff1ca18df374 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:50:06 +0100 Subject: [PATCH 07/10] fix(web): keep quotes and reply drafts through posts and resolution Quotes made while a comment posts now queue and reopen the composer once the post settles. Thread reply drafts survive resolving, reopening, or filtering. Selected quotes keep indentation and escape ampersands, and hidden comments between escaped backticks are stripped. Co-Authored-By: Claude Opus 5.5 --- .../pullRequest/PullRequestCommentForm.tsx | 29 +++++++++++-------- .../pullRequest/PullRequestComposer.tsx | 4 +++ .../PullRequestReviewAnnotation.tsx | 13 +++++++-- .../pullRequest/PullRequestSummaryTab.tsx | 14 ++++++--- .../pullRequestDetail.logic.test.ts | 8 +++-- .../pullRequest/pullRequestDetail.logic.ts | 19 ++++++++---- 6 files changed, 61 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx index 04b999354018d..3bcffd7e2030c 100644 --- a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx @@ -31,6 +31,7 @@ export function PullRequestCommentForm({ onCommentAction, onCommented, onClose, + onQuoted, }: { environmentId: EnvironmentId; /** False while the form stays mounted but hidden; dictation is cancelled then. */ @@ -46,33 +47,37 @@ export function PullRequestCommentForm({ ) => 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); - // A successful post clears the draft, so a quote waits until posting settles. - if (quoteRequest !== answeredQuoteRequest && !locked) { + if (quoteRequest !== answeredQuoteRequest) { setAnsweredQuoteRequest(quoteRequest); - if (quoteRequest) { - setBody((draft) => - draft.trim().length === 0 - ? quoteRequest.text - : `${draft.trimEnd()}\n\n${quoteRequest.text}`, - ); - setCaretAfterQuote(true); - } + if (quoteRequest) setPendingQuotes((quotes) => [...quotes, quoteRequest.text]); + } + // A successful post clears the draft, so quotes wait until posting settles. + if (pendingQuotes.length > 0 && !locked) { + setPendingQuotes([]); + setBody((draft) => + [draft.trimEnd(), ...pendingQuotes].filter((part) => part.length > 0).join("\n\n"), + ); + setCaretAfterQuote(true); } - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves the DOM caret after a quote the textarea just received + // 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]); + }, [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 f15347fc0456f..f604a0695d3bd 100644 --- a/apps/web/src/components/pullRequest/PullRequestComposer.tsx +++ b/apps/web/src/components/pullRequest/PullRequestComposer.tsx @@ -192,6 +192,10 @@ export function PullRequestComposer({ onCommentAction={onCommentAction} onCommented={onCommented} onClose={() => setOpen(false)} + onQuoted={() => { + setRequestedMode("comment"); + setOpen(true); + }} /> ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 7591141b01ee7..8748c7abd57ea 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -433,6 +433,8 @@ export function PullRequestThreadReplyForm({ completionScope, pending, className, + initialReply = "", + onReplyChange, onReply, onSent, }: { @@ -442,11 +444,18 @@ export function PullRequestThreadReplyForm({ completionScope: PullRequestCompletionScope | null; pending: boolean; className?: string; + /** Lets a parent keep the draft when this form remounts elsewhere. */ + initialReply?: string; + onReplyChange?: (reply: string) => void; onReply: (body: string) => Promise; onSent?: () => void; }) { - const [replying, setReplying] = useState(false); - const [reply, setReply] = useState(""); + const [replying, setReplying] = useState(initialReply.length > 0); + const [reply, setReplyState] = useState(initialReply); + const setReply = (next: string) => { + setReplyState(next); + onReplyChange?.(next); + }; const sendingRef = useRef(false); const replyRef = useRef(null); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index bccc8a1b9457e..06d63af755b12 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -683,6 +683,8 @@ export function PullRequestSummaryTab({ reportFailure: false, }); const [threadReplyPending, setThreadReplyPending] = useState(false); + // Resolving, reopening, or filtering remounts a reply form; a mutable map keeps keystrokes from re-rendering the tab. + const [replyDrafts] = useState(() => new Map()); const canReplyToThreads = detail.capabilities.review.reply && detail.viewerPermissions.comment; const sendThreadReply = async (threadId: string, body: string) => { if (threadReplyPending) return false; @@ -696,20 +698,24 @@ export function PullRequestSummaryTab({ onRefresh(); return true; }; - const threadReplyForm = (thread: PullRequestConversationThread, className?: string) => - canReplyToThreads ? ( + const threadReplyForm = (thread: PullRequestConversationThread, className?: string) => { + // Host thread ids can collide across pull requests. + const replyKey = `${detail.url}:${thread.id}`; + return canReplyToThreads ? ( replyDrafts.set(replyKey, reply)} onReply={(body) => sendThreadReply(thread.id, body)} /> ) : null; + }; const commentEditing: CommentEditing = { cwd: detail.workspaceRoot, environmentId, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 5864393bf3e56..aba8051a3da31 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -554,9 +554,10 @@ describe("review thread conversations", () => { }), ).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 }, " picked\nlines ")).toBe( - "> picked\n> lines\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", ); @@ -570,6 +571,9 @@ describe("review thread conversations", () => { body: "Use ``:\n```html\n\n```", }), ).toBe("> Use ``:\n> ```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", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index d827715bd7ce2..746995857d560 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -531,14 +531,21 @@ export function visibleBody(body: string): string | null { /** 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 = - selection?.trim().replace(/[\\`*_[\]{}()#+.!|~<>-]/gu, "\\$&") || - comment.body.replace( - /(```[\s\S]*?```|`[^`\n]*`)|/gu, - (_, code?: string) => code ?? "", - ); + selected || + comment.body + .replace( + /(\\[\s\S]|```[\s\S]*?```|`[^`\n]*`)|/gu, + (_, kept?: string) => kept ?? "", + ) + .trim(); const quote = body - .trim() .split(/\r?\n/u) .map((line) => (line.trim().length === 0 ? ">" : `> ${line}`)) .join("\n"); From 5dc1d77b479c60f0b0623a30843580982e285488 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:16:38 +0100 Subject: [PATCH 08/10] fix(web): clear a remounted thread reply once its post succeeds Thread reply drafts now live in the review store, so the mounted form reads the live draft and a successful post clears it wherever the form is now shown. Co-Authored-By: Claude Opus 5.5 --- .../PullRequestReviewAnnotation.tsx | 26 +++++++++---------- .../pullRequest/PullRequestSummaryTab.tsx | 14 +++------- .../pullRequestReviewStore.test.ts | 8 +++++- .../pullRequest/pullRequestReviewStore.ts | 16 ++++++++++++ 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 8748c7abd57ea..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, @@ -433,8 +437,6 @@ export function PullRequestThreadReplyForm({ completionScope, pending, className, - initialReply = "", - onReplyChange, onReply, onSent, }: { @@ -444,18 +446,16 @@ export function PullRequestThreadReplyForm({ completionScope: PullRequestCompletionScope | null; pending: boolean; className?: string; - /** Lets a parent keep the draft when this form remounts elsewhere. */ - initialReply?: string; - onReplyChange?: (reply: string) => void; onReply: (body: string) => Promise; onSent?: () => void; }) { - const [replying, setReplying] = useState(initialReply.length > 0); - const [reply, setReplyState] = useState(initialReply); - const setReply = (next: string) => { - setReplyState(next); - onReplyChange?.(next); - }; + // 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); @@ -468,7 +468,7 @@ export function PullRequestThreadReplyForm({ if (result._tag === "Failure") throw squashAtomCommandFailure(result); if (!result.value) return; onSent?.(); - setReply(""); + clearReply(replyKey, trimmed); setReplying(false); }; diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 06d63af755b12..bccc8a1b9457e 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -683,8 +683,6 @@ export function PullRequestSummaryTab({ reportFailure: false, }); const [threadReplyPending, setThreadReplyPending] = useState(false); - // Resolving, reopening, or filtering remounts a reply form; a mutable map keeps keystrokes from re-rendering the tab. - const [replyDrafts] = useState(() => new Map()); const canReplyToThreads = detail.capabilities.review.reply && detail.viewerPermissions.comment; const sendThreadReply = async (threadId: string, body: string) => { if (threadReplyPending) return false; @@ -698,24 +696,20 @@ export function PullRequestSummaryTab({ onRefresh(); return true; }; - const threadReplyForm = (thread: PullRequestConversationThread, className?: string) => { - // Host thread ids can collide across pull requests. - const replyKey = `${detail.url}:${thread.id}`; - return canReplyToThreads ? ( + const threadReplyForm = (thread: PullRequestConversationThread, className?: string) => + canReplyToThreads ? ( replyDrafts.set(replyKey, reply)} onReply={(body) => sendThreadReply(thread.id, body)} /> ) : null; - }; const commentEditing: CommentEditing = { cwd: detail.workspaceRoot, environmentId, 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 { From 36d4bab729b0270b13abfee63077d0a54b8faf19 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:51:38 +0100 Subject: [PATCH 09/10] fix(web): keep a quote made as posting settles, and code in tilde fences A quote that arrived in the same render the comment form unlocked was marked answered but dropped from the draft. Quoting a comment also stripped HTML comments inside ~~~ fenced code. Co-Authored-By: Claude Opus 5.5 --- .../PullRequestCommentForm.browser.test.tsx | 51 +++++++++++++++++++ .../pullRequest/PullRequestCommentForm.tsx | 11 ++-- .../pullRequestDetail.logic.test.ts | 3 ++ .../pullRequest/pullRequestDetail.logic.ts | 2 +- 4 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestCommentForm.browser.test.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestCommentForm.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestCommentForm.browser.test.tsx new file mode 100644 index 0000000000000..dc5f54b4fc84e --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestCommentForm.browser.test.tsx @@ -0,0 +1,51 @@ +import { + EnvironmentId, + ProjectId, + SourceControlProviderKind, + type PullRequestDetailView, +} from "@t3tools/contracts"; +import { createRef } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; +import { PullRequestCommentForm, type PullRequestQuoteRequest } from "./PullRequestCommentForm"; + +vi.mock("~/speech/useTextFieldSpeech", () => ({ + TextFieldSpeechControls: () => null, + useTextFieldSpeech: () => ({ blocksSubmission: false, cancel: () => {} }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock(import("~/state/pullRequests"), async (original) => ({ + ...(await original()), + pullRequestEnvironment: { comment: {} } as never, +})); + +const detail = { + provider: SourceControlProviderKind.make("github"), + state: "open", + capabilities: { actions: [] }, + viewerPermissions: { actions: [] }, +} as unknown as PullRequestDetailView; +const reference = { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }; + +it("adds a quote that arrives as posting settles along with the ones queued before it", async () => { + const form = (actionPending: boolean, quoteRequest: PullRequestQuoteRequest) => ( + + ); + await using screen = await render(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/PullRequestCommentForm.tsx b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx index 3bcffd7e2030c..cfe02c07c2319 100644 --- a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx @@ -56,17 +56,18 @@ export function PullRequestCommentForm({ const [answeredQuoteRequest, setAnsweredQuoteRequest] = useState(quoteRequest); const [pendingQuotes, setPendingQuotes] = useState>([]); const [caretAfterQuote, setCaretAfterQuote] = useState(false); + let quotes = pendingQuotes; if (quoteRequest !== answeredQuoteRequest) { setAnsweredQuoteRequest(quoteRequest); - if (quoteRequest) setPendingQuotes((quotes) => [...quotes, quoteRequest.text]); + if (quoteRequest) quotes = [...quotes, quoteRequest.text]; } // A successful post clears the draft, so quotes wait until posting settles. - if (pendingQuotes.length > 0 && !locked) { + if (quotes.length > 0 && !locked) { setPendingQuotes([]); - setBody((draft) => - [draft.trimEnd(), ...pendingQuotes].filter((part) => part.length > 0).join("\n\n"), - ); + 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(() => { diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index aba8051a3da31..009487ab9eb73 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -571,6 +571,9 @@ describe("review thread conversations", () => { 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"); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 746995857d560..fe329992f6f08 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -541,7 +541,7 @@ export function pullRequestQuoteReply(comment: PullRequestComment, selection?: s selected || comment.body .replace( - /(\\[\s\S]|```[\s\S]*?```|`[^`\n]*`)|/gu, + /(\\[\s\S]|```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)|/gu, (_, kept?: string) => kept ?? "", ) .trim(); From 43255210a1143bc6df63a3de70e028c68d26107c Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:48:55 +0100 Subject: [PATCH 10/10] test(web): move the late-quote test into the PR summary browser tests Co-Authored-By: Claude Sonnet 5.5 --- .../PullRequestCommentForm.browser.test.tsx | 51 ------------------- .../PullRequestSummaryTab.browser.test.tsx | 25 +++++++++ 2 files changed, 25 insertions(+), 51 deletions(-) delete mode 100644 apps/web/src/components/pullRequest/PullRequestCommentForm.browser.test.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestCommentForm.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestCommentForm.browser.test.tsx deleted file mode 100644 index dc5f54b4fc84e..0000000000000 --- a/apps/web/src/components/pullRequest/PullRequestCommentForm.browser.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { - EnvironmentId, - ProjectId, - SourceControlProviderKind, - type PullRequestDetailView, -} from "@t3tools/contracts"; -import { createRef } from "react"; -import { expect, it, vi } from "vite-plus/test"; - -import { render } from "../../../test/render"; -import { PullRequestCommentForm, type PullRequestQuoteRequest } from "./PullRequestCommentForm"; - -vi.mock("~/speech/useTextFieldSpeech", () => ({ - TextFieldSpeechControls: () => null, - useTextFieldSpeech: () => ({ blocksSubmission: false, cancel: () => {} }), -})); -vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock(import("~/state/pullRequests"), async (original) => ({ - ...(await original()), - pullRequestEnvironment: { comment: {} } as never, -})); - -const detail = { - provider: SourceControlProviderKind.make("github"), - state: "open", - capabilities: { actions: [] }, - viewerPermissions: { actions: [] }, -} as unknown as PullRequestDetailView; -const reference = { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }; - -it("adds a quote that arrives as posting settles along with the ones queued before it", async () => { - const form = (actionPending: boolean, quoteRequest: PullRequestQuoteRequest) => ( - - ); - await using screen = await render(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.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index c25d5fa19ddd2..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 = { @@ -316,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"); +});