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/agent-manager-latest-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Keep the latest Agent Manager selection when another project finishes loading.
7 changes: 7 additions & 0 deletions packages/kilo-vscode/src/agent-manager/project/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { cloneProject, createProject, defaultParent, onboard, type Onboarding }
import { runner } from "./prepare"

const pending = new WeakSet<ProjectMessageDeps>()
const selections = new WeakMap<ProjectMessageDeps, symbol>()

/** Route one session to a directory inside a project via the shared session provider. */
export function routeProjectSession(
Expand Down Expand Up @@ -138,7 +139,13 @@ async function activateSelection(requested: SidebarTarget, deps: ProjectMessageD
deps.error("The project is unavailable. Check that the repository still exists.")
return
}
// Record the token only for an actionable target, so a click on an
// unavailable project cannot discard a pending valid selection.
const token = Symbol()
selections.set(deps, token)
const result = await deps.ready(ctx, { warm: true })
// A newer click can finish while this project's readiness is pending.
if (selections.get(deps) !== token || !deps.enabled()) return
if (!result.current || !result.ok) {
deps.error("The project is not ready yet. Expand it before selecting a worktree or session.")
deps.push()
Expand Down
47 changes: 47 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-project-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,53 @@ describe("activateSelection — cross-project selection", () => {
expect(calls.selected).toEqual([extra])
})

it.each([true, false])("ignores a superseded selection when readiness completes with ok=%s", async (ok) => {
const gate = Promise.withResolvers<void>()
const { contexts, deps, calls, extra } = setup({
ready: (ctx) =>
ctx.ensureReady(async () => {
ctx.stateManager()
if (!ctx.pinned) await gate.promise
return { ok: ctx.pinned || ok, refsFixed: 0 }
}),
})

const pending = handleProjectMessage(activateMsg(extra), deps)
await handleProjectMessage(activateMsg(PINNED, { kind: "worktree", worktreeId: "wt1" }), deps)
expect(calls.selected).toEqual(["worktree"])

gate.resolve()
await pending

expect(contexts.active()?.id).toBe(PINNED)
expect(calls.selected).toEqual(["worktree"])
expect(contexts.get(extra)?.peekState()?.getActiveTarget()).toBeUndefined()
expect(calls.error).toEqual([])
})

it("keeps a pending selection when a newer click targets an unavailable project", async () => {
const gate = Promise.withResolvers<void>()
const { contexts, deps, calls, extra } = setup({
ready: (ctx) =>
ctx.ensureReady(async () => {
ctx.stateManager()
if (!ctx.pinned) await gate.promise
return { ok: true, refsFixed: 0 }
}),
})

const pending = handleProjectMessage(activateMsg(extra), deps)
await handleProjectMessage(activateMsg("prj-missing"), deps)
expect(calls.selected).toEqual([])
expect(calls.error.length).toBe(1)

gate.resolve()
await pending

expect(contexts.active()?.id).toBe(extra)
expect(calls.selected).toEqual([extra])
})

it("falls back to local without an error toast when the target is gone", async () => {
const { contexts, deps, calls, extra } = setup()
contexts.activate(PINNED)
Expand Down
Loading