feat(agent-manager): improve PR comment interactions

This commit is contained in:
marius-kilocode
2026-08-19 14:12:47 +02:00
parent c356c2f78b
commit ea4f9e0f0d
48 changed files with 1901 additions and 242 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Rework PR review comments in the Agent Manager PR panel: resolved threads now collapse into one-line rows in a Resolved group instead of being dimmed, each thread shows its replies, and every card has prominent Send to agent, Resolve, Copy, Open file, and Open on GitHub actions. A single button sends all unresolved comments to the agent, and comments arrive as structured review comments instead of pasted text.
@@ -5,7 +5,14 @@ import { execWithShellEnv } from "./shell-env"
import { execGhRead } from "./gh"
import { classifyPRError } from "./git-import"
import type { Semaphore } from "./semaphore"
import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./pr/am-pr-utils"
import {
parsePRResult,
checkStatus,
commentsSig,
formatCheckDuration,
parseComments,
parseReviewers,
} from "./pr/am-pr-utils"
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./pr/am-pr-types"
interface PRStatusPollerOptions {
@@ -273,7 +280,7 @@ export class PRStatusPoller {
}
const reviewersSig = reviewers.map((r) => `${r.login}:${r.state}`).join(",")
const hash = `${worktreeId}:${pr.number}:${pr.title}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewersSig}:${pr.body ?? ""}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}`
const hash = `${worktreeId}:${pr.number}:${pr.title}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewersSig}:${pr.body ?? ""}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}:${commentsSig(comments?.comments)}`
if (this.lastHash.get(worktreeId) === hash) return
this.lastHash.set(worktreeId, hash)
@@ -489,13 +496,15 @@ export class PRStatusPoller {
nodes {
id
isResolved
comments(first: 1) {
isOutdated
comments(first: 10) {
nodes {
id
author { login avatarUrl }
body
path
line
originalLine
url
createdAt
diffHunk
@@ -10,6 +10,7 @@ import type { Disposable } from "./host"
import type { Semaphore } from "./semaphore"
import { PRStatusPoller } from "./PRStatusPoller"
import { resolveComment, unresolveComment } from "./pr/PRActions"
import { ghErrorReason } from "./pr/am-pr-utils"
interface PRBridgeHost {
getWorktrees(): Worktree[]
@@ -124,6 +125,7 @@ export class PRStatusBridge {
worktreeId: id,
threadId,
success: false,
error: ghErrorReason(msg),
})
},
)
@@ -12,6 +12,7 @@ export interface GhComment {
body?: string
path?: string
line?: number
originalLine?: number
url?: string
createdAt?: string
diffHunk?: string
@@ -19,6 +20,7 @@ export interface GhComment {
export interface GhThread {
id?: string
isResolved?: boolean
isOutdated?: boolean
comments?: { nodes?: GhComment[] }
}
export interface GhReviewRequest {
@@ -69,8 +69,10 @@ const REVIEWER_STATE: Record<string, ReviewerState> = {
export function parseComments(threads: GhThread[]): PRComment[] {
const items: PRComment[] = []
for (const thread of threads) {
const first = thread.comments?.nodes?.[0]
const nodes = thread.comments?.nodes ?? []
const first = nodes[0]
if (!first) continue
const replies = nodes.slice(1).map((node) => ({ author: node.author?.login ?? "unknown", body: node.body ?? "" }))
items.push({
id: first.id,
threadId: thread.id ?? first.id,
@@ -78,11 +80,14 @@ export function parseComments(threads: GhThread[]): PRComment[] {
avatar: first.author?.avatarUrl,
body: first.body ?? "",
file: first.path,
line: first.line,
// An outdated thread has no current line, so fall back to the line it was written against.
line: first.line ?? first.originalLine,
url: first.url,
resolved: thread.isResolved ?? false,
outdated: thread.isOutdated ?? false,
createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined,
diffHunk: first.diffHunk,
replies: replies.length > 0 ? replies : undefined,
})
}
return items
@@ -105,3 +110,30 @@ export function parseReviewers(requests: GhReviewRequest[], reviews: GhReview[])
}
return [...map.values()]
}
/**
* Short, user-facing reason from a failed `gh` invocation. The raw message
* repeats the whole command line, which is useless inside a comment card.
*/
export function ghErrorReason(message: string): string {
const lines = message
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("Command failed"))
const last = [...lines].reverse().find((line) => !line.startsWith("query") && !line.startsWith("mutation"))
return (last ?? message.trim()).replace(/^gh:\s*/, "").slice(0, 200)
}
/**
* Signature of the comment threads, for poll deduplication. Thread and
* unresolved counts alone hide edits and new replies, which the panel renders.
*/
export function commentsSig(comments?: PRComment[]): string {
if (!comments) return ""
return comments
.map(
(item) =>
`${item.threadId}:${item.resolved ? 1 : 0}${item.outdated ? "o" : ""}:${item.line ?? ""}:${item.body.length}:${(item.replies ?? []).map((reply) => reply.body.length).join("/")}`,
)
.join(",")
}
@@ -59,6 +59,11 @@ export interface PRCheck {
duration?: string
}
export interface PRCommentReply {
author: string
body: string
}
export interface PRComment {
id: string
threadId: string
@@ -69,8 +74,10 @@ export interface PRComment {
line?: number
url?: string
resolved: boolean
outdated: boolean
createdAt?: number
diffHunk?: string
replies?: PRCommentReply[]
}
export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented"
@@ -7,9 +7,33 @@ export interface ReviewCommentData {
selectedText: string
}
export interface PRReviewReply {
author: string
body: string
}
/** A GitHub PR review thread handed to the agent from the Agent Manager PR panel. */
export interface PRReviewCommentData {
id: string
origin: "pr"
author: string
body: string
file?: string
line?: number
diffHunk?: string
outdated?: boolean
replies?: PRReviewReply[]
}
export type ReviewCommentEntry = ReviewCommentData | PRReviewCommentData
export function isPRReviewComment(item: ReviewCommentEntry): item is PRReviewCommentData {
return "origin" in item && item.origin === "pr"
}
export interface ReviewMessageData {
version: 1
comments: ReviewCommentData[]
comments: ReviewCommentEntry[]
}
interface ReviewMessageView {
@@ -21,24 +45,48 @@ const LIMIT = 100
const TOTAL_LIMIT = 1_000_000
const TEXT_LIMIT = 100_000
const SELECTION_LIMIT = 200_000
const AUTHOR_LIMIT = 256
const REPLY_LIMIT = 20
function escapeInline(value: string): string {
return value.replace(/([\\`*_\[\]{}()#+\-!|])/g, "\\$1")
}
export function formatReviewCommentMarkdown(comment: ReviewCommentData): string {
/** Wrap a snippet in a fence long enough to survive backticks inside it. */
function fenced(value: string): string[] {
const matches = value.match(/`+/g) ?? []
const longest = matches.reduce((max, item) => Math.max(max, item.length), 0)
const fence = "`".repeat(Math.max(3, longest + 1))
return [fence, value, fence]
}
function quote(value: string): string {
return value
.split("\n")
.map((line) => (line ? `> ${line}` : ">"))
.join("\n")
}
function formatPR(comment: PRReviewCommentData): string {
const at = comment.file
? `**${escapeInline(comment.file)}**${comment.line ? ` (line ${comment.line})` : ""}, PR comment`
: "PR comment"
const lines = [`${at} by @${comment.author}${comment.outdated ? " (outdated)" : ""}:`]
if (comment.diffHunk) lines.push(...fenced(comment.diffHunk))
lines.push(comment.body)
for (const reply of comment.replies ?? []) lines.push("", quote(`@${reply.author}: ${reply.body}`))
return lines.join("\n")
}
export function formatReviewCommentMarkdown(comment: ReviewCommentEntry): string {
if (isPRReviewComment(comment)) return formatPR(comment)
const lines = [`**${escapeInline(comment.file)}** (line ${comment.line}):`]
if (comment.selectedText) {
const matches = comment.selectedText.match(/`+/g) ?? []
const longest = matches.reduce((max, item) => Math.max(max, item.length), 0)
const fence = "`".repeat(Math.max(3, longest + 1))
lines.push(fence, comment.selectedText, fence)
}
if (comment.selectedText) lines.push(...fenced(comment.selectedText))
lines.push(comment.comment)
return lines.join("\n")
}
export function formatReviewCommentsMarkdown(comments: ReviewCommentData[]): string {
export function formatReviewCommentsMarkdown(comments: ReviewCommentEntry[]): string {
const lines = ["## Review Comments", ""]
for (const item of comments) {
lines.push(formatReviewCommentMarkdown(item), "")
@@ -56,9 +104,80 @@ function text(value: unknown, limit: number): string | undefined {
return value
}
function parseComment(value: unknown): ReviewCommentData | undefined {
function safe(file: string): boolean {
const absolute = file.startsWith("/") || file.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(file)
const traversal = file.split(/[\\/]/).includes("..")
return !absolute && !traversal && !file.includes("\0")
}
function parseReply(value: unknown): PRReviewReply | undefined {
const item = record(value)
if (!item) return undefined
const author = text(item.author, AUTHOR_LIMIT)
const body = text(item.body, TEXT_LIMIT)
if (!author || body === undefined) return undefined
return { author, body }
}
function parseReplies(value: unknown): PRReviewReply[] | undefined {
if (!Array.isArray(value) || value.length > REPLY_LIMIT) return undefined
const list: PRReviewReply[] = []
for (const item of value) {
const reply = parseReply(item)
if (!reply) return undefined
list.push(reply)
}
return list
}
/** Optional PR field: `undefined` when absent, `false` when present but invalid. */
function optional(value: unknown, limit: number, valid?: (item: string) => boolean): string | false | undefined {
if (value === undefined) return undefined
const item = text(value, limit)
if (item === undefined) return false
if (valid && !valid(item)) return false
return item
}
function optionalLine(value: unknown): number | false | undefined {
if (value === undefined) return undefined
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) return false
return value
}
function parsePR(item: Record<string, unknown>): PRReviewCommentData | undefined {
const id = text(item.id, 512)
const author = text(item.author, AUTHOR_LIMIT)
const body = text(item.body, TEXT_LIMIT)
if (!id || !author || body === undefined) return undefined
const file = optional(item.file, 4_096, safe)
const hunk = optional(item.diffHunk, SELECTION_LIMIT)
const line = optionalLine(item.line)
if (file === false || hunk === false || line === false) return undefined
if (item.outdated !== undefined && typeof item.outdated !== "boolean") return undefined
const replies = item.replies === undefined ? undefined : parseReplies(item.replies)
if (item.replies !== undefined && !replies) return undefined
return {
id,
origin: "pr",
author,
body,
file,
line,
diffHunk: hunk,
outdated: item.outdated,
replies,
}
}
function parseComment(value: unknown): ReviewCommentEntry | undefined {
const item = record(value)
if (!item) return undefined
if (item.origin === "pr") return parsePR(item)
if (item.origin !== undefined) return undefined
const id = text(item.id, 512)
const file = text(item.file, 4_096)
@@ -67,30 +186,39 @@ function parseComment(value: unknown): ReviewCommentData | undefined {
const side = item.side
const line = item.line
if (!id || !file || comment === undefined || selectedText === undefined) return undefined
const absolute = file.startsWith("/") || file.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(file)
const traversal = file.split(/[\\/]/).includes("..")
if (absolute || traversal || file.includes("\0")) return undefined
if (!safe(file)) return undefined
if (side !== "additions" && side !== "deletions") return undefined
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) return undefined
return { id, file, side, line, comment, selectedText }
}
function weight(item: ReviewCommentEntry): number {
if (!isPRReviewComment(item))
return item.id.length + item.file.length + item.comment.length + item.selectedText.length
const replies = (item.replies ?? []).reduce((total, reply) => total + reply.author.length + reply.body.length, 0)
return (
item.id.length +
item.author.length +
item.body.length +
(item.file?.length ?? 0) +
(item.diffHunk?.length ?? 0) +
replies
)
}
function view(value: unknown, content: string): ReviewMessageView | undefined {
const data = record(value)
if (!data || data.version !== 1 || !Array.isArray(data.comments)) return undefined
if (data.comments.length === 0 || data.comments.length > LIMIT) return undefined
const comments: ReviewCommentData[] = []
const comments: ReviewCommentEntry[] = []
for (const value of data.comments) {
const item = parseComment(value)
if (!item) return undefined
comments.push(item)
}
const size = comments.reduce(
(total, item) => total + item.id.length + item.file.length + item.comment.length + item.selectedText.length,
0,
)
const size = comments.reduce((total, item) => total + weight(item), 0)
if (size > TOTAL_LIMIT) return undefined
const prefix = formatReviewCommentsMarkdown(comments)
+74 -16
View File
@@ -29,14 +29,15 @@ Object.assign(globalThis, {
CustomEvent: window.CustomEvent,
Event: window.Event,
MouseEvent: window.MouseEvent,
MessageEvent: window.MessageEvent,
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
})
const { render } = await import("solid-js/web")
const { I18nProvider } = await import("@kilocode/kilo-ui/context")
const { MarkedProvider } = await import("@kilocode/kilo-ui/context/marked")
const { VSCodeProvider } = await import("../../webview-ui/src/context/vscode")
const { LanguageProvider } = await import("../../webview-ui/src/context/language")
const { PRComments } = await import("../../webview-ui/agent-manager/pr/PRComments")
const root = document.createElement("div")
@@ -45,56 +46,113 @@ colors.textContent = ":root { --syntax-keyword: rgb(72, 160, 199); --syntax-stri
document.head.append(colors)
document.body.append(root)
const HUNK =
'@@ -1 +1,14 @@\n+import { File as BaseFile, type FileProps } from "@opencode-ai/ui/file"\n+import type { JSX } from "solid-js"\n+import { createDefaultOptions } from "../pierre"\n+\n export * from "@opencode-ai/ui/file"\n+\n+export function File<T>(props: FileProps<T>) {\n+ const View = BaseFile as unknown as (props: FileProps<T>) => JSX.Element\n+ if (props.mode === "text") return <View {...props} />\n+\n+ // Keep inline file diffs on the same Pierre defaults as the dedicated viewer.\n+ const options = { ...createDefaultOptions<T>(props.diffStyle), ...props } as FileProps<T>\n'
const sent: unknown[] = []
window.addEventListener("message", (ev: MessageEvent) => {
if (ev.data?.type === "appendReviewComments") sent.push(ev.data)
})
const dispose = render(
() => (
<VSCodeProvider>
<I18nProvider
value={
{
locale: () => "en",
t: (key: string) => key,
plural: (key: string) => key,
} as never
}
>
<LanguageProvider>
<MarkedProvider>
<PRComments
worktreeId="wt-test"
comments={{
total: 1,
total: 2,
unresolved: 1,
comments: [
{
id: "PRRC_test",
threadId: "PRRT_test",
id: "PRRC_open",
threadId: "PRRT_open",
author: "kilo-code-bot",
body: "comment body survives Pierre rendering",
file: "packages/kilo-ui/src/components/file.tsx",
line: 14,
resolved: false,
diffHunk:
'@@ -1 +1,14 @@\n+import { File as BaseFile, type FileProps } from "@opencode-ai/ui/file"\n+import type { JSX } from "solid-js"\n+import { createDefaultOptions } from "../pierre"\n+\n export * from "@opencode-ai/ui/file"\n+\n+export function File<T>(props: FileProps<T>) {\n+ const View = BaseFile as unknown as (props: FileProps<T>) => JSX.Element\n+ if (props.mode === "text") return <View {...props} />\n+\n+ // Keep inline file diffs on the same Pierre defaults as the dedicated viewer.\n+ const options = { ...createDefaultOptions<T>(props.diffStyle), ...props } as FileProps<T>\n',
outdated: false,
diffHunk: HUNK,
replies: [{ author: "marius", body: "reply body is visible" }],
},
{
id: "PRRC_done",
threadId: "PRRT_done",
author: "reviewer",
body: "settled discussion\n\nsecond paragraph only shows when expanded",
file: "packages/kilo-ui/src/components/other.tsx",
line: 3,
resolved: true,
outdated: false,
},
],
}}
/>
</MarkedProvider>
</I18nProvider>
</LanguageProvider>
</VSCodeProvider>
),
root,
)
await window.happyDOM.waitUntilComplete()
// The unresolved thread renders expanded, with its hunk and its replies.
const host = root.querySelector("diffs-container")
const shadow = host?.shadowRoot
const keyword = shadow?.querySelector('[data-content] span[style*="--syntax-keyword"]')
const string = shadow?.querySelector('[data-content] span[style*="--syntax-string"]')
assert.match(root.textContent ?? "", /comment body survives Pierre rendering/)
assert.match(root.textContent ?? "", /reply body is visible/)
assert.equal(root.querySelectorAll('[data-component="diff"]').length, 1)
assert.ok(keyword)
assert.ok(string)
assert.match(keyword!.getAttribute("style") ?? "", /--syntax-keyword/)
assert.match(string!.getAttribute("style") ?? "", /--syntax-string/)
assert.notEqual(keyword!.getAttribute("style"), string!.getAttribute("style"))
// The resolved thread is hidden behind a collapsed group.
assert.doesNotMatch(root.textContent ?? "", /settled discussion/)
const groups = [...root.querySelectorAll(".am-pr-panel-section-toggle")]
const resolvedGroup = groups.find((node) => /Resolved \(1\)/.test(node.textContent ?? ""))
assert.ok(resolvedGroup, "resolved group heading is present")
;(resolvedGroup as HTMLButtonElement).click()
await window.happyDOM.waitUntilComplete()
// Opening the group reveals a one-line row, not the whole card.
const rows = [...root.querySelectorAll(".am-pr-comment-head")]
const resolvedRow = rows.find((node) => /reviewer/.test(node.textContent ?? ""))
assert.ok(resolvedRow, "resolved row is present")
assert.equal(resolvedRow!.getAttribute("aria-expanded"), "false")
assert.ok(resolvedRow!.querySelector(".am-pr-comment-preview"), "collapsed row shows a preview")
assert.doesNotMatch(root.textContent ?? "", /second paragraph only shows when expanded/)
// The row expands into a full card whose unresolve action is enabled.
;(resolvedRow as HTMLButtonElement).click()
await window.happyDOM.waitUntilComplete()
assert.equal(resolvedRow!.getAttribute("aria-expanded"), "true")
assert.match(root.textContent ?? "", /second paragraph only shows when expanded/)
const card = resolvedRow!.parentElement!
const actions = [...card.querySelectorAll('[data-component="button"]')]
const unresolve = actions.find((node) => /Unresolve/.test(node.textContent ?? ""))
assert.ok(unresolve, "unresolve button is rendered")
assert.equal((unresolve as HTMLButtonElement).disabled, false)
assert.equal(unresolve!.getAttribute("data-disabled"), null)
// Send to agent hands the thread over as a structured review comment.
const send = [...root.querySelectorAll('[data-component="button"]')].find((node) =>
/Send to agent/.test(node.textContent ?? ""),
)
assert.ok(send, "send button is rendered")
;(send as HTMLButtonElement).click()
await window.happyDOM.waitUntilComplete()
assert.equal(sent.length, 1)
const payload = sent[0] as { comments: { id: string; origin: string; author: string; replies?: unknown[] }[] }
assert.equal(payload.comments.length, 1)
assert.equal(payload.comments[0]!.origin, "pr")
assert.equal(payload.comments[0]!.id, "PRRT_open")
assert.equal(payload.comments[0]!.replies?.length, 1)
dispose()
@@ -2,11 +2,14 @@ import { describe, expect, it } from "bun:test"
import {
parsePRResult,
checkStatus,
commentsSig,
formatCheckDuration,
ghErrorReason,
parseComments,
parseReviewers,
} from "../../src/agent-manager/pr/am-pr-utils"
import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/pr/am-pr-types"
import type { PRComment } from "../../src/agent-manager/types"
// --- parsePRResult ---
@@ -248,8 +251,10 @@ describe("parseComments", () => {
line: 10,
url: "https://url",
resolved: true,
outdated: false,
createdAt: new Date("2024-01-01T00:00:00Z").getTime(),
diffHunk: undefined,
replies: undefined,
},
])
})
@@ -278,15 +283,15 @@ describe("parseComments", () => {
expect(parseComments(threads)[0]?.author).toBe("unknown")
})
it("only uses the first comment of each thread", () => {
it("keeps later thread comments as replies of the first one", () => {
const threads: GhThread[] = [
{
id: "PRT_t2",
isResolved: false,
comments: {
nodes: [
{ id: "first", body: "first comment" },
{ id: "second", body: "second comment" },
{ id: "first", body: "first comment", author: { login: "alice" } },
{ id: "second", body: "second comment", author: { login: "bob" } },
],
},
},
@@ -294,6 +299,76 @@ describe("parseComments", () => {
const result = parseComments(threads)
expect(result).toHaveLength(1)
expect(result[0]?.id).toBe("first")
expect(result[0]?.replies).toEqual([{ author: "bob", body: "second comment" }])
})
it("marks an outdated thread", () => {
const threads: GhThread[] = [
{ id: "PRT_t3", isResolved: false, isOutdated: true, comments: { nodes: [{ id: "c4", body: "stale" }] } },
]
expect(parseComments(threads)[0]?.outdated).toBe(true)
})
it("falls back to the original line when the thread has no current line", () => {
const threads: GhThread[] = [
{
id: "PRT_t4",
isResolved: false,
isOutdated: true,
comments: { nodes: [{ id: "c5", body: "moved", path: "src/foo.ts", originalLine: 42 }] },
},
]
expect(parseComments(threads)[0]?.line).toBe(42)
})
})
// --- commentsSig ---
describe("commentsSig", () => {
const thread = (overrides: Partial<PRComment> = {}): PRComment => ({
id: "c1",
threadId: "PRRT_1",
author: "alice",
body: "looks good",
resolved: false,
outdated: false,
...overrides,
})
it("returns an empty signature when there are no comments", () => {
expect(commentsSig()).toBe("")
})
it("changes when a reply is added, which thread counts alone cannot detect", () => {
const before = commentsSig([thread()])
const after = commentsSig([thread({ replies: [{ author: "bob", body: "guard it" }] })])
expect(after).not.toBe(before)
})
it("changes when a body is edited or a thread moves line", () => {
expect(commentsSig([thread({ body: "looks good!" })])).not.toBe(commentsSig([thread()]))
expect(commentsSig([thread({ line: 5 })])).not.toBe(commentsSig([thread()]))
})
it("stays stable for unchanged comments", () => {
expect(commentsSig([thread()])).toBe(commentsSig([thread()]))
})
})
// --- ghErrorReason ---
describe("ghErrorReason", () => {
it("keeps the last meaningful line and strips the gh prefix", () => {
const message = "Command failed: gh api graphql -f query=mutation...\ngh: Resource not accessible by integration"
expect(ghErrorReason(message)).toBe("Resource not accessible by integration")
})
it("falls back to the raw message when there is nothing else", () => {
expect(ghErrorReason(" boom ")).toBe("boom")
})
it("truncates very long output", () => {
expect(ghErrorReason("x".repeat(500)).length).toBe(200)
})
})
@@ -9,7 +9,7 @@ const WEBVIEW = path.join(ROOT, "webview-ui")
const FIXTURE = path.join(ROOT, "tests/fixtures/pr-comments-render.tsx")
describe("PR comments", () => {
it("keeps a comment mounted while Pierre renders its GitHub hunk", async () => {
it("renders hunks, collapses resolved threads, and sends a thread to the agent", async () => {
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW))
const aliases: Record<string, string> = {
"solid-js": path.join(solid, "dist/solid.js"),
@@ -0,0 +1,179 @@
/**
* PR review comment payload
*
* GitHub PR threads travel to the agent through the same review-comment payload
* as local diff comments. The message text must be reproducible from the part
* metadata, otherwise a historical message loses its comment chips.
*/
import { describe, it, expect } from "bun:test"
import {
formatReviewCommentsMarkdown,
isPRReviewComment,
partReview,
parseReview,
reviewMetadata,
type PRReviewCommentData,
type ReviewCommentData,
} from "../../src/shared/review-comments"
import { githubUrl, prMarkdown, prPayload, preview } from "../../webview-ui/agent-manager/pr/pr-comment-payload"
import type { PRComment } from "../../webview-ui/agent-manager/pr/pr-types"
function pr(overrides: Partial<PRReviewCommentData> = {}): PRReviewCommentData {
return {
id: "PRRT_1",
origin: "pr",
author: "alice",
body: "This throws when gh is missing.",
file: "src/gh.ts",
line: 42,
...overrides,
}
}
function local(): ReviewCommentData {
return { id: "c1", file: "src/a.ts", side: "additions", line: 3, comment: "rename", selectedText: "const x = 1" }
}
function thread(overrides: Partial<PRComment> = {}): PRComment {
return {
id: "PRRC_1",
threadId: "PRRT_1",
author: "alice",
body: "This throws when gh is missing.",
file: "src/gh.ts",
line: 42,
resolved: false,
outdated: false,
...overrides,
}
}
describe("PR review comment markdown", () => {
it("names the file, line, and author", () => {
expect(formatReviewCommentsMarkdown([pr()])).toBe(
"## Review Comments\n\n**src/gh.ts** (line 42), PR comment by @alice:\nThis throws when gh is missing.",
)
})
it("drops the location when the thread has none", () => {
const text = formatReviewCommentsMarkdown([pr({ file: undefined, line: undefined })])
expect(text).toContain("PR comment by @alice:")
expect(text).not.toContain("(line")
})
it("marks outdated threads", () => {
expect(formatReviewCommentsMarkdown([pr({ outdated: true })])).toContain("by @alice (outdated):")
})
it("fences the diff hunk and quotes replies", () => {
const text = formatReviewCommentsMarkdown([
pr({
diffHunk: "@@ -1 +1 @@\n-const x = 1\n+const x = 2",
replies: [{ author: "bob", body: "agreed\nguard it" }],
}),
])
expect(text).toContain("```\n@@ -1 +1 @@\n-const x = 1\n+const x = 2\n```")
expect(text).toContain("> @bob: agreed\n> guard it")
})
})
describe("PR review comment metadata", () => {
it("round-trips through the message body", () => {
const data = {
version: 1 as const,
comments: [pr({ diffHunk: "@@ -1 +1 @@", replies: [{ author: "bob", body: "ok" }] })],
}
const text = `${formatReviewCommentsMarkdown(data.comments)}\n\nplease fix these`
const view = partReview(reviewMetadata(data), text)
expect(view?.body).toBe("please fix these")
expect(view?.data.comments[0]).toEqual(data.comments[0])
})
it("round-trips a mixed local and PR payload", () => {
const comments = [local(), pr()]
const text = formatReviewCommentsMarkdown(comments)
const parsed = parseReview({ version: 1, comments }, text)
expect(parsed?.comments).toEqual(comments)
expect(parsed?.comments.filter(isPRReviewComment)).toHaveLength(1)
})
it("keeps parsing a legacy local-only payload", () => {
const comments = [local()]
expect(parseReview({ version: 1, comments }, formatReviewCommentsMarkdown(comments))?.comments).toEqual(comments)
})
it("rejects an unknown origin", () => {
const text = formatReviewCommentsMarkdown([local()])
expect(parseReview({ version: 1, comments: [{ ...local(), origin: "gitlab" }] }, text)).toBeUndefined()
expect(parseReview({ version: 1, comments: [local()] }, text)?.comments).toHaveLength(1)
})
it("rejects a PR entry with a traversal path", () => {
const comments = [pr({ file: "../../etc/passwd" })]
expect(parseReview({ version: 1, comments }, formatReviewCommentsMarkdown(comments))).toBeUndefined()
})
it("rejects a PR entry with a bogus line", () => {
const comments = [pr({ line: 0 })]
expect(parseReview({ version: 1, comments }, formatReviewCommentsMarkdown(comments))).toBeUndefined()
})
})
describe("prPayload", () => {
it("keys the payload by thread so a repeat send replaces the chip", () => {
expect(prPayload(thread()).id).toBe("PRRT_1")
})
it("caps the body", () => {
const payload = prPayload(thread({ body: "x".repeat(5_000) }))
expect(payload.body.length).toBeLessThan(5_000)
expect(payload.body.endsWith("...")).toBe(true)
})
it("keeps the hunk header and the tail of a long hunk", () => {
const hunk = Array.from({ length: 80 }, (_, i) => `line ${i}`).join("\n")
const payload = prPayload(thread({ diffHunk: hunk }))
const lines = payload.diffHunk?.split("\n") ?? []
expect(lines).toHaveLength(42)
expect(lines[0]).toBe("line 0")
expect(lines[1]).toBe("...")
expect(payload.diffHunk?.endsWith("line 79")).toBe(true)
})
it("caps a single-line hunk by characters", () => {
const payload = prPayload(thread({ diffHunk: `@@ -1 +1 @@ ${"x".repeat(20_000)}` }))
expect(payload.diffHunk!.length).toBeLessThan(9_000)
expect(payload.diffHunk?.endsWith("...")).toBe(true)
})
it("caps replies and drops empty reply lists", () => {
const replies = Array.from({ length: 9 }, (_, i) => ({ id: `r${i}`, author: "bob", body: `reply ${i}` }))
expect(prPayload(thread({ replies })).replies).toHaveLength(5)
expect(prPayload(thread({ replies: [] })).replies).toBeUndefined()
})
it("only treats https comment urls as openable", () => {
expect(githubUrl("http://example.com")).toBeUndefined()
expect(githubUrl("javascript:alert(1)")).toBeUndefined()
expect(githubUrl("https://github.com/org/repo/pull/1#discussion_r1")).toBe(
"https://github.com/org/repo/pull/1#discussion_r1",
)
})
it("survives the payload parser", () => {
const comments = [prPayload(thread({ diffHunk: "@@ -1 +1 @@", outdated: true }))]
expect(parseReview({ version: 1, comments }, formatReviewCommentsMarkdown(comments))?.comments).toEqual(comments)
})
it("formats the whole thread for the copy action", () => {
expect(prMarkdown(thread())).toBe("**src/gh.ts** (line 42), PR comment by @alice:\nThis throws when gh is missing.")
})
})
describe("preview", () => {
it("uses the first meaningful line without markdown noise", () => {
expect(preview("\n\n## Heading\nrest")).toBe("Heading")
expect(preview("- nit: rename this")).toBe("nit: rename this")
expect(preview("")).toBe("")
})
})
@@ -2672,6 +2672,7 @@ const AgentManagerContent: Component = () => {
pr={activePR()!.pr}
worktree={activePR()!.wt}
worktreeId={activePR()!.selected}
activeTerminalId={terms.activeId()}
onClose={() => setSidePanel(null)}
onOpenExternal={() =>
vscode.postMessage({
@@ -2680,6 +2681,18 @@ const AgentManagerContent: Component = () => {
url: activePR()!.pr.url,
})
}
onOpenFile={(file, line) => {
const id = diffCtx()
if (id)
vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line })
}}
onOpenUrl={(url) =>
vscode.postMessage({
type: "agentManager.openPR",
worktreeId: activePR()!.selected,
url,
})
}
/>
</Show>
<Show when={subagents.tabs().length > 0}>
+15
View File
@@ -187,6 +187,21 @@ export const dict = {
"agentManager.review.metaFile": "الملف",
"agentManager.review.metaLine": "السطر",
"agentManager.review.metaComment": "تعليق المستخدم",
"agentManager.review.metaAuthor": "المؤلف",
"agentManager.pr.comment.title": "التعليقات",
"agentManager.pr.comment.unresolvedCount": "{{count}} غير محلولة",
"agentManager.pr.comment.resolvedGroup": "تم الحل ({{count}})",
"agentManager.pr.comment.sendAll": "إرسال {{count}} غير محلولة إلى الوكيل",
"agentManager.pr.comment.sendAllToTerminal": "إرسال {{count}} غير محلولة إلى الطرفية",
"agentManager.pr.comment.send": "إرسال إلى الوكيل",
"agentManager.pr.comment.resolve": "حل",
"agentManager.pr.comment.unresolve": "إلغاء الحل",
"agentManager.pr.comment.outdated": "قديم",
"agentManager.pr.comment.sent": "تم الإرسال",
"agentManager.pr.comment.copy": "نسخ التعليق",
"agentManager.pr.comment.openOnGitHub": "فتح على GitHub",
"agentManager.pr.comment.resolveFailed": "تعذر الحل. {{error}}",
"agentManager.pr.comment.unresolveFailed": "تعذر إلغاء الحل. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} مطوي",
"agentManager.review.collapsedWithLarge": "{{collapsed}} مطوي، {{large}} كبير",
"agentManager.review.largeFileCollapsed": "ملف كبير (مطوي)",
+15
View File
@@ -190,6 +190,21 @@ export const dict = {
"agentManager.review.metaFile": "Arquivo",
"agentManager.review.metaLine": "Linha",
"agentManager.review.metaComment": "Comentário do usuário",
"agentManager.review.metaAuthor": "Autor",
"agentManager.pr.comment.title": "Comentários",
"agentManager.pr.comment.unresolvedCount": "{{count}} não resolvidos",
"agentManager.pr.comment.resolvedGroup": "Resolvidos ({{count}})",
"agentManager.pr.comment.sendAll": "Enviar {{count}} não resolvidos para o agente",
"agentManager.pr.comment.sendAllToTerminal": "Enviar {{count}} não resolvidos para o terminal",
"agentManager.pr.comment.send": "Enviar para o agente",
"agentManager.pr.comment.resolve": "Resolver",
"agentManager.pr.comment.unresolve": "Desfazer resolução",
"agentManager.pr.comment.outdated": "Desatualizado",
"agentManager.pr.comment.sent": "Enviado",
"agentManager.pr.comment.copy": "Copiar comentário",
"agentManager.pr.comment.openOnGitHub": "Abrir no GitHub",
"agentManager.pr.comment.resolveFailed": "Não foi possível resolver. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Não foi possível desfazer a resolução. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} recolhidos",
"agentManager.review.collapsedWithLarge": "{{collapsed}} recolhidos, {{large}} grandes",
"agentManager.review.largeFileCollapsed": "Arquivo grande (recolhido)",
+15
View File
@@ -190,6 +190,21 @@ export const dict = {
"agentManager.review.metaFile": "Datoteka",
"agentManager.review.metaLine": "Linija",
"agentManager.review.metaComment": "Komentar korisnika",
"agentManager.review.metaAuthor": "Autor",
"agentManager.pr.comment.title": "Komentari",
"agentManager.pr.comment.unresolvedCount": "{{count}} neriješenih",
"agentManager.pr.comment.resolvedGroup": "Riješeno ({{count}})",
"agentManager.pr.comment.sendAll": "Pošalji {{count}} neriješenih agentu",
"agentManager.pr.comment.sendAllToTerminal": "Pošalji {{count}} neriješenih terminalu",
"agentManager.pr.comment.send": "Pošalji agentu",
"agentManager.pr.comment.resolve": "Riješi",
"agentManager.pr.comment.unresolve": "Poništi rješenje",
"agentManager.pr.comment.outdated": "Zastarjelo",
"agentManager.pr.comment.sent": "Poslano",
"agentManager.pr.comment.copy": "Kopiraj komentar",
"agentManager.pr.comment.openOnGitHub": "Otvori na GitHub",
"agentManager.pr.comment.resolveFailed": "Nije moguće riješiti. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Nije moguće poništiti rješenje. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} sažeto",
"agentManager.review.collapsedWithLarge": "{{collapsed}} sažeto, {{large}} velikih",
"agentManager.review.largeFileCollapsed": "Velika datoteka (sažeto)",
+15
View File
@@ -191,6 +191,21 @@ export const dict = {
"agentManager.review.metaFile": "Fil",
"agentManager.review.metaLine": "Linje",
"agentManager.review.metaComment": "Brugerkommentar",
"agentManager.review.metaAuthor": "Forfatter",
"agentManager.pr.comment.title": "Kommentarer",
"agentManager.pr.comment.unresolvedCount": "{{count}} uløste",
"agentManager.pr.comment.resolvedGroup": "Løste ({{count}})",
"agentManager.pr.comment.sendAll": "Send {{count}} uløste til agenten",
"agentManager.pr.comment.sendAllToTerminal": "Send {{count}} uløste til terminalen",
"agentManager.pr.comment.send": "Send til agenten",
"agentManager.pr.comment.resolve": "Løs",
"agentManager.pr.comment.unresolve": "Ophæv løsning",
"agentManager.pr.comment.outdated": "Forældet",
"agentManager.pr.comment.sent": "Sendt",
"agentManager.pr.comment.copy": "Kopiér kommentar",
"agentManager.pr.comment.openOnGitHub": "Åbn på GitHub",
"agentManager.pr.comment.resolveFailed": "Kunne ikke løse. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Kunne ikke ophæve løsning. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} foldet sammen",
"agentManager.review.collapsedWithLarge": "{{collapsed}} foldet sammen, {{large}} store",
"agentManager.review.largeFileCollapsed": "Stor fil (sammenklappet)",
@@ -192,6 +192,21 @@ export const dict = {
"agentManager.review.metaFile": "Datei",
"agentManager.review.metaLine": "Zeile",
"agentManager.review.metaComment": "Benutzerkommentar",
"agentManager.review.metaAuthor": "Autor",
"agentManager.pr.comment.title": "Kommentare",
"agentManager.pr.comment.unresolvedCount": "{{count}} ungelöst",
"agentManager.pr.comment.resolvedGroup": "Gelöst ({{count}})",
"agentManager.pr.comment.sendAll": "{{count}} ungelöste an Agent senden",
"agentManager.pr.comment.sendAllToTerminal": "{{count}} ungelöste an das Terminal senden",
"agentManager.pr.comment.send": "An Agent senden",
"agentManager.pr.comment.resolve": "Auflösen",
"agentManager.pr.comment.unresolve": "Auflösung aufheben",
"agentManager.pr.comment.outdated": "Veraltet",
"agentManager.pr.comment.sent": "Gesendet",
"agentManager.pr.comment.copy": "Kommentar kopieren",
"agentManager.pr.comment.openOnGitHub": "Auf GitHub öffnen",
"agentManager.pr.comment.resolveFailed": "Konnte nicht aufgelöst werden. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Auflösung konnte nicht aufgehoben werden. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} eingeklappt",
"agentManager.review.collapsedWithLarge": "{{collapsed}} eingeklappt, {{large}} groß",
"agentManager.review.largeFileCollapsed": "Große Datei (eingeklappt)",
@@ -193,6 +193,21 @@ export const dict = {
"agentManager.review.metaFile": "File",
"agentManager.review.metaLine": "Line",
"agentManager.review.metaComment": "User comment",
"agentManager.review.metaAuthor": "Author",
"agentManager.pr.comment.title": "Comments",
"agentManager.pr.comment.unresolvedCount": "{{count}} unresolved",
"agentManager.pr.comment.resolvedGroup": "Resolved ({{count}})",
"agentManager.pr.comment.sendAll": "Send {{count}} unresolved to agent",
"agentManager.pr.comment.sendAllToTerminal": "Send {{count}} unresolved to terminal",
"agentManager.pr.comment.send": "Send to agent",
"agentManager.pr.comment.resolve": "Resolve",
"agentManager.pr.comment.unresolve": "Unresolve",
"agentManager.pr.comment.outdated": "Outdated",
"agentManager.pr.comment.sent": "Sent",
"agentManager.pr.comment.copy": "Copy comment",
"agentManager.pr.comment.openOnGitHub": "Open on GitHub",
"agentManager.pr.comment.resolveFailed": "Could not resolve. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Could not unresolve. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} collapsed",
"agentManager.review.collapsedWithLarge": "{{collapsed}} collapsed, {{large}} large",
"agentManager.review.largeFileCollapsed": "Large file (collapsed)",
+15
View File
@@ -191,6 +191,21 @@ export const dict = {
"agentManager.review.metaFile": "Archivo",
"agentManager.review.metaLine": "Línea",
"agentManager.review.metaComment": "Comentario del usuario",
"agentManager.review.metaAuthor": "Autor",
"agentManager.pr.comment.title": "Comentarios",
"agentManager.pr.comment.unresolvedCount": "{{count}} sin resolver",
"agentManager.pr.comment.resolvedGroup": "Resueltos ({{count}})",
"agentManager.pr.comment.sendAll": "Enviar {{count}} sin resolver al agente",
"agentManager.pr.comment.sendAllToTerminal": "Enviar {{count}} sin resolver al terminal",
"agentManager.pr.comment.send": "Enviar al agente",
"agentManager.pr.comment.resolve": "Resolver",
"agentManager.pr.comment.unresolve": "Deshacer resolución",
"agentManager.pr.comment.outdated": "Obsoleto",
"agentManager.pr.comment.sent": "Enviado",
"agentManager.pr.comment.copy": "Copiar comentario",
"agentManager.pr.comment.openOnGitHub": "Abrir en GitHub",
"agentManager.pr.comment.resolveFailed": "No se pudo resolver. {{error}}",
"agentManager.pr.comment.unresolveFailed": "No se pudo deshacer la resolución. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} contraídos",
"agentManager.review.collapsedWithLarge": "{{collapsed}} contraídos, {{large}} grandes",
"agentManager.review.largeFileCollapsed": "Archivo grande (contraído)",
+15
View File
@@ -194,6 +194,21 @@ export const dict = {
"agentManager.review.metaFile": "فایل",
"agentManager.review.metaLine": "خط",
"agentManager.review.metaComment": "نظر کاربر",
"agentManager.review.metaAuthor": "نویسنده",
"agentManager.pr.comment.title": "نظرات",
"agentManager.pr.comment.unresolvedCount": "{{count}} حل‌نشده",
"agentManager.pr.comment.resolvedGroup": "حل‌شده ({{count}})",
"agentManager.pr.comment.sendAll": "ارسال {{count}} مورد حل‌نشده به عامل",
"agentManager.pr.comment.sendAllToTerminal": "ارسال {{count}} مورد حل‌نشده به ترمینال",
"agentManager.pr.comment.send": "ارسال به عامل",
"agentManager.pr.comment.resolve": "حل کردن",
"agentManager.pr.comment.unresolve": "لغو حل",
"agentManager.pr.comment.outdated": "منسوخ",
"agentManager.pr.comment.sent": "ارسال شد",
"agentManager.pr.comment.copy": "کپی نظر",
"agentManager.pr.comment.openOnGitHub": "باز کردن در GitHub",
"agentManager.pr.comment.resolveFailed": "حل کردن ممکن نبود. {{error}}",
"agentManager.pr.comment.unresolveFailed": "لغو حل ممکن نبود. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} جمع‌شده",
"agentManager.review.collapsedWithLarge": "{{collapsed}} جمع‌شده، {{large}} بزرگ",
"agentManager.review.largeFileCollapsed": "فایل بزرگ (جمع‌شده)",
+15
View File
@@ -192,6 +192,21 @@ export const dict = {
"agentManager.review.metaFile": "Fichier",
"agentManager.review.metaLine": "Ligne",
"agentManager.review.metaComment": "Commentaire de l'utilisateur",
"agentManager.review.metaAuthor": "Auteur",
"agentManager.pr.comment.title": "Commentaires",
"agentManager.pr.comment.unresolvedCount": "{{count}} non résolus",
"agentManager.pr.comment.resolvedGroup": "Résolus ({{count}})",
"agentManager.pr.comment.sendAll": "Envoyer {{count}} non résolus à lagent",
"agentManager.pr.comment.sendAllToTerminal": "Envoyer {{count}} non résolus au terminal",
"agentManager.pr.comment.send": "Envoyer à lagent",
"agentManager.pr.comment.resolve": "Résoudre",
"agentManager.pr.comment.unresolve": "Annuler la résolution",
"agentManager.pr.comment.outdated": "Obsolète",
"agentManager.pr.comment.sent": "Envoyé",
"agentManager.pr.comment.copy": "Copier le commentaire",
"agentManager.pr.comment.openOnGitHub": "Ouvrir sur GitHub",
"agentManager.pr.comment.resolveFailed": "Impossible de résoudre. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Impossible dannuler la résolution. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} repliés",
"agentManager.review.collapsedWithLarge": "{{collapsed}} repliés, {{large}} volumineux",
"agentManager.review.largeFileCollapsed": "Fichier volumineux (replié)",
+15
View File
@@ -197,6 +197,21 @@ export const dict = {
"agentManager.review.metaFile": "File",
"agentManager.review.metaLine": "Riga",
"agentManager.review.metaComment": "Commento utente",
"agentManager.review.metaAuthor": "Autore",
"agentManager.pr.comment.title": "Commenti",
"agentManager.pr.comment.unresolvedCount": "{{count}} non risolti",
"agentManager.pr.comment.resolvedGroup": "Risolti ({{count}})",
"agentManager.pr.comment.sendAll": "Invia {{count}} non risolti all'agente",
"agentManager.pr.comment.sendAllToTerminal": "Invia {{count}} non risolti al terminale",
"agentManager.pr.comment.send": "Invia all'agente",
"agentManager.pr.comment.resolve": "Risolvi",
"agentManager.pr.comment.unresolve": "Annulla risoluzione",
"agentManager.pr.comment.outdated": "Obsoleto",
"agentManager.pr.comment.sent": "Inviato",
"agentManager.pr.comment.copy": "Copia commento",
"agentManager.pr.comment.openOnGitHub": "Apri su GitHub",
"agentManager.pr.comment.resolveFailed": "Impossibile risolvere. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Impossibile annullare la risoluzione. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} compressi",
"agentManager.review.collapsedWithLarge": "{{collapsed}} compressi, {{large}} grandi",
"agentManager.review.largeFileCollapsed": "File grande (compresso)",
+15
View File
@@ -191,6 +191,21 @@ export const dict = {
"agentManager.review.metaFile": "ファイル",
"agentManager.review.metaLine": "行",
"agentManager.review.metaComment": "ユーザーコメント",
"agentManager.review.metaAuthor": "作成者",
"agentManager.pr.comment.title": "コメント",
"agentManager.pr.comment.unresolvedCount": "{{count}} 件未解決",
"agentManager.pr.comment.resolvedGroup": "解決済み ({{count}})",
"agentManager.pr.comment.sendAll": "{{count}} 件の未解決コメントをエージェントに送信",
"agentManager.pr.comment.sendAllToTerminal": "{{count}} 件の未解決コメントをターミナルに送信",
"agentManager.pr.comment.send": "エージェントに送信",
"agentManager.pr.comment.resolve": "解決",
"agentManager.pr.comment.unresolve": "解決を取り消す",
"agentManager.pr.comment.outdated": "古い",
"agentManager.pr.comment.sent": "送信済み",
"agentManager.pr.comment.copy": "コメントをコピー",
"agentManager.pr.comment.openOnGitHub": "GitHubで開く",
"agentManager.pr.comment.resolveFailed": "解決できませんでした。{{error}}",
"agentManager.pr.comment.unresolveFailed": "解決を取り消せませんでした。{{error}}",
"agentManager.review.collapsedOnly": "{{count}} 件折りたたみ",
"agentManager.review.collapsedWithLarge": "{{collapsed}} 件折りたたみ、{{large}} 件がサイズ大",
"agentManager.review.largeFileCollapsed": "大きなファイル(折りたたみ)",
+15
View File
@@ -189,6 +189,21 @@ export const dict = {
"agentManager.review.metaFile": "파일",
"agentManager.review.metaLine": "라인",
"agentManager.review.metaComment": "사용자 코멘트",
"agentManager.review.metaAuthor": "작성자",
"agentManager.pr.comment.title": "댓글",
"agentManager.pr.comment.unresolvedCount": "{{count}}개 미해결",
"agentManager.pr.comment.resolvedGroup": "해결됨 ({{count}})",
"agentManager.pr.comment.sendAll": "{{count}}개 미해결 댓글을 에이전트로 보내기",
"agentManager.pr.comment.sendAllToTerminal": "{{count}}개 미해결 댓글을 터미널로 보내기",
"agentManager.pr.comment.send": "에이전트로 보내기",
"agentManager.pr.comment.resolve": "해결",
"agentManager.pr.comment.unresolve": "해결 취소",
"agentManager.pr.comment.outdated": "오래됨",
"agentManager.pr.comment.sent": "전송됨",
"agentManager.pr.comment.copy": "댓글 복사",
"agentManager.pr.comment.openOnGitHub": "GitHub에서 열기",
"agentManager.pr.comment.resolveFailed": "해결할 수 없습니다. {{error}}",
"agentManager.pr.comment.unresolveFailed": "해결을 취소할 수 없습니다. {{error}}",
"agentManager.review.collapsedOnly": "{{count}}개 접힘",
"agentManager.review.collapsedWithLarge": "{{collapsed}}개 접힘, {{large}}개 대용량",
"agentManager.review.largeFileCollapsed": "큰 파일(접힘)",
+15
View File
@@ -196,6 +196,21 @@ export const dict = {
"agentManager.review.metaFile": "Bestand",
"agentManager.review.metaLine": "Regel",
"agentManager.review.metaComment": "Gebruikersopmerking",
"agentManager.review.metaAuthor": "Auteur",
"agentManager.pr.comment.title": "Opmerkingen",
"agentManager.pr.comment.unresolvedCount": "{{count}} onopgelost",
"agentManager.pr.comment.resolvedGroup": "Opgelost ({{count}})",
"agentManager.pr.comment.sendAll": "{{count}} onopgeloste opmerkingen naar agent sturen",
"agentManager.pr.comment.sendAllToTerminal": "{{count}} onopgeloste opmerkingen naar terminal sturen",
"agentManager.pr.comment.send": "Naar agent sturen",
"agentManager.pr.comment.resolve": "Oplossen",
"agentManager.pr.comment.unresolve": "Oplossing ongedaan maken",
"agentManager.pr.comment.outdated": "Verouderd",
"agentManager.pr.comment.sent": "Verzonden",
"agentManager.pr.comment.copy": "Opmerking kopiëren",
"agentManager.pr.comment.openOnGitHub": "Openen op GitHub",
"agentManager.pr.comment.resolveFailed": "Kan niet oplossen. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Kan oplossing niet ongedaan maken. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} ingeklapt",
"agentManager.review.collapsedWithLarge": "{{collapsed}} ingeklapt, {{large}} groot",
"agentManager.review.largeFileCollapsed": "Groot bestand (ingeklapt)",
+15
View File
@@ -188,6 +188,21 @@ export const dict = {
"agentManager.review.metaFile": "Fil",
"agentManager.review.metaLine": "Linje",
"agentManager.review.metaComment": "Brukerkommentar",
"agentManager.review.metaAuthor": "Forfatter",
"agentManager.pr.comment.title": "Kommentarer",
"agentManager.pr.comment.unresolvedCount": "{{count}} uløste",
"agentManager.pr.comment.resolvedGroup": "Løste ({{count}})",
"agentManager.pr.comment.sendAll": "Send {{count}} uløste til agenten",
"agentManager.pr.comment.sendAllToTerminal": "Send {{count}} uløste til terminalen",
"agentManager.pr.comment.send": "Send til agenten",
"agentManager.pr.comment.resolve": "Løs",
"agentManager.pr.comment.unresolve": "Opphev løsning",
"agentManager.pr.comment.outdated": "Utdatert",
"agentManager.pr.comment.sent": "Sendt",
"agentManager.pr.comment.copy": "Kopier kommentar",
"agentManager.pr.comment.openOnGitHub": "Åpne på GitHub",
"agentManager.pr.comment.resolveFailed": "Kunne ikke løse. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Kunne ikke oppheve løsning. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} kollapset",
"agentManager.review.collapsedWithLarge": "{{collapsed}} kollapset, {{large}} store",
"agentManager.review.largeFileCollapsed": "Stor fil (sammenfoldet)",
+15
View File
@@ -190,6 +190,21 @@ export const dict = {
"agentManager.review.metaFile": "Plik",
"agentManager.review.metaLine": "Linia",
"agentManager.review.metaComment": "Komentarz użytkownika",
"agentManager.review.metaAuthor": "Autor",
"agentManager.pr.comment.title": "Komentarze",
"agentManager.pr.comment.unresolvedCount": "{{count}} nierozwiązanych",
"agentManager.pr.comment.resolvedGroup": "Rozwiązane ({{count}})",
"agentManager.pr.comment.sendAll": "Wyślij {{count}} nierozwiązanych do agenta",
"agentManager.pr.comment.sendAllToTerminal": "Wyślij {{count}} nierozwiązanych do terminala",
"agentManager.pr.comment.send": "Wyślij do agenta",
"agentManager.pr.comment.resolve": "Rozwiąż",
"agentManager.pr.comment.unresolve": "Cofnij rozwiązanie",
"agentManager.pr.comment.outdated": "Nieaktualne",
"agentManager.pr.comment.sent": "Wysłano",
"agentManager.pr.comment.copy": "Kopiuj komentarz",
"agentManager.pr.comment.openOnGitHub": "Otwórz na GitHub",
"agentManager.pr.comment.resolveFailed": "Nie można rozwiązać. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Nie można cofnąć rozwiązania. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} zwiniętych",
"agentManager.review.collapsedWithLarge": "{{collapsed}} zwiniętych, {{large}} dużych",
"agentManager.review.largeFileCollapsed": "Duży plik (zwinięty)",
+15
View File
@@ -191,6 +191,21 @@ export const dict = {
"agentManager.review.metaFile": "Файл",
"agentManager.review.metaLine": "Строка",
"agentManager.review.metaComment": "Комментарий пользователя",
"agentManager.review.metaAuthor": "Автор",
"agentManager.pr.comment.title": "Комментарии",
"agentManager.pr.comment.unresolvedCount": "{{count}} нерешённых",
"agentManager.pr.comment.resolvedGroup": "Решённые ({{count}})",
"agentManager.pr.comment.sendAll": "Отправить {{count}} нерешённых агенту",
"agentManager.pr.comment.sendAllToTerminal": "Отправить {{count}} нерешённых в терминал",
"agentManager.pr.comment.send": "Отправить агенту",
"agentManager.pr.comment.resolve": "Решить",
"agentManager.pr.comment.unresolve": "Отменить решение",
"agentManager.pr.comment.outdated": "Устарело",
"agentManager.pr.comment.sent": "Отправлено",
"agentManager.pr.comment.copy": "Копировать комментарий",
"agentManager.pr.comment.openOnGitHub": "Открыть на GitHub",
"agentManager.pr.comment.resolveFailed": "Не удалось решить. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Не удалось отменить решение. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} свернуто",
"agentManager.review.collapsedWithLarge": "{{collapsed}} свернуто, {{large}} больших",
"agentManager.review.largeFileCollapsed": "Большой файл (свернут)",
+15
View File
@@ -185,6 +185,21 @@ export const dict = {
"agentManager.review.metaFile": "ไฟล์",
"agentManager.review.metaLine": "บรรทัด",
"agentManager.review.metaComment": "ความคิดเห็นของผู้ใช้",
"agentManager.review.metaAuthor": "ผู้เขียน",
"agentManager.pr.comment.title": "ความคิดเห็น",
"agentManager.pr.comment.unresolvedCount": "ยังไม่แก้ไข {{count}} รายการ",
"agentManager.pr.comment.resolvedGroup": "แก้ไขแล้ว ({{count}})",
"agentManager.pr.comment.sendAll": "ส่งความคิดเห็นที่ยังไม่แก้ไข {{count}} รายการไปยังเอเจนต์",
"agentManager.pr.comment.sendAllToTerminal": "ส่งความคิดเห็นที่ยังไม่แก้ไข {{count}} รายการไปยังเทอร์มินัล",
"agentManager.pr.comment.send": "ส่งไปยังเอเจนต์",
"agentManager.pr.comment.resolve": "ทำเครื่องหมายว่าแก้ไขแล้ว",
"agentManager.pr.comment.unresolve": "ยกเลิกการแก้ไข",
"agentManager.pr.comment.outdated": "ล้าสมัย",
"agentManager.pr.comment.sent": "ส่งแล้ว",
"agentManager.pr.comment.copy": "คัดลอกความคิดเห็น",
"agentManager.pr.comment.openOnGitHub": "เปิดบน GitHub",
"agentManager.pr.comment.resolveFailed": "แก้ไขไม่ได้ {{error}}",
"agentManager.pr.comment.unresolveFailed": "ยกเลิกการแก้ไขไม่ได้ {{error}}",
"agentManager.review.collapsedOnly": "ยุบ {{count}} รายการ",
"agentManager.review.collapsedWithLarge": "ยุบ {{collapsed}} รายการ, ขนาดใหญ่ {{large}} รายการ",
"agentManager.review.largeFileCollapsed": "ไฟล์ขนาดใหญ่ (พับอยู่)",
+15
View File
@@ -198,6 +198,21 @@ export const dict = {
"agentManager.review.metaFile": "Dosya",
"agentManager.review.metaLine": "Satır",
"agentManager.review.metaComment": "Kullanıcı yorumu",
"agentManager.review.metaAuthor": "Yazar",
"agentManager.pr.comment.title": "Yorumlar",
"agentManager.pr.comment.unresolvedCount": "{{count}} çözülmemiş",
"agentManager.pr.comment.resolvedGroup": "Çözüldü ({{count}})",
"agentManager.pr.comment.sendAll": "{{count}} çözülmemiş yorumu ajana gönder",
"agentManager.pr.comment.sendAllToTerminal": "{{count}} çözülmemiş yorumu terminale gönder",
"agentManager.pr.comment.send": "Ajana gönder",
"agentManager.pr.comment.resolve": "Çöz",
"agentManager.pr.comment.unresolve": "Çözümü geri al",
"agentManager.pr.comment.outdated": "Güncel değil",
"agentManager.pr.comment.sent": "Gönderildi",
"agentManager.pr.comment.copy": "Yorumu kopyala",
"agentManager.pr.comment.openOnGitHub": "GitHub'da aç",
"agentManager.pr.comment.resolveFailed": "Çözülemedi. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Çözüm geri alınamadı. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} daraltıldı",
"agentManager.review.collapsedWithLarge": "{{collapsed}} daraltıldı, {{large}} büyük",
"agentManager.review.largeFileCollapsed": "Büyük dosya (daraltıldı)",
+15
View File
@@ -199,6 +199,21 @@ export const dict = {
"agentManager.review.metaFile": "Файл",
"agentManager.review.metaLine": "Рядок",
"agentManager.review.metaComment": "Коментар користувача",
"agentManager.review.metaAuthor": "Автор",
"agentManager.pr.comment.title": "Коментарі",
"agentManager.pr.comment.unresolvedCount": "{{count}} невирішених",
"agentManager.pr.comment.resolvedGroup": "Вирішено ({{count}})",
"agentManager.pr.comment.sendAll": "Надіслати {{count}} невирішених агенту",
"agentManager.pr.comment.sendAllToTerminal": "Надіслати {{count}} невирішених до терміналу",
"agentManager.pr.comment.send": "Надіслати агенту",
"agentManager.pr.comment.resolve": "Вирішити",
"agentManager.pr.comment.unresolve": "Скасувати вирішення",
"agentManager.pr.comment.outdated": "Застарілий",
"agentManager.pr.comment.sent": "Надіслано",
"agentManager.pr.comment.copy": "Копіювати коментар",
"agentManager.pr.comment.openOnGitHub": "Відкрити на GitHub",
"agentManager.pr.comment.resolveFailed": "Не вдалося вирішити. {{error}}",
"agentManager.pr.comment.unresolveFailed": "Не вдалося скасувати вирішення. {{error}}",
"agentManager.review.collapsedOnly": "{{count}} згорнуто",
"agentManager.review.collapsedWithLarge": "{{collapsed}} згорнуто, {{large}} великих",
"agentManager.review.largeFileCollapsed": "Великий файл (згорнуто)",
+15
View File
@@ -183,6 +183,21 @@ export const dict = {
"agentManager.review.metaFile": "文件",
"agentManager.review.metaLine": "行",
"agentManager.review.metaComment": "用户评论",
"agentManager.review.metaAuthor": "作者",
"agentManager.pr.comment.title": "评论",
"agentManager.pr.comment.unresolvedCount": "{{count}} 个未解决",
"agentManager.pr.comment.resolvedGroup": "已解决 ({{count}})",
"agentManager.pr.comment.sendAll": "将 {{count}} 个未解决评论发送给代理",
"agentManager.pr.comment.sendAllToTerminal": "将 {{count}} 个未解决评论发送到终端",
"agentManager.pr.comment.send": "发送给代理",
"agentManager.pr.comment.resolve": "解决",
"agentManager.pr.comment.unresolve": "取消解决",
"agentManager.pr.comment.outdated": "已过时",
"agentManager.pr.comment.sent": "已发送",
"agentManager.pr.comment.copy": "复制评论",
"agentManager.pr.comment.openOnGitHub": "在 GitHub 上打开",
"agentManager.pr.comment.resolveFailed": "无法解决。{{error}}",
"agentManager.pr.comment.unresolveFailed": "无法取消解决。{{error}}",
"agentManager.review.collapsedOnly": "{{count}} 个已折叠",
"agentManager.review.collapsedWithLarge": "{{collapsed}} 个已折叠,{{large}} 个过大",
"agentManager.review.largeFileCollapsed": "大文件(已折叠)",
@@ -183,6 +183,21 @@ export const dict = {
"agentManager.review.metaFile": "檔案",
"agentManager.review.metaLine": "行",
"agentManager.review.metaComment": "使用者評論",
"agentManager.review.metaAuthor": "作者",
"agentManager.pr.comment.title": "留言",
"agentManager.pr.comment.unresolvedCount": "{{count}} 個未解決",
"agentManager.pr.comment.resolvedGroup": "已解決 ({{count}})",
"agentManager.pr.comment.sendAll": "將 {{count}} 個未解決留言傳送給代理",
"agentManager.pr.comment.sendAllToTerminal": "將 {{count}} 個未解決留言傳送到終端機",
"agentManager.pr.comment.send": "傳送給代理",
"agentManager.pr.comment.resolve": "解決",
"agentManager.pr.comment.unresolve": "取消解決",
"agentManager.pr.comment.outdated": "已過時",
"agentManager.pr.comment.sent": "已傳送",
"agentManager.pr.comment.copy": "複製留言",
"agentManager.pr.comment.openOnGitHub": "在 GitHub 上開啟",
"agentManager.pr.comment.resolveFailed": "無法解決。{{error}}",
"agentManager.pr.comment.unresolveFailed": "無法取消解決。{{error}}",
"agentManager.review.collapsedOnly": "{{count}} 個已摺疊",
"agentManager.review.collapsedWithLarge": "{{collapsed}} 個已摺疊,{{large}} 個過大",
"agentManager.review.largeFileCollapsed": "大型檔案(已摺疊)",
@@ -0,0 +1,123 @@
/** @jsxImportSource solid-js */
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Markdown } from "@kilocode/kilo-ui/markdown"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../../src/context/language"
import { PRCommentDiff } from "../../diff-viewer/PRCommentDiff"
import { CopyButton } from "./CopyButton"
import { prMarkdown, preview } from "./pr-comment-payload"
import type { PRComment } from "./pr-types"
interface Props {
comment: PRComment
resolved: boolean
pending: boolean
sent: boolean
open: boolean
error?: string
onToggleOpen: () => void
onToggleResolved: () => void
onSend: () => void
onOpenFile?: () => void
onOpenUrl?: () => void
}
export function PRCommentCard(props: Props) {
const { t } = useLanguage()
const location = () => {
const file = props.comment.file
if (!file) return ""
return props.comment.line ? `${file}:${props.comment.line}` : file
}
return (
<div class="am-pr-comment" classList={{ "am-pr-comment-open": props.open }}>
<button
type="button"
class="am-pr-comment-head am-pr-row"
aria-expanded={props.open}
onClick={props.onToggleOpen}
>
<Icon name={props.open ? "chevron-down" : "chevron-right"} size="small" class="am-pr-comment-chevron" />
<Show when={props.resolved}>
<Icon name="circle-check" size="small" class="am-pr-comment-check" />
</Show>
<span class="am-pr-comment-author">{props.comment.author}</span>
<Show when={props.open} fallback={<span class="am-pr-comment-preview">{preview(props.comment.body)}</span>}>
<Show when={location()}>{(value) => <span class="am-pr-comment-file">{value()}</span>}</Show>
</Show>
<Show when={props.comment.outdated}>
<span class="am-pr-comment-tag">{t("agentManager.pr.comment.outdated")}</span>
</Show>
<Show when={props.sent}>
<span class="am-pr-comment-tag am-pr-comment-tag-sent">{t("agentManager.pr.comment.sent")}</span>
</Show>
</button>
<Show when={props.open}>
<Show when={props.comment.diffHunk && props.comment.file}>
<PRCommentDiff file={props.comment.file!} hunk={props.comment.diffHunk!} />
</Show>
<div class="am-pr-comment-body">
<Markdown text={props.comment.body} />
</div>
<For each={props.comment.replies}>
{(reply) => (
<div class="am-pr-comment-reply">
<span class="am-pr-comment-author">{reply.author}</span>
<div class="am-pr-comment-body">
<Markdown text={reply.body} />
</div>
</div>
)}
</For>
<Show when={props.error}>{(err) => <div class="am-pr-comment-error">{err()}</div>}</Show>
<div class="am-pr-comment-actions am-pr-row">
<Button variant="primary" size="small" onClick={props.onSend}>
{t("agentManager.pr.comment.send")}
</Button>
<Button
variant="secondary"
size="small"
class="am-pr-comment-btn"
disabled={props.pending}
onClick={props.onToggleResolved}
>
<Show when={props.pending}>
<Spinner class="am-pr-comment-spinner" />
</Show>
{props.resolved ? t("agentManager.pr.comment.unresolve") : t("agentManager.pr.comment.resolve")}
</Button>
<span class="am-pr-comment-actions-gap" />
<CopyButton text={prMarkdown(props.comment)} label={t("agentManager.pr.comment.copy")} />
<Show when={props.onOpenFile}>
<Tooltip value={t("agentManager.diff.openFile")} placement="top">
<IconButton
icon="go-to-file"
size="small"
variant="ghost"
label={t("agentManager.diff.openFile")}
onClick={() => props.onOpenFile?.()}
/>
</Tooltip>
</Show>
<Show when={props.onOpenUrl}>
<Tooltip value={t("agentManager.pr.comment.openOnGitHub")} placement="top">
<IconButton
icon="square-arrow-top-right"
size="small"
variant="ghost"
label={t("agentManager.pr.comment.openOnGitHub")}
onClick={() => props.onOpenUrl?.()}
/>
</Tooltip>
</Show>
</div>
</Show>
</div>
)
}
@@ -1,126 +1,182 @@
/** @jsxImportSource solid-js */
import { Index, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Markdown } from "@kilocode/kilo-ui/markdown"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { useLanguage } from "../../src/context/language"
import { useVSCode } from "../../src/context/vscode"
import type { PRStatus } from "../../src/types/messages"
import { sendReviewComments } from "../../diff-viewer/review-annotations"
import { PRCommentCard } from "./PRCommentCard"
import { SEND_LIMIT, githubUrl, prPayload } from "./pr-comment-payload"
import type { PRComment } from "./pr-types"
import { SectionHeading } from "./SectionHeading"
import { CopyButton } from "./CopyButton"
import { PRCommentDiff } from "../../diff-viewer/PRCommentDiff"
function CommentCard(props: { comment: PRComment; worktreeId: string }) {
interface Props {
comments: NonNullable<PRStatus["comments"]>
worktreeId: string
activeTerminalId?: string
onOpenFile?: (file: string, line?: number) => void
onOpenUrl?: (url: string) => void
}
type Flags = Record<string, boolean>
function without<T>(map: Record<string, T>, id: string): Record<string, T> {
const next = { ...map }
delete next[id]
return next
}
export function PRComments(props: Props) {
const { t } = useLanguage()
const vscode = useVSCode()
// Track pending action and any error from the result
const [pendingResolved, setPendingResolved] = createSignal<boolean | undefined>(undefined)
const [actionError, setActionError] = createSignal<string | undefined>(undefined)
const [open, setOpen] = createSignal(true)
const [doneOpen, setDoneOpen] = createSignal(false)
// threadId -> resolved state requested by the user, until the next poll confirms it
const [pending, setPending] = createSignal<Flags>({})
const [errors, setErrors] = createSignal<Record<string, string>>({})
// threadId -> expansion override; the default depends on resolved/outdated state
const [expanded, setExpanded] = createSignal<Flags>({})
const [sent, setSent] = createSignal<Flags>({})
// Resolved shows pending state if exists, otherwise server state
const resolved = createMemo(() => pendingResolved() ?? props.comment.resolved)
const resolved = (comment: PRComment) => pending()[comment.threadId] ?? comment.resolved
const expandedFor = (comment: PRComment) => expanded()[comment.threadId] ?? (!resolved(comment) && !comment.outdated)
// Clear pending when server state matches (action confirmed by poll)
createMemo(() => {
const pending = pendingResolved()
if (pending !== undefined && pending === props.comment.resolved) {
setPendingResolved(undefined)
setActionError(undefined)
}
const groups = createMemo(() => {
const list = props.comments.comments
return { todo: list.filter((item) => !resolved(item)), done: list.filter((item) => resolved(item)) }
})
// Drop the optimistic state once a poll reports the state the user asked for.
createEffect(() => {
const map = pending()
const settled = props.comments.comments.filter(
(item) => map[item.threadId] !== undefined && map[item.threadId] === item.resolved,
)
if (settled.length === 0) return
setPending((prev) => {
const next = { ...prev }
for (const item of settled) delete next[item.threadId]
return next
})
})
onMount(() => {
function handler(ev: MessageEvent) {
const msg = ev.data
const isResult =
(msg?.type === "agentManager.resolveCommentResult" || msg?.type === "agentManager.unresolveCommentResult") &&
msg.worktreeId === props.worktreeId &&
msg.threadId === props.comment.threadId
if (!isResult) return
if (!msg.success) {
// Only clear on error - success waits for poll to update props.comment.resolved
setPendingResolved(undefined)
setActionError(
msg.type === "agentManager.resolveCommentResult"
? "Failed to resolve thread."
: "Failed to unresolve thread.",
)
}
const resolveResult = msg?.type === "agentManager.resolveCommentResult"
const unresolveResult = msg?.type === "agentManager.unresolveCommentResult"
if (!resolveResult && !unresolveResult) return
if (msg.worktreeId !== props.worktreeId) return
// Success waits for the poll to report the new server state.
if (msg.success) return
const id = msg.threadId as string
setPending((prev) => without(prev, id))
// Keep the card open so the failure is readable instead of hidden in a collapsed row.
setExpanded((prev) => ({ ...prev, [id]: true }))
const reason = typeof msg.error === "string" && msg.error ? msg.error : t("common.requestFailed")
setErrors((prev) => ({
...prev,
[id]: t(resolveResult ? "agentManager.pr.comment.resolveFailed" : "agentManager.pr.comment.unresolveFailed", {
error: reason,
}),
}))
}
window.addEventListener("message", handler)
onCleanup(() => window.removeEventListener("message", handler))
})
function toggle() {
setActionError(undefined)
const next = !resolved()
setPendingResolved(next)
function toggleResolved(comment: PRComment) {
const next = !resolved(comment)
setErrors((prev) => without(prev, comment.threadId))
setPending((prev) => ({ ...prev, [comment.threadId]: next }))
// A thread the user just resolved collapses, like it does on GitHub. Open the
// resolved group so the thread is visibly moved instead of just disappearing.
setExpanded((prev) => ({ ...prev, [comment.threadId]: !next }))
if (next) setDoneOpen(true)
vscode.postMessage({
type: next ? "agentManager.resolveComment" : "agentManager.unresolveComment",
worktreeId: props.worktreeId,
threadId: props.comment.threadId,
threadId: comment.threadId,
} as never)
}
return (
<div class="am-pr-panel-comment" classList={{ "am-pr-panel-comment-resolved": resolved() }}>
<Show when={props.comment.diffHunk && props.comment.file}>
<PRCommentDiff file={props.comment.file!} hunk={props.comment.diffHunk!} />
</Show>
<div class="am-pr-panel-comment-header am-pr-row">
<span class="am-pr-panel-comment-author">{props.comment.author}</span>
<Show when={props.comment.file}>
<span class="am-pr-panel-comment-file">
{props.comment.file}
<Show when={props.comment.line}>{`:${props.comment.line}`}</Show>
</span>
</Show>
<Show when={resolved()}>
<span class="am-pr-panel-comment-resolved-badge">Resolved</span>
</Show>
<CopyButton text={props.comment.body} class="am-pr-copy-btn" />
</div>
<Show when={actionError()}>{(err) => <div class="am-pr-resolve-error">{err()}</div>}</Show>
<div class="am-pr-panel-comment-body">
<Markdown text={props.comment.body} />
</div>
<div class="am-pr-resolve-row">
<Show
when={pendingResolved() === undefined}
fallback={
<div class="am-pr-resolve-loading">
<Spinner class="am-pr-resolve-spinner" />
<span>Loading</span>
</div>
}
>
<button class="am-pr-resolve-btn" onClick={toggle}>
{resolved() ? "Unresolve comment" : "Resolve comment"}
</button>
</Show>
</div>
</div>
)
}
function send(list: PRComment[]) {
if (list.length === 0) return
const batch = list.slice(0, SEND_LIMIT)
sendReviewComments(batch.map(prPayload), props.activeTerminalId)
setSent((prev) => {
const next = { ...prev }
for (const item of batch) next[item.threadId] = true
return next
})
}
// `Index` keyed by position, not `For` keyed by identity: every poll allocates
// fresh PRComment objects, and remounting would re-run Pierre and Markdown.
const card = (comment: () => PRComment) => (
<PRCommentCard
comment={comment()}
resolved={resolved(comment())}
pending={pending()[comment().threadId] !== undefined}
sent={sent()[comment().threadId] === true}
open={expandedFor(comment())}
error={errors()[comment().threadId]}
onToggleOpen={() => setExpanded((prev) => ({ ...prev, [comment().threadId]: !expandedFor(comment()) }))}
onToggleResolved={() => toggleResolved(comment())}
onSend={() => send([comment()])}
onOpenFile={
comment().file && props.onOpenFile ? () => props.onOpenFile?.(comment().file!, comment().line) : undefined
}
onOpenUrl={
githubUrl(comment().url) && props.onOpenUrl ? () => props.onOpenUrl?.(githubUrl(comment().url)!) : undefined
}
/>
)
export function PRComments(props: { comments: NonNullable<PRStatus["comments"]>; worktreeId: string }) {
const [open, setOpen] = createSignal(true)
return (
<>
<div class="am-pr-panel-divider" />
<div class="am-pr-panel-section">
<SectionHeading
title="Comments"
title={t("agentManager.pr.comment.title")}
open={open()}
onToggle={() => setOpen((v) => !v)}
count={props.comments.unresolved > 0 ? `${props.comments.unresolved} unresolved` : undefined}
count={
groups().todo.length > 0
? t("agentManager.pr.comment.unresolvedCount", { count: groups().todo.length })
: undefined
}
countClass="am-pr-panel-unresolved"
/>
<Show when={open()}>
<Show when={groups().todo.length > 0}>
<Button variant="primary" size="small" class="am-pr-comment-send-all" onClick={() => send(groups().todo)}>
{t(
props.activeTerminalId
? "agentManager.pr.comment.sendAllToTerminal"
: "agentManager.pr.comment.sendAll",
{ count: Math.min(groups().todo.length, SEND_LIMIT) },
)}
</Button>
</Show>
<div class="am-pr-panel-comment-list am-pr-col">
<Index each={props.comments.comments}>
{(comment) => <CommentCard comment={comment()} worktreeId={props.worktreeId} />}
</Index>
<Index each={groups().todo}>{card}</Index>
</div>
<Show when={groups().done.length > 0}>
<div class="am-pr-comment-done-group">
<SectionHeading
title={t("agentManager.pr.comment.resolvedGroup", { count: groups().done.length })}
open={doneOpen()}
onToggle={() => setDoneOpen((v) => !v)}
/>
<Show when={doneOpen()}>
<div class="am-pr-panel-comment-list am-pr-col">
<Index each={groups().done}>{card}</Index>
</div>
</Show>
</div>
</Show>
</Show>
</div>
</>
@@ -17,8 +17,11 @@ interface PRPanelProps {
pr: PRStatus
worktree?: WorktreeState
worktreeId: string
activeTerminalId?: string
onClose: () => void
onOpenExternal: () => void
onOpenFile?: (file: string, line?: number) => void
onOpenUrl?: (url: string) => void
}
export const PRPanel: Component<PRPanelProps> = (props) => {
@@ -76,7 +79,13 @@ export const PRPanel: Component<PRPanelProps> = (props) => {
<Show when={props.pr.comments?.total ? props.pr.comments : undefined}>
{(comments) => (
<div ref={commentsRef}>
<PRComments comments={comments()} worktreeId={props.worktreeId} />
<PRComments
comments={comments()}
worktreeId={props.worktreeId}
activeTerminalId={props.activeTerminalId}
onOpenFile={props.onOpenFile}
onOpenUrl={props.onOpenUrl}
/>
</div>
)}
</Show>
@@ -0,0 +1,55 @@
import { formatReviewCommentMarkdown, type PRReviewCommentData } from "../../../src/shared/review-comments"
import type { PRComment } from "./pr-types"
/** Caps so one talkative PR cannot blow up a prompt. */
const BODY = 4_000
const HUNK = 40
/** A generated or minified file can put the whole hunk on one line. */
const HUNK_CHARS = 8_000
const REPLIES = 5
/** Matches the comment limit enforced by the shared review payload parser. */
export const SEND_LIMIT = 100
function clip(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max)}\n...` : value
}
/** Keep the `@@` header and the tail: the commented line sits at the end. */
function trim(value: string): string {
const lines = value.split("\n")
const cut = lines.length <= HUNK ? lines : [lines[0]!, "...", ...lines.slice(-HUNK)]
return clip(cut.join("\n"), HUNK_CHARS)
}
export function prPayload(comment: PRComment): PRReviewCommentData {
const replies = (comment.replies ?? [])
.slice(0, REPLIES)
.map((reply) => ({ author: reply.author, body: clip(reply.body, BODY) }))
return {
id: comment.threadId,
origin: "pr",
author: comment.author,
body: clip(comment.body, BODY),
file: comment.file,
line: comment.line,
diffHunk: comment.diffHunk ? trim(comment.diffHunk) : undefined,
outdated: comment.outdated || undefined,
replies: replies.length > 0 ? replies : undefined,
}
}
/** Only https urls reach the payload, the markdown, or `openExternal`. */
export function githubUrl(url?: string): string | undefined {
return url?.startsWith("https://") ? url : undefined
}
/** The whole thread as markdown, for the copy action. */
export function prMarkdown(comment: PRComment): string {
return formatReviewCommentMarkdown(prPayload(comment))
}
/** First meaningful line of a comment body, for the collapsed row. */
export function preview(body: string): string {
const line = body.split("\n").find((item) => item.trim().length > 0) ?? ""
return line.replace(/^[#>\-*\s`]+/, "").trim()
}
@@ -234,54 +234,148 @@
/* PR comment list */
.am-pr-panel-comment-list {
gap: 8px;
gap: 6px;
margin-top: 6px;
}
.am-pr-panel-comment {
/* PR comment card */
.am-pr-comment {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
padding: 8px;
opacity: 1;
overflow: hidden;
}
.am-pr-panel-comment-resolved {
opacity: 0.5;
}
.am-pr-panel-comment-header {
.am-pr-comment-head {
width: 100%;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
padding: 6px 8px;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--vscode-foreground);
min-width: 0;
}
.am-pr-panel-comment-author {
.am-pr-comment-head:hover {
background: var(--vscode-list-hoverBackground);
}
.am-pr-comment-head:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.am-pr-comment-open .am-pr-comment-head {
border-bottom: 1px solid var(--vscode-panel-border);
}
.am-pr-comment-chevron {
flex-shrink: 0;
opacity: 0.6;
}
.am-pr-comment-check {
flex-shrink: 0;
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-comment-author {
font-size: var(--kilo-font-size-12);
font-weight: 600;
color: var(--vscode-foreground);
flex-shrink: 0;
}
.am-pr-panel-comment-file {
.am-pr-comment-preview {
font-size: var(--kilo-font-size-12);
color: var(--text-weak);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.am-pr-comment-file {
font-size: var(--kilo-font-size-11);
color: var(--text-weak);
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
direction: rtl;
min-width: 0;
}
.am-pr-panel-comment-resolved-badge {
.am-pr-comment-tag {
font-size: var(--kilo-font-size-10);
color: var(--text-weaker);
border: 1px solid var(--vscode-panel-border);
border-radius: 3px;
padding: 1px 4px;
margin-left: auto;
flex-shrink: 0;
}
.am-pr-copy-btn {
margin-left: auto;
.am-pr-comment-tag-sent {
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-comment-body {
padding: 6px 8px 0;
}
.am-pr-comment-reply {
padding: 6px 8px 0 16px;
border-left: 2px solid var(--vscode-panel-border);
margin-left: 8px;
}
.am-pr-comment-reply .am-pr-comment-body {
padding-left: 0;
}
.am-pr-comment-error {
font-size: var(--kilo-font-size-11);
color: var(--vscode-testing-iconFailed, #f87171);
padding: 6px 8px 0;
}
.am-pr-comment-actions {
gap: 4px;
padding: 8px;
flex-wrap: wrap;
}
.am-pr-comment-actions-gap {
flex: 1;
}
/* Same bordered treatment the local review comment actions use, because the
VS Code theme renders secondary kilo-ui buttons as bare text. */
[data-component="button"].am-pr-comment-btn[data-variant="secondary"] {
border: 1px solid var(--border-base);
background: var(--surface-base);
color: var(--text-base);
}
[data-component="button"].am-pr-comment-btn[data-variant="secondary"]:hover:not(:disabled) {
background: var(--surface-interactive-base);
}
.am-pr-comment-spinner {
width: 12px;
height: 12px;
margin-right: 4px;
}
.am-pr-comment-send-all {
margin-top: 6px;
align-self: flex-start;
}
.am-pr-comment-done-group {
margin-top: 10px;
}
/* Pierre diff hunk preview inside comment cards */
@@ -289,58 +383,7 @@
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
overflow: hidden;
margin-bottom: 6px;
}
/* Resolve button */
.am-pr-resolve-row {
display: flex;
justify-content: flex-start;
margin-top: 15px;
}
.am-pr-resolve-btn {
background: none;
border: 1px solid var(--vscode-panel-border);
border-radius: 3px;
color: var(--text-weak);
cursor: pointer;
font-size: var(--kilo-font-size-14);
padding: 4px 16px;
}
.am-pr-resolve-btn:hover {
color: var(--vscode-foreground);
border-color: var(--vscode-foreground);
}
.am-pr-resolve-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.am-pr-resolve-error {
font-size: var(--kilo-font-size-11);
color: var(--vscode-testing-iconFailed, #f87171);
padding: 2px 0 4px;
}
.am-pr-resolve-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 16px;
font-size: var(--kilo-font-size-14);
color: var(--text-weak);
}
.am-pr-resolve-spinner {
width: 14px;
height: 14px;
}
.am-pr-panel-comment-body [data-component="markdown"] {
font-size: var(--kilo-font-size-12);
color: var(--vscode-foreground);
margin: 6px 8px 0;
}
.am-pr-panel-description {
@@ -349,7 +392,7 @@
}
.am-pr-panel-description [data-component="markdown"],
.am-pr-panel-comment-body [data-component="markdown"] {
.am-pr-comment-body [data-component="markdown"] {
font-size: var(--kilo-font-size-13);
color: var(--vscode-foreground);
}
@@ -13,6 +13,11 @@ export interface PRCheck {
duration?: string
}
export interface PRCommentReply {
author: string
body: string
}
export interface PRComment {
id: string
threadId: string
@@ -23,8 +28,10 @@ export interface PRComment {
line?: number
url?: string
resolved: boolean
outdated: boolean
createdAt?: number
diffHunk?: string
replies?: PRCommentReply[]
}
export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented"
@@ -1,6 +1,7 @@
import type { AnnotationSide, DiffLineAnnotation } from "@pierre/diffs"
import type { WorktreeFileDiff } from "../src/types/messages"
import { extractLines, type ReviewComment } from "./review-comments"
import type { ReviewCommentEntry } from "../src/types/messages"
export interface AnnotationLabels {
commentOnLine: (line: number) => string
@@ -137,7 +138,7 @@ function makeActionButton(title: string, icon: SVGSVGElement, action: () => void
return button
}
export function sendReviewComments(comments: ReviewComment[], activeTerminalId?: string): void {
export function sendReviewComments(comments: ReviewCommentEntry[], activeTerminalId?: string): void {
window.dispatchEvent(
new MessageEvent("message", {
data: {
@@ -56,7 +56,7 @@ import {
type SandboxDefaultState,
type SandboxState,
} from "./prompt-input-utils"
import type { ExtensionMessage, ReviewComment, SendMessageFailedMessage, TextPart } from "../../types/messages"
import type { ExtensionMessage, ReviewCommentEntry, SendMessageFailedMessage, TextPart } from "../../types/messages"
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
import {
createdDraftKey,
@@ -84,7 +84,7 @@ import { isEnterKeyCommitNotIme } from "../../utils/ime-enter"
import { parseMemoryCommand, type ParsedMemoryCommand } from "../../utils/memory-command"
import { useMemory } from "../../context/memory"
function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] {
function mergeReviewComments(current: ReviewCommentEntry[], incoming: ReviewCommentEntry[]): ReviewCommentEntry[] {
if (incoming.length === 0) return current
const map = new Map(current.map((item) => [item.id, item]))
for (const item of incoming) {
@@ -227,7 +227,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const saveDraft = (
key: string,
next: string,
comments: ReviewComment[],
comments: ReviewCommentEntry[],
imgs: ImageAttachment[],
scroll = textareaRef?.scrollTop ?? scrollDrafts.get(key) ?? 0,
) => savePromptDraft(key, next, comments, imgs, scroll)
@@ -239,7 +239,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const [text, setText] = createSignal("")
const [reviewComments, setReviewComments] = createSignal<ReviewComment[]>([])
const [reviewComments, setReviewComments] = createSignal<ReviewCommentEntry[]>([])
const [enhancing, setEnhancing] = createSignal(false)
const [autoApprove, setAutoApprove] = createSignal(false)
const [sandboxes, setSandboxes] = createSignal<Record<string, SandboxState>>({})
@@ -353,7 +353,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const speech = useSpeechToText(vscode, server, language)
const speechModels = useSpeechToTextModels()
const replaceReviewComments = (next: ReviewComment[]) => {
const replaceReviewComments = (next: ReviewCommentEntry[]) => {
setReviewComments(next)
if (next.length === 0) {
reviewDrafts.delete(draftKey())
@@ -3,16 +3,18 @@ import { Button } from "@kilocode/kilo-ui/button"
import { Dialog } from "@kilocode/kilo-ui/dialog"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Markdown } from "@kilocode/kilo-ui/markdown"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import { useWorktreeMode } from "../../context/worktree-mode"
import type { ReviewComment } from "../../types/messages"
import { isPRReviewComment } from "../../../../src/shared/review-comments"
import type { ReviewCommentEntry } from "../../types/messages"
import { fileName } from "./prompt-input-utils"
interface ReviewCommentsProps {
comments: ReviewComment[]
comments: ReviewCommentEntry[]
sessionID?: string
variant?: "draft" | "message"
onRemove?: (id: string) => void
@@ -24,10 +26,22 @@ export const ReviewComments: Component<ReviewCommentsProps> = (props) => {
const vscode = useVSCode()
const worktree = useWorktreeMode()
const dialog = useDialog()
const side = (item: ReviewComment) => (item.side === "deletions" ? "-" : "+")
const title = (item: ReviewComment) => `${fileName(item.file)} ${side(item)}${item.line}`
const author = (item: ReviewCommentEntry) => (isPRReviewComment(item) ? item.author : "")
const side = (item: ReviewCommentEntry) => {
if (isPRReviewComment(item)) return ""
return item.side === "deletions" ? "-" : "+"
}
const line = (item: ReviewCommentEntry) => (item.line ? `${side(item)}${item.line}` : "")
const body = (item: ReviewCommentEntry) => (isPRReviewComment(item) ? item.body : item.comment)
const snippet = (item: ReviewCommentEntry) => (isPRReviewComment(item) ? item.diffHunk : item.selectedText)
const label = (item: ReviewCommentEntry) => (item.file ? fileName(item.file) : `@${author(item)}`)
const title = (item: ReviewCommentEntry) => {
const at = line(item)
return at ? `${label(item)} ${at}` : label(item)
}
const open = (item: ReviewComment) => {
const open = (item: ReviewCommentEntry) => {
if (!item.file) return
if (worktree && props.sessionID) {
vscode.postMessage({
type: "agentManager.openFile",
@@ -42,35 +56,59 @@ export const ReviewComments: Component<ReviewCommentsProps> = (props) => {
dialog.close()
}
const show = (item: ReviewComment) => {
const show = (item: ReviewCommentEntry) => {
dialog.show(() => (
<Dialog title={language.t("agentManager.review.modalTitle")} fit>
<div class="prompt-review-modal">
<div class="prompt-review-modal-head">
<span class="prompt-review-modal-headline">{title(item)}</span>
<Tooltip value={language.t("agentManager.diff.openFile")} placement="top">
<IconButton
icon="go-to-file"
size="small"
variant="ghost"
label={language.t("agentManager.diff.openFile")}
onClick={() => open(item)}
/>
</Tooltip>
<Show when={item.file}>
<Tooltip value={language.t("agentManager.diff.openFile")} placement="top">
<IconButton
icon="go-to-file"
size="small"
variant="ghost"
label={language.t("agentManager.diff.openFile")}
onClick={() => open(item)}
/>
</Tooltip>
</Show>
</div>
<div class="prompt-review-modal-grid">
<span class="prompt-review-modal-label">{language.t("agentManager.review.metaFile")}</span>
<code class="prompt-review-modal-value">{item.file}</code>
<span class="prompt-review-modal-label">{language.t("agentManager.review.metaLine")}</span>
<span class="prompt-review-modal-value">L{item.line}</span>
<Show when={author(item)}>
{(login) => (
<>
<span class="prompt-review-modal-label">{language.t("agentManager.review.metaAuthor")}</span>
<span class="prompt-review-modal-value">@{login()}</span>
</>
)}
</Show>
<Show when={item.file}>
{(file) => (
<>
<span class="prompt-review-modal-label">{language.t("agentManager.review.metaFile")}</span>
<code class="prompt-review-modal-value">{file()}</code>
</>
)}
</Show>
<Show when={item.line}>
{(value) => (
<>
<span class="prompt-review-modal-label">{language.t("agentManager.review.metaLine")}</span>
<span class="prompt-review-modal-value">L{value()}</span>
</>
)}
</Show>
<span class="prompt-review-modal-label">{language.t("agentManager.review.metaComment")}</span>
<span class="prompt-review-modal-value">{item.comment}</span>
<span class="prompt-review-modal-value">
<Show when={isPRReviewComment(item)} fallback={body(item)}>
<Markdown text={body(item)} />
</Show>
</span>
</div>
<Show when={item.selectedText}>
<pre class="prompt-review-modal-snippet">{item.selectedText}</pre>
</Show>
<Show when={snippet(item)}>{(value) => <pre class="prompt-review-modal-snippet">{value()}</pre>}</Show>
</div>
</Dialog>
))
@@ -98,15 +136,12 @@ export const ReviewComments: Component<ReviewCommentsProps> = (props) => {
<div class="prompt-review-chip">
<button type="button" class="prompt-review-chip-body" onClick={() => show(item)}>
<span class="prompt-review-chip-icon">
<Icon name="comment" size="small" />
<Icon name={isPRReviewComment(item) ? "github" : "comment"} size="small" />
</span>
<span class="prompt-review-chip-copy">
<span class="prompt-review-chip-main">
<span class="prompt-review-chip-title">{fileName(item.file)}</span>
<span class="prompt-review-chip-line">
{side(item)}
{item.line}
</span>
<span class="prompt-review-chip-title">{label(item)}</span>
<Show when={line(item)}>{(value) => <span class="prompt-review-chip-line">{value()}</span>}</Show>
</span>
</span>
</button>
@@ -28,6 +28,7 @@ import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { ThinkingSelectorBase } from "../components/shared/ThinkingSelector"
import { DeferredPopover } from "../components/shared/DeferredPopover"
import { ProjectSelect } from "../../agent-manager/ProjectSelect"
import { PRComments } from "../../agent-manager/pr/PRComments"
import { createSignal, onCleanup, onMount, type JSX } from "solid-js"
import type {
AgentProjectSnapshot,
@@ -40,6 +41,7 @@ import type { ReviewComment } from "../../diff-viewer/review-comments"
import { createModeRouter } from "../../agent-manager/mode-router"
import "../../agent-manager/agent-manager.css"
import "../../agent-manager/agent-manager-review.css"
import "../../agent-manager/pr/pr-panel.css"
registerVscodeToolOverrides()
@@ -1484,3 +1486,83 @@ export const MultiProjectSidebar: Story = {
)
},
}
// ---------------------------------------------------------------------------
// PR panel — review comments
// ---------------------------------------------------------------------------
const prComments: NonNullable<PRStatus["comments"]> = {
total: 4,
unresolved: 2,
comments: [
{
id: "PRRC_1",
threadId: "PRRT_1",
author: "octocat",
body: "This throws when `gh` is missing. Can we guard it and fall back to the cached status?",
file: "packages/kilo-vscode/src/agent-manager/gh.ts",
line: 42,
url: "https://github.com/org/repo/pull/8594#discussion_r1",
resolved: false,
outdated: false,
diffHunk:
'@@ -39,7 +39,7 @@ export function execGhRead(args: string[]) {\n- return execWithShellEnv("gh", args, options)\n+ return execWithShellEnv("gh", args, { ...options, env: env(options) })',
replies: [{ author: "hubot", body: "Agreed. A guard plus a log line is enough here." }],
},
{
id: "PRRC_2",
threadId: "PRRT_2",
author: "hubot",
body: "The timeout should be a constant so the poller and the mutation cannot drift apart.",
file: "packages/kilo-vscode/src/agent-manager/pr/PRActions.ts",
line: 8,
url: "https://github.com/org/repo/pull/8594#discussion_r2",
resolved: false,
outdated: true,
},
{
id: "PRRC_3",
threadId: "PRRT_3",
author: "octocat",
body: "nit: rename this variable to `threads`.\n\nIt reads better next to the loop below.",
file: "packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts",
line: 71,
url: "https://github.com/org/repo/pull/8594#discussion_r3",
resolved: true,
outdated: false,
},
{
id: "PRRC_4",
threadId: "PRRT_4",
author: "hubot",
body: "Good catch, fixed in a9f21c3.",
file: "packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx",
line: 118,
url: "https://github.com/org/repo/pull/8594#discussion_r4",
resolved: true,
outdated: false,
},
],
}
export const PRPanelComments: Story = {
name: "PR panel — review comments",
render: () => (
<StoryProviders noPadding>
<div style={{ background: "var(--vscode-editor-background)" }}>
<PRComments comments={prComments} worktreeId="wt-a1" onOpenFile={() => {}} onOpenUrl={() => {}} />
</div>
</StoryProviders>
),
}
export const PRPanelComments200: Story = {
name: "PR panel — review comments (narrow)",
render: () => (
<StoryProviders noPadding>
<div style={{ background: "var(--vscode-editor-background)" }}>
<PRComments comments={prComments} worktreeId="wt-a1" onOpenFile={() => {}} onOpenUrl={() => {}} />
</div>
</StoryProviders>
),
}
@@ -63,6 +63,7 @@ export type {
AggregateCheckStatus,
PRCheck,
PRComment,
PRCommentReply,
PRReviewer,
} from "../../../agent-manager/pr/pr-types"
@@ -178,7 +179,11 @@ export interface LocalGitStats {
behind: number
}
export type { ReviewCommentData as ReviewComment } from "../../../../src/shared/review-comments"
export type {
ReviewCommentData as ReviewComment,
ReviewCommentEntry,
PRReviewCommentData,
} from "../../../../src/shared/review-comments"
/**
* Maximum number of parallel worktree versions for multi-version mode.
@@ -39,7 +39,7 @@ import type {
LocalGitStats,
ManagedSessionState,
PRStatus,
ReviewComment,
ReviewCommentEntry,
RunStatus,
SectionState,
TerminalDestination,
@@ -305,13 +305,13 @@ export interface AppendChatBoxMessage {
export interface AppendReviewCommentsMessage {
type: "appendReviewComments"
comments: ReviewComment[]
comments: ReviewCommentEntry[]
autoSend?: boolean
}
export interface AppendReviewCommentsToTerminalMessage {
type: "appendReviewCommentsToTerminal"
comments: ReviewComment[]
comments: ReviewCommentEntry[]
autoSend?: boolean
targetTerminalId: string
}
@@ -4,7 +4,7 @@ import type { MessageLoadMode } from "./sessions"
import type { PermissionFileDiff } from "./permissions"
import type { ModelSelection, ProviderConfig } from "./providers"
import type { Config } from "./config"
import type { ModelAllocation, ReviewComment, TerminalDestination, TerminalPlacement } from "./agent-manager"
import type { ModelAllocation, ReviewCommentEntry, TerminalDestination, TerminalPlacement } from "./agent-manager"
import type { ReviewMessageData } from "../../../../src/shared/review-comments"
import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets"
import type { AnacondaDesktopWebviewMessage } from "../../../../src/shared/anaconda-desktop-messages"
@@ -1061,7 +1061,7 @@ export interface OpenDiffVirtualRequest {
export interface DiffViewerSendCommentsRequest {
type: "diffViewer.sendComments"
comments: ReviewComment[]
comments: ReviewCommentEntry[]
autoSend: boolean
}
@@ -1,9 +1,9 @@
import type { ReviewComment } from "../types/messages"
import type { ReviewCommentEntry } from "../types/messages"
import type { ImageAttachment } from "../hooks/useImageAttachments"
import { pendingDraftKey, sessionDraftKey } from "./prompt-drafts"
export const drafts = new Map<string, string>()
export const reviewDrafts = new Map<string, ReviewComment[]>()
export const reviewDrafts = new Map<string, ReviewCommentEntry[]>()
export const imageDrafts = new Map<string, ImageAttachment[]>()
export const scrollDrafts = new Map<string, number>()
const discarded = new Set<string>()
@@ -13,7 +13,7 @@ const sending = new Set<string>()
export function savePromptDraft(
key: string,
text: string,
comments: ReviewComment[],
comments: ReviewCommentEntry[],
images: ImageAttachment[],
scroll = 0,
) {
+418
View File
@@ -0,0 +1,418 @@
# Plan: GitHub-style PR review comments in the Agent Manager PR panel
## Problem
The PR panel in Agent Manager renders every GitHub review thread as one flat,
always-expanded card. The card has weak actions and no way to hand a comment to
the agent. Nine concrete defects:
1. **The unresolve button looks disabled.** A resolved card gets
`opacity: 0.5` on the whole card
([`pr-panel.css:248`](../packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css#L248)),
which dims the enabled `Unresolve comment` button
([`PRComments.tsx:96`](../packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx#L96)).
No `disabled` attribute is ever set, so the control works but reads as dead.
This is the reported bug, and dimming is the wrong fix for "this thread is
done".
2. **Nothing collapses.** Every thread, resolved or not, renders its full diff
hunk plus its full Markdown body
([`PRComments.tsx:118-124`](../packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx#L118-L124)).
On a PR with 20 resolved threads, the 2 threads that still need work are
buried behind hundreds of pixels of settled discussion.
3. **The actions are not prominent.** Copy is one ghost icon pushed into the
header row
([`PRComments.tsx:80`](../packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx#L80)),
and resolve is a low-contrast bordered text button at the bottom
([`pr-panel.css:302`](../packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css#L302)).
The local review comment cards are the opposite: a real action row with a
primary send action.
4. **A PR comment cannot be sent to the agent.** Local diff review comments have
`Send to chat` per comment and `Send all to chat` for the batch
([`review-annotations.ts:425-432`](../packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts#L425-L432),
[`DiffPanel.tsx:443-459`](../packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx#L443-L459)).
GitHub PR comments have no equivalent, so the user copies text by hand and
pastes it into the prompt as raw text.
5. **Only the first comment of a thread is fetched.**
`comments(first: 1)` in
[`PRStatusPoller.ts:492`](../packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts#L492)
and the matching parser
([`am-pr-utils.ts:69-89`](../packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts#L69-L89))
drop every reply. The reply usually holds the decision ("agreed, guard it"),
so both the UI and any send-to-agent payload lose the important half of the
conversation.
6. **No outdated state.** `isOutdated` is not queried, so a comment against code
that no longer exists looks identical to a live one.
7. **Resolve failures are opaque.** The bridge drops the real `gh` error and
posts `success: false` without the `error` field that the message type
already declares
([`pr-status-bridge.ts:120-130`](../packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts#L120-L130),
[`types.ts:399-405`](../packages/kilo-vscode/src/agent-manager/types.ts#L399-L405)).
The card shows `Failed to resolve thread.` for a permission error, a network
error, and a stale thread ID alike.
8. **Strings are hardcoded English** in a webview that ships 21 locales.
9. **`createMemo` is used for a side effect**
([`PRComments.tsx:23`](../packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx#L23))
to reconcile the optimistic state with the poll result.
## Reference behavior: how GitHub does it
The target is GitHub's own review-thread model, not a new invention.
| GitHub behavior | Detail |
|---|---|
| Resolved thread collapses | In **Files changed**, a resolved thread renders as one compact row: avatar, author, the first line of the comment truncated, and a `Resolved` label. The body, the diff context, and the reply box are hidden. |
| The row is the disclosure | Clicking the collapsed row expands the full thread in place. Nothing is dimmed after it expands. |
| Unresolve is always live | The expanded thread keeps a normal, fully enabled `Unresolve conversation` button. GitHub never greys it out. |
| Resolve collapses immediately | `Resolve conversation` collapses the thread as soon as the mutation succeeds, which is the feedback that the action worked. |
| Outdated threads collapse too | A thread whose code changed gets an `Outdated` badge and starts collapsed. |
| Timeline summary | In **Conversation**, a resolved thread shows `<author> marked this conversation as resolved` with a `Show resolved` button. |
| Counts are visible | The header shows unresolved conversation counts, and the filter menu offers `Unresolved` / `Resolved` / `All`. |
| Replies stay in the thread | A collapsed row hints at thread size; expanding shows every reply in order. |
Two GitHub affordances are deliberately **not** copied: the conversation filter
dropdown (a 320px inspector panel does not have room for it, and two grouped
sections carry the same information), and reply composition (out of scope, see
Non-goals).
## Proposed UX
### Layout
```
──────────────────────────────────────────────
▾ COMMENTS 3 unresolved
┌────────────────────────────────────────┐
│ ➤ Send 3 unresolved to agent │ primary, full width
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ src/agent-manager/gh.ts:42 │ meta row
│ ┌──────────── diff hunk ─────────────┐ │
│ │ - const x = 1 │ │
│ │ + const x = 2 │ │
│ └────────────────────────────────────┘ │
@alice
│ This throws when gh is missing. │ Markdown body
│ ▸ Show 2 replies │
│ ────────────────────────────────────── │
│ [➤ Send] [Resolve] ⧉ ↗ ⇥ │ action row
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ src/pr/PRActions.ts:8 [Outdated] │ outdated: collapsed by default
│ ▸ @bob this mutation needs a timeout │
└────────────────────────────────────────┘
▸ Resolved (5)
```
With the resolved group open:
```
▾ Resolved (5)
▸ ✓ @bob nit: rename this variable
▸ ✓ @alice can we extract this helper?
▸ ✓ @bob good catch, fixed in a9f21c3
```
### Interaction rules
1. **Grouping is derived from server state.** Unresolved threads render first,
then a collapsible `Resolved (N)` group that starts closed. The section
heading keeps the existing `N unresolved` count.
2. **Unresolved threads render expanded**, except outdated ones, which render
collapsed with an `Outdated` badge (GitHub parity).
3. **Resolved threads render as one-line rows.** The row is a `<button>` with
`aria-expanded`, showing a check icon, `@author`, and the first line of the
body truncated to one line. Clicking it expands the same card the unresolved
threads use, with `Unresolve` in place of `Resolve`.
4. **No opacity dimming on controls.** Remove
`.am-pr-panel-comment-resolved { opacity: 0.5 }`. Resolved state is
communicated by the collapsed row, the check icon, and a muted `Resolved`
badge. Muting applies only to the row preview text, never to a button.
5. **Resolve gives immediate feedback.** On success the card leaves the
unresolved group and a toast appears: `Comment resolved` with an `Undo`
action that calls unresolve on the same thread. The resolved group is not
auto-expanded, so the panel does not jump.
6. **Pending state does not move the layout.** Keep the button in place and show
its spinner inside it, instead of swapping the whole row for a
`Loading` line as today.
7. **Errors stay actionable.** Show the real reason, truncated to one line, and
keep the button enabled for a retry. Offer `Open on GitHub` as the escape
hatch.
8. **Replies are progressive.** The card shows the first comment and a
`Show N replies` disclosure. Replies render with the same Markdown component,
indented, and are always included in a send-to-agent payload even when the
disclosure is closed.
### Action row
Order, left to right, always visible (not hover-only, because a hidden action in
a narrow inspector is an undiscovered action):
| Action | Control | Behavior |
|---|---|---|
| Send to agent | `Button variant="primary" size="small"` with the send glyph | Sends this thread, with replies, to the active session or the active side terminal |
| Resolve / Unresolve | `Button variant="secondary" size="small"` | GraphQL mutation, unchanged transport |
| Copy | `IconButton icon="copy" variant="ghost"` | Copies the full thread as Markdown, not only the first body as today |
| Open on GitHub | `IconButton icon="square-arrow-top-right" variant="ghost"` | Uses `comment.url`, currently fetched and unused |
| Open file | `IconButton icon="go-to-file" variant="ghost"` | `agentManager.openFile` with the worktree session and line; hidden when the thread has no file |
This mirrors the local review comment card, which pairs text buttons for the
primary verbs with ghost icon buttons for the utilities.
### Bulk send
- One primary, full-width button under the section heading:
`Send 3 unresolved to agent`. It only renders when `unresolved > 0`.
- When a side terminal is the active destination, the label becomes
`Send 3 unresolved to terminal`, following the same destination rule the diff
panel already uses via `activeTerminalId`.
- Bulk send never resolves anything on GitHub. The agent has not fixed the code
yet, so resolving would be a lie to the reviewer.
- Sent threads get a `Sent` badge for the rest of the session, kept in a
per-worktree `Set<threadId>` in `AgentManagerApp`, next to the existing
`reviewCommentsByContext` state. Without it, a second click looks identical to
the first and silently duplicates the prompt.
- A toast confirms: `Sent 3 comments to the agent`.
- Payload caps, so one PR cannot blow up a prompt: at most 100 threads (the
existing `LIMIT` in the shared review payload), the diff hunk truncated to 40
lines, each body truncated to 4000 characters, and each thread limited to 10
replies. When anything is dropped, the toast says
`Only the first 100 comments were sent`.
### Reuse the existing comment message UI
Do not paste raw text into the prompt. Convert a PR thread into the same review
comment payload the local diff review already uses, so the message renders as a
comment chip with a detail dialog
([`ReviewComments.tsx`](../packages/kilo-vscode/webview-ui/src/components/chat/ReviewComments.tsx))
and survives in session history through the part metadata
([`review-comments.ts`](../packages/kilo-vscode/src/shared/review-comments.ts)).
The chip gains two PR-specific affordances: a `github` icon instead of the
`comment` icon, and `@author` next to the file name. The detail dialog renders
the body with the shared `Markdown` component and shows the diff hunk in the
existing snippet slot.
## Data model
### GraphQL query
Extend `fetchComments` in
[`PRStatusPoller.ts:478-529`](../packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts#L478-L529):
```graphql
reviewThreads(first: 100) {
totalCount
nodes {
id
isResolved
isOutdated
diffSide
resolvedBy { login }
comments(first: 20) {
totalCount
nodes { id author { login avatarUrl } body path line originalLine url createdAt diffHunk }
}
}
}
```
`originalLine` gives a usable line for outdated threads where `line` is null.
`diffSide` maps `LEFT` to `deletions` and `RIGHT` to `additions`, which the
review payload needs. Thread pagination stays at 100 with no cursor loop, same
as today.
### `PRComment`
Extend the three mirrored declarations
([`src/agent-manager/types.ts:62-74`](../packages/kilo-vscode/src/agent-manager/types.ts#L62-L74),
[`webview-ui/src/types/messages/agent-manager.ts`](../packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts),
[`webview-ui/agent-manager/pr/pr-types.ts:16-28`](../packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts#L16-L28)):
```ts
outdated: boolean
side?: "additions" | "deletions"
resolvedBy?: string
replies?: { id: string; author: string; body: string; createdAt?: number }[]
replyCount?: number // thread totalCount - 1, so ">20 replies" stays truthful
```
`unresolved` count semantics do not change.
### Shared review payload
Add a discriminated PR variant to
[`src/shared/review-comments.ts`](../packages/kilo-vscode/src/shared/review-comments.ts)
rather than loosening the local shape:
```ts
export interface PRReviewCommentData {
id: string // GitHub thread node id
origin: "pr"
author: string
body: string
file?: string
line?: number
side?: "additions" | "deletions"
diffHunk?: string
url?: string
outdated?: boolean
replies?: { author: string; body: string }[]
}
export type ReviewCommentEntry = ReviewCommentData | PRReviewCommentData
```
Rules that keep this safe:
- `version` stays `1`. An entry without `origin` follows the existing strict
local validation, so every historical message keeps parsing.
- `view()` compares the message text against Markdown regenerated from parsed
data. The PR formatter must therefore be fully deterministic and every PR
field must round-trip through `parseComment`, or old and new messages fail the
prefix check and lose their chips.
- `file` and `line` are optional only for `origin: "pr"`, because GitHub allows
a thread with no resolvable line. The formatter omits the missing fragment.
- Path validation (no absolute path, no `..`, no NUL) applies to PR entries too.
The path comes from an API response, but it still reaches `openFile`.
Markdown produced for a PR entry:
````md
## Review Comments
**src/agent-manager/gh.ts** (line 42), PR comment by @alice:
```
@@ -39,7 +39,7 @@
- const x = 1
+ const x = 2
```
This throws when gh is missing.
> @bob: agreed, guard it
````
A thread with no file or line degrades to `PR comment by @alice:`.
## What was implemented
The change landed as one pass over the files below.
### Thread data
| File | Change |
|---|---|
| `src/agent-manager/PRStatusPoller.ts` | Query `isOutdated`, `originalLine`, and `comments(first: 10)` per review thread |
| `src/agent-manager/pr/am-pr-types.ts` | `isOutdated`, `originalLine` on the raw `gh` shapes |
| `src/agent-manager/pr/am-pr-utils.ts` | Map replies from the thread tail, carry `outdated`, fall back to `originalLine`, and add `ghErrorReason` plus `commentsSig` |
| `src/agent-manager/types.ts`, `webview-ui/agent-manager/pr/pr-types.ts` | `outdated`, `replies` on `PRComment` |
| `src/agent-manager/pr-status-bridge.ts` | Send the real `gh` failure reason in the existing `error` field |
The poll deduplication hash now includes a comment signature. Thread and
unresolved counts alone do not change when a reply is added or a body is edited,
so without the signature the panel would render replies it can never refresh.
`resolvedBy` and `diffSide` were not added. Neither is used by the UI or the
payload, so fetching them would only grow the query.
### Shared review payload
`src/shared/review-comments.ts` gained a `PRReviewCommentData` variant of
`ReviewCommentEntry`, guarded by `origin: "pr"`. `version` stays `1`, entries
without `origin` keep the old strict local validation, and every PR field
round-trips so the markdown prefix regenerates byte for byte.
`webview-ui/agent-manager/pr/pr-comment-payload.ts` converts a `PRComment` into
that payload with the caps, and also produces the copy text, the collapsed row
preview, and the `githubUrl` guard that keeps non-https urls away from both the
payload and `openExternal`. Hunk truncation keeps the `@@` header and the tail,
and caps total characters, because a generated file can put a whole hunk on one
line and exceed the shared payload limit.
The payload deliberately carries no url and no reply identifiers. Nothing reads
them, and unused metadata in a persisted message format only invites drift.
### UI
| File | Change |
|---|---|
| `pr/PRComments.tsx` | Section, unresolved and resolved groups, optimistic resolve state, one result listener, bulk send |
| `pr/PRCommentCard.tsx` | New: collapsed row and expanded card with replies and the action row |
| `pr/pr-panel.css` | Row, group, badge, and action styles; the `opacity: 0.5` rule is gone |
| `pr/PRPanel.tsx`, `AgentManagerApp.tsx` | Pass `activeTerminalId`, `onOpenFile`, and `onOpenUrl` through |
| `chat/ReviewComments.tsx` | Render PR entries: `github` icon, author, markdown body, hunk snippet |
| `webview-ui/src/stories/agent-manager.stories.tsx` | `PR panel - review comments` story for visual review |
The card owns its collapsed row instead of a separate row component, and replies
render inline when a card is open instead of behind a second disclosure. Both
choices keep the component count low for the amount of behavior involved.
The list keeps Solid's `Index`, not `For`. Each poll allocates fresh `PRComment`
objects, so identity keying would remount every card and repeat the Pierre diff
and Markdown work on every status push. Sending reuses the existing
`sendReviewComments` helper instead of a second copy of the event envelope.
### Deliberate simplifications
- No toasts and no undo affordance. A successful resolve collapses the card and
opens the `Resolved` group, so the thread is visibly moved rather than gone,
and unresolve is one click away inside that group.
- The `Sent` badge is component-local state, not lifted into `AgentManagerApp`.
It resets when the panel closes, which is enough to stop a double send from
looking identical to the first one.
- A failed resolve or unresolve forces the card open, otherwise the error would
be hidden inside a collapsed row.
### Localization
`agentManager.pr.comment.*` and `agentManager.review.metaAuthor` were added to
`webview-ui/agent-manager/i18n/en.ts` and all 20 other locales, which
`tests/unit/i18n-keys.test.ts` and `agent-manager-i18n-split.test.ts` require.
## Edge cases
| Case | Handling |
|---|---|
| Thread with no file or line | Card shows the author only, no `Open file`, payload omits the location fragment |
| Outdated thread | `Outdated` badge, collapsed by default, `originalLine` used for the payload |
| More than 10 replies | Only the first 10 thread comments are fetched; the rest stay on GitHub, one click away from the card |
| Single-line hunk of a generated file | Truncated by characters so the payload stays inside the shared limit and keeps its chips |
| Comment url with a non-https scheme | Dropped by `githubUrl`, so it never reaches `openExternal` |
| More than 100 threads | Threads past 100 are not fetched today; keep that limit and do not silently claim completeness in the bulk label |
| No write access to the repo | Resolve fails; show the reason and keep `Open on GitHub` |
| Bulk send with an empty prompt | Existing `autoSend` semantics apply: it sends; with a draft present it appends to the draft |
| Bulk send while the agent is busy | `PromptInput` already skips auto-send when disabled, so the comments stay in the draft |
| Worktree switch mid-send | The payload targets the destination captured at click time |
| Poll arrives during a pending mutation | Existing optimistic reconciliation, moved to `createEffect` |
| Resolved group open state | Local signal, not persisted, same as the other PR sections |
## Tests
| Level | Coverage |
|---|---|
| `tests/unit/am-pr-utils.test.ts` | Reply mapping, outdated, `originalLine` fallback, `ghErrorReason`, `commentsSig` change detection |
| `tests/unit/review-comments-pr.test.ts` (new) | PR payload format and parse round-trip, mixed payloads, malformed rejection, legacy compatibility, hunk line and character caps, url guard |
| `tests/unit/pr-comments-render.test.ts` | Hunk still renders; the resolved thread sits collapsed behind the group; the row expands into a card; the unresolve control is enabled; send dispatches `appendReviewComments` with an `origin: "pr"` entry including replies |
| `tests/unit/am-pr-status-bridge.test.ts` | The failure result carries the `gh` error message |
| Storybook | `AgentManager / PR panel - review comments` renders all four thread states for visual review and picks up a CI visual-regression baseline |
Commands: `bun run typecheck`, `bun run lint`, `bun run test:unit` from
`packages/kilo-vscode/`, plus `bun run knip` because new exports are added.
## Non-goals
- Replying to a PR comment or creating a new review comment from the panel.
- Approving, requesting changes, or submitting a review.
- Resolving a thread automatically after the agent edits the code.
- Thread pagination past 100 threads.
- A GitHub-style conversation filter dropdown.
- Reusing the Pierre annotation layer to place PR comments inline in the diff
viewer. That is a larger, separate change.
## Changeset
One `minor` changeset: resolved PR comments collapse like GitHub, PR comments
can be sent to the agent individually or in bulk, and PR comment cards gain
prominent copy, resolve, and open actions.