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