feat(agent-manager): custom worktree names with persistent labels and inline rename (#6175)

This commit is contained in:
Marius
2026-02-23 13:13:13 +00:00
committed by GitHub
parent 308de7f820
commit c17430d08a
7 changed files with 266 additions and 8 deletions
@@ -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<WorktreeStateManager["addWorktree"]>
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
@@ -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 []
@@ -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 }
}
@@ -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()
})
})
// ---------------------------------------------------------------------------
@@ -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<string | null>(null)
const [overClose, setOverClose] = createSignal(false)
const [renamingWt, setRenamingWt] = createSignal<string | null>(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 (
<For each={sortedWorktrees()}>
{(wt, idx) => {
@@ -1161,7 +1179,45 @@ const AgentManagerContent: Component = () => {
>
<Icon name="branch" size="small" />
</Show>
<span class="am-worktree-branch">{worktreeLabel(wt)}</span>
<Show
when={renamingWt() === wt.id}
fallback={
<span
class="am-worktree-branch"
onDblClick={(e) => {
e.stopPropagation()
startRename(wt.id, worktreeLabel(wt))
}}
title="Double-click to rename"
>
{worktreeLabel(wt)}
</span>
}
>
<input
class="am-worktree-rename-input"
value={renameValue()}
onInput={(e) => 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()
})
}
/>
</Show>
<Show when={!busyWorktrees().has(wt.id)}>
<div
class="am-worktree-close"
@@ -1491,6 +1547,7 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
const vscode = useVSCode()
const session = useSession()
const [name, setName] = createSignal("")
const [prompt, setPrompt] = createSignal("")
const [versions, setVersions] = createSignal<VersionCount>(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 (
<Dialog title="New Worktree" fit>
<div class="am-nv-dialog" onKeyDown={handleKeyDown}>
{/* Optional worktree name */}
<input
class="am-nv-name-input"
placeholder="Worktree name (optional)"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
{/* Prompt input — reuses the sidebar chat-input base classes for consistent styling */}
<div class="prompt-input-container am-prompt-input-container">
<div class="prompt-input-wrapper am-prompt-input-wrapper">
@@ -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;
@@ -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 {