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..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 @@ -1567,11 +1570,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..95a16d7050ad 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,12 @@ export class ProjectContexts { } isExpanded(id: string): boolean { - return this.expanded.has(id) + 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. */ @@ -148,6 +154,7 @@ export class ProjectContexts { const ctx = this.usableCtx(id) if (!ctx) return undefined this.activeId = id + this.rememberExpansion(id, false) return ctx } @@ -155,12 +162,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 +176,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 +201,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 +222,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() @@ -241,26 +248,32 @@ 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, } } + 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..4349f709c4ab 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) + 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/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..9c8b6239e91a 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,34 @@ 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({ + 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 96d89b75a79f..3e1507303731 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -22,6 +22,7 @@ import type { LanguageContextValue } from "../src/context/language" import { useVSCode } from "../src/context/vscode" import { projectAdjacentHint, projectSidebarOrder } from "./project-local-navigation" import SectionHeader from "./SectionHeader" +import { SidebarSectionHeader } from "./SidebarSectionHeader" import { WorktreeItem } from "./WorktreeItem" import { UnassignedSessionsSection } from "./UnassignedSessionsSection" import { ProjectActions } from "./ProjectActions" @@ -346,20 +347,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().missing) return + if (!project().trusted) { + 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..3bcd0ecbcec7 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 @@ -30,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) { @@ -48,6 +56,7 @@ const SectionHeader: Component = (props) => { } const cancel = () => { + cancelled = true setRenaming(false) props.onRenameEnd?.() } @@ -56,11 +65,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 +74,44 @@ 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() + }} + onBlur={commit} + 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) => { + return ( +
{ + if (event.button === 0) props.onToggle?.() + }} + > +
+ + + +
{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..625780bc1cc2 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,13 @@ export const UnassignedSessionsSection: Component = (props) => { return (
- + {t("agentManager.section.sessions")}} + onToggle={props.onToggle} + />