Merge pull request #14025 from Kilo-Org/plan-remote-diff-comments

feat(vscode): post inline comments to GitHub pull requests
This commit is contained in:
Marius
2026-09-14 10:16:43 +02:00
committed by GitHub
83 changed files with 2947 additions and 241 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Post inline comments to a checked-out GitHub pull request from Changes and Agent Manager diff views. The compact composer saves locally, sends to Kilo, or posts to GitHub with an explicit destination choice. Cmd/Ctrl+Enter saves a comment, and pressing it again in the review view sends all comments to Kilo without publishing to GitHub. The toolbar shows separate send-all-to-Kilo and send-all-to-GitHub actions, and only the Kilo action carries the keyboard shortcut. GitHub posting stops on the first error so unpublished comments are kept.
@@ -150,6 +150,7 @@ export class PRReviewActions {
if (!result.requestId) throw new Error("Missing request identity.")
const initial = this.host.context(message)
const context = { ...initial, pr: { ...initial.pr } }
await this.checkBranch(context)
if (message.type === "agentManager.loadPRFiles") {
const snapshot = await this.load(context, message)
this.host.post({ ...result, type: "agentManager.loadPRFilesResult", success: true, snapshot })
@@ -180,6 +181,13 @@ export class PRReviewActions {
}
}
private async checkBranch(context: PRReviewContext) {
if (!this.host.checkBranch) return
const branch = await this.host.checkBranch(context.directory)
if (!branch || branch === "HEAD" || branch !== context.branch)
throw new Error("Diff branch changed. Refresh and try again.")
}
private current(context: PRReviewContext, message: Record<string, unknown>) {
if (identity(this.host.context(message)) !== identity(context))
throw new Error("Pull request context changed. Reload the review.")
@@ -203,6 +211,7 @@ export class PRReviewActions {
}
}
const after = await metadata(context)
await this.checkBranch(context)
this.current(context, message)
if (
before.head !== after.head ||
@@ -229,6 +238,7 @@ export class PRReviewActions {
const snapshot = this.snapshot(context, message)
const { file, start, end, body } = selection(snapshot, message)
const fresh = await metadata(context)
await this.checkBranch(context)
this.current(context, message)
if (fresh.head !== snapshot.data.head || fresh.base !== snapshot.base)
throw new Error("Pull request changed. Reload the review before posting.")
@@ -263,6 +273,7 @@ export class PRReviewActions {
if (message.head !== snapshot.data.head)
throw new Error("Pull request changed. Reload the review before submitting.")
const fresh = await metadata(context)
await this.checkBranch(context)
this.current(context, message)
if (fresh.head !== snapshot.data.head || fresh.base !== snapshot.base)
throw new Error("Pull request changed. Reload the review before submitting.")
@@ -18,4 +18,5 @@ export interface PRReviewHost {
conflicts?: (context: PRReviewContext, base: string, head: string) => Promise<string[]>
getPRMergeMethod?: (repo: string) => PRMergeMethod | undefined
savePRMergeMethod?: (repo: string, method: PRMergeMethod) => Promise<void>
checkBranch?: (directory: string) => Promise<string>
}
@@ -17,6 +17,9 @@ import { addCommentReaction, isPRReactionContent, removeCommentReaction } from "
import type { PRStatus } from "../agent-manager/types"
import { ghErrorReason } from "../agent-manager/pr/am-pr-utils"
import { createDiffCommentActions } from "./comment-actions"
import { PRReviewActions } from "../agent-manager/pr/review-actions"
import type { PRReviewContext } from "../agent-manager/pr/review-context"
import { execWithShellEnv } from "../agent-manager/shell-env"
type CommentHandler = (comments: unknown[], autoSend: boolean) => void
type OpenArgs = {
@@ -120,6 +123,7 @@ export class DiffViewerProvider implements vscode.Disposable {
private baseBranchOverride: string | undefined
private target: CommentHandler | undefined
private readonly prPolling: ReturnType<typeof createDiffPRPolling>
private readonly reviews: PRReviewActions
private focusPending = false
private openGeneration = 0
private readonly identity = randomUUID()
@@ -149,6 +153,23 @@ export class DiffViewerProvider implements vscode.Disposable {
onStatus: () => this.sendComments(),
log: (...args) => this.log(...args),
})
this.reviews = new PRReviewActions({
context: (message) => this.reviewContext(message),
post: (message) => {
void this.panel?.webview.postMessage(message)
},
refresh: (review) => {
if (this.commentContext()?.token === review.projectId) this.prPolling.refresh()
},
dirtyFiles: () => [],
checkBranch: async (directory) => {
const result = await execWithShellEnv("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: directory,
timeout: 5_000,
})
return result.stdout.trim()
},
})
}
setCommentHandler(handler: CommentHandler): void {
@@ -279,7 +300,7 @@ export class DiffViewerProvider implements vscode.Disposable {
}
private onMessage(msg: Record<string, unknown>): void {
if (this.actions.handle(msg)) return
if (this.actions.handle(msg) || this.reviews.handle(msg)) return
const handler = this.messageHandlers[msg.type as string]
handler?.(msg)
}
@@ -483,7 +504,14 @@ export class DiffViewerProvider implements vscode.Disposable {
const comments = selected && !match ? [...live, { ...selected, outdated: true }] : live
const ctx = this.commentContext()
const target = ctx
? { projectId: ctx.token, worktreeId: "diff", prNumber: ctx.pr.number, prUrl: ctx.pr.url }
? {
projectId: ctx.token,
worktreeId: "diff",
prNumber: ctx.pr.number,
prUrl: ctx.pr.url,
baseRefOid: ctx.pr.baseRefOid,
headRefOid: ctx.pr.headRefOid,
}
: undefined
void this.panel.webview.postMessage({
type: "diffViewer.prComments",
@@ -522,6 +550,25 @@ export class DiffViewerProvider implements vscode.Disposable {
}
}
private reviewContext(message: Record<string, unknown>): PRReviewContext {
const ctx = this.commentContext()
if (
!ctx ||
message.projectId !== ctx.token ||
message.worktreeId !== "diff" ||
message.prNumber !== ctx.pr.number ||
message.prUrl !== ctx.pr.url
)
throw new Error("Pull request context changed. Refresh and try again.")
return {
pr: ctx.pr,
directory: ctx.directory,
branch: ctx.branch,
worktreeId: "diff",
projectId: ctx.token,
}
}
private getHtml(webview: vscode.Webview): string {
return buildWebviewHtml(webview, {
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.js")),
@@ -3,6 +3,8 @@ export interface PRTarget {
worktreeId: string
prNumber: number
prUrl: string
baseRefOid?: string
headRefOid?: string
}
export interface PRFile {
+4 -1
View File
@@ -45,9 +45,12 @@ export function parsePatch(patch: string, totals?: { additions: unknown; deletio
function hunks(patch: string, selection?: Range) {
const lines = patch.split("\n")
if (lines.at(-1) === "") lines.pop()
// Patches may include file headers (`diff --git`, `---`, `+++`) before the first hunk.
const start = lines.findIndex((line) => line.startsWith("@@"))
if (start < 0) return
const result: Range[] = []
const selected: string[] = []
let index = 0
let index = start
let added = 0
let removed = 0
let left = 0
@@ -198,18 +198,18 @@ test("preserves scroll while adding and editing a review comment", async ({ page
const line = target.locator('[data-line="1"]').last()
await line.hover()
await target.locator("[data-utility-button]").last().click()
await expect(target.locator(".am-annotation-textarea")).toBeVisible()
await target.locator(".am-annotation-textarea").fill("Keep this stable")
await expect(target.locator(".am-annotation-draft textarea")).toBeVisible()
await target.locator(".am-annotation-draft textarea").fill("Keep this stable")
const top = await target.evaluate((el) => el.getBoundingClientRect().top)
const before = await scroller.evaluate((el) => el.scrollTop)
await page.getByRole("button", { name: "Apply agent edit" }).click()
await expect(page.getByTestId("agent-edit-version")).toHaveText("after")
await expect(target.locator(".am-annotation-textarea")).toHaveValue("Keep this stable")
await expect(target.locator(".am-annotation-draft textarea")).toHaveValue("Keep this stable")
await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBeCloseTo(before, 0)
await expect.poll(async () => target.evaluate((el) => el.getBoundingClientRect().top)).toBeCloseTo(top, 0)
await target.getByRole("button", { name: "Comment" }).click()
await target.locator('[data-action="save"]').click()
await expect(target.getByText("Keep this stable")).toBeVisible()
const saved = await scroller.evaluate((el) => el.scrollTop)
@@ -229,15 +229,15 @@ for (const modifier of ["Meta", "Control"] as const) {
for (const text of ["First comment", "Second comment"]) {
await target.locator('[data-line="1"]').last().hover()
await target.locator("[data-utility-button]").last().click()
await target.locator(".am-annotation-textarea").fill(text)
await target.locator(".am-annotation-draft textarea").fill(text)
if (text === "First comment") {
await target.getByRole("button", { name: "Comment", exact: true }).click()
await target.locator('[data-action="save"]').click()
await expect(target.getByText(text, { exact: true })).toBeVisible()
}
}
await page.keyboard.press("Shift+Enter")
await expect(target.locator(".am-annotation-textarea")).toHaveValue("Second comment\n")
await expect(target.locator(".am-annotation-draft textarea")).toHaveValue("Second comment\n")
const result = await page.evaluate((modifier) => {
const sent: Array<{ comments: Array<{ comment: string }>; autoSend: boolean }> = []
@@ -0,0 +1,141 @@
import assert from "node:assert/strict"
import { harness } from "./comment-harness"
import type { PRReviewRequest } from "../../src/shared/pr-comment-actions"
const { window, root, messages, node, button, input, type, last, respond, wait, mount } =
await harness<PRReviewRequest>()
const { PRCommentForm } = await import("../../webview-ui/agent-manager/pr/PRCommentForm")
const saved: string[] = []
const sent: string[] = []
let cancelled = 0
let completed = 0
const release = mount(() => (
<>
<div id="local">
<PRCommentForm
inline
action="diff"
worktreeId="diff-test"
file="example.ts"
side="RIGHT"
startLine={2}
endLine={2}
selectedText="return 1"
destination="local"
onSave={(body) => saved.push(body)}
onSendKilo={(body) => sent.push(body)}
onGithubSuccess={() => completed++}
onCancel={() => cancelled++}
onDestinationChange={() => {}}
/>
</div>
<div id="remote">
<PRCommentForm
inline
action="diff"
worktreeId="diff-test"
file="other.ts"
side="RIGHT"
startLine={5}
endLine={5}
selectedText="old line"
destination="github"
github={{
prNumber: 1,
prUrl: "https://github.com/example/fixture/pull/1",
snapshotId: "snapshot",
label: "GitHub #1",
closed: false,
}}
onSave={() => {}}
onSendKilo={() => {}}
onGithubSuccess={() => completed++}
onCancel={() => cancelled++}
onDestinationChange={() => {}}
/>
</div>
<div id="remote2">
<PRCommentForm
inline
action="diff"
worktreeId="diff-test"
file="other.ts"
side="RIGHT"
startLine={5}
endLine={5}
selectedText="old line"
destination="github"
github={{
prNumber: 2,
prUrl: "https://github.com/example/fixture/pull/2",
snapshotId: "snapshot-2",
label: "GitHub #2",
closed: false,
}}
onSave={() => {}}
onSendKilo={() => {}}
onGithubSuccess={() => completed++}
onCancel={() => cancelled++}
onDestinationChange={() => {}}
/>
</div>
</>
))
await wait()
const local = node("#local")
const remote = node("#remote")
const remote2 = node("#remote2")
// Local-only destination exposes Kilo actions, never the GitHub split button.
assert.equal(button("send-kilo", local).textContent, "Send to Kilo")
assert.equal(button("save", local).textContent, "Save")
assert.equal(button("cancel", local).textContent, "Cancel")
assert.equal(local.querySelector('[data-action="send-primary"]'), null, "no split button without a PR")
assert.equal(messages.length, 0)
type(local, "Keep this")
button("save", local).click()
assert.deepEqual(saved, ["Keep this"])
assert.equal(input(local).value, "", "saving clears the composer")
type(local, "Send this")
button("send-kilo", local).click()
assert.deepEqual(sent, ["Send this"])
assert.equal(input(local).value, "", "sending to Kilo clears the composer")
// Plain Enter sends to Kilo and never posts to GitHub.
type(local, "Keyboard send")
input(local).dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
assert.deepEqual(sent, ["Send this", "Keyboard send"])
assert.equal(messages.length, 0, "local actions never request a GitHub write")
// Cmd/Ctrl+Enter saves the comment locally instead of sending it.
type(local, "Keyboard save")
input(local).dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))
assert.deepEqual(saved, ["Keep this", "Keyboard save"], "Cmd+Enter saves the comment")
assert.deepEqual(sent, ["Send this", "Keyboard send"], "Cmd+Enter does not send to Kilo")
assert.equal(messages.length, 0, "Cmd+Enter never requests a GitHub write")
// The remembered GitHub destination drives the split primary label.
assert.equal(button("send-primary", remote).textContent, "Send to GitHub #1")
node('[aria-label="Choose destination"]', remote)
// Enter is not bound to the GitHub destination, so it cannot publish by accident.
type(remote, "Do not post")
input(remote).dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
assert.equal(messages.length, 0, "Enter never posts to GitHub")
assert.equal(input(remote2).value, "", "a draft is scoped to its own PR identity")
type(remote, "Post me")
button("send-primary", remote).click()
const request = last()
assert.equal(request.type, "agentManager.createReviewComment")
assert.equal(input(remote).disabled, true)
button("send-primary", remote).click()
assert.equal(messages.length, 1, "double submission cannot publish twice")
respond(request, {})
assert.equal(completed, 1)
button("cancel", local).click()
assert.equal(cancelled, 1)
release()
await window.happyDOM.close()
@@ -0,0 +1,98 @@
import assert from "node:assert/strict"
import { harness } from "./comment-harness"
import type { PRReviewRequest } from "../../src/shared/pr-comment-actions"
const { window, root, messages, node, button, input, type, last, respond, wait, mount } =
await harness<PRReviewRequest>()
const { PRCommentForm } = await import("../../webview-ui/agent-manager/pr/PRCommentForm")
const saved: string[] = []
const sent: string[] = []
let cancelled = 0
let completed = 0
let reads = 0
const initial = () => {
reads++
return ""
}
const release = mount(() => (
<>
<div id="local">
<PRCommentForm
inline
action="local"
worktreeId="inline-test"
file="example.ts"
side="RIGHT"
startLine={2}
endLine={2}
selectedText="return 1"
submitOnEnter
onSubmit={(body) => saved.push(body)}
onSend={(body) => sent.push(body)}
onCancel={() => cancelled++}
onEscape={() => cancelled++}
/>
</div>
<div id="remote">
<PRCommentForm
inline
action="line"
worktreeId="inline-test"
prNumber={1}
prUrl="https://github.com/example/fixture/pull/1"
snapshotId="snapshot"
path="example.ts"
side="RIGHT"
startLine={2}
endLine={2}
initialBody={initial()}
onSuccess={() => completed++}
onCancel={() => cancelled++}
/>
</div>
</>
))
await wait()
const local = node("#local")
const remote = node("#remote")
assert.equal(root.querySelector('[data-slot="comment-toolbar"]'), null, "no second toolbar in inline forms")
assert.equal(button("submit", local).textContent, "Save local")
assert.equal(button("send", local).textContent, "Send")
assert.equal(button("send", local).getAttribute("aria-label"), "Send to agent")
assert.equal(button("submit", remote).textContent, "Post to GitHub")
assert.equal(button("discard", remote).textContent, "Cancel")
const before = reads
type(local, "Preview **this**")
assert.equal(reads, before, "typing in one form does not invalidate unrelated drafts")
button("preview", local).click()
await wait()
assert.match(node('[data-slot="comment-preview"]', local).textContent ?? "", /Preview this/)
button("write", local).click()
assert.equal(document.activeElement, input(local))
input(local).dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true }))
assert.equal(saved.length, 0, "Shift+Enter does not submit")
input(local).dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true }))
assert.equal(saved.length, 0, "IME confirmation does not submit")
input(local).dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
assert.deepEqual(saved, ["Preview **this**"])
assert.equal(messages.length, 0, "local save never requests a GitHub write")
type(local, "Send this")
button("send", local).click()
assert.deepEqual(sent, ["Send this"])
type(remote, "Review this line")
button("submit", remote).click()
const request = last()
assert.equal(request.type, "agentManager.createReviewComment")
assert.equal(input(remote).disabled, true)
button("submit", remote).click()
assert.equal(messages.length, 1, "double submission cannot publish twice")
respond(request, { success: false, error: "Snapshot changed" })
assert.equal(input(remote).value, "Review this line")
assert.match(remote.textContent ?? "", /Snapshot changed/)
button("submit", remote).click()
respond(last(), {})
assert.equal(completed, 1)
button("cancel", local).click()
assert.equal(cancelled, 1)
release()
await window.happyDOM.close()
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict"
import { createSignal } from "solid-js"
import { harness } from "./comment-harness"
import type { PRCommentRequest } from "../../src/shared/pr-comment-actions"
const { window, root, button, wait, mount } = await harness<PRCommentRequest>()
const { SendAllButton } = await import("../../webview-ui/diff-viewer/SendAllButton")
const chat: string[] = []
const github: string[] = []
const [number, setNumber] = createSignal<number | undefined>(undefined)
const [pending, setPending] = createSignal(false)
const release = mount(() => (
<SendAllButton
count={2}
githubCount={2}
githubNumber={number()}
pending={pending()}
onSendChat={() => chat.push("chat")}
onSendGithub={() => github.push("github")}
keybind="Ctrl+Enter"
/>
))
await wait()
// Without a PR only the plain chat button is shown.
assert.equal(button("send-all-chat", root).textContent, "Send all to chat (2)")
assert.equal(root.querySelector('[data-action="send-all-github"]'), null)
button("send-all-chat", root).click()
assert.deepEqual(chat, ["chat"])
assert.deepEqual(github, [])
// With a PR both explicit actions appear, and the chat action keeps working.
setNumber(7)
await wait()
assert.equal(button("send-all-chat", root).textContent, "Send all to chat (2)")
assert.equal(button("send-all-github", root).textContent, "Send 2 to GitHub #7")
button("send-all-chat", root).click()
assert.deepEqual(chat, ["chat", "chat"])
assert.deepEqual(github, [])
button("send-all-github", root).click()
assert.deepEqual(github, ["github"])
// A pending send disables both actions.
setPending(true)
await wait()
assert.equal(button("send-all-chat", root).disabled, true)
assert.equal(button("send-all-github", root).disabled, true)
release()
await window.happyDOM.close()
@@ -83,6 +83,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/src/components/shared/BranchSelect.tsx"),
path.join(ROOT, "webview-ui/src/components/chat/TabDnd.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/BaseBranchPicker.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/SendAllButton.tsx"),
]
const SHARED_CSS = path.join(ROOT, "webview-ui/src/styles/session-tabs.css")
const TSX_FILE = TSX_FILES[0]!
@@ -0,0 +1,53 @@
import { afterEach, expect, it } from "bun:test"
import { Window } from "happy-dom"
import { createAnnotationLifecycle } from "../../webview-ui/diff-viewer/annotation-lifecycle"
import type { AnnotationMeta } from "../../webview-ui/diff-viewer/review-annotations"
const previous = { document: globalThis.document, MutationObserver: globalThis.MutationObserver }
afterEach(() => Object.assign(globalThis, previous))
it("releases a wrapper that is never inserted", async () => {
const window = new Window()
Object.assign(globalThis, { document: window.document, MutationObserver: window.MutationObserver })
const lifecycle = createAnnotationLifecycle()
const meta: AnnotationMeta = { type: "draft", comment: null, file: "never.ts", side: "additions", line: 1 }
let released = 0
const disposed = Promise.withResolvers<void>()
lifecycle.track(meta, document.createElement("div"), () => {
released++
disposed.resolve()
})
document.body.append(document.createElement("span"))
await disposed.promise
expect(released).toBe(1)
lifecycle.clear()
await window.happyDOM.close()
})
it("disposes detached and replaced annotation roots exactly once", async () => {
const window = new Window()
Object.assign(globalThis, { document: window.document, MutationObserver: window.MutationObserver })
const lifecycle = createAnnotationLifecycle()
const meta: AnnotationMeta = { type: "draft", comment: null, file: "test.ts", side: "additions", line: 1 }
const host = document.createElement("div")
let released = 0
const disposed = Promise.withResolvers<void>()
lifecycle.track(meta, host, () => {
released++
disposed.resolve()
})
document.body.append(host)
// Flush the insertion observer without HappyDOM's timer-based completion wait.
await Promise.resolve()
expect(released).toBe(0)
host.remove()
await disposed.promise
expect(released).toBe(1)
lifecycle.track(meta, host, () => released++)
lifecycle.track(meta, document.createElement("div"), () => released++)
expect(released).toBe(2)
lifecycle.clear()
lifecycle.clear()
expect(released).toBe(3)
await window.happyDOM.close()
})
@@ -0,0 +1,115 @@
import { describe, expect, it } from "bun:test"
import { postAllGithub, resolveGithubContext, type CommentsGithub } from "../../webview-ui/diff-viewer/comments-github"
import type { PRDiffSnapshot, PRTarget } from "../../src/shared/pr-comment-actions"
import type { ReviewComment } from "../../webview-ui/diff-viewer/review-comments"
const patch = "@@ -1,2 +1,2 @@\n context\n-old\n+new\n"
const target: PRTarget = {
worktreeId: "wt-1",
prNumber: 7,
prUrl: "https://github.com/example/repo/pull/7",
}
const snapshot: PRDiffSnapshot = {
id: "snap-1",
head: "a".repeat(40),
files: [{ path: "src/file.ts", status: "modified", patch }],
}
function comment(id: string, line: number): ReviewComment {
return { id, file: "src/file.ts", side: "additions", line, comment: id, selectedText: "" }
}
function fake(handler: (comment: ReviewComment) => { success: boolean; error?: string }): CommentsGithub {
return {
available: () => true,
resolve: () => undefined,
send: async (item) => handler(item),
}
}
describe("resolveGithubContext", () => {
it("accepts a line inside the PR hunk", () => {
const result = resolveGithubContext({
target,
snapshot,
file: "src/file.ts",
side: "additions",
start: 2,
end: 2,
patch,
})
expect(result).toEqual({
prNumber: 7,
prUrl: target.prUrl,
snapshotId: "snap-1",
label: "GitHub #7",
closed: false,
})
})
it("marks a line outside the hunk as closed", () => {
const result = resolveGithubContext({
target,
snapshot,
file: "src/file.ts",
side: "additions",
start: 9,
end: 9,
patch,
})
expect(result?.closed).toBe(true)
})
it("marks a missing patch as closed", () => {
const result = resolveGithubContext({
target,
snapshot,
file: "src/file.ts",
side: "additions",
start: 2,
end: 2,
})
expect(result?.closed).toBe(true)
})
it("returns undefined without a target or snapshot", () => {
expect(
resolveGithubContext({ snapshot, file: "src/file.ts", side: "additions", start: 2, end: 2, patch }),
).toBeUndefined()
expect(
resolveGithubContext({ target, file: "src/file.ts", side: "additions", start: 2, end: 2, patch }),
).toBeUndefined()
})
})
describe("postAllGithub", () => {
it("posts every comment in order when each request succeeds", async () => {
const sent: string[] = []
const result = await postAllGithub(
[comment("first", 2), comment("second", 1)],
fake((item) => {
sent.push(item.id)
return { success: true }
}),
)
expect(sent).toEqual(["first", "second"])
expect(result.posted.map((item) => item.id)).toEqual(["first", "second"])
expect(result.failure).toBeUndefined()
})
it("stops at the first failure and keeps the unposted comments", async () => {
const sent: string[] = []
const result = await postAllGithub(
[comment("first", 2), comment("second", 1), comment("third", 1)],
fake((item) => {
sent.push(item.id)
return item.id === "second" ? { success: false, error: "boom" } : { success: true }
}),
)
expect(sent).toEqual(["first", "second"])
expect(result.posted.map((item) => item.id)).toEqual(["first"])
expect(result.failure).toBe("boom")
})
})
@@ -0,0 +1,60 @@
import { describe, expect, it } from "bun:test"
import fs from "node:fs"
import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "../..")
const CSS = fs
.readFileSync(path.join(ROOT, "webview-ui/agent-manager/pr/pr-panel.css"), "utf-8")
.replace(/\/\*[\s\S]*?\*\//g, "")
function blocks(source: string) {
return source
.split("}")
.map((chunk) => {
const open = chunk.lastIndexOf("{")
if (open === -1) return undefined
return {
selectors: chunk
.slice(0, open)
.split(",")
.map((value) => value.trim())
.filter(Boolean),
body: chunk.slice(open + 1),
}
})
.filter((value): value is { selectors: string[]; body: string } => value !== undefined)
}
describe("diff composer action ordering", () => {
it("orders the split-button wrapper, not the inner primary button", () => {
const rules = blocks(CSS)
const ordered = rules.filter(
(rule) =>
rule.selectors.some((selector) => selector.startsWith('.am-pr-comment-composer[data-action="diff"]')) &&
/(^|[;\s])order\s*:/.test(rule.body),
)
const targets = ordered.flatMap((rule) => rule.selectors.map((selector) => selector))
const inner = targets.filter((selector) => selector.includes('[data-action="send-primary"]'))
expect(inner, "the inner send-primary keeps DOM order; the wrapper carries flex order").toEqual([])
expect(
targets.some((selector) => selector.includes(".am-split-button")),
"the split-button wrapper must carry the flex order",
).toBe(true)
})
it("keeps preview before the send group and save/cancel on the left", () => {
const rules = blocks(CSS)
const order = (needle: string) => {
const rule = rules.find((item) =>
item.selectors.some(
(selector) => selector.startsWith('.am-pr-comment-composer[data-action="diff"]') && selector.includes(needle),
),
)
const match = rule?.body.match(/(?:^|[;\s])order\s*:\s*(\d+)/)
return match ? Number(match[1]) : 0
}
expect(order('[data-action="save"]')).toBeLessThan(order(".am-split-button"))
expect(order('[data-action="preview"]')).toBeLessThan(order(".am-split-button"))
expect(order('[data-action="cancel"]')).toBeLessThan(order('[data-slot="comment-actions-gap"]'))
})
})
@@ -0,0 +1,4 @@
import { it } from "bun:test"
import { fixture } from "../fixtures/run"
it("routes the unified diff composer to Kilo, GitHub, save, and cancel", () => fixture("diff-comment-form"), 30_000)
@@ -201,10 +201,7 @@ describe("diff preview detail requests", () => {
})
it("discards cancelled standalone details and recovers real failures through the message handler", async () => {
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW))
const result = await build({
stdin: {
contents: `
const child = await renderSurface(`
import assert from "node:assert/strict"
import { createRoot } from "solid-js"
import { SourceController } from "../src/diff/SourceController"
@@ -252,75 +249,119 @@ describe("diff preview detail requests", () => {
controller.dispose()
dispose()
})().catch((err) => { console.error(err); process.exitCode = 1 })
`,
resolveDir: WEBVIEW,
sourcefile: "detail-recovery.ts",
loader: "ts",
},
bundle: true,
platform: "node",
format: "cjs",
write: false,
logLevel: "silent",
plugins: [
{
name: "review-surface",
setup(ctx) {
ctx.onResolve({ filter: /^solid-js$/ }, () => ({ path: path.join(solid, "dist/solid.js") }))
ctx.onResolve({ filter: /^solid-js\/web$/ }, () => ({ path: path.join(solid, "web/dist/server.js") }))
ctx.onResolve({ filter: /.*/ }, (args) => {
if (
args.path !== "probe:surface" &&
(!args.importer.endsWith("/DiffViewerApp.tsx") || ["solid-js", "./diff-state"].includes(args.path))
)
return
return { path: "surface", namespace: "probe" }
})
ctx.onLoad({ filter: /.*/, namespace: "probe" }, () => ({
contents: `
export const state = { posted: [] }
export const useVSCode = () => ({ onMessage(receive) { state.receive = receive; return () => {} } })
export const getVSCodeAPI = () => ({ postMessage: (message) => state.posted.push(message) })
export const useLanguage = () => ({ t: (key) => key })
export const useServer = () => ({})
export const FullScreenDiffView = (props) => { state.view = props; return "" }
export const Toast = { Region: () => "" }
${[
"DialogProvider",
"CodeComponentProvider",
"DiffComponentProvider",
"FileComponentProvider",
"MarkedProvider",
"ThemeProvider",
"LanguageProvider",
"ServerProvider",
"ConfigProvider",
"ProviderProvider",
"VSCodeProvider",
"SpeechToTextModelsProvider",
"SpeechToTextPrewarm",
"Code",
"Diff",
"File",
"Icon",
"DiffPickerHeader",
"BaseBranchPicker",
]
.map((name) => `export const ${name} = (props) => props.children`)
.join("\n")}
`,
loader: "js",
}))
},
},
solidPlugin({ solid: { generate: "ssr" } }),
],
})
const child = Bun.spawnSync(["bun", "-e", result.outputFiles.at(0)!.text], {
cwd: WEBVIEW,
stdout: "pipe",
stderr: "pipe",
})
expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0)
`)
expectPass(child)
})
it("reloads the PR snapshot on a ref-only refresh", async () => {
const child = await renderSurface(`
import assert from "node:assert/strict"
import { createRoot } from "solid-js"
import { DiffViewerApp } from "./diff-viewer/DiffViewerApp"
import { state } from "probe:surface"
globalThis.window = new EventTarget()
const dispose = createRoot((dispose) => { DiffViewerApp({}); return dispose })
const target = (head) => ({
projectId: "p",
worktreeId: "diff",
prNumber: 7,
prUrl: "https://github.com/o/r/pull/7",
baseRefOid: "base",
headRefOid: head,
})
state.receive({ type: "diffViewer.prComments", comments: [], target: target("a"), threads: [] })
assert.equal(state.requests.length, 1, "initial target loads the snapshot")
assert.equal(state.requests[0].headRefOid, "a")
state.receive({ type: "diffViewer.prComments", comments: [], target: target("b"), threads: [] })
assert.equal(state.requests.length, 2, "ref-only refresh reloads the snapshot")
assert.equal(state.requests[1].headRefOid, "b")
dispose()
`)
expectPass(child)
})
})
async function renderSurface(script: string) {
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW))
const result = await build({
stdin: {
contents: script,
resolveDir: WEBVIEW,
sourcefile: "review-surface.ts",
loader: "ts",
},
bundle: true,
platform: "node",
format: "cjs",
write: false,
logLevel: "silent",
plugins: [
{
name: "review-surface",
setup(ctx) {
ctx.onResolve({ filter: /^solid-js$/ }, () => ({ path: path.join(solid, "dist/solid.js") }))
ctx.onResolve({ filter: /^solid-js\/web$/ }, () => ({ path: path.join(solid, "web/dist/server.js") }))
ctx.onResolve({ filter: /.*/ }, (args) => {
if (
args.path !== "probe:surface" &&
(!args.importer.endsWith("/DiffViewerApp.tsx") || ["solid-js", "./diff-state"].includes(args.path))
)
return
return { path: "surface", namespace: "probe" }
})
ctx.onLoad({ filter: /.*/, namespace: "probe" }, () => ({
contents: `
export const state = { posted: [], requests: [] }
export const useVSCode = () => ({ onMessage(receive) { state.receive = receive; return () => {} } })
export const getVSCodeAPI = () => ({ postMessage: (message) => state.posted.push(message) })
export const useLanguage = () => ({ t: (key) => key })
export const useServer = () => ({})
export const FullScreenDiffView = (props) => { state.view = props; return "" }
export const Toast = { Region: () => "" }
export const reviewRequest = (request) => { state.requests.push(request) }
export const createPRDiffs = () => []
export const createDiffCommentForms = () => ({ mount: () => () => {} })
${[
"DialogProvider",
"CodeComponentProvider",
"DiffComponentProvider",
"FileComponentProvider",
"MarkedProvider",
"ThemeProvider",
"LanguageProvider",
"ServerProvider",
"ConfigProvider",
"ProviderProvider",
"VSCodeProvider",
"SpeechToTextModelsProvider",
"SpeechToTextPrewarm",
"Code",
"Diff",
"File",
"Icon",
"IconButton",
"Button",
"Spinner",
"DiffPickerHeader",
"BaseBranchPicker",
]
.map((name) => `export const ${name} = (props) => props.children`)
.join("\n")}
`,
loader: "js",
}))
},
},
solidPlugin({ solid: { generate: "ssr" } }),
],
})
return Bun.spawnSync(["bun", "-e", result.outputFiles.at(0)!.text], {
cwd: WEBVIEW,
stdout: "pipe",
stderr: "pipe",
})
}
function expectPass(child: ReturnType<typeof Bun.spawnSync>) {
expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0)
}
@@ -1,10 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import * as vscode from "vscode"
import { DiffViewerProvider } from "../../src/diff/DiffViewerProvider"
import * as gh from "../../src/agent-manager/gh"
import * as shell from "../../src/agent-manager/shell-env"
import { execGhInput as ghInput } from "../../src/agent-manager/pr/PRActions"
import type { DiffPRPoller, DiffPRPollerOptions } from "../../src/diff/pr-poller"
import type { PRComment, PRStatus } from "../../src/agent-manager/types"
import type { PRReviewCommentData } from "../../src/shared/review-comments"
import type { PanelContext } from "../../src/diff/types"
import type { PRTarget } from "../../src/shared/pr-comment-actions"
const addCommentReaction = mock(async (_commentId: string, _reaction: string, _cwd: string) => {})
const removeCommentReaction = mock(async (_commentId: string, _reaction: string, _cwd: string) => {})
@@ -12,8 +16,11 @@ const isPRReactionContent = (value: unknown): value is string =>
typeof value === "string" &&
["THUMBS_UP", "THUMBS_DOWN", "LAUGH", "HOORAY", "CONFUSED", "HEART", "ROCKET", "EYES"].includes(value)
// Keep the real `execGhInput` so this process-wide module mock does not leak a
// reset mock into other test files that post comments through `gh`.
mock.module("../../src/agent-manager/pr/PRActions", () => ({
addCommentReaction,
execGhInput: ghInput,
isPRReactionContent,
removeCommentReaction,
}))
@@ -96,6 +103,8 @@ function harness() {
add?: boolean
success?: boolean
error?: string
snapshot?: unknown
target?: PRTarget
}> = []
const received = event<unknown>()
const disposed = event<void>()
@@ -220,6 +229,81 @@ describe("DiffViewerProvider.openFromCommand", () => {
})
describe("DiffViewerProvider remote PR comments", () => {
it("routes PR snapshot loading and new comment creation from the standalone panel", async () => {
const read = spyOn(gh, "execGhRead").mockImplementation(async (args) => {
if (args.includes("--input"))
return {
stdout: JSON.stringify({
id: 11,
commit_id: "a".repeat(40),
path: "src/app.ts",
side: "RIGHT",
line: 1,
}),
stderr: "",
}
if (args.some((arg) => arg.includes("/files?")))
return {
stdout: JSON.stringify([
{
filename: "src/app.ts",
status: "modified",
additions: 1,
deletions: 1,
patch: "@@ -1 +1 @@\n-old\n+new",
},
]),
stderr: "",
}
return {
stdout: JSON.stringify({
number: 42,
html_url: "https://github.com/example/repo/pull/42",
head: { sha: "a".repeat(40) },
base: { sha: "b".repeat(40) },
changed_files: 1,
state: "open",
merged: false,
}),
stderr: "",
}
})
spyOn(shell, "execWithShellEnv").mockResolvedValue({ stdout: "feature\n", stderr: "" })
const h = harness()
h.pollers.at(0)!.onStatus("diff", status(), undefined, "feature")
const target = h.posted.findLast((message) => message.type === "diffViewer.prComments")?.target
if (!target) throw new Error("Missing PR target")
h.received.fire({ ...target, type: "agentManager.loadPRFiles", requestId: "load" })
await new Promise((resolve) => setTimeout(resolve, 0))
const loaded = h.messages("agentManager.loadPRFilesResult").at(-1)
expect(loaded).toMatchObject({ success: true, requestId: "load" })
if (!loaded?.snapshot || typeof loaded.snapshot !== "object") throw new Error("Missing PR snapshot")
h.received.fire({
...target,
type: "agentManager.createReviewComment",
requestId: "comment",
snapshotId: (loaded.snapshot as { id: string }).id,
path: "src/app.ts",
side: "RIGHT",
startLine: 1,
endLine: 1,
body: "Please update this.",
})
await new Promise((resolve) => setTimeout(resolve, 0))
// The real execGhInput writes its input to a temp file, so give the round
// trip a bounded amount of time instead of a single macrotask.
for (let i = 0; i < 100 && !h.messages("agentManager.createReviewCommentResult").length; i++)
await new Promise((resolve) => setTimeout(resolve, 2))
expect(h.messages("agentManager.createReviewCommentResult").at(-1)).toMatchObject({
success: true,
requestId: "comment",
})
expect(h.pollers.at(0)!.refresh).toHaveBeenCalled()
read.mockRestore()
})
it("adds and removes reactions on comments in the standalone diff", async () => {
const h = harness()
const item = comment()
@@ -0,0 +1,8 @@
import { it } from "bun:test"
import { fixture } from "../fixtures/run"
it(
"keeps compact shared comment actions, keyboard behavior, and publication safety",
() => fixture("inline-comment-form"),
30_000,
)
@@ -0,0 +1,54 @@
import { describe, expect, it } from "bun:test"
import { canCommentOnPRLine, createPRDiffs } from "../../webview-ui/diff-viewer/pr-diff"
import type { PRDiffSnapshot } from "../../src/shared/pr-comment-actions"
const patch = ["@@ -1,3 +1,4 @@", " one", "-two", "+updated", "+another", " three", ""].join("\n")
const snapshot: PRDiffSnapshot = {
id: "snapshot-1",
head: "a".repeat(40),
files: [{ path: "src/file.ts", status: "modified", patch }],
}
describe("PR diff adapter", () => {
it("projects complete GitHub patches into diff viewer files", () => {
expect(createPRDiffs(snapshot)).toEqual([
{
file: "src/file.ts",
before: "one\ntwo\nthree\n",
after: "one\nupdated\nanother\nthree\n",
patch: "--- a/src/file.ts\n+++ b/src/file.ts\n" + patch,
additions: 2,
deletions: 1,
status: "modified",
tracked: true,
stamp: "snapshot-1",
},
])
})
it("accepts only ranges represented by the PR patch", () => {
expect(canCommentOnPRLine(snapshot, "src/file.ts", "RIGHT", 2, 3)).toBe(true)
expect(canCommentOnPRLine(snapshot, "src/file.ts", "LEFT", 2, 2)).toBe(true)
expect(canCommentOnPRLine(snapshot, "src/file.ts", "RIGHT", 4, 4)).toBe(true)
expect(canCommentOnPRLine(snapshot, "src/file.ts", "RIGHT", 5, 5)).toBe(false)
expect(canCommentOnPRLine(snapshot, "other.ts", "RIGHT", 2, 2)).toBe(false)
})
it("does not project unsupported files", () => {
expect(createPRDiffs({ ...snapshot, files: [{ path: "image.png", status: "modified" }] })).toEqual([])
})
it("accepts a line when GitHub rewrites a control-character escape in its patch", () => {
// GitHub reports `\^@` where git reports the literal `\u0000` escape.
const api = "@@ -1,2 +1,2 @@\n context\n-return key(a, b)\n+return `${a}\\^@${b}`"
const value: PRDiffSnapshot = {
id: "rewrite",
head: "a".repeat(40),
files: [{ path: "app.ts", status: "modified", patch: api }],
}
expect(canCommentOnPRLine(value, "app.ts", "RIGHT", 2, 2)).toBe(true)
expect(canCommentOnPRLine(value, "app.ts", "LEFT", 2, 2)).toBe(true)
expect(canCommentOnPRLine(value, "app.ts", "RIGHT", 3, 3)).toBe(false)
})
})
@@ -116,7 +116,17 @@ function harness() {
})
const review = (snapshot: { id: string }, fields: Record<string, unknown> = {}) =>
send("submitPRReview", { snapshotId: snapshot.id, event: "APPROVE", head, body: "", ...fields })
return { context, host, actions, sent, refresh, send, load, comment, review }
return {
context,
host,
actions,
sent,
refresh,
send,
load,
comment,
review,
}
}
function transport(
@@ -147,6 +157,32 @@ function transport(
}
describe("commit-bound PR review actions", () => {
it("rejects a request when the checked-out branch changed", async () => {
const h = harness()
const completion = Promise.withResolvers<PRReviewResult>()
const actions = new PRReviewActions({
context: () => h.context,
post: completion.resolve,
refresh: () => {},
dirtyFiles: () => [],
checkBranch: async () => "other",
})
expect(
actions.handle({
type: "agentManager.loadPRFiles",
projectId: h.context.projectId,
worktreeId: h.context.worktreeId,
prNumber: h.context.pr.number,
prUrl: h.context.pr.url,
requestId: "branch",
}),
).toBe(true)
const result = await completion.promise
expect(result.success).toBe(false)
expect(result.error).toContain("Diff branch changed")
expect(execute).not.toHaveBeenCalled()
})
it("loads actual GitHub patches and posts exact raw body with a multiline range", async () => {
let input: Record<string, unknown> | undefined
transport([file], (value) => {
@@ -0,0 +1,202 @@
import { afterEach, describe, expect, it } from "bun:test"
import { Window } from "happy-dom"
import {
buildReviewAnnotation,
type AnnotationLabels,
type AnnotationMeta,
type CommentFormActions,
type CommentFormMount,
} from "../../webview-ui/diff-viewer/review-annotations"
const labels: AnnotationLabels = {
commentOnLine: (line) => `Comment on line ${line}`,
editCommentOnLine: (line) => `Edit comment on line ${line}`,
placeholder: "Comment",
cancel: "Cancel",
comment: "Comment",
send: "Send",
save: "Save",
sendToChat: "Send to chat",
edit: "Edit",
delete: "Delete",
}
const original = {
document: globalThis.document,
window: globalThis.window,
raf: globalThis.requestAnimationFrame,
cancel: globalThis.cancelAnimationFrame,
}
let frames: FrameRequestCallback[] = []
afterEach(() => {
globalThis.document = original.document
globalThis.window = original.window
globalThis.requestAnimationFrame = original.raf
globalThis.cancelAnimationFrame = original.cancel
frames = []
})
function setup() {
const view = new Window()
globalThis.document = view.document
globalThis.window = view as unknown as Window & typeof globalThis
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
}) as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = () => {}
return view
}
function flushFrames() {
for (let i = 0; i < 40 && frames.length; i += 1) frames.shift()?.(0)
}
function annotation(): AnnotationMeta {
return { type: "draft", comment: null, file: "src/file.ts", side: "additions", line: 2, endLine: 2 }
}
const diffs = [
{
file: "src/file.ts",
before: "old\n",
after: "new\n",
additions: 1,
deletions: 1,
patch: "@@ -1 +1 @@\n-old\n+new\n",
},
]
function build(opts: { mount?: CommentFormMount; destination?: "local" | "github" } = {}) {
const meta = annotation()
if (opts.destination) meta.destination = opts.destination
const added: string[] = []
const sent: string[] = []
const destinations: string[] = []
const disposals: Array<() => void> = []
let success = 0
let cancelled = 0
const root = buildReviewAnnotation(
{ side: "additions", lineNumber: 2, metadata: meta },
{
diffs,
editing: null,
setEditing: () => {},
addComment: (_file, _side, _line, text) => added.push(text),
sendComment: (_file, _side, _line, text) => sent.push(text),
updateComment: () => {},
deleteComment: () => {},
cancelDraft: () => cancelled++,
completeRemoteDraft: () => success++,
onDestination: (value) => destinations.push(value),
labels,
activeTerminalId: () => undefined,
mount: opts.mount,
track: (_meta, _host, dispose) => disposals.push(dispose),
},
)
return { root, meta, added, sent, destinations, disposals, success: () => success, cancelled: () => cancelled }
}
function mountField() {
const field = document.createElement("textarea")
field.className = "mounted-field"
const actions = document.createElement("div")
actions.className = "am-pr-comment-actions"
const submit = document.createElement("button")
submit.setAttribute("data-action", "submit")
actions.appendChild(submit)
return { field, actions }
}
describe("review annotation draft", () => {
it("mounts one form and forwards save, send, destination, success, and cancel", () => {
setup()
let actions: CommentFormActions | undefined
let mountedHost: HTMLElement | undefined
const mount: CommentFormMount = (host, _meta, value) => {
actions = value
mountedHost = host
const parts = mountField()
host.appendChild(parts.field)
host.appendChild(parts.actions)
return () => host.replaceChildren()
}
const result = build({ mount })
if (!result.root) throw new Error("Missing annotation")
expect(result.root.dataset.mounted).toBe("true")
expect(result.root.querySelector(".am-annotation-destination")).toBeNull()
expect(mountedHost).not.toBeUndefined()
if (!actions) throw new Error("Missing actions")
actions.onBodyChange("Draft text")
expect(result.meta.text).toBe("Draft text")
actions.onSave("Saved body", "selected")
expect(result.added).toEqual(["Saved body"])
actions.onSend("Sent body", "selected")
expect(result.sent).toEqual(["Sent body"])
actions.onDestination("github")
expect(result.meta.destination).toBe("github")
expect(result.destinations).toEqual(["github"])
actions.onGithubSuccess()
expect(result.success()).toBe(1)
actions.onCancel()
expect(result.cancelled()).toBe(1)
})
it("focuses the mounted form editor", () => {
setup()
const mount: CommentFormMount = (host) => {
const parts = mountField()
host.appendChild(parts.field)
host.appendChild(parts.actions)
return () => host.replaceChildren()
}
const result = build({ mount })
if (!result.root) throw new Error("Missing annotation")
document.body.appendChild(result.root)
flushFrames()
expect(document.activeElement).toBe(result.root.querySelector(".am-annotation-form textarea"))
})
it("falls back to a native composer without a mount", async () => {
setup()
const result = build()
if (!result.root) throw new Error("Missing annotation")
document.body.appendChild(result.root)
flushFrames()
const textarea = result.root.querySelector("textarea")
if (!textarea) throw new Error("Missing textarea")
expect(document.activeElement).toBe(textarea)
textarea.value = "Native comment"
textarea.dispatchEvent(new window.Event("input", { bubbles: true }))
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
expect(result.added).toEqual(["Native comment"])
const send = [...result.root.querySelectorAll("button")].find((button) => button.textContent === "Send")
if (!send) throw new Error("Missing send button")
textarea.value = "To chat"
textarea.dispatchEvent(new window.Event("input", { bubbles: true }))
send.click()
expect(result.sent).toEqual(["To chat"])
})
it("disposes the mounted form when the lifecycle releases it", () => {
setup()
let released = 0
const mount: CommentFormMount = (host) => {
const parts = mountField()
host.appendChild(parts.field)
host.appendChild(parts.actions)
return () => {
released++
host.replaceChildren()
}
}
const result = build({ mount })
if (!result.root) throw new Error("Missing annotation")
result.disposals[0]?.()
expect(released).toBe(1)
})
})
@@ -0,0 +1,4 @@
import { it } from "bun:test"
import { fixture } from "../fixtures/run"
it("routes the send-all split button to chat or GitHub", () => fixture("send-all-button"), 30_000)
@@ -180,6 +180,7 @@ import { useTabScroll } from "./tab-scroll"
import { DiffPanelCache } from "./DiffPanelCache"
import { createPRNavigation, PRPanelHost } from "./pr/PRPanelHost"
import { createPRReview } from "./pr/review"
import { createPRDiffCommentState } from "./pr/diff-comment-state"
import { createRevertFile } from "./revert-file"
import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView"
import { createApplyToLocal } from "./apply-to-local"
@@ -1700,6 +1701,16 @@ const AgentManagerContent: Component = () => {
panels.open(SidePanel.Diff)
},
})
const prDiffComments = createPRDiffCommentState({
post: vscode.postMessage,
project: activeProjectId,
statuses: prStatuses,
})
createEffect(() => {
const ctx = diffCtx()
if (!ctx || (!diffOpen() && !reviewActive())) return
prDiffComments.load(ctx)
})
createEffect(() => {
const panel = diffOpen()
const active = reviewActive()
@@ -2600,6 +2611,10 @@ const AgentManagerContent: Component = () => {
}
remoteComments={remote.comments}
remoteTarget={remote.target}
prTarget={prDiffComments.target}
prSnapshot={prDiffComments.snapshot}
prLoading={prDiffComments.loading}
prError={prDiffComments.error}
focusedComment={remote.focus}
composer={composers.get}
lead={() => diffScopeControls(true)}
@@ -2709,6 +2724,10 @@ const AgentManagerContent: Component = () => {
sessionKey={`${activeProjectId() ?? "single"}\0${diffScopeId() ?? ""}`}
projectId={activeProjectId()}
worktreeId={diffCtx()}
prTarget={prDiffComments.target(diffCtx())}
prSnapshot={prDiffComments.snapshot(diffCtx())}
prLoading={prDiffComments.loading(diffCtx())}
prError={prDiffComments.error(diffCtx())}
notice={diffNotice()}
lead={diffScopeControls(false)}
canRevert={scopeCapabilities(review.scope()).revert}
@@ -1,10 +1,8 @@
import { type Component, createMemo, Show, type JSXElement } from "solid-js"
import { Accordion } from "@kilocode/kilo-ui/accordion"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../src/context/language"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { DiffStyleSelect } from "../diff-viewer/InlineSelect"
import {
LONG_DIFF_MARKER_FILE_COUNT,
@@ -15,14 +13,17 @@ import {
toggleOpenFiles,
} from "../diff-viewer/diff-open-policy"
import { DiffEndMarker } from "../diff-viewer/DiffEndMarker"
import { DiffViewerNotice } from "../diff-viewer/DiffViewerNotice"
import { VirtualDiffList } from "../diff-viewer/VirtualDiffList"
import { createDiffViewport } from "../diff-viewer/diff-requests"
import "./pr/pr-panel.css"
import "../diff-viewer/remote-comments.css"
import { RemoteCommentsOutside } from "../diff-viewer/remote-comment-renderer"
import { ReviewDiffItem } from "../diff-viewer/ReviewDiffItem"
import { createReviewView, type ReviewViewProps } from "../diff-viewer/review-controller"
import { notice, reviewSendAllKeybind } from "../diff-viewer/review-setup"
import { type ReviewViewProps } from "../diff-viewer/review-controller"
import { SendAllButton } from "../diff-viewer/SendAllButton"
import { createReviewSurface } from "../diff-viewer/review-surface"
import type { PRDiffSnapshot, PRTarget } from "../../src/shared/pr-comment-actions"
// --- Data model ---
@@ -43,14 +44,18 @@ interface DiffPanelProps extends ReviewViewProps {
lead?: JSXElement
/** Defaults to true. Hides the per-file Revert action when false. */
canRevert?: boolean
prTarget?: PRTarget
prSnapshot?: PRDiffSnapshot
prLoading?: boolean
prError?: string
}
export const DiffPanel: Component<DiffPanelProps> = (props) => {
const { t } = useLanguage()
const noticeText = () => notice(t, props.notice)
const sendAllKeybind = () => reviewSendAllKeybind(t)
let rootRef: HTMLDivElement | undefined
const {
t,
noticeText,
sendAllKeybind,
open,
setOpen,
rows,
@@ -70,7 +75,12 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
commentsByFile,
handleGutterClick,
sendAllClick,
} = createReviewView(props, () => rootRef)
sendAllToGithub,
sendAllGithubCount,
sendAllGithubAvailable,
sendAllPending,
sendAllError,
} = createReviewSurface(props, () => rootRef)
const handleExpandAll = () => {
setOpen(toggleOpenFiles(props.diffs, open()))
@@ -95,6 +105,16 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
what you're looking at and is the primary control. Always shown,
so an empty scope can still be switched away from. */}
<Show when={props.lead}>{props.lead}</Show>
<Show when={props.prTarget}>
{(target) => (
<span class="am-diff-pr-context" title={target().prUrl}>
{t("diffViewer.comment.prContext", { number: target().prNumber })}
<Show when={props.prLoading}>
<Spinner />
</Show>
</span>
)}
</Show>
<Show when={props.diffs.length > 0}>
<>
<DiffStyleSelect
@@ -148,15 +168,9 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<IconButton icon="close" size="small" variant="ghost" label={t("common.close")} onClick={props.onClose} />
</div>
</div>
<DiffViewerNotice text={props.prError} role="alert" />
<Show when={noticeText()}>
<div class="diff-viewer-notice" role="status">
<span class="diff-viewer-notice-icon">
<Icon name="warning" size="small" />
</span>
<span class="diff-viewer-notice-text">{noticeText()}</span>
</div>
</Show>
<DiffViewerNotice text={noticeText()} role="status" />
<Show when={props.loading && props.diffs.length === 0}>
<div class="am-diff-loading">
@@ -222,11 +236,20 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<span class="am-diff-comments-count">
{comments().length} comment{comments().length !== 1 ? "s" : ""}
</span>
<TooltipKeybind title={t("agentManager.review.sendAllToChat")} keybind={sendAllKeybind()} placement="top">
<Button variant="primary" size="small" onClick={sendAllClick}>
{t("agentManager.review.sendAllToChat")}
</Button>
</TooltipKeybind>
<Show when={sendAllError()}>
<span class="am-review-send-error" role="alert">
{sendAllError()}
</span>
</Show>
<SendAllButton
count={comments().length}
githubCount={sendAllGithubCount()}
githubNumber={sendAllGithubAvailable() ? props.prTarget?.prNumber : undefined}
pending={sendAllPending()}
onSendChat={sendAllClick}
onSendGithub={sendAllToGithub}
keybind={sendAllKeybind()}
/>
</div>
</Show>
</Show>
@@ -3,6 +3,7 @@ import type { WorktreeFileDiff } from "../src/types/messages"
import type { ReviewComment } from "../diff-viewer/review-comments"
import type { ReviewComposer } from "../diff-viewer/review-annotations"
import type { PRComment } from "./pr/pr-types"
import type { PRDiffSnapshot, PRTarget } from "../../src/shared/pr-comment-actions"
import { DiffPanel } from "./DiffPanel"
import { diffDataKey } from "./worktree-diffs"
@@ -29,6 +30,10 @@ interface Props {
comments: (ctx: string) => ReviewComment[]
remoteComments?: (ctx: string) => PRComment[]
remoteTarget?: (ctx: string, comment: PRComment) => import("../../src/shared/pr-comment-actions").PRTarget | undefined
prTarget?: (ctx: string) => PRTarget | undefined
prSnapshot?: (ctx: string) => PRDiffSnapshot | undefined
prLoading?: (ctx: string) => boolean
prError?: (ctx: string) => string | undefined
focusedComment?: (key: string) => { id: string; file: string } | undefined
setComments: (ctx: string, comments: ReviewComment[]) => void
composer: (key: string) => ReviewComposer
@@ -121,6 +126,10 @@ export const DiffPanelCache: Component<Props> = (props) => {
? props.remoteTarget?.(entry.ctx, comment)
: undefined
}
prTarget={props.prTarget?.(entry.ctx)}
prSnapshot={props.prSnapshot?.(entry.ctx)}
prLoading={props.prLoading?.(entry.ctx)}
prError={props.prError?.(entry.ctx)}
focusedComment={active() ? props.focusedComment?.(entry.key) : undefined}
onCommentsChange={(comments) => props.setComments(entry.key, comments)}
composer={props.composer(entry.cacheKey)}
@@ -2468,6 +2468,44 @@ body.am-wt-dragging-active * {
font-size: inherit;
color: var(--text-weak);
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.am-annotation-draft[data-mounted] {
font-family: var(--vscode-font-family, sans-serif);
font-size: var(--font-size-base);
padding: 8px;
gap: 4px;
border: 1px solid var(--border-base);
border-radius: 6px;
}
.am-annotation-draft[data-mounted]:focus-within {
border-color: var(--border-focus);
}
.am-annotation-form {
min-width: 0;
}
.am-diff-pr-context,
.am-review-pr-context {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-weak);
font-size: var(--kilo-font-size-11);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.am-diff-pr-context svg,
.am-review-pr-context svg {
flex: 0 0 auto;
}
.am-annotation-textarea {
@@ -2654,6 +2692,15 @@ body.am-wt-dragging-active * {
color: var(--text-weak);
}
.am-review-send-error {
margin: 0 8px;
font-size: var(--font-size-small);
color: var(--vscode-testing-iconFailed, #f87171);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Setup overlay */
.am-setup-overlay {
@@ -2808,7 +2855,7 @@ body.am-wt-dragging-active * {
.am-split-button[data-variant="primary"] > [data-component="button"] {
flex: 1 1 auto;
min-width: 0;
min-height: 26px;
min-height: 24px;
border: 0;
border-radius: 2px 0 0 2px;
background: transparent;
@@ -2826,7 +2873,7 @@ body.am-wt-dragging-active * {
width: 32px;
min-width: 32px;
height: auto;
min-height: 26px;
min-height: 24px;
padding: 0;
border: 0;
border-radius: 0 2px 2px 0;
@@ -2916,6 +2963,19 @@ body.am-wt-dragging-active * {
padding: 6px 10px;
}
/* Two explicit send-all actions; only the chat action shows the shortcut. */
.am-send-all-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.am-send-all-actions [data-component="spinner"] {
width: 12px;
height: 12px;
margin-right: 4px;
}
.am-worktree-menu-gap {
display: inline-flex;
width: 16px;
@@ -227,6 +227,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "إرسال الكل إلى الدردشة ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "إرسال {{count}} إلى GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "توقف الإرسال بسبب خطأ في GitHub: {{error}}",
"agentManager.review.inlineCount": "التعليقات المحلية ({{count}})",
"agentManager.review.prCount": "تعليقات PR ({{count}})",
"agentManager.review.fileCount": "{{count}} ملفًا",
@@ -233,6 +233,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Enviar tudo para o chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Enviar {{count}} para o GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Envio interrompido por um erro do GitHub: {{error}}",
"agentManager.review.inlineCount": "Comentários locais ({{count}})",
"agentManager.review.prCount": "Comentários de PR ({{count}})",
"agentManager.review.fileCount": "{{count}} arquivos",
@@ -231,6 +231,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Pošalji sve u chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Pošalji {{count}} na GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Slanje je zaustavljeno zbog greške na GitHubu: {{error}}",
"agentManager.review.inlineCount": "Lokalni komentari ({{count}})",
"agentManager.review.prCount": "PR komentari ({{count}})",
"agentManager.review.fileCount": "{{count}} datoteka",
@@ -232,6 +232,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Send alt til chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Send {{count}} til GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Afsendelsen blev stoppet på grund af en GitHub-fejl: {{error}}",
"agentManager.review.inlineCount": "Lokale kommentarer ({{count}})",
"agentManager.review.prCount": "PR-kommentarer ({{count}})",
"agentManager.review.fileCount": "{{count}} filer",
@@ -239,6 +239,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Alles an den Chat senden ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "{{count}} an GitHub #{{number}} senden",
"agentManager.review.sendAllToGithubFailed": "Senden wegen eines GitHub-Fehlers gestoppt: {{error}}",
"agentManager.review.inlineCount": "Lokale Kommentare ({{count}})",
"agentManager.review.prCount": "PR-Kommentare ({{count}})",
"agentManager.review.fileCount": "{{count}} Dateien",
@@ -232,6 +232,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Send all to chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Send {{count}} to GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Stopped at a GitHub error: {{error}}",
"agentManager.review.inlineCount": "Local comments ({{count}})",
"agentManager.review.prCount": "PR comments ({{count}})",
"agentManager.review.fileCount": "{{count}} files",
@@ -236,6 +236,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Enviar todo al chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Enviar {{count}} a GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Envío detenido por un error de GitHub: {{error}}",
"agentManager.review.inlineCount": "Comentarios locales ({{count}})",
"agentManager.review.prCount": "Comentarios del PR ({{count}})",
"agentManager.review.fileCount": "{{count}} archivos",
@@ -235,6 +235,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "ارسال همه به چت ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "ارسال {{count}} مورد به GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "ارسال به دلیل خطای GitHub متوقف شد: {{error}}",
"agentManager.review.inlineCount": "نظرات محلی ({{count}})",
"agentManager.review.prCount": "نظرات PR ({{count}})",
"agentManager.review.fileCount": "{{count}} فایل",
@@ -239,6 +239,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Tout envoyer au chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Envoyer {{count}} à GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Envoi interrompu en raison dune erreur GitHub : {{error}}",
"agentManager.review.inlineCount": "Commentaires locaux ({{count}})",
"agentManager.review.prCount": "Commentaires du PR ({{count}})",
"agentManager.review.fileCount": "{{count}} fichiers",
@@ -241,6 +241,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Invia tutto alla chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "Cmd+Invio",
"agentManager.review.sendAllShortcut.other": "Ctrl+Invio",
"agentManager.review.sendAllToGithubWithCount": "Invia {{count}} a GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Invio interrotto a causa di un errore di GitHub: {{error}}",
"agentManager.review.inlineCount": "Commenti locali ({{count}})",
"agentManager.review.prCount": "Commenti della PR ({{count}})",
"agentManager.review.fileCount": "{{count}} file",
@@ -232,6 +232,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "すべてをチャットに送信 ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "{{count}}件をGitHub #{{number}}に送信",
"agentManager.review.sendAllToGithubFailed": "GitHubのエラーにより送信を停止しました: {{error}}",
"agentManager.review.inlineCount": "ローカルコメント ({{count}})",
"agentManager.review.prCount": "PRコメント ({{count}})",
"agentManager.review.fileCount": "{{count}} ファイル",
@@ -230,6 +230,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "모두 채팅으로 보내기 ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "{{count}}개를 GitHub #{{number}}로 보내기",
"agentManager.review.sendAllToGithubFailed": "GitHub 오류로 전송이 중단되었습니다: {{error}}",
"agentManager.review.inlineCount": "로컬 댓글 ({{count}})",
"agentManager.review.prCount": "PR 댓글 ({{count}})",
"agentManager.review.fileCount": "{{count}}개 파일",
@@ -239,6 +239,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Alles naar chat sturen ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "{{count}} naar GitHub #{{number}} sturen",
"agentManager.review.sendAllToGithubFailed": "Verzenden gestopt door een GitHub-fout: {{error}}",
"agentManager.review.inlineCount": "Lokale opmerkingen ({{count}})",
"agentManager.review.prCount": "PR-opmerkingen ({{count}})",
"agentManager.review.fileCount": "{{count}} bestanden",
@@ -230,6 +230,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Send alt til chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Send {{count}} til GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Sendingen ble stoppet på grunn av en GitHub-feil: {{error}}",
"agentManager.review.inlineCount": "Lokale kommentarer ({{count}})",
"agentManager.review.prCount": "PR-kommentarer ({{count}})",
"agentManager.review.fileCount": "{{count}} filer",
@@ -232,6 +232,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Wyślij wszystko do czatu ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Wyślij {{count}} do GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Wysyłanie zatrzymane z powodu błędu GitHuba: {{error}}",
"agentManager.review.inlineCount": "Komentarze lokalne ({{count}})",
"agentManager.review.prCount": "Komentarze PR ({{count}})",
"agentManager.review.fileCount": "{{count}} plików",
@@ -235,6 +235,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Отправить всё в чат ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Отправить {{count}} в GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Отправка остановлена из-за ошибки GitHub: {{error}}",
"agentManager.review.inlineCount": "Локальные комментарии ({{count}})",
"agentManager.review.prCount": "Комментарии PR ({{count}})",
"agentManager.review.fileCount": "{{count}} файлов",
@@ -226,6 +226,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "ส่งทั้งหมดไปยังแชท ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "ส่ง {{count}} รายการไปยัง GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "หยุดการส่งเนื่องจากข้อผิดพลาดของ GitHub: {{error}}",
"agentManager.review.inlineCount": "ความคิดเห็นในเครื่อง ({{count}})",
"agentManager.review.prCount": "ความคิดเห็น PR ({{count}})",
"agentManager.review.fileCount": "{{count}} ไฟล์",
@@ -240,6 +240,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Tümünü sohbete gönder ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "{{count}} yorumu GitHub #{{number}} hedefine gönder",
"agentManager.review.sendAllToGithubFailed": "Gönderim bir GitHub hatası nedeniyle durduruldu: {{error}}",
"agentManager.review.inlineCount": "Yerel yorumlar ({{count}})",
"agentManager.review.prCount": "PR yorumları ({{count}})",
"agentManager.review.fileCount": "{{count}} dosya",
@@ -243,6 +243,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "Надіслати все до чату ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "Надіслати {{count}} до GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "Надсилання зупинено через помилку GitHub: {{error}}",
"agentManager.review.inlineCount": "Локальні коментарі ({{count}})",
"agentManager.review.prCount": "Коментарі PR ({{count}})",
"agentManager.review.fileCount": "{{count}} файлів",
@@ -222,6 +222,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "全部发送到聊天 ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "发送 {{count}} 条评论到 GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "因 GitHub 错误而停止发送:{{error}}",
"agentManager.review.inlineCount": "本地评论 ({{count}})",
"agentManager.review.prCount": "PR 评论 ({{count}})",
"agentManager.review.fileCount": "{{count}} 个文件",
@@ -222,6 +222,8 @@ export const dict = {
"agentManager.review.sendAllToChatWithCount": "全部傳送到聊天 ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.sendAllToGithubWithCount": "傳送 {{count}} 則留言到 GitHub #{{number}}",
"agentManager.review.sendAllToGithubFailed": "因 GitHub 錯誤而停止傳送:{{error}}",
"agentManager.review.inlineCount": "本機留言 ({{count}})",
"agentManager.review.prCount": "PR 留言 ({{count}})",
"agentManager.review.fileCount": "{{count}} 個檔案",
@@ -1,5 +1,8 @@
import { For, Show, createSignal } from "solid-js"
import { For, Show, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@kilocode/kilo-ui/button"
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Spinner } from "@kilocode/kilo-ui/spinner"
@@ -16,13 +19,62 @@ interface Draft {
pending?: string
error?: string
preview?: boolean
destination?: "local" | "github"
sent?: "reply" | "create" | "edit" | "delete" | "line" | "review"
event?: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"
}
type Props = { projectId?: string; worktreeId: string } & (
type Props = {
projectId?: string
worktreeId: string
/** Submit on plain Enter. Diff composers keep their existing Enter-to-send behavior. */
submitOnEnter?: boolean
/** Called when Escape is pressed in the editor. */
onEscape?: () => void
inline?: boolean
} & (
| { action: "reply"; threadId: string }
| { action: "create"; prNumber: number; prUrl: string }
| {
projectId?: string
worktreeId: string
action: "local"
file: string
side: "LEFT" | "RIGHT"
startLine: number
endLine: number
selectedText: string
initialBody?: string
onBodyChange?: (body: string) => void
onSubmit: (body: string, selectedText: string) => void
onSend: (body: string, selectedText: string) => void
onCancel: () => void
}
| {
action: "diff"
worktreeId: string
projectId?: string
file: string
side: "LEFT" | "RIGHT"
startLine: number
endLine: number
selectedText: string
destination: "local" | "github"
github?: {
prNumber: number
prUrl: string
snapshotId: string
label: string
closed: boolean
}
initialBody?: string
onBodyChange?: (body: string) => void
onDestinationChange?: (value: "local" | "github") => void
onSave: (body: string, selectedText: string) => void
onSendKilo: (body: string, selectedText: string) => void
onGithubSuccess: () => void
onCancel: () => void
}
| (PRTarget & {
action: "line"
snapshotId: string
@@ -30,6 +82,8 @@ type Props = { projectId?: string; worktreeId: string } & (
side: "LEFT" | "RIGHT"
startLine: number
endLine: number
initialBody?: string
onBodyChange?: (body: string) => void
source?: string
closed?: boolean
onCancel: () => void
@@ -58,7 +112,7 @@ type Props = { projectId?: string; worktreeId: string } & (
)
// Keep drafts and in-flight replies across thread collapse and panel remounts.
const [drafts, setDrafts] = createSignal<Record<string, Draft>>({})
const [drafts, setDrafts] = createStore<Record<string, Draft | undefined>>({})
const blank: Draft = { body: "", open: false }
const decisions = [
{ event: "COMMENT", action: "review-comment", label: "agentManager.pr.review.comment" },
@@ -66,36 +120,57 @@ const decisions = [
{ event: "REQUEST_CHANGES", action: "review-request-changes", label: "agentManager.pr.review.requestChanges" },
] as const
// The form supports local, inline, reply, edit, and review actions in one shared UI.
// eslint-disable-next-line complexity
export function PRCommentForm(props: Props) {
const { t } = useLanguage()
const vscode = useVSCode()
let editor: HTMLInputElement | undefined
const key = () =>
// The discriminated union does not narrow inside JSX callbacks, so read the
// diff-only fields through accessors that keep TypeScript happy.
const github = () => (props.action === "diff" ? props.github : undefined)
const key = createMemo(() =>
JSON.stringify([
props.projectId,
props.worktreeId,
props.action,
props.action === "reply" ? props.threadId : props.prUrl,
props.action === "reply"
? props.threadId
: props.action === "local" || props.action === "diff"
? props.file
: props.prUrl,
props.action === "edit" ? props.commentId : undefined,
props.action === "local" || props.action === "diff"
? [props.file, props.side, props.startLine, props.endLine]
: undefined,
props.action === "diff" ? [props.github?.prNumber, props.github?.snapshotId] : undefined,
props.action === "line" ? [props.snapshotId, props.path, props.side, props.startLine, props.endLine] : undefined,
props.action === "review" ? [props.snapshotId, props.head] : undefined,
])
const state = () => drafts()[key()] ?? blank
]),
)
const destination = () => {
if (props.action !== "diff") return "local" as const
return drafts[key()]?.destination ?? props.destination
}
const state = () =>
drafts[key()] ??
((props.action === "line" || props.action === "local" || props.action === "diff") && props.initialBody
? { ...blank, body: props.initialBody }
: blank)
const [collapsed, setCollapsed] = createSignal<string>()
const compact = () => props.action === "reply" || props.action === "create"
const cancellable = () => props.action === "edit" || compact()
const cancellable = () => props.action === "edit" || props.action === "local" || props.action === "diff" || compact()
const expanded = () =>
!!state().pending || state().open || (collapsed() !== key() && !!(state().body || state().error))
const placeholder = () =>
t(props.action === "reply" ? "agentManager.pr.comment.replyPlaceholder" : "agentManager.pr.comment.placeholder")
const patch = (value: Partial<Draft>, id = key()) =>
setDrafts((prev) => ({ ...prev, [id]: { ...(prev[id] ?? blank), ...value } }))
const patch = (value: Partial<Draft>, id = key()) => setDrafts(id, (prev) => ({ ...(prev ?? blank), ...value }))
const label = () =>
props.action === "reply"
? t("agentManager.pr.comment.reply")
: props.action === "review"
? t("agentManager.pr.review.summary")
: props.action === "create" || props.action === "line"
: props.action === "create" || props.action === "line" || props.action === "local" || props.action === "diff"
? t("agentManager.pr.comment.add")
: t("common.edit")
const ready = () =>
@@ -134,18 +209,72 @@ export function PRCommentForm(props: Props) {
open: true,
sent: undefined,
preview: false,
...(props.action === "edit" && (!drafts()[key()] || state().sent) ? { body: props.body } : {}),
...(props.action === "edit" && (!drafts[key()] || state().sent) ? { body: props.body } : {}),
})
queueMicrotask(() => editor?.focus())
}
function cancel() {
if (state().pending) return
if (props.action === "local" || props.action === "diff") {
patch({ body: "", error: undefined, preview: false, sent: undefined })
props.onCancel()
return
}
setCollapsed(key())
patch({ open: false })
}
function sendKilo() {
if (props.action !== "diff" || !ready()) return
props.onSendKilo(state().body, props.selectedText)
patch({ body: "", open: false, preview: false, sent: "line" })
}
function saveLocal() {
if (props.action !== "diff" || !ready()) return
props.onSave(state().body, props.selectedText)
patch({ body: "", open: false, preview: false, sent: "line" })
}
function sendGithub() {
const gh = github()
if (props.action !== "diff" || !gh || gh.closed) return
if (!ready()) return
const requestId = crypto.randomUUID()
const message: PRCommentRequest = {
type: "agentManager.createReviewComment",
projectId: props.projectId,
worktreeId: props.worktreeId,
prNumber: gh.prNumber,
prUrl: gh.prUrl,
requestId,
snapshotId: gh.snapshotId,
path: props.file,
side: props.side,
startLine: props.startLine,
endLine: props.endLine,
body: state().body,
}
patch({ pending: requestId, error: undefined, sent: undefined })
reviewRequest(message, vscode.postMessage, (result) => {
patch({ pending: undefined })
if (result.success) {
patch({ body: "", open: false, preview: false, sent: "line" })
props.onGithubSuccess()
return
}
patch({ error: result.error || "failed" })
})
}
function sendPrimary() {
if (destination() === "github") sendGithub()
else sendKilo()
}
function submit(deleting = false) {
if (props.action === "diff") return
const body = state().body
if (
deleting
@@ -153,6 +282,11 @@ export function PRCommentForm(props: Props) {
: !ready()
)
return
if (props.action === "local") {
patch({ body: "", error: undefined, preview: false, sent: undefined })
props.onSubmit(body, props.selectedText)
return
}
const id = key()
const requestId = crypto.randomUUID()
const route = { projectId: props.projectId, worktreeId: props.worktreeId }
@@ -210,7 +344,7 @@ export function PRCommentForm(props: Props) {
}
return (
<div class="am-pr-comment-composer" data-action={props.action}>
<div class="am-pr-comment-composer" data-action={props.action} data-inline={props.inline || undefined}>
<Show
when={!compact() || expanded()}
fallback={
@@ -292,63 +426,91 @@ export function PRCommentForm(props: Props) {
<p>{t("agentManager.pr.review.own")}</p>
</Show>
</Show>
<div data-slot="comment-toolbar">
<Button
data-action="write"
variant="ghost"
size="small"
aria-pressed={!state().preview}
onClick={() => patch({ preview: false })}
>
{t("agentManager.pr.comment.write")}
</Button>
<Button
data-action="preview"
variant="ghost"
size="small"
aria-pressed={!!state().preview}
onClick={() => patch({ preview: true })}
>
{t("agentManager.pr.comment.preview")}
</Button>
<Show when={suggestion()}>
<span data-slot="comment-suggestion">
<Tooltip value={t("agentManager.pr.comment.suggestion")} placement="top">
<IconButton
data-action="suggestion"
icon="code"
variant="ghost"
size="small"
aria-label={t("agentManager.pr.comment.suggestion")}
disabled={!!state().pending || !!state().preview}
onClick={suggest}
/>
</Tooltip>
</span>
</Show>
</div>
<Show when={!props.inline}>
<div data-slot="comment-toolbar">
<Button
data-action="write"
variant="ghost"
size="small"
aria-pressed={!state().preview}
onClick={() => patch({ preview: false })}
>
{t("agentManager.pr.comment.write")}
</Button>
<Button
data-action="preview"
variant="ghost"
size="small"
aria-pressed={!!state().preview}
onClick={() => patch({ preview: true })}
>
{t("agentManager.pr.comment.preview")}
</Button>
<Show when={suggestion()}>
<span data-slot="comment-suggestion">
<Tooltip value={t("agentManager.pr.comment.suggestion")} placement="top">
<IconButton
data-action="suggestion"
icon="code"
variant="ghost"
size="small"
aria-label={t("agentManager.pr.comment.suggestion")}
disabled={!!state().pending || !!state().preview}
onClick={suggest}
/>
</Tooltip>
</span>
</Show>
</div>
</Show>
<div hidden={!!state().preview}>
<TextField
ref={(node: HTMLInputElement) => {
editor = node
}}
multiline
autoResize={!props.inline}
label={label()}
hideLabel
placeholder={placeholder()}
value={state().body}
disabled={!!state().pending}
onChange={(body) => patch({ body, sent: undefined })}
onChange={(body) => {
patch({ body, sent: undefined })
if (props.action === "line" || props.action === "local" || props.action === "diff")
props.onBodyChange?.(body)
}}
onKeyDown={(event: KeyboardEvent) => {
if (
event.key !== "Enter" ||
(!event.ctrlKey && !event.metaKey) ||
event.isComposing ||
event.keyCode === 229
)
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Escape" && props.onEscape) {
event.preventDefault()
props.onEscape()
return
event.preventDefault()
submit()
}
if (props.action === "diff") {
if (event.key !== "Enter" || event.shiftKey) return
// Cmd/Ctrl+Enter saves the comment. Plain Enter sends it to Kilo,
// and GitHub is never published from the keyboard.
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
saveLocal()
return
}
if (destination() === "github") return
event.preventDefault()
sendKilo()
return
}
if (event.key !== "Enter") return
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
submit()
return
}
if (props.submitOnEnter && !event.shiftKey) {
event.preventDefault()
submit()
}
}}
/>
</div>
@@ -359,22 +521,11 @@ export function PRCommentForm(props: Props) {
</Show>
</div>
</Show>
<div class="am-pr-comment-actions am-pr-row">
<Button data-action="submit" variant="primary" size="small" disabled={!ready()} onClick={() => submit()}>
<Show when={state().pending}>
<Spinner class="am-pr-comment-spinner" />
</Show>
{state().pending
? t("agentManager.pr.comment.replySending")
: props.action === "reply"
? t("agentManager.pr.comment.reply")
: props.action === "review"
? t("agentManager.pr.review.title")
: props.action === "create" || props.action === "line"
? t("agentManager.pr.comment.addSubmit")
: t("common.save")}
</Button>
<Show when={cancellable()}>
<Show when={props.action === "diff"}>
<div class="am-pr-comment-actions am-pr-row" data-slot="comment-actions">
<Button data-action="save" variant="secondary" size="small" disabled={!ready()} onClick={saveLocal}>
{t("common.save")}
</Button>
<Button
data-action="cancel"
variant="secondary"
@@ -384,25 +535,197 @@ export function PRCommentForm(props: Props) {
>
{t("common.cancel")}
</Button>
</Show>
<Show when={props.action === "line"}>
<span data-slot="comment-actions-gap" />
<Button
data-action="discard"
variant="secondary"
data-action={state().preview ? "write" : "preview"}
variant="ghost"
size="small"
disabled={!!state().pending}
aria-pressed={!!state().preview}
onClick={() => {
patch({ body: "", error: undefined, preview: false, sent: undefined })
if (props.action === "line") props.onCancel()
patch({ preview: !state().preview })
if (!state().preview) editor?.focus()
}}
>
{t("agentManager.pr.review.discard")}
{t(state().preview ? "agentManager.pr.comment.write" : "agentManager.pr.comment.preview")}
</Button>
<Show
when={github()}
fallback={
<Button
data-action="send-kilo"
variant="primary"
size="small"
disabled={!ready()}
onClick={sendKilo}
>
{t("diffViewer.comment.sendToKilo")}
</Button>
}
>
{(pr) => (
<div class="am-split-button" data-variant="primary" data-disabled={!ready() ? "" : undefined}>
<Button
data-action="send-primary"
data-destination={destination()}
variant="primary"
size="small"
disabled={!ready() || (destination() === "github" && pr().closed)}
aria-busy={!!state().pending}
onClick={sendPrimary}
>
<Show when={state().pending}>
<Spinner class="am-pr-comment-spinner" />
</Show>
{destination() === "github"
? t("diffViewer.comment.sendToGithub", { number: pr().prNumber })
: t("diffViewer.comment.sendToKilo")}
</Button>
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
class="am-split-arrow"
disabled={!!state().pending}
aria-label={t("diffViewer.comment.chooseDestination")}
>
<Icon name="chevron-down" size="small" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="am-split-menu">
<DropdownMenu.Item
onSelect={() => {
if (props.action !== "diff") return
patch({ destination: "local" })
props.onDestinationChange?.("local")
}}
>
<span class="am-menu-check" aria-hidden="true">
<Show when={destination() !== "github"}>
<Icon name="check" size="small" />
</Show>
</span>
<DropdownMenu.ItemLabel>{t("diffViewer.comment.sendToKilo")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={pr().closed}
onSelect={() => {
if (props.action !== "diff") return
patch({ destination: "github" })
props.onDestinationChange?.("github")
}}
>
<span class="am-menu-check" aria-hidden="true">
<Show when={destination() === "github"}>
<Icon name="check" size="small" />
</Show>
</span>
<DropdownMenu.ItemLabel>
{t("diffViewer.comment.sendToGithub", { number: pr().prNumber })}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
<Show when={destination() === "github" && github()?.closed}>
<div class="am-pr-comment-error" role="status">
{t("diffViewer.comment.unavailable")}
</div>
</Show>
</div>
</Show>
<Show when={props.action !== "diff"}>
<div class="am-pr-comment-actions am-pr-row">
<Show when={props.inline}>
<Button
data-action={state().preview ? "write" : "preview"}
variant="ghost"
size="small"
aria-pressed={!!state().preview}
onClick={() => {
patch({ preview: !state().preview })
if (!state().preview) editor?.focus()
}}
>
{t(state().preview ? "agentManager.pr.comment.write" : "agentManager.pr.comment.preview")}
</Button>
<span data-slot="comment-actions-gap" />
</Show>
<Button
data-action="submit"
variant={props.inline && props.action === "local" ? "secondary" : "primary"}
size="small"
disabled={!ready()}
onClick={() => submit()}
>
<Show when={state().pending}>
<Spinner class="am-pr-comment-spinner" />
</Show>
{state().pending
? t("agentManager.pr.comment.replySending")
: props.inline && props.action === "line"
? t("diffViewer.comment.postToGithub")
: props.inline && props.action === "local"
? t("diffViewer.comment.saveLocal")
: props.action === "reply"
? t("agentManager.pr.comment.reply")
: props.action === "review"
? t("agentManager.pr.review.title")
: props.action === "create" || props.action === "line" || props.action === "local"
? t("agentManager.pr.comment.addSubmit")
: t("common.save")}
</Button>
<Show when={props.action === "local"}>
<Button
data-action="send"
aria-label={t("diffViewer.comment.sendToAgent")}
title={t("diffViewer.comment.sendToAgent")}
variant="primary"
size="small"
disabled={!ready()}
onClick={() => {
if (props.action !== "local") return
props.onSend(state().body, props.selectedText)
}}
>
{t("prompt.action.send")}
</Button>
</Show>
<Show when={cancellable()}>
<Button
data-action="cancel"
variant="secondary"
size="small"
disabled={!!state().pending}
onClick={cancel}
>
{t("common.cancel")}
</Button>
</Show>
<Show when={props.action === "line"}>
<Button
data-action="discard"
variant="secondary"
size="small"
disabled={!!state().pending}
onClick={() => {
patch({ body: "", error: undefined, preview: false, sent: undefined })
if (props.action === "line") props.onCancel()
}}
>
{t(props.inline ? "common.cancel" : "agentManager.pr.review.discard")}
</Button>
</Show>
</div>
</Show>
</div>
</Show>
</Show>
<Show when={props.inline && props.action === "line" && props.closed}>
<div class="am-pr-comment-error" role="status">
{t("diffViewer.comment.unavailable")}
</div>
</Show>
<Show when={(!compact() || expanded()) && state().error}>
{(error) => (
<div class="am-pr-comment-error" role="alert">
@@ -0,0 +1,86 @@
import { getOwner, runWithOwner, type Accessor } from "solid-js"
import { render as mount } from "solid-js/web"
import { PRCommentForm } from "./PRCommentForm"
import { useVSCode } from "../../src/context/vscode"
import { extractLines } from "../../diff-viewer/review-comments"
import { createCommentsGithub, resolveGithubContext } from "../../diff-viewer/comments-github"
import { parsePatch } from "../../../src/shared/pr-patch"
import type { AnnotationMeta, CommentFormMount } from "../../diff-viewer/review-annotations"
import type { WorktreeFileDiff } from "../../src/types/messages"
import type { PRDiffSnapshot, PRTarget } from "../../../src/shared/pr-comment-actions"
interface Options {
target: Accessor<PRTarget | undefined>
snapshot: Accessor<PRDiffSnapshot | undefined>
diffs: Accessor<WorktreeFileDiff[]>
worktree: Accessor<string>
/** Gate GitHub publication, for example when only a local diff source is shown. */
canPublish?: Accessor<boolean>
}
function side(value: AnnotationMeta["side"]): "LEFT" | "RIGHT" {
return value === "deletions" ? "LEFT" : "RIGHT"
}
export function createDiffCommentForms(opts: Options) {
const owner = getOwner()
const vscode = useVSCode()
const github = createCommentsGithub({
target: opts.target,
snapshot: opts.snapshot,
diffs: opts.diffs,
post: vscode.postMessage,
canPublish: opts.canPublish,
})
const mountDraft: CommentFormMount = (host, meta, actions) => {
const diff = opts.diffs().find((item) => item.file === meta.file)
const content = meta.side === "deletions" ? (diff?.before ?? "") : (diff?.after ?? "")
const end = meta.endLine ?? meta.line
const selected =
(diff?.patch
? parsePatch(diff.patch, undefined, { side: side(meta.side), start: meta.line, end })?.source
: undefined) ?? extractLines(content, meta.line, end)
const context = () => {
if (opts.canPublish?.() === false) return
return resolveGithubContext({
target: opts.target(),
snapshot: opts.snapshot(),
file: meta.file,
side: meta.side,
start: meta.line,
end,
patch: diff?.patch,
})
}
const attach = () =>
mount(
() => (
<PRCommentForm
inline
action="diff"
worktreeId={opts.worktree()}
projectId={opts.target()?.projectId}
file={meta.file}
side={side(meta.side)}
startLine={meta.line}
endLine={end}
selectedText={selected}
destination={context() && meta.destination === "github" ? "github" : "local"}
github={context()}
initialBody={actions.body}
onBodyChange={actions.onBodyChange}
onDestinationChange={actions.onDestination}
onSave={actions.onSave}
onSendKilo={actions.onSend}
onGithubSuccess={actions.onGithubSuccess}
onCancel={actions.onCancel}
/>
),
host,
)
return owner ? runWithOwner(owner, attach) : attach()
}
return { mount: mountDraft, github }
}
@@ -0,0 +1,77 @@
import { createSignal, untrack, type Accessor } from "solid-js"
import type { PRDiffSnapshot, PRTarget } from "../../../src/shared/pr-comment-actions"
import type { PRStatus } from "../../src/types/messages"
import { reviewRequest } from "./pr-review-request"
interface Options {
post: (message: never) => void
project: Accessor<string | undefined>
statuses: Accessor<Record<string, Pick<PRStatus, "number" | "url" | "baseRefOid" | "headRefOid"> | null>>
}
export function createPRDiffCommentState(opts: Options) {
const [snapshots, setSnapshots] = createSignal<Record<string, PRDiffSnapshot>>({})
const [pending, setPending] = createSignal(new Set<string>())
const [errors, setErrors] = createSignal<Record<string, string>>({})
const target = (ctx: string | undefined): PRTarget | undefined => {
if (!ctx) return
const pr = opts.statuses()[ctx]
if (!pr) return
return {
projectId: opts.project(),
worktreeId: ctx,
prNumber: pr.number,
prUrl: pr.url,
baseRefOid: pr.baseRefOid,
headRefOid: pr.headRefOid,
}
}
const key = (ctx: string | undefined) => {
const route = target(ctx)
return route ? JSON.stringify(route) : ""
}
const snapshot = (ctx: string | undefined) => {
const id = key(ctx)
return id ? snapshots()[id] : undefined
}
const loading = (ctx: string | undefined) => pending().has(key(ctx))
const error = (ctx: string | undefined) => errors()[key(ctx)]
const load = (ctx: string | undefined) => {
const route = target(ctx)
if (!route) return
const id = key(ctx)
// Read the dedupe state untracked so a failed load does not re-trigger the
// effect that called this, which would immediately retry forever.
if (untrack(() => Boolean(snapshots()[id] || pending().has(id)))) return
setPending((prev) => new Set(prev).add(id))
setErrors((prev) => {
const next = { ...prev }
delete next[id]
return next
})
reviewRequest(
{ ...route, type: "agentManager.loadPRFiles", requestId: crypto.randomUUID() },
opts.post,
(result) => {
setPending((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
if (result.type !== "agentManager.loadPRFilesResult") return
if (!result.success || !result.snapshot) {
setErrors((prev) => ({ ...prev, [id]: result.error || "Could not load pull request changes." }))
return
}
setSnapshots((prev) => ({ ...prev, [id]: result.snapshot! }))
},
)
}
return { target, snapshot, loading, error, load }
}
@@ -646,6 +646,76 @@
flex: 1;
}
/* The same editor fits between diff lines without a second card or toolbar. */
.am-pr-comment-composer[data-inline] {
padding: 0;
gap: 4px;
width: 100%;
font-family: var(--vscode-font-family, sans-serif);
}
.am-pr-comment-composer[data-inline] [data-slot="comment-editor"] {
border: 0;
border-radius: 0;
background: transparent;
}
.am-pr-comment-composer[data-inline]
[data-component="input"][data-variant="normal"]
[data-slot="input-wrapper"]:focus-within:not(:has([data-readonly])) {
outline: none;
}
.am-pr-comment-composer[data-inline] [data-slot="comment-preview"],
.am-pr-comment-composer[data-inline] [data-component="input"][data-variant="normal"] textarea[data-slot="input-input"] {
min-height: 56px;
font-family: inherit;
}
.am-pr-comment-composer[data-inline] .am-pr-comment-actions {
padding: 4px 0 0;
gap: 6px;
justify-content: flex-end;
}
.am-pr-comment-composer[data-inline] [data-slot="comment-actions-gap"] {
flex: 1;
order: 1;
}
.am-pr-comment-composer[data-inline] [data-action="cancel"],
.am-pr-comment-composer[data-inline] [data-action="discard"] {
order: 2;
}
.am-pr-comment-composer[data-inline] [data-action="submit"] {
order: 3;
}
.am-pr-comment-composer[data-inline] [data-action="send"] {
order: 4;
}
/* The unified diff composer keeps Save/Cancel on the left and the send action on the right. */
.am-pr-comment-composer[data-action="diff"] [data-action="save"],
.am-pr-comment-composer[data-action="diff"] [data-action="cancel"] {
order: 0;
}
.am-pr-comment-composer[data-action="diff"] [data-slot="comment-actions-gap"] {
order: 1;
}
.am-pr-comment-composer[data-action="diff"] [data-action="preview"],
.am-pr-comment-composer[data-action="diff"] [data-action="write"] {
order: 2;
}
.am-pr-comment-composer[data-action="diff"] .am-split-button,
.am-pr-comment-composer[data-action="diff"] [data-action="send-kilo"] {
order: 3;
}
.am-pr-reactions {
gap: 3px;
flex-wrap: wrap;
@@ -1,4 +1,4 @@
import { batch, createEffect, createSignal, on, onCleanup, Show } from "solid-js"
import { batch, createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js"
import type { Component } from "solid-js"
import { DialogProvider } from "@kilocode/kilo-ui/context/dialog"
import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
@@ -9,6 +9,9 @@ import { Code } from "@kilocode/kilo-ui/code"
import { Diff } from "@kilocode/kilo-ui/diff"
import { File } from "@kilocode/kilo-ui/file"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Button } from "@kilocode/kilo-ui/button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { Toast } from "@kilocode/kilo-ui/toast"
import { FullScreenDiffView } from "./FullScreenDiffView"
@@ -22,7 +25,15 @@ import type { BranchInfo, ReviewComment, WebviewMessage, WorktreeFileDiff } from
import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../src/diff/sources/types"
import type { DiffViewerNotice } from "../src/types/messages/extension-messages"
import type { PRComment } from "../agent-manager/pr/pr-types"
import type { PRTarget } from "../../src/shared/pr-comment-actions"
import { reviewRequest } from "../agent-manager/pr/pr-review-request"
import type { PRDiffSnapshot, PRTarget } from "../../src/shared/pr-comment-actions"
import { createPRDiffs } from "./pr-diff"
// Compare only the PR identity. Ref-only refreshes must not clear local comments.
function samePR(a: PRTarget | undefined, b: PRTarget | undefined) {
return a?.projectId === b?.projectId && a?.prNumber === b?.prNumber && a?.prUrl === b?.prUrl
}
import { createDiffCommentForms } from "../agent-manager/pr/diff-comment-forms"
import { DiffPickerHeader } from "./DiffPickerHeader"
import { BaseBranchPicker } from "./BaseBranchPicker"
import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm"
@@ -63,6 +74,28 @@ const DiffViewerContent: Component = () => {
const [isAuto, setIsAuto] = createSignal(true)
const [currentBranch, setCurrentBranch] = createSignal<string | undefined>(undefined)
const [branchesLoading, setBranchesLoading] = createSignal(false)
const [prMode, setPRMode] = createSignal(false)
const [prSnapshot, setPRSnapshot] = createSignal<PRDiffSnapshot>()
const [prLoading, setPRLoading] = createSignal(false)
const [prError, setPRError] = createSignal<string>()
let prKey = ""
let prRequestId = ""
const prDiffs = createMemo(() => {
const snapshot = prSnapshot()
return snapshot ? createPRDiffs(snapshot) : []
})
const activeDiffs = () => (prMode() ? prDiffs() : diffs())
const activeLoading = () => (prMode() ? prLoading() : loading())
const noLoadingFiles = new Set<string>()
const activeLoadingFiles = () => (prMode() ? noLoadingFiles : loadingFiles())
const forms = createDiffCommentForms({
target,
snapshot: prSnapshot,
diffs: activeDiffs,
worktree: () => "diff",
canPublish: () => prMode(),
})
const isWorkspaceSource = () => {
const id = currentSourceId()
@@ -111,6 +144,46 @@ const DiffViewerContent: Component = () => {
}
}
const requestPRFiles = (next: PRTarget | undefined) => {
if (!next) {
prKey = ""
prRequestId = ""
setPRSnapshot(undefined)
setPRLoading(false)
setPRError(undefined)
setPRMode(false)
return
}
const key = JSON.stringify(next)
if (prKey === key && (prLoading() || prSnapshot())) return
prKey = key
const requestId = crypto.randomUUID()
prRequestId = requestId
setPRSnapshot(undefined)
setPRLoading(true)
setPRError(undefined)
reviewRequest({ ...next, type: "agentManager.loadPRFiles", requestId }, vscode.postMessage, (result) => {
if (prKey !== key || prRequestId !== requestId) return
if (result.type !== "agentManager.loadPRFilesResult") return
if (!result.success || !result.snapshot) {
setPRLoading(false)
setPRError(result.error || t("diffViewer.comment.loadFailed"))
return
}
setPRSnapshot(result.snapshot)
setPRLoading(false)
})
}
const togglePRMode = () => {
if (!prSnapshot()) {
requestPRFiles(target())
return
}
setComments([])
setPRMode((value) => !value)
}
const unsubscribe = vscode.onMessage((msg) => {
if (msg.type === "diffViewer.context") {
if (context() === msg.key) return
@@ -131,15 +204,30 @@ const DiffViewerContent: Component = () => {
setCurrentBase(undefined)
setCurrentBranch(undefined)
setIsAuto(true)
prKey = ""
prRequestId = ""
setPRSnapshot(undefined)
setPRLoading(false)
setPRError(undefined)
setPRMode(false)
})
return
}
if (msg.type === "diffViewer.prComments") {
// Only clear local comments when the PR identity changes. Ref-only
// refreshes (a push or rebase) must keep unsent comments.
const changed = !samePR(target(), msg.target)
batch(() => {
setRemote(msg.comments)
setTarget(msg.target)
setThreads(msg.threads ?? [])
if (changed) {
setComments([])
setDiffStyle("unified")
setPRMode(false)
}
})
requestPRFiles(msg.target)
return
}
if (msg.type === "diffViewer.focusComment") {
@@ -233,6 +321,8 @@ const DiffViewerContent: Component = () => {
setDiffStyle("unified")
setReverting(new Set<string>())
setNotice(undefined)
setPRError(undefined)
setPRMode(false)
}),
)
@@ -275,18 +365,47 @@ const DiffViewerContent: Component = () => {
currentId={currentSourceId()}
onSelect={selectSource}
accessory={
<Show when={isWorkspaceSource()}>
<BaseBranchPicker
branches={branches()}
loading={branchesLoading()}
defaultBranch={defaultBranch()}
autoBase={autoBase()}
currentBase={currentBase()}
isAuto={isAuto()}
currentBranch={currentBranch()}
onSelect={onBaseBranchSelect}
/>
</Show>
<div class="diff-pr-controls">
<Show when={isWorkspaceSource() && !prMode()}>
<BaseBranchPicker
branches={branches()}
loading={branchesLoading()}
defaultBranch={defaultBranch()}
autoBase={autoBase()}
currentBase={currentBase()}
isAuto={isAuto()}
currentBranch={currentBranch()}
onSelect={onBaseBranchSelect}
/>
</Show>
<Show when={target()}>
{(pr) => (
<Show when={isWorkspaceSource()}>
<span class="diff-pr-context" title={pr().prUrl}>
{t("diffViewer.comment.prContext", { number: pr().prNumber })}
</span>
<IconButton
icon="external-link"
size="small"
variant="ghost"
label={t("diffViewer.comment.openPR")}
onClick={() => post({ type: "openExternal", url: pr().prUrl })}
/>
<Button
size="small"
variant={prMode() ? "primary" : "secondary"}
disabled={prLoading() && !prSnapshot()}
onClick={togglePRMode}
>
<Show when={prLoading()}>
<Spinner />
</Show>
{prMode() ? t("diffViewer.comment.localChanges") : t("diffViewer.comment.prChanges")}
</Button>
</Show>
)}
</Show>
</div>
}
/>
</Show>
@@ -298,12 +417,20 @@ const DiffViewerContent: Component = () => {
<span class="diff-viewer-notice-text">{noticeText()}</span>
</div>
</Show>
<Show when={prError()}>
<div class="diff-viewer-notice" role="alert">
<span class="diff-viewer-notice-icon">
<Icon name="warning" size="small" />
</span>
<span class="diff-viewer-notice-text">{prError()}</span>
</div>
</Show>
<FullScreenDiffView
diffs={diffs()}
loading={loading()}
loadingFiles={loadingFiles()}
onRequestDiff={requestDiffFile}
sessionKey={`${context()}\0${currentSourceId() ?? "local"}`}
diffs={activeDiffs()}
loading={activeLoading()}
loadingFiles={activeLoadingFiles()}
onRequestDiff={prMode() ? undefined : requestDiffFile}
sessionKey={`${context()}\0${currentSourceId() ?? "local"}\0${prMode() ? "pr" : "local"}`}
worktreeId="diff"
remoteComments={remote()}
remoteTarget={(comment) => (threads().includes(comment.threadId) ? target() : undefined)}
@@ -312,6 +439,8 @@ const DiffViewerContent: Component = () => {
comments={comments()}
onCommentsChange={setComments}
onSendAll={() => {}}
commentForm={forms.mount}
commentsGithub={forms.github}
diffStyle={diffStyle()}
onDiffStyleChange={(style) => {
setDiffStyle(style)
@@ -331,7 +460,7 @@ const DiffViewerContent: Component = () => {
post({ type: "diffViewer.revertFile", file })
}}
revertingFiles={reverting()}
canRevert={capabilities()?.revert ?? true}
canRevert={!prMode() && (capabilities()?.revert ?? true)}
canComment={capabilities()?.comments ?? true}
onClose={() => {
post({ type: "diffViewer.close" })
@@ -0,0 +1,19 @@
import { Show, type Component } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
interface Props {
text?: string
role: "alert" | "status"
}
/** Shared warning banner used by the inline and full-screen diff views. */
export const DiffViewerNotice: Component<Props> = (props) => (
<Show when={props.text}>
<div class="diff-viewer-notice" role={props.role}>
<span class="diff-viewer-notice-icon">
<Icon name="warning" size="small" />
</span>
<span class="diff-viewer-notice-text">{props.text}</span>
</div>
</Show>
)
@@ -13,8 +13,6 @@ import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../src/context/language"
import { FileTree } from "./FileTree"
import {
LONG_DIFF_MARKER_FILE_COUNT,
@@ -25,12 +23,15 @@ import {
toggleOpenFiles,
} from "./diff-open-policy"
import { DiffEndMarker } from "./DiffEndMarker"
import { DiffViewerNotice } from "./DiffViewerNotice"
import { VirtualDiffList } from "./VirtualDiffList"
import { createDiffViewport } from "./diff-requests"
import { RemoteCommentsOutside } from "./remote-comment-renderer"
import { ReviewDiffItem } from "./ReviewDiffItem"
import { createReviewView, type ReviewViewProps } from "./review-controller"
import { notice, reviewSendAllKeybind } from "./review-setup"
import { type ReviewViewProps } from "./review-controller"
import { createReviewSurface } from "./review-surface"
import { SendAllButton } from "./SendAllButton"
import type { PRDiffSnapshot, PRTarget } from "../../src/shared/pr-comment-actions"
type DiffStyle = "unified" | "split"
@@ -49,15 +50,19 @@ interface FullScreenDiffViewProps extends ReviewViewProps {
canRevert?: boolean
/** Optional leading content rendered first in the toolbar's left group. */
lead?: JSXElement
prTarget?: PRTarget
prSnapshot?: PRDiffSnapshot
prLoading?: boolean
prError?: string
onClose: () => void
}
export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) => {
const { t } = useLanguage()
const noticeText = () => notice(t, props.notice)
const sendAllKeybind = () => reviewSendAllKeybind(t)
let rootRef: HTMLDivElement | undefined
const {
t,
noticeText,
sendAllKeybind,
open,
setOpen,
rows,
@@ -77,7 +82,12 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
commentsByFile,
handleGutterClick,
sendAllClick,
} = createReviewView(props, () => rootRef)
sendAllToGithub,
sendAllGithubCount,
sendAllGithubAvailable,
sendAllPending,
sendAllError,
} = createReviewSurface(props, () => rootRef)
const [manualActiveFile, setManualActiveFile] = createSignal<Record<string, string | null>>({})
const activeFile = createMemo(() => {
@@ -191,6 +201,16 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
<div class="am-review-toolbar">
<div class="am-review-toolbar-left">
<Show when={props.lead}>{props.lead}</Show>
<Show when={props.prTarget}>
{(target) => (
<span class="am-review-pr-context" title={target().prUrl}>
{t("diffViewer.comment.prContext", { number: target().prNumber })}
<Show when={props.prLoading}>
<Spinner />
</Show>
</span>
)}
</Show>
<RadioGroup
options={["unified", "split"] as const}
current={props.diffStyle}
@@ -225,19 +245,26 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
{openLabel()}
</Button>
<Show when={comments().length > 0 && props.canComment !== false}>
<TooltipKeybind
title={t("agentManager.review.sendAllToChat")}
<SendAllButton
count={comments().length}
githubCount={sendAllGithubCount()}
githubNumber={sendAllGithubAvailable() ? props.prTarget?.prNumber : undefined}
pending={sendAllPending()}
onSendChat={sendAllClick}
onSendGithub={sendAllToGithub}
keybind={sendAllKeybind()}
placement="bottom"
>
<Button variant="primary" size="small" onClick={sendAllClick}>
{t("agentManager.review.sendAllToChatWithCount", { count: comments().length })}
</Button>
</TooltipKeybind>
/>
</Show>
<Show when={sendAllError()}>
<span class="am-review-send-error" role="alert">
{sendAllError()}
</span>
</Show>
<IconButton icon="close" size="small" variant="ghost" label={t("common.close")} onClick={props.onClose} />
</div>
</div>
<DiffViewerNotice text={props.prError} role="alert" />
{/* Body: file tree + diff viewer */}
<div class="am-review-body">
@@ -262,14 +289,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
/>
</div>
<div class="am-review-diff" ref={setScroller}>
<Show when={noticeText()}>
<div class="diff-viewer-notice" role="status">
<span class="diff-viewer-notice-icon">
<Icon name="warning" size="small" />
</span>
<span class="diff-viewer-notice-text">{noticeText()}</span>
</div>
</Show>
<DiffViewerNotice text={noticeText()} role="status" />
<Show when={props.loading && props.diffs.length === 0}>
<div class="am-diff-loading">
@@ -0,0 +1,70 @@
import { Show, type Component } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../src/context/language"
interface Props {
/** Number of local comments. */
count: number
/** Number of local comments that can be posted to the PR. */
githubCount: number
/** PR number when a publishable PR is available. */
githubNumber?: number
pending: boolean
onSendChat: () => void
onSendGithub: () => void
keybind: string
placement?: "top" | "bottom"
}
/**
* Send-all actions for the review toolbars.
*
* Without a publishable PR it is the plain send-to-chat button. With a PR it
* shows two explicit buttons. Only the chat button advertises the keyboard
* shortcut, so only it can send everything through the keyboard.
*/
export const SendAllButton: Component<Props> = (props) => {
const { t } = useLanguage()
const placement = () => props.placement ?? "top"
const github = () => props.githubNumber
const chatLabel = () => t("agentManager.review.sendAllToChatWithCount", { count: props.count })
const githubLabel = () =>
t("agentManager.review.sendAllToGithubWithCount", { count: props.githubCount, number: github() ?? 0 })
const Chat = () => (
<TooltipKeybind title={t("agentManager.review.sendAllToChat")} keybind={props.keybind} placement={placement()}>
<Button
data-action="send-all-chat"
variant="primary"
size="small"
disabled={props.pending}
onClick={props.onSendChat}
>
{chatLabel()}
</Button>
</TooltipKeybind>
)
return (
<Show when={github() !== undefined} fallback={<Chat />}>
<div class="am-send-all-actions">
<Chat />
<Tooltip value={githubLabel()} placement={placement()}>
<Button
data-action="send-all-github"
variant="secondary"
size="small"
disabled={props.pending || props.githubCount === 0}
aria-busy={props.pending}
onClick={props.onSendGithub}
>
<Show when={props.pending}>
<Spinner />
</Show>
{githubLabel()}
</Button>
</Tooltip>
</div>
</Show>
)
}
@@ -0,0 +1,34 @@
import type { AnnotationMeta } from "./review-annotations"
// Pierre can replace an annotation without invoking its button handlers.
export function createAnnotationLifecycle() {
const mounts = new Map<AnnotationMeta, { host: HTMLElement; dispose: () => void }>()
let observer: MutationObserver | undefined
const release = (meta: AnnotationMeta) => {
const entry = mounts.get(meta)
if (!entry) return
mounts.delete(meta)
entry.dispose()
if (mounts.size) return
observer?.disconnect()
observer = undefined
}
const track = (meta: AnnotationMeta, host: HTMLElement, dispose: () => void) => {
release(meta)
mounts.set(meta, { host, dispose })
if (observer) return
observer = new MutationObserver(() => {
// The wrapper is inserted synchronously after track returns, so any host
// still detached on an observer flush will never be shown. Releasing it
// keeps a dropped annotation from retaining its form for the session.
for (const [meta, entry] of mounts) {
if (!entry.host.isConnected) release(meta)
}
})
observer.observe(document.body, { childList: true, subtree: true })
}
const clear = () => {
for (const meta of mounts.keys()) release(meta)
}
return { track, clear }
}
@@ -0,0 +1,136 @@
import type { Accessor } from "solid-js"
import type { PRDiffSnapshot, PRTarget } from "../../src/shared/pr-comment-actions"
import { parsePatch } from "../../src/shared/pr-patch"
import type { WorktreeFileDiff } from "../src/types/messages"
import type { ReviewComment } from "./review-comments"
import { canCommentOnPRLine } from "./pr-diff"
import { reviewRequest } from "../agent-manager/pr/pr-review-request"
/** GitHub context for one local comment. `closed` marks a line outside the PR diff. */
export interface GithubContext {
prNumber: number
prUrl: string
snapshotId: string
label: string
closed: boolean
}
export interface CommentsGithub {
/** True when a PR with a loaded snapshot is available for publication. */
available: () => boolean
/** Resolve the GitHub target for a comment. `closed` means the line is not publishable. */
resolve: (comment: ReviewComment) => GithubContext | undefined
send: (comment: ReviewComment) => Promise<{ success: boolean; error?: string }>
}
function side(value: ReviewComment["side"]): "LEFT" | "RIGHT" {
return value === "deletions" ? "LEFT" : "RIGHT"
}
/**
* Resolve the GitHub review target for one line range.
*
* The line must exist in the PR snapshot and in a complete hunk of the patch.
* A missing or incomplete patch returns a closed context so callers can disable
* the action instead of hiding it.
*/
export function resolveGithubContext(opts: {
target?: PRTarget
snapshot?: PRDiffSnapshot
file: string
side: ReviewComment["side"]
start: number
end: number
patch?: string
}): GithubContext | undefined {
if (!opts.target || !opts.snapshot) return
const mapped = side(opts.side)
const allowed =
!!opts.patch &&
!!parsePatch(opts.patch, undefined, { side: mapped, start: opts.start, end: opts.end }) &&
canCommentOnPRLine(opts.snapshot, opts.file, mapped, opts.start, opts.end)
return {
prNumber: opts.target.prNumber,
prUrl: opts.target.prUrl,
snapshotId: opts.snapshot.id,
label: `GitHub #${opts.target.prNumber}`,
closed: !allowed,
}
}
interface Options {
target: Accessor<PRTarget | undefined>
snapshot: Accessor<PRDiffSnapshot | undefined>
diffs: Accessor<WorktreeFileDiff[]>
post: (message: never) => void
/** Gate publication, for example when only local changes are shown. */
canPublish?: Accessor<boolean>
}
export function createCommentsGithub(opts: Options): CommentsGithub {
const resolve = (comment: ReviewComment) => {
if (opts.canPublish?.() === false) return
const diff = opts.diffs().find((item) => item.file === comment.file)
return resolveGithubContext({
target: opts.target(),
snapshot: opts.snapshot(),
file: comment.file,
side: comment.side,
start: comment.line,
end: comment.line,
patch: diff?.patch,
})
}
const available = () => opts.canPublish?.() !== false && !!opts.target() && !!opts.snapshot()
const send = (comment: ReviewComment) => {
const { promise, resolve: settle } = Promise.withResolvers<{ success: boolean; error?: string }>()
const target = opts.target()
const context = resolve(comment)
if (!target || !context || context.closed) {
settle({ success: false })
return promise
}
reviewRequest(
{
type: "agentManager.createReviewComment",
projectId: target.projectId,
worktreeId: target.worktreeId,
prNumber: context.prNumber,
prUrl: context.prUrl,
requestId: crypto.randomUUID(),
snapshotId: context.snapshotId,
path: comment.file,
side: side(comment.side),
startLine: comment.line,
endLine: comment.line,
body: comment.comment,
},
opts.post,
(result) => settle({ success: result.success, error: result.success ? undefined : result.error }),
)
return promise
}
return { available, resolve, send }
}
/**
* Post comments one at a time and stop at the first failure.
*
* A failed request can still have reached GitHub, so callers must keep the
* failed and unposted comments and surface the error instead of retrying.
*/
export async function postAllGithub(
comments: ReviewComment[],
github: CommentsGithub,
): Promise<{ posted: ReviewComment[]; failure?: string }> {
const posted: ReviewComment[] = []
for (const comment of comments) {
const result = await github.send(comment)
if (!result.success) return { posted, failure: result.error }
posted.push(comment)
}
return { posted }
}
@@ -0,0 +1,61 @@
import { normalizeHunk } from "@kilocode/kilo-ui/session-diff"
import type { PRDiffSnapshot } from "../../src/shared/pr-comment-actions"
import { parsePatch } from "../../src/shared/pr-patch"
import type { WorktreeFileDiff } from "../src/types/messages"
type Side = "LEFT" | "RIGHT"
function status(value: string): WorktreeFileDiff["status"] {
if (value === "added") return value
if (value === "deleted" || value === "removed") return "deleted"
return "modified"
}
function counts(patch: string) {
const total = { additions: 0, deletions: 0 }
let hunk = false
for (const line of patch.split("\n")) {
if (line.startsWith("@@")) {
hunk = true
continue
}
if (!hunk) continue
if (line.startsWith("+")) total.additions += 1
if (line.startsWith("-")) total.deletions += 1
}
return total
}
export function createPRDiffs(snapshot: PRDiffSnapshot): WorktreeFileDiff[] {
return snapshot.files.flatMap((file) => {
if (!file.patch) return []
const diff = normalizeHunk(file.path, file.patch)
if (!diff) return []
const total = counts(file.patch)
return [
{
file: diff.file,
before: diff.before,
after: diff.after,
patch: diff.patch,
additions: total.additions,
deletions: total.deletions,
status: status(file.status),
tracked: true,
stamp: snapshot.id,
},
]
})
}
export function canCommentOnPRLine(
snapshot: PRDiffSnapshot | undefined,
file: string,
side: Side,
start: number,
end: number,
): boolean {
const patch = snapshot?.files.find((item) => item.path === file)?.patch
if (!patch) return false
return parsePatch(patch, undefined, { side, start, end }) !== undefined
}
@@ -18,6 +18,22 @@ export interface AnnotationLabels {
delete: string
}
export interface CommentFormActions {
body: string
onBodyChange: (body: string) => void
onSave: (body: string, selectedText: string) => void
onSend: (body: string, selectedText: string) => void
onGithubSuccess: () => void
onCancel: () => void
onDestination: (value: "local" | "github") => void
}
export type CommentFormMount = (
host: HTMLElement,
meta: AnnotationMeta,
actions: CommentFormActions,
) => (() => void) | undefined
export function labels(t: (key: string, params?: UiI18nParams) => string): AnnotationLabels {
return {
commentOnLine: (line) => t("agentManager.review.commentOnLine", { line }),
@@ -44,6 +60,7 @@ export interface AnnotationMeta {
endLine?: number
editing?: boolean
text?: string
destination?: "local" | "github"
}
export type ReviewDraft = Pick<AnnotationMeta, "file" | "side" | "line" | "endLine">
@@ -91,6 +108,7 @@ export function reviewAnnotationSpeechKey(meta: AnnotationMeta): string | undefi
}
interface AnnotationHandlers {
track?: (meta: AnnotationMeta, host: HTMLElement, dispose: () => void) => void
diffs: WorktreeFileDiff[]
editing: string | null
setEditing: (id: string | null) => void
@@ -99,6 +117,10 @@ interface AnnotationHandlers {
updateComment: (id: string, text: string) => void
deleteComment: (id: string) => void
cancelDraft: () => void
completeRemoteDraft?: (meta: AnnotationMeta) => void
/** Remember the destination so the next comment keeps the same choice. */
onDestination?: (value: "local" | "github") => void
mount?: CommentFormMount
labels: AnnotationLabels
activeTerminalId: () => string | undefined
speech?: {
@@ -109,17 +131,23 @@ interface AnnotationHandlers {
}
}
function focusWhenConnected(el: HTMLTextAreaElement): void {
function focusWhenConnected(el: HTMLElement): () => void {
if (el.isConnected) {
el.focus()
return () => {}
}
let attempts = 0
let frame = 0
const tick = () => {
if (el.isConnected) {
el.focus()
return
}
attempts += 1
if (attempts < 20) requestAnimationFrame(tick)
if (attempts < 20) frame = requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}
// Keep composer text off the disposable annotation DOM without making each keystroke reactive.
@@ -245,6 +273,87 @@ export function buildReviewAnnotation(
if (meta.type === "draft") {
wrapper.className = "am-annotation am-annotation-draft"
if (handlers.mount) {
wrapper.dataset.mounted = "true"
const header = document.createElement("div")
header.className = "am-annotation-header"
header.textContent = handlers.labels.commentOnLine(meta.line)
wrapper.appendChild(header)
const host = document.createElement("div")
host.className = "am-annotation-form"
wrapper.appendChild(host)
let dispose: (() => void) | undefined
let unfocus: (() => void) | undefined
let speechField: HTMLTextAreaElement | undefined
const submit = () => {
// Speech-to-text confirms with the local action. GitHub publication stays
// on an explicit button click so a voice command cannot post by accident.
const kilo = host.querySelector<HTMLButtonElement>('[data-action="send-kilo"], [data-action="send"]')
if (kilo && !kilo.disabled) {
kilo.click()
return
}
const primary = host.querySelector<HTMLButtonElement>('[data-action="send-primary"]')
if (primary && primary.dataset.destination !== "github" && !primary.disabled) {
primary.click()
return
}
const fallback = host.querySelector<HTMLButtonElement>('[data-action="submit"]')
if (fallback && !fallback.disabled) fallback.click()
}
// Keep focus and speech-to-text attached to the mounted form's editor.
const afterMount = () => {
const field = host.querySelector<HTMLTextAreaElement>("textarea")
if (!field) return
unfocus?.()
unfocus = focusWhenConnected(field)
if (handlers.speech && field !== speechField) {
speechField = field
field.addEventListener("keydown", (event) => {
if (!handlers.speech?.down(meta, event, submit)) return
event.preventDefault()
event.stopPropagation()
})
field.addEventListener("keyup", (event) => {
if (!handlers.speech?.up(meta, event)) return
event.preventDefault()
event.stopPropagation()
})
}
if (!handlers.speech) return
const row = host.querySelector('[data-slot="comment-actions"]')
const speechHost = handlers.speech.render(meta, field)
if (speechHost && row) row.prepend(speechHost)
}
dispose = handlers.mount(host, meta, {
body: meta.text ?? "",
onBodyChange: (body) => {
meta.text = body
},
onSave: (body, selected) => handlers.addComment(meta.file, meta.side, meta.line, body.trim(), selected),
onSend: (body, selected) => handlers.sendComment(meta.file, meta.side, meta.line, body.trim(), selected),
onGithubSuccess: () => handlers.completeRemoteDraft?.(meta),
onCancel: handlers.cancelDraft,
onDestination: (value) => {
meta.destination = value
handlers.onDestination?.(value)
},
})
afterMount()
handlers.track?.(meta, wrapper, () => {
unfocus?.()
dispose?.()
dispose = undefined
})
return wrapper
}
// Fallback native composer for surfaces without a mounted form (for example the document panel).
const header = document.createElement("div")
header.className = "am-annotation-header"
header.textContent = handlers.labels.commentOnLine(meta.line)
@@ -351,6 +460,11 @@ export function buildReviewAnnotation(
return wrapper
}
return buildSavedAnnotation(meta, handlers)
}
function buildSavedAnnotation(meta: AnnotationMeta, handlers: AnnotationHandlers): HTMLElement {
const wrapper = document.createElement("div")
const comment = meta.comment!
if (meta.editing) {
wrapper.className = "am-annotation am-annotation-draft"
@@ -1,4 +1,14 @@
import { createEffect, createMemo, createRenderEffect, createSignal, on, untrack, type Accessor } from "solid-js"
import {
createEffect,
createMemo,
createRenderEffect,
createSignal,
on,
onCleanup,
untrack,
type Accessor,
} from "solid-js"
import { createAnnotationLifecycle } from "./annotation-lifecycle"
import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs"
import type { UiI18nParams } from "@kilocode/kilo-ui/context"
import type { DiffHandle } from "@kilocode/kilo-ui/pierre"
@@ -20,6 +30,7 @@ import {
sendReviewComments,
labels,
type AnnotationMeta,
type CommentFormMount,
type ReviewComposer,
} from "./review-annotations"
import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech"
@@ -29,6 +40,7 @@ import { createReviewOpenState } from "./review-state"
import { createReviewScrollPreserver } from "./review-scroll"
import { createDiffRows } from "./diff-state"
import { createDiffRequests } from "./diff-requests"
import { postAllGithub, type CommentsGithub } from "./comments-github"
import { treeOrder } from "./file-tree-utils"
import { isDiffExpandable, shouldVirtualizeDiff } from "./diff-open-policy"
import { isMarkdownFile } from "./MarkdownDiffView"
@@ -49,9 +61,14 @@ type Props = {
canComment?: Accessor<boolean>
onSendClick?: () => void
onSendAll?: () => void
commentForm?: Accessor<CommentFormMount | undefined>
commentsGithub?: CommentsGithub
}
export function createReviewController(props: Props) {
const lifecycle = createAnnotationLifecycle()
onCleanup(lifecycle.clear)
const [preferredDestination, setPreferredDestination] = createSignal<"local" | "github">("local")
const active = props.active ?? (() => true)
const canComment = props.canComment ?? (() => true)
const [draft, setDraft] = createSignal(reviewComposerDraft(props.composer()))
@@ -98,6 +115,7 @@ export function createReviewController(props: Props) {
props.key,
() => {
if (!active()) return
lifecycle.clear()
setDraft(null)
draftMeta = null
setEditing(null)
@@ -226,6 +244,11 @@ export function createReviewController(props: Props) {
if (id === null) props.focus()
}
const completeRemoteDraft = (meta: AnnotationMeta) => {
if (draftMeta !== meta) return
cancelDraft()
}
const annotationsForFile = (file: string): DiffLineAnnotation<AnnotationMeta>[] => {
const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta)
draftMeta = result.draftMeta
@@ -239,6 +262,7 @@ export function createReviewController(props: Props) {
const buildAnnotation = (annotation: DiffLineAnnotation<AnnotationMeta>): HTMLElement | undefined =>
buildReviewAnnotation(annotation, {
track: lifecycle.track,
diffs: props.diffs(),
editing: editing(),
setEditing: setEditState,
@@ -247,6 +271,9 @@ export function createReviewController(props: Props) {
updateComment,
deleteComment,
cancelDraft,
completeRemoteDraft,
onDestination: setPreferredDestination,
mount: props.commentForm?.(),
labels: labels(props.label),
activeTerminalId: props.activeTerminalId,
speech,
@@ -255,9 +282,10 @@ export function createReviewController(props: Props) {
const handleGutterClick = (file: string, range: SelectedLineRange) => {
if (!canComment() || draft()) return
const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions"
const destination = preferredDestination()
props.preserveScroll(() => {
const next = { file, side, line: range.start, endLine: range.end }
draftMeta = { type: "draft", comment: null, ...next }
draftMeta = { type: "draft", comment: null, ...next, destination }
props.composer().draft = draftMeta
setDraft(next)
})
@@ -271,6 +299,44 @@ export function createReviewController(props: Props) {
props.onSendAll?.()
}
const [sendAllPending, setSendAllPending] = createSignal(false)
const [sendAllError, setSendAllError] = createSignal<string>()
const githubComments = () => {
const github = props.commentsGithub
if (!github) return []
return props.comments().filter((comment) => {
const context = github.resolve(comment)
return !!context && !context.closed
})
}
const sendAllGithubCount = () => githubComments().length
const sendAllGithubAvailable = () => sendAllGithubCount() > 0
const sendAllToGithub = async () => {
const github = props.commentsGithub
if (!github || sendAllPending()) return
const pending = githubComments()
if (pending.length === 0) return
props.onSendClick?.()
setSendAllPending(true)
setSendAllError(undefined)
const { posted, failure } = await postAllGithub(pending, github)
if (posted.length > 0) {
const ids = new Set(posted.map((comment) => comment.id))
props.preserveScroll(() => props.setComments(props.comments().filter((comment) => !ids.has(comment.id))))
}
setSendAllPending(false)
if (failure !== undefined || posted.length < pending.length) {
setSendAllError(
props.label("agentManager.review.sendAllToGithubFailed", {
error: failure || props.label("common.requestFailed"),
}),
)
}
}
const sendAllClick = () => {
props.onSendClick?.()
sendAllToChat()
@@ -289,7 +355,12 @@ export function createReviewController(props: Props) {
setEditState,
handleGutterClick,
sendAllToChat,
sendAllToGithub,
sendAllClick,
sendAllGithubCount,
sendAllGithubAvailable,
sendAllPending,
sendAllError,
}
}
@@ -314,9 +385,20 @@ export interface ReviewViewProps {
onRequestDiff?: (file: string) => void
onOpenFile?: (file: string, line?: number) => void
canComment?: boolean
commentForm?: CommentFormMount
commentsGithub?: CommentsGithub
}
export function createReviewView(props: ReviewViewProps, root: Accessor<HTMLDivElement | undefined>) {
interface ReviewViewOverrides {
commentForm?: CommentFormMount
commentsGithub?: CommentsGithub
}
export function createReviewView(
props: ReviewViewProps,
root: Accessor<HTMLDivElement | undefined>,
overrides?: ReviewViewOverrides,
) {
const { t } = useLanguage()
const vscode = useVSCode()
const local = createReviewComposer()
@@ -384,6 +466,8 @@ export function createReviewView(props: ReviewViewProps, root: Accessor<HTMLDivE
canComment: () => props.canComment !== false,
onSendClick: props.onSendClick,
onSendAll: props.onSendAll,
commentForm: () => overrides?.commentForm ?? props.commentForm,
commentsGithub: overrides?.commentsGithub ?? props.commentsGithub,
})
const pinned = createMemo(() => {
const keep = new Set(review.pinned())
@@ -455,5 +539,10 @@ export function createReviewView(props: ReviewViewProps, root: Accessor<HTMLDivE
commentsByFile: review.commentsByFile,
handleGutterClick: review.handleGutterClick,
sendAllClick: review.sendAllClick,
sendAllToGithub: review.sendAllToGithub,
sendAllGithubCount: review.sendAllGithubCount,
sendAllGithubAvailable: review.sendAllGithubAvailable,
sendAllPending: review.sendAllPending,
sendAllError: review.sendAllError,
}
}
@@ -0,0 +1,33 @@
import { useLanguage } from "../src/context/language"
import { createDiffCommentForms } from "../agent-manager/pr/diff-comment-forms"
import { createReviewView, type ReviewViewProps } from "./review-controller"
import { notice, reviewSendAllKeybind } from "./review-setup"
import type { PRDiffSnapshot, PRTarget } from "../../src/shared/pr-comment-actions"
interface SurfaceProps extends ReviewViewProps {
notice?: string
sessionId?: string
prTarget?: PRTarget
prSnapshot?: PRDiffSnapshot
}
/** Shared wiring for the inline and full-screen diff review surfaces. */
export function createReviewSurface(props: SurfaceProps, root: () => HTMLDivElement | undefined) {
const { t } = useLanguage()
const forms = createDiffCommentForms({
target: () => props.prTarget,
snapshot: () => props.prSnapshot,
diffs: () => props.diffs,
worktree: () => props.worktreeId ?? props.sessionId ?? "diff",
})
const view = createReviewView(props, root, {
commentForm: props.commentForm ?? forms.mount,
commentsGithub: props.commentsGithub ?? forms.github,
})
return {
t,
noticeText: () => notice(t, props.notice),
sendAllKeybind: () => reviewSendAllKeybind(t),
...view,
}
}
+12
View File
@@ -1276,6 +1276,18 @@ export const dict = {
"الملفات التي غيّرها Kilo خلال الجلسة الحالية، بناءً على لقطات لكل دور. يُعاد ضبطها عند بدء جلسة جديدة.",
"diffViewer.group.session": "الجلسة",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "حفظ محليًا",
"diffViewer.comment.sendToAgent": "إرسال إلى الوكيل",
"diffViewer.comment.postToGithub": "نشر على GitHub",
"diffViewer.comment.loadFailed": "تعذر تحميل تغييرات طلب السحب.",
"diffViewer.comment.unavailable": "هذا السطر غير متاح في اللقطة الحالية لطلب السحب.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "فتح طلب السحب",
"diffViewer.comment.localChanges": "التغييرات المحلية",
"diffViewer.comment.prChanges": "تغييرات PR",
"diffViewer.comment.sendToKilo": "إرسال إلى Kilo",
"diffViewer.comment.sendToGithub": "إرسال إلى GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "اختيار الوجهة",
"diffViewer.notice.snapshotsDisabled":
"اللقطات معطّلة لهذا المستودع. يُرجى تعديل ملفات الإعدادات لعرض تغييرات الجلسة.",
+12
View File
@@ -1321,6 +1321,18 @@ export const dict = {
"Arquivos modificados pelo Kilo durante a sessão atual, com base em snapshots por turno. Reinicia ao começar uma nova sessão.",
"diffViewer.group.session": "Sessão",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Salvar localmente",
"diffViewer.comment.sendToAgent": "Enviar para o agente",
"diffViewer.comment.postToGithub": "Publicar no GitHub",
"diffViewer.comment.loadFailed": "Não foi possível carregar as alterações da solicitação de extração.",
"diffViewer.comment.unavailable": "Esta linha não está disponível no snapshot atual da solicitação de extração.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Abrir PR",
"diffViewer.comment.localChanges": "Alterações locais",
"diffViewer.comment.prChanges": "Alterações do PR",
"diffViewer.comment.sendToKilo": "Enviar para o Kilo",
"diffViewer.comment.sendToGithub": "Enviar para o GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Escolher destino",
"diffViewer.notice.snapshotsDisabled":
"Os snapshots estão desativados para este repositório. Edite seus arquivos de configuração para exibir as alterações da sessão.",
+12
View File
@@ -1312,6 +1312,18 @@ export const dict = {
"Datoteke koje je Kilo promijenio tokom trenutne sesije, na osnovu snapshota po koraku. Resetuje se kada pokrenete novu sesiju.",
"diffViewer.group.session": "Sesija",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Sačuvaj lokalno",
"diffViewer.comment.sendToAgent": "Pošalji agentu",
"diffViewer.comment.postToGithub": "Objavi na GitHubu",
"diffViewer.comment.loadFailed": "Nije moguće učitati izmjene zahtjeva za povlačenje.",
"diffViewer.comment.unavailable": "Ovaj red nije dostupan u trenutnom snimku zahtjeva za povlačenje.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Otvori PR",
"diffViewer.comment.localChanges": "Lokalne izmjene",
"diffViewer.comment.prChanges": "PR izmjene",
"diffViewer.comment.sendToKilo": "Pošalji Kilu",
"diffViewer.comment.sendToGithub": "Pošalji na GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Odaberi odredište",
"diffViewer.notice.snapshotsDisabled":
"Snapshotovi su onemogućeni za ovaj repozitorij. Uredite konfiguracijske datoteke da biste prikazali promjene sesije.",
+12
View File
@@ -1306,6 +1306,18 @@ export const dict = {
"Filer ændret af Kilo i den aktuelle session, baseret på snapshots pr. tur. Nulstilles, når du starter en ny session.",
"diffViewer.group.session": "Session",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Gem lokalt",
"diffViewer.comment.sendToAgent": "Send til agent",
"diffViewer.comment.postToGithub": "Udgiv på GitHub",
"diffViewer.comment.loadFailed": "Kunne ikke indlæse ændringerne i pull requesten.",
"diffViewer.comment.unavailable": "Denne linje er ikke tilgængelig i det aktuelle snapshot af pull requesten.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Åbn PR",
"diffViewer.comment.localChanges": "Lokale ændringer",
"diffViewer.comment.prChanges": "PR-ændringer",
"diffViewer.comment.sendToKilo": "Send til Kilo",
"diffViewer.comment.sendToGithub": "Send til GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Vælg destination",
"diffViewer.notice.snapshotsDisabled":
"Snapshots er deaktiveret for dette repository. Rediger dine konfigurationsfiler for at vise sessionens ændringer.",
@@ -1334,6 +1334,18 @@ export const dict = {
"Von Kilo während der aktuellen Sitzung geänderte Dateien, basierend auf Snapshots pro Runde. Wird beim Start einer neuen Sitzung zurückgesetzt.",
"diffViewer.group.session": "Sitzung",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Lokal speichern",
"diffViewer.comment.sendToAgent": "An Agent senden",
"diffViewer.comment.postToGithub": "Auf GitHub veröffentlichen",
"diffViewer.comment.loadFailed": "Die Änderungen des Pull Requests konnten nicht geladen werden.",
"diffViewer.comment.unavailable": "Diese Zeile ist im aktuellen Snapshot des Pull Requests nicht verfügbar.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Pull Request öffnen",
"diffViewer.comment.localChanges": "Lokale Änderungen",
"diffViewer.comment.prChanges": "PR-Änderungen",
"diffViewer.comment.sendToKilo": "An Kilo senden",
"diffViewer.comment.sendToGithub": "An GitHub #{{number}} senden",
"diffViewer.comment.chooseDestination": "Ziel auswählen",
"diffViewer.notice.snapshotsDisabled":
"Snapshots sind für dieses Repository deaktiviert. Bitte bearbeite deine Konfigurationsdateien, um die Sitzungsänderungen anzuzeigen.",
@@ -1293,6 +1293,18 @@ export const dict = {
"diffViewer.group.git": "Git",
"diffViewer.notice.snapshotsDisabled":
"Snapshots are disabled for this repository. Please edit your configuration files in order to display session changes.",
"diffViewer.comment.saveLocal": "Save local",
"diffViewer.comment.sendToAgent": "Send to agent",
"diffViewer.comment.postToGithub": "Post to GitHub",
"diffViewer.comment.loadFailed": "Could not load the pull request changes.",
"diffViewer.comment.unavailable": "This line is not available in the current pull request snapshot.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Open pull request",
"diffViewer.comment.localChanges": "Local changes",
"diffViewer.comment.prChanges": "PR changes",
"diffViewer.comment.sendToKilo": "Send to Kilo",
"diffViewer.comment.sendToGithub": "Send to GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Choose destination",
"diffViewer.baseBranch.auto": "Default",
"diffViewer.baseBranch.default": "Default",
+12
View File
@@ -1322,6 +1322,18 @@ export const dict = {
"Archivos modificados por Kilo durante la sesión actual, basado en snapshots por turno. Se reinicia al empezar una nueva sesión.",
"diffViewer.group.session": "Sesión",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Guardar localmente",
"diffViewer.comment.sendToAgent": "Enviar al agente",
"diffViewer.comment.postToGithub": "Publicar en GitHub",
"diffViewer.comment.loadFailed": "No se pudieron cargar los cambios del pull request.",
"diffViewer.comment.unavailable": "Esta línea no está disponible en la instantánea actual del pull request.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Abrir pull request",
"diffViewer.comment.localChanges": "Cambios locales",
"diffViewer.comment.prChanges": "Cambios del PR",
"diffViewer.comment.sendToKilo": "Enviar a Kilo",
"diffViewer.comment.sendToGithub": "Enviar a GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Elegir destino",
"diffViewer.notice.snapshotsDisabled":
"Las instantáneas están deshabilitadas para este repositorio. Edita tus archivos de configuración para mostrar los cambios de la sesión.",
+12
View File
@@ -1305,6 +1305,18 @@ export const dict = {
"فایل‌هایی که توسط Kilo در جلسه جاری تغییر کرده‌اند، بر اساس عکس‌های فوری هر نوبت. با شروع جلسه جدید بازنشانی می‌شود.",
"diffViewer.group.session": "جلسه",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "ذخیرهٔ محلی",
"diffViewer.comment.sendToAgent": "ارسال به عامل",
"diffViewer.comment.postToGithub": "انتشار در GitHub",
"diffViewer.comment.loadFailed": "بارگذاری تغییرات درخواست ادغام ممکن نشد.",
"diffViewer.comment.unavailable": "این خط در تصویر لحظه‌ای فعلی درخواست ادغام موجود نیست.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "باز کردن درخواست ادغام",
"diffViewer.comment.localChanges": "تغییرات محلی",
"diffViewer.comment.prChanges": "تغییرات PR",
"diffViewer.comment.sendToKilo": "ارسال به Kilo",
"diffViewer.comment.sendToGithub": "ارسال به GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "انتخاب مقصد",
"diffViewer.notice.snapshotsDisabled":
"عکس‌های فوری برای این مخزن غیرفعال هستند. لطفاً فایل‌های پیکربندی خود را ویرایش کنید تا تغییرات جلسه نمایش داده شوند.",
+12
View File
@@ -1343,6 +1343,18 @@ export const dict = {
"Fichiers modifiés par Kilo pendant la session actuelle, basé sur des snapshots par tour. Réinitialisé lors du démarrage d'une nouvelle session.",
"diffViewer.group.session": "Session",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Enregistrer localement",
"diffViewer.comment.sendToAgent": "Envoyer à lagent",
"diffViewer.comment.postToGithub": "Publier sur GitHub",
"diffViewer.comment.loadFailed": "Impossible de charger les modifications de la pull request.",
"diffViewer.comment.unavailable": "Cette ligne nest pas disponible dans linstantané actuel de la pull request.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Ouvrir la pull request",
"diffViewer.comment.localChanges": "Modifications locales",
"diffViewer.comment.prChanges": "Modifications du PR",
"diffViewer.comment.sendToKilo": "Envoyer à Kilo",
"diffViewer.comment.sendToGithub": "Envoyer à GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Choisir la destination",
"diffViewer.notice.snapshotsDisabled":
"Les instantanés sont désactivés pour ce dépôt. Veuillez modifier vos fichiers de configuration pour afficher les changements de la session.",
+12
View File
@@ -1187,6 +1187,18 @@ export const dict = {
"File modificati da Kilo durante la sessione corrente, basati su snapshot per turno. Si resetta quando inizi una nuova sessione.",
"diffViewer.group.session": "Sessione",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Salva in locale",
"diffViewer.comment.sendToAgent": "Invia all'agente",
"diffViewer.comment.postToGithub": "Pubblica su GitHub",
"diffViewer.comment.loadFailed": "Impossibile caricare le modifiche della pull request.",
"diffViewer.comment.unavailable": "Questa riga non è disponibile nell'istantanea attuale della pull request.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Apri pull request",
"diffViewer.comment.localChanges": "Modifiche locali",
"diffViewer.comment.prChanges": "Modifiche della PR",
"diffViewer.comment.sendToKilo": "Invia a Kilo",
"diffViewer.comment.sendToGithub": "Invia a GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Scegli destinazione",
"diffViewer.notice.snapshotsDisabled":
"Gli snapshot sono disabilitati per questa repository. Modifica i file di configurazione per visualizzare le modifiche della sessione.",
"diffViewer.baseBranch.auto": "Predefinito",
+12
View File
@@ -1298,6 +1298,18 @@ export const dict = {
"現在のセッション中に Kilo が変更したファイル。ターンごとのスナップショットに基づきます。新しいセッションを開始するとリセットされます。",
"diffViewer.group.session": "セッション",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "ローカルに保存",
"diffViewer.comment.sendToAgent": "エージェントに送信",
"diffViewer.comment.postToGithub": "GitHubに投稿",
"diffViewer.comment.loadFailed": "プルリクエストの変更を読み込めませんでした。",
"diffViewer.comment.unavailable": "この行は現在のプルリクエストのスナップショットでは利用できません。",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "プルリクエストを開く",
"diffViewer.comment.localChanges": "ローカルの変更",
"diffViewer.comment.prChanges": "PRの変更",
"diffViewer.comment.sendToKilo": "Kiloに送信",
"diffViewer.comment.sendToGithub": "GitHub #{{number}}に送信",
"diffViewer.comment.chooseDestination": "送信先を選択",
"diffViewer.notice.snapshotsDisabled":
"このリポジトリではスナップショットが無効になっています。セッションの変更を表示するには、構成ファイルを編集してください。",
+12
View File
@@ -1285,6 +1285,18 @@ export const dict = {
"현재 세션 동안 Kilo가 변경한 파일로, 턴별 스냅샷을 기반으로 합니다. 새 세션을 시작하면 초기화됩니다.",
"diffViewer.group.session": "세션",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "로컬에 저장",
"diffViewer.comment.sendToAgent": "에이전트로 보내기",
"diffViewer.comment.postToGithub": "GitHub에 게시",
"diffViewer.comment.loadFailed": "풀 리퀘스트 변경 사항을 불러올 수 없습니다.",
"diffViewer.comment.unavailable": "이 줄은 현재 풀 리퀘스트 스냅샷에서 사용할 수 없습니다.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "풀 리퀘스트 열기",
"diffViewer.comment.localChanges": "로컬 변경 사항",
"diffViewer.comment.prChanges": "PR 변경 사항",
"diffViewer.comment.sendToKilo": "Kilo로 보내기",
"diffViewer.comment.sendToGithub": "GitHub #{{number}}로 보내기",
"diffViewer.comment.chooseDestination": "대상 선택",
"diffViewer.notice.snapshotsDisabled":
"이 리포지토리에서 스냅샷이 비활성화되어 있습니다. 세션 변경 사항을 표시하려면 구성 파일을 편집하세요.",
+12
View File
@@ -1334,6 +1334,18 @@ export const dict = {
"Bestanden die door Kilo tijdens de huidige sessie zijn gewijzigd, gebaseerd op snapshots per beurt. Wordt gereset bij het starten van een nieuwe sessie.",
"diffViewer.group.session": "Sessie",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Lokaal opslaan",
"diffViewer.comment.sendToAgent": "Naar agent sturen",
"diffViewer.comment.postToGithub": "Op GitHub plaatsen",
"diffViewer.comment.loadFailed": "De wijzigingen van de pull request konden niet worden geladen.",
"diffViewer.comment.unavailable": "Deze regel is niet beschikbaar in de huidige snapshot van de pull request.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Pull request openen",
"diffViewer.comment.localChanges": "Lokale wijzigingen",
"diffViewer.comment.prChanges": "PR-wijzigingen",
"diffViewer.comment.sendToKilo": "Naar Kilo sturen",
"diffViewer.comment.sendToGithub": "Naar GitHub #{{number}} sturen",
"diffViewer.comment.chooseDestination": "Bestemming kiezen",
"diffViewer.notice.snapshotsDisabled":
"Snapshots zijn uitgeschakeld voor deze repository. Bewerk je configuratiebestanden om de sessiewijzigingen weer te geven.",
+13
View File
@@ -1302,6 +1302,19 @@ export const dict = {
"Filer endret av Kilo i løpet av gjeldende økt, basert på øyeblikksbilder per tur. Tilbakestilles når du starter en ny økt.",
"diffViewer.group.session": "Økt",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Lagre lokalt",
"diffViewer.comment.sendToAgent": "Send til agent",
"diffViewer.comment.postToGithub": "Publiser på GitHub",
"diffViewer.comment.loadFailed": "Kunne ikke laste inn endringene i pull requesten.",
"diffViewer.comment.unavailable":
"Denne linjen er ikke tilgjengelig i det gjeldende øyeblikksbildet av pull requesten.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Åpne pull request",
"diffViewer.comment.localChanges": "Lokale endringer",
"diffViewer.comment.prChanges": "PR-endringer",
"diffViewer.comment.sendToKilo": "Send til Kilo",
"diffViewer.comment.sendToGithub": "Send til GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Velg mål",
"diffViewer.notice.snapshotsDisabled":
"Snapshots er deaktivert for dette repositoriet. Rediger konfigurasjonsfilene for å vise øktens endringer.",
+12
View File
@@ -1311,6 +1311,18 @@ export const dict = {
"Pliki zmienione przez Kilo w trakcie bieżącej sesji, na podstawie snapshotów na turę. Resetowane przy rozpoczęciu nowej sesji.",
"diffViewer.group.session": "Sesja",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Zapisz lokalnie",
"diffViewer.comment.sendToAgent": "Wyślij do agenta",
"diffViewer.comment.postToGithub": "Opublikuj na GitHubie",
"diffViewer.comment.loadFailed": "Nie udało się wczytać zmian pull requesta.",
"diffViewer.comment.unavailable": "Ten wiersz nie jest dostępny w bieżącej migawce pull requesta.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Otwórz pull request",
"diffViewer.comment.localChanges": "Zmiany lokalne",
"diffViewer.comment.prChanges": "Zmiany PR",
"diffViewer.comment.sendToKilo": "Wyślij do Kilo",
"diffViewer.comment.sendToGithub": "Wyślij do GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Wybierz miejsce docelowe",
"diffViewer.notice.snapshotsDisabled":
"Migawki są wyłączone dla tego repozytorium. Edytuj pliki konfiguracyjne, aby wyświetlać zmiany sesji.",
+12
View File
@@ -1305,6 +1305,18 @@ export const dict = {
"Файлы, изменённые Kilo в текущей сессии, на основе снимков по ходу. Сбрасывается при начале новой сессии.",
"diffViewer.group.session": "Сессия",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Сохранить локально",
"diffViewer.comment.sendToAgent": "Отправить агенту",
"diffViewer.comment.postToGithub": "Опубликовать на GitHub",
"diffViewer.comment.loadFailed": "Не удалось загрузить изменения запроса на слияние.",
"diffViewer.comment.unavailable": "Эта строка недоступна в текущем снимке запроса на слияние.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Открыть PR",
"diffViewer.comment.localChanges": "Локальные изменения",
"diffViewer.comment.prChanges": "Изменения PR",
"diffViewer.comment.sendToKilo": "Отправить в Kilo",
"diffViewer.comment.sendToGithub": "Отправить в GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Выбрать назначение",
"diffViewer.notice.snapshotsDisabled":
"Снимки отключены для этого репозитория. Пожалуйста, отредактируйте файлы конфигурации, чтобы отображать изменения сессии.",
+12
View File
@@ -1282,6 +1282,18 @@ export const dict = {
"ไฟล์ที่ Kilo แก้ไขในช่วงเซสชันปัจจุบัน โดยอิงจากสแน็ปช็อตต่อเทิร์น จะรีเซ็ตเมื่อเริ่มเซสชันใหม่",
"diffViewer.group.session": "เซสชัน",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "บันทึกในเครื่อง",
"diffViewer.comment.sendToAgent": "ส่งไปยังเอเจนต์",
"diffViewer.comment.postToGithub": "โพสต์ไปยัง GitHub",
"diffViewer.comment.loadFailed": "ไม่สามารถโหลดการเปลี่ยนแปลงของคำขอรวมโค้ดได้",
"diffViewer.comment.unavailable": "บรรทัดนี้ไม่มีอยู่ในสแนปช็อตปัจจุบันของคำขอรวมโค้ด",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "เปิด Pull Request",
"diffViewer.comment.localChanges": "การเปลี่ยนแปลงในเครื่อง",
"diffViewer.comment.prChanges": "การเปลี่ยนแปลงของ PR",
"diffViewer.comment.sendToKilo": "ส่งไปยัง Kilo",
"diffViewer.comment.sendToGithub": "ส่งไปยัง GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "เลือกปลายทาง",
"diffViewer.notice.snapshotsDisabled":
"ปิดใช้งานสแนปช็อตสำหรับที่เก็บนี้ กรุณาแก้ไขไฟล์การกำหนดค่าเพื่อแสดงการเปลี่ยนแปลงของเซสชัน",
+12
View File
@@ -1321,6 +1321,18 @@ export const dict = {
"Geçerli oturum sırasında Kilo tarafından değiştirilen dosyalar, tur başı anlık görüntülere dayanır. Yeni bir oturum başlatıldığında sıfırlanır.",
"diffViewer.group.session": "Oturum",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Yerel olarak kaydet",
"diffViewer.comment.sendToAgent": "Ajana gönder",
"diffViewer.comment.postToGithub": "GitHub'da paylaş",
"diffViewer.comment.loadFailed": "Çekme isteğindeki değişiklikler yüklenemedi.",
"diffViewer.comment.unavailable": "Bu satır, çekme isteğinin mevcut anlık görüntüsünde bulunmuyor.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Pull request'i aç",
"diffViewer.comment.localChanges": "Yerel değişiklikler",
"diffViewer.comment.prChanges": "PR değişiklikleri",
"diffViewer.comment.sendToKilo": "Kilo'ya gönder",
"diffViewer.comment.sendToGithub": "GitHub #{{number}} hedefine gönder",
"diffViewer.comment.chooseDestination": "Hedef seç",
"diffViewer.notice.snapshotsDisabled":
"Bu depoda anlık görüntüler devre dışı bırakılmıştır. Oturum değişikliklerini görüntülemek için yapılandırma dosyalarınızı düzenleyin.",
+12
View File
@@ -1321,6 +1321,18 @@ export const dict = {
"Файли, змінені Kilo під час поточної сесії, на основі знімків по ходу. Скидається при старті нової сесії.",
"diffViewer.group.session": "Сесія",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "Зберегти локально",
"diffViewer.comment.sendToAgent": "Надіслати агенту",
"diffViewer.comment.postToGithub": "Опублікувати на GitHub",
"diffViewer.comment.loadFailed": "Не вдалося завантажити зміни пул-реквесту.",
"diffViewer.comment.unavailable": "Цей рядок недоступний у поточному знімку пул-реквесту.",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "Відкрити PR",
"diffViewer.comment.localChanges": "Локальні зміни",
"diffViewer.comment.prChanges": "Зміни PR",
"diffViewer.comment.sendToKilo": "Надіслати до Kilo",
"diffViewer.comment.sendToGithub": "Надіслати до GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "Вибрати призначення",
"diffViewer.notice.snapshotsDisabled":
"Знімки вимкнено для цього репозиторію. Будь ласка, відредагуйте файли конфігурації, щоб відображати зміни сесії.",
+12
View File
@@ -1236,6 +1236,18 @@ export const dict = {
"diffViewer.source.session.tooltip": "Kilo 在当前会话中更改的文件,基于每轮快照。开始新会话时重置。",
"diffViewer.group.session": "会话",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "保存到本地",
"diffViewer.comment.sendToAgent": "发送给智能体",
"diffViewer.comment.postToGithub": "发布到 GitHub",
"diffViewer.comment.loadFailed": "无法加载拉取请求的更改。",
"diffViewer.comment.unavailable": "此行在当前拉取请求快照中不可用。",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "打开拉取请求",
"diffViewer.comment.localChanges": "本地更改",
"diffViewer.comment.prChanges": "PR 更改",
"diffViewer.comment.sendToKilo": "发送到 Kilo",
"diffViewer.comment.sendToGithub": "发送到 GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "选择目标",
"diffViewer.notice.snapshotsDisabled": "此仓库的快照已禁用。请编辑配置文件以显示会话变更。",
"diffViewer.baseBranch.auto": "默认",
+12
View File
@@ -1240,6 +1240,18 @@ export const dict = {
"diffViewer.source.session.tooltip": "Kilo 在目前工作階段中變更的檔案,依據每輪快照。開始新工作階段時重置。",
"diffViewer.group.session": "工作階段",
"diffViewer.group.git": "Git",
"diffViewer.comment.saveLocal": "儲存至本機",
"diffViewer.comment.sendToAgent": "傳送給代理程式",
"diffViewer.comment.postToGithub": "發佈到 GitHub",
"diffViewer.comment.loadFailed": "無法載入提取請求的變更。",
"diffViewer.comment.unavailable": "此行在目前的提取請求快照中無法使用。",
"diffViewer.comment.prContext": "PR #{{number}}",
"diffViewer.comment.openPR": "開啟提取請求",
"diffViewer.comment.localChanges": "本機變更",
"diffViewer.comment.prChanges": "PR 變更",
"diffViewer.comment.sendToKilo": "傳送到 Kilo",
"diffViewer.comment.sendToGithub": "傳送到 GitHub #{{number}}",
"diffViewer.comment.chooseDestination": "選擇目標",
"diffViewer.notice.snapshotsDisabled": "此存放庫的快照已停用。請編輯設定檔以顯示工作階段的變更。",
"diffViewer.baseBranch.auto": "預設",
@@ -172,6 +172,24 @@
flex-shrink: 1;
}
.diff-pr-controls {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.diff-pr-context {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-weak);
font-size: var(--kilo-font-size-11);
font-variant-numeric: tabular-nums;
}
/* ============================================
Inline diff toolbar controls (scope, base, diff style)
============================================