mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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(() => {
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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[] = []
|
||||
|
||||
+6
-2
@@ -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
|
||||
+31
-1
@@ -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())
|
||||
})
|
||||
}
|
||||
+3
-3
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user