From df604a1710869e29f24fe87e4246cea724b36bb3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 24 Sep 2026 11:29:47 +0200 Subject: [PATCH 1/2] fix(agent-manager): preserve view state across project switches Switching projects or worktrees could lose the selected tab, reorder tabs, or drop the right sidebar context. Late terminal replies could also steal selection from the visible context. - Restrict remembered drafts and terminals to their owning project. - Save the outgoing view before a host-driven project activation. - Keep Review's backing session so closing it restores the right tab. - Merge durable tab order with webview-only tabs instead of replacing it. - Namespace diff scope per project and keep review drafts on switch. - Ignore late terminal creation replies from an inactive context. --- .changeset/fix-local-project-switch.md | 5 + .../agent-manager-diff-scope-state.test.ts | 55 +++++++ .../unit/agent-manager-review-routing.test.ts | 1 + .../agent-manager-selection-actions.test.ts | 121 ++++++++++++++- .../unit/agent-manager-terminal-state.test.ts | 109 +++++++++++-- .../unit/agent-manager-worktree-diffs.test.ts | 35 +++++ .../tests/unit/project-review-state.test.ts | 20 +++ .../tests/unit/project-store.test.ts | 17 ++ .../agent-manager/AgentManagerApp.tsx | 145 +++++++++--------- .../agent-manager/diff-review-scope.ts | 4 +- .../agent-manager/diff-scope-state.ts | 10 +- .../agent-manager/project/hydration.ts | 63 ++++++++ .../webview-ui/agent-manager/project/store.ts | 19 ++- .../agent-manager/selection-actions.ts | 31 ++-- .../webview-ui/agent-manager/tab-order.ts | 17 ++ .../agent-manager/terminal/state.ts | 10 +- 16 files changed, 545 insertions(+), 117 deletions(-) create mode 100644 .changeset/fix-local-project-switch.md create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/project/hydration.ts 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..437bd19104d9 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 { @@ -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 @@ -725,18 +722,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 +762,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 +790,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 +1075,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 +1203,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 +1238,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 +1258,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 +1437,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 +1679,7 @@ const AgentManagerContent: Component = () => { const review = createDiffReviewScope({ ctx: diffCtx, + key: () => diffCtx() && diffDataKey(currentProjectId(), diffCtx()!), session: activeDiffSession, panelOpen: diffOpen, reviewActive, @@ -2081,6 +2077,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..b9942fd6f22f 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,6 @@ import { createSignal, type Accessor, type Setter } from "solid-js" +import { isTerminalTabId } from "../../src/utils/terminal-tab-id" +import { mergeTransientTabs } from "../tab-order" import type { AgentManagerStateMessage, LocalGitStats, @@ -91,7 +93,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" || isTerminalTabId(id) || id.startsWith("pending:"), + ), + ]), + ), + })) + } 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-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) From e20c7fd5fc719e18ac85a6e6739847f65ec3fe6f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 24 Sep 2026 11:47:33 +0200 Subject: [PATCH 2/2] refactor(agent-manager): share tab id constants with the project store The project store hardcoded the Review tab id and the pending draft prefix when merging tab order. Move both constants into a shared module and import them from the app and the store so a rename cannot silently break tab-order preservation. --- .../webview-ui/agent-manager/AgentManagerApp.tsx | 3 +-- .../kilo-vscode/webview-ui/agent-manager/project/store.ts | 3 ++- packages/kilo-vscode/webview-ui/agent-manager/tab-ids.ts | 8 ++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/tab-ids.ts diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 437bd19104d9..4092cb025b97 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -230,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) @@ -484,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({ 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 b9942fd6f22f..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,6 +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, @@ -103,7 +104,7 @@ export function createProjectStore(id: string, opts: { tabs?: string[] } = {}) { mergeTransientTabs( previous[key] ?? [], order, - (id) => id === "review" || isTerminalTabId(id) || id.startsWith("pending:"), + (id) => id === REVIEW_TAB_ID || isTerminalTabId(id) || id.startsWith(PENDING_PREFIX), ), ]), ), 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:"