diff --git a/.changeset/fix-local-project-switch.md b/.changeset/fix-local-project-switch.md new file mode 100644 index 000000000000..8e24f8f86406 --- /dev/null +++ b/.changeset/fix-local-project-switch.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve selected tabs, tab order, and review context when switching between projects and worktrees in Agent Manager. diff --git a/packages/kilo-vscode/tests/unit/agent-manager-diff-scope-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-diff-scope-state.test.ts index 88bf665cb430..f232bc347631 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-diff-scope-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-diff-scope-state.test.ts @@ -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"]) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-review-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-review-routing.test.ts index cdd700aae788..97034bfc064a 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-review-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-review-routing.test.ts @@ -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, diff --git a/packages/kilo-vscode/tests/unit/agent-manager-selection-actions.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-selection-actions.test.ts index e2720584e039..47c58a2aadbe 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-selection-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-selection-actions.test.ts @@ -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, @@ -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 }, @@ -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 = { local: "review" } + const sessions: Record = {} + 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 = { 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 = { 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() @@ -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("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: [] }, diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts index 22d7dd673cd9..4a183058dd75 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -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(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> = [] - 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), @@ -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), - 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", () => { @@ -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", @@ -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() }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts index 2feb0ce055e7..3e9cbe1d7ea3 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -41,6 +41,41 @@ describe("diffDataKey", () => { }) describe("createWorktreeDiffs", () => { + it("retains cached local content across project switches until explicitly reset", () => { + createRoot((dispose) => { + let project = "a" + const store = createWorktreeDiffs(vscode([]), () => project) + const entry = { ...diff("a.ts"), before: "before", after: "after", summarized: false } + store.onWorktreeDiff({ + type: "agentManager.worktreeDiff", + projectId: project, + sessionId: "local#branch", + diffs: [entry], + }) + project = "b" + store.onWorktreeDiff({ + type: "agentManager.worktreeDiff", + projectId: project, + sessionId: "local#branch", + diffs: [diff("b.ts")], + }) + store.prune(new Set()) + project = "a" + store.onWorktreeDiffLoading({ + type: "agentManager.worktreeDiffLoading", + projectId: project, + sessionId: "local#branch", + loading: true, + }) + expect(store.diffDatas()[diffDataKey(project, "local#branch")]?.at(0)).toBe(entry) + expect(store.diffLoadingFor(() => "local#branch")).toBe(false) + store.reset() + expect(store.diffDatas()).toEqual({}) + expect(store.diffLoading()).toBe(false) + dispose() + }) + }) + it.each([undefined, "", "project"])("prunes only the complete project namespace %j", (project) => { createRoot((dispose) => { const store = createWorktreeDiffs(vscode([]), () => project) diff --git a/packages/kilo-vscode/tests/unit/project-review-state.test.ts b/packages/kilo-vscode/tests/unit/project-review-state.test.ts index 0f77113666ee..c77b52dc3522 100644 --- a/packages/kilo-vscode/tests/unit/project-review-state.test.ts +++ b/packages/kilo-vscode/tests/unit/project-review-state.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test" +import { createReviewComposers } from "../../webview-ui/agent-manager/review-composers" import { reviewComments, reviewKey, @@ -9,6 +10,25 @@ import { } from "../../webview-ui/agent-manager/project/review-state" describe("project review state", () => { + it("retains unsent composers across project switches and clears only explicit targets", () => { + let project = "a" + const composers = createReviewComposers(() => project) + const draft = composers.get("a\0local#unstaged") + draft.draft = { type: "draft", comment: null, file: "a.ts", side: "additions", line: 1, text: "unsent" } + project = "b" + const other = composers.get("b\0local#unstaged") + expect(other.draft).toBeNull() + composers.prune(new Set()) + project = "a" + expect(composers.get("a\0local#unstaged")).toBe(draft) + expect(draft.draft?.text).toBe("unsent") + composers.clear("local") + expect(composers.get("a\0local#unstaged").draft).toBeNull() + expect(composers.get("b\0local#unstaged")).toBe(other) + composers.clearProject("b") + expect(composers.get("b\0local#unstaged")).not.toBe(other) + }) + it("keeps identical contexts separate by project", () => { let open = setReviewOpen({}, "a", "local", true) open = setReviewOpen(open, "b", "local", false) diff --git a/packages/kilo-vscode/tests/unit/project-store.test.ts b/packages/kilo-vscode/tests/unit/project-store.test.ts index 7f0c7d070e57..ba230c24f7a8 100644 --- a/packages/kilo-vscode/tests/unit/project-store.test.ts +++ b/packages/kilo-vscode/tests/unit/project-store.test.ts @@ -17,6 +17,23 @@ const state = (projectId: string, order: string[]) => ({ }) describe("project stores", () => { + it("preserves temporary tab positions when durable order is pushed again", () => { + const store = createProjectStore("a") + const order = ["terminal:second", "ses-2", "review", "pending:draft", "ses-1", "terminal:first"] + store.setTabOrder({ local: order }) + store.applyState({ ...state("a", []), tabOrder: { local: ["ses-2", "ses-1"] } }) + expect(store.tabOrder().local).toEqual(order) + store.applyState({ ...state("a", []), tabOrder: { local: ["ses-2", "ses-1"] } }) + expect(store.tabOrder().local).toEqual(order) + }) + + it("accepts durable changes without dropping temporary tabs", () => { + const store = createProjectStore("a") + store.setTabOrder({ local: ["ses-1", "terminal:1", "ses-2", "review"] }) + store.applyState({ ...state("a", []), tabOrder: { local: ["ses-2", "ses-3"] } }) + expect(store.tabOrder().local).toEqual(["terminal:1", "ses-2", "review", "ses-3"]) + }) + it("keeps worktree order isolated between projects", () => { const first = createProjectStore("a") const second = createProjectStore("b") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index d0639930036e..4092cb025b97 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -85,16 +85,11 @@ import { createProjectLive } from "./project/live" import { createProjectSessionsLive } from "./project/sessions-live" import { worktreeSessionIds as worktreeMembership, worktreeSessions } from "./project/session-filter" import { applyProjectSelection, createTargetRememberer } from "./project/selection" -import { - createLocalSessions, - needsLocalDraft, - persistLocalTabs, - projectLocalIds, - projectLocalSessions, -} from "./project/local-tabs" +import { createLocalSessions, persistLocalTabs, projectLocalIds, projectLocalSessions } from "./project/local-tabs" import { createProjectRegistry, type PersistedProjectTabs } from "./project/registry" import type { WorktreeBusyState } from "./project/store" -import { rememberTarget, restoreProjectTarget } from "./project/restore" +import { rememberTarget } from "./project/restore" +import { createProjectHydration } from "./project/hydration" import { createProjectStateRouter } from "./project/state" import { createWorktreeActivity } from "./project/session-busy" import { switchProject } from "./project/switch" @@ -143,10 +138,8 @@ import { addPendingTab as addLocalPendingTab, nextTabAfterClose, openSessionTab, - pruneClosed, reconcileTrackedTabs, replacePendingTab, - restoreTrackedTabs, trackedSessionInventory, } from "../src/utils/local-tabs" import { @@ -237,7 +230,7 @@ import "./agent-manager.css" import "./agent-manager-review.css" import { cycleAgent as cycle } from "../src/context/session-agent" import { createSidebarScrollPreserver } from "./sidebar-scroll" -const REVIEW_TAB_ID = "review" +import { PENDING_PREFIX, REVIEW_TAB_ID } from "./tab-ids" /** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */ type SidebarSelection = typeof LOCAL | string | null const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) @@ -388,7 +381,6 @@ const AgentManagerContent: Component = () => { return { pr, selected, wt: worktrees().find((w) => w.id === selected) } }) const diffs = createWorktreeDiffs(vscode, activeProjectId) - createEffect(on(activeProjectId, diffs.reset, { defer: true })) const diffDatas = diffs.diffDatas const diffLoading = diffs.diffLoading const setDiffLoading = diffs.setDiffLoading @@ -401,7 +393,12 @@ const AgentManagerContent: Component = () => { panels.open(SidePanel.Terminal) } const composers = createReviewComposers(currentProjectId) - createEffect(on(activeProjectId, (_next, previous) => previous && composers.clearProject(previous), { defer: true })) + createEffect( + on(projectList, (next, previous) => { + const ids = new Set(next.map((project) => project.id)) + previous?.filter((project) => !ids.has(project.id)).forEach((project) => composers.clearProject(project.id)) + }), + ) const reviewState = createReviewState() const reviewOpenByContext = reviewState.open const setReviewOpenByContext = reviewState.setOpen @@ -487,7 +484,6 @@ const AgentManagerContent: Component = () => { active: isActivePayload, branch: (branch) => setRepoBranch(branch), }) - const PENDING_PREFIX = "pending:" const closedDrafts = new Set() const [activePendingId, setActivePendingId] = createSignal() const [terminalFont, setTerminalFont] = createSignal({ @@ -725,18 +721,22 @@ const AgentManagerContent: Component = () => { let pendingNewSection = false const tabState = createTabPersistence(registry.active, selection, REVIEW_TAB_ID, (m) => vscode.postMessage(m)) - const tabOrderSync = createTabOrderSync({ - LOCAL, - REVIEW_TAB_ID, - order: worktreeTabOrder, - setOrder: setWorktreeTabOrder, - persist: tabState.persistOrder, - localSessionIDs, - sessions: session.sessions, - managedSessions, - reviewOpen: (key) => isReviewOpen(reviewOpenByContext(), currentProjectId() ?? "single", key), - terminalIdsFor: (key) => terms.forSelection(nsKey(key)).map((t) => t.id), - }) + const orders = (store = registry.active) => + createTabOrderSync({ + LOCAL, + REVIEW_TAB_ID, + order: () => store().tabOrder(), + setOrder: (value) => store().setTabOrder(value), + persist: (key, order) => { + if (store() === registry.active()) tabState.persistOrder(key, order) + }, + localSessionIDs: () => store().tabs.ids(), + sessions: session.sessions, + managedSessions: () => store().managedSessions(), + reviewOpen: (key) => isReviewOpen(reviewOpenByContext(), store().id, key), + terminalIdsFor: (key) => terms.forSelection(`${store().id}:${key}`).map((term) => term.id), + }) + const tabOrderSync = orders() const appendToTabOrder = tabOrderSync.append const addPendingTab = () => { const id = `${PENDING_PREFIX}${crypto.randomUUID()}` @@ -761,7 +761,8 @@ const AgentManagerContent: Component = () => { } const focusLocalSession = (id: string, scrollToBottom = false) => { const pending = activePendingId() - const replace = pending && localSessionIDs().includes(pending) ? pending : undefined + const replace = + pending && !localSessionIDs().includes(id) && localSessionIDs().includes(pending) ? pending : undefined placeLocal(id, replace, replace) setActivePendingId(undefined) terms.setActiveId(undefined) @@ -788,9 +789,10 @@ const AgentManagerContent: Component = () => { applied: currentProjectId, active: activeProjectId, owns: (sel) => worktrees().some((wt) => wt.id === sel), - pending: isPending, locals: localSessionIDs, - localTab: (id) => id === REVIEW_TAB_ID || isTerminalTabId(id), + localTab: (id) => id === REVIEW_TAB_ID || terms.hasRemembered(nsKey(LOCAL), id), + session: () => session.currentSessionID() ?? activePendingId(), + rememberSession: (sel, id) => registry.active().sessionRestore.set(sel, id), set: (sel, tab) => registry.active().tabMemory.set(sel, tab), }) createEffect(() => { @@ -1072,6 +1074,7 @@ const AgentManagerContent: Component = () => { setSelection, post: (msg: unknown) => vscode.postMessage(msg as never), tabMemory, + sessionMemory: (sel: string) => registry.active().sessionRestore.get(sel), terms, nsKey, activateTerminal: (id: string) => termHandlers.activate(id), @@ -1199,50 +1202,34 @@ const AgentManagerContent: Component = () => { ...browser.bind(session.currentSessionID), }) const preserveSidebarScroll = createSidebarScrollPreserver(() => selection() ?? session.currentSessionID()) - const applyActiveState = (state: AgentManagerStateMessage) => { - const switched = applyProjectSwitch(state) - setRestricted(state.restricted === true) - if (state.isGitRepo !== undefined) setIsGitRepo(state.isGitRepo) - if (!worktreesLoaded()) setWorktreesLoaded(true) - // When not a git repo, also mark sessions as loaded since the Kilo - // server won't connect to send the sessionsLoaded message. - if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true) - if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") { - diffStyle.setStyle(state.reviewDiffStyle) - } - markdown.setRender(state.reviewMarkdownRender === true) - const current = session.currentSessionID() - if (current && !settingUpSelection()) { - const ms = state.sessions.find((s) => s.id === current) - if (ms?.worktreeId) setSelection(ms.worktreeId) - } - // Restore local session IDs from persisted state (sessions with no worktreeId) - const tracked = trackedSessionInventory(state.sessions, session.sessions()) - const closed = closedSet() - pruneClosed(closed, state.sessions) - const restored = restoreTrackedTabs( - tracked, - localSessionIDs(), - state.tabOrder?.[LOCAL], - isPending, - applyTabOrder, - closed, - ) - if (restored) setLocalSessionIDs(restored) - if (switched === "switched" && needsLocalDraft(localSessionIDs(), terms.forSelection(nsKey(LOCAL)))) addPendingTab() - if (switched !== "same") { - restoreProjectTarget(state, { - selectLocal, - selectWorktree, - focusLocal: focusLocalSession, - focusManaged: focusManagedSession, - setSelection, - setActivePendingId, - }) - requestChatFocus() - } - sidebar.hydrate(state.sidebarCollapsed) - } + const applyActiveState = createProjectHydration({ + switch: (state) => applyProjectSwitch(state), + restricted: setRestricted, + repo: setIsGitRepo, + loaded: () => setWorktreesLoaded(true), + sessionsLoaded: () => setSessionsLoaded(true), + style: diffStyle.setStyle, + markdown: markdown.setRender, + current: session.currentSessionID, + settingUp: () => !!settingUpSelection(), + sessions: session.sessions, + tabs: localSessionIDs, + closed: closedSet, + pending: isPending, + setTabs: setLocalSessionIDs, + terminals: () => terms.forSelection(nsKey(LOCAL)), + draft: addPendingTab, + restore: { + selectLocal, + selectWorktree, + focusLocal: focusLocalSession, + focusManaged: focusManagedSession, + setSelection, + setActivePendingId, + }, + focus: requestChatFocus, + sidebar: sidebar.hydrate, + }) const applyProjectSwitch = (state: AgentManagerStateMessage): "first" | "switched" | "same" => { return switchProject({ @@ -1250,7 +1237,14 @@ const AgentManagerContent: Component = () => { current: currentProjectId, set: setCurrentProjectId, first: () => undefined, - close: () => setReviewActive(false), + close: () => { + saveTabMemory(true) + setSelection(null) + terms.setActiveId(undefined) + setActivePendingId(undefined) + session.clearCurrentSession() + setReviewActive(false) + }, history: () => state.projectId && historySwitches().includes(state.projectId) ? setHistorySwitches((prev) => prev.filter((id) => id !== state.projectId)) @@ -1263,7 +1257,7 @@ const AgentManagerContent: Component = () => { applied: currentProjectId, selection, owns: (sel) => worktrees().some((wt) => wt.id === sel), - sessionId: session.currentSessionID, + sessionId: () => (reviewActive() ? undefined : session.currentSessionID()), post: vscode.postMessage, }) onMount(() => { @@ -1442,7 +1436,7 @@ const AgentManagerContent: Component = () => { showError: (message) => showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }), postMessage: (message) => vscode.postMessage(message as never), - onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId), + onCreated: (key, id, pid) => orders(pid ? () => registry.ensure(pid) : registry.active).append(key, id), onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(focusMemory, terminalId), onScriptRunning: (contextKey, terminalId) => { @@ -1684,6 +1678,7 @@ const AgentManagerContent: Component = () => { const review = createDiffReviewScope({ ctx: diffCtx, + key: () => diffCtx() && diffDataKey(currentProjectId(), diffCtx()!), session: activeDiffSession, panelOpen: diffOpen, reviewActive, @@ -2081,6 +2076,7 @@ const AgentManagerContent: Component = () => { selectSessionTab, clearSession: () => session.clearCurrentSession(), resetOthers: () => { + tabs.remember() setReviewActive(false) setActivePendingId(undefined) session.clearCurrentSession() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts index 1d078909baa1..da533c12c215 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts @@ -18,6 +18,8 @@ interface VsCode { export interface DiffReviewScopeOptions { /** Current diff context (worktree id or the LOCAL pseudo-id). */ ctx: Accessor + /** Project-namespaced memory key, separate from the protocol context. */ + key: Accessor /** Active session inside the context; the Session scope follows it. */ session: Accessor /** Whether the diff side panel is open. */ @@ -29,7 +31,7 @@ export interface DiffReviewScopeOptions { } export function createDiffReviewScope(opts: DiffReviewScopeOptions) { - const scope = createDiffScope(opts.ctx) + const scope = createDiffScope(opts.key) // The composite id (ctx#scope, or ctx#session:) the extension keys // diff data by. Rebuilds when the active session changes while the Session // scope is active, so a session tab switch refetches that session's diff. diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts index b1a5338e0e6a..ce1bda7698de 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts @@ -66,22 +66,22 @@ export function scopeCapabilities(scope: DiffScope): { revert: boolean; comments } /** - * Per-context scope selection. Keeps the last-picked scope per context id so + * Per-context scope selection. Keeps the last-picked scope per namespaced key so * switching between worktrees restores each worktree's scope, while a brand - * new context defaults to Branch. The context is the sidebar selection, so the + * new context defaults to Branch. The key includes the project and selection, so the * picked scope survives session tab switches inside the context. */ -export function createDiffScope(currentCtx: Accessor) { +export function createDiffScope(key: Accessor) { const [scopes, setScopes] = createSignal>({}) const scope = createMemo((): DiffScope => { - const ctx = currentCtx() + const ctx = key() if (!ctx) return DEFAULT_DIFF_SCOPE return scopes()[ctx] ?? DEFAULT_DIFF_SCOPE }) const setScope = (next: DiffScope) => { - const ctx = currentCtx() + const ctx = key() if (!ctx) return setScopes((prev) => ({ ...prev, [ctx]: next })) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/hydration.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/hydration.ts new file mode 100644 index 000000000000..19e4272b49cb --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/hydration.ts @@ -0,0 +1,63 @@ +import { batch } from "solid-js" +import type { AgentManagerStateMessage } from "../../src/types/messages" +import { LOCAL } from "../navigate" +import { applyTabOrder } from "../tab-order" +import { pruneClosed, restoreTrackedTabs, trackedSessionInventory } from "../../src/utils/local-tabs" +import { needsLocalDraft } from "./local-tabs" +import { restoreProjectTarget, type RestoreDeps } from "./restore" + +export function createProjectHydration(opts: { + switch: (state: AgentManagerStateMessage) => "first" | "switched" | "same" + restricted: (value: boolean) => void + repo: (value: boolean) => void + loaded: () => void + sessionsLoaded: () => void + style: (value: "split" | "unified") => void + markdown: (value: boolean) => void + current: () => string | undefined + settingUp: () => boolean + sessions: () => Parameters[1] + tabs: () => string[] + closed: () => Set + pending: (id: string) => boolean + setTabs: (ids: string[]) => void + terminals: () => { id: string }[] + draft: () => void + restore: RestoreDeps + focus: () => void + sidebar: (value: boolean | undefined) => void +}) { + return (state: AgentManagerStateMessage) => + batch(() => { + const switched = opts.switch(state) + opts.restricted(state.restricted === true) + if (state.isGitRepo !== undefined) opts.repo(state.isGitRepo) + opts.loaded() + if (state.isGitRepo === false) opts.sessionsLoaded() + if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") opts.style(state.reviewDiffStyle) + opts.markdown(state.reviewMarkdownRender === true) + const current = opts.current() + if (current && !opts.settingUp()) { + const session = state.sessions.find((item) => item.id === current) + if (session?.worktreeId) opts.restore.setSelection(session.worktreeId) + } + const tracked = trackedSessionInventory(state.sessions, opts.sessions()) + const closed = opts.closed() + pruneClosed(closed, state.sessions) + const restored = restoreTrackedTabs( + tracked, + opts.tabs(), + state.tabOrder?.[LOCAL], + opts.pending, + applyTabOrder, + closed, + ) + if (restored) opts.setTabs(restored) + if (switched === "switched" && needsLocalDraft(opts.tabs(), opts.terminals())) opts.draft() + if (switched !== "same") { + restoreProjectTarget(state, opts.restore) + opts.focus() + } + opts.sidebar(state.sidebarCollapsed) + }) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/store.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/store.ts index a5322b16a9a9..d12363d17864 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/store.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/store.ts @@ -1,4 +1,7 @@ import { createSignal, type Accessor, type Setter } from "solid-js" +import { isTerminalTabId } from "../../src/utils/terminal-tab-id" +import { mergeTransientTabs } from "../tab-order" +import { PENDING_PREFIX, REVIEW_TAB_ID } from "../tab-ids" import type { AgentManagerStateMessage, LocalGitStats, @@ -91,7 +94,22 @@ export function createProjectStore(id: string, opts: { tabs?: string[] } = {}) { setWorktreeHealth(state.worktreeHealth ?? {}) setOrphanDirectories(state.orphanDirectories ?? []) setSections(state.sections ?? []) - if (state.tabOrder) setTabOrder(state.tabOrder) + if (state.tabOrder) { + const incoming = state.tabOrder + setTabOrder((previous) => ({ + ...previous, + ...Object.fromEntries( + Object.entries(incoming).map(([key, order]) => [ + key, + mergeTransientTabs( + previous[key] ?? [], + order, + (id) => id === REVIEW_TAB_ID || isTerminalTabId(id) || id.startsWith(PENDING_PREFIX), + ), + ]), + ), + })) + } if (state.pinnedTabs) setPinnedTabs(state.pinnedTabs) if (state.worktreeOrder) setWorktreeOrder(state.worktreeOrder) if ("defaultBaseBranch" in state) setDefaultBaseBranch(state.defaultBaseBranch || undefined) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts b/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts index f8178037089d..49a9f825f12d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts @@ -34,22 +34,21 @@ export function createTabMemory(opts: { applied: () => string | undefined active: () => string | undefined owns: (selection: string) => boolean - pending: (id: string) => boolean locals: () => string[] localTab?: (id: string) => boolean + session?: () => string | undefined + rememberSession?: (selection: string, id: string) => void set: (selection: string, tab: string) => void }) { - return () => { + return (switching = false) => { const sel = opts.selection() const tab = opts.tab() if (sel === null || !tab) return - if (opts.multi() && opts.applied() !== opts.active()) return - if ( - opts.multi() && - !(sel === LOCAL ? opts.localTab?.(tab) || opts.pending(tab) || opts.locals().includes(tab) : opts.owns(sel)) - ) - return + if (!switching && opts.multi() && opts.applied() !== opts.active()) return + if (opts.multi() && !(sel === LOCAL ? opts.localTab?.(tab) || opts.locals().includes(tab) : opts.owns(sel))) return rememberSelectionTab(opts.set, sel, tab) + const session = opts.session?.() + if (session) opts.rememberSession?.(sel, session) } } @@ -59,6 +58,7 @@ export interface SelectionActionDeps { setSelection: (id: string) => void post: (msg: unknown) => void tabMemory: () => Record + sessionMemory?: (selection: string) => string | undefined terms: TermState /** Terminal state is keyed by project-namespaced context; map a plain * selection ("local" or a worktree id) to its terminal-state key. */ @@ -104,7 +104,7 @@ export function createSessionRestore(deps: { remember: () => { const selection = deps.selection() const id = deps.current() ?? deps.pending() - if (selection !== null && id) deps.remember(selection, id) + if (selection !== null && id && deps.sessions().some((item) => item.id === id)) deps.remember(selection, id) }, restore: () => { const selection = deps.selection() @@ -141,6 +141,7 @@ export function selectLocalAction( deps.saveTabMemory() deps.post({ type: "agentManager.requestRepoInfo" }) const remembered = deps.tabMemory()[LOCAL] + const backing = deps.isReviewTab(remembered, LOCAL) ? deps.sessionMemory?.(LOCAL) : remembered batch(() => { deps.setReviewActive(false) deps.setSelection(LOCAL) @@ -151,12 +152,12 @@ export function selectLocalAction( } deps.terms.setActiveId(undefined) const real = locals.filter((item) => !deps.isPending(item.id)) - const target = remembered ? real.find((s) => s.id === remembered) : undefined - const draft = remembered && deps.isPending(remembered) ? remembered : undefined + const target = backing ? real.find((s) => s.id === backing) : undefined + const draft = locals.find((item) => item.id === backing && deps.isPending(item.id))?.id const fallback = target?.id ?? draft ?? - (remembered && ids.includes(remembered) ? remembered : undefined) ?? + (backing && ids.includes(backing) ? backing : undefined) ?? real[0]?.id ?? ids[0] ?? locals.find((item) => deps.isPending(item.id))?.id @@ -189,9 +190,9 @@ export function selectWorktreeAction( return } deps.terms.setActiveId(undefined) - const target = remembered ? sessions.find((s) => s.id === remembered) : undefined - const fallback = - target?.id ?? (remembered && ids.includes(remembered) ? remembered : undefined) ?? sessions[0]?.id ?? ids[0] + const backing = deps.isReviewTab(remembered, worktreeId) ? deps.sessionMemory?.(worktreeId) : remembered + const target = backing ? sessions.find((s) => s.id === backing) : undefined + const fallback = target?.id ?? (backing && ids.includes(backing) ? backing : undefined) ?? sessions[0]?.id ?? ids[0] if (fallback) deps.selectSession(fallback) else deps.resetSession() deps.setReviewActive(deps.isReviewTab(remembered, worktreeId)) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/tab-ids.ts b/packages/kilo-vscode/webview-ui/agent-manager/tab-ids.ts new file mode 100644 index 000000000000..31d259f536e9 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/tab-ids.ts @@ -0,0 +1,8 @@ +/** + * Agent Manager tab ids that live only in the webview and are not part of the + * durable host tab order: the Review tab and pending draft tabs. Kept in one + * module so the app and the project store cannot drift apart. + */ + +export const REVIEW_TAB_ID = "review" +export const PENDING_PREFIX = "pending:" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts b/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts index b84771205d75..26cc3b3aaedd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts @@ -4,6 +4,23 @@ export { applyPinnedTabs, reorderTabs, togglePinnedTab } from "../src/utils/tab-order" +/** Restore durable ordering without moving tabs that only exist in this webview. */ +export function mergeTransientTabs( + previous: string[], + incoming: string[], + transient: (id: string) => boolean, +): string[] { + const result = [...incoming] + for (const [index, id] of previous.entries()) { + if (!transient(id) || result.includes(id)) continue + const before = previous.slice(0, index).findLast((item) => result.includes(item)) + const after = previous.slice(index + 1).find((item) => result.includes(item)) + const position = before ? result.indexOf(before) + 1 : after ? result.indexOf(after) : result.length + result.splice(position, 0, id) + } + return result +} + /** * Apply a custom ordering to a list of items. * diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts index dfeb79b7d1f1..0b0bd32b84e9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -935,12 +935,12 @@ export interface TerminalMessageHandlerDeps { postMessage: (message: unknown) => void /** * Called with the context key ("local" or worktree id) and the new - * terminal id once a `terminal.created` message lands. The main + * terminal id and owning project once a `terminal.created` message lands. The main * component uses this hook to append the id to its per-context tab * order so the terminal renders at the end of the tab bar rather * than wherever `tabIds()`'s base composition happens to put it. */ - onCreated?: (contextKey: string, terminalId: string) => void + onCreated?: (contextKey: string, terminalId: string, projectId?: string) => void /** Side terminal create failed for a context. */ onSideError?: (contextKey: string) => void /** Side terminal was closed (locally or by the extension). */ @@ -984,9 +984,11 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { } return } - deps.rememberSession?.() deps.state.add(key === LOCAL ? null : key, term) - deps.onCreated?.(target, msg.terminalId) + deps.onCreated?.(target, msg.terminalId, msg.projectId) + // A late reply belongs in its owner's tab strip, not the visible context. + if (deps.state.currentKey() !== key) return + deps.rememberSession?.() deps.saveTabMemory() deps.setSelection(target) deps.activate(msg.terminalId)