From 5b4f9cd2895f38caf8001317aaf31a1f603c5cfa Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Tue, 7 Apr 2026 11:58:11 -0700 Subject: [PATCH] feat(code): show PR comments in review panel --- apps/code/package.json | 2 + apps/code/src/main/services/git/schemas.ts | 42 ++ apps/code/src/main/services/git/service.ts | 67 +++ apps/code/src/main/trpc/routers/git.ts | 16 + .../code-editor/stores/diffViewerStore.ts | 5 + .../components/CloudReviewPage.tsx | 35 +- .../components/DiffSettingsMenu.tsx | 12 + .../components/InteractiveFileDiff.tsx | 174 +++++--- .../components/PrCommentThread.tsx | 402 ++++++++++++++++++ .../code-review/hooks/usePrCommentActions.ts | 37 ++ .../renderer/features/code-review/types.ts | 35 +- .../code-review/utils/prCommentAnnotations.ts | 55 +++ .../code-review/utils/reviewPrompts.ts | 27 ++ .../editor/components/MarkdownRenderer.tsx | 8 +- .../git-interaction/hooks/usePrDetails.ts | 58 ++- pnpm-lock.yaml | 109 +++++ 16 files changed, 1002 insertions(+), 82 deletions(-) create mode 100644 apps/code/src/renderer/features/code-review/components/PrCommentThread.tsx create mode 100644 apps/code/src/renderer/features/code-review/hooks/usePrCommentActions.ts create mode 100644 apps/code/src/renderer/features/code-review/utils/prCommentAnnotations.ts diff --git a/apps/code/package.json b/apps/code/package.json index 77bbb14d8..159dab795 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -186,6 +186,8 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "reflect-metadata": "^0.2.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "smol-toml": "^1.6.0", diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index 255bdd84a..b0b1a1ad3 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -328,6 +328,48 @@ export const getPrDetailsByUrlOutput = z.object({ }); export type PrDetailsByUrlOutput = z.infer; +// getPrReviewComments schemas +export const prReviewCommentUserSchema = z.object({ + login: z.string(), + avatar_url: z.string(), +}); + +export const prReviewCommentSchema = z.object({ + id: z.number(), + body: z.string(), + path: z.string(), + line: z.number().nullable(), + original_line: z.number().nullable(), + side: z.enum(["LEFT", "RIGHT"]), + start_line: z.number().nullable(), + start_side: z.enum(["LEFT", "RIGHT"]).nullable(), + diff_hunk: z.string(), + in_reply_to_id: z.number().nullish(), + user: prReviewCommentUserSchema, + created_at: z.string(), + updated_at: z.string(), + subject_type: z.enum(["line", "file"]).nullable(), +}); + +export type PrReviewComment = z.infer; + +export const getPrReviewCommentsInput = z.object({ + prUrl: z.string(), +}); +export const getPrReviewCommentsOutput = z.array(prReviewCommentSchema); + +// replyToPrComment schemas +export const replyToPrCommentInput = z.object({ + prUrl: z.string(), + commentId: z.number(), + body: z.string(), +}); +export const replyToPrCommentOutput = z.object({ + success: z.boolean(), + comment: prReviewCommentSchema.nullable(), +}); +export type ReplyToPrCommentOutput = z.infer; + // updatePrByUrl schemas export const prActionType = z.enum(["close", "reopen", "ready", "draft"]); export type PrActionType = z.infer; diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 6a79be317..2c63b9e4b 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -56,10 +56,12 @@ import type { OpenPrOutput, PrActionType, PrDetailsByUrlOutput, + PrReviewComment, PrStatusOutput, PublishOutput, PullOutput, PushOutput, + ReplyToPrCommentOutput, SyncOutput, UpdatePrByUrlOutput, } from "./schemas"; @@ -982,6 +984,71 @@ export class GitService extends TypedEventEmitter { } } + public async getPrReviewComments(prUrl: string): Promise { + const pr = parsePrUrl(prUrl); + if (!pr) return []; + + const { owner, repo, number } = pr; + + try { + const result = await execGh([ + "api", + `repos/${owner}/${repo}/pulls/${number}/comments`, + "--paginate", + "--slurp", + ]); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to fetch PR review comments: ${result.stderr || result.error || "Unknown error"}`, + ); + } + + const pages = JSON.parse(result.stdout) as PrReviewComment[][]; + return pages.flat(); + } catch (error) { + log.warn("Failed to fetch PR review comments", { prUrl, error }); + throw error; + } + } + + public async replyToPrComment( + prUrl: string, + commentId: number, + body: string, + ): Promise { + const pr = parsePrUrl(prUrl); + if (!pr) { + return { success: false, comment: null }; + } + + try { + const result = await execGh([ + "api", + `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments/${commentId}/replies`, + "-X", + "POST", + "-f", + `body=${body}`, + ]); + + if (result.exitCode !== 0) { + log.warn("Failed to reply to PR comment", { + prUrl, + commentId, + error: result.stderr || result.error, + }); + return { success: false, comment: null }; + } + + const data = JSON.parse(result.stdout) as PrReviewComment; + return { success: true, comment: data }; + } catch (error) { + log.warn("Failed to reply to PR comment", { prUrl, commentId, error }); + return { success: false, comment: null }; + } + } + public async getBranchChangedFiles( repo: string, branch: string, diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 1041f7836..43f19d186 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -44,6 +44,8 @@ import { getPrChangedFilesOutput, getPrDetailsByUrlInput, getPrDetailsByUrlOutput, + getPrReviewCommentsInput, + getPrReviewCommentsOutput, getPrTemplateInput, getPrTemplateOutput, ghStatusOutput, @@ -58,6 +60,8 @@ import { pullOutput, pushInput, pushOutput, + replyToPrCommentInput, + replyToPrCommentOutput, searchGithubIssuesInput, searchGithubIssuesOutput, stageFilesInput, @@ -315,6 +319,18 @@ export const gitRouter = router({ getService().updatePrByUrl(input.prUrl, input.action), ), + getPrReviewComments: publicProcedure + .input(getPrReviewCommentsInput) + .output(getPrReviewCommentsOutput) + .query(({ input }) => getService().getPrReviewComments(input.prUrl)), + + replyToPrComment: publicProcedure + .input(replyToPrCommentInput) + .output(replyToPrCommentOutput) + .mutation(({ input }) => + getService().replyToPrComment(input.prUrl, input.commentId, input.body), + ), + getBranchChangedFiles: publicProcedure .input(getBranchChangedFilesInput) .output(getBranchChangedFilesOutput) diff --git a/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts b/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts index ed4cefe45..d5beaf142 100644 --- a/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts +++ b/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts @@ -11,6 +11,7 @@ interface DiffViewerStoreState { loadFullFiles: boolean; wordDiffs: boolean; hideWhitespaceChanges: boolean; + showReviewComments: boolean; } interface DiffViewerStoreActions { @@ -20,6 +21,7 @@ interface DiffViewerStoreActions { toggleLoadFullFiles: () => void; toggleWordDiffs: () => void; toggleHideWhitespaceChanges: () => void; + toggleShowReviewComments: () => void; } type DiffViewerStore = DiffViewerStoreState & DiffViewerStoreActions; @@ -32,6 +34,7 @@ export const useDiffViewerStore = create()( loadFullFiles: false, wordDiffs: true, hideWhitespaceChanges: false, + showReviewComments: true, setViewMode: (mode) => set((state) => { if (state.viewMode === mode) { @@ -64,6 +67,8 @@ export const useDiffViewerStore = create()( toggleWordDiffs: () => set((s) => ({ wordDiffs: !s.wordDiffs })), toggleHideWhitespaceChanges: () => set((s) => ({ hideWhitespaceChanges: !s.hideWhitespaceChanges })), + toggleShowReviewComments: () => + set((s) => ({ showReviewComments: !s.showReviewComments })), }), { name: "diff-viewer-storage", diff --git a/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx b/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx index 35236aa9d..001f7324f 100644 --- a/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx +++ b/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx @@ -1,3 +1,5 @@ +import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; +import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles"; import type { FileDiffMetadata } from "@pierre/diffs"; import { processFile } from "@pierre/diffs"; @@ -6,6 +8,7 @@ import { useReviewNavigationStore } from "@renderer/features/code-review/stores/ import type { ChangedFile, Task } from "@shared/types"; import { useMemo } from "react"; import type { DiffOptions } from "../types"; +import type { PrCommentThread } from "../utils/prCommentAnnotations"; import { InteractiveFileDiff } from "./InteractiveFileDiff"; import { DeferredDiffPlaceholder, @@ -23,8 +26,12 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { const isReviewOpen = useReviewNavigationStore( (s) => (s.reviewModes[taskId] ?? "closed") !== "closed", ); + const showReviewComments = useDiffViewerStore((s) => s.showReviewComments); const { effectiveBranch, prUrl, isRunActive, remoteFiles, isLoading } = useCloudChangedFiles(taskId, task, isReviewOpen); + const { commentThreads } = usePrDetails(prUrl, { + includeComments: isReviewOpen && showReviewComments, + }); const allPaths = useMemo(() => remoteFiles.map((f) => f.path), [remoteFiles]); @@ -44,23 +51,20 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { if (!prUrl && !effectiveBranch && remoteFiles.length === 0) { if (isRunActive) { return ( - - - - - Waiting for changes... - + + + + Waiting for changes... ); } - return ( - - - No file changes yet - - - ); + return null; } return ( @@ -105,6 +109,7 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { options={diffOptions} collapsed={isCollapsed} onToggle={() => toggleFile(file.path)} + commentThreads={showReviewComments ? commentThreads : undefined} /> ); @@ -120,6 +125,7 @@ function CloudFileDiff({ options, collapsed, onToggle, + commentThreads, }: { file: ChangedFile; taskId: string; @@ -127,6 +133,7 @@ function CloudFileDiff({ options: DiffOptions; collapsed: boolean; onToggle: () => void; + commentThreads?: Map; }) { const fileDiff = useMemo((): FileDiffMetadata | undefined => { if (!file.patch) return undefined; @@ -157,6 +164,8 @@ function CloudFileDiff({ fileDiff={fileDiff} options={{ ...options, collapsed }} taskId={taskId} + prUrl={prUrl} + commentThreads={commentThreads} renderCustomHeader={(fd) => ( s.toggleHideWhitespaceChanges, ); + const showReviewComments = useDiffViewerStore((s) => s.showReviewComments); + const toggleShowReviewComments = useDiffViewerStore( + (s) => s.toggleShowReviewComments, + ); return ( @@ -42,6 +46,14 @@ export function DiffSettingsMenu() { {hideWhitespaceChanges ? "Show whitespace" : "Hide whitespace"} + + + + {showReviewComments + ? "Hide review comments" + : "Show review comments"} + + ); diff --git a/apps/code/src/renderer/features/code-review/components/InteractiveFileDiff.tsx b/apps/code/src/renderer/features/code-review/components/InteractiveFileDiff.tsx index 3f8195b1e..76d91b49b 100644 --- a/apps/code/src/renderer/features/code-review/components/InteractiveFileDiff.tsx +++ b/apps/code/src/renderer/features/code-review/components/InteractiveFileDiff.tsx @@ -19,7 +19,77 @@ import { buildCommentMergedOptions, buildHunkAnnotations, } from "../utils/diffAnnotations"; +import { buildFileAnnotations } from "../utils/prCommentAnnotations"; import { CommentAnnotation } from "./CommentAnnotation"; +import { PrCommentThread } from "./PrCommentThread"; + +function renderSharedAnnotation( + annotation: DiffLineAnnotation, + filePath: string, + taskId: string, + prUrl: string | null, + reset: () => void, +): React.ReactNode { + if (annotation.metadata.kind === "comment") { + const { startLine, endLine, side } = annotation.metadata; + return ( + + ); + } + + if (annotation.metadata.kind === "pr-comment") { + return ( + + ); + } + + return null; +} + +function HunkRevertButton({ + isReverting, + onRevert, +}: { + isReverting: boolean; + onRevert: () => void; +}) { + return ( +
+ +
+ ); +} function isPatchDiff(props: InteractiveFileDiffProps): props is PatchDiffProps { return "fileDiff" in props && props.fileDiff != null; @@ -38,6 +108,8 @@ function PatchDiffView({ options, renderCustomHeader, taskId, + prUrl, + commentThreads, }: PatchDiffProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -70,13 +142,18 @@ function PatchDiffView({ () => (repoPath ? buildHunkAnnotations(fileDiff) : []), [fileDiff, repoPath], ); - const annotations = useMemo( + const prAnnotations = useMemo( () => - commentAnnotation - ? [...hunkAnnotations, commentAnnotation] - : hunkAnnotations, - [hunkAnnotations, commentAnnotation], + commentThreads + ? buildFileAnnotations(commentThreads, currentFilePath) + : [], + [commentThreads, currentFilePath], ); + const annotations = useMemo(() => { + const all = [...hunkAnnotations, ...prAnnotations]; + if (commentAnnotation) all.push(commentAnnotation); + return all; + }, [hunkAnnotations, prAnnotations, commentAnnotation]); const handleRevert = useCallback( async (hunkIndex: number) => { @@ -133,50 +210,25 @@ function PatchDiffView({ const renderAnnotation = useCallback( (annotation: DiffLineAnnotation) => { - if (annotation.metadata.kind === "comment") { - const { startLine, endLine, side } = annotation.metadata; + if (annotation.metadata.kind === "hunk-revert") { + const { hunkIndex } = annotation.metadata; return ( - handleRevert(hunkIndex)} /> ); } - if (annotation.metadata.kind !== "hunk-revert") return null; - const { hunkIndex } = annotation.metadata; - const isReverting = revertingHunks.has(hunkIndex); - - return ( -
- -
+ return renderSharedAnnotation( + annotation, + currentFilePath, + taskId ?? "", + prUrl ?? null, + reset, ); }, - [handleRevert, reset, revertingHunks, taskId, currentFilePath], + [handleRevert, revertingHunks, reset, taskId, prUrl, currentFilePath], ); const mergedOptions = useMemo( @@ -207,6 +259,8 @@ function FilesDiffView({ options, renderCustomHeader, taskId, + prUrl, + commentThreads, }: FilesDiffProps) { const { selectedRange, @@ -218,27 +272,27 @@ function FilesDiffView({ const filePath = newFile.name || oldFile.name; - const annotations = useMemo( - () => (commentAnnotation ? [commentAnnotation] : []), - [commentAnnotation], + const prAnnotations = useMemo( + () => + commentThreads ? buildFileAnnotations(commentThreads, filePath) : [], + [commentThreads, filePath], ); + const annotations = useMemo(() => { + const all = [...prAnnotations]; + if (commentAnnotation) all.push(commentAnnotation); + return all; + }, [prAnnotations, commentAnnotation]); const renderAnnotation = useCallback( - (annotation: DiffLineAnnotation) => { - if (annotation.metadata.kind !== "comment") return null; - const { startLine, endLine, side } = annotation.metadata; - return ( - - ); - }, - [reset, taskId, filePath], + (annotation: DiffLineAnnotation) => + renderSharedAnnotation( + annotation, + filePath, + taskId ?? "", + prUrl ?? null, + reset, + ), + [reset, taskId, prUrl, filePath], ); const mergedOptions = useMemo( diff --git a/apps/code/src/renderer/features/code-review/components/PrCommentThread.tsx b/apps/code/src/renderer/features/code-review/components/PrCommentThread.tsx new file mode 100644 index 000000000..51b519c92 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/PrCommentThread.tsx @@ -0,0 +1,402 @@ +import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; +import { sendPromptToAgent } from "@features/sessions/utils/sendPromptToAgent"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; +import type { PrReviewComment } from "@main/services/git/schemas"; +import { + CaretDown, + CaretUp, + ChatCircle, + File, + Robot, + WarningCircle, + X, +} from "@phosphor-icons/react"; +import { + Avatar, + Badge, + Box, + Button, + Flex, + IconButton, + Text, +} from "@radix-ui/themes"; +import { formatRelativeTimeShort } from "@utils/time"; +import { useCallback, useEffect, useRef, useState } from "react"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import type { PluggableList } from "unified"; +import { usePrCommentActions } from "../hooks/usePrCommentActions"; +import type { PrCommentMetadata } from "../types"; +import { + buildAskAboutPrCommentPrompt, + buildFixPrCommentPrompt, +} from "../utils/reviewPrompts"; + +const ghRehypePlugins: PluggableList = [ + rehypeRaw, + [rehypeSanitize, defaultSchema], +]; + +const MAX_COMMENT_HEIGHT = 120; + +interface ThreadActionBarProps { + prUrl: string | null; + taskId: string; + filePath: string; + endLine: number; + side: "old" | "new"; + comments: PrReviewComment[]; + showReplyBox: boolean; + pendingReply: string | null; + onShowReplyBox: () => void; + onHideReplyBox: () => void; + onSubmitReply: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + textareaRefCallback: (el: HTMLTextAreaElement | null) => void; +} + +function ThreadActionBar({ + prUrl, + taskId, + filePath, + endLine, + side, + comments, + showReplyBox, + pendingReply, + onShowReplyBox, + onHideReplyBox, + onSubmitReply, + onKeyDown, + textareaRefCallback, +}: ThreadActionBarProps) { + const agentActionsEnabled = useFeatureFlag("posthog-code-pr-agent-actions"); + + if (showReplyBox) { + return ( +
+