Merge pull request #13213 from Kilo-Org/thin-dumpling

feat(agent-manager): render PR comment diffs with Pierre
This commit is contained in:
Marius
2026-08-19 10:02:32 +02:00
committed by GitHub
8 changed files with 239 additions and 53 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Render Agent Manager pull request comment diffs with the Pierre-backed diff viewer and syntax highlighting.
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { normalize, text } from "./session-diff"
import { normalize, normalizeHunk, text } from "./session-diff"
describe("session diff", () => {
test("keeps unified patch content", () => {
@@ -61,4 +61,27 @@ describe("session diff", () => {
expect(view.fileDiff.deletionLines.length).toBe(2)
expect(view.fileDiff.additionLines.length).toBe(2)
})
test("normalizes GitHub hunk-only patches for Pierre", () => {
const view = normalizeHunk("src/foo.ts", "@@ -340,2 +340,2 @@\n one\n-two\n+three\n")
expect(view?.patch).toBe("--- a/src/foo.ts\n+++ b/src/foo.ts\n@@ -340,2 +340,2 @@\n one\n-two\n+three\n")
expect(view?.fileDiff.hunks[0]?.deletionStart).toBe(340)
expect(view?.fileDiff.hunks[0]?.additionStart).toBe(340)
})
test("rejects an empty or malformed GitHub hunk", () => {
expect(normalizeHunk("src/foo.ts", "")).toBeUndefined()
expect(normalizeHunk("src/foo.ts", "not a diff")).toBeUndefined()
})
test("renders a real GitHub hunk with blank lines", () => {
const view = normalizeHunk(
"packages/kilo-ui/src/components/file.tsx",
'@@ -1 +1,14 @@\n+import { File as BaseFile, type FileProps } from "@opencode-ai/ui/file"\n+import type { JSX } from "solid-js"\n+import { createDefaultOptions } from "../pierre"\n+\n export * from "@opencode-ai/ui/file"\n+\n+export function File<T>(props: FileProps<T>) {\n+ const View = BaseFile as unknown as (props: FileProps<T>) => JSX.Element\n+ if (props.mode === "text") return <View {...props} />\n+\n+ // Keep inline file diffs on the same Pierre defaults as the dedicated viewer.\n+ const options = { ...createDefaultOptions<T>(props.diffStyle), ...props } as FileProps<T>\n',
)
expect(view?.fileDiff.hunks).toHaveLength(1)
expect(view?.fileDiff.hunks[0]?.additionStart).toBe(1)
})
})
@@ -57,6 +57,13 @@ function name(diff: ReviewDiff) {
return diff.file ?? ""
}
function patchFor(file: string, value: string) {
if (!value.trimStart().startsWith("@@")) return value
if (!file) return value
const body = value.endsWith("\n") ? value : `${value}\n`
return `--- a/${file}\n+++ b/${file}\n${body}`
}
function contents(diff: ReviewDiff): DiffText {
if (typeof diff.patch === "string") {
return { ...reconstruct(diff.patch), patch: diff.patch }
@@ -93,6 +100,28 @@ export function normalize(diff: ReviewDiff): ViewDiff {
}
}
export function normalizeHunk(file: string, patch: string) {
if (!file || !patch.trim()) return
const value = patchFor(file, patch)
let fileDiff: FileDiffMetadata | undefined
try {
fileDiff = processFile(value, { cacheKey: value })
} catch {
return
}
if (!fileDiff?.hunks.length) return
return {
file,
patch: value,
before: fileDiff.deletionLines.join(""),
after: fileDiff.additionLines.join(""),
additions: fileDiff.additionLines.length,
deletions: fileDiff.deletionLines.length,
status: "modified" as const,
fileDiff,
}
}
export function text(diff: ViewDiff, side: "deletions" | "additions") {
const lines = side === "deletions" ? diff.fileDiff.deletionLines : diff.fileDiff.additionLines
const out = lines.join("")
@@ -0,0 +1,100 @@
import assert from "node:assert/strict"
import { Window } from "happy-dom"
const window = new Window({ url: "http://localhost" })
class CSSStyleSheetStub {
replaceSync() {}
replace() {
return Promise.resolve(this)
}
}
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
Node: window.Node,
Element: window.Element,
HTMLElement: window.HTMLElement,
HTMLDivElement: window.HTMLDivElement,
HTMLPreElement: window.HTMLPreElement,
HTMLAnchorElement: window.HTMLAnchorElement,
HTMLButtonElement: window.HTMLButtonElement,
SVGElement: window.SVGElement,
ShadowRoot: window.ShadowRoot,
customElements: window.customElements,
CSSStyleSheet: CSSStyleSheetStub,
MutationObserver: window.MutationObserver,
ResizeObserver: window.ResizeObserver,
CustomEvent: window.CustomEvent,
Event: window.Event,
MouseEvent: window.MouseEvent,
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
})
const { render } = await import("solid-js/web")
const { I18nProvider } = await import("@kilocode/kilo-ui/context")
const { MarkedProvider } = await import("@kilocode/kilo-ui/context/marked")
const { VSCodeProvider } = await import("../../webview-ui/src/context/vscode")
const { PRComments } = await import("../../webview-ui/agent-manager/pr/PRComments")
const root = document.createElement("div")
const colors = document.createElement("style")
colors.textContent = ":root { --syntax-keyword: rgb(72, 160, 199); --syntax-string: rgb(206, 145, 120); }"
document.head.append(colors)
document.body.append(root)
const dispose = render(
() => (
<VSCodeProvider>
<I18nProvider
value={
{
locale: () => "en",
t: (key: string) => key,
plural: (key: string) => key,
} as never
}
>
<MarkedProvider>
<PRComments
worktreeId="wt-test"
comments={{
total: 1,
unresolved: 1,
comments: [
{
id: "PRRC_test",
threadId: "PRRT_test",
author: "kilo-code-bot",
body: "comment body survives Pierre rendering",
file: "packages/kilo-ui/src/components/file.tsx",
line: 14,
resolved: false,
diffHunk:
'@@ -1 +1,14 @@\n+import { File as BaseFile, type FileProps } from "@opencode-ai/ui/file"\n+import type { JSX } from "solid-js"\n+import { createDefaultOptions } from "../pierre"\n+\n export * from "@opencode-ai/ui/file"\n+\n+export function File<T>(props: FileProps<T>) {\n+ const View = BaseFile as unknown as (props: FileProps<T>) => JSX.Element\n+ if (props.mode === "text") return <View {...props} />\n+\n+ // Keep inline file diffs on the same Pierre defaults as the dedicated viewer.\n+ const options = { ...createDefaultOptions<T>(props.diffStyle), ...props } as FileProps<T>\n',
},
],
}}
/>
</MarkedProvider>
</I18nProvider>
</VSCodeProvider>
),
root,
)
await window.happyDOM.waitUntilComplete()
const host = root.querySelector("diffs-container")
const shadow = host?.shadowRoot
const keyword = shadow?.querySelector('[data-content] span[style*="--syntax-keyword"]')
const string = shadow?.querySelector('[data-content] span[style*="--syntax-string"]')
assert.match(root.textContent ?? "", /comment body survives Pierre rendering/)
assert.equal(root.querySelectorAll('[data-component="diff"]').length, 1)
assert.ok(keyword)
assert.ok(string)
assert.match(keyword!.getAttribute("style") ?? "", /--syntax-keyword/)
assert.match(string!.getAttribute("style") ?? "", /--syntax-string/)
assert.notEqual(keyword!.getAttribute("style"), string!.getAttribute("style"))
dispose()
@@ -0,0 +1,57 @@
import { describe, expect, it } from "bun:test"
import { unlinkSync } from "node:fs"
import path from "node:path"
import { build } from "esbuild"
import { solidPlugin } from "esbuild-plugin-solid"
const ROOT = path.resolve(import.meta.dir, "../..")
const WEBVIEW = path.join(ROOT, "webview-ui")
const FIXTURE = path.join(ROOT, "tests/fixtures/pr-comments-render.tsx")
describe("PR comments", () => {
it("keeps a comment mounted while Pierre renders its GitHub hunk", async () => {
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW))
const aliases: Record<string, string> = {
"solid-js": path.join(solid, "dist/solid.js"),
"solid-js/web": path.join(solid, "web/dist/web.js"),
"solid-js/store": path.join(solid, "store/dist/store.js"),
}
const dedupe = {
name: "solid-dedupe",
setup(ctx: Parameters<NonNullable<Parameters<typeof build>[0]["plugins"]>[number]["setup"]>[0]) {
ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] }))
ctx.onResolve({ filter: /pierre\/worker$/ }, (args) => {
if (args.path.includes("@pierre")) return
return { path: path.join(WEBVIEW, "pierre-worker.ts") }
})
ctx.onResolve({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, () => ({
path: "markdown-shiki-worker-url",
namespace: "kilo-worker-url",
}))
ctx.onLoad({ filter: /.*/, namespace: "kilo-worker-url" }, () => ({
contents: "export default undefined",
loader: "js",
}))
},
}
const result = await build({
entryPoints: [FIXTURE],
bundle: true,
conditions: ["browser"],
external: ["happy-dom"],
format: "esm",
logLevel: "silent",
platform: "node",
plugins: [dedupe, solidPlugin()],
target: "es2022",
write: false,
})
const file = path.join(ROOT, `.pr-comments-render-${crypto.randomUUID()}.mjs`)
await Bun.write(file, result.outputFiles[0]!.contents)
const child = Bun.spawnSync(["bun", file], { cwd: WEBVIEW, stdout: "pipe", stderr: "pipe" })
unlinkSync(file)
const output = child.stdout.toString() + child.stderr.toString()
expect(child.exitCode, output).toBe(0)
})
})
@@ -7,27 +7,7 @@ import type { PRStatus } from "../../src/types/messages"
import type { PRComment } from "./pr-types"
import { SectionHeading } from "./SectionHeading"
import { CopyButton } from "./CopyButton"
function DiffHunk(props: { hunk: string }) {
const lines = () => props.hunk.split("\n")
return (
<div class="am-pr-diff-hunk">
<Index each={lines()}>
{(line) => {
const text = line()
const cls = text.startsWith("+")
? "am-pr-diff-line-add"
: text.startsWith("-")
? "am-pr-diff-line-del"
: text.startsWith("@@")
? "am-pr-diff-line-meta"
: "am-pr-diff-line-ctx"
return <div class={`am-pr-diff-line ${cls}`}>{text || " "}</div>
}}
</Index>
</div>
)
}
import { PRCommentDiff } from "../../diff-viewer/PRCommentDiff"
function CommentCard(props: { comment: PRComment; worktreeId: string }) {
const vscode = useVSCode()
@@ -83,7 +63,9 @@ function CommentCard(props: { comment: PRComment; worktreeId: string }) {
return (
<div class="am-pr-panel-comment" classList={{ "am-pr-panel-comment-resolved": resolved() }}>
<Show when={props.comment.diffHunk}>{(hunk) => <DiffHunk hunk={hunk()} />}</Show>
<Show when={props.comment.diffHunk && props.comment.file}>
<PRCommentDiff file={props.comment.file!} hunk={props.comment.diffHunk!} />
</Show>
<div class="am-pr-panel-comment-header am-pr-row">
<span class="am-pr-panel-comment-author">{props.comment.author}</span>
<Show when={props.comment.file}>
@@ -284,39 +284,12 @@
margin-left: auto;
}
/* Diff hunk preview inside comment cards */
/* Pierre diff hunk preview inside comment cards */
.am-pr-diff-hunk {
font-family: var(--font-mono, monospace);
font-size: var(--kilo-font-size-11);
border-radius: 3px;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
overflow: hidden;
margin-bottom: 6px;
border: 1px solid var(--vscode-panel-border);
}
.am-pr-diff-line {
padding: 1px 6px;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.am-pr-diff-line-add {
background: color-mix(in lab, var(--syntax-diff-add, #318430) 15%, transparent);
color: var(--syntax-diff-add, #318430);
}
.am-pr-diff-line-del {
background: color-mix(in lab, var(--syntax-diff-delete, #da3319) 15%, transparent);
color: var(--syntax-diff-delete, #da3319);
}
.am-pr-diff-line-meta {
color: var(--text-weaker);
}
.am-pr-diff-line-ctx {
color: var(--vscode-foreground);
}
/* Resolve button */
@@ -0,0 +1,17 @@
import { Show, createMemo } from "solid-js"
import { Diff } from "@kilocode/kilo-ui/diff"
import { normalizeHunk } from "@kilocode/kilo-ui/session-diff"
export function PRCommentDiff(props: { file: string; hunk: string }) {
const view = createMemo(() => normalizeHunk(props.file, props.hunk))
return (
<Show when={view()}>
{(value) => (
<div class="am-pr-diff-hunk">
<Diff fileDiff={value().fileDiff} diffStyle="unified" virtualized={false} />
</div>
)}
</Show>
)
}