diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 5533ad7323a..33b988a922f 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1698,7 +1698,6 @@ export class AgentManagerProvider implements Disposable { panel.reveal(false) focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel)) } - public isActive(): boolean { return this.panel?.active === true } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index f6ee1415919..a9c2f4b3552 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -50,6 +50,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/SidebarBody.tsx"), path.join(ROOT, "webview-ui/agent-manager/TabBar.tsx"), path.join(ROOT, "webview-ui/agent-manager/ProjectBranchDialog.tsx"), + path.join(ROOT, "webview-ui/agent-manager/DefaultBaseBranchDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalTab.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), @@ -866,7 +867,7 @@ describe("KiloProvider — pending session refresh on reconnect", () => { // --------------------------------------------------------------------------- describe("Agent Manager — dialog listener cleanup", () => { - const tsx = fs.readFileSync(TSX_FILE, "utf-8") + const tsx = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/DefaultBaseBranchDialog.tsx"), "utf-8") /** * Regression: handleChangeDefaultBaseBranch subscribes to vscode.onMessage @@ -874,34 +875,20 @@ describe("Agent Manager — dialog listener cleanup", () => { * and the Escape keydown handler. If the dialog closed via backdrop click or * external dialog.close(), the listener leaked and stacked on every reopen. * - * The fix ties unsub() to Solid's onCleanup inside the dialog.show() render - * function so it always disposes regardless of how the dialog closes. + * The fix ties unsub() to the dialog component's Solid cleanup so it always + * disposes regardless of how the dialog closes. */ - it("handleChangeDefaultBaseBranch uses onCleanup(unsub) inside dialog.show", () => { - const fnStart = tsx.indexOf("const handleChangeDefaultBaseBranch") - expect(fnStart, "handleChangeDefaultBaseBranch must exist").toBeGreaterThan(-1) - - // Grab the function body (enough to cover the dialog.show callback) - const snippet = tsx.slice(fnStart, fnStart + 2000) - - // The dialog.show callback must register onCleanup(unsub) - const showIdx = snippet.indexOf("dialog.show(") - expect(showIdx, "dialog.show() call must exist").toBeGreaterThan(-1) - const afterShow = snippet.slice(showIdx) - expect(afterShow, "onCleanup(unsub) must be inside dialog.show callback").toContain("onCleanup(unsub)") + it("DefaultBaseBranchDialog disposes its message listener on cleanup", () => { + expect(tsx).toContain("const unsub = vscode.onMessage") + expect(tsx).toContain("onCleanup(unsub)") }) - it("selectBranch does not manually call unsub (handled by onCleanup)", () => { - const fnStart = tsx.indexOf("const handleChangeDefaultBaseBranch") - const snippet = tsx.slice(fnStart, fnStart + 2000) - - // Find the selectBranch function body - const selStart = snippet.indexOf("const selectBranch") - expect(selStart, "selectBranch must exist").toBeGreaterThan(-1) - const selEnd = snippet.indexOf("}", selStart + 50) - const selBody = snippet.slice(selStart, selEnd + 1) - - expect(selBody, "selectBranch should not call unsub() directly").not.toContain("unsub()") + it("select does not manually call unsub (handled by onCleanup)", () => { + const selStart = tsx.indexOf("const select =") + expect(selStart, "select must exist").toBeGreaterThan(-1) + const selEnd = tsx.indexOf("}", selStart + 40) + const selBody = tsx.slice(selStart, selEnd + 1) + expect(selBody, "select should not call unsub() directly").not.toContain("unsub()") }) }) diff --git a/packages/kilo-vscode/tests/unit/session-variants.test.ts b/packages/kilo-vscode/tests/unit/session-variants.test.ts new file mode 100644 index 00000000000..c266da63105 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-variants.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test" +import { createSessionVariants } from "../../webview-ui/src/context/session-variants" +import type { ExtensionMessage, ModelSelection } from "../../webview-ui/src/types/messages" + +const model: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } + +function setup(session?: string) { + const selections: Record = {} + const messages: Array<{ type: string; key?: string; value?: string }> = [] + const order: string[] = [] + let handler: ((message: ExtensionMessage) => void) | undefined + const variants = createSessionVariants({ + selections: () => selections, + set: (key, value) => { + selections[key] = value + }, + selected: () => model, + session: () => session, + agent: () => "code", + find: () => ({ variants: { low: {}, high: {} } }), + post: (message) => { + order.push("post") + messages.push(message) + }, + listen: (next) => { + order.push("listen") + handler = next + return () => order.push("unsub") + }, + }) + return { variants, selections, messages, order, dispatch: (message: ExtensionMessage) => handler?.(message) } +} + +describe("session variants", () => { + it("subscribes before requesting persisted variants and returns cleanup", () => { + const state = setup() + const unsub = state.variants.load() + expect(state.order).toEqual(["listen", "post"]) + expect(state.messages).toEqual([{ type: "requestVariants" }]) + unsub() + expect(state.order).toEqual(["listen", "post", "unsub"]) + }) + + it("loads global variants without restoring stale session variants", () => { + const state = setup() + state.variants.load() + state.dispatch({ + type: "variantsLoaded", + variants: { "agent/code/anthropic/claude-sonnet-4": "high", "session/old/model": "low" }, + }) + expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" }) + }) + + it("persists global selections but keeps session selections local", () => { + const global = setup() + global.variants.select("high") + expect(global.messages).toEqual([ + { type: "persistVariant", key: "agent/code/anthropic/claude-sonnet-4", value: "high" }, + ]) + + const scoped = setup("session-a") + scoped.variants.select("low") + expect(scoped.selections).toEqual({ "session/session-a/anthropic/claude-sonnet-4": "low" }) + expect(scoped.messages).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 030398cbedf..50aff3bff75 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -21,7 +21,6 @@ import type { AgentManagerKeybindingsMessage, AgentManagerMultiVersionProgressMessage, AgentManagerSendInitialMessage, - AgentManagerBranchesMessage, AgentManagerWorktreeDiffMessage, AgentManagerWorktreeDiffFileMessage, AgentManagerWorktreeDiffLoadingMessage, @@ -43,7 +42,6 @@ import type { SectionState, SessionInfo, SessionCreatedMessage, - BranchInfo, TerminalDestination, TerminalFont, } from "../src/types/messages" @@ -78,6 +76,7 @@ import { ProviderShell } from "../src/context/provider-shell" import { ChatView } from "../src/components/chat" import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" +import { DefaultBaseBranchDialog } from "./DefaultBaseBranchDialog" import { createModeRouter } from "./mode-router" import { ProjectList } from "./ProjectList" import { SidebarBody } from "./SidebarBody" @@ -152,7 +151,6 @@ import type { ReviewComment } from "../diff-viewer/review-comments" import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search" -import { BranchSelect } from "../src/components/shared/BranchSelect" import { randomColor } from "./section-colors" import { createNewTaskDrafts } from "./new-task-drafts" import { @@ -251,7 +249,6 @@ const AgentManagerContent: Component = () => { projectList().length === 0 || pid === undefined || pid === activeProjectId() const repoDefaultBranch = () => defaultBaseBranch() ?? repoDetectedBranch() ?? "main" - const hasConfiguredBranch = () => !!defaultBaseBranch() const DEFAULT_SIDEBAR_WIDTH = 260 const MIN_SIDEBAR_WIDTH = 200 @@ -1744,99 +1741,15 @@ const AgentManagerContent: Component = () => { const setupScript = metrics.click("configure_setup_script", "worktree_settings", handleConfigureSetupScript) const handleChangeDefaultBaseBranch = () => { - const [search, setSearch] = createSignal("") - const [branches, setBranches] = createSignal([]) - const [loading, setLoading] = createSignal(true) - const [highlighted, setHighlighted] = createSignal(-1) - - const unsub = vscode.onMessage((msg) => { - if (msg.type === "agentManager.branches") { - const ev = msg as AgentManagerBranchesMessage - setBranches(ev.branches) - if (ev.defaultBranch) setRepoDetectedBranch(ev.defaultBranch) - setLoading(false) - } - }) - - vscode.postMessage({ type: "agentManager.requestBranches" }) - - const filtered = createMemo(() => { - const s = search().toLowerCase() - if (!s) return branches() - return branches().filter((b) => b.name.toLowerCase().includes(s)) - }) - - const selectBranch = (name: string | undefined) => { - vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch: name }) - setDefaultBaseBranch(name) - dialog.close() - } - - const handleKeyDown = (e: KeyboardEvent) => { - const items = filtered() - // offset by 1 for auto-detect option (-1 = auto-detect) - const total = items.length + 1 - if (e.key === "ArrowDown") { - e.preventDefault() - e.stopPropagation() - setHighlighted((prev) => Math.min(prev + 1, total - 2)) - } else if (e.key === "ArrowUp") { - e.preventDefault() - e.stopPropagation() - setHighlighted((prev) => Math.max(prev - 1, -1)) - } else if (e.key === "Enter") { - e.preventDefault() - e.stopPropagation() - const idx = highlighted() - if (idx === -1) { - selectBranch(undefined) - } else { - const branch = items[idx] - if (branch) selectBranch(branch.name) - } - } else if (e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - dialog.close() - } - } - - dialog.show(() => { - onCleanup(unsub) - return ( - -
- { - setSearch(v) - setHighlighted(-1) - }} - onSelect={(b) => selectBranch(b.name)} - onSearchKeyDown={handleKeyDown} - selected={defaultBaseBranch()} - highlighted={highlighted()} - onHighlight={setHighlighted} - searchPlaceholder={t("agentManager.dialog.searchBranches")} - emptyLabel={t("agentManager.import.noMatchingBranches")} - loadingLabel={t("agentManager.import.loadingBranches")} - defaultLabel={t("agentManager.dialog.branchBadge.default")} - remoteLabel={t("agentManager.dialog.branchBadge.remote")} - defaultName={defaultBaseBranch()} - autoOption={{ - label: t("agentManager.worktree.defaultBaseBranchAuto"), - hint: repoDetectedBranch(), - active: !hasConfiguredBranch(), - highlighted: highlighted() === -1, - onSelect: () => selectBranch(undefined), - }} - /> -
-
- ) - }) + dialog.show(() => ( + dialog.close()} + /> + )) } const handleShowKeyboardShortcuts = () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DefaultBaseBranchDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DefaultBaseBranchDialog.tsx new file mode 100644 index 00000000000..4b4abbba1c7 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/DefaultBaseBranchDialog.tsx @@ -0,0 +1,110 @@ +/** @jsxImportSource solid-js */ + +import { createMemo, createSignal, onCleanup, type Component } from "solid-js" +import { Dialog } from "@kilocode/kilo-ui/dialog" +import { BranchSelect } from "../src/components/shared/BranchSelect" +import { useLanguage } from "../src/context/language" +import { useVSCode } from "../src/context/vscode" +import type { AgentManagerBranchesMessage, BranchInfo } from "../src/types/messages" + +interface Props { + selected?: string + detected?: string + onSelect: (branch?: string) => void + onDetected: (branch: string) => void + onClose: () => void +} + +export const DefaultBaseBranchDialog: Component = (props) => { + const { t } = useLanguage() + const vscode = useVSCode() + const [search, setSearch] = createSignal("") + const [branches, setBranches] = createSignal([]) + const [loading, setLoading] = createSignal(true) + const [highlighted, setHighlighted] = createSignal(-1) + const filtered = createMemo(() => { + const value = search().toLowerCase() + return value ? branches().filter((branch) => branch.name.toLowerCase().includes(value)) : branches() + }) + const select = (branch?: string) => { + vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch }) + props.onSelect(branch) + props.onClose() + } + const unsub = vscode.onMessage((message) => { + if (message.type !== "agentManager.branches") return + const event = message as AgentManagerBranchesMessage + setBranches(event.branches) + if (event.defaultBranch) props.onDetected(event.defaultBranch) + setLoading(false) + }) + onCleanup(unsub) + vscode.postMessage({ type: "agentManager.requestBranches" }) + + const keydown = (event: KeyboardEvent) => { + const items = filtered() + const total = items.length + 1 + if (event.key === "ArrowDown") { + event.preventDefault() + event.stopPropagation() + setHighlighted((value) => Math.min(value + 1, total - 2)) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + event.stopPropagation() + setHighlighted((value) => Math.max(value - 1, -1)) + return + } + if (event.key === "Enter") { + event.preventDefault() + event.stopPropagation() + const index = highlighted() + if (index === -1) { + select() + return + } + const branch = items[index] + if (branch) select(branch.name) + return + } + if (event.key !== "Escape") return + event.preventDefault() + event.stopPropagation() + props.onClose() + } + + return ( + +
+ { + setSearch(value) + setHighlighted(-1) + }} + onSelect={(branch) => select(branch.name)} + onSearchKeyDown={keydown} + selected={props.selected} + highlighted={highlighted()} + onHighlight={setHighlighted} + searchPlaceholder={t("agentManager.dialog.searchBranches")} + emptyLabel={t("agentManager.import.noMatchingBranches")} + loadingLabel={t("agentManager.import.loadingBranches")} + defaultLabel={t("agentManager.dialog.branchBadge.default")} + remoteLabel={t("agentManager.dialog.branchBadge.remote")} + defaultName={props.selected} + autoOption={{ + label: t("agentManager.worktree.defaultBaseBranchAuto"), + hint: props.detected, + active: !props.selected, + highlighted: highlighted() === -1, + onSelect: () => select(), + }} + /> +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-variants.ts b/packages/kilo-vscode/webview-ui/src/context/session-variants.ts new file mode 100644 index 00000000000..5902605944b --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-variants.ts @@ -0,0 +1,73 @@ +import type { Accessor } from "solid-js" +import type { ExtensionMessage, ModelSelection } from "../types/messages" +import { getAgentVariant, getVariant, preserveVariant, variantKey } from "./session-variant-store" + +interface Model { + variants?: Record +} + +type Message = { type: "requestVariants" } | { type: "persistVariant"; key: string; value: string } + +interface Options { + selections: Accessor> + set: (key: string, value: string) => void + selected: (sessionID?: string) => ModelSelection | null + session: Accessor + agent: (sessionID?: string) => string + find: (selection: ModelSelection) => Model | undefined + post: (message: Message) => void + listen: (handler: (message: ExtensionMessage) => void) => () => void +} + +export function createSessionVariants(options: Options) { + const list = (sessionID?: string) => { + const selection = options.selected(sessionID) + if (!selection) return [] + return Object.keys(options.find(selection)?.variants ?? {}) + } + + const agent = (name: string, selection: ModelSelection | null) => { + if (!selection) return undefined + return getAgentVariant(options.selections(), selection, options.find(selection), name) + } + + const current = (sessionID?: string) => { + const sid = sessionID ?? options.session() + const selection = options.selected(sid) + if (!selection) return undefined + const variants = list(sid) + if (variants.length === 0) return undefined + return getVariant(options.selections(), selection, variants, options.agent(sid), sid) + } + + const select = (value: string, sessionID?: string) => { + const sid = sessionID ?? options.session() + const selection = options.selected(sid) + if (!selection) return + const key = variantKey(selection, options.agent(sid), sid) + options.set(key, value) + if (!sid) options.post({ type: "persistVariant", key, value }) + } + + const carry = (selection: ModelSelection, value: string | undefined, name: string, sessionID?: string) => { + const next = preserveVariant(value, Object.keys(options.find(selection)?.variants ?? {})) + if (!next) return + const key = variantKey(selection, name, sessionID) + options.set(key, next) + if (!sessionID) options.post({ type: "persistVariant", key, value: next }) + } + + const load = () => { + const unsub = options.listen((message) => { + if (message.type !== "variantsLoaded") return + for (const [key, value] of Object.entries(message.variants)) { + if (key.startsWith("session/")) continue + options.set(key, value) + } + }) + options.post({ type: "requestVariants" }) + return unsub + } + + return { carry, list, agent, current, select, load } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 839b0dc3f50..1e09519fa5d 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -74,14 +74,8 @@ import { errorIDs } from "./session-errors" import { PartStash } from "./part-stash" import { mergeParts, sameParts } from "./session-parts" import { state as todoState } from "./todo-revert" -import { - getAgentVariant, - getVariant, - preserveVariant, - sessionVariantKeys, - transferVariants, - variantKey, -} from "./session-variant-store" +import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store" +import { createSessionVariants } from "./session-variants" import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model" import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments" import { visibleMessages as filterVisibleMessages } from "./session-queue" @@ -670,13 +664,18 @@ export const SessionProvider: ParentComponent = (props) => { }) } - function carryVariant(selection: ModelSelection, current: string | undefined, agent: string, sessionID?: string) { - const value = preserveVariant(current, Object.keys(provider.findModel(selection)?.variants ?? {})) - if (!value) return - const key = variantKey(selection, agent, sessionID) - setStore("variantSelections", key, value) - if (!sessionID) vscode.postMessage({ type: "persistVariant", key, value }) - } + const variants = createSessionVariants({ + selections: () => store.variantSelections, + set: (key, value) => setStore("variantSelections", key, value), + selected, + session: currentSessionID, + agent: agentForScope, + find: provider.findModel, + post: vscode.postMessage, + listen: vscode.onMessage, + }) + const { carry: carryVariant, list: variantList, agent: variantForAgent, current: currentVariant } = variants + const selectVariant = variants.select function selectModel(providerID: string, modelID: string, sessionID?: string) { const sid = sessionID ?? currentSessionID() @@ -930,50 +929,7 @@ export const SessionProvider: ParentComponent = (props) => { clearTimeout(fallback) }) - const variantList = (sessionID?: string) => { - const sel = selected(sessionID) - if (!sel) return [] - const model = provider.findModel(sel) - if (!model?.variants) return [] - return Object.keys(model.variants) - } - - function variantForAgent(agentName: string, sel: ModelSelection | null) { - if (!sel) return undefined - const model = provider.findModel(sel) - return getAgentVariant(store.variantSelections, sel, model, agentName) - } - - const currentVariant = (sessionID?: string) => { - const sid = sessionID ?? currentSessionID() - const sel = selected(sid) - if (!sel) return undefined - const list = variantList(sid) - if (list.length === 0) return undefined - return getVariant(store.variantSelections, sel, list, agentForScope(sid), sid) - } - - const selectVariant = (value: string, sessionID?: string) => { - const sid = sessionID ?? currentSessionID() - const sel = selected(sid) - if (!sel) return - const key = variantKey(sel, agentForScope(sid), sid) - setStore("variantSelections", key, value) - if (!sid) vscode.postMessage({ type: "persistVariant", key, value }) - } - - // Load persisted variants from extension globalState - const unsubVariants = vscode.onMessage((message: ExtensionMessage) => { - if (message.type !== "variantsLoaded") return - for (const [k, v] of Object.entries(message.variants)) { - if (k.startsWith("session/")) continue - setStore("variantSelections", k, v) - } - }) - - vscode.postMessage({ type: "requestVariants" }) - - onCleanup(unsubVariants) + onCleanup(variants.load()) // Load persisted per-mode model selections from model.json via extension host. // Uses replace semantics so a reset (empty payload) clears old entries.