From 1cdad80eacee618df35f944d8f5864814d8a2c47 Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 23 Feb 2026 19:26:45 +0100 Subject: [PATCH] feat(agent-manager): add advanced worktree creation with base branch and custom branch name (#6201) * feat(agent-manager): add advanced worktree creation with base branch and custom branch name * fix: remove unused CSS classes and make SendInitialMessage.text optional - Remove am-branch-group-label and am-branch-empty CSS classes (unused import tab leftovers) - Make AgentManagerSendInitialMessage.text optional to match no-prompt code path --- AgentManagerApp.tsx | 0 packages/kilo-vscode/package.json | 11 + .../src/agent-manager/AgentManagerProvider.ts | 114 ++++--- .../src/agent-manager/WorktreeManager.ts | 126 +++++++- packages/kilo-vscode/src/extension.ts | 3 + .../agent-manager/AgentManagerApp.tsx | 281 +++++++++++++++--- .../agent-manager/agent-manager.css | 225 ++++++++++++++ .../webview-ui/src/types/messages.ts | 29 +- 8 files changed, 702 insertions(+), 87 deletions(-) create mode 100644 AgentManagerApp.tsx diff --git a/AgentManagerApp.tsx b/AgentManagerApp.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 0b2924377c..cf7ca68435 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -134,6 +134,11 @@ "title": "Agent Manager: Close Worktree", "category": "Kilo Code" }, + { + "command": "kilo-code.new.agentManager.advancedWorktree", + "title": "Agent Manager: Advanced New Worktree", + "category": "Kilo Code" + }, { "command": "kilo-code.new.generateCommitMessage", "title": "Generate Commit Message", @@ -362,6 +367,12 @@ "key": "ctrl+shift+w", "mac": "cmd+shift+w", "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.advancedWorktree", + "key": "ctrl+shift+n", + "mac": "cmd+shift+n", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" } ], "configuration": { diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 51637e98ca..52a4754a18 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -134,7 +134,9 @@ export class AgentManagerProvider implements vscode.Disposable { private async onMessage(msg: Record): Promise | null> { const type = msg.type as string - if (type === "agentManager.createWorktree") return this.onCreateWorktree() + if (type === "agentManager.createWorktree") { + return this.onCreateWorktree(msg.baseBranch as string | undefined, msg.branchName as string | undefined) + } if (type === "agentManager.deleteWorktree" && typeof msg.worktreeId === "string") return this.onDeleteWorktree(msg.worktreeId) if (type === "agentManager.promoteSession" && typeof msg.sessionId === "string") @@ -186,6 +188,10 @@ export class AgentManagerProvider implements vscode.Disposable { }) return null } + if (type === "agentManager.requestBranches") { + void this.onRequestBranches() + return null + } if (type === "agentManager.setTabOrder" && typeof msg.key === "string" && Array.isArray(msg.order)) { this.state?.setTabOrder(msg.key as string, msg.order as string[]) return null @@ -226,11 +232,14 @@ 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, - name?: string, - label?: string, - ): Promise<{ + private async createWorktreeOnDisk(opts?: { + groupId?: string + baseBranch?: string + branchName?: string + existingBranch?: string + name?: string + label?: string + }): Promise<{ worktree: ReturnType result: CreateWorktreeResult } | null> { @@ -249,7 +258,12 @@ export class AgentManagerProvider implements vscode.Disposable { let result: CreateWorktreeResult try { - result = await manager.createWorktree({ prompt: name || "kilo" }) + result = await manager.createWorktree({ + prompt: opts?.name || "kilo", + baseBranch: opts?.baseBranch, + branchName: opts?.branchName, + existingBranch: opts?.existingBranch, + }) } catch (error) { const msg = error instanceof Error ? error.message : String(error) this.postToWebview({ @@ -269,8 +283,8 @@ export class AgentManagerProvider implements vscode.Disposable { branch: result.branch, path: result.path, parentBranch: result.parentBranch, - groupId, - label, + groupId: opts?.groupId, + label: opts?.label, }) // Push state immediately so the sidebar shows the new worktree with a loading indicator @@ -363,8 +377,8 @@ export class AgentManagerProvider implements vscode.Disposable { // --------------------------------------------------------------------------- /** Create a new worktree with an auto-created first session. */ - private async onCreateWorktree(): Promise { - const created = await this.createWorktreeOnDisk() + private async onCreateWorktree(baseBranch?: string, branchName?: string): Promise { + const created = await this.createWorktreeOnDisk({ baseBranch, branchName }) if (!created) return null // Run setup script for new worktree (blocks until complete, shows in overlay) @@ -423,7 +437,7 @@ export class AgentManagerProvider implements vscode.Disposable { /** Promote a session: create a worktree and move the session into it. */ private async onPromoteSession(sessionId: string): Promise { - const created = await this.createWorktreeOnDisk() + const created = await this.createWorktreeOnDisk({}) if (!created) return null // Run setup script for new worktree (blocks until complete, shows in overlay) @@ -509,14 +523,28 @@ export class AgentManagerProvider implements vscode.Disposable { return null } + // --------------------------------------------------------------------------- + // Branch discovery + // --------------------------------------------------------------------------- + + private async onRequestBranches(): Promise { + const manager = this.getWorktreeManager() + if (!manager) return + try { + const data = await manager.listBranches() + this.postToWebview({ type: "agentManager.branches", branches: data.branches, defaultBranch: data.defaultBranch }) + } catch (error) { + this.log(`Failed to list branches: ${error}`) + } + } + // --------------------------------------------------------------------------- // Multi-version worktree creation // --------------------------------------------------------------------------- /** Create N worktree sessions for the same prompt (multi-version mode). */ private async onCreateMultiVersion(msg: Record): Promise { - const text = msg.text as string - if (!text) return null + const text = (msg.text as string | undefined)?.trim() || undefined const versions = Math.min(Math.max(Number(msg.versions) || 1, 1), 4) const worktreeName = (msg.name as string | undefined)?.trim() || undefined @@ -524,12 +552,14 @@ export class AgentManagerProvider implements vscode.Disposable { const modelID = msg.modelID as string | undefined const agent = msg.agent as string | undefined const files = msg.files as Array<{ mime: string; url: string }> | undefined + const baseBranch = msg.baseBranch as string | undefined + const branchName = (msg.branchName as string | undefined)?.trim() || undefined // Generate a shared group ID for multi-version worktrees const groupId = versions > 1 ? `grp-${Date.now()}` : undefined this.log( - `Creating ${versions} multi-version worktrees for: ${text.slice(0, 60)}${groupId ? ` (group=${groupId})` : ""}`, + `Creating ${versions} worktrees${text ? ` for: ${text.slice(0, 60)}` : ""}${groupId ? ` (group=${groupId})` : ""}`, ) // Notify webview that multi-version creation has started @@ -553,8 +583,14 @@ export class AgentManagerProvider implements vscode.Disposable { for (let i = 0; i < versions; i++) { this.log(`Creating worktree ${i + 1}/${versions}`) - const version = versionedName(worktreeName, i, versions) - const wt = await this.createWorktreeOnDisk(groupId, version.branch, version.label) + const version = versionedName(branchName || worktreeName, i, versions) + const wt = await this.createWorktreeOnDisk({ + groupId, + baseBranch, + branchName: version.branch, + name: version.branch, + label: version.label, + }) if (!wt) { this.log(`Failed to create worktree for version ${i + 1}`) continue @@ -607,29 +643,31 @@ export class AgentManagerProvider implements vscode.Disposable { }) } - // Phase 2: Send the initial prompt to all sessions via the KiloProvider's - // message handling (same path as typing in the chat). This ensures SSE - // subscriptions and session tracking are properly set up before the message - // is sent. We route each message through the webview→KiloProvider pipeline. + // Phase 2: Send the initial prompt to all sessions, or clear busy state if no text for (let i = 0; i < created.length; i++) { const entry = created[i]! - this.log(`Sending initial message to version ${i + 1} (session=${entry.sessionId})`) - - // Tell the webview to send the message through the normal session flow - this.postToWebview({ - type: "agentManager.sendInitialMessage", - sessionId: entry.sessionId, - worktreeId: entry.worktreeId, - text, - providerID, - modelID, - agent, - files, - }) - - // Small delay between sends to avoid overwhelming the backend - if (i < created.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 300)) + if (text) { + this.log(`Sending initial message to version ${i + 1} (session=${entry.sessionId})`) + this.postToWebview({ + type: "agentManager.sendInitialMessage", + sessionId: entry.sessionId, + worktreeId: entry.worktreeId, + text, + providerID, + modelID, + agent, + files, + }) + if (i < created.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 300)) + } + } else { + // No prompt — just clear the busy state for this worktree + this.postToWebview({ + type: "agentManager.sendInitialMessage", + sessionId: entry.sessionId, + worktreeId: entry.worktreeId, + }) } } diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 28df15009d..c3ca0d419d 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -27,6 +27,14 @@ export interface CreateWorktreeResult { parentBranch: string } +export interface BranchInfo { + name: string + isLocal: boolean + isRemote: boolean + lastCommitDate: number + isDefault: boolean +} + const KILOCODE_DIR = ".kilocode" const SESSION_ID_FILE = "session-id" const METADATA_FILE = "metadata.json" @@ -44,7 +52,12 @@ export class WorktreeManager { this.log = log } - async createWorktree(params: { prompt?: string; existingBranch?: string }): Promise { + async createWorktree(params: { + prompt?: string + existingBranch?: string + baseBranch?: string + branchName?: string + }): Promise { const repo = await this.git.checkIsRepo() if (!repo) throw new Error( @@ -54,15 +67,29 @@ export class WorktreeManager { await this.ensureDir() await this.ensureGitExclude() - const parent = await this.currentBranch() - let branch = params.existingBranch ?? generateBranchName(params.prompt || "agent-task") + const parent = params.baseBranch || (await this.currentBranch()) + + // Validate baseBranch exists if explicitly provided + if (params.baseBranch) { + const exists = await this.branchExists(params.baseBranch) + if (!exists) throw new Error(`Base branch "${params.baseBranch}" does not exist`) + // Check if the base branch is a remote-only branch and fetch it + const branches = await this.git.branch() + if (!branches.all.includes(params.baseBranch) && branches.all.includes(`remotes/origin/${params.baseBranch}`)) { + await this.git.fetch("origin", params.baseBranch) + } + } + + let branch = params.existingBranch ?? params.branchName ?? generateBranchName(params.prompt || "agent-task") if (params.existingBranch) { const exists = await this.branchExists(branch) if (!exists) throw new Error(`Branch "${branch}" does not exist`) } - let worktreePath = path.join(this.dir, branch) + // Sanitize directory name — replace slashes with dashes for filesystem safety + const dirName = branch.replace(/\//g, "-") + let worktreePath = path.join(this.dir, dirName) if (fs.existsSync(worktreePath)) { this.log(`Worktree directory exists, cleaning up before re-creation: ${worktreePath}`) @@ -72,20 +99,32 @@ export class WorktreeManager { try { const args = params.existingBranch ? ["worktree", "add", worktreePath, branch] - : ["worktree", "add", "-b", branch, worktreePath] + : params.baseBranch + ? ["worktree", "add", "-b", branch, worktreePath, params.baseBranch] + : ["worktree", "add", "-b", branch, worktreePath] await this.git.raw(args) } catch (error) { const msg = error instanceof Error ? error.message : String(error) + if (msg.includes("already checked out")) { + // Extract worktree path from error like "fatal: 'branch' is already checked out at '/path'" + const match = msg.match(/already checked out at '([^']+)'/) + const loc = match ? match[1] : "another worktree" + throw new Error(`Branch "${branch}" is already checked out in worktree at: ${loc}`) + } if (!msg.includes("already exists") || params.existingBranch) { throw new Error(`Failed to create worktree: ${msg}`) } // Branch name collision -- retry with unique suffix branch = `${branch}-${Date.now()}` - worktreePath = path.join(this.dir, branch) - await this.git.raw(["worktree", "add", "-b", branch, worktreePath]) + const retryDir = branch.replace(/\//g, "-") + worktreePath = path.join(this.dir, retryDir) + const retryArgs = params.baseBranch + ? ["worktree", "add", "-b", branch, worktreePath, params.baseBranch] + : ["worktree", "add", "-b", branch, worktreePath] + await this.git.raw(retryArgs) } - this.log(`Created worktree: ${worktreePath} (branch: ${branch})`) + this.log(`Created worktree: ${worktreePath} (branch: ${branch}, base: ${parent})`) return { branch, path: worktreePath, parentBranch: parent } } @@ -176,6 +215,73 @@ export class WorktreeManager { return undefined } + // --------------------------------------------------------------------------- + // Branch & worktree discovery + // --------------------------------------------------------------------------- + + async listBranches(): Promise<{ branches: BranchInfo[]; defaultBranch: string }> { + const defBranch = await this.defaultBranch() + + // Get local branches with commit dates + const localRaw = await this.git + .raw(["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)\t%(committerdate:unix)", "refs/heads/"]) + .then((out) => out.trim()) + .catch(() => "") + + // Get remote branches with commit dates + const remoteRaw = await this.git + .raw([ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname:short)\t%(committerdate:unix)", + "refs/remotes/origin/", + ]) + .then((out) => out.trim()) + .catch(() => "") + + const map = new Map() + + for (const line of localRaw.split("\n").filter(Boolean)) { + const [name, dateStr] = line.split("\t") + if (!name) continue + map.set(name, { + name, + isLocal: true, + isRemote: false, + lastCommitDate: parseInt(dateStr || "0", 10), + isDefault: name === defBranch, + }) + } + + for (const line of remoteRaw.split("\n").filter(Boolean)) { + const [ref, dateStr] = line.split("\t") + if (!ref) continue + const name = ref.replace(/^origin\//, "") + if (name === "HEAD") continue + const existing = map.get(name) + if (existing) { + existing.isRemote = true + } else { + map.set(name, { + name, + isLocal: false, + isRemote: true, + lastCommitDate: parseInt(dateStr || "0", 10), + isDefault: name === defBranch, + }) + } + } + + // Sort: default first, then by lastCommitDate descending + const branches = [...map.values()].sort((a, b) => { + if (a.isDefault && !b.isDefault) return -1 + if (!a.isDefault && b.isDefault) return 1 + return b.lastCommitDate - a.lastCommitDate + }) + + return { branches, defaultBranch: defBranch } + } + // --------------------------------------------------------------------------- // Git exclude management // --------------------------------------------------------------------------- @@ -279,7 +385,7 @@ export class WorktreeManager { return (await this.git.revparse(["--abbrev-ref", "HEAD"])).trim() } - private async branchExists(name: string): Promise { + async branchExists(name: string): Promise { try { const branches = await this.git.branch() return branches.all.includes(name) || branches.all.includes(`remotes/origin/${name}`) @@ -288,7 +394,7 @@ export class WorktreeManager { } } - private async defaultBranch(): Promise { + async defaultBranch(): Promise { try { const head = await this.git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]) const match = head.trim().match(/refs\/remotes\/origin\/(.+)$/) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 904de6fe23..42444f61ad 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -100,6 +100,9 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.agentManager.closeWorktree", () => { agentManagerProvider.postMessage({ type: "action", action: "closeWorktree" }) }), + vscode.commands.registerCommand("kilo-code.new.agentManager.advancedWorktree", () => { + agentManagerProvider.postMessage({ type: "action", action: "advancedWorktree" }) + }), ) // Register autocomplete provider diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 3965ac82af..cd484de2fe 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -19,6 +19,7 @@ import type { AgentManagerKeybindingsMessage, AgentManagerMultiVersionProgressMessage, AgentManagerSendInitialMessage, + AgentManagerBranchInfo, WorktreeState, ManagedSessionState, SessionInfo, @@ -87,6 +88,7 @@ const defaultBindings: Record = { newTab: isMac ? "⌘T" : "Ctrl+T", closeTab: isMac ? "⌘W" : "Ctrl+W", newWorktree: isMac ? "⌘N" : "Ctrl+N", + advancedWorktree: isMac ? "⌘⇧N" : "Ctrl+Shift+N", closeWorktree: isMac ? "⌘⇧W" : "Ctrl+Shift+W", agentManagerOpen: isMac ? "⌘⇧M" : "Ctrl+Shift+M", focusPanel: isMac ? "⌘." : "Ctrl+.", @@ -177,6 +179,7 @@ function buildShortcutCategories(bindings: Record): ShortcutCate { label: "Previous item", binding: bindings.previousSession ?? "" }, { label: "Next item", binding: bindings.nextSession ?? "" }, { label: "New worktree", binding: bindings.newWorktree ?? "" }, + { label: "Advanced worktree", binding: bindings.advancedWorktree ?? "" }, { label: "Delete worktree", binding: bindings.closeWorktree ?? "" }, ], }, @@ -544,6 +547,7 @@ const AgentManagerContent: Component = () => { } else if (msg.action === "newTab") handleNewTabForCurrentSelection() else if (msg.action === "closeTab") closeActiveTab() else if (msg.action === "newWorktree") handleNewWorktreeOrPromote() + else if (msg.action === "advancedWorktree") showAdvancedWorktreeDialog() else if (msg.action === "closeWorktree") closeSelectedWorktree() else if (msg.action === "focusInput") window.dispatchEvent(new Event("focusPrompt")) } @@ -559,8 +563,8 @@ const AgentManagerContent: Component = () => { if (["t", "w", "n"].includes(e.key.toLowerCase()) && !e.shiftKey) { e.preventDefault() } - // Prevent defaults for shift variants (close worktree) - if (e.key.toLowerCase() === "w" && e.shiftKey) { + // Prevent defaults for shift variants (close worktree, advanced new worktree) + if (["w", "n"].includes(e.key.toLowerCase()) && e.shiftKey) { e.preventDefault() } } @@ -721,15 +725,18 @@ const AgentManagerContent: Component = () => { session.setSessionAgent(ev.sessionId, ev.agent) } - vscode.postMessage({ - type: "sendMessage", - text: ev.text, - sessionID: ev.sessionId, - providerID: ev.providerID, - modelID: ev.modelID, - agent: ev.agent, - files: ev.files, - }) + // Only send a message if there's text — otherwise just clear busy state + if (ev.text) { + vscode.postMessage({ + type: "sendMessage", + text: ev.text, + sessionID: ev.sessionId, + providerID: ev.providerID, + modelID: ev.modelID, + agent: ev.agent, + files: ev.files, + }) + } // Clear busy state — use worktreeId from the message directly // to avoid race condition where managedSessions() hasn't updated yet if (ev.worktreeId) { @@ -1074,11 +1081,21 @@ const AgentManagerContent: Component = () => { New Worktree + + {parseBindingTokens(kb().newWorktree ?? "").map((t) => ( + {t} + ))} + - - New with Versions... + + Advanced... + + {parseBindingTokens(kb().advancedWorktree ?? "").map((t) => ( + {t} + ))} + @@ -1546,22 +1563,62 @@ const AgentManagerContent: Component = () => { } // --------------------------------------------------------------------------- -// Advanced "New Worktree" dialog — prompt, versions, model, mode +// Advanced "New Worktree" dialog — prompt, versions, model, mode, advanced options // --------------------------------------------------------------------------- type VersionCount = 1 | 2 | 3 | 4 const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4] +function sanitizeSegment(text: string, maxLength = 50): string { + return text + .toLowerCase() + .trim() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9._+@-]/g, "") + .replace(/\.{2,}/g, ".") + .replace(/@\{/g, "@") + .replace(/-+/g, "-") + .replace(/^[-.]|[-.]+$/g, "") + .replace(/\.lock$/g, "") + .slice(0, maxLength) +} + +function sanitizeBranchName(name: string): string { + return name + .split("/") + .map((s) => sanitizeSegment(s)) + .filter(Boolean) + .join("/") +} + +function formatRelativeTime(epoch: number): string { + const diff = Math.floor(Date.now() / 1000) - epoch + if (diff < 60) return "now" + if (diff < 3600) return `${Math.floor(diff / 60)}m` + if (diff < 86400) return `${Math.floor(diff / 3600)}h` + if (diff < 2592000) return `${Math.floor(diff / 86400)}d` + if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo` + return `${Math.floor(diff / 31536000)}y` +} + const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { const vscode = useVSCode() const session = useSession() const [name, setName] = createSignal("") const [prompt, setPrompt] = createSignal("") - const [versions, setVersions] = createSignal(2) + const [versions, setVersions] = createSignal(1) const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(null) const [agent, setAgent] = createSignal(session.selectedAgent()) const [starting, setStarting] = createSignal(false) + const [showAdvanced, setShowAdvanced] = createSignal(false) + const [branchName, setBranchName] = createSignal("") + const [baseBranch, setBaseBranch] = createSignal(null) + const [branches, setBranches] = createSignal([]) + const [defaultBranch, setDefaultBranch] = createSignal("main") + const [branchSearch, setBranchSearch] = createSignal("") + const [baseBranchOpen, setBaseBranchOpen] = createSignal(false) + const [highlightedIndex, setHighlightedIndex] = createSignal(0) let textareaRef: HTMLTextAreaElement | undefined @@ -1571,19 +1628,42 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { textareaRef.focus() textareaRef.select() }) + vscode.postMessage({ type: "agentManager.requestBranches" }) }) - const canSubmit = () => prompt().trim().length > 0 && !starting() + // Listen for branch data + const handler = (e: MessageEvent) => { + const msg = e.data as ExtensionMessage + if (msg.type === "agentManager.branches") { + setBranches(msg.branches) + setDefaultBranch(msg.defaultBranch) + } + } + window.addEventListener("message", handler) + onCleanup(() => window.removeEventListener("message", handler)) + + const effectiveBaseBranch = () => baseBranch() ?? defaultBranch() + + const filteredBranches = createMemo(() => { + const search = branchSearch().toLowerCase() + if (!search) return branches() + return branches().filter((b) => b.name.toLowerCase().includes(search)) + }) + + const canSubmit = () => !starting() const handleSubmit = () => { - const text = prompt().trim() - if (!text || starting()) return + if (starting()) return setStarting(true) + const text = prompt().trim() || undefined const count = versions() const sel = model() const defaultAgent = session.agents()[0]?.name const selectedAgent = agent() !== defaultAgent ? agent() : undefined + const advanced = showAdvanced() + const customBranch = advanced ? branchName().trim() || undefined : undefined + vscode.postMessage({ type: "agentManager.createMultiVersion", text, @@ -1592,6 +1672,8 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { providerID: sel?.providerID, modelID: sel?.modelID, agent: selectedAgent, + baseBranch: advanced ? (baseBranch() ?? undefined) : undefined, + branchName: customBranch, }) props.onClose() @@ -1651,28 +1733,138 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { -
- - - -
+
+ {/* Advanced options toggle */} + + + +
+
+ Branch name + setBranchName(sanitizeBranchName(e.currentTarget.value))} + /> +
+
+ Base branch +
+ + +
e.stopPropagation()}> + +
+ + {(branch, index) => ( + + )} + +
+
+
+
+
+
+
+ {/* Version selector + info */}
Versions @@ -1692,6 +1884,21 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { {versions()} worktrees will run in parallel
+ + {/* Submit button */} + ) 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 30fbe2e8de..d8cd67c68d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -795,6 +795,14 @@ button.am-section-toggle:hover .am-section-label { min-width: 460px; } +/* Allow dropdowns to escape the dialog bounds */ +[data-component="dialog"]:has(.am-nv-dialog) [data-slot="dialog-content"] { + overflow: visible; +} +[data-component="dialog"]:has(.am-nv-dialog) [data-slot="dialog-body"] { + overflow: visible; +} + .am-nv-name-input { width: 100%; height: 32px; @@ -909,6 +917,223 @@ button.am-section-toggle:hover .am-section-label { height: 14px; } +.am-nv-submit { + width: 100%; + justify-content: center; +} + +/* Advanced options toggle */ + +.am-advanced-toggle { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 0; + border: none; + background: none; + color: var(--text-weak); + font-size: 12px; + cursor: pointer; + transition: color 100ms; +} + +.am-advanced-toggle:hover { + color: var(--text-base); +} + +/* Advanced options section */ + +.am-advanced-section { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 0; +} + +.am-advanced-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.am-advanced-input { + width: 100%; + padding: 6px 10px; + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + background: var(--surface-base); + color: var(--text-base); + font-size: 12px; + font-family: inherit; + outline: none; + transition: border-color 120ms; +} + +.am-advanced-input:focus { + border-color: var(--border-focus, #007fd4); +} + +.am-advanced-input::placeholder { + color: var(--text-weaker); +} + +/* Branch selector trigger + dropdown */ + +.am-branch-selector-wrapper { + position: relative; +} + +.am-branch-selector-trigger { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + background: var(--surface-base); + color: var(--text-base); + font-size: 12px; + cursor: pointer; + transition: border-color 120ms; +} + +.am-branch-selector-trigger:hover { + border-color: var(--border-hover); +} + +.am-branch-selector-value { + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-branch-badge { + font-size: 10px; + font-weight: 500; + padding: 1px 6px; + border-radius: 3px; + background: var(--surface-raised-stronger-non-alpha); + color: var(--text-weak); + white-space: nowrap; +} + +.am-branch-badge-remote { + background: var(--surface-raised-base); + opacity: 0.7; +} + +.am-branch-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 100; + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + background: var(--surface-float-base, var(--surface-raised-base)); + box-shadow: var(--shadow-lg-border-base, 0 8px 24px rgba(0, 0, 0, 0.3)); + max-height: 280px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.am-branch-search { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--border-base); +} + +.am-branch-search-input { + flex: 1; + border: none; + background: none; + color: var(--text-base); + font-size: 12px; + font-family: inherit; + outline: none; +} + +.am-branch-search-input::placeholder { + color: var(--text-weaker); +} + +.am-branch-list { + overflow-y: auto; + max-height: 220px; + padding: 4px 0; +} + +.am-branch-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border: none; + background: none; + color: var(--text-base); + font-size: 12px; + cursor: pointer; + transition: background 80ms; + text-align: left; +} + +.am-branch-item:hover { + background: var(--surface-raised-base-hover); +} + +.am-branch-item-active { + background: var(--surface-raised-base); +} + +.am-branch-item-highlighted { + background: var(--surface-raised-base-hover); +} + +/* Keyboard shortcut hints in dropdown menus */ + +.am-menu-shortcut { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 2px; +} + +.am-menu-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 3px; + background: var(--surface-raised-stronger-non-alpha); + color: var(--text-weak); + font-size: 10px; + font-family: inherit; + line-height: 1; +} + +.am-branch-item-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono, monospace); +} + +.am-branch-item-time { + font-size: 11px; + color: var(--text-weaker); + white-space: nowrap; +} + /* HoverCard popover for worktree items */ .am-hover-card { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index d6e9b9bd41..f951186667 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -611,6 +611,22 @@ export interface AgentManagerMultiVersionProgressMessage { groupId?: string } +// Branch info for branch selector (extension → webview) +export interface AgentManagerBranchInfo { + name: string + isLocal: boolean + isRemote: boolean + lastCommitDate: number + isDefault: boolean +} + +// Branch list response (extension → webview) +export interface AgentManagerBranchesMessage { + type: "agentManager.branches" + branches: AgentManagerBranchInfo[] + defaultBranch: string +} + // Stored variant selections loaded from extension globalState (extension → webview) export interface VariantsLoadedMessage { type: "variantsLoaded" @@ -622,7 +638,7 @@ export interface AgentManagerSendInitialMessage { type: "agentManager.sendInitialMessage" sessionId: string worktreeId: string - text: string + text?: string providerID?: string modelID?: string agent?: string @@ -889,6 +905,8 @@ export interface TelemetryRequest { // Create a new worktree (with auto-created first session) export interface CreateWorktreeRequest { type: "agentManager.createWorktree" + baseBranch?: string + branchName?: string } // Delete a worktree and dissociate its sessions @@ -937,13 +955,14 @@ export interface ShowTerminalRequest { // Create multiple worktree sessions for the same prompt (multi-version mode) export interface CreateMultiVersionRequest { type: "agentManager.createMultiVersion" - text: string + text?: string versions: number providerID?: string modelID?: string agent?: string files?: FileAttachment[] baseBranch?: string + branchName?: string } // Persist tab order for a context (worktree ID or "local") @@ -959,6 +978,11 @@ export interface SetSessionsCollapsedRequest { collapsed: boolean } +// Request branch list for base branch selector +export interface RequestBranchesMessage { + type: "agentManager.requestBranches" +} + // Variant persistence (webview → extension) export interface PersistVariantRequest { type: "persistVariant" @@ -1025,6 +1049,7 @@ export type WebviewMessage = | SetSessionsCollapsedRequest | PersistVariantRequest | RequestVariantsMessage + | RequestBranchesMessage // ============================================ // VS Code API type