From 0b3171393bc5cf864e7907f5b5b615e160b8abf7 Mon Sep 17 00:00:00 2001 From: h0x91B Date: Mon, 3 Aug 2026 12:43:14 +0300 Subject: [PATCH] Add a per-comment Send to agent button in the diff review Each local inline review comment can now be sent to the agent on its own. A sent comment is marked with a persisted sentAt and leaves the review export payload, so the batch copy/send only carries comments not yet delivered; editing a comment clears the mark. --- ...ure-send-single-review-comment-to-agent.md | 3 + docs/ux/PRODUCT_UX_BIBLE.md | 3 +- docs/ux/UX_DECISIONS.md | 6 + docs/ux/ux-architecture.yaml | 14 +- src/mainview/components/TaskDiffViewer.tsx | 174 +++++++++++++----- .../__tests__/TaskDiffViewer.test.tsx | 57 ++++++ .../i18n/translations/en/infoPanel.ts | 8 +- .../i18n/translations/es/infoPanel.ts | 8 +- .../i18n/translations/ru/infoPanel.ts | 8 +- 9 files changed, 231 insertions(+), 50 deletions(-) create mode 100644 change-logs/2026/08/03/feature-send-single-review-comment-to-agent.md diff --git a/change-logs/2026/08/03/feature-send-single-review-comment-to-agent.md b/change-logs/2026/08/03/feature-send-single-review-comment-to-agent.md new file mode 100644 index 000000000..7731f738b --- /dev/null +++ b/change-logs/2026/08/03/feature-send-single-review-comment-to-agent.md @@ -0,0 +1,3 @@ +Short: Send one review comment at a time + +Every inline comment in the diff review now has its own "Send to agent" button, so a comment can go to the agent the moment it is written instead of waiting for the whole review. A sent comment is marked "Sent" and drops out of the batch payload, so "Copy to Clipboard" and the batch "Send to Agent" only carry comments that have not been sent yet; editing a sent comment makes it deliverable again. diff --git a/docs/ux/PRODUCT_UX_BIBLE.md b/docs/ux/PRODUCT_UX_BIBLE.md index 03dbd3658..1cc3d6de7 100644 --- a/docs/ux/PRODUCT_UX_BIBLE.md +++ b/docs/ux/PRODUCT_UX_BIBLE.md @@ -181,12 +181,13 @@ Layout = left **Files aside** (collapsible, `22rem`) + right **diff stream**. - **Files aside** contains two cards: the **Review export card** (top) and the **Files card** (read-progress + expand/collapse-all + the file tree). - **Per-file header (diff stream):** status chip (A/M/D/R/C/T/?), path (click = expand/collapse), **copy-file-path** icon button (role `neutral`/icon), `+N/−N` stat pill, **mark-read** checkbox (success-tinted when read), expand/collapse caret. - **Inline comments:** drag across the gutter to select a line range (or use the hover `+` widget for a single line) → composer opens → comment is added to a per-file/per-side/per-line thread. Threads render inline and are editable/deletable in place. +- **Per-comment actions (budget: 3, no growth):** `Edit` (`neutral`), `Delete` (`destructive`), `Send to agent` (`secondary`) — the same pattern as the GitHub thread action, pushing that single comment into the task terminal. Sending is per comment and **sticky**: the comment is marked `Sent`, persisted with the review, and leaves the export payload, so `Copy review` and the batch `Send to Agent` cover **only unsent** comments. Sent comments stay visible and re-readable in the export card (greyed, `Sent` badge); editing one clears the mark so the edited text can be delivered again. **Review export card — action hierarchy (the one budgeted cluster):** | Control | Role | Token | Visibility | |---|---|---|---| -| Copy review | `primary` (the single primary here) | `bg-accent` solid, success-tint on copied | always (disabled when 0 comments) | +| Copy review | `primary` (the single primary here) | `bg-accent` solid, success-tint on copied | always (disabled when 0 unsent comments) | | Reset review | `destructive`, low-emphasis | ghost-danger: `text-danger` + `border-danger/30` + `hover:bg-danger/10` | only when ≥ 1 comment; confirmation required | | Comment count | `status` | `bg-raised` mono badge | always | | Comment item | `link`-like (scroll-to) | `bg-raised/65`, accent on hover | per comment | diff --git a/docs/ux/UX_DECISIONS.md b/docs/ux/UX_DECISIONS.md index 68411026c..d7e0a5528 100644 --- a/docs/ux/UX_DECISIONS.md +++ b/docs/ux/UX_DECISIONS.md @@ -4,6 +4,12 @@ Compact index of UX architecture decisions — the *why* behind rules that live `PRODUCT_UX_BIBLE.md` / `ux-architecture.yaml`. Max ~5 lines per entry; details live in git history, PRs, and `decisions/NNN-*.md`. Newest first. +## 2026-08-03 — Local review comments send one-by-one; the batch send covers only unsent ones + +- **Rule:** Every local inline review comment carries its own `Send to agent` (role `secondary`, beside Edit/Delete, sending/sent states + toasts); a sent comment is marked `Sent` and leaves the Review export card's copy/batch-send payload, which is now scoped to unsent comments. Bible §5.3, yaml `surfaces.diff_review_viewer.inline_comment_actions`. +- **Why:** reviewers want a comment delivered the moment they write it, and a batch that still carries already-sent comments makes the agent re-handle them. Rejected: keeping send batch-only (the reported pain) and hiding sent comments from the card (they must stay visible and re-readable). +- **Status:** Decided. Evidence: `src/mainview/components/TaskDiffViewer.tsx`, `src/mainview/components/pr-review/GithubThreadView.tsx`. + ## 2026-08-01 — Split resize lives on the boundary, never in a toolbar - **Rule:** Native terminal split boundaries carry a `role="separator"` grab strip (resting grip, ≥9px hit target, `col-resize`/`row-resize`, ghost line, commit on release, Arrow-key steps) and no control anywhere else; absent for a single pane, a zoomed pane, and the narrow carousel. Bible §10 row `layout boundary manipulation`, yaml `surfaces.native_terminal_panes`. diff --git a/docs/ux/ux-architecture.yaml b/docs/ux/ux-architecture.yaml index cf169e17f..fc829ac6b 100644 --- a/docs/ux/ux-architecture.yaml +++ b/docs/ux/ux-architecture.yaml @@ -281,7 +281,7 @@ ux_architecture: diff_review_viewer: purpose: "Full-screen read + inline-review of a task's diff; export comments as an XML review prompt for the agent." reached_from: ["inspector:show_diff", "inspector:diff_summary_badge"] - allowed: ["view_mode_toggle", "diff_mode_toggle", "file_tree_nav", "in_diff_search", "mark_read", "copy_file_path", "inline_comment", "review_export", "review_copy", "review_reset", "github_review_layer_readonly", "pr_conversation_block", "send_thread_to_agent", "open_on_github_link"] + allowed: ["view_mode_toggle", "diff_mode_toggle", "file_tree_nav", "in_diff_search", "mark_read", "copy_file_path", "inline_comment", "review_export", "review_copy", "review_reset", "send_comment_to_agent", "github_review_layer_readonly", "pr_conversation_block", "send_thread_to_agent", "open_on_github_link"] forbidden: ["task_lifecycle_action", "git_mutation", "global_destination", "github_write_action"] layout: "left Files aside (collapsible, 22rem: Review-export card + Files card) + right diff stream" github_review_layer: @@ -293,10 +293,18 @@ ux_architecture: send_to_agent: { role: "secondary", note: "pushes the thread into the task terminal as a fix prompt; the surface primary stays review_copy" } open_on_github: { role: "link", note: "external deep link to the exact comment" } include_in_export: { role: "toggle", note: "opt-in selection into the existing XML export; exported entries carry an origin marker (github vs local)" } + inline_comment_actions: + note: "Actions on ONE local review comment, inside its bubble in the diff. Budget: 3 controls, no growth." + controls: + edit_comment: { role: "neutral", token: "border-edge + bg-base, hover:bg-elevated-hover" } + delete_comment: { role: "destructive", token: "text-danger + border-danger/25 + bg-danger/10" } + send_to_agent: { role: "secondary", token: "border-edge + bg-base; success-tint (border-success/40 + bg-success/10 + text-success) once sent", note: "same pattern as github thread_actions.send_to_agent — pushes this one comment into the task terminal; the surface primary stays review_copy" } + sent_state: + rule: "Sending is per comment and sticky: the comment is marked Sent (persisted with the review) and leaves the review_export payload, so review_copy / batch send cover ONLY unsent comments. Sent comments stay visible and re-readable in the export card, greyed with a Sent badge; editing a sent comment clears the mark so the edited text can be delivered again." review_export_card: - note: "The one budgeted action cluster on this surface. One primary only." + note: "The one budgeted action cluster on this surface. One primary only. Payload = unsent comments only." controls: - copy_review: { role: "primary", token: "bg-accent (solid), success-tint on copied", visible: "always; disabled at 0 comments" } + copy_review: { role: "primary", token: "bg-accent (solid), success-tint on copied", visible: "always; disabled at 0 unsent comments" } reset_review: { role: "destructive", token: "ghost-danger: text-danger + border-danger/30 + hover:bg-danger/10", visible: "only when >=1 comment; confirm() required" } comment_count: { role: "status", token: "bg-raised mono badge" } comment_item: { role: "link", token: "bg-raised/65, accent on hover; scroll-to-comment" } diff --git a/src/mainview/components/TaskDiffViewer.tsx b/src/mainview/components/TaskDiffViewer.tsx index 04e3fa7a4..8a0a7cd23 100644 --- a/src/mainview/components/TaskDiffViewer.tsx +++ b/src/mainview/components/TaskDiffViewer.tsx @@ -145,6 +145,8 @@ interface InlineDiffComment { startLine: number; endLine: number; side: InlineCommentSideKey; + /** Set once the comment was sent to the agent on its own; keeps it out of the batch export. */ + sentAt?: string; } interface InlineDiffCommentThread { @@ -176,6 +178,8 @@ interface InlineReviewExportEntry { origin: "local" | "github"; /** GitHub login of the thread's first commenter; null for local entries. */ author: string | null; + /** When this local comment was already sent to the agent on its own; null otherwise. */ + sentAt: string | null; } interface DiffSearchLineCandidate { @@ -228,6 +232,8 @@ interface TaskDiffFileSectionProps { onCancelEditComment: () => void; onSaveEditComment: (commentId: string, body: string) => void; onDeleteComment: (commentId: string) => void; + onSendComment: (commentId: string) => void; + sendingCommentIds: Record; onToggleExpanded: () => void; onToggleRead: () => void; /** True while a markdown file renders its rendered preview instead of the source diff. */ @@ -472,6 +478,31 @@ function hasAnyInlineComments(state: InlineDiffCommentsState): boolean { return false; } +/** Rewrites one comment anywhere in the review state, leaving every other entry untouched. */ +function mapInlineComment( + state: InlineDiffCommentsState, + commentId: string, + transform: (comment: InlineDiffComment) => InlineDiffComment, +): InlineDiffCommentsState { + const nextState: InlineDiffCommentsState = {}; + for (const [fileId, fileComments] of Object.entries(state)) { + const nextFileComments = createEmptyInlineCommentFileData(); + for (const side of ["oldFile", "newFile"] as const) { + for (const [lineNumber, thread] of Object.entries(fileComments[side])) { + nextFileComments[side][lineNumber] = { + data: { + comments: thread.data.comments.map((comment) => ( + comment.id === commentId ? transform(comment) : comment + )), + }, + }; + } + } + nextState[fileId] = nextFileComments; + } + return nextState; +} + function buildInlineReviewExportEntries( files: TaskDiffFile[], inlineComments: InlineDiffCommentsState, @@ -501,6 +532,7 @@ function buildInlineReviewExportEntries( createdAt: comment.createdAt, origin: "local", author: null, + sentAt: comment.sentAt ?? null, }); } } @@ -556,6 +588,7 @@ function buildGithubReviewExportEntries( createdAt: thread.comments[0]?.createdAt ?? "", origin: "github", author: thread.comments[0]?.author ?? null, + sentAt: null, }); } @@ -606,6 +639,8 @@ function InlineCommentThreadView({ onCancelEdit, onSaveEdit, onDeleteComment, + onSendComment, + sendingCommentIds, }: { thread: InlineDiffCommentThread; side: InlineCommentSideKey; @@ -616,6 +651,8 @@ function InlineCommentThreadView({ onCancelEdit: () => void; onSaveEdit: (commentId: string, body: string) => void; onDeleteComment: (commentId: string) => void; + onSendComment: (commentId: string) => void; + sendingCommentIds: Record; }) { const t = useT(); const editTextareaRef = useRef(null); @@ -694,6 +731,29 @@ function InlineCommentThreadView({ {comment.body}
+
- - {reviewExportEntries.length} + + + {pendingReviewExportEntries.length} + + {sentReviewCommentCount > 0 && ( + + {t("infoPanel.diffReviewSentCount", { count: String(sentReviewCommentCount) })} + + )} @@ -3511,10 +3591,11 @@ function TaskDiffViewer({ task, project, request, onBack, navigationGuardRef }: scrollToComment(entry.id, entry.fileId); }} aria-label={isEditing ? undefined : t("infoPanel.diffReviewCommentItem", { number: String(index + 1) })} + data-testid={entry.sentAt ? "review-export-item-sent" : "review-export-item"} className={`rounded-lg border px-3 py-2 space-y-2 ${ isEditing ? "border-accent/40 bg-accent/10" - : "border-edge bg-raised/65 cursor-pointer transition-colors hover:border-accent/30 hover:bg-accent/5 focus:outline-none focus:ring-1 focus:ring-accent/40" + : `border-edge bg-raised/65 cursor-pointer transition-colors hover:border-accent/30 hover:bg-accent/5 focus:outline-none focus:ring-1 focus:ring-accent/40${entry.sentAt ? " opacity-60" : ""}` }`} >
@@ -3531,6 +3612,11 @@ function TaskDiffViewer({ task, project, request, onBack, navigationGuardRef }: {entry.author ?? "GitHub"} )} + {entry.sentAt && ( + + {t("infoPanel.diffReviewSendCommentSent")} + + )}
@@ -3553,7 +3639,7 @@ function TaskDiffViewer({ task, project, request, onBack, navigationGuardRef }: