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