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
34 changes: 34 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestCommentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@ 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,
reference,
detail,
actionPending,
textareaRef,
quoteRequest,
onCommentAction,
onCommented,
onClose,
onQuoted,
}: {
environmentId: EnvironmentId;
/** False while the form stays mounted but hidden; dictation is cancelled then. */
Expand All @@ -35,16 +40,45 @@ export function PullRequestCommentForm({
detail: PullRequestDetailView;
actionPending: boolean;
textareaRef: RefObject<HTMLTextAreaElement | null>;
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<ReadonlyArray<string>>([]);
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,
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/components/pullRequest/PullRequestComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +27,7 @@ export function PullRequestComposer({
onCommented,
onReviewSubmitted,
openReviewRequest,
quoteRequest,
}: {
environmentId: EnvironmentId;
reference: PullRequestRef;
Expand All @@ -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);
Expand All @@ -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) =>
Expand Down Expand Up @@ -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);
}}
/>
</div>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<PullRequestQuoteRequest>();
const {
canWriteSourceControl,
detailQuery,
Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -2005,6 +2010,7 @@ export function PullRequestDetailPanel({
onCommented={refreshDetail}
onReviewSubmitted={refreshDetail}
openReviewRequest={openReviewRequest}
quoteRequest={quoteRequest}
/>
</div>
) : null}
Expand Down
202 changes: 126 additions & 76 deletions apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(null);
const [savingEdit, setSavingEdit] = useState(false);
const sendingRef = useRef(false);
const replyRef = useRef<HTMLTextAreaElement>(null);
const [loadedPage, setLoadedPage] = useState<
(PullRequestThreadCommentsResult & { readonly threadId: string }) | null
>(null);
Expand Down Expand Up @@ -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
? {
Expand All @@ -276,9 +269,6 @@ export function ReviewThreadCard({
}
: previous,
);
setReply("");
setReplying(false);
};
const loadMore = async () => {
if (nextCommentsCursor === null || loadingMore) return;
setLoadingMore(true);
Expand Down Expand Up @@ -420,67 +410,127 @@ export function ReviewThreadCard({
</Button>
</div>
) : null}

{canReply ? (
replying ? (
<SpeechTextField
environmentId={environmentId}
projectId={reference.projectId}
ownerKey={JSON.stringify(["pr-reply", environmentId, reference, thread.id])}
text={reply}
textareaRef={replyRef}
onTextChange={setReply}
disabled={pending}
>
{(speech) => (
<div className="mt-2">
<PullRequestMarkdownTextarea
ref={replyRef}
readOnly={speech.freezesEditor}
autoFocus
size="sm"
completionScope={completionScope}
value={reply}
placeholder="Reply"
aria-label="Reply to this conversation"
onValueChange={setReply}
onKeyDown={submitKeys({
value: reply,
pending,
allowEmpty: speech.blocksSubmission,
onSubmit: () => speech.submitAfterDictation((text) => void send(text)),
onCancel: () =>
speech.state.phase === "idle" ? setReplying(false) : speech.cancel(),
})}
/>
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
<TextFieldSpeechControls speech={speech} disabled={pending} />
<Button size="xs" variant="ghost" onClick={() => setReplying(false)}>
Cancel
</Button>
<Button
size="xs"
disabled={
speech.preparing ||
pending ||
(!speech.blocksSubmission && reply.trim().length === 0)
}
onClick={() => speech.submitAfterDictation((text) => void send(text))}
>
Reply
</Button>
</div>
</div>
)}
</SpeechTextField>
) : (
<Button size="xs" variant="ghost" className="mt-2" onClick={() => setReplying(true)}>
Reply
</Button>
)
) : null}
</>
) : null}
{/* Stays mounted while collapsed so an unsent reply survives. */}
{canReply ? (
<div hidden={!expanded}>
<PullRequestThreadReplyForm
environmentId={environmentId}
reference={reference}
threadId={thread.id}
completionScope={completionScope}
pending={pending}
onReply={onReply}
onSent={keepLoadedPage}
/>
</div>
) : null}
</div>
);
}

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<boolean>;
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<HTMLTextAreaElement>(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 (
<div className={cn("mt-2", className)}>
<Button size="xs" variant="ghost" onClick={() => setReplying(true)}>
Reply
</Button>
</div>
);
}
return (
<SpeechTextField
environmentId={environmentId}
projectId={reference.projectId}
ownerKey={JSON.stringify(["pr-reply", environmentId, reference, threadId])}
text={reply}
textareaRef={replyRef}
onTextChange={setReply}
disabled={pending}
>
{(speech) => (
<div className={cn("mt-2", className)}>
<PullRequestMarkdownTextarea
ref={replyRef}
readOnly={speech.freezesEditor}
autoFocus
size="sm"
completionScope={completionScope}
value={reply}
placeholder="Reply"
aria-label="Reply to this conversation"
onValueChange={setReply}
onKeyDown={submitKeys({
value: reply,
pending,
allowEmpty: speech.blocksSubmission,
onSubmit: () => speech.submitAfterDictation((text) => void send(text)),
onCancel: () =>
speech.state.phase === "idle" ? setReplying(false) : speech.cancel(),
})}
/>
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
<TextFieldSpeechControls speech={speech} disabled={pending} />
<Button size="xs" variant="ghost" onClick={() => setReplying(false)}>
Cancel
</Button>
<Button
size="xs"
disabled={
speech.preparing ||
pending ||
(!speech.blocksSubmission && reply.trim().length === 0)
}
onClick={() => speech.submitAfterDictation((text) => void send(text))}
>
Reply
</Button>
</div>
</div>
)}
</SpeechTextField>
);
}
Loading
Loading