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
This commit is contained in:
Marius
2026-02-23 19:26:45 +01:00
committed by GitHub
parent 2e99329aad
commit 1cdad80eac
8 changed files with 702 additions and 87 deletions
View File
+11
View File
@@ -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": {
@@ -134,7 +134,9 @@ export class AgentManagerProvider implements vscode.Disposable {
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | 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<WorktreeStateManager["addWorktree"]>
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<null> {
const created = await this.createWorktreeOnDisk()
private async onCreateWorktree(baseBranch?: string, branchName?: string): Promise<null> {
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<null> {
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<void> {
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<string, unknown>): Promise<null> {
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,15 +643,11 @@ 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]!
if (text) {
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,
@@ -626,11 +658,17 @@ export class AgentManagerProvider implements vscode.Disposable {
agent,
files,
})
// Small delay between sends to avoid overwhelming the backend
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,
})
}
}
// Notify completion
@@ -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<CreateWorktreeResult> {
async createWorktree(params: {
prompt?: string
existingBranch?: string
baseBranch?: string
branchName?: string
}): Promise<CreateWorktreeResult> {
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]
: 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<string, BranchInfo>()
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<boolean> {
async branchExists(name: string): Promise<boolean> {
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<string> {
async defaultBranch(): Promise<string> {
try {
const head = await this.git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"])
const match = head.trim().match(/refs\/remotes\/origin\/(.+)$/)
+3
View File
@@ -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
@@ -19,6 +19,7 @@ import type {
AgentManagerKeybindingsMessage,
AgentManagerMultiVersionProgressMessage,
AgentManagerSendInitialMessage,
AgentManagerBranchInfo,
WorktreeState,
ManagedSessionState,
SessionInfo,
@@ -87,6 +88,7 @@ const defaultBindings: Record<string, string> = {
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<string, string>): 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,6 +725,8 @@ const AgentManagerContent: Component = () => {
session.setSessionAgent(ev.sessionId, ev.agent)
}
// Only send a message if there's text — otherwise just clear busy state
if (ev.text) {
vscode.postMessage({
type: "sendMessage",
text: ev.text,
@@ -730,6 +736,7 @@ const AgentManagerContent: Component = () => {
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 = () => {
<DropdownMenu.Content class="am-split-menu">
<DropdownMenu.Item onSelect={handleCreateWorktree}>
<DropdownMenu.ItemLabel>New Worktree</DropdownMenu.ItemLabel>
<span class="am-menu-shortcut">
{parseBindingTokens(kb().newWorktree ?? "").map((t) => (
<kbd class="am-menu-key">{t}</kbd>
))}
</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={showAdvancedWorktreeDialog}>
<Icon name="layers" size="small" />
<DropdownMenu.ItemLabel>New with Versions...</DropdownMenu.ItemLabel>
<Icon name="settings-gear" size="small" />
<DropdownMenu.ItemLabel>Advanced...</DropdownMenu.ItemLabel>
<span class="am-menu-shortcut">
{parseBindingTokens(kb().advancedWorktree ?? "").map((t) => (
<kbd class="am-menu-key">{t}</kbd>
))}
</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -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<VersionCount>(2)
const [versions, setVersions] = createSignal<VersionCount>(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<string | null>(null)
const [branches, setBranches] = createSignal<AgentManagerBranchInfo[]>([])
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,27 +1733,137 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} />
</Show>
</div>
<div class="prompt-input-hint-actions">
<Tooltip value={`${isMac ? "\u2318" : "Ctrl+"}Enter`} placement="top">
<Button variant="primary" size="small" onClick={handleSubmit} disabled={!canSubmit()}>
<Show
when={!starting()}
fallback={
<>
<Spinner class="am-nv-spinner" />
<span>Creating...</span>
</>
}
<div class="prompt-input-hint-actions" />
</div>
</div>
{/* Advanced options toggle */}
<button class="am-advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced())} type="button">
<Icon name={showAdvanced() ? "chevron-down" : "chevron-right"} size="small" />
<span>Advanced options</span>
</button>
<Show when={showAdvanced()}>
<div class="am-advanced-section">
<div class="am-advanced-field">
<span class="am-nv-config-label">Branch name</span>
<input
class="am-advanced-input"
type="text"
placeholder="auto-generated"
value={branchName()}
onInput={(e) => setBranchName(sanitizeBranchName(e.currentTarget.value))}
/>
</div>
<div class="am-advanced-field">
<span class="am-nv-config-label">Base branch</span>
<div class="am-branch-selector-wrapper">
<button
class="am-branch-selector-trigger"
onClick={() => setBaseBranchOpen(!baseBranchOpen())}
type="button"
>
<svg data-slot="icon-svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.5 1.5L14.5 8L1.5 14.5V9L10 8L1.5 7V1.5Z" />
</svg>
<Icon name="branch" size="small" />
<span class="am-branch-selector-value">{effectiveBaseBranch()}</span>
<Show when={!baseBranch()}>
<span class="am-branch-badge">default</span>
</Show>
<Icon name="selector" size="small" />
</button>
<Show when={baseBranchOpen()}>
<div class="am-branch-dropdown" onWheel={(e) => e.stopPropagation()}>
<div class="am-branch-search">
<Icon name="magnifying-glass" size="small" />
<input
class="am-branch-search-input"
type="text"
placeholder="Search branches..."
value={branchSearch()}
ref={(el) => requestAnimationFrame(() => el.focus())}
onInput={(e) => {
setBranchSearch(e.currentTarget.value)
setHighlightedIndex(0)
}}
onKeyDown={(e) => {
const items = filteredBranches()
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
const next = Math.min(highlightedIndex() + 1, items.length - 1)
setHighlightedIndex(next)
requestAnimationFrame(() => {
document
.querySelector(`.am-branch-item[data-index="${next}"]`)
?.scrollIntoView({ block: "nearest" })
})
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
const prev = Math.max(highlightedIndex() - 1, 0)
setHighlightedIndex(prev)
requestAnimationFrame(() => {
document
.querySelector(`.am-branch-item[data-index="${prev}"]`)
?.scrollIntoView({ block: "nearest" })
})
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
const selected = items[highlightedIndex()]
if (selected) {
setBaseBranch(selected.name)
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}
} else if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}
}}
/>
</div>
<div class="am-branch-list">
<For each={filteredBranches()}>
{(branch, index) => (
<button
class="am-branch-item"
classList={{
"am-branch-item-active": effectiveBaseBranch() === branch.name,
"am-branch-item-highlighted": highlightedIndex() === index(),
}}
data-index={index()}
onClick={() => {
setBaseBranch(branch.name)
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}}
onMouseEnter={() => setHighlightedIndex(index())}
type="button"
>
<Icon name="branch" size="small" />
<span class="am-branch-item-name">{branch.name}</span>
<Show when={branch.isDefault}>
<span class="am-branch-badge">default</span>
</Show>
<Show when={!branch.isLocal && branch.isRemote}>
<span class="am-branch-badge am-branch-badge-remote">remote</span>
</Show>
<span class="am-branch-item-time">{formatRelativeTime(branch.lastCommitDate)}</span>
</button>
)}
</For>
</div>
</div>
</Show>
</Button>
</Tooltip>
</div>
</div>
</div>
</Show>
{/* Version selector + info */}
<div class="am-nv-version-bar">
@@ -1692,6 +1884,21 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
<span class="am-nv-version-hint">{versions()} worktrees will run in parallel</span>
</Show>
</div>
{/* Submit button */}
<Button variant="primary" size="large" class="am-nv-submit" onClick={handleSubmit} disabled={!canSubmit()}>
<Show
when={!starting()}
fallback={
<>
<Spinner class="am-nv-spinner" />
<span>Creating...</span>
</>
}
>
Create Workspace
</Show>
</Button>
</div>
</Dialog>
)
@@ -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 {
@@ -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