import { usePullRequestReviewScope } from "./usePullRequestReviewKey"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useReviewThreadActions } from "./useReviewThreadActions"; import type { PullRequestDetail } from "@t3tools/contracts"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { PullRequestAttachments } from "./PullRequestAttachments"; import { usePendingReviewCommentActions } from "./usePendingReviewCommentActions"; import { PullRequestCommentActions } from "./PullRequestCommentActions"; /** * Pull-request-specific annotations: conversations already on the host and comments queued for * the review being written. New comment composition uses the shared diff annotation. */ import type { EnvironmentId, PullRequestActor, PullRequestDiffSide, PullRequestRef, PullRequestReviewPosition, PullRequestReviewThread, PullRequestThreadCommentsResult, PullRequestThreadComment, } from "@t3tools/contracts"; import { CheckIcon, ClockIcon, HammerIcon, MessageSquarePlusIcon, Trash2Icon, CircleCheckIcon, CircleIcon, } from "lucide-react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { SpeechTextField, TextFieldSpeechControls } from "~/speech/useTextFieldSpeech"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Badge } from "../ui/badge"; import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut"; import { editPullRequestThreadComment, mergePullRequestThreadComments, visibleBody, } from "./pullRequestDetail.logic"; import { PullRequestActorAvatar, PullRequestActorLabel } from "./pullRequestPresentation"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; import { pendingReviewDiscardTargets, usePendingReviewComments, usePullRequestReviewStore, type PendingReviewComment, } from "./pullRequestReviewStore"; import { PullRequestMarkdownTextarea, type PullRequestCompletionScope, } from "./PullRequestMarkdownTextarea"; const CARD_CLASS = "mx-3 my-2 rounded-xl border border-border/70 bg-background p-3 text-sm shadow-sm"; function submitKeys(input: { readonly value: string; readonly pending: boolean; readonly allowEmpty?: boolean; readonly onSubmit: () => void; readonly onCancel?: (() => void) | undefined; }) { return (event: React.KeyboardEvent) => { if (event.key === "Escape" && input.onCancel) { event.preventDefault(); input.onCancel(); } if (isCommentSubmitShortcut(event, input.value, input.pending, input.allowEmpty)) { event.preventDefault(); input.onSubmit(); } }; } export function toViewerSide(side: PullRequestDiffSide) { return side === "left" ? ("deletions" as const) : ("additions" as const); } export function getReviewPositionAnchor(position: PullRequestReviewPosition): { line: number; side: PullRequestDiffSide; } { switch (position.kind) { case "added": return { line: position.newLine, side: "right" }; case "deleted": return { line: position.oldLine, side: "left" }; case "context": return { line: position.side === "left" ? position.oldLine : position.newLine, side: position.side, }; } } export function PendingReviewCommentCard({ comment, environmentId, reference, canCopyLink, onAddToComposer, }: { comment: PendingReviewComment; environmentId: EnvironmentId; reference: PullRequestRef; canCopyLink: boolean; onAddToComposer?: (() => void) | undefined; }) { const [replyCommentId, setReplyCommentId] = useState(null); const { busy, canSave, canDiscard, saveDraft, copyLink, discard } = usePendingReviewCommentActions(environmentId, reference); if (replyCommentId) return ( ); return (
event.stopPropagation()} >
Pending {comment.hostedComment ? "Saved on GitHub · private until submitted" : canCopyLink ? busy ? "Saving to GitHub…" : "Only saved in T3" : "Sent when you submit the review"} {canCopyLink && canSave && !comment.hostedComment && !busy ? ( ) : null}
copyLink(comment) : undefined} disabled={busy} onAddToComposer={onAddToComposer} />

{comment.body}

{canCopyLink && canSave ? ( ) : null}
); } /** The conversations on one diff line. Resolved ones hide behind their authors' avatars. */ export function InlineReviewThreads({ threads, renderThread, revealedThreadId, }: { threads: ReadonlyArray; renderThread: (thread: PullRequestReviewThread, hidden: boolean) => ReactNode; revealedThreadId?: string | undefined; }) { const [shown, setShown] = useState>(() => new Set()); // A conversation seen open stays shown once resolved, so it does not vanish under the reader. const open = threads.filter((thread) => !thread.isResolved); const visible = threads.filter((thread) => !thread.isResolved || thread.id === revealedThreadId); if (visible.some((thread) => !shown.has(thread.id))) { setShown(new Set([...shown, ...visible.map((thread) => thread.id)])); } const resolved = threads.filter((thread) => thread.isResolved); const expanded = resolved.every((thread) => shown.has(thread.id)); const authors = new Map(); for (const comment of resolved.flatMap((thread) => thread.comments)) { authors.set(comment.author?.login ?? "ghost", comment.author); } if (authors.size === 0) authors.set("ghost", null); const label = resolved.length === 1 ? "Resolved conversation" : `${resolved.length} resolved conversations`; return ( <> {resolved.length > 0 ? ( event.stopPropagation()} onClick={() => setShown(new Set((expanded ? open : threads).map((thread) => thread.id))) } /> } > {[...authors].slice(0, 3).map(([login, author]) => ( ))} {label} ) : null} {threads.map((thread) => renderThread(thread, thread.isResolved && !shown.has(thread.id)))} ); } export function ReviewThreadCard({ thread, workspaceRoot, canReply, canResolve, canReact, environmentId, reference, completionScope = null, pending, fixPending, fixLabel = "Fix in a thread", onFix, onAddToChat, onReply, onLoadMore, canEditComment, onEditComment, onToggleResolved, onReacted, hidden, onAddToComposer, revealRequest, }: { thread: PullRequestReviewThread; workspaceRoot: string; canReply: boolean; canResolve: boolean; canReact: boolean; environmentId: EnvironmentId; reference: PullRequestRef; completionScope?: PullRequestCompletionScope | null; pending: boolean; fixPending?: boolean; fixLabel?: string; onFix?: () => void; /** * Absent where there is no composer beside the panel to add the conversation to. Handed the * thread with every comment the reader has loaded, not only the first page. */ onAddToChat?: (thread: PullRequestReviewThread) => void; onReply: (body: string) => Promise; onLoadMore: (cursor: string) => Promise; canEditComment: (comment: PullRequestThreadComment) => boolean; onEditComment: (commentId: string, body: string) => Promise; onToggleResolved: () => void; onReacted: () => void; hidden?: boolean; revealRequest?: number | undefined; onAddToComposer?: ((comment: PullRequestThreadComment) => void) | undefined; }) { // A resolved thread is finished work, so it opens collapsed and stays one line until asked for. const pendingActions = usePendingReviewCommentActions(environmentId, reference); const [expanded, setExpanded] = useState( hidden !== undefined || revealRequest !== undefined || !thread.isResolved, ); const [lastRevealRequest, setLastRevealRequest] = useState(revealRequest); if (lastRevealRequest !== revealRequest) { setLastRevealRequest(revealRequest); if (revealRequest !== undefined) setExpanded(true); } const [uploading, setUploading] = useState(false); const [replying, setReplying] = useState(false); const [reply, setReply] = useState(""); const [lastResolved, setLastResolved] = useState(thread.isResolved); 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); const [loadingMore, setLoadingMore] = useState(false); const currentPage = loadedPage?.threadId === thread.id ? loadedPage : null; const receivedComments = mergePullRequestThreadComments( thread.comments, currentPage?.comments ?? [], ); const { key: reviewKey, ready: reviewReady } = usePullRequestReviewScope( environmentId, reference, ); const storedComments = usePendingReviewComments(reviewKey, [ { ...thread, comments: receivedComments }, ]); const pendingComments = reviewReady ? storedComments : []; const removedIds = usePullRequestReviewStore((store) => store.removedHostComments[reviewKey]); const discardById = new Map( pendingReviewDiscardTargets( pendingComments, [{ ...thread, comments: receivedComments }], removedIds, ).flatMap((comment) => comment.hostedComment ? [[comment.hostedComment.commentId, comment] as const] : [], ), ); const pendingById = new Map( pendingComments.flatMap((comment) => comment.hostedComment ? [[comment.hostedComment.commentId, comment] as const] : [], ), ); const comments = receivedComments.filter( (comment) => !comment.pendingReviewId || (reviewReady && (thread.line === null ? !removedIds?.includes(comment.id) : pendingById.has(comment.id))), ); const nextCommentsCursor = currentPage === null ? (thread.nextCommentsCursor ?? null) : currentPage.nextCursor; const commentCount = thread.commentCount ?? comments.length; // Follow resolution changes from this client or GitHub while allowing a finished thread to // be opened manually. Failed resolve requests leave the current view and draft intact. if (lastResolved !== thread.isResolved) { setLastResolved(thread.isResolved); if (hidden === undefined) setExpanded(!thread.isResolved); } const saveEdit = async (commentId: string, body: string) => { if (savingEdit) return; setSavingEdit(true); const saved = await onEditComment(commentId, body); setSavingEdit(false); if (saved) { usePullRequestReviewStore.getState().updateHostedCommentBody(reviewKey, commentId, body); setLoadedPage((previous) => previous?.threadId === thread.id ? { ...previous, comments: editPullRequestThreadComment(previous.comments, commentId, body), } : previous, ); setEditingId(null); } }; const send = async (text: string) => { const trimmed = text.trim(); if (trimmed.length === 0 || pending || uploading || 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. setLoadedPage((previous) => previous?.threadId === thread.id ? { ...previous, nextCursor: previous.nextCursor ?? thread.nextCommentsCursor ?? null, } : previous, ); setReply(""); }; const loadMore = async () => { if (nextCommentsCursor === null || loadingMore) return; setLoadingMore(true); const result = await settlePromise(() => onLoadMore(nextCommentsCursor)); setLoadingMore(false); if (result._tag === "Failure") throw squashAtomCommandFailure(result); const page = result.value; if (page === null) return; setLoadedPage((previous) => ({ threadId: thread.id, comments: mergePullRequestThreadComments( previous?.threadId === thread.id ? previous.comments : [], page.comments, ), nextCursor: page.nextCursor, })); }; if (hidden) return null; return (
event.stopPropagation()} >
{thread.isResolved ? ( ) : ( )} {thread.isOutdated ? outdated : null} {onFix ? ( ) : null} {onAddToChat && comments.some((comment) => visibleBody(comment.body) !== null) ? ( ) : null} {canResolve ? ( ) : null}
{expanded ? ( <>
{comments.map((comment, index) => (
{formatRelativeTimeLabel(comment.createdAt)} {comment.pendingReviewId ? ( Pending ) : null} {index === 0 && thread.isOutdated ? ( Outdated ) : null}
{index === 0 && canResolve && !thread.isResolved && !comment.pendingReviewId ? ( ) : null} {comment.pendingReviewId && discardById.has(comment.id) && pendingActions.canDiscard ? ( ) : null} setEditingId(comment.id) : undefined} onAddToComposer={onAddToComposer ? () => onAddToComposer(comment) : undefined} resolution={ canResolve && !thread.comments.some((entry) => entry.pendingReviewId) ? { resolved: thread.isResolved, pending, onToggle: onToggleResolved } : undefined } />
{editingId === comment.id ? ( void saveEdit(comment.id, body)} onCancel={() => setEditingId(null)} /> ) : (
)}
))}
{nextCommentsCursor !== null ? (
) : null} {canReply ? ( replying ? ( {(speech) => (
speech.submitAfterDictation((text) => void send(text)), onCancel: () => speech.state.phase === "idle" ? setReplying(false) : speech.cancel(), })} />
)}
) : ( ) ) : null} ) : null}
); } /** Shared cached queries keep a Guide or pending-comment conversation live after a reply. */ export function HostedReviewThreadCard({ environmentId, reference, threadId, commentId, }: { environmentId: EnvironmentId; reference: PullRequestRef; threadId?: string | undefined; commentId?: string | undefined; }) { const target = { environmentId, input: reference }; const detail = useEnvironmentQuery(pullRequestEnvironment.detail(target)); const activity = useEnvironmentQuery(pullRequestEnvironment.activity(target)); const thread = activity.data?.reviewThreads.find((entry) => threadId ? entry.id === threadId : entry.comments.some((comment) => comment.id === commentId), ); const refreshActivity = activity.refresh; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Re-read hosted activity when opening a newly saved comment's conversation. useEffect(() => { if (commentId) refreshActivity(); }, [commentId, refreshActivity]); if (!thread || !detail.data) return (
{activity.error ?? detail.error ?? (activity.isPending || detail.isPending ? "Loading conversation…" : "This conversation is not available yet. Refresh to check again.")}
); return ( ); } function HostedReviewThreadContent({ environmentId, reference, detail, thread, onRefresh, }: { environmentId: EnvironmentId; reference: PullRequestRef; detail: PullRequestDetail; thread: PullRequestReviewThread; onRefresh: () => void; }) { const actions = useReviewThreadActions(environmentId, reference, detail, onRefresh); return ( ); }