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/fix-local-project-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Preserve selected tabs, tab order, and review context when switching between projects and worktrees in Agent Manager.
Original file line number Diff line number Diff line change
@@ -1,7 +1,62 @@
import { describe, it, expect } from "bun:test"
import path from "node:path"
import { composeDiffId, parseDiffId, scopeDescriptors } from "../../webview-ui/agent-manager/diff-scope-state"

describe("agent-manager webview diff scope descriptors", () => {
it("restores project and worktree scopes without namespacing protocol IDs", () => {
const child = Bun.spawnSync(
[
process.execPath,
"--conditions=browser",
"-e",
`
import { strict as assert } from "node:assert"
import { createRoot, createSignal } from "solid-js"
import { createDiffReviewScope } from "./webview-ui/agent-manager/diff-review-scope"
import { diffDataKey } from "./webview-ui/agent-manager/worktree-diffs"

createRoot((dispose) => {
const [project, setProject] = createSignal("a")
const [ctx, setContext] = createSignal("local")
const [session, setSession] = createSignal("ses-a")
const sent = []
const review = createDiffReviewScope({
ctx, key: () => diffDataKey(project(), ctx()), session, project,
panelOpen: () => false, reviewActive: () => false,
vscode: { postMessage: (msg) => sent.push(msg) },
})
review.select("local#unstaged")
setProject("b")
assert.equal(review.id(), "local#branch")
review.select("local#staged")
setContext("wt-1")
assert.equal(review.id(), "wt-1#branch")
review.select("wt-1#session:ses-a")
setSession("ses-b")
assert.equal(review.id(), "wt-1#session:ses-b")
setContext("local")
assert.equal(review.id(), "local#staged")
setProject("a")
assert.equal(review.id(), "local#unstaged")
setContext("wt-1")
assert.equal(review.id(), "wt-1#branch")
review.selectBase("main")
assert.deepEqual(sent, [{
type: "agentManager.setDiffBaseBranch", projectId: "a",
sessionId: "wt-1", scope: "branch", branch: "main",
}])
setProject("b")
assert.equal(review.id(), "wt-1#session:ses-b")
assert.equal(review.descriptors().at(-1).id, "wt-1#session:ses-b")
dispose()
})
`,
],
{ cwd: path.resolve(import.meta.dir, "../.."), stdout: "pipe", stderr: "pipe" },
)
expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0)
})

it("offers the three git scopes without an active session", () => {
const descriptors = scopeDescriptors("wt_1")
expect(descriptors.map((d) => d.type)).toEqual(["workspace", "staged", "unstaged"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe("project review routing", () => {
const sent: unknown[] = []
const review = createDiffReviewScope({
ctx: () => "shared",
key: () => `${current()}\0shared`,
session: () => undefined,
panelOpen: () => false,
reviewActive: () => false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { needsLocalDraft } from "../../webview-ui/agent-manager/project/local-tabs"
import { createTerminalState } from "../../webview-ui/agent-manager/terminal/state"
import { createSidePanel } from "../../webview-ui/agent-manager/side-panel-state"
import { SidePanel } from "../../webview-ui/agent-manager/side-panel-layout"
import {
createTabMemory,
rememberSelectionTab,
Expand Down Expand Up @@ -69,9 +71,8 @@ describe("selectLocalAction", () => {
applied: () => "project-a",
active: () => "project-a",
owns: () => false,
pending: (id) => id.startsWith("pending:"),
locals: () => ["ses-a1", "ses-a2"],
localTab: (id) => id === "review" || id.startsWith("terminal:"),
locals: () => ["ses-a1", "ses-a2", "pending:draft"],
localTab: (id) => id === "review" || id === "terminal:1",
set: (key, id) => {
memory[key] = id
},
Expand All @@ -95,6 +96,94 @@ describe("selectLocalAction", () => {
expect(result.calls).toContain("local:ses-a2")
})

it("saves the outgoing view before applying a host-driven project switch", () => {
const memory: Record<string, string> = { local: "review" }
const sessions: Record<string, string> = {}
const save = createTabMemory({
selection: () => "local",
tab: () => "terminal:a",
multi: () => true,
applied: () => "a",
active: () => "b",
owns: () => false,
locals: () => ["ses-a"],
localTab: (id) => id === "terminal:a",
session: () => "ses-a",
rememberSession: (key, id) => {
sessions[key] = id
},
set: (key, id) => {
memory[key] = id
},
})

save()
expect(memory.local).toBe("review")
save(true)
expect(memory.local).toBe("terminal:a")
expect(sessions.local).toBe("ses-a")
})

it("does not replace destination memory with a foreign terminal", () => {
const memory: Record<string, string> = { local: "ses-b2" }
const result = deps()
result.value.tabMemory = () => memory
result.value.saveTabMemory = createTabMemory({
selection: () => "local",
tab: () => "terminal:a",
multi: () => true,
applied: () => "b",
active: () => "b",
owns: () => false,
locals: () => ["ses-b1", "ses-b2"],
localTab: (id) => id === "terminal:b",
set: (key, id) => {
memory[key] = id
},
})
selectLocalAction(result.value, [{ id: "ses-b1" }, { id: "ses-b2" }])
expect(memory.local).toBe("ses-b2")
expect(result.calls).toEqual(["local:ses-b2"])
})

it.each(["ses-a2", "pending:a"])("restores %s after leaving a fresh project's draft", (tab) => {
const memory: Record<string, string> = { local: tab }
const ids = ["ses-a1", tab]
const result = deps()
result.value.isPending = (id) => id.startsWith("pending:")
result.value.setActivePendingId = (id) => {
if (id) result.calls.push(`draft:${id}`)
}
result.value.tabMemory = () => memory
result.value.saveTabMemory = createTabMemory({
selection: () => "local",
tab: () => "pending:b",
multi: () => true,
applied: () => "project-a",
active: () => "project-a",
owns: () => false,
locals: () => ids,
set: (key, id) => {
memory[key] = id
},
})

selectLocalAction(result.value, [{ id: "ses-a1" }], ids)

expect(memory.local).toBe(tab)
expect(result.calls).toEqual([`${result.value.isPending(tab) ? "draft" : "local"}:${tab}`])
})

it("ignores a remembered draft that does not belong to Local", () => {
const result = deps()
result.value.isPending = (id) => id.startsWith("pending:")
result.value.tabMemory = () => ({ local: "pending:b" })

selectLocalAction(result.value, [{ id: "ses-a" }], ["ses-a"])

expect(result.calls).toEqual(["local:ses-a"])
})

it("focuses a project session when shared session metadata is stale", () => {
const result = deps()

Expand All @@ -106,6 +195,32 @@ describe("selectLocalAction", () => {

for (const target of ["local", "wt-b"]) {
describe(`${target} terminal restoration`, () => {
it("restores Review's backing session and contextual panel", () => {
createRoot((dispose) => {
const [current, setCurrent] = createSignal<string | undefined>("ses-2")
const panels = createSidePanel({ project: () => "a", selection: () => target, current })
panels.open(SidePanel.EditPreview)
setCurrent("other")
expect(panels.panel()).toBeNull()
const result = deps()
result.value.tabMemory = () => ({ [target]: "review" })
result.value.sessionMemory = () => "ses-2"
result.value.isReviewTab = (id) => id === "review"
result.value.focusLocal = setCurrent
result.value.selectSession = setCurrent
result.value.setReviewActive = (active) => {
if (active) result.calls.push("review")
}
const sessions = [{ id: "ses-1" }, { id: "ses-2" }]
if (target === "local") selectLocalAction(result.value, sessions)
else selectWorktreeAction(result.value, target, sessions)
expect(current()).toBe("ses-2")
expect(panels.panel()).toBe(SidePanel.EditPreview)
expect(result.calls).toEqual(["review"])
dispose()
})
})

it.each([
{ remembered: undefined, ids: [] },
{ remembered: "terminal:closed", ids: [] },
Expand Down
109 changes: 95 additions & 14 deletions packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,20 +671,28 @@ describe("Agent Manager terminal state", () => {
// `projectId` so the answer lands back in the namespaced context.
function nsScene(initial: string | null = LOCAL, pid = "prj-1") {
const [selection, setSelection] = createSignal<string | null>(initial)
const ns = (sel: string) => `${pid}:${sel}`
const [project, setProject] = createSignal(pid)
const ns = (sel: string) => `${project()}:${sel}`
const state = createTerminalState(() => {
const sel = selection()
return sel === null ? null : ns(sel)
})
const posted: Array<Record<string, unknown>> = []
const events = { activated: [] as string[], selected: [] as string[], created: [] as string[] }
const events = {
activated: [] as string[],
selected: [] as string[],
created: [] as Array<{ key: string; id: string; project?: string }>,
saved: 0,
remembered: 0,
reset: 0,
}
const handlers = createTerminalHandlers({
state,
tabIds: () => state.current().map((term) => term.id),
selectReview: () => undefined,
selectSessionTab: () => undefined,
clearSession: () => undefined,
resetOthers: () => undefined,
resetOthers: () => events.reset++,
isPendingId: () => false,
findTab: () => undefined,
postMessage: (message) => posted.push(message as Record<string, unknown>),
Expand All @@ -696,14 +704,21 @@ describe("Agent Manager terminal state", () => {
})
const dispatch = createTerminalMessageHandler({
state,
activate: (id) => events.activated.push(id),
saveTabMemory: () => undefined,
setSelection: (value) => events.selected.push(value),
activate: (id) => {
events.activated.push(id)
handlers.activate(id)
},
saveTabMemory: () => events.saved++,
rememberSession: () => events.remembered++,
setSelection: (value) => {
events.selected.push(value)
setSelection(value)
},
showError: () => undefined,
postMessage: (message) => posted.push(message as Record<string, unknown>),
onCreated: (contextKey) => events.created.push(contextKey),
onCreated: (key, id, project) => events.created.push({ key, id, project }),
})
return { state, posted, events, handlers, dispatch, ns }
return { state, posted, events, handlers, dispatch, ns, selection, setSelection, setProject }
}

it("keeps the namespaced state key out of side creates and buckets project-stamped answers", () => {
Expand Down Expand Up @@ -732,20 +747,20 @@ describe("Agent Manager terminal state", () => {
})
})

it("buckets project-stamped tab terminals under the namespaced context", () => {
it.each([LOCAL, "wt-1"])("activates a project-stamped tab terminal while its %s context is still active", (key) => {
createRoot((dispose) => {
const item = nsScene(LOCAL)
const item = nsScene(key)
item.handlers.requestNew()
expect(item.posted[0]).toMatchObject({
type: "agentManager.terminal.create",
placement: "tab",
worktreeId: null,
worktreeId: key === LOCAL ? null : key,
})
const created: ExtensionMessage = {
type: "agentManager.terminal.created",
createId: String(item.posted[0]!.createId),
placement: "tab",
worktreeId: null,
worktreeId: key === LOCAL ? null : key,
projectId: "prj-1",
terminalId: "terminal:tab",
title: "Terminal 1",
Expand All @@ -755,9 +770,75 @@ describe("Agent Manager terminal state", () => {
expect(item.dispatch(created)).toBe(true)
expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"])
// Selection and tab order stay on the plain protocol id.
expect(item.events.selected).toEqual([LOCAL])
expect(item.events.created).toEqual([LOCAL])
expect(item.events.selected).toEqual([key])
expect(item.events.created).toEqual([{ key, id: "terminal:tab", project: "prj-1" }])
expect(item.events.activated).toEqual(["terminal:tab"])
expect(item.events.saved).toBe(1)
expect(item.events.remembered).toBe(1)
expect(item.events.reset).toBe(1)
dispose()
})
})

it.each([LOCAL, "wt-1"])("keeps a late %s terminal reply in its owner after a project switch", (key) => {
createRoot((dispose) => {
const item = nsScene(key)
item.handlers.requestNew()
item.setProject("prj-2")
item.state.add(item.ns(key), {
id: "terminal:current",
title: "Current",
wsUrl: "ws://current",
font,
placement: "tab",
})
item.state.setActiveId("terminal:current")
item.state.setSideActive(item.ns(key), "terminal:side")
item.state.requestFocus("terminal:side")
const focus = item.state.focusRequest()

item.dispatch({
...createdSide(String(item.posted.at(0)!.createId), "terminal:late", "Late", key === LOCAL ? null : key),
placement: "tab",
projectId: "prj-1",
})

expect(item.events.created).toEqual([{ key, id: "terminal:late", project: "prj-1" }])
expect(item.state.forSelection(`prj-1:${key}`).map((term) => term.id)).toEqual(["terminal:late"])
expect(item.state.current().map((term) => term.id)).toEqual(["terminal:current"])
expect(item.selection()).toBe(key)
expect(item.state.activeId()).toBe("terminal:current")
expect(item.state.sideActiveFor(item.ns(key))).toBe("terminal:side")
expect(item.state.focusRequest()).toBe(focus)
expect(item.events.activated).toEqual([])
expect(item.events.selected).toEqual([])
expect(item.events.saved).toBe(0)
expect(item.events.remembered).toBe(0)
expect(item.events.reset).toBe(0)
dispose()
})
})

it.each(["wt-2", null])("does not leave context %s for a late terminal reply in the same project", (key) => {
createRoot((dispose) => {
const item = nsScene("wt-1")
item.handlers.requestNew()
item.setSelection(key)
item.dispatch({
...createdSide(String(item.posted.at(0)!.createId), "terminal:late", "Late", "wt-1"),
placement: "tab",
projectId: "prj-1",
})

expect(item.events.created).toEqual([{ key: "wt-1", id: "terminal:late", project: "prj-1" }])
expect(item.state.forSelection("prj-1:wt-1").map((term) => term.id)).toEqual(["terminal:late"])
expect(item.selection()).toBe(key)
expect(item.state.activeId()).toBeUndefined()
expect(item.events.activated).toEqual([])
expect(item.events.selected).toEqual([])
expect(item.events.saved).toBe(0)
expect(item.events.remembered).toBe(0)
expect(item.events.reset).toBe(0)
dispose()
})
})
Expand Down
Loading
Loading