From c17430d08a8e71727937455bdd65d0c8af9540bd Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 23 Feb 2026 14:13:13 +0100 Subject: [PATCH] feat(agent-manager): custom worktree names with persistent labels and inline rename (#6175) --- .../src/agent-manager/AgentManagerProvider.ts | 22 ++++- .../src/agent-manager/WorktreeStateManager.ts | 23 ++++- .../src/agent-manager/branch-name.ts | 19 ++++ .../tests/unit/worktree-manager.test.ts | 97 ++++++++++++++++++- .../agent-manager/AgentManagerApp.tsx | 70 ++++++++++++- .../agent-manager/agent-manager.css | 41 ++++++++ .../webview-ui/src/types/messages.ts | 2 + 7 files changed, 266 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d210eaa18ca..5b9c1f190f6 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -4,6 +4,7 @@ import { KiloProvider } from "../KiloProvider" import { buildWebviewHtml } from "../utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { WorktreeStateManager } from "./WorktreeStateManager" +import { versionedName } from "./branch-name" import { SetupScriptService } from "./SetupScriptService" import { SetupScriptRunner } from "./SetupScriptRunner" import { SessionTerminalManager } from "./SessionTerminalManager" @@ -154,6 +155,14 @@ export class AgentManagerProvider implements vscode.Disposable { void this.onCreateMultiVersion(msg) return null } + if (type === "agentManager.renameWorktree" && typeof msg.worktreeId === "string" && typeof msg.label === "string") { + const state = this.getStateManager() + if (state) { + state.updateWorktreeLabel(msg.worktreeId as string, msg.label as string) + this.pushState() + } + return null + } if (type === "agentManager.requestState") { void this.stateReady ?.then(() => { @@ -205,7 +214,11 @@ export class AgentManagerProvider implements vscode.Disposable { // --------------------------------------------------------------------------- /** Create a git worktree on disk and register it in state. Returns null on failure. */ - private async createWorktreeOnDisk(groupId?: string): Promise<{ + private async createWorktreeOnDisk( + groupId?: string, + name?: string, + label?: string, + ): Promise<{ worktree: ReturnType result: CreateWorktreeResult } | null> { @@ -224,7 +237,7 @@ export class AgentManagerProvider implements vscode.Disposable { let result: CreateWorktreeResult try { - result = await manager.createWorktree({ prompt: "kilo" }) + result = await manager.createWorktree({ prompt: name || "kilo" }) } catch (error) { const msg = error instanceof Error ? error.message : String(error) this.postToWebview({ @@ -240,6 +253,7 @@ export class AgentManagerProvider implements vscode.Disposable { path: result.path, parentBranch: result.parentBranch, groupId, + label, }) // Push state immediately so the sidebar shows the new worktree with a loading indicator @@ -461,6 +475,7 @@ export class AgentManagerProvider implements vscode.Disposable { if (!text) return null const versions = Math.min(Math.max(Number(msg.versions) || 1, 1), 4) + const worktreeName = (msg.name as string | undefined)?.trim() || undefined const providerID = msg.providerID as string | undefined const modelID = msg.modelID as string | undefined const agent = msg.agent as string | undefined @@ -494,7 +509,8 @@ export class AgentManagerProvider implements vscode.Disposable { for (let i = 0; i < versions; i++) { this.log(`Creating worktree ${i + 1}/${versions}`) - const wt = await this.createWorktreeOnDisk(groupId) + const version = versionedName(worktreeName, i, versions) + const wt = await this.createWorktreeOnDisk(groupId, version.branch, version.label) if (!wt) { this.log(`Failed to create worktree for version ${i + 1}`) continue diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 5e3fb33312f..f8d19d3699b 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -20,6 +20,8 @@ export interface Worktree { createdAt: string /** Shared identifier for worktrees created together via multi-version mode. */ groupId?: string + /** User-provided display name for the worktree. */ + label?: string } export interface ManagedSession { @@ -109,7 +111,13 @@ export class WorktreeStateManager { // Mutations // --------------------------------------------------------------------------- - addWorktree(params: { branch: string; path: string; parentBranch: string; groupId?: string }): Worktree { + addWorktree(params: { + branch: string + path: string + parentBranch: string + groupId?: string + label?: string + }): Worktree { const id = generateId("wt") const wt: Worktree = { id, @@ -119,12 +127,23 @@ export class WorktreeStateManager { createdAt: new Date().toISOString(), } if (params.groupId) wt.groupId = params.groupId + if (params.label) wt.label = params.label this.worktrees.set(id, wt) - this.log(`Added worktree ${id}: ${params.branch}${params.groupId ? ` (group=${params.groupId})` : ""}`) + this.log( + `Added worktree ${id}: ${params.branch}${params.label ? ` (label=${params.label})` : ""}${params.groupId ? ` (group=${params.groupId})` : ""}`, + ) void this.save() return wt } + updateWorktreeLabel(id: string, label: string): void { + const wt = this.worktrees.get(id) + if (!wt) return + wt.label = label || undefined + this.log(`Updated worktree ${id} label to "${label}"`) + void this.save() + } + removeWorktree(id: string): ManagedSession[] { const removed = this.worktrees.delete(id) if (!removed) return [] diff --git a/packages/kilo-vscode/src/agent-manager/branch-name.ts b/packages/kilo-vscode/src/agent-manager/branch-name.ts index b57bbebc904..c0abce3dfc9 100644 --- a/packages/kilo-vscode/src/agent-manager/branch-name.ts +++ b/packages/kilo-vscode/src/agent-manager/branch-name.ts @@ -11,3 +11,22 @@ export function generateBranchName(prompt: string): string { return `${sanitized || "kilo"}-${Date.now()}` } + +/** + * Compute the branch name and display label for a version in a multi-version group. + * Returns undefined values when no custom name is provided (falls back to auto-generated). + */ +export function versionedName( + base: string | undefined, + index: number, + total: number, +): { branch: string | undefined; label: string | undefined } { + if (!base) return { branch: undefined, label: undefined } + if (total > 1 && index > 0) { + return { + branch: `${base}_v${index + 1}`, + label: `${base} v${index + 1}`, + } + } + return { branch: base, label: base } +} diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 6a15a2cf127..b8d40e2e9a5 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -3,7 +3,8 @@ import os from "node:os" import path from "node:path" import fs from "node:fs/promises" import { WorktreeManager } from "../../src/agent-manager/WorktreeManager" -import { generateBranchName } from "../../src/agent-manager/branch-name" +import { generateBranchName, versionedName } from "../../src/agent-manager/branch-name" +import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" import simpleGit from "simple-git" // Each test gets its own temp directory -- no shared state, safe to run in parallel. @@ -77,6 +78,100 @@ describe("generateBranchName", () => { const name = generateBranchName("FIX BUG") expect(name).toMatch(/^fix-bug-\d+$/) }) + + it("handles custom name with version suffix _v2", () => { + const name = generateBranchName("my-feature_v2") + expect(name).toMatch(/^my-feature-v2-\d+$/) + }) + + it("handles a clean custom name", () => { + const name = generateBranchName("auth-refactor") + expect(name).toMatch(/^auth-refactor-\d+$/) + }) +}) + +// --------------------------------------------------------------------------- +// versionedName +// --------------------------------------------------------------------------- + +describe("versionedName", () => { + it("returns base name for first version", () => { + const result = versionedName("auth-refactor", 0, 3) + expect(result).toEqual({ branch: "auth-refactor", label: "auth-refactor" }) + }) + + it("appends _v2 to branch and v2 to label for second version", () => { + const result = versionedName("auth-refactor", 1, 3) + expect(result).toEqual({ branch: "auth-refactor_v2", label: "auth-refactor v2" }) + }) + + it("appends _v3 to branch and v3 to label for third version", () => { + const result = versionedName("auth-refactor", 2, 3) + expect(result).toEqual({ branch: "auth-refactor_v3", label: "auth-refactor v3" }) + }) + + it("returns undefined for both when no name provided", () => { + expect(versionedName(undefined, 0, 3)).toEqual({ branch: undefined, label: undefined }) + expect(versionedName(undefined, 1, 3)).toEqual({ branch: undefined, label: undefined }) + }) + + it("returns undefined for empty string name", () => { + expect(versionedName("", 0, 2)).toEqual({ branch: undefined, label: undefined }) + }) + + it("no suffix for single version", () => { + const result = versionedName("test", 0, 1) + expect(result).toEqual({ branch: "test", label: "test" }) + }) +}) + +// --------------------------------------------------------------------------- +// WorktreeStateManager -- updateWorktreeLabel +// --------------------------------------------------------------------------- + +describe("WorktreeStateManager.updateWorktreeLabel", () => { + it("persists label on a worktree", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-label-")) + tempDirs.push(dir) + const state = new WorktreeStateManager(dir, () => {}) + const wt = state.addWorktree({ branch: "test", path: dir, parentBranch: "main" }) + state.updateWorktreeLabel(wt.id, "my custom name") + await state.flush() + + expect(state.getWorktree(wt.id)?.label).toBe("my custom name") + }) + + it("clears label when set to empty string", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-label-")) + tempDirs.push(dir) + const state = new WorktreeStateManager(dir, () => {}) + const wt = state.addWorktree({ branch: "test", path: dir, parentBranch: "main", label: "initial" }) + await state.flush() + state.updateWorktreeLabel(wt.id, "") + await state.flush() + + expect(state.getWorktree(wt.id)?.label).toBeUndefined() + }) + + it("survives save and reload", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-label-")) + tempDirs.push(dir) + const state = new WorktreeStateManager(dir, () => {}) + const wt = state.addWorktree({ branch: "test", path: dir, parentBranch: "main", label: "persisted" }) + await state.flush() + + const state2 = new WorktreeStateManager(dir, () => {}) + await state2.load() + expect(state2.getWorktree(wt.id)?.label).toBe("persisted") + }) + + it("no-ops for nonexistent worktree", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-label-")) + tempDirs.push(dir) + const state = new WorktreeStateManager(dir, () => {}) + state.updateWorktreeLabel("nonexistent", "test") + await state.flush() + }) }) // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index c87d7ced5ee..b8914c691ec 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -379,8 +379,9 @@ const AgentManagerContent: Component = () => { const visibleTabId = createMemo(() => session.currentSessionID() ?? activePendingId()) const tabScroll = useTabScroll(activeTabs, visibleTabId) - // Display name for worktree — uses first tab in custom order when available + // Display name for worktree — prefers persisted label, then first session title, then branch const worktreeLabel = (wt: WorktreeState): string => { + if (wt.label) return wt.label const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id) const ids = new Set(managed.map((ms) => ms.id)) const sessions = session.sessions().filter((s) => ids.has(s.id)) @@ -1108,6 +1109,23 @@ const AgentManagerContent: Component = () => { {(() => { const [hoveredWt, setHoveredWt] = createSignal(null) const [overClose, setOverClose] = createSignal(false) + const [renamingWt, setRenamingWt] = createSignal(null) + const [renameValue, setRenameValue] = createSignal("") + + const startRename = (wtId: string, current: string) => { + setRenamingWt(wtId) + setRenameValue(current) + } + + const commitRename = (wtId: string) => { + const value = renameValue().trim() + setRenamingWt(null) + if (!value) return + vscode.postMessage({ type: "agentManager.renameWorktree", worktreeId: wtId, label: value }) + } + + const cancelRename = () => setRenamingWt(null) + return ( {(wt, idx) => { @@ -1161,7 +1179,45 @@ const AgentManagerContent: Component = () => { > - {worktreeLabel(wt)} + { + e.stopPropagation() + startRename(wt.id, worktreeLabel(wt)) + }} + title="Double-click to rename" + > + {worktreeLabel(wt)} + + } + > + setRenameValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + commitRename(wt.id) + } + if (e.key === "Escape") { + e.preventDefault() + cancelRename() + } + }} + onBlur={() => commitRename(wt.id)} + onClick={(e) => e.stopPropagation()} + ref={(el) => + requestAnimationFrame(() => { + el.focus() + el.select() + }) + } + /> +
void }> = (props) => { const vscode = useVSCode() const session = useSession() + const [name, setName] = createSignal("") const [prompt, setPrompt] = createSignal("") const [versions, setVersions] = createSignal(2) const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(null) @@ -1521,6 +1578,7 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { vscode.postMessage({ type: "agentManager.createMultiVersion", text, + name: name().trim() || undefined, versions: count, providerID: sel?.providerID, modelID: sel?.modelID, @@ -1546,6 +1604,14 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { return (
+ {/* Optional worktree name */} + setName(e.currentTarget.value)} + /> + {/* Prompt input — reuses the sidebar chat-input base classes for consistent styling */}
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index a3bc3b55fb5..30fbe2e8de4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -209,6 +209,21 @@ button.am-section-toggle:hover .am-section-label { min-width: 0; } +.am-worktree-rename-input { + flex: 1; + min-width: 0; + height: 22px; + padding: 0 4px; + border: 1px solid var(--border-focus, #007fd4); + border-radius: 3px; + background: var(--surface-base); + color: var(--text-base); + font-size: inherit; + font-weight: 500; + font-family: inherit; + outline: none; +} + .am-worktree-close { position: absolute; right: 4px; @@ -225,6 +240,11 @@ button.am-section-toggle:hover .am-section-label { opacity: 1; } +.am-worktree-item:has(.am-worktree-rename-input) .am-worktree-close { + opacity: 0; + pointer-events: none; +} + .am-worktree-spinner { width: 16px; height: 16px; @@ -775,6 +795,27 @@ button.am-section-toggle:hover .am-section-label { min-width: 460px; } +.am-nv-name-input { + width: 100%; + height: 32px; + padding: 0 10px; + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + background: var(--surface-base); + color: var(--text-base); + font-size: 13px; + outline: none; + box-sizing: border-box; +} + +.am-nv-name-input:focus { + border-color: var(--border-focus, #007fd4); +} + +.am-nv-name-input::placeholder { + color: var(--text-weaker); +} + /* Dialog overrides for prompt-input-container — resizable from bottom edge */ .am-nv-dialog .am-prompt-input-container { margin: 0; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 592a8ffb847..d6e9b9bd417 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -569,6 +569,8 @@ export interface WorktreeState { createdAt: string /** Shared identifier for worktrees created together via multi-version mode. */ groupId?: string + /** User-provided display name for the worktree. */ + label?: string } export interface ManagedSessionState {