mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
feat(agent-manager): improve PR comment interactions
This commit is contained in:
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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": "ملف كبير (مطوي)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "فایل بزرگ (جمعشده)",
|
||||
|
||||
@@ -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 à l’agent",
|
||||
"agentManager.pr.comment.sendAllToTerminal": "Envoyer {{count}} non résolus au terminal",
|
||||
"agentManager.pr.comment.send": "Envoyer à l’agent",
|
||||
"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 d’annuler la résolution. {{error}}",
|
||||
"agentManager.review.collapsedOnly": "{{count}} repliés",
|
||||
"agentManager.review.collapsedWithLarge": "{{collapsed}} repliés, {{large}} volumineux",
|
||||
"agentManager.review.largeFileCollapsed": "Fichier volumineux (replié)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "大きなファイル(折りたたみ)",
|
||||
|
||||
@@ -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": "큰 파일(접힘)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "Большой файл (свернут)",
|
||||
|
||||
@@ -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": "ไฟล์ขนาดใหญ่ (พับอยู่)",
|
||||
|
||||
@@ -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ı)",
|
||||
|
||||
@@ -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": "Великий файл (згорнуто)",
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user