mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-18 09:24:02 +08:00
feat(agent-manager): custom worktree names with persistent labels and inline rename (#6175)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user