diff --git a/.changeset/pr-comments-from-changes.md b/.changeset/pr-comments-from-changes.md new file mode 100644 index 000000000000..3ef64c5ce98e --- /dev/null +++ b/.changeset/pr-comments-from-changes.md @@ -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. diff --git a/packages/kilo-vscode/src/agent-manager/pr/review-actions.ts b/packages/kilo-vscode/src/agent-manager/pr/review-actions.ts index c33ec95e1eea..e75c0177079a 100644 --- a/packages/kilo-vscode/src/agent-manager/pr/review-actions.ts +++ b/packages/kilo-vscode/src/agent-manager/pr/review-actions.ts @@ -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) { 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.") diff --git a/packages/kilo-vscode/src/agent-manager/pr/review-context.ts b/packages/kilo-vscode/src/agent-manager/pr/review-context.ts index 2c613515ade3..74a36bb72471 100644 --- a/packages/kilo-vscode/src/agent-manager/pr/review-context.ts +++ b/packages/kilo-vscode/src/agent-manager/pr/review-context.ts @@ -18,4 +18,5 @@ export interface PRReviewHost { conflicts?: (context: PRReviewContext, base: string, head: string) => Promise getPRMergeMethod?: (repo: string) => PRMergeMethod | undefined savePRMergeMethod?: (repo: string, method: PRMergeMethod) => Promise + checkBranch?: (directory: string) => Promise } diff --git a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts index daf06bd8a6bb..e68c33a18cf8 100644 --- a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts @@ -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 + 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): 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): 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")), diff --git a/packages/kilo-vscode/src/shared/pr-comment-actions.ts b/packages/kilo-vscode/src/shared/pr-comment-actions.ts index d7de0a23a797..a34f0e59ac54 100644 --- a/packages/kilo-vscode/src/shared/pr-comment-actions.ts +++ b/packages/kilo-vscode/src/shared/pr-comment-actions.ts @@ -3,6 +3,8 @@ export interface PRTarget { worktreeId: string prNumber: number prUrl: string + baseRefOid?: string + headRefOid?: string } export interface PRFile { diff --git a/packages/kilo-vscode/src/shared/pr-patch.ts b/packages/kilo-vscode/src/shared/pr-patch.ts index 02ea588fac4c..45bc4239f8f5 100644 --- a/packages/kilo-vscode/src/shared/pr-patch.ts +++ b/packages/kilo-vscode/src/shared/pr-patch.ts @@ -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 diff --git a/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts index 35889bcb2673..4d6bf0b58214 100644 --- a/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts +++ b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts @@ -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 }> = [] diff --git a/packages/kilo-vscode/tests/fixtures/diff-comment-form.tsx b/packages/kilo-vscode/tests/fixtures/diff-comment-form.tsx new file mode 100644 index 000000000000..a74c56b4e8e6 --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/diff-comment-form.tsx @@ -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() +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(() => ( + <> +
+ saved.push(body)} + onSendKilo={(body) => sent.push(body)} + onGithubSuccess={() => completed++} + onCancel={() => cancelled++} + onDestinationChange={() => {}} + /> +
+
+ {}} + onSendKilo={() => {}} + onGithubSuccess={() => completed++} + onCancel={() => cancelled++} + onDestinationChange={() => {}} + /> +
+
+ {}} + onSendKilo={() => {}} + onGithubSuccess={() => completed++} + onCancel={() => cancelled++} + onDestinationChange={() => {}} + /> +
+ +)) +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() diff --git a/packages/kilo-vscode/tests/fixtures/inline-comment-form.tsx b/packages/kilo-vscode/tests/fixtures/inline-comment-form.tsx new file mode 100644 index 000000000000..5e93fad090fc --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/inline-comment-form.tsx @@ -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() +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(() => ( + <> +
+ saved.push(body)} + onSend={(body) => sent.push(body)} + onCancel={() => cancelled++} + onEscape={() => cancelled++} + /> +
+
+ completed++} + onCancel={() => cancelled++} + /> +
+ +)) +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() diff --git a/packages/kilo-vscode/tests/fixtures/send-all-button.tsx b/packages/kilo-vscode/tests/fixtures/send-all-button.tsx new file mode 100644 index 000000000000..27e5c36be1be --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/send-all-button.tsx @@ -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() +const { SendAllButton } = await import("../../webview-ui/diff-viewer/SendAllButton") + +const chat: string[] = [] +const github: string[] = [] +const [number, setNumber] = createSignal(undefined) +const [pending, setPending] = createSignal(false) + +const release = mount(() => ( + 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() diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 6e035725143b..5f6c7d18fd0c 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -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]! diff --git a/packages/kilo-vscode/tests/unit/annotation-lifecycle.test.ts b/packages/kilo-vscode/tests/unit/annotation-lifecycle.test.ts new file mode 100644 index 000000000000..0f833e542c1b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/annotation-lifecycle.test.ts @@ -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() + 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() + 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() +}) diff --git a/packages/kilo-vscode/tests/unit/comments-github.test.ts b/packages/kilo-vscode/tests/unit/comments-github.test.ts new file mode 100644 index 000000000000..8395294bf612 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/comments-github.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/composer-action-order.test.ts b/packages/kilo-vscode/tests/unit/composer-action-order.test.ts new file mode 100644 index 000000000000..5305522bf70b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/composer-action-order.test.ts @@ -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"]')) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-comment-form.test.ts b/packages/kilo-vscode/tests/unit/diff-comment-form.test.ts new file mode 100644 index 000000000000..676c3d0b041f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-comment-form.test.ts @@ -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) diff --git a/packages/kilo-vscode/tests/unit/diff-preview-request.test.ts b/packages/kilo-vscode/tests/unit/diff-preview-request.test.ts index 1a3e9d4cadb7..ca30f660383c 100644 --- a/packages/kilo-vscode/tests/unit/diff-preview-request.test.ts +++ b/packages/kilo-vscode/tests/unit/diff-preview-request.test.ts @@ -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) { + expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0) +} diff --git a/packages/kilo-vscode/tests/unit/diff-viewer-provider.test.ts b/packages/kilo-vscode/tests/unit/diff-viewer-provider.test.ts index 5c36b051bf8a..b5111f9b1491 100644 --- a/packages/kilo-vscode/tests/unit/diff-viewer-provider.test.ts +++ b/packages/kilo-vscode/tests/unit/diff-viewer-provider.test.ts @@ -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() const disposed = event() @@ -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() diff --git a/packages/kilo-vscode/tests/unit/inline-comment-form.test.ts b/packages/kilo-vscode/tests/unit/inline-comment-form.test.ts new file mode 100644 index 000000000000..7c5ec845fd2d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/inline-comment-form.test.ts @@ -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, +) diff --git a/packages/kilo-vscode/tests/unit/pr-diff.test.ts b/packages/kilo-vscode/tests/unit/pr-diff.test.ts new file mode 100644 index 000000000000..1350a1be9f8e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/pr-diff.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/pr-review-actions.test.ts b/packages/kilo-vscode/tests/unit/pr-review-actions.test.ts index 9b44059a8130..82e2678ccc36 100644 --- a/packages/kilo-vscode/tests/unit/pr-review-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/pr-review-actions.test.ts @@ -116,7 +116,17 @@ function harness() { }) const review = (snapshot: { id: string }, fields: Record = {}) => 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() + 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 | undefined transport([file], (value) => { diff --git a/packages/kilo-vscode/tests/unit/review-annotations.test.ts b/packages/kilo-vscode/tests/unit/review-annotations.test.ts new file mode 100644 index 000000000000..c96b646f5b87 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/review-annotations.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/send-all-button.test.ts b/packages/kilo-vscode/tests/unit/send-all-button.test.ts new file mode 100644 index 000000000000..2ce91bd01bb8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/send-all-button.test.ts @@ -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) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 40139a719a9c..4ee355de1890 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 68d51ecebbd4..783785b41012 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -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 = (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 = (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 = (props) => { what you're looking at and is the primary control. Always shown, so an empty scope can still be switched away from. */} {props.lead} + + {(target) => ( + + {t("diffViewer.comment.prContext", { number: target().prNumber })} + + + + + )} + 0}> <> = (props) => { + - -
- - - - {noticeText()} -
-
+
@@ -222,11 +236,20 @@ export const DiffPanel: Component = (props) => { {comments().length} comment{comments().length !== 1 ? "s" : ""} - - - + + + {sendAllError()} + + +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx index 4a14ef4f7b2d..c3aa2f67fb00 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx @@ -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.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)} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index b1531dd183a7..d9fd5fb8c655 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -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; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 9dffe7fcd8d0..5f047b832453 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -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}} ملفًا", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 50696625a1dd..9cbf250343c8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 786933dd7f77..5e39ce637cc2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index 954b34e272ef..7ed33f8c0da4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 4384386718c4..6a213fe9666d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 01e27fcf5a6a..a70c83d1731f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 58b6138743ca..6d61a25ed665 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index 8ff591fb0f54..7c87d7198f5c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -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}} فایل", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index f0bef734c425..8df4cb61c964 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -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 d’une erreur GitHub : {{error}}", "agentManager.review.inlineCount": "Commentaires locaux ({{count}})", "agentManager.review.prCount": "Commentaires du PR ({{count}})", "agentManager.review.fileCount": "{{count}} fichiers", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index ba04dafd8ca2..d3976fb9ab64 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 4d8c6ef21068..7fc490da636d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -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}} ファイル", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 959e6ebe3653..9d05e13b9013 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -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}}개 파일", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 4c2e62106848..e773024c2efc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index c3202c70b7cd..00560cf75fbb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index ca3d0ef15cda..24620951e534 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 0e8abb52bc82..cec6fa80f884 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -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}} файлов", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index c169772521f2..a8740363f86e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -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}} ไฟล์", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 97db13c415b2..42e53b1cc924 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 08755e2b1c97..5fe85216a252 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -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}} файлів", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index cfd7cd449e67..ba6cf78440a8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -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}} 个文件", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 194cd10acd57..d0dc8cb7464a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -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}} 個檔案", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRCommentForm.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRCommentForm.tsx index d41f00c849a5..ae14746c5a27 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRCommentForm.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRCommentForm.tsx @@ -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>({}) +const [drafts, setDrafts] = createStore>({}) 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() 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, id = key()) => - setDrafts((prev) => ({ ...prev, [id]: { ...(prev[id] ?? blank), ...value } })) + const patch = (value: Partial, 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 ( -
+
{t("agentManager.pr.review.own")}

-
- - - - - - - - - -
+ +
+ + + + + + + + + +
+
@@ -359,22 +521,11 @@ export function PRCommentForm(props: Props) {
-
- - + +
+ - - + + + {t("diffViewer.comment.sendToKilo")} + + } + > + {(pr) => ( +
+ + + + + + + + { + if (props.action !== "diff") return + patch({ destination: "local" }) + props.onDestinationChange?.("local") + }} + > + + {t("diffViewer.comment.sendToKilo")} + + { + if (props.action !== "diff") return + patch({ destination: "github" }) + props.onDestinationChange?.("github") + }} + > + + + {t("diffViewer.comment.sendToGithub", { number: pr().prNumber })} + + + + + +
+ )} +
+
+ +
+ {t("diffViewer.comment.unavailable")} +
-
+ + +
+ + + + + + + + + + + + + + +
+
+ +
+ {t("diffViewer.comment.unavailable")} +
+
{(error) => ( + + + (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" }) diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerNotice.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerNotice.tsx new file mode 100644 index 000000000000..e92f2726803d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerNotice.tsx @@ -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.text} +
+
+) diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx index 3883b5d35b40..a98bbbf7dfdf 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx @@ -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 = (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 = (props) => commentsByFile, handleGutterClick, sendAllClick, - } = createReviewView(props, () => rootRef) + sendAllToGithub, + sendAllGithubCount, + sendAllGithubAvailable, + sendAllPending, + sendAllError, + } = createReviewSurface(props, () => rootRef) const [manualActiveFile, setManualActiveFile] = createSignal>({}) const activeFile = createMemo(() => { @@ -191,6 +201,16 @@ export const FullScreenDiffView: Component = (props) =>
{props.lead} + + {(target) => ( + + {t("diffViewer.comment.prContext", { number: target().prNumber })} + + + + + )} + = (props) => {openLabel()} 0 && props.canComment !== false}> - - - + /> + + + + {sendAllError()} +
+ {/* Body: file tree + diff viewer */}
@@ -262,14 +289,7 @@ export const FullScreenDiffView: Component = (props) => />
- -
- - - - {noticeText()} -
-
+
diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/SendAllButton.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/SendAllButton.tsx new file mode 100644 index 000000000000..8c2369da9a95 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/SendAllButton.tsx @@ -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) => { + 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 = () => ( + + + + ) + return ( + }> +
+ + + + +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/annotation-lifecycle.ts b/packages/kilo-vscode/webview-ui/diff-viewer/annotation-lifecycle.ts new file mode 100644 index 000000000000..adf3c5862b17 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/annotation-lifecycle.ts @@ -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 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 } +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/comments-github.ts b/packages/kilo-vscode/webview-ui/diff-viewer/comments-github.ts new file mode 100644 index 000000000000..4408ec5f1ef8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/comments-github.ts @@ -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 + snapshot: Accessor + diffs: Accessor + post: (message: never) => void + /** Gate publication, for example when only local changes are shown. */ + canPublish?: Accessor +} + +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 } +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/pr-diff.ts b/packages/kilo-vscode/webview-ui/diff-viewer/pr-diff.ts new file mode 100644 index 000000000000..9202c37f91c2 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/pr-diff.ts @@ -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 +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts b/packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts index 023eb6bf2172..8f3b9ce62d50 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts @@ -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 @@ -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('[data-action="send-kilo"], [data-action="send"]') + if (kilo && !kilo.disabled) { + kilo.click() + return + } + const primary = host.querySelector('[data-action="send-primary"]') + if (primary && primary.dataset.destination !== "github" && !primary.disabled) { + primary.click() + return + } + const fallback = host.querySelector('[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("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" diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/review-controller.ts b/packages/kilo-vscode/webview-ui/diff-viewer/review-controller.ts index 7ea25657ba2a..ad6c7ab1e1ba 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/review-controller.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/review-controller.ts @@ -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 onSendClick?: () => void onSendAll?: () => void + commentForm?: Accessor + 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[] => { 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): 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() + + 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 +} + +interface ReviewViewOverrides { + commentForm?: CommentFormMount + commentsGithub?: CommentsGithub } -export function createReviewView(props: ReviewViewProps, root: Accessor) { +export function createReviewView( + props: ReviewViewProps, + root: Accessor, + overrides?: ReviewViewOverrides, +) { const { t } = useLanguage() const vscode = useVSCode() const local = createReviewComposer() @@ -384,6 +466,8 @@ export function createReviewView(props: ReviewViewProps, root: Accessor 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 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, + } +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 73378c16280e..37432d352ca1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1271,6 +1271,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": "اللقطات معطّلة لهذا المستودع. يُرجى تعديل ملفات الإعدادات لعرض تغييرات الجلسة.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 28da3d1e8391..3da69bc6c06b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1315,6 +1315,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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 7d074e9dfff5..63528e4905a7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1306,6 +1306,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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index ad1c1675d6ae..19535187afda 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1300,6 +1300,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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index ae42dccac5e2..05e9a335459e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1328,6 +1328,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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 22ee25dd0ed0..ca0401d00528 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1288,6 +1288,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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 323dfe538eed..ac15acdbf1cd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1316,6 +1316,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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index eec06fa51ba8..fe87b590eddd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -1300,6 +1300,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": "عکس‌های فوری برای این مخزن غیرفعال هستند. لطفاً فایل‌های پیکربندی خود را ویرایش کنید تا تغییرات جلسه نمایش داده شوند.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index bcbe3dffecc2..343df6ca5e47 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1337,6 +1337,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 à l’agent", + "diffViewer.comment.postToGithub": "Publier sur GitHub", + "diffViewer.comment.loadFailed": "Impossible de charger les modifications de la pull request.", + "diffViewer.comment.unavailable": "Cette ligne n’est pas disponible dans l’instantané 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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index d0c66a910589..93e7ad59cec2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1181,6 +1181,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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 57e436a09f6c..9d2bf628a618 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1293,6 +1293,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": "このリポジトリではスナップショットが無効になっています。セッションの変更を表示するには、構成ファイルを編集してください。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 7b2d48de3d68..1338458d003d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1280,6 +1280,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": "이 리포지토리에서 스냅샷이 비활성화되어 있습니다. 세션 변경 사항을 표시하려면 구성 파일을 편집하세요.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 5cb90ec14bad..971f15033f41 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1328,6 +1328,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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 365e338bda0e..d558fba68a18 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1296,6 +1296,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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 5e4a106e596c..9e9e45b04599 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1306,6 +1306,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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 1e80fb9200d1..d8c7add0a160 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1300,6 +1300,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": "Снимки отключены для этого репозитория. Пожалуйста, отредактируйте файлы конфигурации, чтобы отображать изменения сессии.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index d36f9f58b8d4..9eabc2e215f7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1277,6 +1277,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": "ปิดใช้งานสแนปช็อตสำหรับที่เก็บนี้ กรุณาแก้ไขไฟล์การกำหนดค่าเพื่อแสดงการเปลี่ยนแปลงของเซสชัน", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 138a25bd78d6..5598e3c2de15 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1316,6 +1316,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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index bf2d71cefdeb..5d762ca80f4b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1316,6 +1316,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": "Знімки вимкнено для цього репозиторію. Будь ласка, відредагуйте файли конфігурації, щоб відображати зміни сесії.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index e1700157f6de..3ed772a52068 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1231,6 +1231,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": "默认", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 439ee1bb5d56..c90388f40301 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1235,6 +1235,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": "預設", diff --git a/packages/kilo-vscode/webview-ui/src/styles/banners.css b/packages/kilo-vscode/webview-ui/src/styles/banners.css index edbda58d57f5..8ab79e6e760a 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/banners.css +++ b/packages/kilo-vscode/webview-ui/src/styles/banners.css @@ -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) ============================================