From 245ada532e30f0eb2ba494531f8f33dec5855861 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 10:48:41 +0200 Subject: [PATCH 1/4] feat(agent-manager): align and persist collapsible headers --- .changeset/persist-project-accordion.md | 5 + .../src/agent-manager/AgentManagerProvider.ts | 11 +- .../src/agent-manager/project/contexts.ts | 30 ++-- .../src/agent-manager/project/messages.ts | 10 +- .../src/agent-manager/project/registry.ts | 102 +++++++++--- .../tests/unit/agent-manager-arch.test.ts | 1 + .../tests/unit/agent-project-contexts.test.ts | 27 ++- .../tests/unit/agent-project-messages.test.ts | 39 ++++- .../agent-manager/ProjectSidebarBody.tsx | 34 ++-- .../agent-manager/ProjectsSection.tsx | 115 +++++++------ .../agent-manager/SectionHeader.tsx | 78 +++++---- .../webview-ui/agent-manager/SidebarBody.tsx | 44 ++--- .../agent-manager/SidebarSectionHeader.tsx | 53 ++++++ .../UnassignedSessionsSection.tsx | 13 +- .../agent-manager/agent-manager.css | 154 ++++++++---------- 15 files changed, 452 insertions(+), 264 deletions(-) create mode 100644 .changeset/persist-project-accordion.md create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx diff --git a/.changeset/persist-project-accordion.md b/.changeset/persist-project-accordion.md new file mode 100644 index 000000000000..6fda8a714a66 --- /dev/null +++ b/.changeset/persist-project-accordion.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Allow Agent Manager project headers to collapse or expand their project body, and persist that state across panel opens and VS Code restarts. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 39b844f9894e..09db8d3227f3 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1567,11 +1567,20 @@ export class AgentManagerProvider implements Disposable { } private pushProjects(): void { + const projects = this.contexts.snapshots() this.postToWebview({ type: "agentManager.projects", multiProject: this.host.multiProject(), - projects: this.contexts.snapshots(), + projects, }) + if (this.panel) { + for (const project of projects) { + if (project.active || !project.expanded || !project.trusted || project.missing) continue + if (this.contexts.get(project.id)) continue + const ctx = this.contexts.expand(project.id) + if (ctx) this.initExpanded(ctx) + } + } this.projectPollers.sync(this.contexts) } diff --git a/packages/kilo-vscode/src/agent-manager/project/contexts.ts b/packages/kilo-vscode/src/agent-manager/project/contexts.ts index a5ed3f344516..ddc2914f83ce 100644 --- a/packages/kilo-vscode/src/agent-manager/project/contexts.ts +++ b/packages/kilo-vscode/src/agent-manager/project/contexts.ts @@ -32,6 +32,7 @@ interface ContextsOptions { registry: { list(): StoredProject[] get(id: string): StoredProject | undefined + expanded?(id: string): boolean | undefined } /** Registry trust lookup for non-pinned projects. */ trusted: (id: string) => boolean @@ -44,7 +45,7 @@ interface ContextsOptions { export class ProjectContexts { private readonly contexts = new Map() private activeId: string | undefined - private readonly expanded = new Set() + private readonly expansion = new Map() constructor(private readonly opts: ContextsOptions) {} @@ -83,7 +84,7 @@ export class ProjectContexts { const pinned = this.pinned() if (pinned) { this.activeId = pinned.id - this.expanded.add(pinned.id) + this.rememberExpansion(pinned.id, true) return pinned } if (!this.opts.enabled()) return undefined @@ -91,7 +92,7 @@ export class ProjectContexts { if (!first) return undefined const ctx = this.ensure(first.id, first.root, false) this.activeId = ctx.id - this.expanded.add(ctx.id) + this.rememberExpansion(ctx.id, false) return ctx } @@ -140,7 +141,8 @@ export class ProjectContexts { } isExpanded(id: string): boolean { - return this.expanded.has(id) + this.rememberExpansion(id, false) + return this.expansion.get(id) === true } /** Make a project the active context and expand it. Returns undefined when not allowed. */ @@ -148,6 +150,7 @@ export class ProjectContexts { const ctx = this.usableCtx(id) if (!ctx) return undefined this.activeId = id + this.rememberExpansion(id, false) return ctx } @@ -155,12 +158,12 @@ export class ProjectContexts { expand(id: string): ProjectContext | undefined { const ctx = this.usableCtx(id) if (!ctx) return undefined - this.expanded.add(id) + this.expansion.set(id, true) return ctx } collapse(id: string): void { - this.expanded.delete(id) + this.expansion.set(id, false) if (this.isActive(id)) return this.contexts.get(id)?.suspend() } @@ -169,10 +172,10 @@ export class ProjectContexts { disable(): ProjectContext | undefined { const pinned = this.pinned() this.activeId = pinned?.id - if (pinned) this.expanded.add(pinned.id) + if (pinned) this.expansion.set(pinned.id, true) for (const ctx of this.contexts.values()) { if (ctx.pinned) continue - this.expanded.delete(ctx.id) + this.expansion.set(ctx.id, false) ctx.suspend() // Match remove()/syncPinned(): drop the routes too, otherwise the shared // route service accumulates entries for every disabled project. @@ -194,7 +197,7 @@ export class ProjectContexts { async remove(id: string): Promise { const ctx = this.contexts.get(id) if (!ctx || ctx.pinned) return false - this.expanded.delete(id) + this.expansion.delete(id) if (this.activeId === id) this.activeId = undefined this.contexts.delete(id) this.opts.remove?.(id) @@ -215,7 +218,7 @@ export class ProjectContexts { for (const [id, ctx] of [...this.contexts]) { if (!ctx.pinned) continue this.contexts.delete(id) - this.expanded.delete(id) + this.expansion.delete(id) if (this.activeId === id) this.activeId = undefined this.opts.remove?.(id) ctx.suspend() @@ -254,13 +257,18 @@ export class ProjectContexts { } } + private rememberExpansion(id: string, fallback: boolean): void { + if (this.expansion.has(id)) return + this.expansion.set(id, this.opts.registry.expanded?.(id) ?? fallback) + } + async dispose(): Promise { for (const ctx of this.contexts.values()) { this.opts.remove?.(ctx.id) await ctx.dispose() } this.contexts.clear() - this.expanded.clear() + this.expansion.clear() this.activeId = undefined } } diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index 9bf8c542f8a5..28e4c3bde879 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -189,9 +189,15 @@ function selectProject(id: string, deps: ProjectMessageDeps): void { async function setExpanded(id: string, expanded: boolean, deps: ProjectMessageDeps): Promise { if (disabled(deps)) return + const ctx = expanded ? deps.contexts.usable(id) : deps.contexts.resolve(id) + if (!ctx) { + deps.push() + return + } + await deps.registry.setExpanded(id, expanded) if (expanded) { - const ctx = deps.contexts.expand(id) - if (ctx) deps.expand(ctx) + deps.contexts.expand(id) + deps.expand(ctx) } if (!expanded) deps.contexts.collapse(id) deps.push() diff --git a/packages/kilo-vscode/src/agent-manager/project/registry.ts b/packages/kilo-vscode/src/agent-manager/project/registry.ts index 9b11a1151366..1a727db40be3 100644 --- a/packages/kilo-vscode/src/agent-manager/project/registry.ts +++ b/packages/kilo-vscode/src/agent-manager/project/registry.ts @@ -1,10 +1,10 @@ /** * ProjectRegistry — global catalog of additional Agent Manager projects. * - * The registry persists only *additional* projects: repositories the user - * explicitly added through the Agent Manager project picker. The pinned - * default project is always derived from the current VS Code workspace at - * runtime and is never stored here. + * The registry persists *additional* projects: repositories the user explicitly + * added through the Agent Manager project picker. The pinned default project is + * always derived from the current VS Code workspace at runtime; only its + * accordion preference is stored by id. * * Storage is injected so the registry stays free of VS Code imports and can * be unit-tested with an in-memory store. The file is versioned; corrupt or @@ -31,11 +31,20 @@ export interface StoredProject { /** Whether project-controlled scripts may execute for this project. */ trusted: boolean addedAt: string + /** Whether this project accordion should render its body. */ + expanded?: boolean } interface RegistryFile { version: 1 projects: StoredProject[] + /** Expansion state for the pinned workspace project, which is not a catalog entry. */ + pinnedExpanded?: Record +} + +interface ParsedRegistry { + projects: StoredProject[] + pinnedExpanded: Record } export interface RegistryStorage { @@ -53,16 +62,17 @@ function valid(entry: unknown): entry is StoredProject { typeof e.root === "string" && typeof e.order === "number" && typeof e.trusted === "boolean" && - typeof e.addedAt === "string" + typeof e.addedAt === "string" && + (e.expanded === undefined || typeof e.expanded === "boolean") ) } -function parse(raw: unknown, log: (msg: string) => void): StoredProject[] { - if (!raw || typeof raw !== "object") return [] +function parse(raw: unknown, log: (msg: string) => void): ParsedRegistry { + if (!raw || typeof raw !== "object") return { projects: [], pinnedExpanded: {} } const file = raw as Partial if (file.version !== VERSION || !Array.isArray(file.projects)) { if (file.version !== undefined) log("project registry has an unsupported shape, starting empty") - return [] + return { projects: [], pinnedExpanded: {} } } const seen = new Set() const out: StoredProject[] = [] @@ -72,11 +82,18 @@ function parse(raw: unknown, log: (msg: string) => void): StoredProject[] { seen.add(entry.id) out.push(entry) } - return out.sort((a, b) => a.order - b.order) + const pinnedExpanded: Record = {} + if (file.pinnedExpanded && typeof file.pinnedExpanded === "object") { + for (const [id, expanded] of Object.entries(file.pinnedExpanded)) { + if (typeof expanded === "boolean") pinnedExpanded[id] = expanded + } + } + return { projects: out.sort((a, b) => a.order - b.order), pinnedExpanded } } export class ProjectRegistry { private projects: StoredProject[] | undefined + private pinnedExpanded: Record | undefined private queue: Promise = Promise.resolve() constructor( @@ -85,20 +102,26 @@ export class ProjectRegistry { ) {} private load(): StoredProject[] { - this.projects ??= parse(this.storage.read(), this.log) + if (!this.projects) { + const parsed = parse(this.storage.read(), this.log) + this.projects = parsed.projects + this.pinnedExpanded = parsed.pinnedExpanded + } return this.projects } /** Fresh re-read + validate/dedupe/sort, used by every mutation. */ - private fresh(): StoredProject[] { + private fresh(): ParsedRegistry { return parse(this.storage.read(), this.log) } /** Persist the next catalog and update the cache only after the write succeeds. */ - private async write(next: StoredProject[]): Promise { + private async write(next: StoredProject[], pinnedExpanded: Record): Promise { const file: RegistryFile = { version: VERSION, projects: next } + if (Object.keys(pinnedExpanded).length > 0) file.pinnedExpanded = pinnedExpanded await this.storage.write(file) this.projects = next + this.pinnedExpanded = pinnedExpanded } /** Serialize mutations within this instance; a failed mutation does not poison the queue. */ @@ -119,6 +142,14 @@ export class ProjectRegistry { return this.load().find((p) => p.id === id) } + /** Return explicit expansion state, if one has been persisted. */ + expanded(id: string): boolean | undefined { + const project = this.get(id) + if (project) return project.expanded + this.load() + return this.pinnedExpanded?.[id] + } + /** Register an additional project. Throws when the id is already registered. */ add(input: { id: string; root: string; label?: string }): Promise { return this.run(() => this.doAdd(input)) @@ -126,8 +157,9 @@ export class ProjectRegistry { private async doAdd(input: { id: string; root: string; label?: string }): Promise { const current = this.fresh() - if (current.find((p) => p.id === input.id)) throw new Error("That repository is already registered as a project.") - const order = current.reduce((max, p) => Math.max(max, p.order), 0) + 1 + if (current.projects.find((p) => p.id === input.id)) + throw new Error("That repository is already registered as a project.") + const order = current.projects.reduce((max, p) => Math.max(max, p.order), 0) + 1 const project: StoredProject = { id: input.id, root: input.root, @@ -136,7 +168,7 @@ export class ProjectRegistry { trusted: false, addedAt: new Date().toISOString(), } - await this.write([...current, project]) + await this.write([...current.projects, project], current.pinnedExpanded) return project } @@ -147,9 +179,11 @@ export class ProjectRegistry { private async doRemove(id: string): Promise { const current = this.fresh() - const next = current.filter((p) => p.id !== id) - if (next.length === current.length) return false - await this.write(next) + const next = current.projects.filter((p) => p.id !== id) + if (next.length === current.projects.length) return false + const pinnedExpanded = { ...current.pinnedExpanded } + delete pinnedExpanded[id] + await this.write(next, pinnedExpanded) return true } @@ -159,8 +193,11 @@ export class ProjectRegistry { private async doSetTrusted(id: string, trusted: boolean): Promise { const current = this.fresh() - if (!current.find((p) => p.id === id)) return false - await this.write(current.map((p) => (p.id === id ? { ...p, trusted } : p))) + if (!current.projects.find((p) => p.id === id)) return false + await this.write( + current.projects.map((p) => (p.id === id ? { ...p, trusted } : p)), + current.pinnedExpanded, + ) return true } @@ -170,8 +207,29 @@ export class ProjectRegistry { private async doSetLabel(id: string, label: string | undefined): Promise { const current = this.fresh() - if (!current.find((p) => p.id === id)) return false - await this.write(current.map((p) => (p.id === id ? { ...p, label } : p))) + if (!current.projects.find((p) => p.id === id)) return false + await this.write( + current.projects.map((p) => (p.id === id ? { ...p, label } : p)), + current.pinnedExpanded, + ) + return true + } + + setExpanded(id: string, expanded: boolean): Promise { + return this.run(() => this.doSetExpanded(id, expanded)) + } + + private async doSetExpanded(id: string, expanded: boolean): Promise { + const current = this.fresh() + if (current.projects.some((p) => p.id === id)) { + await this.write( + current.projects.map((p) => (p.id === id ? { ...p, expanded } : p)), + current.pinnedExpanded, + ) + return true + } + const pinnedExpanded = { ...current.pinnedExpanded, [id]: expanded } + await this.write(current.projects, pinnedExpanded) return 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 353070c085b2..b4a24df88a78 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -38,6 +38,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"), + path.join(ROOT, "webview-ui/agent-manager/SidebarSectionHeader.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarToggleButton.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"), diff --git a/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts index 97cb9265f966..126c0ca5467a 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts @@ -17,11 +17,14 @@ function stored(id: string, trusted = false): StoredProject { } } -function setup(opts: { workspace?: string; enabled?: boolean; projects?: StoredProject[] } = {}) { +function setup( + opts: { workspace?: string; enabled?: boolean; projects?: StoredProject[]; expanded?: Record } = {}, +) { const registryProjects = opts.projects ?? [] const registry = { list: () => registryProjects, get: (id: string) => registryProjects.find((p) => p.id === id), + expanded: (id: string) => opts.expanded?.[id], } const created: string[] = [] const contexts = new ProjectContexts({ @@ -207,6 +210,28 @@ describe("ProjectContexts", () => { expect(list[1]!.initialized).toBe(false) }) + it("hydrates persisted project expansion without initializing the project", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ + workspace: WORKSPACE, + enabled: true, + projects: [extra], + expanded: { [extra.id]: true }, + }) + + const list = contexts.snapshots() + + expect(list[1]!.expanded).toBe(true) + expect(list[1]!.initialized).toBe(false) + expect(contexts.get(extra.id)).toBeUndefined() + }) + + it("hydrates a persisted collapsed pinned project", () => { + const { contexts } = setup({ workspace: WORKSPACE, expanded: { [PINNED]: false } }) + + expect(contexts.snapshots()[0]!.expanded).toBe(false) + }) + it("hides registry projects from snapshots when the flag is off", () => { const extra = stored("prj-extra", true) const { contexts } = setup({ workspace: WORKSPACE, enabled: false, projects: [extra] }) diff --git a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts index 762268d78d08..c4a88084a9fd 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts @@ -4,7 +4,7 @@ import * as os from "os" import * as path from "path" import { execFileSync } from "child_process" import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages" -import { ProjectRegistry } from "../../src/agent-manager/project/registry" +import { ProjectRegistry, type RegistryStorage } from "../../src/agent-manager/project/registry" import { ProjectContexts } from "../../src/agent-manager/project/contexts" import { projectIdFor } from "../../src/agent-manager/project/paths" import type { AgentManagerInMessage } from "../../src/agent-manager/types" @@ -20,12 +20,13 @@ function gitRepo(): string { function setup(opts: { enabled?: boolean; workspace?: string } = {}) { let stored: unknown let pickResult: string | undefined - const registry = new ProjectRegistry({ + const storage: RegistryStorage = { read: () => stored, write: (value) => { stored = value }, - }) + } + const registry = new ProjectRegistry(storage) const contexts = new ProjectContexts({ workspaceRoot: () => opts.workspace ?? WORKSPACE, registry, @@ -63,7 +64,7 @@ function setup(opts: { enabled?: boolean; workspace?: string } = {}) { const pick = (dir: string | undefined) => { pickResult = dir } - return { registry, contexts, deps, calls, pick } + return { registry, contexts, deps, calls, pick, storage } } function msg(type: string, extra: Record = {}): AgentManagerInMessage { @@ -170,6 +171,36 @@ describe("handleProjectMessage", () => { expect(calls.expand).toEqual([id]) }) + it("persists project expansion state across registry instances", async () => { + const repo = gitRepo() + const { deps, registry, storage, calls } = setup() + const id = projectIdFor(repo) + await registry.add({ id, root: repo }) + await registry.setTrusted(id, true) + + await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: true }), deps) + + const restored = new ProjectRegistry(storage) + expect(restored.expanded(id)).toBe(true) + expect(restored.get(id)?.expanded).toBe(true) + + await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: false }), deps) + + expect(new ProjectRegistry(storage).expanded(id)).toBe(false) + expect(calls.push).toBe(2) + }) + + it("persists the pinned project expansion state without adding it to the catalog", async () => { + const { deps, registry, storage } = setup() + const id = projectIdFor(WORKSPACE) + + await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: false }), deps) + + const restored = new ProjectRegistry(storage) + expect(restored.expanded(id)).toBe(false) + expect(registry.list()).toEqual([]) + }) + it("does not initialize untrusted projects on expand", async () => { const repo = gitRepo() const { deps, registry, calls } = setup() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index 40b3cd82cb48..2e6bd7b0d3b2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -21,6 +21,7 @@ import type { import type { LanguageContextValue } from "../src/context/language" import { useVSCode } from "../src/context/vscode" import SectionHeader from "./SectionHeader" +import { SidebarSectionHeader } from "./SidebarSectionHeader" import { WorktreeItem } from "./WorktreeItem" import { UnassignedSessionsSection } from "./UnassignedSessionsSection" import { ProjectActions } from "./ProjectActions" @@ -313,20 +314,25 @@ export const ProjectSidebarBody: Component = (props) => {
-
- - post({ type: "agentManager.createWorktree" })} - onNew={() => props.onNewWorktree(props.project.id)} - onSection={() => createSection()} - onSetup={() => post({ type: "agentManager.configureSetupScript" })} - onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)} - /> -
+ {props.t("agentManager.section.worktrees")}} + actions={ + post({ type: "agentManager.createWorktree" })} + onNew={() => props.onNewWorktree(props.project.id)} + onSection={() => createSection()} + onSetup={() => post({ type: "agentManager.configureSetupScript" })} + onBranch={() => + props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch) + } + /> + } + />
= (props) => (
-
- -
- {props.tools} - -
-
+ {props.t("agentManager.projects")}} + actions={ +
+ {props.tools} + +
+ } + />
project.id)}> {(id) => { const project = () => props.projects.find((item) => item.id === id)! return (
-
- - - props.onRemove(project().id)} - /> - - -
+ } + onToggle={() => { + if (!project().trusted && !project().missing) { + props.onTrust(project().id) + return + } + const expanded = !project().expanded + props.onExpand(project().id, expanded) + if (!project().active && project().trusted) props.onSelect(project().id) + }} + /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx index 823b3c4800ab..475c8192e47e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx @@ -5,6 +5,7 @@ import { Icon } from "@kilocode/kilo-ui/icon" import type { SectionState } from "../src/types/messages" import { SECTION_COLORS, colorCss } from "./section-colors" import { useLanguage } from "../src/context/language" +import { SidebarSectionHeader } from "./SidebarSectionHeader" interface Props { section: SectionState @@ -56,11 +57,6 @@ const SectionHeader: Component = (props) => { if (props.autoRename && !renaming()) startRename() }) - const handleClick = (e: MouseEvent) => { - if (e.button !== 0 || renaming()) return - props.onToggle() - } - const droppable = createDroppable(props.dropId ?? props.section.id) return ( @@ -70,41 +66,43 @@ const SectionHeader: Component = (props) => { style={{ "--section-color": border() }} > - -
- - setValue(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") commit() - if (e.key === "Escape") cancel() - }} - onBlur={commit} - onClick={(e) => e.stopPropagation()} - ref={(el) => - requestAnimationFrame(() => - requestAnimationFrame(() => { - el.focus() - el.select() - }), - ) - } - /> - } - > - {props.section.name} - -
- {props.count} + + setValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commit() + if (e.key === "Escape") cancel() + }} + onClick={(e) => e.stopPropagation()} + ref={(el) => + requestAnimationFrame(() => + requestAnimationFrame(() => { + el.focus() + el.select() + }), + ) + } + /> + } + > + {props.section.name} + + } + count={props.count} + onToggle={() => { + if (!renaming()) props.onToggle() + }} + /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx index a48c5c643c29..840ec8cfba62 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -28,6 +28,7 @@ import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" import { useVSCode } from "../src/context/vscode" import SectionHeader from "./SectionHeader" +import { SidebarSectionHeader } from "./SidebarSectionHeader" import { WorktreeItem } from "./WorktreeItem" import { WorktreeSectionActions } from "./WorktreeSectionActions" import { UnassignedSessionsSection } from "./UnassignedSessionsSection" @@ -183,26 +184,29 @@ export const SidebarBody: Component = (props) => { {/* WORKTREES section */}
-
- - props.onSearchRef(value)} - onSelect={props.onSearchSelect} - onCreate={props.onCreateWorktree} - onNew={props.onNewWorktree} - onSection={props.onNewSection} - onShortcuts={props.onShortcuts} - onSetup={props.onSetup} - onBranch={props.onBranch} - /> -
+ {props.t("agentManager.section.worktrees")}} + actions={ + props.onSearchRef(value)} + onSelect={props.onSearchSelect} + onCreate={props.onCreateWorktree} + onNew={props.onNewWorktree} + onSection={props.onNewSection} + onShortcuts={props.onShortcuts} + onSetup={props.onSetup} + onBranch={props.onBranch} + /> + } + />
void + count?: JSX.Element + actions?: JSX.Element + class?: string + title?: string + ariaLabel?: string +} + +/** Shared layout for sidebar headings with a fixed leading control column. */ +export const SidebarSectionHeader: Component = (props) => { + const keydown = (event: KeyboardEvent) => { + if (!props.onToggle || event.target !== event.currentTarget) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + props.onToggle() + } + + return ( +
{ + if (event.button === 0) props.onToggle?.() + }} + onKeyDown={keydown} + > +
+ + + +
{props.label}
+
+ + {props.count} + + +
{props.actions}
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx index 966fee8234e4..22aae231875e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx @@ -6,6 +6,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip" import type { SessionInfo } from "../src/types/messages" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" +import { SidebarSectionHeader } from "./SidebarSectionHeader" interface Props { sessions: Accessor @@ -29,12 +30,12 @@ export const UnassignedSessionsSection: Component = (props) => { return (
- + {t("agentManager.section.sessions")}} + onToggle={props.onToggle} + />
Date: Tue, 4 Aug 2026 11:34:46 +0200 Subject: [PATCH 2/4] fix(agent-manager): address review findings --- .../src/agent-manager/AgentManagerProvider.ts | 15 +++--- .../src/agent-manager/project/contexts.ts | 13 ++++-- .../src/agent-manager/project/messages.ts | 4 +- .../tests/unit/agent-project-contexts.test.ts | 6 +++ .../agent-manager/ProjectsSection.tsx | 2 +- .../agent-manager/SectionHeader.tsx | 9 ++++ .../agent-manager/SidebarSectionHeader.tsx | 31 ++++++------- .../UnassignedSessionsSection.tsx | 1 + .../agent-manager/agent-manager.css | 25 +++++++++- .../src/stories/agent-manager.stories.tsx | 46 ++++++++++--------- 10 files changed, 101 insertions(+), 51 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 09db8d3227f3..9c67a03b3ad2 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -395,6 +395,7 @@ export class AgentManagerProvider implements Disposable { this.panelSessions.clear() void ctx.sessions.abortSessions(ids).catch((err) => this.log("Failed to abort sessions on panel close:", err)) this.statsPoller.stop() + this.projectPollers.dispose() this.prBridge.poller.stop() this.diffs.stop() this.activeSessionId = undefined @@ -450,12 +451,14 @@ export class AgentManagerProvider implements Disposable { /** Initialize an expanded background project and push its state (no panel wiring). */ private initExpanded(ctx: ProjectContext): void { - void initContextState(ctx, (...args) => this.log(...args)).then((result) => { - if (!result.current) return - registerProjectSessions(ctx, this.panel?.sessions) - this.pushState(ctx) - this.projectPollers.sync(this.contexts) - }) + void initContextState(ctx, (...args) => this.log(...args)) + .then((result) => { + if (!result.current) return + registerProjectSessions(ctx, this.panel?.sessions) + this.pushState(ctx) + this.projectPollers.sync(this.contexts) + }) + .catch((err) => this.log("Failed to initialize expanded project:", err)) } // Message interceptor diff --git a/packages/kilo-vscode/src/agent-manager/project/contexts.ts b/packages/kilo-vscode/src/agent-manager/project/contexts.ts index ddc2914f83ce..95a16d7050ad 100644 --- a/packages/kilo-vscode/src/agent-manager/project/contexts.ts +++ b/packages/kilo-vscode/src/agent-manager/project/contexts.ts @@ -141,8 +141,12 @@ export class ProjectContexts { } isExpanded(id: string): boolean { - this.rememberExpansion(id, false) - return this.expansion.get(id) === true + const value = this.expansion.get(id) + if (value !== undefined) return value + const stored = this.opts.registry.expanded?.(id) + if (stored !== undefined) return stored + const root = this.opts.workspaceRoot() + return root !== undefined && projectIdFor(canonicalizePath(root)) === id } /** Make a project the active context and expand it. Returns undefined when not allowed. */ @@ -244,16 +248,17 @@ export class ProjectContexts { const id = ctx?.id ?? stored!.id const root = ctx?.root ?? stored!.root const pinned = ctx?.pinned ?? false + const missing = ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root) return { id, root, label: stored?.label || path.basename(root) || root, pinned, active: this.isActive(id), - expanded: this.isExpanded(id), + expanded: !missing && this.isExpanded(id), initialized: ctx?.loaded ?? false, trusted: pinned || (stored?.trusted ?? false), - missing: ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root), + missing, } } diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index 28e4c3bde879..4349f709c4ab 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -196,8 +196,8 @@ async function setExpanded(id: string, expanded: boolean, deps: ProjectMessageDe } await deps.registry.setExpanded(id, expanded) if (expanded) { - deps.contexts.expand(id) - deps.expand(ctx) + const next = deps.contexts.expand(id) + if (next) deps.expand(next) } if (!expanded) deps.contexts.collapse(id) deps.push() diff --git a/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts index 126c0ca5467a..9c8b6239e91a 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts @@ -210,6 +210,12 @@ describe("ProjectContexts", () => { expect(list[1]!.initialized).toBe(false) }) + it("keeps the pinned project expanded before active state is initialized", () => { + const { contexts } = setup({ workspace: WORKSPACE }) + + expect(contexts.isExpanded(PINNED)).toBe(true) + }) + it("hydrates persisted project expansion without initializing the project", () => { const extra = stored("prj-extra", true) const { contexts } = setup({ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx index 55dfd4f5aaf1..5dbe5f71470c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx @@ -96,7 +96,7 @@ export const ProjectsSection: Component = (props) => ( } const expanded = !project().expanded props.onExpand(project().id, expanded) - if (!project().active && project().trusted) props.onSelect(project().id) + if (!project().active && project().trusted && !project().missing) props.onSelect(project().id) }} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx index 475c8192e47e..3bcd0ecbcec7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx @@ -31,16 +31,23 @@ const SectionHeader: Component = (props) => { const { t } = useLanguage() const [renaming, setRenaming] = createSignal(false) const [value, setValue] = createSignal("") + let cancelled = false const border = () => colorCss(props.section.color) ?? "var(--vscode-panel-border)" const startRename = () => { + cancelled = false setValue(props.section.name) setRenaming(true) } const commit = () => { + if (cancelled) { + cancelled = false + return + } const trimmed = value().trim() + cancelled = true setRenaming(false) props.onRenameEnd?.() if (trimmed && trimmed !== props.section.name) { @@ -49,6 +56,7 @@ const SectionHeader: Component = (props) => { } const cancel = () => { + cancelled = true setRenaming(false) props.onRenameEnd?.() } @@ -83,6 +91,7 @@ const SectionHeader: Component = (props) => { if (e.key === "Enter") commit() if (e.key === "Escape") cancel() }} + onBlur={commit} onClick={(e) => e.stopPropagation()} ref={(el) => requestAnimationFrame(() => diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx index aefcd95ffd27..ebbc69446c93 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx @@ -14,31 +14,30 @@ interface Props { /** Shared layout for sidebar headings with a fixed leading control column. */ export const SidebarSectionHeader: Component = (props) => { - const keydown = (event: KeyboardEvent) => { - if (!props.onToggle || event.target !== event.currentTarget) return - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - props.onToggle() - } - return (
{ if (event.button === 0) props.onToggle?.() }} - onKeyDown={keydown} >
- +
{props.label}
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx index 22aae231875e..625780bc1cc2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx @@ -33,6 +33,7 @@ export const UnassignedSessionsSection: Component = (props) => { {t("agentManager.section.sessions")}} onToggle={props.onToggle} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index c1f150256f30..a94cf39d149f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -190,7 +190,7 @@ html[data-theme="kilo-vscode"] box-sizing: border-box; } -.am-sidebar-header[role="button"] { +.am-sidebar-header-toggleable { cursor: pointer; user-select: none; } @@ -213,6 +213,26 @@ html[data-theme="kilo-vscode"] color: var(--text-weak); } +.am-sidebar-header-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + padding: 0; + color: inherit; + background: none; + border: 0; + border-radius: var(--radius-sm); + cursor: pointer; +} + +.am-sidebar-header-toggle:hover { + color: var(--text-base); + background: var(--surface-inset-base-hover); +} + .am-sidebar-header-label { display: flex; align-items: center; @@ -244,6 +264,9 @@ html[data-theme="kilo-vscode"] } .am-section-header { + display: flex; + align-items: center; + justify-content: space-between; padding: 4px 8px 2px; } diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 9ebef0f8766f..2ce729af0756 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -16,6 +16,7 @@ import { SessionContext } from "../context/session" import { ServerContext } from "../context/server" import { WorktreeModeProvider } from "../context/worktree-mode" import { SidebarSearchMenu } from "../../agent-manager/SidebarSearchMenu" +import { SidebarSectionHeader } from "../../agent-manager/SidebarSectionHeader" import { SidebarToggleButton } from "../../agent-manager/SidebarToggleButton" import { SideTerminalPanel, createTerminalState } from "../../agent-manager/terminal" import { LOCAL } from "../../agent-manager/navigate" @@ -1162,27 +1163,30 @@ export const SidebarSearchOpen: Story = { return (
-
- -
- sidebarSearchItems} - keybind="⌘F" - current={() => sidebarSearchItems.find((item) => item.key === selected())} - labels={{ - search: "Search worktrees and sessions", - scope: "Searches the local workspace, local sessions, worktrees, and their sessions", - contexts: "LOCAL & WORKTREES", - sessions: "SESSIONS", - waiting: "Wait", - retry: "Retry", - }} - onSelect={(item) => setSelected(item.key)} - defaultOpen - portal={false} - /> -
-
+ WORKTREES} + actions={ +
+ sidebarSearchItems} + keybind="⌘F" + current={() => sidebarSearchItems.find((item) => item.key === selected())} + labels={{ + search: "Search worktrees and sessions", + scope: "Searches the local workspace, local sessions, worktrees, and their sessions", + contexts: "LOCAL & WORKTREES", + sessions: "SESSIONS", + waiting: "Wait", + retry: "Retry", + }} + onSelect={(item) => setSelected(item.key)} + defaultOpen + portal={false} + /> +
+ } + /> {selected()} From 43a222d07283cf7805d01619a028e0267f0ea830 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 11:48:43 +0200 Subject: [PATCH 3/4] fix(agent-manager): preserve search story structure --- .../src/stories/agent-manager.stories.tsx | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 2ce729af0756..9ebef0f8766f 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -16,7 +16,6 @@ import { SessionContext } from "../context/session" import { ServerContext } from "../context/server" import { WorktreeModeProvider } from "../context/worktree-mode" import { SidebarSearchMenu } from "../../agent-manager/SidebarSearchMenu" -import { SidebarSectionHeader } from "../../agent-manager/SidebarSectionHeader" import { SidebarToggleButton } from "../../agent-manager/SidebarToggleButton" import { SideTerminalPanel, createTerminalState } from "../../agent-manager/terminal" import { LOCAL } from "../../agent-manager/navigate" @@ -1163,30 +1162,27 @@ export const SidebarSearchOpen: Story = { return (
- WORKTREES} - actions={ -
- sidebarSearchItems} - keybind="⌘F" - current={() => sidebarSearchItems.find((item) => item.key === selected())} - labels={{ - search: "Search worktrees and sessions", - scope: "Searches the local workspace, local sessions, worktrees, and their sessions", - contexts: "LOCAL & WORKTREES", - sessions: "SESSIONS", - waiting: "Wait", - retry: "Retry", - }} - onSelect={(item) => setSelected(item.key)} - defaultOpen - portal={false} - /> -
- } - /> +
+ +
+ sidebarSearchItems} + keybind="⌘F" + current={() => sidebarSearchItems.find((item) => item.key === selected())} + labels={{ + search: "Search worktrees and sessions", + scope: "Searches the local workspace, local sessions, worktrees, and their sessions", + contexts: "LOCAL & WORKTREES", + sessions: "SESSIONS", + waiting: "Wait", + retry: "Retry", + }} + onSelect={(item) => setSelected(item.key)} + defaultOpen + portal={false} + /> +
+
{selected()} From aaa2a8fc6e2f07ab50530a933bf6e4ec33c18957 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 11:57:41 +0200 Subject: [PATCH 4/4] fix(agent-manager): ignore missing project toggles --- .../kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx index 5dbe5f71470c..7f5e9ea31a5a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx @@ -90,7 +90,8 @@ export const ProjectsSection: Component = (props) => ( } onToggle={() => { - if (!project().trusted && !project().missing) { + if (project().missing) return + if (!project().trusted) { props.onTrust(project().id) return }