feat(agent-manager): preview edits in side panel

This commit is contained in:
marius-kilocode
2026-08-21 12:38:38 +02:00
parent 05f2ff0934
commit abcb6cb597
42 changed files with 904 additions and 196 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Open edit, write, and patch changes in the Agent Manager side panel instead of a new editor tab. Clicking a tool or file name expands the change inline, while the open-diff button shows the full change in the Agent Manager panel, or in a diff tab outside Agent Manager. Multi-file patches now open every changed file, each sized to its own diff, and the panel keeps the unified or split view you last selected.
@@ -2552,8 +2552,6 @@ ToolRegistry.register({
})
})
const canOpenDiff = () => !!data.openDiff && !!path() && !!view()
const canOpenFile = () => !!data.openFile && !!path()
const openDiff = () => {
const v = view()
if (!canOpenDiff() || !v) return
@@ -2565,19 +2563,6 @@ ToolRegistry.register({
})
}
const handleFileClick = (e: MouseEvent) => {
e.stopPropagation()
if (canOpenDiff()) {
openDiff()
return
}
if (canOpenFile()) {
data.openFile!(path())
}
}
const handleOpenDiffClick = (e: MouseEvent) => {
e.stopPropagation()
openDiff()
@@ -2604,7 +2589,6 @@ ToolRegistry.register({
path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined}
changes={props.metadata.filediff}
animate={reveal()}
onClick={canOpenDiff() || canOpenFile() ? handleFileClick : undefined}
/>
)}
</Show>
@@ -2669,8 +2653,6 @@ ToolRegistry.register({
return normalize(diff)
})
const canOpenDiff = () => !!data.openDiff && !!props.input.filePath && !!view()
const canOpenFile = () => !!data.openFile && !!props.input.filePath
const openDiff = () => {
const v = view()
if (!data.openDiff || !props.input.filePath || !v) return
@@ -2682,17 +2664,6 @@ ToolRegistry.register({
})
}
const handleFileClick = (e: MouseEvent) => {
e.stopPropagation()
if (canOpenDiff()) {
openDiff()
return
}
if (canOpenFile()) {
data.openFile!(props.input.filePath!)
}
}
const handleOpenDiffClick = (e: MouseEvent) => {
e.stopPropagation()
openDiff()
@@ -2719,7 +2690,6 @@ ToolRegistry.register({
path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined}
changes={props.metadata.filediff}
animate={reveal()}
onClick={canOpenDiff() || canOpenFile() ? handleFileClick : undefined}
/>
)}
</Show>
@@ -2802,13 +2772,55 @@ ToolRegistry.register({
const view = (file: ApplyPatchFile) => {
const patch = file.patch ?? file.diff
if (!patch) return
return normalize({
const value = normalize({
file: file.relativePath,
patch,
additions: file.additions,
deletions: file.deletions,
})
// apply_patch can report a file whose payload is not a parsable unified
// diff. Rendering it yields an empty "+0 -0" pane, so treat such a file
// as having no preview instead of showing a blank diff.
if (!value.fileDiff.hunks.length) return
return value
}
const openAllDiff = () => {
const diffs = files().flatMap((file) => {
const diff = view(file)
return diff
? [{
file: file.relativePath,
patch: diff.patch,
// Patch metadata reports 0 for some added files; fall back to the
// parsed hunks so the header matches the rendered diff.
additions: diff.additions || diff.fileDiff.additionLines.length,
deletions: diff.deletions || diff.fileDiff.deletionLines.length,
}]
: []
})
const first = diffs[0]
if (!data.openDiff || !first) return
data.openDiff(diffs.length === 1 ? first : { ...first, files: diffs })
}
const allDiffAction = () => (
<Show when={data.openDiff && files().some((file) => view(file))}>
<span data-slot="tool-trigger-actions">
<Tooltip value={i18n.t("ui.messagePart.openInDiffViewer")} placement="top" gutter={4}>
<IconButton
icon="square-arrow-top-right"
size="small"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation()
openAllDiff()
}}
aria-label={i18n.t("ui.messagePart.openInDiffViewer")}
/>
</Tooltip>
</span>
</Show>
)
const pending = createMemo(() => busy(props.status))
const reveal = useToolReveal(pending, () => props.reveal !== false)
const single = createMemo(() => {
@@ -2877,14 +2889,6 @@ ToolRegistry.register({
path={file().relativePath.includes("/") ? getDirectory(file().relativePath) : undefined}
changes={{ additions: file().additions, deletions: file().deletions }}
animate={reveal()}
onClick={
data.openFile && file().filePath
? (e: MouseEvent) => {
e.stopPropagation()
data.openFile!(file().filePath)
}
: undefined
}
/>
)}
</Show>
@@ -2900,6 +2904,7 @@ ToolRegistry.register({
</Show>
</div>
</div>
{allDiffAction()}
</div>
}
>
@@ -2941,12 +2946,6 @@ ToolRegistry.register({
<span
data-slot="apply-patch-filename"
classList={{ clickable: !!data.openFile }}
onClick={(e: MouseEvent) => {
if (!data.openFile) return
e.stopPropagation()
data.openFile(file.filePath)
}}
>
{getFilename(file.relativePath)}
</span>
@@ -9,6 +9,7 @@ export interface DiffVirtualFile {
patch?: string
additions: number
deletions: number
files?: Omit<DiffVirtualFile, "files" | "initialDiffStyle">[]
initialDiffStyle: "unified" | "split"
}
@@ -33,8 +34,9 @@ export class DiffVirtualProvider implements vscode.Disposable {
public open(diff: DiffVirtualFile): void {
this.pending = diff
const count = diff.files?.length ?? 1
const filename = diff.file.split("/").pop() ?? diff.file
const title = `Changes: ${filename}`
const title = count > 1 ? `Changes: ${count} files` : `Changes: ${filename}`
if (this.panel) {
this.panel.title = title
@@ -15,6 +15,7 @@ import { WorktreeImporter } from "../../src/agent-manager/worktree-importer"
const ROOT = path.resolve(import.meta.dir, "../..")
const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
const EDIT_PREVIEW_PANEL_FILE = path.join(ROOT, "webview-ui/agent-manager/EditPreviewPanel.tsx")
const CSS_FILES = [
path.join(ROOT, "webview-ui/agent-manager/agent-manager.css"),
path.join(ROOT, "webview-ui/agent-manager/agent-manager-review.css"),
@@ -22,6 +23,7 @@ const CSS_FILES = [
const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SubagentPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/EditPreviewPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"),
path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
@@ -30,6 +32,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/ImageDiffView.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/MarkdownDiffView.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/VirtualDiffView.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/MarkdownAnnotationLayer.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/markdown-comment-ranges.ts"),
path.join(ROOT, "webview-ui/diff-viewer/DiffEndMarker.tsx"),
@@ -165,6 +168,30 @@ describe("Agent Manager CSS/TSX Consistency", () => {
})
})
describe("Agent Manager edit preview", () => {
it("provides a visible close action", () => {
const source = fs.readFileSync(EDIT_PREVIEW_PANEL_FILE, "utf-8")
expect(source).toContain('icon="close"')
expect(source).toContain('class="am-edit-preview-close"')
expect(source).toContain("onClick={props.state.close}")
})
it("drives every stacked file from one shared style control", () => {
const source = fs.readFileSync(EDIT_PREVIEW_PANEL_FILE, "utf-8")
expect(source).toContain("RadioGroup")
expect(source).toContain("styleSelect={false}")
})
it("sizes stacked files to their own diff instead of a fixed height", () => {
const css = readAllCss()
expect(css).toContain(".am-edit-preview-files > .am-review-layout")
expect(css).not.toContain("flex: 0 0 min(420px, 50%)")
const view = fs.readFileSync(path.join(ROOT, "webview-ui/diff-viewer/VirtualDiffView.tsx"), "utf-8")
expect(view).toContain("value.fileDiff.hunks.length")
expect(view).toContain("virtualized={heavy()}")
})
})
describe("Agent Manager Provider Messages", () => {
function getMethodBody(name: string): string {
const project = new Project({ compilerOptions: { allowJs: true } })
@@ -62,15 +62,23 @@ describe("DataBridge shape (perf regression guard)", () => {
describe("DataBridge openDiff wiring (regression guard)", () => {
const openDiffBlock = () => {
const match = src.match(/const\s+openDiff\s*=\s*\(diff:\s*\{[\s\S]*?\n\s*\}\n\n\s*const\s+openUrl/)
const match = src.match(
/const\s+openDiff\s*=\s*\(diff:\s*PermissionFileDiff\)[\s\S]*?\n\s*\}\n\n\s*const\s+openUrl/,
)
expect(match).toBeTruthy()
return match![0]
}
it("wires openDiff to the openDiffVirtual webview message", () => {
expect(openDiffBlock()).toMatch(
/postMessage\(\{\s*type:\s*["']openDiffVirtual["']\s*,\s*diff\s*,\s*initialDiffStyle:\s*["']split["']\s*\}\)/,
/postMessage\(\{\s*type:\s*["']openDiffVirtual["']\s*,\s*diff\s*,\s*initialDiffStyle:\s*diffStyle\?\.style\(\)\s*\?\?\s*["']unified["']\s*\}\)/,
)
expect(src).toContain("onOpenDiff={openDiff}")
})
it("routes Agent Manager diffs to the inspector event", () => {
expect(src).toContain("dispatchAgentManagerEditPreview")
expect(src).toContain('initialDiffStyle: diffStyle?.style() ?? "unified"')
expect(src).toContain("useWorktreeMode")
})
})
@@ -0,0 +1,93 @@
import { describe, expect, it } from "bun:test"
import { createRoot } from "solid-js"
import {
createEditPreview,
isEditPreviewDiff,
previewMatchesContext,
} from "../../webview-ui/agent-manager/edit-preview"
const diff = {
file: "src/example.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
additions: 1,
deletions: 1,
}
describe("Agent Manager edit preview", () => {
it("replaces the current patch and opens the inspector", () => {
createRoot((dispose) => {
const calls = { shown: 0, hidden: 0 }
const preview = createEditPreview({
show: () => calls.shown++,
hide: () => calls.hidden++,
})
preview.open(diff, "session-1", "unified")
expect(preview.preview()).toEqual({ diff, sessionID: "session-1", style: "unified", markdown: false })
preview.open({ ...diff, file: "src/next.ts" }, "session-2")
expect(preview.preview()?.diff.file).toBe("src/next.ts")
expect(preview.preview()?.style).toBe("unified")
expect(calls.shown).toBe(2)
preview.close()
expect(preview.preview()).toBeUndefined()
expect(calls.hidden).toBe(1)
dispose()
})
})
it("updates the display preferences without changing the patch", () => {
createRoot((dispose) => {
const preview = createEditPreview({ show: () => undefined, hide: () => undefined })
preview.open(diff)
preview.updateStyle("unified")
preview.updateMarkdown(true)
expect(preview.preview()?.diff).toEqual(diff)
expect(preview.preview()?.style).toBe("unified")
expect(preview.preview()?.markdown).toBe(true)
dispose()
})
})
it("uses the shared style when opening a preview", () => {
createRoot((dispose) => {
const style = () => "split" as const
const changes: string[] = []
const preview = createEditPreview({
show: () => undefined,
hide: () => undefined,
style,
onStyleChange: (value) => changes.push(value),
})
preview.open(diff)
expect(preview.preview()?.style).toBe("split")
preview.updateStyle("unified")
expect(changes).toEqual(["unified"])
dispose()
})
})
it("validates edit preview payloads", () => {
expect(isEditPreviewDiff(diff)).toBe(true)
expect(
isEditPreviewDiff({
...diff,
files: [diff, { ...diff, file: "src/other.ts" }],
}),
).toBe(true)
expect(isEditPreviewDiff({ ...diff, additions: "1" })).toBe(false)
expect(isEditPreviewDiff({ file: "src/example.ts" })).toBe(false)
expect(isEditPreviewDiff({ ...diff, files: [] })).toBe(false)
})
it("keeps a preview scoped to its current session and worktree", () => {
expect(previewMatchesContext("session-1", "session-1", "wt-1", "wt-1")).toBe(true)
expect(previewMatchesContext("session-1", "session-2", "wt-1", "wt-1")).toBe(false)
expect(previewMatchesContext("session-1", "session-1", "wt-2", "wt-1")).toBe(false)
expect(previewMatchesContext("session-1", "session-1", "local", undefined)).toBe(true)
expect(previewMatchesContext("session-1", "session-1", null, undefined)).toBe(true)
expect(previewMatchesContext("session-1", "session-1", "wt-1", undefined)).toBe(false)
})
})
@@ -182,6 +182,11 @@ describe("Edit tool diff-first click contract (source)", () => {
expect(editBlock).toMatch(/props\.input\.oldString\s*\?\?\s*""/)
expect(editBlock).toMatch(/props\.input\.newString\s*\?\?\s*""/)
})
it("edit file names leave the parent trigger responsible for inline expansion", () => {
expect(editBlock).not.toContain("handleFileClick")
expect(editBlock).toContain("handleOpenDiffClick")
})
})
describe("Write and apply_patch patch rendering contracts (source)", () => {
@@ -197,12 +202,36 @@ describe("Write and apply_patch patch rendering contracts (source)", () => {
expect(writeBlock).toContain('mode="diff"')
})
it("write file names leave the parent trigger responsible for inline expansion", () => {
expect(writeBlock).not.toContain("handleFileClick")
expect(writeBlock).toContain("handleOpenDiffClick")
})
it("apply_patch tool can render from patch metadata without before/after", () => {
expect(patchBlock).toContain("file.patch")
expect(patchBlock).toContain("normalize({")
expect(patchBlock).toContain("file: file.relativePath")
expect(patchBlock).toContain('mode="diff"')
})
it("apply_patch tool exposes the diff action for each file", () => {
expect(patchBlock).toContain("data.openDiff")
expect(patchBlock).toContain("const allDiffAction = ()")
expect(patchBlock).toContain("{allDiffAction()}")
expect(patchBlock).not.toContain("data.openFile(file.filePath)")
})
it("apply_patch skips files whose patch has no parsable hunks", () => {
expect(patchBlock).toContain("value.fileDiff.hunks.length")
expect(patchBlock).toContain("diff.additions || diff.fileDiff.additionLines.length")
expect(patchBlock).toContain("diff.deletions || diff.fileDiff.deletionLines.length")
})
it("apply_patch open action preserves every file in a multi-file payload", () => {
expect(patchBlock).toContain("const diffs = files().flatMap")
expect(patchBlock).toContain("files: diffs")
expect(patchBlock).toContain("diffs.length === 1 ? first")
})
})
describe("Bash tool static terminal preview (source)", () => {
@@ -73,6 +73,7 @@ import { FeedbackProvider } from "../src/context/feedback"
import { MemoryProvider } from "../src/context/memory"
import { SessionProvider, useSession } from "../src/context/session"
import { WorktreeModeProvider } from "../src/context/worktree-mode"
import { DiffStyleProvider, useDiffStyle } from "../src/context/diff-style"
import { ProviderShell } from "../src/context/provider-shell"
import { ChatView } from "../src/components/chat"
import HistoryView from "../src/components/history/HistoryView"
@@ -179,7 +180,9 @@ import { SidebarToggleButton } from "./SidebarToggleButton"
import { setTabWidths } from "./tab-widths"
import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout"
import { SubagentPanel } from "./SubagentPanel"
import { createSubagentTabs } from "./subagent-tabs"
import { attachSubagentEvent, createSubagentTabs } from "./subagent-tabs"
import { EditPreviewPanel } from "./EditPreviewPanel"
import { createAgentManagerEditPreview, createEditPreviewContextGuard } from "./edit-preview"
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import { createChatFocus, createFocusBridge, createPromptFocus, forgetTerminalFocus, hasQuestionOption } from "./focus"
@@ -189,7 +192,6 @@ import "./agent-manager.css"
import "./agent-manager-review.css"
import { cycleAgent as cycle } from "../src/context/session-agent"
const REVIEW_TAB_ID = "review"
interface SetupState {
active: boolean
message: string
@@ -198,7 +200,6 @@ interface SetupState {
worktreeId?: string
errorCode?: string
}
/** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */
type SidebarSelection = typeof LOCAL | string | null
export type SidePanelState = SidePanel | null
@@ -214,9 +215,7 @@ const AgentManagerContent: Component = () => {
const dialog = useDialog()
const mode = createModeRouter()
let sidebarSearchMenu: SidebarSearchMenuRef | undefined
const [kb, setKb] = createSignal<Record<string, string>>(defaultBindings)
const [setup, setSetup] = createSignal<SetupState>({ active: false, message: "" })
const worktrees = () => registry.active().worktrees()
const setWorktrees = (v: Parameters<Setter<WorktreeState[]>>[0]) => registry.active().setWorktrees(v)
@@ -252,9 +251,7 @@ const AgentManagerContent: Component = () => {
)
const isActivePayload = (pid: string | undefined) =>
projectList().length === 0 || pid === undefined || pid === activeProjectId()
const repoDefaultBranch = () => defaultBaseBranch() ?? repoDetectedBranch() ?? "main"
const DEFAULT_SIDEBAR_WIDTH = 260
const MIN_SIDEBAR_WIDTH = 200
const MAX_SIDEBAR_WIDTH_RATIO = 0.4
@@ -287,11 +284,9 @@ const AgentManagerContent: Component = () => {
const toggleSidebar = sidebar.toggle
const sections = () => registry.active().sections()
const setSections = (v: Parameters<Setter<SectionState[]>>[0]) => registry.active().setSections(v)
// rAF coalescing for resize handlers — at most one signal write per frame
let sidebarRaf: number | undefined
let pendingSidebarWidth: number | undefined
const [history, setHistory] = createSignal(false)
const [sidePanel, setSidePanel] = createSignal<SidePanelState>(null)
const diffOpen = () => sidePanel() === SidePanel.Diff
@@ -317,12 +312,16 @@ const AgentManagerContent: Component = () => {
setReviewActive(false)
setSidePanel(SidePanel.Terminal)
}
const [reviewOpenByContext, setReviewOpenByContext] = createSignal<Record<string, boolean>>({})
const [reviewCommentsByContext, setReviewCommentsByContext] = createSignal<Record<string, ReviewComment[]>>({})
const reviewComposer = createReviewComposer()
const [reviewActive, setReviewActive] = createSignal(false)
const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified")
const diffStyle = useDiffStyle()!
const setSharedDiffStyle = (style: "unified" | "split") => {
if (diffStyle.style() === style) return
diffStyle.setStyle(style)
vscode.postMessage({ type: "agentManager.setReviewDiffStyle", style })
}
const subagents = createSubagentTabs({
current: session.currentSessionID,
sync: (id, parentID) => session.syncSession(id, parentID, "inspector"),
@@ -334,16 +333,29 @@ const AgentManagerContent: Component = () => {
},
hide: () => setSidePanel(null),
})
const editPreview = createAgentManagerEditPreview(
setHistory,
setReviewActive,
() => setSidePanel(SidePanel.EditPreview),
() => setSidePanel(null),
diffStyle.style,
setSharedDiffStyle,
)
createEditPreviewContextGuard(
editPreview.preview,
() => session.currentSessionID() ?? undefined,
() => selection() ?? null,
(id: string) => managedSessions().find((item) => item.id === id)?.worktreeId ?? undefined,
editPreview.close,
)
const markdown = createMarkdownRender(vscode)
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
const worktreeStats = () => registry.active().worktreeStats()
const prStatuses = () => registry.active().prStatuses()
const runStatuses = () => registry.active().runStatuses()
const setRunStatuses: Setter<Record<string, RunStatus>> = (v) => registry.active().setRunStatuses(v)
const runScriptConfigured = () => registry.active().runScriptConfigured()
const setRunScriptConfigured = (v: Parameters<Setter<boolean>>[0]) => registry.active().setRunScriptConfigured(v)
// Local repo git stats (branch name, diff additions/deletions, commits)
const localStats = () => registry.active().localStats()
const projectLive = createProjectLive({
@@ -351,7 +363,6 @@ const AgentManagerContent: Component = () => {
active: isActivePayload,
branch: (branch) => setRepoBranch(branch),
})
const PENDING_PREFIX = "pending:"
const closedDrafts = new Set<string>()
const [activePendingId, setActivePendingId] = createSignal<string | undefined>()
@@ -1113,7 +1124,7 @@ const AgentManagerContent: Component = () => {
// server won't connect to send the sessionsLoaded message.
if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true)
if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") {
setReviewDiffStyle(state.reviewDiffStyle)
diffStyle.setStyle(state.reviewDiffStyle)
}
markdown.setRender(state.reviewMarkdownRender === true)
const current = session.currentSessionID()
@@ -1222,17 +1233,8 @@ const AgentManagerContent: Component = () => {
if (match) projectNav.jump(parseInt(match[1]!) - 1)
}
}
const subagent = (event: Event) => {
const detail = (event as CustomEvent<{ sessionID?: unknown; title?: unknown; parentSessionID?: unknown }>).detail
if (typeof detail?.sessionID !== "string") return
subagents.open(
detail.sessionID,
typeof detail.title === "string" ? detail.title : undefined,
typeof detail.parentSessionID === "string" ? detail.parentSessionID : undefined,
)
}
const detachSubagent = attachSubagentEvent(subagents.open)
window.addEventListener("message", handler)
window.addEventListener("agentManager.openSubagent", subagent)
// Prevent Cmd/Ctrl shortcuts from triggering native browser actions
const preventDefaults = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return
@@ -1281,7 +1283,7 @@ const AgentManagerContent: Component = () => {
confirmDeleteWorktree(sel)
}
window.addEventListener("keydown", deleteKeyHandler)
onCleanup(() => window.removeEventListener("agentManager.openSubagent", subagent))
onCleanup(detachSubagent)
// Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held.
// Capture phase so the terminal's key handlers can't swallow them; blur resets state
@@ -1755,12 +1757,6 @@ const AgentManagerContent: Component = () => {
return diffNotices()[key]
})
const setSharedDiffStyle = (style: "unified" | "split") => {
if (reviewDiffStyle() === style) return
setReviewDiffStyle(style)
vscode.postMessage({ type: "agentManager.setReviewDiffStyle", style })
}
const requestDiffFile = (file: string) => {
const id = diffScopeId()
if (!id) return
@@ -2658,7 +2654,7 @@ const AgentManagerContent: Component = () => {
notice={diffNotice()}
lead={diffScopeControls(true)}
canRevert={scopeCapabilities(review.scope()).revert}
diffStyle={reviewDiffStyle()}
diffStyle={diffStyle.style()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
onMarkdownRenderChange={markdown.update}
@@ -2712,6 +2708,9 @@ const AgentManagerContent: Component = () => {
onClosePanel={() => setSidePanel(null)}
/>
</Show>
<Show when={editPreview.preview()}>
<EditPreviewPanel state={editPreview} visible={() => sidePanel() === SidePanel.EditPreview} />
</Show>
<SideTerminalPanel
state={terms}
contextKey={terms.sideKey}
@@ -2760,7 +2759,7 @@ const AgentManagerContent: Component = () => {
composer={reviewComposer}
onSendAll={closeReviewTab}
onSendClick={() => metrics.track("send_review_comments", "fullscreen_review")}
diffStyle={reviewDiffStyle()}
diffStyle={diffStyle.style()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
onMarkdownRenderChange={markdown.update}
@@ -2788,9 +2787,11 @@ export const AgentManagerApp: Component = () => {
<ProviderShell.Session>
<ProviderShell.Chat>
<WorktreeModeProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
<DiffStyleProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
</DiffStyleProvider>
</WorktreeModeProvider>
</ProviderShell.Chat>
</ProviderShell.Session>
@@ -0,0 +1,81 @@
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { For, Show, type Accessor, type Component } from "solid-js"
import { VirtualDiffView } from "../diff-viewer/VirtualDiffView"
import { useLanguage } from "../src/context/language"
import type { EditPreview } from "./edit-preview"
interface Props {
state: {
preview: Accessor<EditPreview | undefined>
updateStyle: (style: "unified" | "split") => void
updateMarkdown: (render: boolean) => void
close: () => void
}
visible: Accessor<boolean>
}
export const EditPreviewPanel: Component<Props> = (props) => {
const { t } = useLanguage()
return (
<section
class="am-edit-preview-panel"
classList={{ "am-edit-preview-panel-visible": props.visible() }}
aria-label={t("agentManager.editPreview.title")}
aria-hidden={!props.visible()}
inert={!props.visible()}
>
<header class="am-edit-preview-header">
<div class="am-edit-preview-heading">
<Icon name="edit" size="small" />
<span>{t("agentManager.editPreview.title")}</span>
</div>
<Show when={props.state.preview()}>
{(preview) => (
<RadioGroup
options={["unified", "split"] as const}
current={preview().style}
value={(style) => style}
label={(style) =>
style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split")
}
size="small"
onSelect={(style) => {
if (style) props.state.updateStyle(style)
}}
/>
)}
</Show>
<IconButton
icon="close"
size="small"
variant="ghost"
class="am-edit-preview-close"
type="button"
label={t("agentManager.editPreview.close")}
onClick={props.state.close}
/>
</header>
<Show when={props.state.preview()}>
{(preview) => (
<div class="am-edit-preview-files">
<For each={preview().diff.files ?? [preview().diff]}>
{(diff) => (
<VirtualDiffView
diff={diff}
diffStyle={preview().style}
onDiffStyleChange={props.state.updateStyle}
markdownRender={preview().markdown}
onMarkdownRenderChange={props.state.updateMarkdown}
styleSelect={false}
/>
)}
</For>
</div>
)}
</Show>
</section>
)
}
@@ -5066,6 +5066,89 @@ body.vscode-high-contrast-light {
pointer-events: auto;
}
.am-edit-preview-panel {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
opacity: 0;
pointer-events: none;
z-index: 1;
background: var(--surface-base);
will-change: opacity;
}
.am-edit-preview-panel-visible {
opacity: 1;
pointer-events: auto;
}
.am-edit-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
height: 32px;
padding: 0 4px 0 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
background: var(--surface-base);
}
.am-edit-preview-heading {
display: flex;
align-items: center;
min-width: 0;
gap: 6px;
color: var(--text-base);
font-size: var(--font-size-small);
font-weight: 600;
}
.am-edit-preview-close {
flex-shrink: 0;
}
.am-edit-preview-files {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow-y: auto;
}
/* Each file is as tall as its own diff; the list is the single scroller, so a
one-line change no longer reserves a tall empty pane. */
.am-edit-preview-files > .am-review-layout {
flex: 0 0 auto;
min-height: 0;
border-bottom: 1px solid var(--border-weak-base);
}
.am-edit-preview-files > .am-review-layout > .am-review-diff {
flex: 0 0 auto;
overflow: visible;
}
/* Unbounded or extreme diffs keep Pierre's virtualizer, which needs a capped
viewport of its own. */
.am-edit-preview-files > .am-review-layout[data-virtualized="true"] {
height: min(70vh, 640px);
}
.am-edit-preview-files > .am-review-layout[data-virtualized="true"] > .am-review-diff {
flex: 1;
overflow-y: auto;
}
.am-edit-preview-unavailable {
padding: 16px;
color: var(--text-weak);
font-size: var(--font-size-small);
}
.am-subagent-header {
display: flex;
align-items: center;
@@ -0,0 +1,133 @@
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
import type { PermissionFileDiff } from "../src/types/messages"
import type { DiffStyle } from "../src/context/diff-style"
import { LOCAL } from "./navigate"
export interface EditPreview {
diff: PermissionFileDiff
sessionID?: string
style: "unified" | "split"
markdown: boolean
}
export function previewMatchesContext(
previewSessionID: string | undefined,
currentSessionID: string | null | undefined,
selection: string | null | undefined,
worktreeID: string | undefined,
): boolean {
if (!previewSessionID || previewSessionID !== currentSessionID) return false
if (worktreeID) return worktreeID === selection
return selection === LOCAL || selection === null
}
export function createEditPreviewContextGuard(
preview: Accessor<EditPreview | undefined>,
current: Accessor<string | null | undefined>,
selection: Accessor<string | null | undefined>,
owner: (sessionID: string) => string | undefined,
close: () => void,
) {
createEffect(
on(
() => {
const item = preview()
const worktree = item?.sessionID ? owner(item.sessionID) : undefined
return `${item?.sessionID ?? ""}:${current() ?? ""}:${selection() ?? "unassigned"}:${worktree ?? "local"}`
},
() => {
const item = preview()
if (item && !previewMatchesContext(item.sessionID, current(), selection(), owner(item.sessionID!))) close()
},
{ defer: true },
),
)
}
interface Options {
show: () => void
hide: () => void
style?: Accessor<DiffStyle>
onStyleChange?: (style: DiffStyle) => void
}
export function createEditPreview(opts: Options) {
const [preview, setPreview] = createSignal<EditPreview>()
const open = (diff: PermissionFileDiff, sessionID?: string, style?: DiffStyle) => {
setPreview({ diff, sessionID, style: style ?? opts.style?.() ?? "unified", markdown: false })
opts.show()
}
const updateStyle = (style: "unified" | "split") => {
setPreview((current) => (current ? { ...current, style } : current))
opts.onStyleChange?.(style)
}
const updateMarkdown = (markdown: boolean) => {
setPreview((current) => (current ? { ...current, markdown } : current))
}
const close = () => {
setPreview(undefined)
opts.hide()
}
return { preview: preview as Accessor<EditPreview | undefined>, open, updateStyle, updateMarkdown, close }
}
export function isEditPreviewDiff(value: unknown): value is PermissionFileDiff {
if (!value || typeof value !== "object") return false
const diff = value as Partial<PermissionFileDiff>
return (
typeof diff.file === "string" &&
typeof diff.additions === "number" &&
typeof diff.deletions === "number" &&
(diff.patch === undefined || typeof diff.patch === "string") &&
(diff.files === undefined ||
(Array.isArray(diff.files) && diff.files.length > 0 && diff.files.every((file) => isEditPreviewDiff(file))))
)
}
export function handleEditPreviewEvent(
event: Event,
open: (diff: PermissionFileDiff, sessionID?: string, style?: "unified" | "split") => void,
): void {
const detail = (event as CustomEvent<{ diff?: unknown; sessionID?: unknown; initialDiffStyle?: unknown }>).detail
if (!isEditPreviewDiff(detail?.diff)) return
open(
detail.diff,
typeof detail.sessionID === "string" ? detail.sessionID : undefined,
detail.initialDiffStyle === "split" ? "split" : "unified",
)
}
export function attachEditPreviewEvent(
open: (diff: PermissionFileDiff, sessionID?: string, style?: "unified" | "split") => void,
): () => void {
const handler = (event: Event) => handleEditPreviewEvent(event, open)
window.addEventListener("agentManager.openEditPreview", handler)
return () => window.removeEventListener("agentManager.openEditPreview", handler)
}
export function createAgentManagerEditPreview(
history: (value: boolean) => void,
review: (value: boolean) => void,
show: () => void,
hide: () => void,
style?: Accessor<DiffStyle>,
onStyleChange?: (style: DiffStyle) => void,
) {
const state = createEditPreview({
show: () => {
history(false)
review(false)
show()
},
hide,
style,
onStyleChange,
})
onCleanup(attachEditPreviewEvent(state.open))
return state
}
@@ -148,6 +148,9 @@ export const dict = {
"agentManager.diff.revertFile": "استعادة الملف",
"agentManager.diff.revertSuccess": "تم استعادة الملف",
"agentManager.diff.revertError": "فشل الاستعادة",
"agentManager.editPreview.title": "معاينة التعديل",
"agentManager.editPreview.close": "إغلاق معاينة التعديل",
"agentManager.editPreview.openInPanel": "عرض التغييرات في اللوحة",
"agentManager.diff.applyBranchOnly":
"لا يعمل تطبيق التغييرات إلا على فرق الفرع الكامل. انتقل إلى نطاق Branch لتطبيقها.",
"agentManager.open.button": "فتح",
@@ -151,6 +151,9 @@ export const dict = {
"agentManager.diff.revertFile": "Reverter arquivo",
"agentManager.diff.revertSuccess": "Arquivo revertido",
"agentManager.diff.revertError": "Falha ao reverter",
"agentManager.editPreview.title": "Pré-visualização da edição",
"agentManager.editPreview.close": "Fechar pré-visualização da edição",
"agentManager.editPreview.openInPanel": "Ver alterações no painel",
"agentManager.diff.applyBranchOnly":
"Aplicar funciona apenas no diff completo da branch. Mude para o escopo Branch para aplicar.",
"agentManager.open.button": "Abrir",
@@ -151,6 +151,9 @@ export const dict = {
"agentManager.diff.revertFile": "Vrati datoteku",
"agentManager.diff.revertSuccess": "Datoteka vraćena",
"agentManager.diff.revertError": "Vraćanje neuspješno",
"agentManager.editPreview.title": "Pregled uređivanja",
"agentManager.editPreview.close": "Zatvori pregled uređivanja",
"agentManager.editPreview.openInPanel": "Prikaži izmjene u panelu",
"agentManager.diff.applyBranchOnly":
"Primijeni radi samo s kompletnim diffom grane. Prebacite se na opseg Branch da biste primijenili.",
"agentManager.open.button": "Otvori",
@@ -152,6 +152,9 @@ export const dict = {
"agentManager.diff.revertFile": "Gendan fil",
"agentManager.diff.revertSuccess": "Fil gendannet",
"agentManager.diff.revertError": "Gendannelse fejlede",
"agentManager.editPreview.title": "Forhåndsvisning af redigering",
"agentManager.editPreview.close": "Luk forhåndsvisning af redigering",
"agentManager.editPreview.openInPanel": "Vis ændringer i panelet",
"agentManager.diff.applyBranchOnly":
"Anvend virker kun på hele Branch-diffen. Skift til Branch-området for at anvende.",
"agentManager.open.button": "Åbn",
@@ -153,6 +153,9 @@ export const dict = {
"agentManager.diff.revertFile": "Datei zurücksetzen",
"agentManager.diff.revertSuccess": "Datei zurückgesetzt",
"agentManager.diff.revertError": "Zurücksetzen fehlgeschlagen",
"agentManager.editPreview.title": "Bearbeitungsvorschau",
"agentManager.editPreview.close": "Bearbeitungsvorschau schließen",
"agentManager.editPreview.openInPanel": "Änderungen im Panel anzeigen",
"agentManager.diff.applyBranchOnly":
"Anwenden funktioniert nur für den vollständigen Branch-Diff. Wechsle zum Bereich Branch, um anzuwenden.",
"agentManager.open.button": "Öffnen",
@@ -155,6 +155,9 @@ export const dict = {
"agentManager.diff.revertFile": "Revert file",
"agentManager.diff.revertSuccess": "File reverted",
"agentManager.diff.revertError": "Revert failed",
"agentManager.editPreview.title": "Edit preview",
"agentManager.editPreview.close": "Close edit preview",
"agentManager.editPreview.openInPanel": "View changes in panel",
"agentManager.diff.applyBranchOnly": "Apply works on the full branch diff. Switch to the Branch scope to apply.",
"agentManager.open.button": "Open",
"agentManager.open.tooltip": "Open this worktree in VS Code",
@@ -152,6 +152,9 @@ export const dict = {
"agentManager.diff.revertFile": "Revertir archivo",
"agentManager.diff.revertSuccess": "Archivo revertido",
"agentManager.diff.revertError": "Error al revertir",
"agentManager.editPreview.title": "Vista previa de la edición",
"agentManager.editPreview.close": "Cerrar vista previa de la edición",
"agentManager.editPreview.openInPanel": "Ver cambios en el panel",
"agentManager.diff.applyBranchOnly":
"Aplicar solo funciona con el diff completo de la rama. Cambia al ámbito Branch para aplicar.",
"agentManager.open.button": "Abrir",
@@ -155,6 +155,9 @@ export const dict = {
"agentManager.diff.revertFile": "بازگردانی فایل",
"agentManager.diff.revertSuccess": "فایل بازگردانی شد",
"agentManager.diff.revertError": "بازگردانی ناموفق بود",
"agentManager.editPreview.title": "پیش‌نمایش ویرایش",
"agentManager.editPreview.close": "بستن پیش‌نمایش ویرایش",
"agentManager.editPreview.openInPanel": "نمایش تغییرات در پنل",
"agentManager.diff.applyBranchOnly":
"اعمال تغییرات روی اختلاف کامل شاخه انجام می‌شود. برای اعمال، به محدوده Branch بروید.",
"agentManager.open.button": "باز کردن",
@@ -152,6 +152,9 @@ export const dict = {
"agentManager.diff.revertFile": "Rétablir le fichier",
"agentManager.diff.revertSuccess": "Fichier rétabli",
"agentManager.diff.revertError": "Échec du rétablissement",
"agentManager.editPreview.title": "Aperçu de la modification",
"agentManager.editPreview.close": "Fermer l'aperçu de la modification",
"agentManager.editPreview.openInPanel": "Afficher les modifications dans le panneau",
"agentManager.diff.applyBranchOnly":
"Appliquer ne fonctionne que sur le diff complet de la branche. Passez à la portée Branch pour appliquer.",
"agentManager.open.button": "Ouvrir",
@@ -158,6 +158,9 @@ export const dict = {
"agentManager.diff.revertFile": "Ripristina file",
"agentManager.diff.revertSuccess": "File ripristinato",
"agentManager.diff.revertError": "Ripristino non riuscito",
"agentManager.editPreview.title": "Anteprima modifica",
"agentManager.editPreview.close": "Chiudi anteprima modifica",
"agentManager.editPreview.openInPanel": "Visualizza modifiche nel pannello",
"agentManager.diff.applyBranchOnly":
"Applica funziona solo sul diff completo del branch. Passa all'ambito Branch per applicare.",
"agentManager.open.button": "Apri",
@@ -151,6 +151,9 @@ export const dict = {
"agentManager.diff.revertFile": "ファイルを元に戻す",
"agentManager.diff.revertSuccess": "ファイルを元に戻しました",
"agentManager.diff.revertError": "元に戻せませんでした",
"agentManager.editPreview.title": "編集プレビュー",
"agentManager.editPreview.close": "編集プレビューを閉じる",
"agentManager.editPreview.openInPanel": "変更をパネルで表示",
"agentManager.diff.applyBranchOnly":
"適用はブランチ全体の差分に対してのみ利用できます。適用するにはスコープを Branch に切り替えてください。",
"agentManager.open.button": "開く",
@@ -149,6 +149,9 @@ export const dict = {
"agentManager.diff.revertFile": "파일 되돌리기",
"agentManager.diff.revertSuccess": "파일이 되돌려졌습니다",
"agentManager.diff.revertError": "되돌리기 실패",
"agentManager.editPreview.title": "편집 미리보기",
"agentManager.editPreview.close": "편집 미리보기 닫기",
"agentManager.editPreview.openInPanel": "패널에서 변경 사항 보기",
"agentManager.diff.applyBranchOnly":
"적용은 전체 브랜치 diff에서만 작동합니다. 적용하려면 범위를 Branch로 전환하세요.",
"agentManager.open.button": "열기",
@@ -157,6 +157,9 @@ export const dict = {
"agentManager.diff.revertFile": "Bestand terugzetten",
"agentManager.diff.revertSuccess": "Bestand teruggezet",
"agentManager.diff.revertError": "Terugzetten mislukt",
"agentManager.editPreview.title": "Voorbeeld van bewerking",
"agentManager.editPreview.close": "Voorbeeld van bewerking sluiten",
"agentManager.editPreview.openInPanel": "Wijzigingen in paneel bekijken",
"agentManager.diff.applyBranchOnly":
"Toepassen werkt alleen op de volledige branch-diff. Schakel naar het bereik Branch om toe te passen.",
"agentManager.open.button": "Openen",
@@ -150,6 +150,9 @@ export const dict = {
"agentManager.diff.revertFile": "Tilbakestill fil",
"agentManager.diff.revertSuccess": "Fil tilbakestilt",
"agentManager.diff.revertError": "Tilbakestilling feilet",
"agentManager.editPreview.title": "Forhåndsvisning av redigering",
"agentManager.editPreview.close": "Lukk forhåndsvisning av redigering",
"agentManager.editPreview.openInPanel": "Vis endringer i panelet",
"agentManager.diff.applyBranchOnly": "Bruk fungerer kun på hele Branch-diffen. Bytt til Branch-omfanget for å bruke.",
"agentManager.open.button": "Åpne",
"agentManager.open.tooltip": "Åpne dette Worktree-et i VS Code",
@@ -151,6 +151,9 @@ export const dict = {
"agentManager.diff.revertFile": "Cofnij plik",
"agentManager.diff.revertSuccess": "Plik cofnięty",
"agentManager.diff.revertError": "Cofanie nie powiodło się",
"agentManager.editPreview.title": "Podgląd edycji",
"agentManager.editPreview.close": "Zamknij podgląd edycji",
"agentManager.editPreview.openInPanel": "Wyświetl zmiany w panelu",
"agentManager.diff.applyBranchOnly":
"Funkcja Zastosuj działa tylko z pełnym diffem brancha. Przełącz się na zakres Branch, aby zastosować.",
"agentManager.open.button": "Otwórz",
@@ -152,6 +152,9 @@ export const dict = {
"agentManager.diff.revertFile": "Откатить файл",
"agentManager.diff.revertSuccess": "Файл откатан",
"agentManager.diff.revertError": "Ошибка отката",
"agentManager.editPreview.title": "Предпросмотр редактирования",
"agentManager.editPreview.close": "Закрыть предпросмотр редактирования",
"agentManager.editPreview.openInPanel": "Показать изменения на панели",
"agentManager.diff.applyBranchOnly":
"Применение работает только с полным diff ветки. Чтобы применить изменения, переключитесь на область Branch.",
"agentManager.open.button": "Открыть",
@@ -146,6 +146,9 @@ export const dict = {
"agentManager.diff.revertFile": "ย้อนกลับไฟล์",
"agentManager.diff.revertSuccess": "ย้อนกลับไฟล์แล้ว",
"agentManager.diff.revertError": "ย้อนกลับล้มเหลว",
"agentManager.editPreview.title": "ตัวอย่างการแก้ไข",
"agentManager.editPreview.close": "ปิดตัวอย่างการแก้ไข",
"agentManager.editPreview.openInPanel": "ดูการเปลี่ยนแปลงในแผง",
"agentManager.diff.applyBranchOnly":
"นำไปใช้ได้เฉพาะกับ diff ของ Branch ทั้งหมดเท่านั้น สลับไปที่ขอบเขต Branch เพื่อใช้งาน",
"agentManager.open.button": "เปิด",
@@ -158,6 +158,9 @@ export const dict = {
"agentManager.diff.revertFile": "Dosyayı geri al",
"agentManager.diff.revertSuccess": "Dosya geri alındı",
"agentManager.diff.revertError": "Geri alma başarısız",
"agentManager.editPreview.title": "Düzenleme önizlemesi",
"agentManager.editPreview.close": "Düzenleme önizlemesini kapat",
"agentManager.editPreview.openInPanel": "Değişiklikleri panelde görüntüle",
"agentManager.diff.applyBranchOnly":
"Uygula yalnızca tam Branch diff'inde çalışır. Uygulamak için Branch kapsamına geçin.",
"agentManager.open.button": "Aç",
@@ -159,6 +159,9 @@ export const dict = {
"agentManager.diff.revertFile": "Скасувати зміни файлу",
"agentManager.diff.revertSuccess": "Файл відновлено",
"agentManager.diff.revertError": "Не вдалося відновити",
"agentManager.editPreview.title": "Попередній перегляд редагування",
"agentManager.editPreview.close": "Закрити попередній перегляд редагування",
"agentManager.editPreview.openInPanel": "Переглянути зміни на панелі",
"agentManager.diff.applyBranchOnly":
"Застосування працює лише з повним diff гілки. Щоб застосувати зміни, перемкніться на область Branch.",
"agentManager.open.button": "Відкрити",
@@ -145,6 +145,9 @@ export const dict = {
"agentManager.diff.revertFile": "还原文件",
"agentManager.diff.revertSuccess": "文件已还原",
"agentManager.diff.revertError": "还原失败",
"agentManager.editPreview.title": "编辑预览",
"agentManager.editPreview.close": "关闭编辑预览",
"agentManager.editPreview.openInPanel": "在面板中查看更改",
"agentManager.diff.applyBranchOnly": "应用仅适用于完整的分支差异。请切换到 Branch 范围后再应用。",
"agentManager.open.button": "打开",
"agentManager.open.tooltip": "在 VS Code 中打开此 Worktree",
@@ -145,6 +145,9 @@ export const dict = {
"agentManager.diff.revertFile": "還原檔案",
"agentManager.diff.revertSuccess": "檔案已還原",
"agentManager.diff.revertError": "還原失敗",
"agentManager.editPreview.title": "編輯預覽",
"agentManager.editPreview.close": "關閉編輯預覽",
"agentManager.editPreview.openInPanel": "在面板中檢視變更",
"agentManager.diff.applyBranchOnly": "套用僅適用於完整的分支差異。請切換至 Branch 範圍後再套用。",
"agentManager.open.button": "開啟",
"agentManager.open.tooltip": "在 VS Code 中開啟此 Worktree",
@@ -10,6 +10,7 @@ export enum SidePanel {
PR = "pr",
Terminal = "terminal",
Subagents = "subagents",
EditPreview = "edit-preview",
}
function viewportWidth(viewport: number): number {
@@ -88,3 +88,17 @@ export function createSubagentTabs(opts: Options) {
return { tabs, active, open, select, close, closeOthers, reorder }
}
export function attachSubagentEvent(open: (id: string, title?: string, parentID?: string) => void): () => void {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ sessionID?: unknown; title?: unknown; parentSessionID?: unknown }>).detail
if (typeof detail?.sessionID !== "string") return
open(
detail.sessionID,
typeof detail.title === "string" ? detail.title : undefined,
typeof detail.parentSessionID === "string" ? detail.parentSessionID : undefined,
)
}
window.addEventListener("agentManager.openSubagent", handler)
return () => window.removeEventListener("agentManager.openSubagent", handler)
}
@@ -0,0 +1,150 @@
import { createEffect, createMemo, on, onCleanup, Show, type Component } from "solid-js"
import { Diff } from "@kilocode/kilo-ui/diff"
import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { normalize } from "@kilocode/kilo-ui/session-diff"
import { useLanguage } from "../src/context/language"
import { EXTREME_DIFF_CHANGED_LINES } from "./diff-open-policy"
import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView"
export interface VirtualDiffFile {
file: string
patch?: string
additions: number
deletions: number
files?: VirtualDiffFile[]
}
export interface VirtualDiffViewProps {
diff: VirtualDiffFile
diffStyle: "unified" | "split"
onDiffStyleChange: (style: "unified" | "split") => void
markdownRender: boolean
onMarkdownRenderChange: (render: boolean) => void
/** Hidden when a shared control already drives every stacked file. */
styleSelect?: boolean
}
export const VirtualDiffView: Component<VirtualDiffViewProps> = (props) => {
const { t } = useLanguage()
let scroller: HTMLDivElement | undefined
createEffect(
on(
() => props.diff,
() => {
if (scroller) scroller.scrollTop = 0
},
{ defer: true },
),
)
onCleanup(() => {
scroller = undefined
})
const filename = () => {
const file = props.diff.file
return file.includes("/") ? (file.split("/").pop() ?? file) : file
}
const directory = () => {
const file = props.diff.file
if (!file.includes("/")) return null
return file.split("/").slice(0, -1).join("/")
}
// A patch that Pierre cannot turn into hunks has nothing to show; the caller
// renders the unavailable state instead of an empty diff container.
const view = createMemo(() => {
if (!props.diff.patch) return
const value = normalize(props.diff)
if (!value.fileDiff.hunks.length) return
return value
})
// Provided counts win, but added files often report 0 while the patch has
// real lines, so the parsed hunks are the fallback.
const counts = createMemo(() => {
const value = view()
if (!value) return { additions: props.diff.additions, deletions: props.diff.deletions }
return {
additions: props.diff.additions || value.fileDiff.additionLines.length,
deletions: props.diff.deletions || value.fileDiff.deletionLines.length,
}
})
// Hunk-bounded patches render fully and let the surrounding list scroll, so a
// small change no longer reserves a tall pane. Only unbounded or extreme
// diffs keep Pierre's own virtualizer, which needs a capped scroll box.
const heavy = createMemo(
() => !props.diff.patch || counts().additions + counts().deletions > EXTREME_DIFF_CHANGED_LINES,
)
return (
<div class="am-review-layout" data-virtualized={heavy() ? "true" : undefined}>
<div class="am-review-toolbar">
<div class="am-review-toolbar-left">
<Show when={props.styleSelect !== false}>
<RadioGroup
options={["unified", "split"] as const}
current={props.diffStyle}
value={(style) => style}
label={(style) =>
style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split")
}
size="small"
onSelect={(style) => {
if (style) props.onDiffStyleChange(style)
}}
/>
</Show>
<span class="am-review-toolbar-stats">
<FileIcon node={{ path: props.diff.file, type: "file" }} />
<Show when={directory()}>
<span class="am-review-toolbar-dir">{`\u2066${directory()}/\u2069`}</span>
</Show>
<span class="am-review-toolbar-fname">{filename()}</span>
<span class="am-review-toolbar-adds">+{counts().additions}</span>
<span class="am-review-toolbar-dels">-{counts().deletions}</span>
</span>
</div>
<Show when={isMarkdownFile(props.diff.file)}>
<Tooltip value={props.markdownRender ? "Show raw Markdown" : "Render Markdown"} placement="bottom">
<IconButton
icon={props.markdownRender ? "code" : "eye"}
size="small"
variant="ghost"
label={props.markdownRender ? "Show raw Markdown" : "Render Markdown"}
onClick={() => props.onMarkdownRenderChange(!props.markdownRender)}
/>
</Tooltip>
</Show>
</div>
<div class="am-review-diff" style={{ width: "100%" }} ref={(el) => (scroller = el)}>
<Show
when={view()}
fallback={<div class="am-edit-preview-unavailable">Diff preview unavailable for this file.</div>}
>
{(current) => (
<Show
when={props.markdownRender && isMarkdownFile(props.diff.file)}
fallback={
<Diff
fileDiff={current().fileDiff}
diffStyle={props.diffStyle}
hunkSeparators="simple"
virtualized={heavy()}
/>
}
>
<MarkdownDiffView diff={{ file: props.diff.file, before: current().before, after: current().after }} />
</Show>
)}
</Show>
</div>
</div>
)
}
@@ -1,4 +1,4 @@
import { createMemo, createSignal, onCleanup, Show, createEffect, on } from "solid-js"
import { createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import type { Component } from "solid-js"
import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
@@ -7,49 +7,29 @@ import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
import { Code } from "@kilocode/kilo-ui/code"
import { Diff } from "@kilocode/kilo-ui/diff"
import { File } from "@kilocode/kilo-ui/file"
import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { normalize } from "@kilocode/kilo-ui/session-diff"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { LanguageProvider, useLanguage } from "../src/context/language"
import { ServerProvider, useServer } from "../src/context/server"
import { getVSCodeAPI, VSCodeProvider } from "../src/context/vscode"
import { isMarkdownFile, MarkdownDiffView } from "../diff-viewer/MarkdownDiffView"
import { VirtualDiffView, type VirtualDiffFile } from "../diff-viewer/VirtualDiffView"
type DiffStyle = "unified" | "split"
interface DiffVirtualFile {
file: string
patch?: string
additions: number
deletions: number
}
const DiffVirtualContent: Component = () => {
const { t } = useLanguage()
const [diff, setDiff] = createSignal<DiffVirtualFile | null>(null)
const [diff, setDiff] = createSignal<VirtualDiffFile | null>(null)
const [style, setStyle] = createSignal<DiffStyle>("unified")
const [markdown, setMarkdown] = createSignal(false)
let scrollerRef: HTMLDivElement | undefined
createEffect(
on(
diff,
() => {
if (scrollerRef) {
scrollerRef.scrollTop = 0
}
},
{ defer: true },
),
)
const files = createMemo(() => {
const current = diff()
return current?.files?.length ? current.files : current ? [current] : []
})
const handler = (event: MessageEvent) => {
const msg = event.data as {
type: string
diff?: DiffVirtualFile
diff?: VirtualDiffFile
initialDiffStyle?: DiffStyle
markdownRender?: boolean
}
@@ -63,84 +43,46 @@ const DiffVirtualContent: Component = () => {
window.addEventListener("message", handler)
onCleanup(() => window.removeEventListener("message", handler))
const filename = () => {
const f = diff()?.file ?? ""
return f.includes("/") ? (f.split("/").pop() ?? f) : f
}
const directory = () => {
const f = diff()?.file ?? ""
if (!f.includes("/")) return null
return f.split("/").slice(0, -1).join("/")
}
const view = createMemo(() => {
const d = diff()
if (!d?.patch) return
return normalize(d)
})
return (
<div class="am-review-layout">
<Show when={diff()}>
{(d) => (
<>
<div class="am-review-toolbar">
<div class="am-review-toolbar-left">
<RadioGroup
options={["unified", "split"] as const}
current={style()}
size="small"
value={(s) => s}
label={(s) =>
s === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split")
}
onSelect={(s) => {
if (s) setStyle(s)
}}
/>
<span class="am-review-toolbar-stats">
<FileIcon node={{ path: d().file, type: "file" }} />
<Show when={directory()}>
<span class="am-review-toolbar-dir">{`\u2066${directory()}/\u2069`}</span>
</Show>
<span class="am-review-toolbar-fname">{filename()}</span>
<span class="am-review-toolbar-adds">+{d().additions}</span>
<span class="am-review-toolbar-dels">-{d().deletions}</span>
</span>
</div>
<Show when={isMarkdownFile(d().file)}>
<Tooltip value={markdown() ? "Show raw Markdown" : "Render Markdown"} placement="bottom">
<IconButton
icon={markdown() ? "code" : "eye"}
size="small"
variant="ghost"
label={markdown() ? "Show raw Markdown" : "Render Markdown"}
onClick={() => {
const next = !markdown()
setMarkdown(next)
getVSCodeAPI().postMessage({ type: "diffVirtual.setMarkdownRender", render: next })
}}
/>
</Tooltip>
</Show>
</div>
<div class="am-review-diff" style={{ width: "100%" }} ref={(el) => (scrollerRef = el)}>
<Show when={view()}>
{(v) => (
<Show
when={markdown() && isMarkdownFile(d().file)}
fallback={<Diff fileDiff={v().fileDiff} diffStyle={style()} hunkSeparators="simple" />}
>
<MarkdownDiffView diff={{ file: d().file, before: v().before, after: v().after }} />
</Show>
)}
</Show>
</div>
</>
)}
</Show>
</div>
<Show when={files().length > 0}>
<div class="am-review-layout">
<div class="am-review-toolbar">
<div class="am-review-toolbar-left">
<RadioGroup
options={["unified", "split"] as const}
current={style()}
value={(value) => value}
label={(value) =>
value === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split")
}
size="small"
onSelect={(value) => {
if (value) {
setStyle(value)
}
}}
/>
</div>
</div>
<div class="am-edit-preview-files">
<For each={files()}>
{(current) => (
<VirtualDiffView
diff={current}
diffStyle={style()}
onDiffStyleChange={setStyle}
markdownRender={markdown()}
onMarkdownRenderChange={(render) => {
setMarkdown(render)
getVSCodeAPI().postMessage({ type: "diffVirtual.setMarkdownRender", render })
}}
styleSelect={false}
/>
)}
</For>
</div>
</div>
</Show>
)
}
+16 -2
View File
@@ -14,6 +14,10 @@ import { SidebarEmptyState } from "./components/chat/SidebarEmptyState"
import { SidebarTopBar } from "./components/chat/SidebarTopBar"
import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded"
import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides"
import { useWorktreeMode } from "./context/worktree-mode"
import { useDiffStyle } from "./context/diff-style"
import { dispatchAgentManagerEditPreview } from "./utils/agent-manager-events"
import type { PermissionFileDiff } from "./types/messages"
// Override the upstream "task" tool renderer with the fully-expanded version
// that shows child session parts inline in the VS Code sidebar.
@@ -51,6 +55,8 @@ export const DataBridge: Component<{ children: any }> = (props) => {
const vscode = useVSCode()
const prov = useProvider()
const server = useServer()
const worktree = useWorktreeMode()
const diffStyle = useDiffStyle()
// Memos for fields that change infrequently (not per-token) — cheap and
// avoids allocating a fresh array/object on every consumer read.
@@ -124,8 +130,16 @@ export const DataBridge: Component<{ children: any }> = (props) => {
vscode.postMessage({ type: "openFile", filePath, line, column, sessionID })
}
const openDiff = (diff: { file: string; patch?: string; additions: number; deletions: number }) => {
vscode.postMessage({ type: "openDiffVirtual", diff, initialDiffStyle: "split" })
const openDiff = (diff: PermissionFileDiff) => {
if (worktree) {
dispatchAgentManagerEditPreview({
diff,
sessionID: session.currentSessionID(),
initialDiffStyle: diffStyle?.style() ?? "unified",
})
return
}
vscode.postMessage({ type: "openDiffVirtual", diff, initialDiffStyle: diffStyle?.style() ?? "unified" })
}
const openUrl = (url: string) => {
@@ -6,6 +6,11 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { normalize } from "@kilocode/kilo-ui/session-diff"
import type { PermissionFileDiff } from "../../types/messages"
import { useVSCode } from "../../context/vscode"
import { useSession } from "../../context/session"
import { useWorktreeMode } from "../../context/worktree-mode"
import { useDiffStyle } from "../../context/diff-style"
import { dispatchAgentManagerEditPreview } from "../../utils/agent-manager-events"
import { useLanguage } from "../../context/language"
interface PermissionDiffProps {
filediff: PermissionFileDiff
@@ -13,6 +18,10 @@ interface PermissionDiffProps {
export const PermissionDiff: Component<PermissionDiffProps> = (props) => {
const vscode = useVSCode()
const session = useSession()
const worktree = useWorktreeMode()
const diffStyle = useDiffStyle()
const { t } = useLanguage()
const filename = createMemo(() => {
const parts = props.filediff.file.split("/")
return parts[parts.length - 1] ?? props.filediff.file
@@ -31,10 +40,18 @@ export const PermissionDiff: Component<PermissionDiffProps> = (props) => {
})
const openInTab = () => {
if (worktree) {
dispatchAgentManagerEditPreview({
diff: props.filediff,
sessionID: session.currentSessionID(),
initialDiffStyle: diffStyle?.style() ?? "unified",
})
return
}
vscode.postMessage({
type: "openDiffVirtual",
diff: props.filediff,
initialDiffStyle: "unified",
initialDiffStyle: diffStyle?.style() ?? "unified",
})
}
@@ -66,8 +83,13 @@ export const PermissionDiff: Component<PermissionDiffProps> = (props) => {
</div>
<div data-slot="permission-diff-actions">
<DiffChanges changes={props.filediff} />
<Tooltip value="View in new tab">
<IconButton size="small" icon="expand" onClick={openInTab} aria-label="View diff in new tab" />
<Tooltip value={worktree ? t("agentManager.editPreview.openInPanel") : "View in new tab"}>
<IconButton
size="small"
icon="expand"
onClick={openInTab}
aria-label={worktree ? t("agentManager.editPreview.openInPanel") : "View diff in new tab"}
/>
</Tooltip>
</div>
</div>
@@ -0,0 +1,19 @@
import { createContext, createSignal, useContext, type Accessor, type ParentComponent } from "solid-js"
export type DiffStyle = "unified" | "split"
interface DiffStyleContextValue {
style: Accessor<DiffStyle>
setStyle: (style: DiffStyle) => void
}
const DiffStyleContext = createContext<DiffStyleContextValue>()
export const DiffStyleProvider: ParentComponent = (props) => {
const [style, setStyle] = createSignal<DiffStyle>("unified")
return <DiffStyleContext.Provider value={{ style, setStyle }}>{props.children}</DiffStyleContext.Provider>
}
export function useDiffStyle(): DiffStyleContextValue | undefined {
return useContext(DiffStyleContext)
}
@@ -18,6 +18,7 @@ export interface PermissionFileDiff {
patch?: string
additions: number
deletions: number
files?: PermissionFileDiff[]
}
export interface PermissionPatchFile {
@@ -0,0 +1,11 @@
import type { PermissionFileDiff } from "../types/messages"
export interface AgentManagerEditPreviewDetail {
diff: PermissionFileDiff
sessionID?: string
initialDiffStyle: "unified" | "split"
}
export function dispatchAgentManagerEditPreview(detail: AgentManagerEditPreviewDetail): void {
window.dispatchEvent(new CustomEvent("agentManager.openEditPreview", { detail }))
}
+10
View File
@@ -55,6 +55,16 @@ export type OpenDiffFn = (diff: {
patch?: string // kilocode_change
additions: number
deletions: number
// kilocode_change start - multi-file patch preview payload
files?: Array<{
file: string
before?: string
after?: string
patch?: string
additions: number
deletions: number
}>
// kilocode_change end
}) => void
export type OpenUrlFn = (url: string) => void