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.
This commit is contained in:
marius-kilocode
2026-07-28 13:17:28 +02:00
parent 2108dc5885
commit c9d59c4a87
11 changed files with 193 additions and 47 deletions
@@ -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-")
@@ -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>): AgentManagerStateMessage {
@@ -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[] = []
@@ -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([])
})
})
@@ -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<string, AgentManagerStateMessage>()
/** 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(() => {
@@ -17,7 +17,7 @@ import type {
PRStatus,
ProjectSessionInfo,
WorktreeGitStats,
} from "../src/types/messages"
} from "../../src/types/messages"
export interface ProjectLiveShared {
stats: (map: Record<string, WorktreeGitStats>) => void
@@ -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[] = []
@@ -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
@@ -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<typeof rememberTarget>[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())
})
}
@@ -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
@@ -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<string>) => void
}) {
/** States waiting for their project to become catalog-active before applying. */
const pending = new Map<string, AgentManagerStateMessage>()
/** 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 }
}