diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index eed32f282b9..ba9c1911b7e 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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, }) } diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 2ea1b512315..9b135fb07b2 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -36,6 +36,7 @@ interface StateFile { sessions: Record> tabOrder?: Record sessionsCollapsed?: boolean + reviewDiffStyle?: "unified" | "split" } const STATE_FILE = "agent-manager.json" @@ -53,6 +54,7 @@ export class WorktreeStateManager { private sessions = new Map() private tabOrder: Record = {} private collapsed = false + private reviewDiffStyle: "unified" | "split" = "unified" private readonly log: (msg: string) => void private saving: Promise | 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) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 6996f868220..6d3e33ec84a 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -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] diff --git a/packages/kilo-vscode/tests/unit/file-tree.test.ts b/packages/kilo-vscode/tests/unit/file-tree.test.ts new file mode 100644 index 00000000000..b997f86cfd4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/file-tree.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/review-comments.test.ts b/packages/kilo-vscode/tests/unit/review-comments.test.ts new file mode 100644 index 00000000000..f823e3b5413 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/review-comments.test.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 & Pick): 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") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index e222b646249..798384bc679 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.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>({}) + const [reviewCommentsByWorktree, setReviewCommentsByWorktree] = createSignal>({}) + 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>({}) @@ -317,6 +328,37 @@ const AgentManagerContent: Component = () => { // Per-context tab memory: maps sidebar selection key -> last active session/pending ID const [tabMemory, setTabMemory] = createSignal>({}) + 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 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 = () => {
- - {(s) => { + + {(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 ( + 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 ( { + 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" > + + +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FileTree.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FileTree.tsx new file mode 100644 index 00000000000..3ac9e807020 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/FileTree.tsx @@ -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 ( +
+ + + + {(child) => ( + + } + > + + + )} + + +
+ ) +} + +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 ( + + ) +} + +export const FileTree: Component = (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 ( +
+
+ + {(node) => ( + + } + > + + + )} + +
+
+ {t("session.review.filesChanged", { count: totals().files })} + +{totals().additions} + -{totals().deletions} +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx new file mode 100644 index 00000000000..c781b622308 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -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 = (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([]) + const [openInit, setOpenInit] = createSignal(false) + const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number } | null>(null) + const [editing, setEditing] = createSignal(null) + const [activeFile, setActiveFile] = createSignal(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() + 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[] => { + const fileComments = commentsByFile().get(file) ?? [] + const result: DiffLineAnnotation[] = 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): 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('[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((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 ( +
+ {/* Toolbar */} +
+
+ style} + label={(style) => + style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split") + } + onSelect={(style) => { + if (style) props.onDiffStyleChange(style) + }} + /> + + {t("session.review.filesChanged", { count: totals().files })} + +{totals().additions} + -{totals().deletions} + +
+
+ + 0}> + + + + + +
+
+ + {/* Body: file tree + diff viewer */} +
+
+
+ +
+ setTreeWidth(Math.max(160, Math.min(w, 400)))} + /> +
+
+ +
+ + {t("session.review.loadingChanges")} +
+
+ + +
+ {t("session.review.noChanges")} +
+
+ + 0}> +
+ + + {(diff) => { + const isAdded = () => diff.status === "added" + const isDeleted = () => diff.status === "deleted" + const fileCommentCount = () => (commentsByFile().get(diff.file) ?? []).length + + return ( + + + +
+
+ +
+ + {`\u202A${getDirectory(diff.file)}\u202C`} + + {getFilename(diff.file)} + 0}> + {fileCommentCount()} + +
+
+
+ + + {t("ui.sessionReview.change.added")} + + + + + {t("ui.sessionReview.change.removed")} + + + + + + + + +
+
+
+
+ + + + 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)} + /> + + +
+ ) + }} +
+
+
+
+
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 85ed718f786..699f82b5914 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -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); +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/file-tree-utils.ts b/packages/kilo-vscode/webview-ui/agent-manager/file-tree-utils.ts new file mode 100644 index 00000000000..3762d5993d4 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/file-tree-utils.ts @@ -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() + + 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 }) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts new file mode 100644 index 00000000000..c71f9c7ab34 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts @@ -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, + 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 +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-comments.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-comments.ts new file mode 100644 index 00000000000..ead76722b8c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-comments.ts @@ -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 + }) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx index 2fab8f95245..55fd6e41179 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx @@ -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<{
) } + +/** 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 +
+ +
+ + {props.label} + + + +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index f0059f7bb5f..c3a9f53d6fd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 0987f13f450..e76f601c247 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index e8b8cbeecbd..56553b7df4a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index a4c7c8abf3a..a4e4483db58 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index d4bf19df056..0cc3a11165b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index ea0a37f10ac..654b88c6b9c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index b2851a5bbc0..f58817f75bb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 5d3a76be54e..c8956a57fa6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 66460a2ea1a..0d8c8089a53 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -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を貼り付け...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 023595822c3..36e8ada140f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -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 붙여넣기...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 0b0a1859137..18eed1c9c91 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 225436ada0a..c56074ec403 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 94e53e5682b..f46f296f1c7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 11b9faa3057..b0174fe2275 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 06a3eae2934..ff633947cd9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index e7cbdcbaafc..25fe03a4549 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 668d3755a2d..fab03a03225 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -652,6 +652,7 @@ export interface AgentManagerStateMessage { sessions: ManagedSessionState[] tabOrder?: Record 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