From 57a9a6012c7eac98a7a0704443f671101935a795 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 31 Jul 2026 11:16:36 +0200 Subject: [PATCH] fix(vscode): restore Agent Manager terminals (#12720) --- ...gent-manager-terminal-project-namespace.md | 5 + .../src/agent-manager/AgentManagerProvider.ts | 1 + .../src/agent-manager/terminal-routing.ts | 8 ++ .../kilo-vscode/src/agent-manager/types.ts | 3 + .../unit/agent-manager-terminal-font.test.ts | 1 + .../agent-manager-terminal-routing.test.ts | 7 ++ .../unit/agent-manager-terminal-state.test.ts | 98 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 7 +- .../agent-manager/selection-actions.ts | 7 +- .../agent-manager/terminal/state.ts | 24 +++-- .../src/types/messages/extension-messages.ts | 3 + 11 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 .changeset/agent-manager-terminal-project-namespace.md diff --git a/.changeset/agent-manager-terminal-project-namespace.md b/.changeset/agent-manager-terminal-project-namespace.md new file mode 100644 index 0000000000..7f7560042d --- /dev/null +++ b/.changeset/agent-manager-terminal-project-namespace.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Agent Manager embedded terminals not opening in the side panel or as a tab. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 59a8ba8951..56aa14f5b2 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -134,6 +134,7 @@ export class AgentManagerProvider implements Disposable { getServerConfig: () => this.connectionService.getServerConfig() ?? undefined, getRoot: () => this.getRoot(), getWorktreePath: (id) => this.getStateManager()?.getWorktree(id)?.path, + getProjectId: () => this.projectScope.current()?.id ?? this.contexts.active()?.id, log: (...args) => this.log("[XTerm]", ...args), post: (msg) => this.postToWebview(msg), getTerminalFont: () => readTerminalFont(), diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 0de5399600..ee4dd03379 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -34,6 +34,10 @@ export interface TerminalRoutingDeps { getRoot(): string | undefined /** Resolve a worktree id to its on-disk path, or undefined if unknown. */ getWorktreePath(worktreeId: string): string | undefined + /** Project the current message is dispatched for; stamped onto + * `terminal.created` so the webview can namespace its per-project + * terminal state (worktree ids collide across projects). */ + getProjectId(): string | undefined /** Output channel log — prefixed by the caller. */ log(...args: unknown[]): void /** Send a message back to the webview. */ @@ -115,6 +119,9 @@ export class TerminalRouter { const generation = this.generation const manager = this.manager const cwd = this.resolveCwd(worktreeId) + // Captured synchronously: the project scope is only current while the + // dispatch runs, not when the async create settles. + const pid = this.deps.getProjectId() if (!cwd) { this.deps.post({ type: "agentManager.terminal.error", @@ -141,6 +148,7 @@ export class TerminalRouter { createId, placement, worktreeId: created.worktreeId, + ...(pid ? { projectId: pid } : {}), terminalId: created.terminalId, title: created.title, wsUrl: created.wsUrl, diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 6e26186d4d..73c78a1e54 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -190,6 +190,9 @@ interface TerminalCreatedMessage { placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ worktreeId: string | null + /** Project that owns the create; the webview namespaces its per-project + * terminal state with it (mirrors `ScriptTerminalView.projectId`). */ + projectId?: string terminalId: string title: string wsUrl: string diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts index 02b7fffe27..603a2cce0b 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts @@ -57,6 +57,7 @@ describe("Agent Manager terminal font", () => { getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, + getProjectId: () => "prj-1", log: () => undefined, post: resolve, getTerminalFont: () => font, diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts index ef9bfcd6c4..a29cc3af97 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -25,6 +25,7 @@ describe("Agent Manager terminal routing", () => { getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: (id) => (id === "wt-1" ? "/workspace/wt-1" : undefined), + getProjectId: () => "prj-1", log: () => undefined, post: (message) => messages.push(message), getTerminalFont: () => font, @@ -42,6 +43,7 @@ describe("Agent Manager terminal routing", () => { createId: "side-1", placement: "side", worktreeId: "wt-1", + projectId: "prj-1", }) router.handle({ @@ -83,6 +85,7 @@ describe("Agent Manager terminal routing", () => { getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, + getProjectId: () => "prj-1", log: () => undefined, post: (message) => messages.push(message), getTerminalFont: () => font, @@ -132,6 +135,7 @@ describe("Agent Manager terminal routing", () => { getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, + getProjectId: () => "prj-1", log: () => undefined, post: (message) => messages.push(message), getTerminalFont: () => font, @@ -178,6 +182,7 @@ describe("Agent Manager terminal routing", () => { getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, + getProjectId: () => "prj-1", log: () => undefined, post: (message) => messages.push(message), getTerminalFont: () => font, @@ -217,6 +222,7 @@ describe("Agent Manager terminal routing", () => { getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, + getProjectId: () => "prj-1", log: () => undefined, post: () => undefined, getTerminalFont: () => font, @@ -262,6 +268,7 @@ describe("Agent Manager terminal routing", () => { getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, + getProjectId: () => "prj-1", log: () => undefined, post: (message) => messages.push(message), getTerminalFont: () => font, 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 ac3a8613b3..0cb69375c3 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 @@ -561,4 +561,102 @@ describe("Agent Manager terminal state", () => { dispose() }) }) + + // Multi-project regression: AgentManagerApp feeds createTerminalState a + // project-namespaced selection accessor (`${projectId}:${sel}`) so ids + // from different projects never collide. The wire protocol still speaks + // plain worktree ids, and `terminal.created` carries the owning + // `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 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 handlers = createTerminalHandlers({ + state, + tabIds: () => state.current().map((term) => term.id), + selectReview: () => undefined, + selectSessionTab: () => undefined, + clearSession: () => undefined, + resetOthers: () => undefined, + isPendingId: () => false, + findTab: () => undefined, + postMessage: (message) => posted.push(message as Record), + onShowSide: () => undefined, + getSelection: selection, + LOCAL, + REVIEW_TAB_ID: "review", + }) + const dispatch = createTerminalMessageHandler({ + state, + activate: (id) => events.activated.push(id), + saveTabMemory: () => undefined, + setSelection: (value) => events.selected.push(value), + showError: () => undefined, + postMessage: (message) => posted.push(message as Record), + onCreated: (contextKey) => events.created.push(contextKey), + }) + return { state, posted, events, handlers, dispatch, ns } + } + + it("keeps the namespaced state key out of side creates and buckets project-stamped answers", () => { + createRoot((dispose) => { + const item = nsScene(LOCAL) + item.handlers.requestSide() + + expect(item.posted).toHaveLength(1) + const request = item.posted[0]! + expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null }) + expect(item.dispatch({ ...createdSide(String(request.createId), "terminal:side"), projectId: "prj-1" })).toBe( + true, + ) + expect(item.state.sideKey()).toBe("prj-1:local") + expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"]) + expect(item.state.sideActiveFor("prj-1:local")).toBe("terminal:side") + + // A worktree context sends its plain worktree id, not "prj-1:wt-1". + const wt = nsScene("wt-1") + wt.handlers.addSide() + expect(wt.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", + placement: "side", + worktreeId: "wt-1", + }) + dispose() + }) + }) + + it("buckets project-stamped tab terminals under the namespaced context", () => { + createRoot((dispose) => { + const item = nsScene(LOCAL) + item.handlers.requestNew() + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", + placement: "tab", + worktreeId: null, + }) + const created: ExtensionMessage = { + type: "agentManager.terminal.created", + createId: String(item.posted[0]!.createId), + placement: "tab", + worktreeId: null, + projectId: "prj-1", + terminalId: "terminal:tab", + title: "Terminal 1", + wsUrl: "ws://tab", + font, + } + 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.activated).toEqual(["terminal:tab"]) + dispose() + }) + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b1adf20541..9bba90f470 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -890,6 +890,7 @@ const AgentManagerContent: Component = () => { post: (msg: unknown) => vscode.postMessage(msg as never), tabMemory, terms, + nsKey, activateTerminal: (id: string) => termHandlers.activate(id), setActivePendingId, selectSession: session.selectSession, @@ -2066,9 +2067,11 @@ const AgentManagerContent: Component = () => { setLocalSessionIDs(sessionSubset) } // Mirror the order into the terminal state so `terms.current()` - // (the source for renderTerminalLayer's slot order) matches. + // (the source for renderTerminalLayer's slot order) matches. The + // terminal state is keyed by namespaced context, not the plain + // tab-order key. const terminalSubset = reordered.filter(isTerminalTabId) - if (terminalSubset.length > 0) terms.reorder(key, terminalSubset) + if (terminalSubset.length > 0) terms.reorder(nsKey(key), terminalSubset) } const handleDragEnd = () => { 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 0de67640ca..c3e3c1f2b4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts @@ -24,6 +24,9 @@ export interface SelectionActionDeps { post: (msg: unknown) => void tabMemory: () => Record terms: TermState + /** Terminal state is keyed by project-namespaced context; map a plain + * selection ("local" or a worktree id) to its terminal-state key. */ + nsKey: (sel: string) => string activateTerminal: (id: string) => void setActivePendingId: (id: string | undefined) => void selectSession: (id: string) => void @@ -40,7 +43,7 @@ export function selectLocalAction(deps: SelectionActionDe deps.setSelection(LOCAL) deps.post({ type: "agentManager.requestRepoInfo" }) const remembered = deps.tabMemory()[LOCAL] - if (deps.terms.hasRemembered(LOCAL, remembered)) return deps.activateTerminal(remembered!) + if (deps.terms.hasRemembered(deps.nsKey(LOCAL), remembered)) return deps.activateTerminal(remembered!) deps.terms.setActiveId(undefined) const target = remembered ? locals.find((s) => s.id === remembered) : undefined const fallback = target ?? locals[0] @@ -64,7 +67,7 @@ export function selectWorktreeAction( deps.saveTabMemory() deps.setSelection(worktreeId) const remembered = deps.tabMemory()[worktreeId] - if (deps.terms.hasRemembered(worktreeId, remembered)) return deps.activateTerminal(remembered!) + if (deps.terms.hasRemembered(deps.nsKey(worktreeId), remembered)) return deps.activateTerminal(remembered!) deps.terms.setActiveId(undefined) const target = remembered ? sessions.find((s) => s.id === remembered) : undefined const fallback = target ?? sessions[0] 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 f5b4d4003d..caa4e6e53b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -639,13 +639,16 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { const createSide = () => { const key = deps.state.sideKey() + // The wire protocol speaks plain worktree ids; `sideKey` is the + // project-namespaced state key and must not leak into the message. + const sel = deps.getSelection() const id = newId() deps.state.beginSide(key, id) deps.postMessage({ type: "agentManager.terminal.create", createId: id, placement: "side", - worktreeId: key === deps.LOCAL ? null : key, + worktreeId: sel === null || sel === deps.LOCAL ? null : sel, }) } @@ -838,7 +841,10 @@ type CreatedMessage = Extract function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { - const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId + // `target` is the plain protocol id (selection/tab-order keys); `key` is + // the project-namespaced terminal-state key (same shape as syncScripts). + const target = msg.worktreeId === null ? LOCAL : msg.worktreeId + const key = msg.projectId ? `${msg.projectId}:${target}` : target const term = { id: msg.terminalId, title: msg.title, @@ -852,20 +858,20 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { // reloaded (or the context is gone) — close the PTY again instead // of leaking it. const request = deps.state.completeSide(msg.createId) - if (!request || request.contextKey !== contextKey) { + if (!request || request.contextKey !== key) { deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) return } - deps.state.add(msg.worktreeId, term) + deps.state.add(key === LOCAL ? null : key, term) // The newest terminal becomes the visible one in its panel. - deps.state.setSideActive(contextKey, msg.terminalId) - deps.onSideCreated?.(contextKey, msg.terminalId) + deps.state.setSideActive(key, msg.terminalId) + deps.onSideCreated?.(key, msg.terminalId) return } - deps.state.add(msg.worktreeId, term) - deps.onCreated?.(contextKey, msg.terminalId) + deps.state.add(key === LOCAL ? null : key, term) + deps.onCreated?.(target, msg.terminalId) deps.saveTabMemory() - deps.setSelection(contextKey) + deps.setSelection(target) deps.activate(msg.terminalId) } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 35d737de99..4b5481dd02 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -785,6 +785,9 @@ export interface AgentManagerTerminalCreatedMessage { placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ worktreeId: string | null + /** Project that owns the create; the webview namespaces its per-project + * terminal state with it (mirrors `ScriptTerminalView.projectId`). */ + projectId?: string terminalId: string title: string wsUrl: string