feat(agent-manager): add fullscreen review tab with shared diff controls (#6387)

* tmp

* merge

* tmp

* fix

* chore(agent-manager): remove fullscreen diff planning spec

* Fix review comments

* Fix scroll position problems and deduplicate code

* fix(agent-manager): restore Cmd+Enter send-all in review panes

* Add tests and fix cmd enter to send all comments

* Add translations

* Fix tranlsations

* Update translations

* Fix tranlsations
This commit is contained in:
Marius
2026-02-26 13:59:01 +00:00
committed by GitHub
parent dab4ae78fb
commit 25ac1341fc
31 changed files with 2728 additions and 314 deletions
@@ -242,6 +242,10 @@ export class AgentManagerProvider implements vscode.Disposable {
this.state?.setSessionsCollapsed(msg.collapsed)
return null
}
if (type === "agentManager.setReviewDiffStyle" && (msg.style === "unified" || msg.style === "split")) {
this.state?.setReviewDiffStyle(msg.style)
return null
}
if (type === "agentManager.requestExternalWorktrees") {
void this.onRequestExternalWorktrees()
@@ -1237,6 +1241,7 @@ export class AgentManagerProvider implements vscode.Disposable {
sessions: state.getSessions(),
tabOrder: state.getTabOrder(),
sessionsCollapsed: state.getSessionsCollapsed(),
reviewDiffStyle: state.getReviewDiffStyle(),
isGitRepo: true,
})
@@ -1251,6 +1256,7 @@ export class AgentManagerProvider implements vscode.Disposable {
type: "agentManager.state",
worktrees: [],
sessions: [],
reviewDiffStyle: "unified",
isGitRepo: false,
})
}
@@ -36,6 +36,7 @@ interface StateFile {
sessions: Record<string, Omit<ManagedSession, "id">>
tabOrder?: Record<string, string[]>
sessionsCollapsed?: boolean
reviewDiffStyle?: "unified" | "split"
}
const STATE_FILE = "agent-manager.json"
@@ -53,6 +54,7 @@ export class WorktreeStateManager {
private sessions = new Map<string, ManagedSession>()
private tabOrder: Record<string, string[]> = {}
private collapsed = false
private reviewDiffStyle: "unified" | "split" = "unified"
private readonly log: (msg: string) => void
private saving: Promise<void> | undefined
private pendingSave = false
@@ -230,6 +232,19 @@ export class WorktreeStateManager {
void this.save()
}
// ---------------------------------------------------------------------------
// Review diff style
// ---------------------------------------------------------------------------
getReviewDiffStyle(): "unified" | "split" {
return this.reviewDiffStyle
}
setReviewDiffStyle(value: "unified" | "split"): void {
this.reviewDiffStyle = value
void this.save()
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
@@ -241,6 +256,7 @@ export class WorktreeStateManager {
this.worktrees.clear()
this.sessions.clear()
this.tabOrder = {}
this.reviewDiffStyle = "unified"
for (const [id, wt] of Object.entries(data.worktrees ?? {})) {
this.worktrees.set(id, { id, ...wt })
@@ -252,6 +268,9 @@ export class WorktreeStateManager {
this.tabOrder = data.tabOrder
}
this.collapsed = data.sessionsCollapsed ?? false
if (data.reviewDiffStyle === "split") {
this.reviewDiffStyle = "split"
}
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
@@ -321,6 +340,9 @@ export class WorktreeStateManager {
if (this.collapsed) {
data.sessionsCollapsed = true
}
if (this.reviewDiffStyle === "split") {
data.reviewDiffStyle = "split"
}
try {
const dir = path.dirname(this.file)
@@ -18,6 +18,9 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx"),
path.join(ROOT, "webview-ui/agent-manager/FileTree.tsx"),
path.join(ROOT, "webview-ui/agent-manager/review-annotations.ts"),
path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"),
]
const TSX_FILE = TSX_FILES[0]
@@ -0,0 +1,312 @@
import { describe, it, expect } from "bun:test"
import { buildFileTree, flatten, flattenChain, type FileTreeNode } from "../../webview-ui/agent-manager/file-tree-utils"
import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages"
function diff(file: string, status?: "added" | "deleted" | "modified"): WorktreeFileDiff {
return { file, before: "", after: "", additions: 1, deletions: 0, status }
}
// ── buildFileTree ──────────────────────────────────────────────────────────
describe("buildFileTree", () => {
it("returns empty array for empty input", () => {
expect(buildFileTree([])).toEqual([])
})
it("places root-level files at the top", () => {
const tree = buildFileTree([diff("README.md"), diff("package.json")])
expect(tree).toHaveLength(2)
expect(tree[0]!.name).toBe("README.md")
expect(tree[0]!.path).toBe("README.md")
expect(tree[0]!.children).toBeUndefined()
expect(tree[1]!.name).toBe("package.json")
})
it("groups files under shared directories", () => {
const tree = buildFileTree([diff("src/a.ts"), diff("src/b.ts")])
expect(tree).toHaveLength(1)
const src = tree[0]!
expect(src.name).toBe("src")
expect(src.path).toBe("src")
expect(src.children).toHaveLength(2)
expect(src.children![0]!.name).toBe("a.ts")
expect(src.children![1]!.name).toBe("b.ts")
})
it("creates nested directory structure", () => {
const tree = buildFileTree([diff("src/components/Button.tsx")])
expect(tree).toHaveLength(1)
const src = tree[0]!
expect(src.name).toBe("src")
expect(src.children).toHaveLength(1)
const components = src.children![0]!
expect(components.name).toBe("components")
expect(components.path).toBe("src/components")
expect(components.children).toHaveLength(1)
expect(components.children![0]!.name).toBe("Button.tsx")
expect(components.children![0]!.path).toBe("src/components/Button.tsx")
})
it("reuses existing directory nodes for shared prefixes", () => {
const tree = buildFileTree([diff("src/a.ts"), diff("src/utils/b.ts"), diff("src/utils/c.ts")])
const src = tree[0]!
expect(src.children).toHaveLength(2) // a.ts, utils/
const utils = src.children!.find((n) => n.name === "utils")!
expect(utils.children).toHaveLength(2)
expect(utils.children![0]!.name).toBe("b.ts")
expect(utils.children![1]!.name).toBe("c.ts")
})
it("handles deeply nested paths", () => {
const tree = buildFileTree([diff("a/b/c/d/e.ts")])
expect(tree[0]!.name).toBe("a")
expect(tree[0]!.children![0]!.name).toBe("b")
expect(tree[0]!.children![0]!.children![0]!.name).toBe("c")
expect(tree[0]!.children![0]!.children![0]!.children![0]!.name).toBe("d")
expect(tree[0]!.children![0]!.children![0]!.children![0]!.children![0]!.name).toBe("e.ts")
})
it("mixes root-level files with directory files", () => {
const tree = buildFileTree([diff("README.md"), diff("src/index.ts"), diff("test/index.test.ts")])
expect(tree).toHaveLength(3) // README.md, src/, test/
expect(tree[0]!.name).toBe("README.md")
expect(tree[0]!.children).toBeUndefined()
expect(tree[1]!.name).toBe("src")
expect(tree[1]!.children).toHaveLength(1)
expect(tree[2]!.name).toBe("test")
expect(tree[2]!.children).toHaveLength(1)
})
it("attaches diff data to leaf nodes", () => {
const d = diff("src/a.ts", "added")
const tree = buildFileTree([d])
const leaf = tree[0]!.children![0]!
expect(leaf.diff).toBe(d)
})
it("does not attach diff data to directory nodes", () => {
const tree = buildFileTree([diff("src/a.ts")])
expect(tree[0]!.diff).toBeUndefined()
})
it("separates diverging paths with common prefix", () => {
const tree = buildFileTree([diff("src/a.ts"), diff("src/b/c.ts")])
const src = tree[0]!
expect(src.children).toHaveLength(2)
const file = src.children!.find((n) => n.name === "a.ts")
const dir = src.children!.find((n) => n.name === "b")
expect(file).toBeDefined()
expect(file!.children).toBeUndefined()
expect(dir).toBeDefined()
expect(dir!.children).toHaveLength(1)
})
})
// ── flattenChain ───────────────────────────────────────────────────────────
describe("flattenChain", () => {
it("returns node unchanged when it has no children", () => {
const node: FileTreeNode = { name: "file.ts", path: "file.ts" }
expect(flattenChain(node)).toBe(node)
})
it("returns node unchanged when it has multiple children", () => {
const node: FileTreeNode = {
name: "src",
path: "src",
children: [
{ name: "a.ts", path: "src/a.ts" },
{ name: "b.ts", path: "src/b.ts" },
],
}
expect(flattenChain(node)).toBe(node)
})
it("returns node unchanged when single child is a file (no children)", () => {
const node: FileTreeNode = {
name: "src",
path: "src",
children: [{ name: "index.ts", path: "src/index.ts" }],
}
expect(flattenChain(node)).toBe(node)
})
it("flattens single-child directory chain", () => {
const node: FileTreeNode = {
name: "src",
path: "src",
children: [
{
name: "components",
path: "src/components",
children: [{ name: "Button.tsx", path: "src/components/Button.tsx" }],
},
],
}
const result = flattenChain(node)
expect(result.name).toBe("src/components")
expect(result.path).toBe("src/components")
expect(result.children).toHaveLength(1)
expect(result.children![0]!.name).toBe("Button.tsx")
})
it("flattens deeply nested single-child chains", () => {
const node: FileTreeNode = {
name: "a",
path: "a",
children: [
{
name: "b",
path: "a/b",
children: [
{
name: "c",
path: "a/b/c",
children: [{ name: "file.ts", path: "a/b/c/file.ts" }],
},
],
},
],
}
const result = flattenChain(node)
expect(result.name).toBe("a/b/c")
expect(result.path).toBe("a/b/c")
})
it("stops flattening at multi-child branch points", () => {
const node: FileTreeNode = {
name: "a",
path: "a",
children: [
{
name: "b",
path: "a/b",
children: [
{ name: "x.ts", path: "a/b/x.ts" },
{ name: "y.ts", path: "a/b/y.ts" },
],
},
],
}
const result = flattenChain(node)
expect(result.name).toBe("a/b")
expect(result.children).toHaveLength(2)
})
})
// ── flatten ─────────────────────────────────────────────────────────────────
describe("flatten", () => {
it("returns empty array for empty input", () => {
expect(flatten([])).toEqual([])
})
it("leaves file nodes unchanged", () => {
const nodes: FileTreeNode[] = [{ name: "file.ts", path: "file.ts" }]
const result = flatten(nodes)
expect(result).toHaveLength(1)
expect(result[0]!.name).toBe("file.ts")
expect(result[0]!.children).toBeUndefined()
})
it("flattens single-child directory chains into combined names", () => {
const nodes: FileTreeNode[] = [
{
name: "src",
path: "src",
children: [
{
name: "components",
path: "src/components",
children: [{ name: "Button.tsx", path: "src/components/Button.tsx" }],
},
],
},
]
const result = flatten(nodes)
expect(result).toHaveLength(1)
expect(result[0]!.name).toBe("src/components")
expect(result[0]!.children).toHaveLength(1)
expect(result[0]!.children![0]!.name).toBe("Button.tsx")
})
it("does not flatten directories with multiple children", () => {
const nodes: FileTreeNode[] = [
{
name: "src",
path: "src",
children: [
{ name: "a.ts", path: "src/a.ts" },
{ name: "b.ts", path: "src/b.ts" },
],
},
]
const result = flatten(nodes)
expect(result[0]!.name).toBe("src")
expect(result[0]!.children).toHaveLength(2)
})
it("flattens recursively through nested levels", () => {
// a/ -> b/ -> c/ -> {x.ts, y.ts}
// Should flatten a/b/c with children [x.ts, y.ts]
const nodes: FileTreeNode[] = [
{
name: "a",
path: "a",
children: [
{
name: "b",
path: "a/b",
children: [
{
name: "c",
path: "a/b/c",
children: [
{ name: "x.ts", path: "a/b/c/x.ts" },
{ name: "y.ts", path: "a/b/c/y.ts" },
],
},
],
},
],
},
]
const result = flatten(nodes)
expect(result[0]!.name).toBe("a/b/c")
expect(result[0]!.children).toHaveLength(2)
})
it("handles a real-world project structure", () => {
const diffs = [
diff("packages/ui/src/components/Button.tsx"),
diff("packages/ui/src/components/Modal.tsx"),
diff("packages/ui/src/index.ts"),
diff("packages/cli/src/main.ts"),
diff("README.md"),
]
const result = flatten(buildFileTree(diffs))
// packages/ should not flatten because it has ui/ and cli/
// packages/ui/src has two children (components/ and index.ts) — no flatten
// packages/cli/src has one child (main.ts) which is a file — no flatten
const packages = result.find((n) => n.name.startsWith("packages"))
expect(packages).toBeDefined()
// Root-level README should be present
const readme = result.find((n) => n.name === "README.md")
expect(readme).toBeDefined()
})
it("does not flatten a directory whose single child is a file", () => {
const nodes: FileTreeNode[] = [
{
name: "src",
path: "src",
children: [{ name: "index.ts", path: "src/index.ts" }],
},
]
const result = flatten(nodes)
// src/ has one child, but that child is a file (no children), so no flatten
expect(result[0]!.name).toBe("src")
expect(result[0]!.children).toHaveLength(1)
expect(result[0]!.children![0]!.name).toBe("index.ts")
})
})
@@ -0,0 +1,206 @@
import { describe, it, expect } from "bun:test"
import {
sanitizeReviewComments,
formatReviewCommentsMarkdown,
extractLines,
getDirectory,
getFilename,
type ReviewComment,
} from "../../webview-ui/agent-manager/review-comments"
import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages"
function diff(file: string, before: string, after: string): WorktreeFileDiff {
return { file, before, after, additions: 1, deletions: 0 }
}
function comment(overrides: Partial<ReviewComment> & Pick<ReviewComment, "file" | "line">): ReviewComment {
return {
id: `c-${overrides.file}-${overrides.line}`,
side: "additions",
comment: "test comment",
selectedText: "",
...overrides,
}
}
// ── sanitizeReviewComments ────────────────────────────────────────────────
describe("sanitizeReviewComments", () => {
it("returns empty array when no comments", () => {
expect(sanitizeReviewComments([], [diff("a.ts", "", "line1")])).toEqual([])
})
it("returns empty array when no diffs", () => {
expect(sanitizeReviewComments([comment({ file: "a.ts", line: 1 })], [])).toEqual([])
})
it("filters out comments for files not in diffs", () => {
const result = sanitizeReviewComments([comment({ file: "missing.ts", line: 1 })], [diff("a.ts", "", "content")])
expect(result).toEqual([])
})
it("keeps comments with valid line numbers", () => {
const c = comment({ file: "a.ts", line: 1 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "line1\nline2\nline3")])
expect(result).toEqual([c])
})
it("keeps comment on the last line (boundary)", () => {
const c = comment({ file: "a.ts", line: 3 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "a\nb\nc")])
expect(result).toEqual([c])
})
it("filters comment on line max+1 (off by one)", () => {
const c = comment({ file: "a.ts", line: 4 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "a\nb\nc")])
expect(result).toEqual([])
})
it("filters comment on line 0", () => {
const c = comment({ file: "a.ts", line: 0 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "content")])
expect(result).toEqual([])
})
it("filters comment with negative line", () => {
const c = comment({ file: "a.ts", line: -1 })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "content")])
expect(result).toEqual([])
})
it("uses diff.before for deletions side", () => {
const c = comment({ file: "a.ts", line: 2, side: "deletions" })
// before has 2 lines, after has 0 — comment should be valid on deletions side
const result = sanitizeReviewComments([c], [diff("a.ts", "old1\nold2", "")])
expect(result).toEqual([c])
})
it("uses diff.after for additions side", () => {
const c = comment({ file: "a.ts", line: 2, side: "additions" })
// after has 3 lines — comment on line 2 should be valid
const result = sanitizeReviewComments([c], [diff("a.ts", "", "new1\nnew2\nnew3")])
expect(result).toEqual([c])
})
it("rejects deletions comment when before content is empty", () => {
const c = comment({ file: "a.ts", line: 1, side: "deletions" })
const result = sanitizeReviewComments([c], [diff("a.ts", "", "after")])
expect(result).toEqual([])
})
it("rejects additions comment when after content is empty", () => {
const c = comment({ file: "a.ts", line: 1, side: "additions" })
const result = sanitizeReviewComments([c], [diff("a.ts", "before", "")])
expect(result).toEqual([])
})
it("returns all when all comments are valid", () => {
const comments = [
comment({ file: "a.ts", line: 1 }),
comment({ file: "a.ts", line: 2 }),
comment({ file: "b.ts", line: 1 }),
]
const diffs = [diff("a.ts", "", "x\ny"), diff("b.ts", "", "z")]
const result = sanitizeReviewComments(comments, diffs)
expect(result).toHaveLength(3)
})
it("filters a mix of valid and invalid comments", () => {
const valid = comment({ file: "a.ts", line: 1 })
const invalid = comment({ file: "a.ts", line: 100 })
const missing = comment({ file: "gone.ts", line: 1 })
const result = sanitizeReviewComments([valid, invalid, missing], [diff("a.ts", "", "content")])
expect(result).toEqual([valid])
})
})
// ── formatReviewCommentsMarkdown ────────────────────────────────────────────
describe("formatReviewCommentsMarkdown", () => {
it("returns header only for empty array", () => {
const result = formatReviewCommentsMarkdown([])
expect(result).toBe("## Review Comments\n")
})
it("formats a single comment without selected text", () => {
const result = formatReviewCommentsMarkdown([
comment({ file: "src/a.ts", line: 5, comment: "Fix this", selectedText: "" }),
])
expect(result).toContain("**src/a.ts** (line 5):")
expect(result).toContain("Fix this")
expect(result).not.toContain("```")
})
it("includes code block for comment with selected text", () => {
const result = formatReviewCommentsMarkdown([
comment({ file: "a.ts", line: 1, comment: "Wrong return", selectedText: "return null" }),
])
expect(result).toContain("```\nreturn null\n```")
expect(result).toContain("Wrong return")
})
it("formats multiple comments in order", () => {
const result = formatReviewCommentsMarkdown([
comment({ file: "a.ts", line: 1, comment: "First" }),
comment({ file: "b.ts", line: 10, comment: "Second", selectedText: "code" }),
])
const firstIdx = result.indexOf("**a.ts** (line 1):")
const secondIdx = result.indexOf("**b.ts** (line 10):")
expect(firstIdx).toBeLessThan(secondIdx)
})
})
// ── extractLines ────────────────────────────────────────────────────────────
describe("extractLines", () => {
const content = "alpha\nbeta\ngamma\ndelta"
it("extracts a single line (1-indexed)", () => {
expect(extractLines(content, 1, 1)).toBe("alpha")
expect(extractLines(content, 2, 2)).toBe("beta")
expect(extractLines(content, 4, 4)).toBe("delta")
})
it("extracts a range of lines", () => {
expect(extractLines(content, 2, 3)).toBe("beta\ngamma")
})
it("extracts from first to last", () => {
expect(extractLines(content, 1, 4)).toBe("alpha\nbeta\ngamma\ndelta")
})
it("returns empty string for out-of-range start", () => {
expect(extractLines(content, 10, 10)).toBe("")
})
it("handles empty content", () => {
expect(extractLines("", 1, 1)).toBe("")
})
})
// ── getDirectory / getFilename ──────────────────────────────────────────────
describe("getDirectory", () => {
it("returns empty string for root-level file", () => {
expect(getDirectory("file.ts")).toBe("")
})
it("returns directory path with trailing slash", () => {
expect(getDirectory("src/file.ts")).toBe("src/")
})
it("handles deeply nested paths", () => {
expect(getDirectory("a/b/c/d.ts")).toBe("a/b/c/")
})
})
describe("getFilename", () => {
it("returns the full name for root-level file", () => {
expect(getFilename("file.ts")).toBe("file.ts")
})
it("returns just the filename from a path", () => {
expect(getFilename("src/components/Button.tsx")).toBe("Button.tsx")
})
})
@@ -73,10 +73,14 @@ import { formatRelativeDate } from "../src/utils/date"
import { useImageAttachments } from "../src/hooks/useImageAttachments"
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, LOCAL } from "./navigate"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
import { ConstrainDragYAxis, SortableTab } from "./sortable-tab"
import { ConstrainDragYAxis, SortableReviewTab, SortableTab } from "./sortable-tab"
import { DiffPanel } from "./DiffPanel"
import { FullScreenDiffView } from "./FullScreenDiffView"
import type { ReviewComment } from "./review-comments"
import "./agent-manager.css"
const REVIEW_TAB_ID = "review"
interface SetupState {
active: boolean
message: string
@@ -306,6 +310,13 @@ const AgentManagerContent: Component = () => {
const [diffLoading, setDiffLoading] = createSignal(false)
const [diffWidth, setDiffWidth] = createSignal(Math.round(window.innerWidth * 0.5))
// Full-screen review state (in-memory, per worktree)
const [reviewOpenByWorktree, setReviewOpenByWorktree] = createSignal<Record<string, boolean>>({})
const [reviewCommentsByWorktree, setReviewCommentsByWorktree] = createSignal<Record<string, ReviewComment[]>>({})
const [reviewActive, setReviewActive] = createSignal(false)
const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified")
// reviewOpen (memo below) controls tab presence for selected worktree.
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
const [worktreeStats, setWorktreeStats] = createSignal<Record<string, WorktreeGitStats>>({})
@@ -317,6 +328,37 @@ const AgentManagerContent: Component = () => {
// Per-context tab memory: maps sidebar selection key -> last active session/pending ID
const [tabMemory, setTabMemory] = createSignal<Record<string, string>>({})
const reviewOpen = createMemo(() => {
const sel = selection()
if (!sel || sel === LOCAL) return false
return reviewOpenByWorktree()[sel] === true
})
const setReviewOpenForWorktree = (worktreeId: string, open: boolean) => {
setReviewOpenByWorktree((prev) => {
if (prev[worktreeId] === open) return prev
return { ...prev, [worktreeId]: open }
})
}
const setReviewOpenForSelection = (open: boolean) => {
const sel = selection()
if (!sel || sel === LOCAL) return
setReviewOpenForWorktree(sel, open)
}
const reviewComments = createMemo(() => {
const sel = selection()
if (!sel || sel === LOCAL) return [] as ReviewComment[]
return reviewCommentsByWorktree()[sel] ?? []
})
const setReviewCommentsForSelection = (comments: ReviewComment[]) => {
const sel = selection()
if (!sel || sel === LOCAL) return
setReviewCommentsByWorktree((prev) => ({ ...prev, [sel]: comments }))
}
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
// Drag-and-drop state for tab reordering
@@ -345,7 +387,7 @@ const AgentManagerContent: Component = () => {
const sel = selection()
if (sel === null) return
const key = sel === LOCAL ? LOCAL : sel
const active = session.currentSessionID() ?? activePendingId()
const active = reviewActive() ? REVIEW_TAB_ID : (session.currentSessionID() ?? activePendingId())
if (active) {
setTabMemory((prev) => (prev[key] === active ? prev : { ...prev, [key]: active }))
}
@@ -362,6 +404,23 @@ const AgentManagerContent: Component = () => {
}
})
// Drop in-memory review state for worktrees that no longer exist.
createEffect(() => {
const ids = new Set(worktrees().map((wt) => wt.id))
setReviewOpenByWorktree((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))
if (Object.keys(next).length === Object.keys(prev).length) return prev
return next
})
setReviewCommentsByWorktree((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))
if (Object.keys(next).length === Object.keys(prev).length) return prev
return next
})
})
const worktreeSessionIds = createMemo(
() =>
new Set(
@@ -427,11 +486,24 @@ const AgentManagerContent: Component = () => {
return false
})
createEffect(() => {
const sel = selection()
if (!sel || sel === LOCAL) {
if (reviewActive()) setReviewActive(false)
return
}
if (reviewActive() && !reviewOpen()) {
setReviewActive(false)
}
})
// Read-only mode: viewing an unassigned session (not in a worktree or local)
const readOnly = createMemo(() => selection() === null && !!session.currentSessionID())
// Tab scroll: hidden scrollbar with fade overflow indicators
const visibleTabId = createMemo(() => session.currentSessionID() ?? activePendingId())
const visibleTabId = createMemo(() =>
reviewActive() ? REVIEW_TAB_ID : (session.currentSessionID() ?? activePendingId()),
)
const tabScroll = useTabScroll(activeTabs, visibleTabId)
// Display name for worktree — prefers persisted label, then first session title, then branch
@@ -523,6 +595,7 @@ const AgentManagerContent: Component = () => {
} else {
saveTabMemory()
setSelection(null)
setReviewActive(false)
session.selectSession(item.id)
}
@@ -548,25 +621,34 @@ const AgentManagerContent: Component = () => {
// Navigate tabs with Cmd+Left/Right
const navigateTab = (direction: "left" | "right") => {
const tabs = activeTabs()
if (tabs.length === 0) return
const current = session.currentSessionID()
// Find current index — if no current session, look for the active pending tab
const idx = current ? tabs.findIndex((s) => s.id === current) : tabs.findIndex((s) => s.id === activePendingId())
const ids = tabIds()
if (ids.length === 0) return
const current = reviewActive() ? REVIEW_TAB_ID : (session.currentSessionID() ?? activePendingId() ?? "")
const idx = ids.indexOf(current)
if (idx === -1) return
const next = direction === "left" ? idx - 1 : idx + 1
if (next < 0 || next >= tabs.length) return
const target = tabs[next]!
if (next < 0 || next >= ids.length) return
const targetId = ids[next]!
if (targetId === REVIEW_TAB_ID) {
if (!reviewOpen()) setReviewOpenForSelection(true)
setReviewActive(true)
return
}
const target = tabLookup().get(targetId)
if (!target) return
setReviewActive(false)
if (isPending(target.id)) {
setActivePendingId(target.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(target.id)
return
}
setActivePendingId(undefined)
session.selectSession(target.id)
}
const selectLocal = () => {
saveTabMemory()
setReviewActive(false)
setSelection(LOCAL)
vscode.postMessage({ type: "agentManager.requestRepoInfo" })
const locals = localSessions()
@@ -601,6 +683,7 @@ const AgentManagerContent: Component = () => {
} else {
session.setCurrentSessionID(undefined)
}
setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByWorktree()[worktreeId] === true)
}
onMount(() => {
@@ -616,7 +699,12 @@ const AgentManagerContent: Component = () => {
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" })
} else if (msg.action === "toggleDiff") {
setDiffOpen((prev) => !prev)
if (reviewActive()) {
closeReviewTab()
setDiffOpen(true)
} else {
setDiffOpen((prev) => !prev)
}
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
else if (msg.action === "closeTab") closeActiveTab()
else if (msg.action === "newWorktree") handleNewWorktreeOrPromote()
@@ -743,6 +831,9 @@ const AgentManagerContent: Component = () => {
// server won't connect to send the sessionsLoaded message.
if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true)
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") {
setReviewDiffStyle(state.reviewDiffStyle)
}
const current = session.currentSessionID()
if (current) {
const ms = state.sessions.find((s) => s.id === current)
@@ -881,36 +972,90 @@ const AgentManagerContent: Component = () => {
}
})
// Start/stop diff watch when panel opens/closes or session changes
// Start/stop diff watch when panel opens/closes, review tab opens, or session changes
createEffect(() => {
const open = diffOpen()
const panel = diffOpen()
const review = reviewActive()
const sel = selection()
const id = session.currentSessionID()
if (open) {
if (panel) {
if (sel === LOCAL) {
// For local tab, diff against unpushed changes using LOCAL sentinel
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: LOCAL })
return
} else if (id) {
const ms = managedSessions().find((s) => s.id === id)
if (ms?.worktreeId) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id })
} else {
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
return
}
} else {
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
}
} else {
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
return
}
if (review) {
// Review tab is open but no specific session — try using any session in the worktree
const sel = selection()
if (sel && sel !== LOCAL) {
const managed = managedSessions().find((ms) => ms.worktreeId === sel)
if (managed) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: managed.id })
return
}
}
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
return
}
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
})
onCleanup(() => {
if (diffOpen() || reviewActive()) {
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
}
})
onCleanup(() => {
if (diffOpen()) {
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
const openReviewTab = () => {
const sel = selection()
if (!sel || sel === LOCAL) return
setDiffOpen(false)
setReviewOpenForWorktree(sel, true)
setReviewActive(true)
}
// Deferred close: flip signal immediately for instant UI feedback,
// the <Show> unmount triggers heavy FileDiff cleanup but the tab bar
// and chat view are already visible before that work runs.
const closeReviewTab = () => {
setReviewActive(false)
setReviewOpenForSelection(false)
}
// Data for the review tab: use current session's diff data, or first available for the worktree
const reviewDiffs = createMemo(() => {
const data = diffDatas()
const sel = selection()
const id = session.currentSessionID()
if (id && data[id]) {
const current = managedSessions().find((s) => s.id === id)
if (sel && sel !== LOCAL && current?.worktreeId === sel) return data[id]!
}
if (!sel || sel === LOCAL) return []
const ids = managedSessions()
.filter((s) => s.worktreeId === sel)
.map((s) => s.id)
for (const sid of ids) {
if (data[sid]) return data[sid]!
}
return []
})
const setSharedDiffStyle = (style: "unified" | "split") => {
if (reviewDiffStyle() === style) return
setReviewDiffStyle(style)
vscode.postMessage({ type: "agentManager.setReviewDiffStyle", style })
}
const handleConfigureSetupScript = () => {
vscode.postMessage({ type: "agentManager.configureSetupScript" })
}
@@ -1055,8 +1200,25 @@ const AgentManagerContent: Component = () => {
}
}
const handleReviewTabMouseDown = (e: MouseEvent) => {
if (e.button !== 1) return
e.preventDefault()
e.stopPropagation()
closeReviewTab()
}
// Drag-and-drop handlers for tab reordering
const tabIds = createMemo(() => activeTabs().map((s) => s.id))
const tabLookup = createMemo(() => new Map(activeTabs().map((s) => [s.id, s])))
const tabIds = createMemo(() => {
const ids = activeTabs().map((s) => s.id)
const sel = selection()
if (!sel || sel === LOCAL) return ids
const current = reviewOpen() ? [...ids, REVIEW_TAB_ID] : ids
return applyTabOrder(
current.map((id) => ({ id })),
worktreeTabOrder()[sel],
).map((item) => item.id)
})
const handleDragStart = (event: DragEvent) => {
const id = event.draggable?.id
@@ -1070,13 +1232,14 @@ const AgentManagerContent: Component = () => {
const sel = selection()
if (sel === LOCAL) {
setLocalSessionIDs((prev) => reorderTabs(prev, from, to) ?? prev)
} else if (sel) {
return
}
if (sel) {
setWorktreeTabOrder((prev) => {
const ids = applyTabOrder(
tabIds().map((id) => ({ id })),
prev[sel],
).map((item) => item.id)
const reordered = reorderTabs(ids, from, to)
const ids = activeTabs().map((s) => ({ id: s.id }))
if (reviewOpen()) ids.push({ id: REVIEW_TAB_ID })
const current = applyTabOrder(ids, prev[sel]).map((item) => item.id)
const reordered = reorderTabs(current, from, to)
if (!reordered) return prev
return { ...prev, [sel]: reordered }
})
@@ -1090,21 +1253,28 @@ const AgentManagerContent: Component = () => {
if (sel === LOCAL) {
const order = localSessionIDs().filter((id) => !isPending(id))
if (order.length > 0) vscode.postMessage({ type: "agentManager.setTabOrder", key: LOCAL, order })
} else if (sel) {
const order = worktreeTabOrder()[sel]
if (order) vscode.postMessage({ type: "agentManager.setTabOrder", key: sel, order })
return
}
if (sel) {
const order = tabIds().filter((id) => id !== REVIEW_TAB_ID)
if (order.length > 0) vscode.postMessage({ type: "agentManager.setTabOrder", key: sel, order })
}
}
const draggedTab = createMemo(() => {
const id = draggingTab()
if (!id) return undefined
if (id === REVIEW_TAB_ID) return { id, title: t("session.tab.review") }
return activeTabs().find((s) => s.id === id)
})
// Close the currently active tab via keyboard shortcut.
// If no tabs remain, fall through to close the selected worktree.
const closeActiveTab = () => {
if (reviewActive()) {
closeReviewTab()
return
}
const tabs = activeTabs()
if (tabs.length === 0) {
closeSelectedWorktree()
@@ -1579,6 +1749,7 @@ const AgentManagerContent: Component = () => {
onClick={() => {
saveTabMemory()
setSelection(null)
setReviewActive(false)
session.selectSession(s.id)
}}
>
@@ -1624,8 +1795,38 @@ const AgentManagerContent: Component = () => {
<div class={`am-tab-fade am-tab-fade-left ${tabScroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list" ref={tabScroll.setRef}>
<SortableProvider ids={tabIds()}>
<For each={activeTabs()}>
{(s) => {
<For each={tabIds()}>
{(id) => {
if (id === REVIEW_TAB_ID) {
const ids = tabIds()
const activeId = reviewActive()
? REVIEW_TAB_ID
: (session.currentSessionID() ?? activePendingId() ?? "")
const tabDirection = reviewActive()
? ""
: adjacentHint(REVIEW_TAB_ID, activeId, ids, kb().previousTab ?? "", kb().nextTab ?? "")
return (
<SortableReviewTab
id={REVIEW_TAB_ID}
label={t("session.tab.review")}
tooltip={t("command.review.toggle")}
keybind={tabDirection}
closeKeybind={kb().closeTab ?? ""}
active={reviewActive()}
onSelect={() => setReviewActive(true)}
onMiddleClick={handleReviewTabMouseDown}
onClose={(e: MouseEvent) => {
e.stopPropagation()
closeReviewTab()
}}
/>
)
}
const s = tabLookup().get(id)
if (!s) return null
const pending = isPending(s.id)
const active = () =>
pending
@@ -1633,24 +1834,28 @@ const AgentManagerContent: Component = () => {
: s.id === session.currentSessionID()
const tabDirection = () => {
if (active()) return ""
const ids = activeTabs().map((t) => t.id)
const activeId = session.currentSessionID() ?? activePendingId() ?? ""
const ids = tabIds()
const activeId = reviewActive()
? REVIEW_TAB_ID
: (session.currentSessionID() ?? activePendingId() ?? "")
return adjacentHint(s.id, activeId, ids, kb().previousTab ?? "", kb().nextTab ?? "")
}
return (
<SortableTab
tab={s}
active={active()}
active={active() && !reviewActive()}
keybind={tabDirection()}
closeKeybind={kb().closeTab ?? ""}
onSelect={() => {
setReviewActive(false)
if (pending) {
setActivePendingId(s.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(s.id)
return
}
setActivePendingId(undefined)
session.selectSession(s.id)
}}
onMiddleClick={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
onClose={(e: MouseEvent) => handleCloseTab(s.id, e)}
@@ -1688,8 +1893,15 @@ const AgentManagerContent: Component = () => {
placement="bottom"
>
<button
class={`am-diff-toggle-btn ${diffOpen() ? "am-tab-diff-btn-active" : ""} ${hasChanges() ? "am-diff-toggle-has-changes" : ""}`}
onClick={() => setDiffOpen((prev) => !prev)}
class={`am-diff-toggle-btn ${diffOpen() && !reviewActive() ? "am-tab-diff-btn-active" : ""} ${hasChanges() ? "am-diff-toggle-has-changes" : ""}`}
onClick={() => {
if (reviewActive()) {
closeReviewTab()
setDiffOpen(true)
return
}
setDiffOpen((prev) => !prev)
}}
title={t("agentManager.diff.toggle")}
>
<Icon name="layers" size="small" />
@@ -1703,6 +1915,18 @@ const AgentManagerContent: Component = () => {
</TooltipKeybind>
)
})()}
<Show when={selection() !== LOCAL}>
<Tooltip value={t("command.review.toggle")} placement="bottom">
<IconButton
icon="expand"
size="small"
variant="ghost"
label={t("command.review.toggle")}
class={reviewActive() ? "am-tab-diff-btn-active" : ""}
onClick={openReviewTab}
/>
</Tooltip>
</Show>
<TooltipKeybind
title={t("agentManager.tab.terminal")}
keybind={kb().showTerminal ?? ""}
@@ -1791,7 +2015,11 @@ const AgentManagerContent: Component = () => {
)
})()}
<Show when={!contextEmpty()}>
<div class={`am-detail-content ${diffOpen() ? "am-detail-split" : ""}`}>
{/* Chat + side diff panel (hidden when review tab is active) */}
<div
class={`am-detail-content ${diffOpen() ? "am-detail-split" : ""}`}
style={{ display: reviewActive() ? "none" : undefined }}
>
<div class="am-chat-wrapper">
<ChatView
onSelectSession={(id) => {
@@ -1818,7 +2046,7 @@ const AgentManagerContent: Component = () => {
</Show>
</div>
<Show when={diffOpen()}>
<div class="am-diff-panel-wrapper" style={{ width: `${diffWidth()}px`, "flex-shrink": "0" }}>
<div class="am-diff-resize" style={{ width: `${diffWidth()}px` }}>
<ResizeHandle
direction="horizontal"
edge="start"
@@ -1827,18 +2055,40 @@ const AgentManagerContent: Component = () => {
max={Math.round(window.innerWidth * 0.8)}
onResize={(w) => setDiffWidth(Math.max(200, Math.min(w, window.innerWidth * 0.8)))}
/>
<DiffPanel
diffs={diffDatas()[selection() === LOCAL ? LOCAL : (session.currentSessionID() ?? "")] ?? []}
loading={diffLoading()}
onClose={() => setDiffOpen(false)}
onOpenFile={(file) => {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file })
}}
/>
<div class="am-diff-panel-wrapper">
<DiffPanel
diffs={diffDatas()[selection() === LOCAL ? LOCAL : (session.currentSessionID() ?? "")] ?? []}
loading={diffLoading()}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
onClose={() => setDiffOpen(false)}
onExpand={selection() !== LOCAL ? openReviewTab : undefined}
onOpenFile={(file) => {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file })
}}
/>
</div>
</div>
</Show>
</div>
{/* Full-screen review tab (lazy-mounted, stays alive once opened for fast toggle) */}
<Show when={reviewOpen()}>
<div class="am-review-host" style={{ display: reviewActive() ? undefined : "none" }}>
<FullScreenDiffView
diffs={reviewDiffs()}
loading={diffLoading()}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
onSendAll={closeReviewTab}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
onClose={closeReviewTab}
/>
</div>
</Show>
</Show>
</div>
</div>
@@ -6,73 +6,86 @@ import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import type { DiffLineAnnotation, AnnotationSide } from "@pierre/diffs"
import type { WorktreeFileDiff } from "../src/types/messages"
import { useLanguage } from "../src/context/language"
import {
formatReviewCommentsMarkdown,
getDirectory,
getFilename,
sanitizeReviewComments,
type ReviewComment,
} from "./review-comments"
import { buildReviewAnnotation, type AnnotationLabels, type AnnotationMeta } from "./review-annotations"
// --- Data model ---
interface ReviewComment {
id: string
file: string
side: AnnotationSide
line: number
comment: string
selectedText: string
}
// Annotation metadata — kept as stable references for pierre's cache
interface AnnotationMeta {
type: "comment" | "draft"
comment: ReviewComment | null
file: string
side: AnnotationSide
line: number
}
interface DiffPanelProps {
diffs: WorktreeFileDiff[]
loading: boolean
diffStyle?: "unified" | "split"
onDiffStyleChange?: (style: "unified" | "split") => void
comments: ReviewComment[]
onCommentsChange: (comments: ReviewComment[]) => void
onSendAll?: () => void
onClose: () => void
onExpand?: () => void
onOpenFile?: (relativePath: string) => void
}
function getDirectory(path: string): string {
const idx = path.lastIndexOf("/")
return idx === -1 ? "" : path.slice(0, idx + 1)
}
function getFilename(path: string): string {
const idx = path.lastIndexOf("/")
return idx === -1 ? path : path.slice(idx + 1)
}
function extractLines(content: string, start: number, end: number): string {
return content
.split("\n")
.slice(start - 1, end)
.join("\n")
}
export const DiffPanel: Component<DiffPanelProps> = (props) => {
const { t } = useLanguage()
const [comments, setComments] = createSignal<ReviewComment[]>([])
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
const sendAllKeybind = () =>
isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other")
const labels = (): AnnotationLabels => ({
commentOnLine: (line) => t("agentManager.review.commentOnLine", { line }),
editCommentOnLine: (line) => t("agentManager.review.editCommentOnLine", { line }),
placeholder: t("agentManager.review.commentPlaceholder"),
cancel: t("common.cancel"),
comment: t("agentManager.review.commentAction"),
save: t("common.save"),
sendToChat: t("agentManager.review.sendToChat"),
edit: t("common.edit"),
delete: t("common.delete"),
})
const [open, setOpen] = createSignal<string[]>([])
const [openInit, setOpenInit] = createSignal(false)
const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number } | null>(null)
const [editing, setEditing] = createSignal<string | null>(null)
let nextId = 0
const comments = () => props.comments
const setComments = (next: ReviewComment[]) => props.onCommentsChange(next)
const updateComments = (updater: (prev: ReviewComment[]) => ReviewComment[]) => setComments(updater(comments()))
// Stable draft metadata ref — avoids recreating the object on every signal read
// so pierre's annotation cache doesn't invalidate and destroy the textarea
let draftMeta: AnnotationMeta | null = null
// Ref to the scrollable container — used to preserve scroll position when
// annotation changes cause pierre to fully re-render diffs
let rootRef: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined
const focusRoot = () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
rootRef?.focus()
})
})
}
const keepNativeFocus = (target: EventTarget | null) => {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return true
if (target instanceof HTMLElement && target.isContentEditable) return true
return false
}
// Run a callback while preserving the scroll position of the diff container.
// Pierre destroys and rebuilds the DOM on annotation changes (via innerHTML = ""),
// which resets scrollTop. We capture it before the update and restore it across
@@ -95,6 +108,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
setDraft(null)
draftMeta = null
})
focusRoot()
}
// Auto-open files when diffs arrive
@@ -102,7 +116,12 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
on(
() => props.diffs,
(diffs) => {
if (diffs.length <= 15) setOpen(diffs.map((d) => d.file))
const files = diffs.map((d) => d.file)
setOpen((prev) => prev.filter((file) => files.includes(file)))
if (openInit()) return
if (diffs.length === 0) return
if (diffs.length <= 15) setOpen(files)
setOpenInit(true)
},
),
)
@@ -112,26 +131,66 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const addComment = (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => {
preserveScroll(() => {
const id = `c-${++nextId}-${Date.now()}`
setComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }])
updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }])
setDraft(null)
draftMeta = null
})
focusRoot()
}
const updateComment = (id: string, text: string) => {
preserveScroll(() => {
setComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c)))
updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c)))
setEditing(null)
})
focusRoot()
}
const deleteComment = (id: string) => {
preserveScroll(() => {
setComments((prev) => prev.filter((c) => c.id !== id))
updateComments((prev) => prev.filter((c) => c.id !== id))
if (editing() === id) setEditing(null)
})
focusRoot()
}
const setEditState = (id: string | null) => {
preserveScroll(() => setEditing(id))
if (id === null) focusRoot()
}
createEffect(
on(
() => [props.diffs, comments()] as const,
([diffs, current]) => {
const valid = sanitizeReviewComments(current, diffs)
if (valid.length !== current.length) {
setComments(valid)
}
const edit = editing()
if (edit && !valid.some((comment) => comment.id === edit)) {
setEditing(null)
}
const currentDraft = draft()
if (!currentDraft) return
const diff = diffs.find((item) => item.file === currentDraft.file)
if (!diff) {
setDraft(null)
draftMeta = null
return
}
const content = currentDraft.side === "deletions" ? diff.before : diff.after
const max = content.length === 0 ? 0 : content.split("\n").length
if (currentDraft.line < 1 || currentDraft.line > max) {
setDraft(null)
draftMeta = null
}
},
),
)
// --- Per-file memoized annotations ---
const commentsByFile = createMemo(() => {
@@ -163,246 +222,114 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
return result
}
// Compute commentedLines ranges for visual highlights on lines with comments
const commentedLinesForFile = (file: string): SelectedLineRange[] => {
const fileComments = commentsByFile().get(file) ?? []
return fileComments.map((c) => ({
start: c.line,
end: c.line,
side: c.side,
}))
}
// Focus a textarea once it's connected to the DOM (pierre renders async via slots)
const focusWhenConnected = (el: HTMLTextAreaElement) => {
let attempts = 0
const tick = () => {
if (el.isConnected) {
el.focus()
return
}
if (++attempts < 20) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
// --- renderAnnotation (vanilla DOM — called by pierre) ---
const buildAnnotation = (annotation: DiffLineAnnotation<AnnotationMeta>): HTMLElement | undefined => {
const meta = annotation.metadata
if (!meta) return undefined
return buildReviewAnnotation(annotation, {
diffs: props.diffs,
editing: editing(),
setEditing: setEditState,
addComment,
updateComment,
deleteComment,
cancelDraft,
labels: labels(),
})
}
const wrapper = document.createElement("div")
if (meta.type === "draft") {
wrapper.className = "am-annotation am-annotation-draft"
const header = document.createElement("div")
header.className = "am-annotation-header"
header.textContent = `Comment on line ${meta.line}`
const textarea = document.createElement("textarea")
textarea.className = "am-annotation-textarea"
textarea.rows = 3
textarea.placeholder = "Leave a comment..."
const actions = document.createElement("div")
actions.className = "am-annotation-actions"
const cancelBtn = document.createElement("button")
cancelBtn.className = "am-annotation-btn"
cancelBtn.textContent = "Cancel"
const submitBtn = document.createElement("button")
submitBtn.className = "am-annotation-btn am-annotation-btn-submit"
submitBtn.textContent = "Comment"
actions.appendChild(cancelBtn)
actions.appendChild(submitBtn)
wrapper.appendChild(header)
wrapper.appendChild(textarea)
wrapper.appendChild(actions)
focusWhenConnected(textarea)
const submit = () => {
const text = textarea.value.trim()
if (!text) return
// Extract selected text from the diff content
const diff = props.diffs.find((d) => d.file === meta.file)
const content = meta.side === "deletions" ? (diff?.before ?? "") : (diff?.after ?? "")
const selected = extractLines(content, meta.line, meta.line)
addComment(meta.file, meta.side, meta.line, text, selected)
}
cancelBtn.addEventListener("click", (e) => {
e.stopPropagation()
cancelDraft()
})
submitBtn.addEventListener("click", (e) => {
e.stopPropagation()
submit()
})
textarea.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault()
cancelDraft()
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
})
return wrapper
}
// Existing comment — check if in edit mode
const c = meta.comment!
const isEditing = editing() === c.id
if (isEditing) {
wrapper.className = "am-annotation am-annotation-draft"
const header = document.createElement("div")
header.className = "am-annotation-header"
header.textContent = `Edit comment on line ${c.line}`
const textarea = document.createElement("textarea")
textarea.className = "am-annotation-textarea"
textarea.rows = 3
textarea.value = c.comment
const actions = document.createElement("div")
actions.className = "am-annotation-actions"
const cancelBtn = document.createElement("button")
cancelBtn.className = "am-annotation-btn"
cancelBtn.textContent = "Cancel"
const saveBtn = document.createElement("button")
saveBtn.className = "am-annotation-btn am-annotation-btn-submit"
saveBtn.textContent = "Save"
actions.appendChild(cancelBtn)
actions.appendChild(saveBtn)
wrapper.appendChild(header)
wrapper.appendChild(textarea)
wrapper.appendChild(actions)
focusWhenConnected(textarea)
cancelBtn.addEventListener("click", (e) => {
e.stopPropagation()
setEditing(null)
})
saveBtn.addEventListener("click", (e) => {
e.stopPropagation()
const text = textarea.value.trim()
if (text) updateComment(c.id, text)
})
textarea.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault()
setEditing(null)
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
const text = textarea.value.trim()
if (text) updateComment(c.id, text)
}
})
return wrapper
}
// Read-only comment — no code quote or line label since the annotation
// is visually anchored right below the relevant line
wrapper.className = "am-annotation"
const body = document.createElement("div")
body.className = "am-annotation-comment"
const text = document.createElement("div")
text.className = "am-annotation-comment-text"
text.textContent = c.comment
body.appendChild(text)
const btns = document.createElement("div")
btns.className = "am-annotation-comment-actions"
const makeBtn = (title: string, svg: string, action: () => void) => {
const btn = document.createElement("button")
btn.className = "am-annotation-icon-btn"
btn.title = title
btn.innerHTML = svg
btn.addEventListener("click", (e) => {
e.stopPropagation()
action()
})
return btn
}
btns.appendChild(
makeBtn(
"Send to chat",
'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M1 1l14 7-14 7V9l10-1L1 7z"/></svg>',
() => {
const quote = c.selectedText ? `\n> \`\`\`\n> ${c.selectedText.split("\n").join("\n> ")}\n> \`\`\`\n` : ""
const msg = `**${c.file}** (line ${c.line}):${quote}\n${c.comment}`
window.dispatchEvent(new MessageEvent("message", { data: { type: "appendChatBoxMessage", text: msg } }))
deleteComment(c.id)
},
),
)
btns.appendChild(
makeBtn(
"Edit",
'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M13.2 1.1l1.7 1.7-1.1 1.1-1.7-1.7zM1 11.5V13.2h1.7l7.8-7.8-1.7-1.7z"/></svg>',
() => setEditing(c.id),
),
)
btns.appendChild(
makeBtn(
"Delete",
'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm3.1 9.3l-.8.8L8 8.8l-2.3 2.3-.8-.8L7.2 8 4.9 5.7l.8-.8L8 7.2l2.3-2.3.8.8L8.8 8z"/></svg>',
() => deleteComment(c.id),
),
)
wrapper.appendChild(body)
wrapper.appendChild(btns)
return wrapper
const handleRootMouseDown = (e: MouseEvent) => {
if (keepNativeFocus(e.target)) return
focusRoot()
}
// --- Gutter utility click ---
const handleGutterClick = (file: string, result: { lineNumber: number; side: AnnotationSide }) => {
// Don't open a second draft while one is active
if (draft()) return
setDraft({ file, side: result.side, line: result.lineNumber })
preserveScroll(() => {
setDraft({ file, side: result.side, line: result.lineNumber })
})
}
// --- Send all ---
const sendAllToChat = () => {
const all = comments()
if (all.length === 0) return
const lines = ["## Review Comments", ""]
for (const c of all) {
lines.push(`**${c.file}** (line ${c.line}):`)
if (c.selectedText) {
lines.push("```")
lines.push(c.selectedText)
lines.push("```")
}
lines.push(c.comment)
lines.push("")
}
const text = lines.join("\n")
const text = formatReviewCommentsMarkdown(all)
window.dispatchEvent(new MessageEvent("message", { data: { type: "appendChatBoxMessage", text } }))
preserveScroll(() => setComments([]))
props.onSendAll?.()
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Enter") return
if (!(e.metaKey || e.ctrlKey)) return
const target = e.target
if (keepNativeFocus(target)) return
if (comments().length === 0) return
e.preventDefault()
e.stopPropagation()
sendAllToChat()
}
const totals = createMemo(() => ({
files: props.diffs.length,
additions: props.diffs.reduce((sum, diff) => sum + diff.additions, 0),
deletions: props.diffs.reduce((sum, diff) => sum + diff.deletions, 0),
}))
return (
<div class="am-diff-panel">
<div class="am-diff-panel" onKeyDown={handleKeyDown} onMouseDown={handleRootMouseDown} tabIndex={-1} ref={rootRef}>
<div class="am-diff-header">
<span class="am-diff-header-title">Changes</span>
<IconButton icon="close" size="small" variant="ghost" label="Close" onClick={props.onClose} />
<div class="am-diff-header-main">
<span class="am-diff-header-title">{t("session.review.change.other")}</span>
<Show when={props.diffs.length > 0}>
<>
<RadioGroup
options={["unified", "split"] as const}
current={props.diffStyle ?? "unified"}
size="small"
value={(style) => style}
label={(style) =>
style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split")
}
onSelect={(style) => {
if (!style) return
props.onDiffStyleChange?.(style)
}}
/>
<span class="am-diff-header-stats">
<span>{t("session.review.filesChanged", { count: totals().files })}</span>
<span class="am-diff-header-adds">+{totals().additions}</span>
<span class="am-diff-header-dels">-{totals().deletions}</span>
</span>
</>
</Show>
</div>
<div class="am-diff-header-actions">
<Show when={props.onExpand}>
<Tooltip value={t("command.review.toggle")} placement="bottom">
<IconButton
icon="expand"
size="small"
variant="ghost"
label={t("command.review.toggle")}
onClick={() => props.onExpand?.()}
/>
</Tooltip>
</Show>
<IconButton icon="close" size="small" variant="ghost" label={t("common.close")} onClick={props.onClose} />
</div>
</div>
<Show when={props.loading && props.diffs.length === 0}>
<div class="am-diff-loading">
<Spinner />
<span>Computing diff...</span>
<span>{t("session.review.loadingChanges")}</span>
</div>
</Show>
<Show when={!props.loading && props.diffs.length === 0}>
<div class="am-diff-empty">
<span>No changes detected</span>
<span>{t("session.review.noChanges")}</span>
</div>
</Show>
@@ -435,12 +362,12 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<div data-slot="session-review-trigger-actions">
<Show when={isAdded()}>
<span data-slot="session-review-change" data-type="added">
Added
{t("ui.sessionReview.change.added")}
</span>
</Show>
<Show when={isDeleted()}>
<span data-slot="session-review-change" data-type="removed">
Removed
{t("ui.sessionReview.change.removed")}
</span>
</Show>
<Show when={!isAdded() && !isDeleted()}>
@@ -472,8 +399,8 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<Diff<AnnotationMeta>
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
diffStyle={props.diffStyle ?? "unified"}
annotations={annotationsForFile(diff.file)}
commentedLines={commentedLinesForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)}
@@ -492,9 +419,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<span class="am-diff-comments-count">
{comments().length} comment{comments().length !== 1 ? "s" : ""}
</span>
<Button variant="primary" size="small" onClick={sendAllToChat}>
Send all to chat
</Button>
<TooltipKeybind title={t("agentManager.review.sendAllToChat")} keybind={sendAllKeybind()} placement="top">
<Button variant="primary" size="small" onClick={sendAllToChat}>
{t("agentManager.review.sendAllToChat")}
</Button>
</TooltipKeybind>
</div>
</Show>
</Show>
@@ -0,0 +1,142 @@
import { type Component, createSignal, createMemo, For, Show } from "solid-js"
import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { Icon } from "@kilocode/kilo-ui/icon"
import type { WorktreeFileDiff } from "../src/types/messages"
import { useLanguage } from "../src/context/language"
import { buildFileTree, flatten, type FileTreeNode } from "./file-tree-utils"
export type { FileTreeNode } from "./file-tree-utils"
export { buildFileTree, flatten, flattenChain } from "./file-tree-utils"
interface FileTreeProps {
diffs: WorktreeFileDiff[]
activeFile: string | null
onFileSelect: (path: string) => void
}
const DirectoryNode: Component<{
node: FileTreeNode
activeFile: string | null
onFileSelect: (path: string) => void
depth: number
}> = (props) => {
const [expanded, setExpanded] = createSignal(true)
const hasActiveDescendant = createMemo(() => {
if (!props.activeFile) return false
return props.activeFile.startsWith(props.node.path + "/")
})
return (
<div class="am-file-tree-group">
<button
class={`am-file-tree-dir ${hasActiveDescendant() ? "am-file-tree-dir-highlight" : ""}`}
style={{ "padding-left": `${8 + props.depth * 12}px` }}
onClick={() => setExpanded((p) => !p)}
>
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
<Icon name="folder" size="small" />
<span class="am-file-tree-name">{props.node.name}</span>
</button>
<Show when={expanded()}>
<For each={props.node.children ?? []}>
{(child) => (
<Show
when={child.children}
fallback={
<FileNode
node={child}
activeFile={props.activeFile}
onFileSelect={props.onFileSelect}
depth={props.depth + 1}
/>
}
>
<DirectoryNode
node={child}
activeFile={props.activeFile}
onFileSelect={props.onFileSelect}
depth={props.depth + 1}
/>
</Show>
)}
</For>
</Show>
</div>
)
}
const FileNode: Component<{
node: FileTreeNode
activeFile: string | null
onFileSelect: (path: string) => void
depth: number
}> = (props) => {
const active = () => props.activeFile === props.node.path
const status = () => props.node.diff?.status ?? "modified"
return (
<button
class={`am-file-tree-file ${active() ? "am-file-tree-active" : ""}`}
classList={{
"am-file-tree-status-added": status() === "added",
"am-file-tree-status-deleted": status() === "deleted",
"am-file-tree-status-modified": status() === "modified",
}}
style={{ "padding-left": `${8 + props.depth * 12}px` }}
onClick={() => props.onFileSelect(props.node.path)}
>
<FileIcon node={{ path: props.node.path, type: "file" }} />
<span class="am-file-tree-name">{props.node.name}</span>
<Show when={props.node.diff}>
{(diff) => (
<span class="am-file-tree-changes">
<Show when={diff().status === "added"}>
<span class="am-file-tree-badge-added">A</span>
</Show>
<Show when={diff().status === "deleted"}>
<span class="am-file-tree-badge-deleted">D</span>
</Show>
<Show when={diff().status !== "added" && diff().status !== "deleted"}>
<span class="am-file-tree-stat-add">+{diff().additions}</span>
<span class="am-file-tree-stat-del">-{diff().deletions}</span>
</Show>
</span>
)}
</Show>
</button>
)
}
export const FileTree: Component<FileTreeProps> = (props) => {
const { t } = useLanguage()
const tree = createMemo(() => flatten(buildFileTree(props.diffs)))
const totals = createMemo(() => {
const adds = props.diffs.reduce((s, d) => s + d.additions, 0)
const dels = props.diffs.reduce((s, d) => s + d.deletions, 0)
return { files: props.diffs.length, additions: adds, deletions: dels }
})
return (
<div class="am-file-tree">
<div class="am-file-tree-list">
<For each={tree()}>
{(node) => (
<Show
when={node.children}
fallback={
<FileNode node={node} activeFile={props.activeFile} onFileSelect={props.onFileSelect} depth={0} />
}
>
<DirectoryNode node={node} activeFile={props.activeFile} onFileSelect={props.onFileSelect} depth={0} />
</Show>
)}
</For>
</div>
<div class="am-file-tree-summary">
<span>{t("session.review.filesChanged", { count: totals().files })}</span>
<span class="am-file-tree-summary-adds">+{totals().additions}</span>
<span class="am-file-tree-summary-dels">-{totals().deletions}</span>
</div>
</div>
)
}
@@ -0,0 +1,498 @@
import { type Component, createSignal, createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Accordion } from "@kilocode/kilo-ui/accordion"
import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header"
import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { Icon } from "@kilocode/kilo-ui/icon"
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 type { DiffLineAnnotation, AnnotationSide } from "@pierre/diffs"
import type { WorktreeFileDiff } from "../src/types/messages"
import { useLanguage } from "../src/context/language"
import { FileTree } from "./FileTree"
import {
formatReviewCommentsMarkdown,
getDirectory,
getFilename,
sanitizeReviewComments,
type ReviewComment,
} from "./review-comments"
import { buildReviewAnnotation, type AnnotationLabels, type AnnotationMeta } from "./review-annotations"
type DiffStyle = "unified" | "split"
interface FullScreenDiffViewProps {
diffs: WorktreeFileDiff[]
loading: boolean
comments: ReviewComment[]
onCommentsChange: (comments: ReviewComment[]) => void
onSendAll?: () => void
diffStyle: DiffStyle
onDiffStyleChange: (style: DiffStyle) => void
onClose: () => void
}
export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) => {
const { t } = useLanguage()
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
const sendAllKeybind = () =>
isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other")
const labels = (): AnnotationLabels => ({
commentOnLine: (line) => t("agentManager.review.commentOnLine", { line }),
editCommentOnLine: (line) => t("agentManager.review.editCommentOnLine", { line }),
placeholder: t("agentManager.review.commentPlaceholder"),
cancel: t("common.cancel"),
comment: t("agentManager.review.commentAction"),
save: t("common.save"),
sendToChat: t("agentManager.review.sendToChat"),
edit: t("common.edit"),
delete: t("common.delete"),
})
const [open, setOpen] = createSignal<string[]>([])
const [openInit, setOpenInit] = createSignal(false)
const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number } | null>(null)
const [editing, setEditing] = createSignal<string | null>(null)
const [activeFile, setActiveFile] = createSignal<string | null>(null)
const [treeWidth, setTreeWidth] = createSignal(240)
let nextId = 0
let draftMeta: AnnotationMeta | null = null
let rootRef: HTMLDivElement | undefined
let scrollRef: HTMLDivElement | undefined
let syncFrame: number | undefined
const comments = () => props.comments
const setComments = (next: ReviewComment[]) => props.onCommentsChange(next)
const updateComments = (updater: (prev: ReviewComment[]) => ReviewComment[]) => setComments(updater(comments()))
const focusRoot = () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
rootRef?.focus()
})
})
}
const keepNativeFocus = (target: EventTarget | null) => {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return true
if (target instanceof HTMLElement && target.isContentEditable) return true
return false
}
const preserveScroll = (fn: () => void) => {
const el = scrollRef
if (!el) return fn()
const top = el.scrollTop
fn()
requestAnimationFrame(() => {
el.scrollTop = top
requestAnimationFrame(() => {
el.scrollTop = top
})
})
}
const cancelDraft = () => {
preserveScroll(() => {
setDraft(null)
draftMeta = null
})
focusRoot()
}
// Auto-open files when diffs arrive
createEffect(
on(
() => props.diffs,
(diffs) => {
const files = diffs.map((d) => d.file)
setOpen((prev) => prev.filter((file) => files.includes(file)))
if (diffs.length === 0) {
setActiveFile(null)
return
}
if (!openInit()) {
if (diffs.length <= 15) setOpen(files)
setOpenInit(true)
}
const current = activeFile()
if (!current || !diffs.some((d) => d.file === current)) {
setActiveFile(diffs[0]!.file)
}
},
),
)
// --- CRUD ---
const addComment = (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => {
preserveScroll(() => {
const id = `c-${++nextId}-${Date.now()}`
updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }])
setDraft(null)
draftMeta = null
})
focusRoot()
}
const updateComment = (id: string, text: string) => {
preserveScroll(() => {
updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c)))
setEditing(null)
})
focusRoot()
}
const deleteComment = (id: string) => {
preserveScroll(() => {
updateComments((prev) => prev.filter((c) => c.id !== id))
if (editing() === id) setEditing(null)
})
focusRoot()
}
const setEditState = (id: string | null) => {
preserveScroll(() => setEditing(id))
if (id === null) focusRoot()
}
const handleRootMouseDown = (e: MouseEvent) => {
if (keepNativeFocus(e.target)) return
focusRoot()
}
createEffect(
on(
() => [props.diffs, comments()] as const,
([diffs, current]) => {
const valid = sanitizeReviewComments(current, diffs)
if (valid.length !== current.length) {
setComments(valid)
}
const edit = editing()
if (edit && !valid.some((comment) => comment.id === edit)) {
setEditing(null)
}
const currentDraft = draft()
if (!currentDraft) return
const diff = diffs.find((item) => item.file === currentDraft.file)
if (!diff) {
setDraft(null)
draftMeta = null
return
}
const content = currentDraft.side === "deletions" ? diff.before : diff.after
const max = content.length === 0 ? 0 : content.split("\n").length
if (currentDraft.line < 1 || currentDraft.line > max) {
setDraft(null)
draftMeta = null
}
},
),
)
// --- Per-file memoized annotations ---
const commentsByFile = createMemo(() => {
const map = new Map<string, ReviewComment[]>()
for (const c of comments()) {
const arr = map.get(c.file) ?? []
arr.push(c)
map.set(c.file, arr)
}
return map
})
const annotationsForFile = (file: string): DiffLineAnnotation<AnnotationMeta>[] => {
const fileComments = commentsByFile().get(file) ?? []
const result: DiffLineAnnotation<AnnotationMeta>[] = fileComments.map((c) => ({
side: c.side,
lineNumber: c.line,
metadata: { type: "comment" as const, comment: c, file: c.file, side: c.side, line: c.line },
}))
const d = draft()
if (d && d.file === file) {
if (!draftMeta || draftMeta.file !== d.file || draftMeta.side !== d.side || draftMeta.line !== d.line) {
draftMeta = { type: "draft", comment: null, file: d.file, side: d.side, line: d.line }
}
result.push({ side: d.side, lineNumber: d.line, metadata: draftMeta })
}
return result
}
const buildAnnotation = (annotation: DiffLineAnnotation<AnnotationMeta>): HTMLElement | undefined => {
return buildReviewAnnotation(annotation, {
diffs: props.diffs,
editing: editing(),
setEditing: setEditState,
addComment,
updateComment,
deleteComment,
cancelDraft,
labels: labels(),
})
}
const handleGutterClick = (file: string, result: { lineNumber: number; side: AnnotationSide }) => {
if (draft()) return
preserveScroll(() => {
setDraft({ file, side: result.side, line: result.lineNumber })
})
}
const sendAllToChat = () => {
const all = comments()
if (all.length === 0) return
const text = formatReviewCommentsMarkdown(all)
window.dispatchEvent(new MessageEvent("message", { data: { type: "appendChatBoxMessage", text } }))
preserveScroll(() => setComments([]))
props.onSendAll?.()
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Enter") return
if (!(e.metaKey || e.ctrlKey)) return
const target = e.target
if (keepNativeFocus(target)) return
if (comments().length === 0) return
e.preventDefault()
e.stopPropagation()
sendAllToChat()
}
const handleFileSelect = (path: string) => {
setActiveFile(path)
// Ensure the accordion is open for this file
if (!open().includes(path)) {
setOpen((prev) => [...prev, path])
}
// Scroll to the file in the diff viewer
requestAnimationFrame(() => {
const el = scrollRef?.querySelector(`[data-slot="accordion-item"][data-file-path="${CSS.escape(path)}"]`)
if (el instanceof HTMLElement) {
el.scrollIntoView({ block: "start", behavior: "smooth" })
}
})
}
const handleExpandAll = () => {
const allOpen = open().length === props.diffs.length
setOpen(allOpen ? [] : props.diffs.map((d) => d.file))
}
const syncActiveFileFromScroll = () => {
const container = scrollRef
if (!container) return
const headers = Array.from(container.querySelectorAll<HTMLElement>('[data-slot="accordion-item"][data-file-path]'))
if (headers.length === 0) return
const top = container.getBoundingClientRect().top + 1
const first = headers[0]?.dataset.filePath
const selected = headers.reduce<string | undefined>((carry, header) => {
const path = header.dataset.filePath
if (!path) return carry
if (header.getBoundingClientRect().top <= top) return path
return carry
}, first)
if (selected) setActiveFile(selected)
}
const scheduleSyncActiveFile = () => {
if (syncFrame !== undefined) cancelAnimationFrame(syncFrame)
syncFrame = requestAnimationFrame(() => {
syncFrame = undefined
syncActiveFileFromScroll()
})
}
// Keep file tree selection in sync with viewport during scroll in both directions.
createEffect(() => {
const container = scrollRef
if (!container) return
const onScroll = () => scheduleSyncActiveFile()
const resize = new ResizeObserver(() => scheduleSyncActiveFile())
container.addEventListener("scroll", onScroll, { passive: true })
resize.observe(container)
scheduleSyncActiveFile()
onCleanup(() => {
container.removeEventListener("scroll", onScroll)
resize.disconnect()
if (syncFrame !== undefined) {
cancelAnimationFrame(syncFrame)
syncFrame = undefined
}
})
})
createEffect(
on(
() => [props.diffs, open()] as const,
() => scheduleSyncActiveFile(),
),
)
const totals = createMemo(() => ({
files: props.diffs.length,
additions: props.diffs.reduce((s, d) => s + d.additions, 0),
deletions: props.diffs.reduce((s, d) => s + d.deletions, 0),
}))
return (
<div
class="am-review-layout"
onKeyDown={handleKeyDown}
onMouseDown={handleRootMouseDown}
tabIndex={-1}
ref={rootRef}
>
{/* Toolbar */}
<div class="am-review-toolbar">
<div class="am-review-toolbar-left">
<RadioGroup
options={["unified", "split"] as const}
current={props.diffStyle}
size="small"
value={(style) => style}
label={(style) =>
style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split")
}
onSelect={(style) => {
if (style) props.onDiffStyleChange(style)
}}
/>
<span class="am-review-toolbar-stats">
<span>{t("session.review.filesChanged", { count: totals().files })}</span>
<span class="am-review-toolbar-adds">+{totals().additions}</span>
<span class="am-review-toolbar-dels">-{totals().deletions}</span>
</span>
</div>
<div class="am-review-toolbar-right">
<Button size="small" variant="ghost" onClick={handleExpandAll}>
<Icon name="chevron-grabber-vertical" size="small" />
{open().length === props.diffs.length ? t("ui.sessionReview.collapseAll") : t("ui.sessionReview.expandAll")}
</Button>
<Show when={comments().length > 0}>
<TooltipKeybind
title={t("agentManager.review.sendAllToChat")}
keybind={sendAllKeybind()}
placement="bottom"
>
<Button variant="primary" size="small" onClick={sendAllToChat}>
{t("agentManager.review.sendAllToChatWithCount", { count: comments().length })}
</Button>
</TooltipKeybind>
</Show>
<IconButton icon="close" size="small" variant="ghost" label={t("common.close")} onClick={props.onClose} />
</div>
</div>
{/* Body: file tree + diff viewer */}
<div class="am-review-body">
<div class="am-review-tree-resize" style={{ width: `${treeWidth()}px` }}>
<div class="am-review-tree-wrapper">
<FileTree diffs={props.diffs} activeFile={activeFile()} onFileSelect={handleFileSelect} />
</div>
<ResizeHandle
direction="horizontal"
edge="end"
size={treeWidth()}
min={160}
max={400}
onResize={(w) => setTreeWidth(Math.max(160, Math.min(w, 400)))}
/>
</div>
<div class="am-review-diff" ref={scrollRef}>
<Show when={props.loading && props.diffs.length === 0}>
<div class="am-diff-loading">
<Spinner />
<span>{t("session.review.loadingChanges")}</span>
</div>
</Show>
<Show when={!props.loading && props.diffs.length === 0}>
<div class="am-diff-empty">
<span>{t("session.review.noChanges")}</span>
</div>
</Show>
<Show when={props.diffs.length > 0}>
<div class="am-review-diff-content" data-component="session-review">
<Accordion multiple value={open()} onChange={setOpen}>
<For each={props.diffs}>
{(diff) => {
const isAdded = () => diff.status === "added"
const isDeleted = () => diff.status === "deleted"
const fileCommentCount = () => (commentsByFile().get(diff.file) ?? []).length
return (
<Accordion.Item value={diff.file} data-file-path={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
<div data-slot="session-review-file-info">
<FileIcon node={{ path: diff.file, type: "file" }} />
<div data-slot="session-review-file-name-container">
<Show when={diff.file.includes("/")}>
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
</Show>
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
<Show when={fileCommentCount() > 0}>
<span class="am-diff-file-badge">{fileCommentCount()}</span>
</Show>
</div>
</div>
<div data-slot="session-review-trigger-actions">
<Show when={isAdded()}>
<span data-slot="session-review-change" data-type="added">
{t("ui.sessionReview.change.added")}
</span>
</Show>
<Show when={isDeleted()}>
<span data-slot="session-review-change" data-type="removed">
{t("ui.sessionReview.change.removed")}
</span>
</Show>
<Show when={!isAdded() && !isDeleted()}>
<DiffChanges changes={diff} />
</Show>
<span data-slot="session-review-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={open().includes(diff.file)}>
<Diff<AnnotationMeta>
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
diffStyle={props.diffStyle}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)}
/>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</Show>
</div>
</div>
</div>
)
}
@@ -786,8 +786,21 @@ button.am-section-toggle:hover .am-section-label {
border-right: 1px solid var(--border-weak-base);
}
.am-diff-resize {
position: relative;
display: flex;
flex-shrink: 0;
min-width: 0;
}
.am-diff-resize > [data-component="resize-handle"]::after {
background: var(--surface-interactive-base);
}
.am-diff-panel-wrapper {
position: relative;
flex: 1;
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
@@ -802,6 +815,7 @@ button.am-section-toggle:hover .am-section-label {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
min-height: 0;
/* Pierre diffs needs --diffs-light-bg/--diffs-dark-bg for the color-mix()
calculations that produce red/green line backgrounds. Without a Shiki
@@ -843,6 +857,12 @@ button.am-section-toggle:hover .am-section-label {
var(--vscode-editor-background, #1e1e1e) 60%,
var(--syntax-diff-delete, #da3319)
);
--am-diff-count-size: 12px;
}
.am-diff-panel:focus,
.am-review-layout:focus {
outline: none;
}
.am-diff-header {
@@ -863,6 +883,38 @@ button.am-section-toggle:hover .am-section-label {
color: var(--text-weak);
}
.am-diff-header-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.am-diff-header-stats {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--font-size-small);
color: var(--text-weak);
white-space: nowrap;
}
.am-diff-header-adds {
color: var(--syntax-diff-add, #318430);
font-weight: 500;
}
.am-diff-header-dels {
color: var(--syntax-diff-delete, #da3319);
font-weight: 500;
}
.am-diff-header [data-component="radio-group"],
.am-diff-header [data-component="radio-group"] [data-slot="radio-group-item-label"],
.am-diff-header [data-component="radio-group"] [data-slot="radio-group-item-control"] {
cursor: pointer;
}
.am-diff-loading {
display: flex;
align-items: center;
@@ -897,12 +949,63 @@ button.am-section-toggle:hover .am-section-label {
height: auto;
contain: none;
scrollbar-width: auto;
width: 100%;
}
.am-diff-content[data-component="session-review"] [data-component="sticky-accordion-header"] {
--sticky-accordion-top: 0px;
}
/* Ensure accordion headers always span full panel width */
.am-diff-content [data-component="accordion"],
.am-review-diff-content [data-component="accordion"] {
width: 100% !important;
align-items: stretch !important;
}
.am-diff-content [data-slot="accordion-item"],
.am-review-diff-content [data-slot="accordion-item"],
.am-diff-content [data-slot="accordion-header"],
.am-review-diff-content [data-slot="accordion-header"],
.am-diff-content [data-slot="accordion-trigger"],
.am-review-diff-content [data-slot="accordion-trigger"] {
width: 100% !important;
align-self: stretch !important;
}
.am-diff-content [data-slot="accordion-item"][data-file-path],
.am-review-diff-content [data-slot="accordion-item"][data-file-path] {
width: 100% !important;
align-self: stretch !important;
}
.am-diff-content [data-slot="accordion-content"],
.am-review-diff-content [data-slot="accordion-content"] {
width: 100% !important;
}
.am-diff-content [data-component="sticky-accordion-header"],
.am-review-diff-content [data-component="sticky-accordion-header"] {
width: 100% !important;
left: 0;
right: 0;
}
.am-diff-content [data-slot="session-review-trigger-content"],
.am-review-diff-content [data-slot="session-review-trigger-content"] {
width: 100%;
min-width: 0;
}
/* Keep +N/-N indicators at consistent size across side/fullscreen views */
.am-diff-panel [data-component="diff-changes"] [data-slot="diff-changes-additions"],
.am-diff-panel [data-component="diff-changes"] [data-slot="diff-changes-deletions"],
.am-review-layout [data-component="diff-changes"] [data-slot="diff-changes-additions"],
.am-review-layout [data-component="diff-changes"] [data-slot="diff-changes-deletions"] {
font-size: var(--am-diff-count-size);
line-height: 1.2;
}
/* Inline annotations (pierre renderAnnotation) */
.am-annotation {
@@ -2212,3 +2315,362 @@ button.am-section-toggle:hover .am-section-label {
color: var(--text-weaker);
line-height: 1.5;
}
/* Diff panel header actions row */
.am-diff-header-actions {
display: flex;
align-items: center;
gap: 2px;
}
/* Review tab in tab bar */
.am-tab-review {
display: flex;
align-items: center;
gap: 4px;
border-left: 1px solid var(--border-weak-base);
}
.am-tab-review [data-component="icon"] {
flex-shrink: 0;
opacity: 0.7;
}
/* Full-screen review tab layout */
.am-review-layout {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
--am-diff-count-size: 12px;
/* Inherit pierre diffs CSS vars from am-diff-panel */
--diffs-light-bg: var(--vscode-editor-background, #1e1e1e);
--diffs-dark-bg: var(--vscode-editor-background, #1e1e1e);
--diffs-bg-addition-override: color-mix(
in lab,
var(--vscode-editor-background, #1e1e1e) 82%,
var(--syntax-diff-add, #318430)
);
--diffs-bg-addition-number-override: color-mix(
in lab,
var(--vscode-editor-background, #1e1e1e) 72%,
var(--syntax-diff-add, #318430)
);
--diffs-bg-addition-emphasis-override: color-mix(
in lab,
var(--vscode-editor-background, #1e1e1e) 60%,
var(--syntax-diff-add, #318430)
);
--diffs-bg-deletion-override: color-mix(
in lab,
var(--vscode-editor-background, #1e1e1e) 82%,
var(--syntax-diff-delete, #da3319)
);
--diffs-bg-deletion-number-override: color-mix(
in lab,
var(--vscode-editor-background, #1e1e1e) 72%,
var(--syntax-diff-delete, #da3319)
);
--diffs-bg-deletion-emphasis-override: color-mix(
in lab,
var(--vscode-editor-background, #1e1e1e) 60%,
var(--syntax-diff-delete, #da3319)
);
}
/* Review toolbar */
.am-review-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
background: var(--surface-base);
gap: 8px;
}
.am-review-toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.am-review-toolbar-right {
display: flex;
align-items: center;
gap: 6px;
}
.am-review-toolbar [data-component="radio-group"],
.am-review-toolbar [data-component="radio-group"] [data-slot="radio-group-item-label"],
.am-review-toolbar [data-component="radio-group"] [data-slot="radio-group-item-control"] {
cursor: pointer;
}
.am-review-toolbar
[data-component="radio-group"]
[data-slot="radio-group-item-label"]:active
[data-slot="radio-group-item-control"] {
transform: translateY(1px);
}
.am-review-toolbar-stats {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--font-size-small);
color: var(--text-weak);
}
.am-review-toolbar-adds {
color: var(--syntax-diff-add, #318430);
font-weight: 500;
}
.am-review-toolbar-dels {
color: var(--syntax-diff-delete, #da3319);
font-weight: 500;
}
/* Review body: file tree + diff viewer */
.am-review-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.am-review-host {
display: flex;
flex: 1;
min-height: 0;
}
.am-review-tree-resize {
position: relative;
display: flex;
flex-shrink: 0;
min-width: 0;
}
.am-review-tree-wrapper {
flex: 1;
height: 100%;
min-width: 0;
overflow: hidden;
border-right: 1px solid var(--border-weak-base);
}
.am-review-tree-resize > [data-component="resize-handle"]::after {
background: var(--surface-interactive-base);
}
.am-review-diff {
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
}
.am-review-diff-content[data-component="session-review"] {
height: auto;
contain: none;
scrollbar-width: auto;
width: 100%;
}
.am-review-diff-content[data-component="session-review"] [data-component="sticky-accordion-header"] {
--sticky-accordion-top: 0px;
}
/* Ensure the diff container fills the available width.
pierre's diffs-container needs explicit width for split mode. */
.am-review-diff diffs-container,
.am-review-diff [data-component="diff"] {
width: 100%;
display: block;
}
/* Tighter diff line height in review mode */
.am-review-layout {
--diffs-line-height: 20px;
}
/* ─── File tree (GitHub-style) ─── */
.am-file-tree {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
font-size: 13px;
font-family: var(--font-family-sans, -apple-system, BlinkMacSystemFont, sans-serif);
}
.am-file-tree-list {
flex: 1;
overflow-y: auto;
padding: 4px 6px;
}
.am-file-tree-group {
display: flex;
flex-direction: column;
}
/* Shared row base for files and directories */
.am-file-tree-dir,
.am-file-tree-file {
display: flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 8px;
border: none;
background: none;
color: var(--text-base);
font-size: inherit;
font-family: inherit;
cursor: pointer;
width: 100%;
text-align: left;
white-space: nowrap;
border-radius: 5px;
position: relative;
}
.am-file-tree-dir:hover,
.am-file-tree-file:hover {
background: var(--surface-inset-base-hover, rgba(128, 128, 128, 0.1));
}
/* Directory-specific: slightly muted text */
.am-file-tree-dir {
color: var(--text-weak);
}
/* Shrink icons to 16px */
.am-file-tree [data-component="file-icon"],
.am-file-tree [data-component="icon"] {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* Directory chevron + folder icon color */
.am-file-tree-dir [data-component="icon"] {
flex-shrink: 0;
color: var(--text-weaker);
}
.am-file-tree-dir-highlight > [data-component="icon"]:last-of-type {
color: var(--surface-interactive-strong);
}
/* Active file — subtle background + left accent bar */
.am-file-tree-active {
background: var(--surface-base-active, rgba(128, 128, 128, 0.15));
}
.am-file-tree-active::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 18px;
border-radius: 3px;
background: var(--surface-interactive-strong, var(--vscode-focusBorder, #007fd4));
}
.am-file-tree-active:hover {
background: var(--surface-base-active, rgba(128, 128, 128, 0.15));
}
.am-file-tree-name {
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
/* Color the name for added / deleted files */
.am-file-tree-status-added .am-file-tree-name {
color: var(--syntax-diff-add, #318430);
}
.am-file-tree-status-deleted .am-file-tree-name {
color: var(--syntax-diff-delete, #da3319);
}
.am-file-tree-status-modified .am-file-tree-name {
color: var(--text-base);
}
/* Compact +N -N stats pushed to the right */
.am-file-tree-changes {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
font-size: var(--am-diff-count-size);
font-family: var(--vscode-editor-font-family, monospace);
line-height: 1;
color: var(--text-weaker);
}
.am-file-tree-stat-add {
color: var(--syntax-diff-add, #318430);
}
.am-file-tree-stat-del {
color: var(--syntax-diff-delete, #da3319);
}
/* Status badges for added / deleted files */
.am-file-tree-badge-added {
font-size: var(--am-diff-count-size);
font-weight: 600;
padding: 1px 5px;
border-radius: 4px;
background: color-mix(in lab, var(--syntax-diff-add, #318430) 15%, transparent);
color: var(--syntax-diff-add, #318430);
}
.am-file-tree-badge-deleted {
font-size: var(--am-diff-count-size);
font-weight: 600;
padding: 1px 5px;
border-radius: 4px;
background: color-mix(in lab, var(--syntax-diff-delete, #da3319) 15%, transparent);
color: var(--syntax-diff-delete, #da3319);
}
/* File tree summary footer */
.am-file-tree-summary {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-top: 1px solid var(--border-weak-base);
font-size: 11px;
color: var(--text-weaker);
flex-shrink: 0;
}
.am-file-tree-summary-adds {
color: var(--syntax-diff-add, #318430);
}
.am-file-tree-summary-dels {
color: var(--syntax-diff-delete, #da3319);
}
@@ -0,0 +1,53 @@
import type { WorktreeFileDiff } from "../src/types/messages"
export interface FileTreeNode {
name: string
path: string
children?: FileTreeNode[]
diff?: WorktreeFileDiff
}
export function buildFileTree(diffs: WorktreeFileDiff[]): FileTreeNode[] {
const root: FileTreeNode[] = []
const dirs = new Map<string, FileTreeNode>()
for (const diff of diffs) {
const parts = diff.file.split("/")
const filename = parts.pop()!
let parent = root
let accumulated = ""
for (const part of parts) {
accumulated = accumulated ? `${accumulated}/${part}` : part
const existing = dirs.get(accumulated)
if (existing) {
parent = existing.children!
} else {
const node: FileTreeNode = { name: part, path: accumulated, children: [] }
dirs.set(accumulated, node)
parent.push(node)
parent = node.children!
}
}
parent.push({ name: filename, path: diff.file, diff })
}
return root
}
// Flatten single-child directory chains: src/components/ instead of src > components
export function flatten(nodes: FileTreeNode[]): FileTreeNode[] {
return nodes.map((node) => {
if (!node.children) return node
const flat = flattenChain(node)
return { ...flat, children: flat.children ? flatten(flat.children) : undefined }
})
}
export function flattenChain(node: FileTreeNode): FileTreeNode {
if (!node.children || node.children.length !== 1) return node
const child = node.children[0]!
if (!child.children) return node
return flattenChain({ name: `${node.name}/${child.name}`, path: child.path, children: child.children })
}
@@ -0,0 +1,251 @@
import type { AnnotationSide, DiffLineAnnotation } from "@pierre/diffs"
import type { WorktreeFileDiff } from "../src/types/messages"
import { extractLines, formatReviewCommentMarkdown, type ReviewComment } from "./review-comments"
export interface AnnotationLabels {
commentOnLine: (line: number) => string
editCommentOnLine: (line: number) => string
placeholder: string
cancel: string
comment: string
save: string
sendToChat: string
edit: string
delete: string
}
export interface AnnotationMeta {
type: "comment" | "draft"
comment: ReviewComment | null
file: string
side: AnnotationSide
line: number
}
interface AnnotationHandlers {
diffs: WorktreeFileDiff[]
editing: string | null
setEditing: (id: string | null) => void
addComment: (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => void
updateComment: (id: string, text: string) => void
deleteComment: (id: string) => void
cancelDraft: () => void
labels: AnnotationLabels
}
function focusWhenConnected(el: HTMLTextAreaElement): void {
let attempts = 0
const tick = () => {
if (el.isConnected) {
el.focus()
return
}
attempts += 1
if (attempts < 20) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
function makeIcon(pathData: string): SVGSVGElement {
const ns = "http://www.w3.org/2000/svg"
const svg = document.createElementNS(ns, "svg")
svg.setAttribute("width", "14")
svg.setAttribute("height", "14")
svg.setAttribute("viewBox", "0 0 16 16")
svg.setAttribute("fill", "currentColor")
const path = document.createElementNS(ns, "path")
path.setAttribute("d", pathData)
svg.appendChild(path)
return svg
}
function makeActionButton(title: string, icon: SVGSVGElement, action: () => void): HTMLButtonElement {
const button = document.createElement("button")
button.className = "am-annotation-icon-btn"
button.title = title
button.appendChild(icon)
button.addEventListener("click", (event) => {
event.stopPropagation()
action()
})
return button
}
export function buildReviewAnnotation(
annotation: DiffLineAnnotation<AnnotationMeta>,
handlers: AnnotationHandlers,
): HTMLElement | undefined {
const meta = annotation.metadata
if (!meta) return undefined
const wrapper = document.createElement("div")
if (meta.type === "draft") {
wrapper.className = "am-annotation am-annotation-draft"
const header = document.createElement("div")
header.className = "am-annotation-header"
header.textContent = handlers.labels.commentOnLine(meta.line)
const textarea = document.createElement("textarea")
textarea.className = "am-annotation-textarea"
textarea.rows = 3
textarea.placeholder = handlers.labels.placeholder
const actions = document.createElement("div")
actions.className = "am-annotation-actions"
const cancelButton = document.createElement("button")
cancelButton.className = "am-annotation-btn"
cancelButton.textContent = handlers.labels.cancel
const submitButton = document.createElement("button")
submitButton.className = "am-annotation-btn am-annotation-btn-submit"
submitButton.textContent = handlers.labels.comment
actions.appendChild(cancelButton)
actions.appendChild(submitButton)
wrapper.appendChild(header)
wrapper.appendChild(textarea)
wrapper.appendChild(actions)
focusWhenConnected(textarea)
const submit = () => {
const text = textarea.value.trim()
if (!text) return
const diff = handlers.diffs.find((item) => item.file === meta.file)
const content = meta.side === "deletions" ? (diff?.before ?? "") : (diff?.after ?? "")
const selected = extractLines(content, meta.line, meta.line)
handlers.addComment(meta.file, meta.side, meta.line, text, selected)
}
cancelButton.addEventListener("click", (event) => {
event.stopPropagation()
handlers.cancelDraft()
})
submitButton.addEventListener("click", (event) => {
event.stopPropagation()
submit()
})
textarea.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault()
handlers.cancelDraft()
return
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
submit()
}
})
return wrapper
}
const comment = meta.comment!
if (handlers.editing === comment.id) {
wrapper.className = "am-annotation am-annotation-draft"
const header = document.createElement("div")
header.className = "am-annotation-header"
header.textContent = handlers.labels.editCommentOnLine(comment.line)
const textarea = document.createElement("textarea")
textarea.className = "am-annotation-textarea"
textarea.rows = 3
textarea.value = comment.comment
const actions = document.createElement("div")
actions.className = "am-annotation-actions"
const cancelButton = document.createElement("button")
cancelButton.className = "am-annotation-btn"
cancelButton.textContent = handlers.labels.cancel
const saveButton = document.createElement("button")
saveButton.className = "am-annotation-btn am-annotation-btn-submit"
saveButton.textContent = handlers.labels.save
actions.appendChild(cancelButton)
actions.appendChild(saveButton)
wrapper.appendChild(header)
wrapper.appendChild(textarea)
wrapper.appendChild(actions)
focusWhenConnected(textarea)
cancelButton.addEventListener("click", (event) => {
event.stopPropagation()
handlers.setEditing(null)
})
saveButton.addEventListener("click", (event) => {
event.stopPropagation()
const text = textarea.value.trim()
if (!text) return
handlers.updateComment(comment.id, text)
})
textarea.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault()
handlers.setEditing(null)
return
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
const text = textarea.value.trim()
if (!text) return
handlers.updateComment(comment.id, text)
}
})
return wrapper
}
wrapper.className = "am-annotation"
const body = document.createElement("div")
body.className = "am-annotation-comment"
const text = document.createElement("div")
text.className = "am-annotation-comment-text"
text.textContent = comment.comment
body.appendChild(text)
const actions = document.createElement("div")
actions.className = "am-annotation-comment-actions"
actions.appendChild(
makeActionButton(handlers.labels.sendToChat, makeIcon("M1 1l14 7-14 7V9l10-1L1 7z"), () => {
const msg = formatReviewCommentMarkdown(comment)
window.dispatchEvent(new MessageEvent("message", { data: { type: "appendChatBoxMessage", text: msg } }))
handlers.deleteComment(comment.id)
}),
)
actions.appendChild(
makeActionButton(
handlers.labels.edit,
makeIcon("M13.2 1.1l1.7 1.7-1.1 1.1-1.7-1.7zM1 11.5V13.2h1.7l7.8-7.8-1.7-1.7z"),
() => handlers.setEditing(comment.id),
),
)
actions.appendChild(
makeActionButton(
handlers.labels.delete,
makeIcon(
"M8 1a7 7 0 100 14A7 7 0 008 1zm3.1 9.3l-.8.8L8 8.8l-2.3 2.3-.8-.8L7.2 8 4.9 5.7l.8-.8L8 7.2l2.3-2.3.8.8L8.8 8z",
),
() => handlers.deleteComment(comment.id),
),
)
wrapper.appendChild(body)
wrapper.appendChild(actions)
return wrapper
}
@@ -0,0 +1,79 @@
import type { AnnotationSide } from "@pierre/diffs"
import type { WorktreeFileDiff } from "../src/types/messages"
export interface ReviewComment {
id: string
file: string
side: AnnotationSide
line: number
comment: string
selectedText: string
}
function lineCount(text: string): number {
if (text.length === 0) return 0
return text.split("\n").length
}
export function getDirectory(path: string): string {
const idx = path.lastIndexOf("/")
return idx === -1 ? "" : path.slice(0, idx + 1)
}
export function getFilename(path: string): string {
const idx = path.lastIndexOf("/")
return idx === -1 ? path : path.slice(idx + 1)
}
export function extractLines(content: string, start: number, end: number): string {
return content
.split("\n")
.slice(start - 1, end)
.join("\n")
}
function escapeMarkdownInline(value: string): string {
return value.replace(/([\\`*_\[\]{}()#+\-!|])/g, "\\$1")
}
function fenceFor(value: string): string {
const matches = value.match(/`+/g) ?? []
const longest = matches.reduce((max, item) => Math.max(max, item.length), 0)
return "`".repeat(Math.max(3, longest + 1))
}
function formatCodeBlock(value: string): string[] {
const fence = fenceFor(value)
return [fence, value, fence]
}
export function formatReviewCommentMarkdown(comment: ReviewComment): string {
const lines = [`**${escapeMarkdownInline(comment.file)}** (line ${comment.line}):`]
if (comment.selectedText) {
lines.push(...formatCodeBlock(comment.selectedText))
}
lines.push(comment.comment)
return lines.join("\n")
}
export function formatReviewCommentsMarkdown(comments: ReviewComment[]): string {
const lines = ["## Review Comments", ""]
for (const comment of comments) {
lines.push(formatReviewCommentMarkdown(comment))
lines.push("")
}
return lines.join("\n")
}
export function sanitizeReviewComments(comments: ReviewComment[], diffs: WorktreeFileDiff[]): ReviewComment[] {
const map = new Map(diffs.map((diff) => [diff.file, diff]))
return comments.filter((comment) => {
const diff = map.get(comment.file)
if (!diff) return false
const content = comment.side === "deletions" ? diff.before : diff.after
const max = lineCount(content)
if (comment.line < 1) return false
if (comment.line > max) return false
return true
})
}
@@ -8,6 +8,7 @@ import type { Transformer } from "@thisbeyond/solid-dnd"
import { createRoot } from "solid-js"
import type { SessionInfo } from "../src/types/messages"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../src/context/language"
@@ -78,3 +79,51 @@ export const SortableTab: Component<{
</div>
)
}
/** Draggable review tab variant with leading icon and custom tooltip. */
export const SortableReviewTab: Component<{
id: string
label: string
tooltip: string
keybind?: string
closeKeybind?: string
active: boolean
onSelect: () => void
onMiddleClick: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
}> = (props) => {
const { t } = useLanguage()
const sortable = createSortable(props.id)
// Prevent tree-shaking of the directive reference used by `use:sortable`
void sortable
return (
// @ts-ignore - use:sortable is a SolidJS directive compiled by esbuild-plugin-solid
<div
use:sortable
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
data-tab-id={props.id}
>
<TooltipKeybind title={props.tooltip} keybind={props.keybind ?? ""} placement="bottom" inactive={props.active}>
<div
class={`am-tab am-tab-review ${props.active ? "am-tab-active" : ""}`}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
>
<Icon name="layers" size="small" />
<span class="am-tab-label">{props.label}</span>
<TooltipKeybind title={t("agentManager.tab.close")} keybind={props.closeKeybind ?? ""} placement="bottom">
<IconButton
icon="close-small"
size="small"
variant="ghost"
label={t("agentManager.tab.closeTab")}
class="am-tab-close"
onClick={props.onClose}
/>
</TooltipKeybind>
</div>
</TooltipKeybind>
</div>
)
}
@@ -1016,6 +1016,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "تبديل لوحة الفرق",
"agentManager.shortcuts.category.quickSwitch": "التبديل السريع",
"agentManager.shortcuts.jumpToItem": "الانتقال إلى العنصر 1\u20139",
"agentManager.review.sendAllToChat": "إرسال الكل إلى الدردشة",
"agentManager.review.sendAllToChatWithCount": "إرسال الكل إلى الدردشة ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "تعليق على السطر {{line}}",
"agentManager.review.editCommentOnLine": "تعديل التعليق على السطر {{line}}",
"agentManager.review.commentPlaceholder": "اترك تعليقًا...",
"agentManager.review.commentAction": "تعليق",
"agentManager.review.sendToChat": "إرسال إلى الدردشة",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "الصق رابط PR...",
@@ -1030,6 +1030,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Alternar painel de diff",
"agentManager.shortcuts.category.quickSwitch": "Troca rápida",
"agentManager.shortcuts.jumpToItem": "Ir para o item 1\u20139",
"agentManager.review.sendAllToChat": "Enviar tudo para o chat",
"agentManager.review.sendAllToChatWithCount": "Enviar tudo para o chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Comentar na linha {{line}}",
"agentManager.review.editCommentOnLine": "Editar comentário na linha {{line}}",
"agentManager.review.commentPlaceholder": "Deixe um comentário...",
"agentManager.review.commentAction": "Comentar",
"agentManager.review.sendToChat": "Enviar para o chat",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Cole a URL do PR...",
@@ -1052,6 +1052,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Prebaci panel za diff",
"agentManager.shortcuts.category.quickSwitch": "Brzo prebacivanje",
"agentManager.shortcuts.jumpToItem": "Idi na stavku 1\u20139",
"agentManager.review.sendAllToChat": "Pošalji sve u chat",
"agentManager.review.sendAllToChatWithCount": "Pošalji sve u chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Komentar na liniji {{line}}",
"agentManager.review.editCommentOnLine": "Uredi komentar na liniji {{line}}",
"agentManager.review.commentPlaceholder": "Ostavi komentar...",
"agentManager.review.commentAction": "Komentariši",
"agentManager.review.sendToChat": "Pošalji u chat",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Zalijepite PR URL...",
@@ -1025,6 +1025,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Skift diff-panel",
"agentManager.shortcuts.category.quickSwitch": "Hurtigskift",
"agentManager.shortcuts.jumpToItem": "Hop til element 1\u20139",
"agentManager.review.sendAllToChat": "Send alt til chat",
"agentManager.review.sendAllToChatWithCount": "Send alt til chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Kommenter på linje {{line}}",
"agentManager.review.editCommentOnLine": "Rediger kommentar på linje {{line}}",
"agentManager.review.commentPlaceholder": "Skriv en kommentar...",
"agentManager.review.commentAction": "Kommenter",
"agentManager.review.sendToChat": "Send til chat",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Indsæt PR URL...",
@@ -1039,6 +1039,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Diff-Panel umschalten",
"agentManager.shortcuts.category.quickSwitch": "Schnellwechsel",
"agentManager.shortcuts.jumpToItem": "Zu Element 1\u20139 springen",
"agentManager.review.sendAllToChat": "Alles an den Chat senden",
"agentManager.review.sendAllToChatWithCount": "Alles an den Chat senden ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Kommentar zu Zeile {{line}}",
"agentManager.review.editCommentOnLine": "Kommentar zu Zeile {{line}} bearbeiten",
"agentManager.review.commentPlaceholder": "Kommentar hinterlassen...",
"agentManager.review.commentAction": "Kommentieren",
"agentManager.review.sendToChat": "An Chat senden",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "PR-URL einfügen...",
@@ -1081,6 +1081,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Toggle diff panel",
"agentManager.shortcuts.category.quickSwitch": "Quick Switch",
"agentManager.shortcuts.jumpToItem": "Jump to item 1\u20139",
"agentManager.review.sendAllToChat": "Send all to chat",
"agentManager.review.sendAllToChatWithCount": "Send all to chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Comment on line {{line}}",
"agentManager.review.editCommentOnLine": "Edit comment on line {{line}}",
"agentManager.review.commentPlaceholder": "Leave a comment...",
"agentManager.review.commentAction": "Comment",
"agentManager.review.sendToChat": "Send to chat",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Paste PR URL...",
@@ -1033,6 +1033,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Alternar panel de diff",
"agentManager.shortcuts.category.quickSwitch": "Cambio rápido",
"agentManager.shortcuts.jumpToItem": "Ir al elemento 1\u20139",
"agentManager.review.sendAllToChat": "Enviar todo al chat",
"agentManager.review.sendAllToChatWithCount": "Enviar todo al chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Comentar en la línea {{line}}",
"agentManager.review.editCommentOnLine": "Editar comentario en la línea {{line}}",
"agentManager.review.commentPlaceholder": "Deja un comentario...",
"agentManager.review.commentAction": "Comentar",
"agentManager.review.sendToChat": "Enviar al chat",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Pegar URL del PR...",
@@ -1041,6 +1041,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Basculer le panneau de diff",
"agentManager.shortcuts.category.quickSwitch": "Changement rapide",
"agentManager.shortcuts.jumpToItem": "Aller à l'élément 1\u20139",
"agentManager.review.sendAllToChat": "Tout envoyer au chat",
"agentManager.review.sendAllToChatWithCount": "Tout envoyer au chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Commenter la ligne {{line}}",
"agentManager.review.editCommentOnLine": "Modifier le commentaire de la ligne {{line}}",
"agentManager.review.commentPlaceholder": "Laisser un commentaire...",
"agentManager.review.commentAction": "Commenter",
"agentManager.review.sendToChat": "Envoyer au chat",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Coller l'URL du PR...",
@@ -1021,6 +1021,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "差分パネルを切り替え",
"agentManager.shortcuts.category.quickSwitch": "クイック切り替え",
"agentManager.shortcuts.jumpToItem": "項目 1\u20139 に移動",
"agentManager.review.sendAllToChat": "すべてをチャットに送信",
"agentManager.review.sendAllToChatWithCount": "すべてをチャットに送信 ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "{{line}} 行目にコメント",
"agentManager.review.editCommentOnLine": "{{line}} 行目のコメントを編集",
"agentManager.review.commentPlaceholder": "コメントを入力...",
"agentManager.review.commentAction": "コメント",
"agentManager.review.sendToChat": "チャットに送信",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "PR URLを貼り付け...",
@@ -1021,6 +1021,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "차이점 패널 전환",
"agentManager.shortcuts.category.quickSwitch": "빠른 전환",
"agentManager.shortcuts.jumpToItem": "항목 1\u20139(으)로 이동",
"agentManager.review.sendAllToChat": "모두 채팅으로 보내기",
"agentManager.review.sendAllToChatWithCount": "모두 채팅으로 보내기 ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "{{line}}줄에 댓글",
"agentManager.review.editCommentOnLine": "{{line}}줄 댓글 편집",
"agentManager.review.commentPlaceholder": "댓글을 남기세요...",
"agentManager.review.commentAction": "댓글",
"agentManager.review.sendToChat": "채팅으로 보내기",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "PR URL 붙여넣기...",
@@ -1026,6 +1026,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Veksle diff-panel",
"agentManager.shortcuts.category.quickSwitch": "Hurtigbytte",
"agentManager.shortcuts.jumpToItem": "Hopp til element 1\u20139",
"agentManager.review.sendAllToChat": "Send alt til chat",
"agentManager.review.sendAllToChatWithCount": "Send alt til chat ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Kommenter på linje {{line}}",
"agentManager.review.editCommentOnLine": "Rediger kommentar på linje {{line}}",
"agentManager.review.commentPlaceholder": "Legg igjen en kommentar...",
"agentManager.review.commentAction": "Kommenter",
"agentManager.review.sendToChat": "Send til chat",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Lim inn PR URL...",
@@ -1028,6 +1028,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Przełącz panel diff",
"agentManager.shortcuts.category.quickSwitch": "Szybkie przełączanie",
"agentManager.shortcuts.jumpToItem": "Przejdź do elementu 1\u20139",
"agentManager.review.sendAllToChat": "Wyślij wszystko do czatu",
"agentManager.review.sendAllToChatWithCount": "Wyślij wszystko do czatu ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Skomentuj linię {{line}}",
"agentManager.review.editCommentOnLine": "Edytuj komentarz do linii {{line}}",
"agentManager.review.commentPlaceholder": "Zostaw komentarz...",
"agentManager.review.commentAction": "Komentuj",
"agentManager.review.sendToChat": "Wyślij do czatu",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Wklej URL PR...",
@@ -1029,6 +1029,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "Переключить панель diff",
"agentManager.shortcuts.category.quickSwitch": "Быстрое переключение",
"agentManager.shortcuts.jumpToItem": "Перейти к элементу 1\u20139",
"agentManager.review.sendAllToChat": "Отправить всё в чат",
"agentManager.review.sendAllToChatWithCount": "Отправить всё в чат ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "Комментарий к строке {{line}}",
"agentManager.review.editCommentOnLine": "Редактировать комментарий к строке {{line}}",
"agentManager.review.commentPlaceholder": "Оставьте комментарий...",
"agentManager.review.commentAction": "Комментировать",
"agentManager.review.sendToChat": "Отправить в чат",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Вставьте URL PR...",
@@ -1014,6 +1014,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "สลับแผง diff",
"agentManager.shortcuts.category.quickSwitch": "สลับด่วน",
"agentManager.shortcuts.jumpToItem": "ข้ามไปยังรายการ 1\u20139",
"agentManager.review.sendAllToChat": "ส่งทั้งหมดไปยังแชท",
"agentManager.review.sendAllToChatWithCount": "ส่งทั้งหมดไปยังแชท ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "แสดงความคิดเห็นที่บรรทัด {{line}}",
"agentManager.review.editCommentOnLine": "แก้ไขความคิดเห็นที่บรรทัด {{line}}",
"agentManager.review.commentPlaceholder": "แสดงความคิดเห็น...",
"agentManager.review.commentAction": "แสดงความคิดเห็น",
"agentManager.review.sendToChat": "ส่งไปยังแชท",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "วาง URL ของ PR...",
@@ -1016,6 +1016,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "切换差异面板",
"agentManager.shortcuts.category.quickSwitch": "快速切换",
"agentManager.shortcuts.jumpToItem": "跳转到项目 1\u20139",
"agentManager.review.sendAllToChat": "全部发送到聊天",
"agentManager.review.sendAllToChatWithCount": "全部发送到聊天 ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "在第 {{line}} 行评论",
"agentManager.review.editCommentOnLine": "编辑第 {{line}} 行评论",
"agentManager.review.commentPlaceholder": "留下评论...",
"agentManager.review.commentAction": "评论",
"agentManager.review.sendToChat": "发送到聊天",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "粘贴 PR URL...",
@@ -1012,6 +1012,15 @@ export const dict = {
"agentManager.shortcuts.toggleDiff": "切換差異面板",
"agentManager.shortcuts.category.quickSwitch": "快速切換",
"agentManager.shortcuts.jumpToItem": "跳至項目 1\u20139",
"agentManager.review.sendAllToChat": "全部傳送到聊天",
"agentManager.review.sendAllToChatWithCount": "全部傳送到聊天 ({{count}})",
"agentManager.review.sendAllShortcut.mac": "⌘Enter",
"agentManager.review.sendAllShortcut.other": "Ctrl+Enter",
"agentManager.review.commentOnLine": "在第 {{line}} 行留言",
"agentManager.review.editCommentOnLine": "編輯第 {{line}} 行留言",
"agentManager.review.commentPlaceholder": "留下留言...",
"agentManager.review.commentAction": "留言",
"agentManager.review.sendToChat": "傳送到聊天",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "貼上 PR URL...",
@@ -652,6 +652,7 @@ export interface AgentManagerStateMessage {
sessions: ManagedSessionState[]
tabOrder?: Record<string, string[]>
sessionsCollapsed?: boolean
reviewDiffStyle?: "unified" | "split"
isGitRepo?: boolean
}
@@ -1165,6 +1166,12 @@ export interface SetSessionsCollapsedRequest {
collapsed: boolean
}
// Persist review diff style preference
export interface SetReviewDiffStyleRequest {
type: "agentManager.setReviewDiffStyle"
style: "unified" | "split"
}
export interface RequestBranchesMessage {
type: "agentManager.requestBranches"
}
@@ -1279,6 +1286,7 @@ export type WebviewMessage =
| CreateMultiVersionRequest
| SetTabOrderRequest
| SetSessionsCollapsedRequest
| SetReviewDiffStyleRequest
| PersistVariantRequest
| RequestVariantsMessage
| RequestCloudSessionDataMessage