From 2d34f5136ad1aa7b0a5b05143a2f24abdd31364c Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 26 Feb 2026 11:04:30 +0100 Subject: [PATCH 1/3] feat(agent-manager): make diff panel resizable with cursor and width memory (#6384) Move ResizeHandle inside diff panel wrapper with edge=start for correct positioning. Set col-resize cursor on document.body during drag so it persists when mouse leaves the handle. diffWidth signal persists across open/close cycles since it lives in the parent component scope. --- .../webview-ui/agent-manager/AgentManagerApp.tsx | 16 ++++++++-------- .../webview-ui/agent-manager/agent-manager.css | 5 +++++ packages/ui/src/components/resize-handle.tsx | 5 +++++ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 3eae1690d84..e7989de7110 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1662,15 +1662,15 @@ const AgentManagerContent: Component = () => { - setDiffWidth(Math.max(200, Math.min(w, window.innerWidth * 0.8)))} - />
+ setDiffWidth(Math.max(200, Math.min(w, window.innerWidth * 0.8)))} + /> [data-component="resize-handle"]::after { + background: var(--surface-interactive-base); +} + .am-diff-panel { display: flex; flex-direction: column; diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index e2eed1bb7c8..371b2e02b3e 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -32,8 +32,12 @@ export function ResizeHandle(props: ResizeHandleProps) { const startSize = local.size let current = startSize + // kilocode_change start - set resize cursor on body during drag + const cursor = local.direction === "horizontal" ? "col-resize" : "row-resize" + // kilocode_change end document.body.style.userSelect = "none" document.body.style.overflow = "hidden" + document.body.style.cursor = cursor // kilocode_change const onMouseMove = (moveEvent: MouseEvent) => { const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY @@ -53,6 +57,7 @@ export function ResizeHandle(props: ResizeHandleProps) { const onMouseUp = () => { document.body.style.userSelect = "" document.body.style.overflow = "" + document.body.style.cursor = "" // kilocode_change document.removeEventListener("mousemove", onMouseMove) document.removeEventListener("mouseup", onMouseUp) From e7d47eb503ae4ef81d1b1ef3c9a3d49371d50489 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 26 Feb 2026 11:04:55 +0100 Subject: [PATCH 2/3] feat(agent-manager): add diff viewer to local tab for unpushed changes (#6363) * feat(agent-manager): add diff viewer to local tab for unpushed changes * refactor: use async git and cache diff target to avoid blocking extension host --- .../src/agent-manager/AgentManagerProvider.ts | 71 +++++++++++++++---- .../agent-manager/AgentManagerApp.tsx | 48 +++++++------ 2 files changed, 86 insertions(+), 33 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 779ad492cd6..4f099d64b75 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import * as cp from "child_process" import type { KiloConnectionService, SessionInfo, HttpClient } from "../services/cli-backend" import { KiloProvider } from "../KiloProvider" import { buildWebviewHtml } from "../utils" @@ -21,6 +22,7 @@ import { MAX_MULTI_VERSIONS } from "./constants" * SESSIONS (bottom) with unassociated workspace sessions. */ const PLATFORM = "agent-manager" as const +const LOCAL_DIFF_ID = "local" as const export class AgentManagerProvider implements vscode.Disposable { public static readonly viewType = "kilo-code.new.AgentManagerPanel" @@ -37,6 +39,7 @@ export class AgentManagerProvider implements vscode.Disposable { private diffInterval: ReturnType | undefined private diffSessionId: string | undefined private lastDiffHash: string | undefined + private cachedDiffTarget: { directory: string; baseBranch: string } | undefined constructor( private readonly extensionUri: vscode.Uri, @@ -1272,7 +1275,8 @@ export class AgentManagerProvider implements vscode.Disposable { // --------------------------------------------------------------------------- /** Resolve worktree path + parentBranch for a session, or undefined if not applicable. */ - private resolveDiffTarget(sessionId: string): { directory: string; baseBranch: string } | undefined { + private async resolveDiffTarget(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { + if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocalDiffTarget() const state = this.getStateManager() if (!state) { this.log(`resolveDiffTarget: no state manager for session ${sessionId}`) @@ -1297,7 +1301,45 @@ export class AgentManagerProvider implements vscode.Disposable { return { directory: worktree.path, baseBranch: worktree.parentBranch } } - /** One-shot diff fetch with loading indicators. Used by requestWorktreeDiff. */ + /** Resolve diff target for the local workspace — diffs against the remote tracking branch. */ + private async resolveLocalDiffTarget(): Promise<{ directory: string; baseBranch: string } | undefined> { + const root = this.getWorkspaceRoot() + if (!root) return undefined + const tracking = await this.getRemoteTrackingBranch(root) + if (!tracking) { + this.log("Local diff: no remote tracking branch found") + return undefined + } + return { directory: root, baseBranch: tracking } + } + + /** Detect the remote tracking branch for the current branch in the given directory. */ + private async getRemoteTrackingBranch(cwd: string): Promise { + // Try configured upstream tracking branch first (e.g. origin/feature-x) + const upstream = await this.git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]) + if (upstream) return upstream + + // No upstream configured — construct origin/ + const branch = await this.git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) + if (!branch || branch === "HEAD") return undefined + const ref = `origin/${branch}` + const resolved = await this.git(cwd, ["rev-parse", "--verify", ref]) + if (resolved) return ref + + return undefined + } + + /** Run a git command asynchronously and return trimmed stdout, or undefined on failure. */ + private git(cwd: string, args: string[]): Promise { + return new Promise((resolve) => { + cp.execFile("git", args, { cwd, encoding: "utf-8", timeout: 5000 }, (err, stdout) => { + if (err) resolve(undefined) + else resolve(stdout.trim() || undefined) + }) + }) + } + + /** One-shot diff fetch with loading indicators. Resolves target async, then fetches. */ private async onRequestWorktreeDiff(sessionId: string): Promise { // Ensure state is loaded before resolving diff target — avoids race where // startDiffWatch arrives before initializeState() finishes loading state from disk. @@ -1308,9 +1350,12 @@ export class AgentManagerProvider implements vscode.Disposable { await this.stateReady.catch((err) => this.log("stateReady rejected, continuing diff resolve:", err)) } - const target = this.resolveDiffTarget(sessionId) + const target = await this.resolveDiffTarget(sessionId) if (!target) return + // Cache the resolved target so subsequent polls skip resolution entirely + this.cachedDiffTarget = target + this.postToWebview({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true }) try { const client = this.connectionService.getHttpClient() @@ -1330,9 +1375,9 @@ export class AgentManagerProvider implements vscode.Disposable { } } - /** Polling diff fetch — no loading state, only pushes when hash changes. */ + /** Polling diff fetch — uses cached target, no loading state, only pushes when hash changes. */ private async pollDiff(sessionId: string): Promise { - const target = this.resolveDiffTarget(sessionId) + const target = this.cachedDiffTarget if (!target) return try { @@ -1356,13 +1401,14 @@ export class AgentManagerProvider implements vscode.Disposable { this.lastDiffHash = undefined this.log(`Starting diff polling for session ${sessionId}`) - // Initial fetch with loading state - void this.onRequestWorktreeDiff(sessionId) - - // Subsequent polls without loading state - this.diffInterval = setInterval(() => { - void this.pollDiff(sessionId) - }, 2500) + // Initial fetch resolves + caches the diff target, then starts interval polling + void this.onRequestWorktreeDiff(sessionId).then(() => { + // Only start interval if still watching the same session (may have been stopped) + if (this.diffSessionId !== sessionId) return + this.diffInterval = setInterval(() => { + void this.pollDiff(sessionId) + }, 2500) + }) } private stopDiffPolling(): void { @@ -1372,6 +1418,7 @@ export class AgentManagerProvider implements vscode.Disposable { } this.diffSessionId = undefined this.lastDiffHash = undefined + this.cachedDiffTarget = undefined } private postToWebview(message: Record): void { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index e7989de7110..180446df0b8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -826,11 +826,19 @@ const AgentManagerContent: Component = () => { // Start/stop diff watch when panel opens/closes or session changes createEffect(() => { const open = diffOpen() + const sel = selection() const id = session.currentSessionID() - if (open && id) { - const ms = managedSessions().find((s) => s.id === id) - if (ms?.worktreeId) { - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id }) + if (open) { + if (sel === LOCAL) { + // For local tab, diff against unpushed changes using LOCAL sentinel + vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: LOCAL }) + } 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" }) + } } else { vscode.postMessage({ type: "agentManager.stopDiffWatch" }) } @@ -1532,22 +1540,20 @@ const AgentManagerContent: Component = () => { />
- - - setDiffOpen((prev) => !prev)} - /> - - + + setDiffOpen((prev) => !prev)} + /> + { onResize={(w) => setDiffWidth(Math.max(200, Math.min(w, window.innerWidth * 0.8)))} /> setDiffOpen(false)} /> From 5ccfa591db069881f1558e005d1b4d8066408a44 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 26 Feb 2026 11:05:05 +0100 Subject: [PATCH 3/3] fix: normalize agent manager worktree path handling on Windows (#6382) * fix: normalize agent manager worktree path comparisons Prevent Windows separator/casing mismatches from breaking worktree lookup, external-worktree detection, and import validation. * fix: harden managed worktree delete path guard Replace raw prefix matching with a relative-path boundary check so sibling paths like worktrees-evil are rejected. --- .../src/agent-manager/AgentManagerProvider.ts | 3 ++- .../src/agent-manager/WorktreeManager.ts | 23 +++++++++++++++++-- .../src/agent-manager/WorktreeStateManager.ts | 4 +++- .../src/agent-manager/git-import.ts | 11 +++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 4f099d64b75..5cea5d8d5db 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -6,6 +6,7 @@ import { buildWebviewHtml } from "../utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { WorktreeStateManager } from "./WorktreeStateManager" import { versionedName } from "./branch-name" +import { normalizePath } from "./git-import" import { SetupScriptService } from "./SetupScriptService" import { SetupScriptRunner } from "./SetupScriptRunner" import { SessionTerminalManager } from "./SessionTerminalManager" @@ -978,7 +979,7 @@ export class AgentManagerProvider implements vscode.Disposable { let worktree: ReturnType | undefined try { const externals = await manager.listExternalWorktrees(new Set(state.getWorktrees().map((wt) => wt.path))) - if (!externals.some((e) => e.path === wtPath)) { + if (!externals.some((e) => normalizePath(e.path) === normalizePath(wtPath))) { this.postToWebview({ type: "agentManager.importResult", success: false, diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 9feeaa9cf86..623b3a55a58 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -20,6 +20,7 @@ import { checkedOutBranchesFromWorktreeList, classifyPRError, validateGitRef, + normalizePath, type PRInfo, type BranchListItem, } from "./git-import" @@ -170,7 +171,7 @@ export class WorktreeManager { // Git doesn't know about this directory — remove it directly if (fs.existsSync(worktreePath)) { - if (!worktreePath.startsWith(this.dir)) { + if (!this.isManagedPath(worktreePath)) { this.log(`Refusing to remove path outside worktrees directory: ${worktreePath}`) return } @@ -256,6 +257,20 @@ export class WorktreeManager { } } + /** + * Returns true when target is strictly inside the managed worktrees directory. + * Prevents sibling-prefix confusion such as "/worktrees-evil". + */ + private isManagedPath(target: string): boolean { + const root = path.resolve(this.dir) + const child = path.resolve(target) + const rel = normalizePath(path.relative(root, child)) + if (!rel || rel === ".") return false + if (rel.startsWith("../")) return false + if (path.isAbsolute(rel)) return false + return true + } + private async addExcludeEntry(excludePath: string, entry: string, comment: string): Promise { const infoDir = path.dirname(excludePath) if (!fs.existsSync(infoDir)) await fs.promises.mkdir(infoDir, { recursive: true }) @@ -390,8 +405,12 @@ export class WorktreeManager { async listExternalWorktrees(managedPaths: Set): Promise { try { const raw = await this.git.raw(["worktree", "list", "--porcelain"]) + const normalizedRoot = normalizePath(this.root) + const normalizedManaged = new Set([...managedPaths].map(normalizePath)) return parseWorktreeList(raw) - .filter((e) => !e.bare && e.path !== this.root && !managedPaths.has(e.path)) + .filter( + (e) => !e.bare && normalizePath(e.path) !== normalizedRoot && !normalizedManaged.has(normalizePath(e.path)), + ) .map((e) => ({ path: e.path, branch: e.branch })) } catch (error) { this.log(`Failed to list external worktrees: ${error}`) diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 5c498e886f8..2ea1b512315 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -11,6 +11,7 @@ import * as path from "path" import * as fs from "fs" +import { normalizePath } from "./git-import" export interface Worktree { id: string @@ -75,8 +76,9 @@ export class WorktreeStateManager { /** Find worktree by its filesystem path. */ findWorktreeByPath(wtPath: string): Worktree | undefined { + const target = normalizePath(wtPath) for (const wt of this.worktrees.values()) { - if (wt.path === wtPath) return wt + if (normalizePath(wt.path) === target) return wt } return undefined } diff --git a/packages/kilo-vscode/src/agent-manager/git-import.ts b/packages/kilo-vscode/src/agent-manager/git-import.ts index abbb066843a..81432295db7 100644 --- a/packages/kilo-vscode/src/agent-manager/git-import.ts +++ b/packages/kilo-vscode/src/agent-manager/git-import.ts @@ -132,6 +132,17 @@ export function validateGitRef(value: string, label: string): void { } } +/** + * Normalize a filesystem path for cross-platform comparison. + * Converts backslashes to forward slashes, strips trailing slashes, + * and lowercases Windows drive-letter paths (case-insensitive filesystem). + */ +export function normalizePath(p: string): string { + const normalized = p.replace(/\\/g, "/").replace(/\/+$/, "") + if (/^[A-Za-z]:/.test(normalized)) return normalized.toLowerCase() + return normalized +} + export function classifyPRError(msg: string): PRErrorKind { if (msg.includes("command not found") || msg.includes("ENOENT") || msg.includes("is not recognized")) return "gh_missing"