From c9d59c4a87b7c7bccc1730917b466191f4c934f8 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 28 Jul 2026 13:17:28 +0200 Subject: [PATCH] refactor(agent-manager): extract project state router and selection modules Group the multi-project webview logic under agent-manager/project/: - state.ts: createProjectStateRouter, the catalog/state ordering and deferral logic, now unit-tested (state-before-catalog, catalog-first, pending flush, removal pruning) - selection.ts: selection ack application plus the target rememberer - restore.ts, live.ts, sessions-live.ts, local-tabs.ts: moved as-is No behavior change. --- .../tests/unit/agent-local-tabs-store.test.ts | 2 +- .../tests/unit/agent-project-restore.test.ts | 2 +- .../agent-project-selection-webview.test.ts | 2 +- .../unit/agent-project-state-router.test.ts | 68 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 60 +++++++--------- .../{project-live.ts => project/live.ts} | 2 +- .../local-tabs.ts} | 2 +- .../restore.ts} | 8 ++- .../selection.ts} | 32 ++++++++- .../sessions-live.ts} | 6 +- .../webview-ui/agent-manager/project/state.ts | 56 +++++++++++++++ 11 files changed, 193 insertions(+), 47 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/agent-project-state-router.test.ts rename packages/kilo-vscode/webview-ui/agent-manager/{project-live.ts => project/live.ts} (98%) rename packages/kilo-vscode/webview-ui/agent-manager/{local-tabs-store.ts => project/local-tabs.ts} (97%) rename packages/kilo-vscode/webview-ui/agent-manager/{project-restore.ts => project/restore.ts} (93%) rename packages/kilo-vscode/webview-ui/agent-manager/{project-selection.ts => project/selection.ts} (55%) rename packages/kilo-vscode/webview-ui/agent-manager/{project-sessions-live.ts => project/sessions-live.ts} (88%) create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/project/state.ts diff --git a/packages/kilo-vscode/tests/unit/agent-local-tabs-store.test.ts b/packages/kilo-vscode/tests/unit/agent-local-tabs-store.test.ts index ce85b5a8f1..7e1b10cec1 100644 --- a/packages/kilo-vscode/tests/unit/agent-local-tabs-store.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-local-tabs-store.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test" import { createRoot } from "solid-js" -import { createLocalTabs } from "../../webview-ui/agent-manager/local-tabs-store" +import { createLocalTabs } from "../../webview-ui/agent-manager/project/local-tabs" const pending = (id: string) => id.startsWith("pending-") diff --git a/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts b/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts index 407f5de28e..0b050648a0 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { rememberTarget, restoreProjectTarget, type RestoreDeps } from "../../webview-ui/agent-manager/project-restore" +import { rememberTarget, restoreProjectTarget, type RestoreDeps } from "../../webview-ui/agent-manager/project/restore" import type { AgentManagerStateMessage } from "../../webview-ui/src/types/messages" function state(over: Partial): AgentManagerStateMessage { diff --git a/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts b/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts index 1ef9b74ab4..146a738be6 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { applyProjectSelection } from "../../webview-ui/agent-manager/project-selection" +import { applyProjectSelection } from "../../webview-ui/agent-manager/project/selection" function deps(active: string) { const calls: string[] = [] diff --git a/packages/kilo-vscode/tests/unit/agent-project-state-router.test.ts b/packages/kilo-vscode/tests/unit/agent-project-state-router.test.ts new file mode 100644 index 0000000000..ecc57fe54f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-state-router.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test" +import { createProjectStateRouter } from "../../webview-ui/agent-manager/project/state" +import type { AgentManagerStateMessage, AgentProjectSnapshot } from "../../src/types/messages" + +const state = (projectId: string) => ({ type: "agentManager.state", projectId }) as AgentManagerStateMessage +const project = (id: string, active = false) => ({ id, label: id, root: `/repo/${id}`, active }) as AgentProjectSnapshot + +function setup(catalog: AgentProjectSnapshot[] = []) { + const calls: { applied: string[]; pruned: string[][] } = { applied: [], pruned: [] } + let list = catalog + const router = createProjectStateRouter({ + catalog: () => list, + apply: (s) => calls.applied.push(s.projectId ?? ""), + pruneLive: (ids) => calls.pruned.push([...ids]), + }) + return { router, calls, set: (projects: AgentProjectSnapshot[]) => (list = projects) } +} + +describe("createProjectStateRouter", () => { + it("applies state directly when the catalog is empty (legacy mode)", () => { + const { router, calls } = setup() + expect(router.routeState(state("prj-a"))).toBe("applied") + expect(calls.applied).toEqual(["prj-a"]) + }) + + it("defers state that arrives before its catalog activation and flushes it", () => { + const { router, calls, set } = setup([project("prj-a", true), project("prj-b")]) + + expect(router.routeState(state("prj-b"))).toBe("deferred") + expect(calls.applied).toEqual([]) + + set([project("prj-a"), project("prj-b", true)]) + router.routeCatalog([project("prj-a"), project("prj-b", true)]) + expect(calls.applied).toEqual(["prj-b"]) + }) + + it("applies state immediately when its project is already catalog-active", () => { + const { router, calls } = setup([project("prj-a", true), project("prj-b")]) + expect(router.routeState(state("prj-a"))).toBe("applied") + expect(calls.applied).toEqual(["prj-a"]) + }) + + it("keeps the newest deferred state per project", () => { + const { router, calls, set } = setup([project("prj-a", true)]) + router.routeState({ ...state("prj-b"), worktrees: [{ id: "1" }] } as never) + router.routeState({ ...state("prj-b"), worktrees: [{ id: "1" }, { id: "2" }] } as never) + set([project("prj-b", true)]) + router.routeCatalog([project("prj-b", true)]) + expect(calls.applied).toHaveLength(1) + }) + + it("drops deferred state for projects removed from the catalog", () => { + const { router, calls, set } = setup([project("prj-a", true), project("prj-b")]) + router.routeState(state("prj-b")) + set([project("prj-a", true)]) + router.routeCatalog([project("prj-a", true)]) + expect(calls.applied).toEqual([]) + expect(calls.pruned.at(-1)).toEqual(["prj-a"]) + }) + + it("does not flush deferred state for a project that is not the active one", () => { + const { router, calls, set } = setup([project("prj-a", true), project("prj-b")]) + router.routeState(state("prj-b")) + set([project("prj-c", true)]) + router.routeCatalog([project("prj-a"), project("prj-b"), project("prj-c", true)]) + expect(calls.applied).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 512d8c3f75..b74cad2a27 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -86,11 +86,12 @@ import { NewWorktreeDialog } from "./NewWorktreeDialog" import { ProjectList } from "./ProjectList" import { SidebarBody } from "./SidebarBody" import { TabBar } from "./TabBar" -import { createProjectLive } from "./project-live" -import { createProjectSessionsLive } from "./project-sessions-live" -import { applyProjectSelection } from "./project-selection" -import { createLocalSessions, createLocalTabs, persistLocalTabs, type PersistedLocalTabs } from "./local-tabs-store" -import { rememberTarget, restoreProjectTarget } from "./project-restore" +import { createProjectLive } from "./project/live" +import { createProjectSessionsLive } from "./project/sessions-live" +import { applyProjectSelection, createTargetRememberer } from "./project/selection" +import { createLocalSessions, createLocalTabs, persistLocalTabs, type PersistedLocalTabs } from "./project/local-tabs" +import { rememberTarget, restoreProjectTarget } from "./project/restore" +import { createProjectStateRouter } from "./project/state" import { selectLocalAction, selectWorktreeAction } from "./selection-actions" import { DataBridge, MermaidDownloadBridge } from "../src/App" import { LanguageBridge } from "../src/context/language-bridge" @@ -1097,6 +1098,12 @@ const AgentManagerContent: Component = () => { setRunStatuses(map) } + const router = createProjectStateRouter({ + catalog: projectList, + apply: (state) => applyActiveState(state), + pruneLive: (ids) => projectLive.prune(ids), + }) + /** Store the project catalog pushed by the extension and drop states of removed projects. */ const applyProjects = (msg: ExtensionMessage) => { if (msg.type !== "agentManager.projects") return @@ -1105,34 +1112,18 @@ const AgentManagerContent: Component = () => { setProjectList(ev.projects) const ids = new Set(ev.projects.map((p) => p.id)) setProjectStates((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))) - projectLive.prune(ids) - for (const id of [...pendingState.keys()]) if (!ids.has(id)) pendingState.delete(id) - const active = ev.projects.find((p) => p.active)?.id - const pending = active ? pendingState.get(active) : undefined - if (pending) { - pendingState.delete(active!) - applyActiveState(pending) - } + router.routeCatalog(ev.projects) } - /** States waiting for their project to become catalog-active before applying. */ - const pendingState = new Map() - /** Apply one project state payload. Background payloads only feed their accordion summary. */ const applyState = (msg: ExtensionMessage) => { if (msg.type !== "agentManager.state") return const state = msg as AgentManagerStateMessage const pid = state.projectId if (pid) setProjectStates((prev) => ({ ...prev, [pid]: state })) - // Background project payloads feed only their accordion summary; the - // shared signals below belong to the active project alone. On activation - // the state push can arrive before the catalog push, so defer it instead - // of dropping it. - if (!isActivePayload(pid)) { - if (pid) pendingState.set(pid, state) - return - } - applyActiveState(state) + // Background payloads feed only their accordion summary; on activation the + // state push can arrive before the catalog push, so the router defers it. + router.routeState(state) } /** Apply a state payload to the shared signals of the active project. */ @@ -1218,17 +1209,14 @@ const AgentManagerContent: Component = () => { if (switched && localSessionIDs().length === 0) addPendingTab() } - /** Persist the current selection per project so switching back restores it. */ - createEffect(() => { - const pid = activeProjectId() - if (!pid || !multiProject()) return - // activeProjectId flips with the catalog push, before the new project's - // state is applied. In that window selection() still belongs to the - // previous project, so persisting it would poison this project's target. - if (currentProjectId() !== pid) return - const sel = selection() - if (sel && sel !== LOCAL && !worktrees().some((wt) => wt.id === sel)) return - rememberTarget(vscode.postMessage, pid, sel, session.currentSessionID()) + createTargetRememberer({ + pid: activeProjectId, + enabled: multiProject, + applied: currentProjectId, + selection, + owns: (sel) => worktrees().some((wt) => wt.id === sel), + sessionId: session.currentSessionID, + post: vscode.postMessage, }) onMount(() => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project-live.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/live.ts similarity index 98% rename from packages/kilo-vscode/webview-ui/agent-manager/project-live.ts rename to packages/kilo-vscode/webview-ui/agent-manager/project/live.ts index 3fc8ea12d3..51b69f61ca 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project-live.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/live.ts @@ -17,7 +17,7 @@ import type { PRStatus, ProjectSessionInfo, WorktreeGitStats, -} from "../src/types/messages" +} from "../../src/types/messages" export interface ProjectLiveShared { stats: (map: Record) => void diff --git a/packages/kilo-vscode/webview-ui/agent-manager/local-tabs-store.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/local-tabs.ts similarity index 97% rename from packages/kilo-vscode/webview-ui/agent-manager/local-tabs-store.ts rename to packages/kilo-vscode/webview-ui/agent-manager/project/local-tabs.ts index acd32d027d..660e0727ef 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/local-tabs-store.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/local-tabs.ts @@ -1,5 +1,5 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import type { SessionInfo } from "../src/types/messages/sessions" +import type { SessionInfo } from "../../src/types/messages/sessions" const EMPTY: string[] = [] diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project-restore.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/restore.ts similarity index 93% rename from packages/kilo-vscode/webview-ui/agent-manager/project-restore.ts rename to packages/kilo-vscode/webview-ui/agent-manager/project/restore.ts index 62ca574126..cf551bea4d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project-restore.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/restore.ts @@ -8,8 +8,12 @@ * to Local with a fresh "New Session" draft. */ -import type { AgentManagerStateMessage, AgentManagerSidebarTarget, RememberTargetMessage } from "../src/types/messages" -import { LOCAL } from "./navigate" +import type { + AgentManagerStateMessage, + AgentManagerSidebarTarget, + RememberTargetMessage, +} from "../../src/types/messages" +import { LOCAL } from "../navigate" export interface RestoreDeps { selectLocal: () => void diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project-selection.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/selection.ts similarity index 55% rename from packages/kilo-vscode/webview-ui/agent-manager/project-selection.ts rename to packages/kilo-vscode/webview-ui/agent-manager/project/selection.ts index 776e65417c..e2b9ef94b1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project-selection.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/selection.ts @@ -1,4 +1,7 @@ -import type { ExtensionMessage, ManagedSessionState } from "../src/types/messages" +import { createEffect } from "solid-js" +import type { ExtensionMessage, ManagedSessionState } from "../../src/types/messages" +import { LOCAL } from "../navigate" +import { rememberTarget } from "./restore" export function applyProjectSelection( msg: ExtensionMessage, @@ -34,3 +37,30 @@ export function applyProjectSelection( } return true } + +/** + * Persist the current selection for the applied project so switching back + * restores it. Skips while the catalog and applied project disagree (the + * switch window), and never persists a selection the project does not own. + */ +export function createTargetRememberer(opts: { + pid: () => string | undefined + enabled: () => boolean + applied: () => string | undefined + selection: () => string | null + owns: (sel: string) => boolean + sessionId: () => string | undefined + post: Parameters[0] +}): void { + createEffect(() => { + const pid = opts.pid() + if (!pid || !opts.enabled()) return + // pid flips with the catalog push, before the new project's state is + // applied. In that window selection() still belongs to the previous + // project, so persisting it would poison this project's target. + if (opts.applied() !== pid) return + const sel = opts.selection() + if (sel && sel !== LOCAL && !opts.owns(sel)) return + rememberTarget(opts.post, pid, sel, opts.sessionId()) + }) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project-sessions-live.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/sessions-live.ts similarity index 88% rename from packages/kilo-vscode/webview-ui/agent-manager/project-sessions-live.ts rename to packages/kilo-vscode/webview-ui/agent-manager/project/sessions-live.ts index 6349d23cbc..4882fc8dcf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project-sessions-live.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/sessions-live.ts @@ -1,7 +1,7 @@ import { createMemo } from "solid-js" -import type { ManagedSessionState } from "../src/types/messages/agent-manager" -import type { ProjectSessionInfo, SessionInfo } from "../src/types/messages/sessions" -import { isKnownRootSession } from "./navigate" +import type { ManagedSessionState } from "../../src/types/messages/agent-manager" +import type { ProjectSessionInfo, SessionInfo } from "../../src/types/messages/sessions" +import { isKnownRootSession } from "../navigate" /** * Sidebar session lists per project. Background projects use the pushed diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/state.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/state.ts new file mode 100644 index 0000000000..36a0b3f6df --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/state.ts @@ -0,0 +1,56 @@ +import type { AgentManagerStateMessage, AgentProjectSnapshot } from "../../src/types/messages" + +/** + * Route project state payloads to the shared signals. + * + * On project activation the extension pushes the new project's state before + * the catalog marks it active, so a naive "is this the active project" gate + * would drop the payload entirely. The router defers such payloads and flushes + * them when the catalog confirms the activation, making the webview robust to + * either ordering (state-before-catalog or catalog-before-state). + */ +export function createProjectStateRouter(deps: { + /** Catalog entries as last pushed by the extension (empty before the first push). */ + catalog: () => readonly AgentProjectSnapshot[] + /** Apply a state payload to the shared signals of the active project. */ + apply: (state: AgentManagerStateMessage) => void + /** Prune live session caches for projects that left the catalog. */ + pruneLive: (ids: Set) => void +}) { + /** States waiting for their project to become catalog-active before applying. */ + const pending = new Map() + + /** Whether a payload for the project would apply to the shared signals right now. */ + const isActive = (pid: string | undefined): boolean => { + const catalog = deps.catalog() + if (catalog.length === 0) return true + return pid !== undefined && catalog.some((p) => p.active && p.id === pid) + } + + /** Route a state payload: apply immediately, or defer for the next catalog push. */ + const routeState = (state: AgentManagerStateMessage): "applied" | "deferred" => { + if (isActive(state.projectId)) { + deps.apply(state) + return "applied" + } + if (state.projectId) pending.set(state.projectId, state) + return "deferred" + } + + /** + * Route a catalog push. Prunes caches for removed projects, then flushes a + * deferred state for the newly active project, if one is waiting. + */ + const routeCatalog = (projects: readonly AgentProjectSnapshot[]): void => { + const ids = new Set(projects.map((p) => p.id)) + deps.pruneLive(ids) + for (const id of [...pending.keys()]) if (!ids.has(id)) pending.delete(id) + const active = projects.find((p) => p.active)?.id + const state = active ? pending.get(active) : undefined + if (!state) return + pending.delete(active!) + deps.apply(state) + } + + return { isActive, routeState, routeCatalog } +}