feat(agent-manager): default base branch selection and BranchSelect component (#6701)

- Add persistent default base branch with picker dialog
- Extract BranchSelect reusable component from duplicated branch list UI
- Add base-branch.ts pure logic module with normalizeBaseBranch/chooseBaseBranch
- Add isCheckedOut flag to branch list items
- Update WorktreeStateManager to persist defaultBaseBranch
- Add i18n keys for all 16 locales
- Add unit tests for base-branch logic
- Update architecture tests
This commit is contained in:
Marius
2026-03-06 14:01:20 +01:00
committed by GitHub
parent ee9fb09673
commit 8cca3d8784
26 changed files with 541 additions and 141 deletions
@@ -9,6 +9,7 @@ import { KiloProvider } from "../KiloProvider"
import { buildWebviewHtml } from "../utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { WorktreeStateManager } from "./WorktreeStateManager"
import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller"
import { GitOps, type ApplyConflict } from "./GitOps"
import { versionedName } from "./branch-name"
@@ -281,6 +282,12 @@ export class AgentManagerProvider implements vscode.Disposable {
return null
}
if (type === "agentManager.setDefaultBaseBranch") {
const branch = normalizeBaseBranch(msg.branch as string | undefined)
this.state?.setDefaultBaseBranch(branch)
this.pushState()
return null
}
if (type === "agentManager.requestExternalWorktrees") {
void this.onRequestExternalWorktrees()
return null
@@ -386,6 +393,31 @@ export class AgentManagerProvider implements vscode.Disposable {
// Shared helpers
// ---------------------------------------------------------------------------
/** Resolve the effective base branch using the configured default, explicit override, and existence check. */
private async resolveBaseBranch(
manager: WorktreeManager,
state: WorktreeStateManager,
explicit?: string,
): Promise<string | undefined> {
const configured = state.getDefaultBaseBranch()
if (!configured && !explicit) return undefined
const configuredExists = configured ? await manager.branchExists(configured) : false
const result = chooseBaseBranch({ explicit, configured, configuredExists })
if (result.stale) {
this.clearStaleDefaultBaseBranch(state, result.stale)
}
return result.branch
}
/** Reset a stale default base branch and notify the webview. */
private clearStaleDefaultBaseBranch(state: WorktreeStateManager, stale: string): void {
this.log(`Default base branch "${stale}" no longer exists, clearing`)
state.setDefaultBaseBranch(undefined)
this.pushState()
}
/** Create a git worktree on disk and register it in state. Returns null on failure. */
private async createWorktreeOnDisk(opts?: {
groupId?: string
@@ -411,11 +443,16 @@ export class AgentManagerProvider implements vscode.Disposable {
this.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Creating git worktree..." })
// Resolve effective base branch using configured default
const effectiveBase = opts?.existingBranch
? undefined
: await this.resolveBaseBranch(manager, state, opts?.baseBranch)
let result: CreateWorktreeResult
try {
result = await manager.createWorktree({
prompt: opts?.name || "kilo",
baseBranch: opts?.baseBranch,
baseBranch: effectiveBase ?? opts?.baseBranch,
branchName: opts?.branchName,
existingBranch: opts?.existingBranch,
})
@@ -940,10 +977,23 @@ export class AgentManagerProvider implements vscode.Disposable {
try {
const result = await manager.listBranches()
const checkedOut = await manager.checkedOutBranches()
const filtered = result.branches.filter((b) => !checkedOut.has(b.name))
// Include isCheckedOut flag on each branch — let the webview decide how to filter
const branches = result.branches.map((b) => ({
...b,
isCheckedOut: checkedOut.has(b.name),
}))
// Validate configured default branch still exists
const state = this.getStateManager()
const configured = state?.getDefaultBaseBranch()
if (configured && !branches.some((b) => b.name === configured)) {
this.clearStaleDefaultBaseBranch(state!, configured)
}
this.postToWebview({
type: "agentManager.branches",
branches: filtered,
branches,
defaultBranch: result.defaultBranch,
})
} catch (error) {
@@ -1336,7 +1386,8 @@ export class AgentManagerProvider implements vscode.Disposable {
if (!manager) return
try {
const branch = await manager.currentBranch()
this.postToWebview({ type: "agentManager.repoInfo", branch })
const defaultBranch = await manager.defaultBranch()
this.postToWebview({ type: "agentManager.repoInfo", branch, defaultBranch })
} catch (error) {
this.log(`Failed to get current branch: ${error}`)
}
@@ -1409,6 +1460,7 @@ export class AgentManagerProvider implements vscode.Disposable {
sessionsCollapsed: state.getSessionsCollapsed(),
reviewDiffStyle: state.getReviewDiffStyle(),
isGitRepo: true,
defaultBaseBranch: state.getDefaultBaseBranch(),
})
this.statsPoller.setEnabled(worktrees.length > 0 || this.panel !== undefined)
@@ -37,6 +37,7 @@ interface StateFile {
tabOrder?: Record<string, string[]>
sessionsCollapsed?: boolean
reviewDiffStyle?: "unified" | "split"
defaultBaseBranch?: string
}
const STATE_FILE = "agent-manager.json"
@@ -55,6 +56,7 @@ export class WorktreeStateManager {
private tabOrder: Record<string, string[]> = {}
private collapsed = false
private reviewDiffStyle: "unified" | "split" = "unified"
private defaultBase: string | undefined
private readonly log: (msg: string) => void
private saving: Promise<void> | undefined
private pendingSave = false
@@ -245,6 +247,19 @@ export class WorktreeStateManager {
void this.save()
}
// ---------------------------------------------------------------------------
// Default base branch
// ---------------------------------------------------------------------------
getDefaultBaseBranch(): string | undefined {
return this.defaultBase
}
setDefaultBaseBranch(value: string | undefined): void {
this.defaultBase = value
void this.save()
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
@@ -271,6 +286,7 @@ export class WorktreeStateManager {
if (data.reviewDiffStyle === "split") {
this.reviewDiffStyle = "split"
}
this.defaultBase = data.defaultBaseBranch
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
@@ -343,6 +359,9 @@ export class WorktreeStateManager {
if (this.reviewDiffStyle === "split") {
data.reviewDiffStyle = "split"
}
if (this.defaultBase) {
data.defaultBaseBranch = this.defaultBase
}
try {
const dir = path.dirname(this.file)
@@ -0,0 +1,34 @@
/**
* Pure logic for resolving the effective base branch.
*
* Priority chain:
* 1. Explicit (per-worktree override, from dialog)
* 2. Configured default (persisted in agent-manager.json), if it still exists
* 3. Auto-detect (undefined → WorktreeManager.defaultBranch() at call site)
*/
/** Trim whitespace, return undefined for empty/blank. */
export function normalizeBaseBranch(value: string | undefined): string | undefined {
if (!value) return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
/**
* Pick the effective base branch given an explicit override, a configured
* default, and whether that configured branch still exists in the repo.
*
* Returns `{ branch }` with the winner, and optionally `{ stale }` when the
* configured branch no longer exists (so the caller can clear it).
*/
export function chooseBaseBranch(opts: { explicit?: string; configured?: string; configuredExists?: boolean }): {
branch?: string
stale?: string
} {
if (opts.explicit) return { branch: opts.explicit }
if (opts.configured) {
if (opts.configuredExists) return { branch: opts.configured }
return { stale: opts.configured }
}
return {}
}
@@ -4,6 +4,7 @@ export interface BranchListItem {
isRemote: boolean
isDefault: boolean
lastCommitDate?: string
isCheckedOut?: boolean
}
export interface PRUrlParts {
@@ -25,6 +25,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/review-annotations.ts"),
path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/BranchSelect.tsx"),
path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"),
]
const TSX_FILE = TSX_FILES[0]
@@ -171,6 +172,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
"agentManager.requestRepoInfo",
"agentManager.requestState",
"agentManager.setTabOrder",
"agentManager.setDefaultBaseBranch",
]
for (const msg of expected) {
expect(text, `onMessage should handle "${msg}"`).toContain(msg)
@@ -0,0 +1,59 @@
import { describe, it, expect } from "bun:test"
import { normalizeBaseBranch, chooseBaseBranch } from "../../src/agent-manager/base-branch"
describe("normalizeBaseBranch", () => {
it("returns undefined for undefined input", () => {
expect(normalizeBaseBranch(undefined)).toBeUndefined()
})
it("returns undefined for empty string", () => {
expect(normalizeBaseBranch("")).toBeUndefined()
})
it("returns undefined for whitespace-only string", () => {
expect(normalizeBaseBranch(" ")).toBeUndefined()
expect(normalizeBaseBranch("\t\n")).toBeUndefined()
})
it("trims whitespace from valid input", () => {
expect(normalizeBaseBranch(" main ")).toBe("main")
expect(normalizeBaseBranch(" develop\n")).toBe("develop")
})
it("returns the value as-is when already trimmed", () => {
expect(normalizeBaseBranch("main")).toBe("main")
expect(normalizeBaseBranch("feature/branch")).toBe("feature/branch")
})
})
describe("chooseBaseBranch", () => {
it("prefers explicit over configured", () => {
const result = chooseBaseBranch({ explicit: "release", configured: "develop", configuredExists: true })
expect(result).toEqual({ branch: "release" })
})
it("uses configured when it exists and no explicit is given", () => {
const result = chooseBaseBranch({ configured: "develop", configuredExists: true })
expect(result).toEqual({ branch: "develop" })
})
it("marks configured as stale when it no longer exists", () => {
const result = chooseBaseBranch({ configured: "old-branch", configuredExists: false })
expect(result).toEqual({ stale: "old-branch" })
})
it("falls back to auto-detect (undefined) when nothing is configured", () => {
const result = chooseBaseBranch({})
expect(result).toEqual({})
})
it("falls back to auto-detect when configured is undefined", () => {
const result = chooseBaseBranch({ configured: undefined, configuredExists: false })
expect(result).toEqual({})
})
it("explicit takes priority even when configured is stale", () => {
const result = chooseBaseBranch({ explicit: "main", configured: "deleted", configuredExists: false })
expect(result).toEqual({ branch: "main" })
})
})
@@ -56,7 +56,6 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { Popover } from "@kilocode/kilo-ui/popover"
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
import { ServerProvider } from "../src/context/server"
import { ProviderProvider } from "../src/context/provider"
@@ -85,6 +84,7 @@ import { FullScreenDiffView } from "./FullScreenDiffView"
import { ApplyDialog } from "./ApplyDialog"
import { groupApplyConflicts } from "./apply-conflicts"
import type { ReviewComment } from "./review-comments"
import { BranchSelect } from "./BranchSelect"
import { WorktreeItem } from "./WorktreeItem"
import "./agent-manager.css"
@@ -313,6 +313,11 @@ const AgentManagerContent: Component = () => {
const [worktreesLoaded, setWorktreesLoaded] = createSignal(false)
const [sessionsLoaded, setSessionsLoaded] = createSignal(false)
const [isGitRepo, setIsGitRepo] = createSignal(true)
const [repoDetectedBranch, setRepoDetectedBranch] = createSignal<string | undefined>()
const [defaultBaseBranch, setDefaultBaseBranch] = createSignal<string | undefined>()
const repoDefaultBranch = () => defaultBaseBranch() ?? repoDetectedBranch() ?? "main"
const hasConfiguredBranch = () => !!defaultBaseBranch()
const DEFAULT_SIDEBAR_WIDTH = 260
const MIN_SIDEBAR_WIDTH = 200
@@ -1055,6 +1060,7 @@ const AgentManagerContent: Component = () => {
if (msg.type === "agentManager.repoInfo") {
const info = msg as AgentManagerRepoInfoMessage
setRepoBranch(info.branch)
if (info.defaultBranch) setRepoDetectedBranch(info.defaultBranch)
}
if (msg.type === "agentManager.worktreeSetup") {
@@ -1114,6 +1120,7 @@ const AgentManagerContent: Component = () => {
if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") {
setReviewDiffStyle(state.reviewDiffStyle)
}
if ("defaultBaseBranch" in state) setDefaultBaseBranch(state.defaultBaseBranch || undefined)
const current = session.currentSessionID()
if (current) {
const ms = state.sessions.find((s) => s.id === current)
@@ -1412,6 +1419,101 @@ const AgentManagerContent: Component = () => {
vscode.postMessage({ type: "agentManager.configureSetupScript" })
}
const handleChangeDefaultBaseBranch = () => {
const [search, setSearch] = createSignal("")
const [branches, setBranches] = createSignal<BranchInfo[]>([])
const [loading, setLoading] = createSignal(true)
const [highlighted, setHighlighted] = createSignal(-1)
const unsub = vscode.onMessage((msg) => {
if (msg.type === "agentManager.branches") {
const ev = msg as AgentManagerBranchesMessage
setBranches(ev.branches)
if (ev.defaultBranch) setRepoDetectedBranch(ev.defaultBranch)
setLoading(false)
}
})
vscode.postMessage({ type: "agentManager.requestBranches" })
const filtered = createMemo(() => {
const s = search().toLowerCase()
if (!s) return branches()
return branches().filter((b) => b.name.toLowerCase().includes(s))
})
const selectBranch = (name: string | undefined) => {
vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch: name })
setDefaultBaseBranch(name)
unsub()
dialog.close()
}
const handleKeyDown = (e: KeyboardEvent) => {
const items = filtered()
// offset by 1 for auto-detect option (-1 = auto-detect)
const total = items.length + 1
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
setHighlighted((prev) => Math.min(prev + 1, total - 2))
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
setHighlighted((prev) => Math.max(prev - 1, -1))
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
const idx = highlighted()
if (idx === -1) {
selectBranch(undefined)
} else {
const branch = items[idx]
if (branch) selectBranch(branch.name)
}
} else if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
unsub()
dialog.close()
}
}
dialog.show(() => (
<Dialog title={t("agentManager.worktree.defaultBaseBranch")} fit>
<div class="am-default-base-branch">
<BranchSelect
branches={filtered()}
loading={loading()}
search={search()}
onSearch={(v) => {
setSearch(v)
setHighlighted(-1)
}}
onSelect={(b) => selectBranch(b.name)}
onSearchKeyDown={handleKeyDown}
selected={defaultBaseBranch()}
highlighted={highlighted()}
onHighlight={setHighlighted}
searchPlaceholder={t("agentManager.dialog.searchBranches")}
emptyLabel={t("agentManager.import.noMatchingBranches")}
loadingLabel={t("agentManager.import.loadingBranches")}
defaultLabel={t("agentManager.dialog.branchBadge.default")}
remoteLabel={t("agentManager.dialog.branchBadge.remote")}
defaultName={defaultBaseBranch()}
autoOption={{
label: t("agentManager.worktree.defaultBaseBranchAuto"),
hint: repoDetectedBranch(),
active: !hasConfiguredBranch(),
highlighted: highlighted() === -1,
onSelect: () => selectBranch(undefined),
}}
/>
</div>
</Dialog>
))
}
const handleShowKeyboardShortcuts = () => {
const categories = buildShortcutCategories(kb(), t)
dialog.show(() => (
@@ -1449,7 +1551,7 @@ const AgentManagerContent: Component = () => {
// Advanced worktree dialog — opens a full dialog with prompt, versions, model, mode
const showAdvancedWorktreeDialog = () => {
dialog.show(() => <NewWorktreeDialog onClose={() => dialog.close()} />)
dialog.show(() => <NewWorktreeDialog onClose={() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />)
}
const confirmDeleteWorktree = (worktreeId: string) => {
@@ -1849,6 +1951,12 @@ const AgentManagerContent: Component = () => {
<DropdownMenu.Item onSelect={handleConfigureSetupScript}>
<DropdownMenu.ItemLabel>{t("agentManager.worktree.setupScript")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={handleChangeDefaultBaseBranch}>
<DropdownMenu.ItemLabel>
{t("agentManager.worktree.defaultBaseBranch")}: {repoDefaultBranch()}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -2435,7 +2543,7 @@ function sanitizeBranchName(name: string): string {
.join("/")
}
const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBranch?: string }> = (props) => {
const { t } = useLanguage()
const vscode = useVSCode()
const session = useSession()
@@ -2445,7 +2553,7 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
// --- Shared branch data (used by both New tab's base branch selector and Import tab) ---
const [branches, setBranches] = createSignal<BranchInfo[]>([])
const [branchesLoading, setBranchesLoading] = createSignal(false)
const [defaultBranch, setDefaultBranch] = createSignal("main")
const [defaultBranch, setDefaultBranch] = createSignal(props.defaultBaseBranch ?? "main")
const [branchSearch, setBranchSearch] = createSignal("")
// --- New tab state ---
@@ -2552,7 +2660,7 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
if (msg.type === "agentManager.branches") {
const ev = msg as AgentManagerBranchesMessage
setBranches(ev.branches)
setDefaultBranch(ev.defaultBranch)
if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch)
setBranchesLoading(false)
}
if (msg.type === "agentManager.importResult") {
@@ -2730,98 +2838,69 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
</button>
}
>
<div class="am-dropdown-search">
<Icon name="magnifying-glass" size="small" />
<input
class="am-dropdown-search-input"
type="text"
placeholder={t("agentManager.dialog.searchBranches")}
value={branchSearch()}
autofocus
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()
<BranchSelect
branches={filteredBranches()}
loading={branchesLoading()}
search={branchSearch()}
onSearch={(v) => {
setBranchSearch(v)
setHighlightedIndex(0)
}}
onSelect={(b) => {
setBaseBranch(b.name)
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}}
onSearchKeyDown={(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)
}
}}
/>
</div>
<div class="am-dropdown-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"
>
<span class="am-branch-item-left">
<Icon name="branch" size="small" />
<span class="am-branch-item-name">{branch.name}</span>
<Show when={branch.isDefault}>
<span class="am-branch-badge">{t("agentManager.dialog.branchBadge.default")}</span>
</Show>
<Show when={!branch.isLocal && branch.isRemote}>
<span class="am-branch-badge am-branch-badge-remote">
{t("agentManager.dialog.branchBadge.remote")}
</span>
</Show>
</span>
<Show when={branch.lastCommitDate}>
<span class="am-branch-item-time">{formatRelativeDate(branch.lastCommitDate!)}</span>
</Show>
</button>
)}
</For>
</div>
} else if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}
}}
selected={effectiveBaseBranch()}
highlighted={highlightedIndex()}
onHighlight={setHighlightedIndex}
searchPlaceholder={t("agentManager.dialog.searchBranches")}
emptyLabel={t("agentManager.import.noMatchingBranches")}
defaultLabel={t("agentManager.dialog.branchBadge.default")}
remoteLabel={t("agentManager.dialog.branchBadge.remote")}
defaultName={defaultBranch()}
/>
</Popover>
</div>
</div>
@@ -3000,49 +3079,18 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
</button>
}
>
<div class="am-dropdown-search">
<Icon name="magnifying-glass" size="small" />
<input
class="am-dropdown-search-input"
type="text"
placeholder={t("agentManager.dialog.searchBranches")}
value={branchSearch()}
onInput={(e) => setBranchSearch(e.currentTarget.value)}
autofocus
/>
</div>
<div class="am-dropdown-list">
<Show
when={filteredBranches().length > 0}
fallback={
<div class="am-dropdown-empty">
{branchesLoading()
? t("agentManager.import.loadingBranches")
: t("agentManager.import.noMatchingBranches")}
</div>
}
>
<For each={filteredBranches()}>
{(branch) => (
<div class="am-branch-item" onClick={() => handleBranchSelect(branch.name)}>
<span class="am-branch-item-left">
<Icon name="branch" size="small" />
<span class="am-branch-item-name">{branch.name}</span>
<Show when={branch.isDefault}>
<span class="am-branch-badge">{t("agentManager.dialog.branchBadge.default")}</span>
</Show>
<Show when={!branch.isLocal && branch.isRemote}>
<span class="am-branch-badge">{t("agentManager.dialog.branchBadge.remote")}</span>
</Show>
</span>
<Show when={branch.lastCommitDate}>
<span class="am-branch-item-time">{formatRelativeDate(branch.lastCommitDate!)}</span>
</Show>
</div>
)}
</For>
</Show>
</div>
<BranchSelect
branches={filteredBranches().filter((b) => !b.isCheckedOut)}
loading={branchesLoading()}
search={branchSearch()}
onSearch={setBranchSearch}
onSelect={(b) => handleBranchSelect(b.name)}
searchPlaceholder={t("agentManager.dialog.searchBranches")}
loadingLabel={t("agentManager.import.loadingBranches")}
emptyLabel={t("agentManager.import.noMatchingBranches")}
defaultLabel={t("agentManager.dialog.branchBadge.default")}
remoteLabel={t("agentManager.dialog.branchBadge.remote")}
/>
</Popover>
</div>
</div>
@@ -0,0 +1,128 @@
// Reusable branch selector: search input + scrollable list with keyboard navigation
import { Component, For, Show } from "solid-js"
import type { BranchInfo } from "../src/types/messages"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { formatRelativeDate } from "../src/utils/date"
export interface AutoOption {
label: string
hint?: string
active: boolean
highlighted?: boolean
onSelect: () => void
}
export interface BranchSelectProps {
branches: BranchInfo[]
loading?: boolean
search: string
onSearch: (value: string) => void
onSelect: (branch: BranchInfo) => void
onSearchKeyDown?: (event: KeyboardEvent) => void
selected?: string
highlighted?: number
onHighlight?: (index: number) => void
defaultName?: string
searchPlaceholder: string
loadingLabel?: string
emptyLabel: string
defaultLabel: string
remoteLabel: string
autoOption?: AutoOption
}
export const BranchSelect: Component<BranchSelectProps> = (props) => {
const isDefault = (branch: BranchInfo) => {
if (props.defaultName) return branch.name === props.defaultName
return branch.isDefault
}
return (
<>
<div class="am-dropdown-search">
<Icon name="magnifying-glass" size="small" />
<input
class="am-dropdown-search-input"
type="text"
placeholder={props.searchPlaceholder}
value={props.search}
autofocus
onInput={(e) => props.onSearch(e.currentTarget.value)}
onKeyDown={(e) => props.onSearchKeyDown?.(e)}
/>
</div>
<div class="am-dropdown-list">
<Show when={props.autoOption}>
{(opt) => (
<button
class="am-branch-item"
classList={{
"am-branch-item-active": opt().active,
"am-branch-item-highlighted": opt().highlighted,
}}
data-index="auto"
onClick={() => opt().onSelect()}
type="button"
>
<span class="am-branch-item-left">
<Icon name="branch" size="small" />
<span class="am-branch-item-name">{opt().label}</span>
</span>
<Show when={opt().hint}>
<span class="am-branch-hint">{opt().hint}</span>
</Show>
</button>
)}
</Show>
<Show
when={props.branches.length > 0}
fallback={
<Show when={!props.autoOption}>
<div class="am-dropdown-empty">
{props.loading ? (props.loadingLabel ?? props.emptyLabel) : props.emptyLabel}
</div>
</Show>
}
>
<For each={props.branches}>
{(branch, index) => (
<button
class="am-branch-item"
classList={{
"am-branch-item-active": props.selected === branch.name,
"am-branch-item-highlighted": props.highlighted === index(),
}}
data-index={index()}
onClick={() => props.onSelect(branch)}
onMouseEnter={() => props.onHighlight?.(index())}
type="button"
>
<span class="am-branch-item-left">
<Icon name="branch" size="small" />
<span class="am-branch-item-name">{branch.name}</span>
<Show when={isDefault(branch)}>
<span class="am-branch-badge">{props.defaultLabel}</span>
</Show>
<Show when={!branch.isLocal && branch.isRemote}>
<span class="am-branch-badge am-branch-badge-remote">{props.remoteLabel}</span>
</Show>
</span>
<Show when={branch.lastCommitDate}>
<span class="am-branch-item-time">{formatRelativeDate(branch.lastCommitDate!)}</span>
</Show>
</button>
)}
</For>
</Show>
<Show when={props.loading && props.branches.length === 0 && !props.autoOption}>
<div class="am-dropdown-empty">
<Spinner class="am-setup-spinner" />
<span>{props.loadingLabel}</span>
</div>
</Show>
</div>
</>
)
}
@@ -3132,3 +3132,19 @@ button.am-section-toggle:hover .am-section-label {
.am-file-tree-summary-dels {
color: var(--syntax-diff-delete, #da3319);
}
/* ============ Default base branch picker dialog ============ */
.am-default-base-branch {
min-width: 360px;
}
.am-default-base-branch .am-dropdown-list {
max-height: 320px;
}
.am-branch-hint {
margin-left: auto;
color: var(--text-weaker);
font-size: 11px;
}
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "انقر مرتين لإعادة التسمية",
"agentManager.worktree.versions": "{{count}} نسخ",
"agentManager.worktree.advancedOptions": "خيارات Worktree متقدمة",
"agentManager.worktree.defaultBaseBranch": "الفرع الأساسي الافتراضي",
"agentManager.worktree.defaultBaseBranchAuto": "اكتشاف تلقائي",
"agentManager.hoverCard.branch": "الفرع",
"agentManager.hoverCard.base": "الأساس",
"agentManager.hoverCard.sessions": "الجلسات",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Clique duplo para renomear",
"agentManager.worktree.versions": "{{count}} versões",
"agentManager.worktree.advancedOptions": "Opções avançadas de Worktree",
"agentManager.worktree.defaultBaseBranch": "Branch base padrão",
"agentManager.worktree.defaultBaseBranchAuto": "Detecção automática",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sessões",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Dvostruki klik za preimenovanje",
"agentManager.worktree.versions": "{{count}} verzija",
"agentManager.worktree.advancedOptions": "Napredne opcije Worktree-a",
"agentManager.worktree.defaultBaseBranch": "Zadana bazna grana",
"agentManager.worktree.defaultBaseBranchAuto": "Automatsko otkrivanje",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Baza",
"agentManager.hoverCard.sessions": "Sesije",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Dobbeltklik for at omdøbe",
"agentManager.worktree.versions": "{{count}} versioner",
"agentManager.worktree.advancedOptions": "Avancerede Worktree-indstillinger",
"agentManager.worktree.defaultBaseBranch": "Standard basisgren",
"agentManager.worktree.defaultBaseBranchAuto": "Automatisk registrering",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sessioner",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Doppelklick zum Umbenennen",
"agentManager.worktree.versions": "{{count}} Versionen",
"agentManager.worktree.advancedOptions": "Erweiterte Worktree-Optionen",
"agentManager.worktree.defaultBaseBranch": "Standard-Basiszweig",
"agentManager.worktree.defaultBaseBranchAuto": "Automatisch erkennen",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Basis",
"agentManager.hoverCard.sessions": "Sitzungen",
@@ -15,6 +15,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Double-click to rename",
"agentManager.worktree.versions": "{{count}} versions",
"agentManager.worktree.advancedOptions": "Advanced worktree options",
"agentManager.worktree.defaultBaseBranch": "Default Base Branch",
"agentManager.worktree.defaultBaseBranchAuto": "Auto-detect",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
@@ -37,7 +39,6 @@ export const dict = {
"agentManager.setup.failed": "Workspace setup failed",
"agentManager.setup.settingUp": "Setting up workspace",
"agentManager.shortcuts.title": "Keyboard Shortcuts",
"agentManager.shortcuts.category.sidebar": "Sidebar",
"agentManager.shortcuts.category.tabs": "Tabs",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Doble clic para renombrar",
"agentManager.worktree.versions": "{{count}} versiones",
"agentManager.worktree.advancedOptions": "Opciones avanzadas de Worktree",
"agentManager.worktree.defaultBaseBranch": "Rama base predeterminada",
"agentManager.worktree.defaultBaseBranchAuto": "Detección automática",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sesiones",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Double-cliquez pour renommer",
"agentManager.worktree.versions": "{{count}} versions",
"agentManager.worktree.advancedOptions": "Options avancées du Worktree",
"agentManager.worktree.defaultBaseBranch": "Branche de base par défaut",
"agentManager.worktree.defaultBaseBranchAuto": "Détection automatique",
"agentManager.hoverCard.branch": "BRANCHE",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sessions",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "ダブルクリックで名前を変更",
"agentManager.worktree.versions": "{{count}}バージョン",
"agentManager.worktree.advancedOptions": "Worktreeの詳細オプション",
"agentManager.worktree.defaultBaseBranch": "デフォルトベースブランチ",
"agentManager.worktree.defaultBaseBranchAuto": "自動検出",
"agentManager.hoverCard.branch": "ブランチ",
"agentManager.hoverCard.base": "ベース",
"agentManager.hoverCard.sessions": "セッション",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "더블 클릭하여 이름 변경",
"agentManager.worktree.versions": "{{count}}개 버전",
"agentManager.worktree.advancedOptions": "Worktree 고급 옵션",
"agentManager.worktree.defaultBaseBranch": "기본 베이스 브랜치",
"agentManager.worktree.defaultBaseBranchAuto": "자동 감지",
"agentManager.hoverCard.branch": "브랜치",
"agentManager.hoverCard.base": "베이스",
"agentManager.hoverCard.sessions": "세션",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Dobbeltklikk for å gi nytt navn",
"agentManager.worktree.versions": "{{count}} versjoner",
"agentManager.worktree.advancedOptions": "Avanserte Worktree-alternativer",
"agentManager.worktree.defaultBaseBranch": "Standard basisgren",
"agentManager.worktree.defaultBaseBranchAuto": "Automatisk gjenkjenning",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Økter",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Kliknij dwukrotnie, aby zmienić nazwę",
"agentManager.worktree.versions": "{{count}} wersji",
"agentManager.worktree.advancedOptions": "Zaawansowane opcje Worktree",
"agentManager.worktree.defaultBaseBranch": "Domyślna gałąź bazowa",
"agentManager.worktree.defaultBaseBranchAuto": "Automatyczne wykrywanie",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Baza",
"agentManager.hoverCard.sessions": "Sesje",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "Двойной клик для переименования",
"agentManager.worktree.versions": "{{count}} версий",
"agentManager.worktree.advancedOptions": "Расширенные настройки Worktree",
"agentManager.worktree.defaultBaseBranch": "Базовая ветка по умолчанию",
"agentManager.worktree.defaultBaseBranchAuto": "Автоопределение",
"agentManager.hoverCard.branch": "ВЕТКА",
"agentManager.hoverCard.base": "Основа",
"agentManager.hoverCard.sessions": "Сессии",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "ดับเบิลคลิกเพื่อเปลี่ยนชื่อ",
"agentManager.worktree.versions": "{{count}} เวอร์ชัน",
"agentManager.worktree.advancedOptions": "ตัวเลือก Worktree ขั้นสูง",
"agentManager.worktree.defaultBaseBranch": "สาขาฐานเริ่มต้น",
"agentManager.worktree.defaultBaseBranchAuto": "ตรวจจับอัตโนมัติ",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "ฐาน",
"agentManager.hoverCard.sessions": "เซสชัน",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "双击重命名",
"agentManager.worktree.versions": "{{count}} 个版本",
"agentManager.worktree.advancedOptions": "Worktree 高级选项",
"agentManager.worktree.defaultBaseBranch": "默认基础分支",
"agentManager.worktree.defaultBaseBranchAuto": "自动检测",
"agentManager.hoverCard.branch": "分支",
"agentManager.hoverCard.base": "基础",
"agentManager.hoverCard.sessions": "会话",
@@ -14,6 +14,8 @@ export const dict = {
"agentManager.worktree.doubleClickRename": "按兩下以重新命名",
"agentManager.worktree.versions": "{{count}} 個版本",
"agentManager.worktree.advancedOptions": "Worktree 進階選項",
"agentManager.worktree.defaultBaseBranch": "預設基礎分支",
"agentManager.worktree.defaultBaseBranchAuto": "自動偵測",
"agentManager.hoverCard.branch": "分支",
"agentManager.hoverCard.base": "基底",
"agentManager.hoverCard.sessions": "工作階段",
@@ -626,6 +626,7 @@ export interface AgentManagerSessionMetaMessage {
export interface AgentManagerRepoInfoMessage {
type: "agentManager.repoInfo"
branch: string
defaultBranch?: string
}
// Agent Manager worktree setup progress
@@ -674,6 +675,7 @@ export interface AgentManagerStateMessage {
sessionsCollapsed?: boolean
reviewDiffStyle?: "unified" | "split"
isGitRepo?: boolean
defaultBaseBranch?: string
}
// Resolved keybindings for agent manager actions
@@ -703,6 +705,7 @@ export interface BranchInfo {
isRemote: boolean
isDefault: boolean
lastCommitDate?: string
isCheckedOut?: boolean
}
export interface AgentManagerBranchesMessage {
@@ -1450,6 +1453,12 @@ export interface EnhancePromptRequest {
requestId: string
}
// Set default base branch (webview → extension)
export interface SetDefaultBaseBranchRequest {
type: "agentManager.setDefaultBaseBranch"
branch?: string
}
export type WebviewMessage =
| SendMessageRequest
| AbortRequest
@@ -1532,6 +1541,7 @@ export type WebviewMessage =
// legacy-migration end
| ApplyWorktreeDiffMessage
| EnhancePromptRequest
| SetDefaultBaseBranchRequest
// ============================================
// VS Code API type