Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-project-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Let Agent Manager users choose the repository when creating or importing a worktree in multi-project mode.
834 changes: 834 additions & 0 deletions .kilo/plans/agent-manager-new-worktree-project-selector.md

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -811,15 +811,15 @@ export class AgentManagerProvider implements Disposable {

private onImportMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
if (m.type === "agentManager.requestBranches") {
void this.importer.branches()
void this.importer.branches(m.projectId)
return null
}
if (m.type === "agentManager.importFromBranch") {
void this.importer.branch(m.branch)
void this.importer.branch(m.branch, m.projectId)
return null
}
if (m.type === "agentManager.importFromPR") {
void this.importer.pr(m.url)
void this.importer.pr(m.url, m.projectId)
return null
}
}
Expand Down Expand Up @@ -1055,6 +1055,7 @@ export class AgentManagerProvider implements Disposable {
this.pushState()
this.postToWebview({
type: "agentManager.worktreeSetup",
projectId: this.host.multiProject() ? this.context?.id : undefined,
status: "ready",
message: "Worktree ready",
sessionId,
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ interface BranchesMessage {

interface ImportResultMessage {
type: "agentManager.importResult"
projectId?: string
success: boolean
message: string
errorCode?: WorktreeSetupErrorCode
Expand Down
36 changes: 19 additions & 17 deletions packages/kilo-vscode/src/agent-manager/worktree-importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ export class WorktreeImporter {

constructor(private readonly host: WorktreeImporterHost) {}

async branches(): Promise<void> {
async branches(projectId?: string): Promise<void> {
const manager = this.host.manager()
if (!manager) {
this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" })
return
}

Expand All @@ -46,31 +46,32 @@ export class WorktreeImporter {

this.host.post({
type: "agentManager.branches",
projectId,
branches,
defaultBranch: result.defaultBranch,
})
} catch (error) {
this.host.log(`Failed to list branches: ${error}`)
this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" })
}
}

async branch(branch: string): Promise<void> {
await this.run({ branch })
async branch(branch: string, projectId?: string): Promise<void> {
await this.run({ branch }, projectId)
}

async pr(url: string): Promise<void> {
await this.run({ url })
async pr(url: string, projectId?: string): Promise<void> {
await this.run({ url }, projectId)
}

private async run(target: { branch: string } | { url: string }): Promise<void> {
private async run(target: { branch: string } | { url: string }, projectId?: string): Promise<void> {
const manager = this.host.manager()
const state = this.host.state()
if (!manager || !state) {
this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
this.host.post({ type: "agentManager.importResult", projectId, success: false, message: "Not a git repository" })
return
}
if (this.busy()) return
if (this.busy(projectId)) return
this.importing = true
const branch = "branch" in target
const creating = branch ? "Creating worktree from branch..." : "Resolving PR..."
Expand All @@ -79,7 +80,7 @@ export class WorktreeImporter {
? `Branch "${target.branch}" is already checked out in another worktree`
: "This PR's branch is already checked out in another worktree"
try {
const progress = { type: "agentManager.worktreeSetup", status: "creating" } as const
const progress = { type: "agentManager.worktreeSetup", projectId, status: "creating" } as const
this.host.post({ ...progress, message: creating })
const result = branch
? await manager.createWorktree({ existingBranch: target.branch })
Expand All @@ -102,7 +103,7 @@ export class WorktreeImporter {
state.addSession(session.id, worktree.id)
this.host.register(session.id, result.path)
this.host.ready(session.id, result, worktree.id)
this.host.post({ type: "agentManager.importResult", success: true, message: success })
this.host.post({ type: "agentManager.importResult", projectId, success: true, message: success })
this.host.log(`${log} as worktree ${worktree.id}`)
} catch (error) {
state.removeWorktree(worktree.id)
Expand All @@ -111,27 +112,28 @@ export class WorktreeImporter {
throw error
}
} catch (error) {
this.importError(error, duplicate)
this.importError(error, duplicate, projectId)
} finally {
this.importing = false
}
}

private busy(): boolean {
private busy(projectId?: string): boolean {
if (!this.importing) return false
this.host.post({
type: "agentManager.importResult",
projectId,
success: false,
message: "Another import is already in progress",
})
return true
}

private importError(error: unknown, duplicate: string): void {
private importError(error: unknown, duplicate: string, projectId?: string): void {
const raw = error instanceof Error ? error.message : String(error)
const message = raw.includes("already used by worktree") || raw.includes("already checked out") ? duplicate : raw
const code = classifyWorktreeError(message)
this.host.post({ type: "agentManager.worktreeSetup", status: "error", message, errorCode: code })
this.host.post({ type: "agentManager.importResult", success: false, message, errorCode: code })
this.host.post({ type: "agentManager.worktreeSetup", projectId, status: "error", message, errorCode: code })
this.host.post({ type: "agentManager.importResult", projectId, success: false, message, errorCode: code })
}
}
3 changes: 2 additions & 1 deletion packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"),
path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"),
Expand Down Expand Up @@ -690,7 +691,7 @@ describe("Agent Manager Provider — onMessage routing", () => {

it("worktree import behavior lives in the cohesive importer", () => {
const text = importer()
for (const value of ["createFromPR", "createWorktree", "this.busy()"]) expect(text).toContain(value)
for (const value of ["createFromPR", "createWorktree", "this.busy(projectId)"]) expect(text).toContain(value)
expect(body("onImportMessage")).toContain("this.importer")
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "bun:test"
import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"

const root = join(__dirname, "..", "..")
const dialog = readFileSync(join(root, "webview-ui", "agent-manager", "NewWorktreeDialog.tsx"), "utf8")
const app = readFileSync(join(root, "webview-ui", "agent-manager", "AgentManagerApp.tsx"), "utf8")
const importer = readFileSync(join(root, "src", "agent-manager", "worktree-importer.ts"), "utf8")
const css = readFileSync(join(root, "webview-ui", "agent-manager", "agent-manager.css"), "utf8")

describe("Agent Manager New Worktree project targeting", () => {
it("routes dialog operations through the selected project and rejects stale responses", () => {
expect(dialog).toContain("const [project, setProject]")
expect(dialog).toContain("if (ev.projectId !== project()) return")
expect(dialog).toContain('type: "agentManager.requestBranches", projectId: id')
expect(dialog).toContain('type: "agentManager.createMultiVersion"')
expect(dialog).toContain("projectId: target")
expect(dialog).toContain('type: "agentManager.importFromPR"')
expect(dialog).toContain('type: "agentManager.importFromBranch"')
})

it("does not replace a pending cross-project activation", () => {
expect(app).toContain("if (pendingCreate()) return")
expect(app).toContain('msg.type === "agentManager.importResult"')
expect(app).toContain("!msg.success && pendingCreate()?.projectId === msg.projectId")
})

it("tags branch and import responses with their owning project", () => {
expect(importer).toContain("async branches(projectId?: string)")
expect(importer).toContain('type: "agentManager.branches", projectId')
expect(importer).toContain('type: "agentManager.importResult", projectId')
expect(importer).toContain('type: "agentManager.worktreeSetup", projectId')
})

it("keeps the project picker aligned with the dialog selector system", () => {
expect(css).toContain(".am-nv-project-inline")
expect(css).toContain(".am-project-option")
expect(css).toContain('[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"])')
})

it("defines project labels in every Agent Manager locale", () => {
const keys = [
"agentManager.dialog.project.select",
"agentManager.dialog.project.untrusted",
"agentManager.dialog.project.missing",
]
const locales = readdirSync(join(root, "webview-ui", "agent-manager", "i18n")).filter((file) =>
file.endsWith(".ts"),
)

for (const file of locales) {
const source = readFileSync(join(root, "webview-ui", "agent-manager", "i18n", file), "utf8")
for (const key of keys) expect(source, `${file} is missing ${key}`).toContain(`"${key}"`)
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
AgentManagerWorktreeDiffLoadingMessage,
AgentManagerWorktreeDiffNoticeMessage,
AgentManagerDiffBranchesMessage,
AgentManagerImportResultMessage,
AgentManagerApplyWorktreeDiffResultMessage,
AgentManagerWorktreeStatsMessage,
AgentManagerLocalStatsMessage,
Expand Down Expand Up @@ -266,6 +267,12 @@ const AgentManagerContent: Component = () => {
const [currentProjectId, setCurrentProjectId] = createSignal<string | undefined>()
const [projectStates, setProjectStates] = createSignal<Record<string, AgentManagerStateMessage>>({})
const activeProjectId = () => projectList().find((p) => p.active)?.id ?? currentProjectId()
const [pendingCreate, setPendingCreate] = createSignal<{ projectId: string }>()
const scheduleCreate = (projectId: string) => {
if (projectId === activeProjectId()) return
if (pendingCreate()) return
Comment thread
marius-kilocode marked this conversation as resolved.
setPendingCreate({ projectId })
}
const isActivePayload = (pid: string | undefined) =>
projectList().length === 0 || pid === undefined || pid === activeProjectId()

Expand All @@ -282,6 +289,14 @@ const AgentManagerContent: Component = () => {
persisted: persisted ?? {},
activeId: () => currentProjectId() ?? "single",
})
const defaultBase = (id: string) => {
const store = registry.ensure(id)
return (
store.defaultBaseBranch() ??
store.localStats()?.branch ??
(id === activeProjectId() ? repoDetectedBranch() : undefined)
)
}
const localSessionIDs = () => registry.active().tabs.ids()
const setLocalSessionIDs = (next: string[] | ((prev: string[]) => string[])) => registry.active().tabs.set(next)
/** Remove a session ID from the local tab (no-op if absent). */
Expand Down Expand Up @@ -1371,6 +1386,15 @@ const AgentManagerContent: Component = () => {

if (msg.type === "agentManager.worktreeSetup") {
const ev = msg as AgentManagerWorktreeSetupMessage
const pending = pendingCreate()
if (ev.status === "ready" && ev.projectId && pending?.projectId === ev.projectId && ev.worktreeId) {
setPendingCreate(undefined)
vscode.postMessage({
type: "agentManager.activateSelection",
target: { projectId: ev.projectId, kind: "worktree", worktreeId: ev.worktreeId },
})
}
if (ev.status === "error" && pending?.projectId === ev.projectId) setPendingCreate(undefined)
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
const updateBusy: Setter<Map<string, WorktreeBusyState>> = (value) => store.setBusy(value)
if (ev.status === "ready" || ev.status === "error") {
Expand Down Expand Up @@ -1412,6 +1436,9 @@ const AgentManagerContent: Component = () => {
}
}

if (msg.type === "agentManager.importResult" && !msg.success && pendingCreate()?.projectId === msg.projectId)
setPendingCreate(undefined)

if (msg.type === "agentManager.sessionAdded") {
const ev = msg as { type: string; sessionId: string; worktreeId: string }
saveTabMemory()
Expand Down Expand Up @@ -1453,6 +1480,7 @@ const AgentManagerContent: Component = () => {
// When a multi-version progress update arrives, mark newly created worktrees as loading
if ((msg as { type: string }).type === "agentManager.multiVersionProgress") {
const ev = msg as unknown as AgentManagerMultiVersionProgressMessage
if (ev.status === "done" && pendingCreate()?.projectId === ev.projectId) setPendingCreate(undefined)
if (ev.status === "done" && ev.groupId) {
// Clear busy state for all worktrees in this group
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
Expand Down Expand Up @@ -1871,7 +1899,15 @@ const AgentManagerContent: Component = () => {
if (!loaded()) return
expandSidebar()
dialog.show(() => (
<NewWorktreeDialog mode={mode} onClose={() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />
<NewWorktreeDialog
mode={mode}
onClose={() => dialog.close()}
projectId={multiProject() ? activeProjectId() : undefined}
projects={multiProject() ? projectList : undefined}
activeProjectId={activeProjectId()}
defaultBase={defaultBase}
onCreate={scheduleCreate}
/>
))
}

Expand Down Expand Up @@ -2348,6 +2384,8 @@ const AgentManagerContent: Component = () => {
selection={selection() ?? undefined}
currentSessionID={session.currentSessionID}
mode={mode}
defaultBase={defaultBase}
onCreate={scheduleCreate}
bindings={kb()}
t={t}
onSearchRef={(ref) => (sidebarSearchMenu = ref)}
Expand Down
Loading
Loading