From f4e6f122a6bc8635dacfad0df1da7e031b5d60b2 Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 30 Jul 2026 12:21:09 -0300 Subject: [PATCH 1/2] feat: batch review comments into one prompt for the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A diff could hand the session a reference — a file, a line range — but never an instruction, so anything that actually had to change was typed out by hand while looking straight at it. Right-clicking a selection now also offers "Comment on lines", in both review surfaces: the Review dock's working diff and a pull request's Files changed. The note is held against those lines rather than written out. Comments collect at the foot of the panel in the order the diff was read, each removable on its own, and one Send hands the batch over as a single prompt. The batch goes in as a bracketed paste. Newlines are what make the block readable and also what a TUI reads as send — one per line — so the envelope xterm puts around text pasted by hand is what keeps the batch one paste, and unsent: Enter stays the user's. Verified against a real Claude Code, which takes it as "[Pasted text #1 +N lines]". Comments live in a module-level store keyed by what is under review — the checkout's path, or the pull request's URL — in memory only: an anchor is a line number of the diff as it was rendered, and outliving a restart would mean outliving every edit made in between. useInject now answers whether the write landed, so a Send with no session to take it says so and keeps the comments instead of dropping them. Refs #119 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 ++ frontend/src/components/diff/CommentBatch.tsx | 88 +++++++++++++++ .../src/components/diff/CommentDialog.tsx | 81 ++++++++++++++ frontend/src/components/diff/FileDiff.tsx | 69 ++++++++---- frontend/src/components/diff/InjectMenu.tsx | 16 ++- frontend/src/components/diff/ReviewPanel.tsx | 29 +++-- .../src/components/pulls/PullRequestView.tsx | 3 +- frontend/src/components/pulls/PullsFiles.tsx | 12 ++- frontend/src/lib/review-comments.test.ts | 80 ++++++++++++++ frontend/src/lib/review-comments.ts | 102 ++++++++++++++++++ frontend/src/lib/use-inject.ts | 11 +- 11 files changed, 464 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/diff/CommentBatch.tsx create mode 100644 frontend/src/components/diff/CommentDialog.tsx create mode 100644 frontend/src/lib/review-comments.test.ts create mode 100644 frontend/src/lib/review-comments.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b531f9d80..ec43b0568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Review comments, handed to the session as one prompt.** A diff could give + the session a *reference* — a file, a line range — but never an instruction, + so anything you actually wanted changed had to be typed out by hand while + looking straight at it. Right-clicking a selection, in the Review dock or in a + pull request's Files changed, now also offers *Comment on lines*: the note is + held against those lines instead of being written out. Comments collect at the + foot of the panel in the order you read the diff, each removable on its own, + and one *Send* hands the whole batch over as a single prompt — pasted into the + session unsent, so the last word before the agent starts is still yours. They + outlive leaving the tab but not a restart: a line number written before an + edit points somewhere else after it. - **Every worktree gets a dev-server port of its own.** Two worktrees of one project both ran `pnpm dev` and both wanted the same port, so the second one lost — and the setup script that had just installed the dependencies had no diff --git a/frontend/src/components/diff/CommentBatch.tsx b/frontend/src/components/diff/CommentBatch.tsx new file mode 100644 index 000000000..273ea9600 --- /dev/null +++ b/frontend/src/components/diff/CommentBatch.tsx @@ -0,0 +1,88 @@ +import { Send, X } from "lucide-react" +import { useSyncExternalStore } from "react" +import { toast } from "sonner" +import { IconAction } from "@/components/common/IconAction" +import { Button } from "@/components/ui/button" +import { splitPath } from "@/lib/git/lang-badge" +import { + clearReviewComments, + composeReviewComments, + removeReviewComment, + reviewComments, + subscribeReviewComments, +} from "@/lib/review-comments" + +interface CommentBatchProps { + /** What is under review: the checkout's path, or the pull request's URL. */ + target: string + /** Write into the session's terminal; false when no session took it. */ + onInject: (text: string) => boolean +} + +// CommentBatch is the strip at the foot of a review panel: the comments written +// so far, and the one action that hands them all to the session as a single +// prompt. Nothing at all until the first comment is written. +export function CommentBatch({ target, onInject }: CommentBatchProps) { + const comments = useSyncExternalStore(subscribeReviewComments, () => reviewComments(target)) + + if (comments.length === 0) { + return null + } + + // Comments are the one thing here the user typed, so they survive a failed + // send: only a write the session took clears them. + const send = () => { + if (!onInject(composeReviewComments(comments))) { + toast.error("Open a session to send these comments to") + return + } + clearReviewComments(target) + } + + return ( +
+
    + {comments.map((comment) => ( +
  • + + {splitPath(comment.path).base}:{comment.lines} + + + {comment.text} + + + removeReviewComment(target, comment.id)} + > + + + +
  • + ))} +
+
+ + {comments.length} {comments.length === 1 ? "comment" : "comments"} + + + + + +
+
+ ) +} diff --git a/frontend/src/components/diff/CommentDialog.tsx b/frontend/src/components/diff/CommentDialog.tsx new file mode 100644 index 000000000..c17ac680e --- /dev/null +++ b/frontend/src/components/diff/CommentDialog.tsx @@ -0,0 +1,81 @@ +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +/** Where a comment lands: the file, and the new-file lines the selection covered. */ +export interface CommentAnchor { + path: string + lines: string +} + +interface CommentDialogProps { + /** The lines being commented on, or null while the dialog is closed. */ + anchor: CommentAnchor | null + onCancel: () => void + onAdd: (text: string) => void +} + +// CommentDialog collects one line comment — the note the user would otherwise +// have typed into the terminal while looking at these lines. Text only: where +// the comment goes, and when the batch is sent, stay the panel's. +export function CommentDialog({ anchor, onCancel, onAdd }: CommentDialogProps) { + const [text, setText] = useState("") + + const dismiss = () => { + setText("") + onCancel() + } + + const add = () => { + if (text.trim() === "") { + return + } + onAdd(text) + setText("") + } + + return ( + !next && dismiss()}> + + + Comment + + {anchor && `${anchor.path}:${anchor.lines}`} + + +