.kilo/plans/agent-manager-multi-project-uniform-ui.md
Status: Ready, corrected architecture; implementation pending
Date: 2026-07-22
This plan supersedes the active-body plus background-summary UI described in agent-manager-multi-project-runtime.md. The existing project registry, immutable project-root concept, per-project state payloads, and project-tagged stats work are useful foundations. The current read-only ProjectSummary and active-project dynamic getter routing are not the target architecture.
Configuration reads, writes, scopes, drafts, trust, revisions, and release-blocking tests are specified canonically in agent-manager-multi-project-configuration.md. If this plan and that document differ on configuration behavior, the configuration document wins.
The step-by-step implementation sequence from the current branch is agent-manager-multi-project-implementation-handoff.md. Implementers should follow that handoff slice by slice rather than treating this architecture document as an unordered task list.
Every expanded project permanently renders the real existing Agent Manager sidebar UI:
Selecting or interacting with a project may change the shared detail pane and top toolbar. It must not remove, replace, downgrade, or remount any expanded project body. There is no active-body versus background-summary rendering mode.
The shared detail pane remains singular. The sidebar displays all expanded projects concurrently; the detail pane displays one selected project/local/worktree/session target at a time.
The current implementation is a valid partial runtime foundation, but it is not safe to extend directly into fully interactive background cards.
Keep and evolve:
ProjectRegistry and the pinned-workspace project concept;ProjectContext;initContextState extraction;WorktreeItem, SectionHeader, WorktreeSectionActions, and UnassignedSessionsSection components;Replace or refactor before exposing interactive multi-project UI:
ProjectSummary; do not add more behavior to it;project.active renders renderBody();contexts.active() and optional projectId;KiloProvider project/session-route aware.A project is one canonical Git repository root in the current extension host.
type ProjectId = string
interface ProjectRef {
projectId: ProjectId
}
interface WorktreeRef extends ProjectRef {
worktreeId: string
}
interface SessionRef extends ProjectRef {
sessionId: string
}
type SidebarTarget =
| { projectId: ProjectId; kind: "local" }
| { projectId: ProjectId; kind: "worktree"; worktreeId: string }
| { projectId: ProjectId; kind: "session"; sessionId: string }
Raw backend and repository-local IDs remain unchanged. Composite references are required at every panel/runtime boundary so identical local sentinels, worktree IDs, section IDs, or session IDs cannot cross projects.
Project identity must include the extension-host URI scheme and authority in addition to the canonical root. Adding a project must also resolve canonical git-common-dir and reject another exposed project sharing that common Git directory. Linked worktrees of one repository cannot be registered as independent projects in the first release.
activeProjectId is not a rendering mode and not an implicit mutation target.
It means:
KiloProvider.It never selects a Settings write target. Settings bindings are explicit and remain unchanged when detail selection changes.
Project expansion controls rendering. Every project.expanded project renders one stable ProjectSidebarBody keyed by projectId, regardless of active status.
Do not send selectProject followed by an unqualified worktree/session action. VS Code message handlers are asynchronous and are not a transaction queue.
Use one atomic selection intent:
interface ActivateSelectionMessage {
type: "agentManager.activateSelection"
target: SidebarTarget
}
The coordinator validates the project and target, ensures the context is ready, activates the project-specific Kilo route, then emits one project-qualified activation result. The sidebar body stays mounted; only shared detail state changes.
Mutations that do not need the detail pane, such as rename, delete, section edits, run, setup, open window, PR actions, or worktree creation, execute directly against their explicit ProjectRef or WorktreeRef. They do not activate another project as a side effect.
See agent-manager-multi-project-configuration.md for the complete contract.
The current config protocol is unsafe for multi-project use:
KiloProvider.fetchAndSendConfig() resolves a mutable current directory and emits one unqualified configLoaded value (packages/kilo-vscode/src/KiloProvider.ts:2513).ConfigProvider owns one global/project/effective draft and sends an unqualified updateConfig (packages/kilo-vscode/webview-ui/src/context/config.tsx:54, :243).KiloProvider.handleUpdateConfig() resolves getWorkspaceDirectory() again at save time (packages/kilo-vscode/src/KiloProvider.ts:2982). A draft loaded for project A can therefore be written to project B after detail activation changes.packages/opencode/src/config/config.ts:944, packages/opencode/src/kilocode/config/config.ts:67). Concurrent editors can overwrite one another, and a newly created higher-priority config file can redirect a pending write.commit_message and indexing.enabled to the project while most fields go global (packages/kilo-vscode/webview-ui/src/utils/config-scope.ts:3). The UI does not make this target choice explicit.saveProject() using the mutable provider workspace directory (packages/kilo-vscode/src/provider-actions.ts:252). Disabling only handleUpdateConfig() is insufficient.This is a release blocker, not a follow-up cleanup.
Preserve useful project-local editing such as commit_message.prompt and repository indexing rules. Indexing enablement is machine-local project consent, default off, and cannot be granted by repository config. Replace hidden active-directory targeting with explicit User | Project scope, a required Settings project selector for Project scope, an opaque immutable binding, and optimistic concurrency by target revision.
Do not maintain one write model for flag-off mode and another for multi-project mode. Single-project mode uses the same explicit protocol, with a narrow adapter that injects the pinned ProjectRef for reads and open-file actions.
The current ConfigProvider conflates two different state machines. Split them:
{ projectId, directory, activationGeneration }, follows atomic detail activation, and drives chat, providers, agents, features, display, MCP, indexing, and sandbox behavior.Use distinct messages. Names may follow repository conventions, but the fields and invariants are mandatory:
interface RuntimeConfigLoaded {
type: "runtimeConfigLoaded"
route: {
projectId: ProjectId
directory: string
activationGeneration: number
}
config: Config
features: FeatureFlags
}
type ConfigScope = "global" | "project"
interface ConfigTarget {
scope: ConfigScope
path: string
revision: string
exists: boolean
writable: boolean
}
interface SettingsBinding {
id: string
connectionGeneration: number
scope: ConfigScope
project?: {
projectId: ProjectId
root: string
generation: number
}
directory: string
target: ConfigTarget
}
interface ReadSettingsConfig {
type: "settingsConfig.read"
requestId: string
scope: ConfigScope
projectId?: ProjectId
}
interface SettingsConfigSnapshot {
type: "settingsConfig.snapshot"
requestId: string
binding: SettingsBinding
preview?: {
projectId: ProjectId
root: string
generation: number
directory: string
}
targetConfig: Config
effective: Config
global: Config
project: Config
fields: Record<string, ResolvedConfigField>
collections: Record<string, ResolvedConfigField[]>
}
interface WriteSettingsConfig {
type: "settingsConfig.write"
requestId: string
bindingId: string
set: Partial<Config>
unset: string[][]
}
For a global read, projectId is optional preview context used only to explain project shadowing. It never changes the global target. A project read requires projectId and always resolves the immutable ProjectContext.root, not the active worktree or session.
For User scope, preview identity is presentation state, not part of the editable target identity. The controller keeps the global target binding and draft separate from the latest project preview. A clean preview change may replace both from one fresh snapshot. A dirty preview change updates only preview, effective/source metadata, and project raw data. It retains the original global target revision; if the fresh read reports a different global path or revision, mark the draft stale instead of silently rebasing it.
targetConfig is the parsed raw content of the one file the API will patch. It is not the aggregate global or project layer. The form edits this raw target value so it never copies values inherited from another file into the target. The aggregate values remain in the snapshot solely for source, inheritance, and effective-preview UI. Existing JSONC comments remain server-side raw text and are preserved by patching; the webview does not round-trip serialized file contents.
Parse targetConfig from raw JSONC without expanding {env:} or {file:} variables, so the Settings protocol never turns placeholders into secret values. If the exact target is syntactically or structurally invalid, return diagnostics and writable: false for form editing rather than treating it as {} and overwriting it. The recovery action is Open file. Source metadata must cover every path rendered by Settings, not only the current hand-maintained overlay field list.
The extension owns an in-memory binding registry. Binding IDs are opaque, unpredictable, and scoped to one provider/webview lifecycle. The webview returns only bindingId, not a client-authoritative path or project envelope. On write the extension must:
bindingId and reject unknown or expired bindings;await;getWorkspaceDirectory(), contexts.active(), or any fallback;{ requestId, binding.id } only.A binding expires after a successful save, backend reconnect, trust revocation, project removal, or context generation change. Success returns a new snapshot and binding. Cached snapshots are keyed by binding/scope/project identity; there is no singleton cachedConfigMessage for Settings.
Evolve the Kilo-owned /config/overlay API rather than adding project identity to generic Config.update. The backend does not know Agent Manager projectId; it enforces the routed directory and filesystem target while the extension enforces project identity.
GET /config/overlay continues to take explicit directory and scope, but returns:
interface ConfigOverlaySnapshot {
context: { directory: string; worktree?: string }
scope: ConfigScope
targetConfig: Config
effective: Config
global: Config
project: Config
sources: ConfigSource[]
targets: {
global: ConfigTarget
project: ConfigTarget
active: ConfigTarget
}
fields: Record<string, ResolvedConfigField>
collections: Record<string, ResolvedConfigField[]>
}
The revision is a SHA-256 fingerprint of scope, canonical target path, existence marker, and exact file bytes. It is not an mtime. Exact bytes are required so comment-only JSONC edits and delete/recreate cycles with different content cause conflicts. The missing-file revision includes the intended canonical path. An ABA delete/recreate with identical bytes may retain the same revision; that state is content-equivalent and does not lose a user edit.
PATCH /config/overlay?directory=... accepts exactly one scope per request:
interface ConfigOverlayWrite {
scope: ConfigScope
set?: Record<string, unknown>
unset?: string[][]
expected: {
path: string
revision: string
}
}
interface ConfigOverlayWriteResult {
outcome: "applied" | "applied_but_overridden"
changed: boolean
overriddenPaths: string[][]
snapshot: ConfigOverlaySnapshot
}
The server performs the following transaction:
expected.path as an arbitrary destination.set/unset to the raw target layer, preserve JSONC comments where supported, validate the result, and atomically replace the target using a temporary file in the same directory. Do not expose a partially written config.scope, routed directory, canonical target, and new revision. Global changes invalidate all runtime config caches; project changes invalidate only matching directory/project caches.applied_but_overridden is success. For each set leaf, the refreshed overlay identifies whether a higher layer still wins, such as project config shadowing a user value or managed/runtime config shadowing a project value. The UI keeps the saved value and explains why effective behavior did not change.
Do not send global and project patches in one webview message or pretend that two files save atomically. User and Project scope maintain independent drafts and save independently. VS Code extension preferences such as autocomplete settings are a third, visibly separate store with their own request acknowledgements.
Domain actions outside Settings must not perform client-side read-modify-write against a mutable effective config. Provider/custom-provider/work-style actions are User-global operations and should call either the same revision-aware User endpoint or a narrow backend mutation that merges named fields under the target lock. Permission Dock "always" rules remain a narrow global rule mutation tied to the exact pending request/session route; that session directory validates the request but does not select a project config target. All such mutations emit the revisioned config event, so a dirty Settings editor becomes stale rather than being overwritten.
Expected typed failures are:
unknown_project or stale_project_generation from the extension;untrusted_project or workspace_untrusted from the extension;binding_mismatch or binding_expired from the extension;config_target_changed with the current target descriptor;config_revision_conflict with the current target descriptor and fresh snapshot when available;config_target_not_writable or project target escaping its trusted root;config_invalid with schema/parse details;Every failure preserves the draft and names the scope/project/path. A config event is never treated as confirmation of the user's save; only a matching write response may clear that draft.
Save, Discard, or Stay. Drafts are never carried to a different project.requestId.Reload, Open file, and Discard; it does not auto-merge or force overwrite. A future three-way merge may use the original snapshot, fresh snapshot, and draft.The first release presents explicit User | Project scope, a required selector in Project scope, source badges, and separate binding-keyed drafts. The selected scope applies to the entire operation. Remove hidden splitConfigByScope; no setting silently chooses a different file. Inherited user values remain visible in Project scope with Override/Reset to inherited affordances backed by set/unset.
ProjectContext.root. It never targets the last selected Local/worktree/session card.WorktreeRef and its own immutable binding..kilo/.kilocode config according to current precedence, but the expected target must remain within the trusted project checkout after canonical/symlink resolution. A symlink escape is read-only and must not be patched through the API.realpath. For a missing target, canonicalize its nearest existing ancestor and append the remaining path components before checking containment. Global writes must similarly remain under the canonical Global.Path.config root. Do not rely on a lexical prefix check.pinned as an unconditional trust grant.packages/opencode/src/kilocode/config/overlay.ts, replace path-only targets with revisioned target descriptors and add generic changed-path source resolution.packages/opencode/src/kilocode/config/ that resolves, confines, locks, fingerprints, patches, validates, and atomically replaces one target. Keep shared packages/opencode/src/config/config.ts changes to minimal marked delegation/invalidation hooks.packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts and handlers/config-console.ts with the write precondition, result, and typed error contracts. Regenerate packages/sdk/js/.packages/kilo-vscode/src/kilo-provider/ and have KiloProvider.ts delegate reads/writes to it. The controller receives explicit project-route resolution; it never reads mutable active workspace state during a bound operation.configLoaded, configUpdated, and unqualified updateConfig in webview-ui/src/types/messages/ with runtime and Settings message families. Keep a temporary single-project adapter only at the protocol edge.webview-ui/src/context/config.tsx into activation-bound runtime state and binding-keyed Settings editor state. Settings components consume the editor context; non-settings consumers continue to use runtime-effective config.webview-ui/src/utils/config-scope.ts after migrating every control to explicit scope. Preserve explicit project editing for commit messages and repository indexing rules; move indexing enablement to machine-local project consent.ProjectRef, resolve ProjectContext.root, verify trust, and show the exact path. For a missing target, open an unsaved document at that explicit path or require an explicit create action; never create it merely by visiting Settings.Focused tests belong in:
packages/opencode/test/kilocode/server/config-overlay.test.ts for raw target snapshots, target changes, 409 revisions, source shadowing, locks, symlink confinement, and response outcomes;packages/opencode/test/kilocode/project-config-update.test.ts for target selection and atomic writes from repository roots/nested directories;packages/kilo-vscode/tests/unit/config-utils.test.ts, replacing singleton ConfigState assumptions with binding/request-aware draft tests;packages/kilo-vscode/tests/unit/config-scope.test.ts, deleted when splitConfigByScope is removed and replaced by tests proving one explicit scope per save;.kilo/agent-manager.json per projectYes, every project has its own repository-local state file:
project-a/.kilo/agent-manager.json
project-b/.kilo/agent-manager.json
project-c/.kilo/agent-manager.json
This is already structurally supported because WorktreeStateManager derives its file from its immutable constructor root. The final architecture makes this ownership explicit and safe.
Each ProjectContext owns exactly one:
WorktreeStateManager(root) writing only <root>/.kilo/agent-manager.json;WorktreeManager(root);SetupScriptService(root) and project run service;The global project registry stores descriptors only. It never stores worktrees, sections, sessions, tab order, or repository behavior. The pinned project is derived from the current workspace and remains outside the registry.
The first explicit expansion of a trusted project calls one single-flight ensureReady() promise:
.kilo/agent-manager.json;Repeated expansion must reuse the same initialization promise. Collapse/removal during initialization records the desired lifecycle transition and prevents late completion from posting or mutating a replacement context.
All mutations for one project execute through that context's serialized mutation queue. An operation captures the context and generation before its first await and never re-resolves through contexts.active() afterward.
Collapse:
Removal:
.kilo/agent-manager.json, worktrees, branches, or sessions;Panel disposal awaits all initialized contexts before shared services are disposed.
Two VS Code windows writing the same repository state file remain the same pre-existing concurrency boundary as today's single-project Agent Manager. This plan prevents one panel from creating two contexts for the same Git common directory; cross-window file coordination is not expanded in this feature.
ProjectContext becomes a lifecycle owner rather than only a lazy service container.
type ProjectLifecycle =
| "cold"
| "initializing"
| "ready"
| "suspended"
| "disposing"
| "disposed"
interface ProjectContext {
readonly id: ProjectId
readonly root: string
readonly commonGitDir: string
readonly generation: number
ensureReady(): Promise<ReadyProjectContext>
run<T>(operation: (ctx: ReadyProjectContext) => Promise<T>): Promise<T>
suspend(): Promise<void>
dispose(): Promise<void>
}
Every async init, poll, mutation, diff, run, import, setup, PR update, branch naming, and session operation checks context identity and generation before committing state or posting to the webview.
When multi-project mode exposes more than one project, every repository operation requires project identity.
Required project qualification includes:
Missing, unknown, mismatched, inactive-when-required, or ambiguous identities return a typed route error. They never fall back to the current project or workspaceFolders[0].
Single-project mode retains a narrow compatibility adapter that injects the pinned project identity. Core handlers always operate on explicit references.
Keep one embedded KiloProvider. Multiple providers would duplicate event handling, terminal registrations, caches, and current-session state for one webview.
Add an Agent Manager route service:
interface SessionRoute extends SessionRef {
directory: string
generation: number
}
It must:
SessionInfo records.The shared detail pane consumes the selected SessionRef. Every sidebar body consumes its project's real session store. Background session UI may initially be visually simpler, but it must not display fabricated titles or placeholder session objects.
Project activation in KiloProvider must invalidate and refresh project-scoped config, providers, agents, commands, skills, MCP state, indexing, sandbox state, and Git status under an activation generation guard.
Some services remain panel-global to avoid duplicate VS Code registrations, but all associations use composite references.
ProjectId + SessionId or ProjectId + WorktreeId;WorktreeRef, including project Local;SessionRef;SidebarTarget;ProjectRef and target;Remove unknown-worktree-to-current-root terminal fallback. A context supplies a validated CWD to the global terminal manager.
Global backend events are routed once by event directory and registered session route. Status, tool, orchestration, permission, and question events retain their directory and enter exactly one owning project context. A cold/collapsed context is not initialized by an unsolicited event.
Extract the existing real renderBody() from AgentManagerApp.tsx into ProjectSidebarBody. Do not maintain active and background implementations.
ProjectSidebarBody receives a stable project-scoped store and project-qualified action callbacks. It reuses:
WorktreeSectionActions;WorktreeItem;SectionHeader and current drag/drop behavior;UnassignedSessionsSection;Render it for every expanded project:
<For each={projects()}>
{(project) => (
<ProjectAccordion project={project}>
<Show when={project.expanded}>
<ProjectSidebarBody projectId={project.id} />
</Show>
</ProjectAccordion>
)}
</For>
The body is keyed by projectId, not active status. Changing detail selection cannot remount it.
Replace active-only shared sidebar signals with Map<ProjectId, ProjectSidebarStore> or an equivalent Solid store registry.
Each store owns:
SessionInfo and managed-session state;Only shared detail/chat/terminal state remains global. The top toolbar reads the selected detail target's project store.
project-live.ts may be evolved into this complete store registry. It must stop mirroring active payloads into a separate sidebar signal set.
The project list becomes the sidebar scroll container. Individual project bodies must not use a global 50vh cap that makes two projects fight for viewport height. Section and project collapse state controls density; normal browser scrolling exposes all expanded worktrees.
Every ready expanded context owns the same poller set. There is no active singleton poller plus background poller split.
Pollers emit { projectId, generation } and update only their owning context/store. Stop/suspend increments generation before clearing timers so in-flight Git/PR results are dropped.
Context state is pushed after every project mutation, not only initial expansion or active-project changes. This includes worktree/session/section/order/run/setup changes in background projects.
Panel visibility changes poll cadence uniformly across all expanded contexts.
Registry mutations use one in-process queue. Each mutation re-reads persisted storage, validates it, applies one mutation, writes once, then updates memory. Trust/remove persistence failures are user-visible and do not leave UI state ahead of storage.
Registry descriptors include canonical root, canonical Git common directory, URI scheme/authority, order, label, trust, and added time. They do not contain Agent Manager repository state.
/config/overlay reads and compare-and-swap writes, first for User scope and with the same generic implementation covered for Project scope.User | Project Settings scope and an immutable trusted project selector/binding for Project writes.splitConfigByScope persistence and audit every direct/indirect config mutation, including provider disconnect, import/reset, custom providers, work-style presets, Permission Dock, and indexing.Do not enable multi-project broadly before this slice passes.
Do not expose interactive background controls before this slice passes.
ProjectContext.project-live.ts into a complete project sidebar store registry.renderBody() intact into ProjectSidebarBody.ProjectSummary and its CSS.packages/opencode/, run the focused config overlay/project-update tests and CLI typecheck. Run the opencode annotation and Promise-facade guards for shared-file hooks.packages/kilo-vscode/, run typecheck, lint, focused/unit tests, knip, compile/package, and architecture guards.The feature is not complete until tests prove:
ProjectSidebarBody instances.{ requestId, binding.id }; an SSE config event or out-of-order read cannot confirm a save.ProjectSummary or alternate fake-card body exists..kilo/agent-manager.json.┌─ Sidebar ─────────────────────────────────┬─ Shared detail pane ──────────┐
│ ← selected project search calendar gear │ │
│ │ Chat / diff / terminal │
│ PROJECTS + │ for the selected │
│ ▾ abalone-bactrosaurus │ project/worktree/session │
│ ┌─────────────────────────────────┐ │ │
│ │ Local main +3295 -548 ↓54 │ │ │
│ └─────────────────────────────────┘ │ │
│ WORKTREES actions │ │
│ ┌─────────────────────────────────┐ │ │
│ │ feature-x #1234 +120 -8 ↓2 │ │ │
│ │ real menus, rename, run, open │ │ │
│ └─────────────────────────────────┘ │ │
│ ┌─────────────────────────────────┐ │ │
│ │ bugfix-y +45 -12 │ │ │
│ └─────────────────────────────────┘ │ │
│ SESSIONS │ │
│ • Fix login redirect running │ │
│ • Refactor auth │ │
│ │ │
│ ▾ kilo-pi-provider │ │
│ ┌─────────────────────────────────┐ │ │
│ │ Local master +16 -6 ↓1 │ │ │
│ └─────────────────────────────────┘ │ │
│ WORKTREES actions │ │
│ ┌─────────────────────────────────┐ │ │
│ │ provider-z #88 +88 -3 │ │ │
│ │ real menus, rename, run, open │ │ │
│ └─────────────────────────────────┘ │ │
│ SESSIONS │ │
│ • Add provider tests │ │
│ │ │
│ ▸ docs-repo collapsed │ │
│ │ │
│ [New Session] [Run] │ │
└───────────────────────────────────────────┴──────────────────────────────┘
Selecting any card:
- keeps every project body mounted and pixel-stable;
- changes only the selected styling, top toolbar target, and shared detail pane;
- never swaps a real body for a summary or vice versa.