diff --git a/.changeset/quiet-project-session-switch.md b/.changeset/quiet-project-session-switch.md new file mode 100644 index 00000000000..d830c7cc825 --- /dev/null +++ b/.changeset/quiet-project-session-switch.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Load the correct Agent Manager session history on the first worktree switch between projects. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 0527a5eda15..8b1166997c3 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -804,6 +804,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.add(session.id) this.postMessage({ type: "sessionCreated", + projectId: this.opts.projectQualifier?.()?.projectId, session: this.sessionToWebview(session), ...(activate ? { activate: true } : {}), }) @@ -1946,6 +1947,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Notify webview of the new session this.postMessage({ type: "sessionCreated", + projectId: this.opts.projectQualifier?.()?.projectId, session: this.sessionToWebview(this.currentSession!), }) } catch (error) { @@ -3472,6 +3474,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.add(session.id) this.postMessage({ type: "sessionCreated", + projectId: this.opts.projectQualifier?.()?.projectId, session: this.sessionToWebview(session), draftID, }) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 2c79bc52476..d0ea4b21002 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -46,6 +46,7 @@ import { forkSession } from "./fork-session" import { AgentManagerVisiblePresence } from "./am-visible-presence" import { continueInWorktree } from "./continue-in-worktree" import { WorktreeDiffController } from "./worktree-diff-controller" +import { sendDiffBranches as postDiffBranches } from "./project/diff-branches" import { WorktreeImporter } from "./worktree-importer" import { createWorktreeOnDisk, @@ -221,6 +222,7 @@ export class AgentManagerProvider implements Disposable { localDiffFile: local.file, post: (msg) => this.postToWebview(msg), log: (...args) => this.log(...args), + projectId: () => this.context?.id, }) const pollers = createPollers({ git: this.gitOps, @@ -272,7 +274,8 @@ export class AgentManagerProvider implements Disposable { pushState: (ctx) => this.pushState(ctx), hasPanelSession: (id) => this.panelSessions.has(id), closeSession: (id) => this.onCloseSession(id), - postSessionClosed: (id) => this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id }), + postSessionClosed: (id, projectId) => + this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id, projectId }), log: (...args) => this.log(...args), }) this.unsubTool = this.connectionService.onEventFiltered( @@ -829,6 +832,7 @@ export class AgentManagerProvider implements Disposable { } private onDiffMessage(m: AgentManagerInMessage): Record | null | undefined { + if ("projectId" in m && m.projectId && m.projectId !== this.context?.id) return null if (m.type === "agentManager.requestWorktreeDiff") { void this.diffs.request(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null @@ -854,14 +858,14 @@ export class AgentManagerProvider implements Disposable { return null } if (m.type === "agentManager.requestDiffBranches") { - void this.diffs.postBranches(composeDiffId(m.sessionId, normalizeScope(m.scope))) + void this.sendDiffBranches(m.sessionId, m.scope, this.context?.id) return null } if (m.type === "agentManager.setDiffBaseBranch") { void this.diffs .setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch) .catch((err) => this.log("Failed to set diff base:", err instanceof Error ? err.message : String(err))) - .then(() => void this.diffs.postBranches(composeDiffId(m.sessionId, normalizeScope(m.scope)))) + .then(() => void this.sendDiffBranches(m.sessionId, m.scope, this.context?.id)) return null } if (m.type === "agentManager.openFile") { @@ -871,6 +875,17 @@ export class AgentManagerProvider implements Disposable { if (m.type === "agentManager.requestDocument") return this.diffs.document(m.sessionId, m.file, m.contextKey) } + private async sendDiffBranches(sessionId: string, scope?: string, projectId = this.context?.id): Promise { + return postDiffBranches( + this.diffs, + (message) => this.postToWebview(message), + (...args) => this.log(...args), + sessionId, + scope, + projectId, + ) + } + private onBridgeMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type !== "openFile") return undefined @@ -1163,6 +1178,7 @@ export class AgentManagerProvider implements Disposable { notifyForked: (s, from, wt) => this.postToWebview({ type: "agentManager.sessionForked", + projectId: this.context?.id, sessionId: s.id, forkedFromId: from, worktreeId: wt, @@ -1265,7 +1281,7 @@ export class AgentManagerProvider implements Disposable { try { const branch = await manager.currentBranch() const defaultBranch = await manager.defaultBranch() - this.postToWebview({ type: "agentManager.repoInfo", branch, defaultBranch }) + this.postToWebview({ type: "agentManager.repoInfo", branch, defaultBranch, projectId: this.context?.id }) } catch (error) { this.log(`Failed to get current branch: ${error}`) } @@ -1306,6 +1322,7 @@ export class AgentManagerProvider implements Disposable { this.pushState() this.postToWebview({ type: "agentManager.sessionAdded", + projectId: this.context?.id, sessionId: session.id, worktreeId: worktree.id, }) diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts b/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts index 1f24b314ead..507d0e79d0c 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts @@ -21,7 +21,7 @@ export interface OrchestrationBridgeDeps { pushState: (ctx?: ProjectContext) => void hasPanelSession: (id: string) => boolean closeSession: (id: string) => Promise - postSessionClosed: (id: string) => void + postSessionClosed: (id: string, projectId?: string) => void log: (...args: unknown[]) => void } @@ -57,7 +57,7 @@ export function createOrchestrationBridge(deps: OrchestrationBridgeDeps): AgentM } else { await deps.closeSession(id) } - deps.postSessionClosed(id) + deps.postSessionClosed(id, ctx?.id) }, directories: () => { const all: string[] = [] diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index fc67efa5fb2..df5dbbec502 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -21,6 +21,7 @@ interface PRBridgeHost { openExternal(url: string): void log(...args: unknown[]): void semaphore?: Semaphore + projectId?: () => string | undefined } /** Minimal panel surface needed by the bridge (subset of PanelContext). */ @@ -51,6 +52,7 @@ export class PRStatusBridge { openExternal: (url: string) => void log: (...args: unknown[]) => void semaphore?: Semaphore + projectId?: () => string | undefined }): PRStatusBridge { return new PRStatusBridge(opts) } @@ -81,61 +83,63 @@ export class PRStatusBridge { /** Handle an incoming webview message. Returns true if handled. */ handleMessage(m: Record): boolean { if (m.type === "agentManager.refreshPR") { + if (typeof m.projectId === "string" && m.projectId !== this.host.projectId?.()) return true this.poller.refresh(m.worktreeId as string) return true } if (m.type === "agentManager.openPR") { - const url = (m.url as string) ?? this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)?.prUrl - if (url) this.host.openExternal(url) - return true - } - const isResolve = m.type === "agentManager.resolveComment" - const isUnresolve = m.type === "agentManager.unresolveComment" - if (isResolve || isUnresolve) { - const id = m.worktreeId as string - const threadId = m.threadId as string - const wt = this.host.getWorktrees().find((w: Worktree) => w.id === id) - const cwd = wt?.path ?? this.host.getWorkspaceRoot() - const resultType = isResolve ? "agentManager.resolveCommentResult" : "agentManager.unresolveCommentResult" - if (!cwd) { - this.host.log("resolveComment: no cwd for worktree", id) - this.host.postToWebview({ - type: resultType, - worktreeId: id, - threadId, - success: false, - }) + const explicit = typeof m.url === "string" ? m.url : undefined + if (explicit) { + this.host.openExternal(explicit) return true } - const action = isResolve ? resolveComment : unresolveComment - action(threadId, cwd).then( - () => { - this.host.postToWebview({ - type: resultType, - worktreeId: id, - threadId, - success: true, - }) - // Refresh PR data after successful mutation to get updated comment state - this.poller.refresh(id) - }, - (err: unknown) => { - const msg = err instanceof Error ? err.message : String(err) - this.host.log(`${resultType} failed: ${msg}`) - this.host.postToWebview({ - type: resultType, - worktreeId: id, - threadId, - success: false, - error: ghErrorReason(msg), - }) - }, - ) + if (typeof m.projectId === "string" && m.projectId !== this.host.projectId?.()) return true + const url = this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)?.prUrl + if (url) this.host.openExternal(url) return true } + if (m.type === "agentManager.resolveComment" || m.type === "agentManager.unresolveComment") + return this.handleComment(m) return false } + private handleComment(m: Record): boolean { + if (typeof m.projectId === "string" && m.projectId !== this.host.projectId?.()) return true + const id = m.worktreeId as string + const threadId = m.threadId as string + const projectId = typeof m.projectId === "string" ? m.projectId : this.host.projectId?.() + const wt = this.host.getWorktrees().find((w) => w.id === id) + const cwd = wt?.path ?? this.host.getWorkspaceRoot() + const resolve = m.type === "agentManager.resolveComment" + const resultType = resolve ? "agentManager.resolveCommentResult" : "agentManager.unresolveCommentResult" + const result = (success: boolean, error?: string) => + this.host.postToWebview({ + type: resultType, + ...(projectId ? { projectId } : {}), + worktreeId: id, + threadId, + success, + ...(error ? { error } : {}), + }) + if (!cwd) { + this.host.log("resolveComment: no cwd for worktree", id) + result(false) + return true + } + const action = resolve ? resolveComment : unresolveComment + action(threadId, cwd).then( + () => { + result(true) + this.poller.refresh(id) + }, + (err: unknown) => { + this.host.log(`${resultType} failed: ${err instanceof Error ? err.message : String(err)}`) + result(false, ghErrorReason(err instanceof Error ? err.message : String(err))) + }, + ) + return true + } + /** Remove cached status for a deleted worktree. */ remove(worktreeId: string): void { this.cache.delete(worktreeId) @@ -189,6 +193,7 @@ function reportError( worktreeId: id, pr: null, error: err, + ...(host.projectId?.() ? { projectId: host.projectId() } : {}), } as AgentManagerOutMessage) // Always forward auth/missing errors so the webview can show a toast, // regardless of whether prior data exists. Deduplicate per error type @@ -210,7 +215,12 @@ function accept(bridge: PRStatusBridge, host: PRBridgeHost, id: string, pr: PRSt return } const merged = pr && prev ? mergePRStatus(prev, pr) : pr - const msg = { type: "agentManager.prStatus", worktreeId: id, pr: merged } as AgentManagerOutMessage + const msg = { + type: "agentManager.prStatus", + worktreeId: id, + pr: merged, + ...(host.projectId?.() ? { projectId: host.projectId() } : {}), + } as AgentManagerOutMessage bridge["cache"].set(id, msg) if (pr && branch !== undefined) bridge["branches"].set(id, branch) if (!pr) bridge["branches"].delete(id) diff --git a/packages/kilo-vscode/src/agent-manager/project/diff-branches.ts b/packages/kilo-vscode/src/agent-manager/project/diff-branches.ts new file mode 100644 index 00000000000..cdac26fb2ee --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/diff-branches.ts @@ -0,0 +1,30 @@ +import type { AgentManagerOutMessage } from "../types" +import { composeDiffId, normalizeScope } from "../diff-scope" +import type { WorktreeDiffController } from "../worktree-diff-controller" + +export async function sendDiffBranches( + diffs: WorktreeDiffController, + post: (message: AgentManagerOutMessage) => void, + log: (...args: unknown[]) => void, + sessionId: string, + scope?: string, + projectId?: string, +): Promise { + const id = composeDiffId(sessionId, normalizeScope(scope)) + const result = await diffs.branches(id).catch((err) => { + log("Failed to list diff branches:", err instanceof Error ? err.message : String(err)) + return undefined + }) + if (!result) return + post({ + type: "agentManager.diffBranches", + projectId, + sessionId: id, + branches: result.branches, + defaultBranch: result.defaultBranch, + autoBase: result.autoBase, + currentBase: result.currentBase, + isAuto: result.isAuto, + currentBranch: result.currentBranch, + }) +} diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index 997c7a82406..a6b9cc9f9cb 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -122,7 +122,12 @@ function rememberTarget(projectId: string, target: SidebarTarget, deps: ProjectM // Never persist a target the project does not have: the webview can race a // project switch and still hold the previous project's selection. if (target.kind === "worktree" && !state.getWorktree(target.worktreeId)) return - if (target.kind === "session" && !state.getSession(target.sessionId)) return + if ( + target.kind === "session" && + !state.getSession(target.sessionId) && + !deps.contexts.get(projectId)?.hasLiveSession(target.sessionId) + ) + return state.setActiveTarget(target) } diff --git a/packages/kilo-vscode/src/agent-manager/project/pollers.ts b/packages/kilo-vscode/src/agent-manager/project/pollers.ts index a024b4a20ac..3fb0534a2f6 100644 --- a/packages/kilo-vscode/src/agent-manager/project/pollers.ts +++ b/packages/kilo-vscode/src/agent-manager/project/pollers.ts @@ -79,6 +79,7 @@ function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair { openExternal: deps.openExternal, log: deps.log, semaphore: deps.semaphore, + projectId: () => ctx.id, }) return { stats, pr } } @@ -209,6 +210,7 @@ export function createPollers(opts: { openExternal: opts.openExternal, log: opts.log, semaphore: opts.semaphore, + projectId: opts.activeId, }) const projects = new ProjectPollers({ git: opts.git, diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 8021bc7e509..7df3cbaa252 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -262,12 +262,14 @@ interface ErrorOutMessage { interface SessionAddedMessage { type: "agentManager.sessionAdded" + projectId?: string sessionId: string worktreeId: string } interface SessionForkedMessage { type: "agentManager.sessionForked" + projectId?: string sessionId: string forkedFromId: string worktreeId?: string @@ -275,6 +277,7 @@ interface SessionForkedMessage { interface SessionClosedMessage { type: "agentManager.sessionClosed" + projectId?: string sessionId: string } @@ -335,10 +338,12 @@ interface RepoInfoMessage { type: "agentManager.repoInfo" branch: string defaultBranch?: string + projectId?: string } interface ApplyWorktreeDiffResultMessage { type: "agentManager.applyWorktreeDiffResult" + projectId?: string worktreeId: string status: ApplyDiffStatus message: string @@ -347,6 +352,7 @@ interface ApplyWorktreeDiffResultMessage { interface WorktreeDiffLoadingMessage { type: "agentManager.worktreeDiffLoading" + projectId?: string sessionId: string loading: boolean } @@ -354,18 +360,21 @@ interface WorktreeDiffLoadingMessage { /** Source-level notice for a diff context (e.g. snapshots disabled). */ interface WorktreeDiffNoticeMessage { type: "agentManager.worktreeDiffNotice" + projectId?: string sessionId: string notice?: string } interface WorktreeDiffMessage { type: "agentManager.worktreeDiff" + projectId?: string sessionId: string diffs: WorktreeDiffEntry[] } interface WorktreeDiffFileMessage { type: "agentManager.worktreeDiffFile" + projectId?: string sessionId: string file: string diff: WorktreeDiffEntry | null @@ -386,6 +395,7 @@ interface DocumentMessage { interface RevertWorktreeFileResultMessage { type: "agentManager.revertWorktreeFileResult" + projectId?: string sessionId: string file: string status: "success" | "error" @@ -395,6 +405,7 @@ interface RevertWorktreeFileResultMessage { /** Branch picker data for a context's diff directory. */ interface DiffBranchesMessage { type: "agentManager.diffBranches" + projectId?: string sessionId: string branches: BranchListItem[] defaultBranch: string @@ -420,6 +431,7 @@ interface PRErrorOutMessage { interface CommentActionResultMessage { type: "agentManager.resolveCommentResult" | "agentManager.unresolveCommentResult" + projectId?: string worktreeId: string threadId: string success: boolean @@ -723,18 +735,21 @@ interface ImportFromPRIn { interface RequestWorktreeDiffIn { type: "agentManager.requestWorktreeDiff" + projectId?: string sessionId: string scope?: string } interface ApplyWorktreeDiffIn { type: "agentManager.applyWorktreeDiff" + projectId?: string worktreeId: string selectedFiles?: string[] } interface RequestWorktreeDiffFileIn { type: "agentManager.requestWorktreeDiffFile" + projectId?: string sessionId: string file: string scope?: string @@ -744,6 +759,7 @@ interface RequestWorktreeDiffFileIn { interface StartDiffWatchIn { type: "agentManager.startDiffWatch" + projectId?: string sessionId: string scope?: string /** Active session for the session scope (ctx alone is a worktree/local id). */ @@ -752,10 +768,12 @@ interface StartDiffWatchIn { interface StopDiffWatchIn { type: "agentManager.stopDiffWatch" + projectId?: string } interface RevertWorktreeFileIn { type: "agentManager.revertWorktreeFile" + projectId?: string sessionId: string file: string scope?: string @@ -763,12 +781,14 @@ interface RevertWorktreeFileIn { interface RequestDiffBranchesIn { type: "agentManager.requestDiffBranches" + projectId?: string sessionId: string scope?: string } interface SetDiffBaseBranchIn { type: "agentManager.setDiffBaseBranch" + projectId?: string sessionId: string scope?: string branch?: string @@ -776,6 +796,7 @@ interface SetDiffBaseBranchIn { interface RefreshPRIn { type: "agentManager.refreshPR" + projectId?: string worktreeId: string } @@ -788,6 +809,7 @@ interface OpenPRIn { interface CommentActionIn { type: "agentManager.resolveComment" | "agentManager.unresolveComment" + projectId?: string worktreeId: string threadId: string } diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index d4ca7059923..c9746389139 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -29,6 +29,7 @@ export interface WorktreeDiffControllerContext { localDiffFile: (dir: string, base: string, file: string) => Promise post: (msg: AgentManagerOutMessage) => void log: (...args: unknown[]) => void + projectId?: () => string | undefined } export class WorktreeDiffController { @@ -37,6 +38,7 @@ export class WorktreeDiffController { private applying: string | undefined /** Intended watch mode for the active context; isPolling lags the initial fetch. */ private poll = false + private owner: string | undefined /** Ephemeral per-context base override, keyed by context id. */ private baseOverrides = new Map() @@ -48,27 +50,32 @@ export class WorktreeDiffController { { loading: (source, loading) => ({ type: "agentManager.worktreeDiffLoading", + projectId: this.owner, sessionId: source.descriptor.id, loading, }), notice: (source, notice) => ({ type: "agentManager.worktreeDiffNotice", + projectId: this.owner, sessionId: source.descriptor.id, notice, }), diffs: (source, diffs) => ({ type: "agentManager.worktreeDiff", + projectId: this.owner, sessionId: source.descriptor.id, diffs: diffs as AgentManagerDiffFile[], }), diffFile: (source, file, diff) => ({ type: "agentManager.worktreeDiffFile", + projectId: this.owner, sessionId: source?.descriptor.id ?? "", file, diff: diff as AgentManagerDiffFile | null, }), revertFileResult: (source, file, result) => ({ type: "agentManager.revertWorktreeFileResult", + projectId: this.owner, sessionId: source?.descriptor.id ?? "", file, status: result.ok ? "success" : "error", @@ -76,6 +83,7 @@ export class WorktreeDiffController { }), unsupportedRevert: (source, file) => ({ type: "agentManager.revertWorktreeFileResult", + projectId: this.owner, sessionId: source?.descriptor.id ?? "", file, status: "error", @@ -166,7 +174,7 @@ export class WorktreeDiffController { } public async request(id: string): Promise { - if (this.controller.currentId !== id) { + if (this.controller.currentId !== id || this.owner !== this.ctx.projectId?.()) { await this.activate(id, false, true) return } @@ -177,7 +185,13 @@ export class WorktreeDiffController { public async requestFile(id: string, file: string): Promise { if (!file) return if (this.controller.currentId !== id) { - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId: id, file, diff: null }) + this.ctx.post({ + type: "agentManager.worktreeDiffFile", + projectId: this.owner, + sessionId: id, + file, + diff: null, + }) return } await this.controller.requestFile(file) @@ -228,7 +242,7 @@ export class WorktreeDiffController { } public start(id: string): void { - if (this.controller.isPolling && this.controller.currentId === id) return + if (this.controller.isPolling && this.controller.currentId === id && this.owner === this.ctx.projectId?.()) return this.ctx.log(`Starting diff polling for ${id}`) void this.activate(id, true, true) } @@ -237,6 +251,7 @@ export class WorktreeDiffController { this.controller.stop() this.target = undefined this.poll = false + this.owner = undefined } /** @@ -271,13 +286,22 @@ export class WorktreeDiffController { private async activate(id: string, poll: boolean, fetch: boolean): Promise { this.target = undefined this.poll = poll + const owner = this.ctx.projectId?.() + this.owner = owner await this.ready("stateReady rejected, continuing diff activate:") + if (this.owner !== owner || this.ctx.projectId?.() !== owner) return const { ctx } = parseDiffId(id) const resolved = await this.resolve(ctx) + if (this.owner !== owner || this.ctx.projectId?.() !== owner) return this.target = resolved ? { sessionId: id, ...resolved } : undefined // Clear any stale source notice up front; sources only push a notice when // one is active, so a swap away from a noticing source must reset it. - this.ctx.post({ type: "agentManager.worktreeDiffNotice", sessionId: id, notice: undefined }) + this.ctx.post({ + type: "agentManager.worktreeDiffNotice", + projectId: this.owner, + sessionId: id, + notice: undefined, + }) this.controller.setContext({ workspaceRoot: this.ctx.getRoot(), dir: resolved?.directory, @@ -367,6 +391,7 @@ export class WorktreeDiffController { private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void { this.ctx.post({ type: "agentManager.revertWorktreeFileResult", + projectId: this.owner, sessionId, file, status: result.ok ? "success" : "error", @@ -382,6 +407,7 @@ export class WorktreeDiffController { ): void { this.ctx.post({ type: "agentManager.applyWorktreeDiffResult", + projectId: this.owner, worktreeId, status, message, diff --git a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts index f0d5ad5fffb..1949df0686d 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts @@ -40,6 +40,16 @@ describe("Agent Manager New Worktree project targeting", () => { expect(css).toContain('[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"])') }) + it("keeps project activation separate from accordion expansion", () => { + const header = readFileSync(join(root, "webview-ui", "agent-manager", "SidebarSectionHeader.tsx"), "utf8") + const projects = readFileSync(join(root, "webview-ui", "agent-manager", "ProjectsSection.tsx"), "utf8") + expect(header).toContain("onClick?: () => void") + expect(header).toContain("(props.onClick ?? props.onToggle)?.()") + expect(projects).toContain("onToggle={() => {") + expect(projects).toContain("onClick={() => {") + expect(projects).toContain("if (!project().active) props.onSelect(project().id)") + }) + it("defines project labels in every Agent Manager locale", () => { const keys = ["agentManager.dialog.project.select", "agentManager.dialog.project.missing"] const locales = readdirSync(join(root, "webview-ui", "agent-manager", "i18n")).filter((file) => diff --git a/packages/kilo-vscode/tests/unit/agent-manager-selection-actions.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-selection-actions.test.ts new file mode 100644 index 00000000000..a3497865b57 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-selection-actions.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test" +import { + rememberSelectionTab, + selectLocalAction, + selectWorktreeAction, + type SelectionActionDeps, +} from "../../webview-ui/agent-manager/selection-actions" + +function deps() { + const calls: string[] = [] + const value: SelectionActionDeps<{ id: string }> = { + saveTabMemory: () => {}, + setReviewActive: () => {}, + setSelection: () => {}, + post: () => {}, + tabMemory: () => ({}), + terms: { hasRemembered: () => false, setActiveId: () => {} }, + nsKey: (id) => id, + activateTerminal: () => {}, + setActivePendingId: () => {}, + focusLocal: (id) => calls.push(`local:${id}`), + selectSession: (id) => calls.push(`select:${id}`), + clearSession: () => {}, + resetSession: () => calls.push("reset"), + isPending: () => false, + isReviewTab: () => false, + } + return { calls, value } +} + +describe("selectWorktreeAction", () => { + it("selects a managed session before its metadata reaches the live store", () => { + const result = deps() + + selectWorktreeAction(result.value, "wt-b", [], ["ses-b"]) + + expect(result.calls).toEqual(["select:ses-b"]) + }) + + it("restores a remembered managed session before choosing the first session", () => { + const result = deps() + result.value.tabMemory = () => ({ "wt-b": "ses-b2" }) + + selectWorktreeAction(result.value, "wt-b", [{ id: "ses-b1" }], ["ses-b1", "ses-b2"]) + + expect(result.calls).toEqual(["select:ses-b2"]) + }) + + it("resets only when the project has no known session", () => { + const result = deps() + + selectWorktreeAction(result.value, "wt-b", []) + + expect(result.calls).toEqual(["reset"]) + }) +}) + +describe("selectLocalAction", () => { + it("restores the remembered second local tab", () => { + const result = deps() + result.value.tabMemory = () => ({ local: "ses-a2" }) + + selectLocalAction(result.value, [{ id: "ses-a1" }, { id: "ses-a2" }]) + + expect(result.calls).toContain("local:ses-a2") + }) + + it("focuses a project session when shared session metadata is stale", () => { + const result = deps() + + selectLocalAction(result.value, [], ["ses-b"]) + + expect(result.calls).toContain("local:ses-b") + }) +}) + +describe("rememberSelectionTab", () => { + it("stores the active tab under the sidebar context", () => { + const calls: string[][] = [] + + rememberSelectionTab((selection, tab) => calls.push([selection, tab]), "local", "ses-a2") + + expect(calls).toEqual([["local", "ses-a2"]]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts b/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts index 0b050648a0a..29d4942fb29 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts @@ -22,7 +22,7 @@ function deps() { const impl: RestoreDeps = { selectLocal: () => record("local")(), selectWorktree: (id) => record("worktree")(id), - selectSession: (id) => record("session")(id), + focusLocal: (id) => record("focusLocal")(id), focusManaged: (wt, sid) => record("managed")(wt, sid), setSelection: (id) => record("selection")(id), setActivePendingId: (id) => record("pending")(id), @@ -57,11 +57,10 @@ describe("restoreProjectTarget", () => { expect(calls.managed).toEqual(["0", "ses-1"]) }) - it("restores a local session target via Local + select", () => { + it("restores a local session target through the local focus path", () => { const { calls, impl } = deps() restoreProjectTarget(state({ activeTarget: { projectId: "prj-a", kind: "session", sessionId: "ses-x" } }), impl) - expect(calls.selection).toEqual(["local"]) - expect(calls.session).toEqual(["ses-x"]) + expect(calls.focusLocal).toEqual(["ses-x"]) }) it("falls back to Local when the target no longer exists", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts b/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts index 146a738be65..eeaed7f91a4 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from "bun:test" import { applyProjectSelection } from "../../webview-ui/agent-manager/project/selection" -function deps(active: string) { +function deps(active: string, applied = active) { const calls: string[] = [] return { calls, value: { active: (projectId: string) => projectId === active, + applied: (projectId: string) => projectId === applied, managed: () => [], local: (projectId: string) => calls.push(`local:${projectId}`), worktree: (projectId: string, worktreeId: string) => calls.push(`worktree:${projectId}:${worktreeId}`), - session: (sessionId: string) => calls.push(`session:${sessionId}`), - openTab: (sessionId: string) => calls.push(`openTab:${sessionId}`), + focusLocal: (sessionId: string) => calls.push(`focusLocal:${sessionId}`), managedSession: (worktreeId: string, sessionId: string) => calls.push(`managed:${worktreeId}:${sessionId}`), }, } @@ -57,11 +57,11 @@ describe("applyProjectSelection", () => { expect(result.calls).toEqual(["local:prj-a", "worktree:prj-a:wt-a"]) }) - it("applies a worktree ack optimistically even before the state push arrives", () => { - // Regression: reactivation pushes state asynchronously, so the ack can - // arrive first. The catalog push is synchronous, so the project is already - // active and the ack must not be dropped. - const result = deps("prj-a") + it("waits for the project state before applying an acknowledgement", () => { + // Cold reactivation can acknowledge before the state push. Applying the + // worktree then would read the previous project's store and clear the + // current transcript. The state restore applies the target afterward. + const result = deps("prj-a", "prj-b") applyProjectSelection( { type: "agentManager.selectionActivated", @@ -70,6 +70,6 @@ describe("applyProjectSelection", () => { result.value, ) - expect(result.calls).toEqual(["worktree:prj-a:wt-a"]) + expect(result.calls).toEqual([]) }) }) diff --git a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts index 12d403fb704..a94155a1e47 100644 --- a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts @@ -21,8 +21,9 @@ const pr: PRStatus = { files: 0, } -function harness(opts: { hasPersisted?: boolean } = {}) { +function harness(opts: { hasPersisted?: boolean; projectId?: string } = {}) { const sent: AgentManagerOutMessage[] = [] + const opened: string[] = [] const worktrees: { id: string; path: string; branch: string; prUrl?: string }[] = [ { id: "wt1", path: "/repo/wt1", branch: "feature" }, ] @@ -32,13 +33,38 @@ function harness(opts: { hasPersisted?: boolean } = {}) { postToWebview: (msg) => sent.push(msg), updateWorktreePR: () => {}, hasPersistedPR: () => opts.hasPersisted ?? false, - openExternal: () => {}, + openExternal: (url) => opened.push(url), log: () => {}, + projectId: () => opts.projectId, }) const onStatus = (bridge.poller as unknown as { options: { onStatus: (...a: unknown[]) => void } }).options.onStatus - return { bridge, sent, onStatus, worktrees } + return { bridge, sent, opened, onStatus, worktrees } } +describe("PRStatusBridge.handleMessage openPR", () => { + it("opens an explicit URL from a background project", () => { + const { bridge, opened } = harness({ projectId: "active" }) + + bridge.handleMessage({ + type: "agentManager.openPR", + projectId: "background", + worktreeId: "wt1", + url: "https://github.com/x/y/pull/2", + }) + + expect(opened).toEqual(["https://github.com/x/y/pull/2"]) + }) + + it("does not look up a background worktree without an explicit URL", () => { + const { bridge, opened, worktrees } = harness({ projectId: "active" }) + worktrees[0]!.prUrl = "https://github.com/x/y/pull/1" + + bridge.handleMessage({ type: "agentManager.openPR", projectId: "background", worktreeId: "wt1" }) + + expect(opened).toEqual([]) + }) +}) + // --- error deduplication --- describe("PRStatusBridge.notifyError", () => { diff --git a/packages/kilo-vscode/tests/unit/project-message-ownership.test.ts b/packages/kilo-vscode/tests/unit/project-message-ownership.test.ts new file mode 100644 index 00000000000..e9583e7d254 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-message-ownership.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "bun:test" +import { ownsParent, ownsProject } from "../../webview-ui/agent-manager/project/message-ownership" + +describe("project message ownership", () => { + it("rejects messages from another project", () => { + expect(ownsProject({ projectId: "a" }, "b")).toBe(false) + expect(ownsProject({}, "b")).toBe(true) + }) + + it("resolves parent session ownership", () => { + const states = { a: { sessions: [{ id: "s-a" }] }, b: { sessions: [{ id: "s-b" }] } } + expect(ownsParent(states, "s-a", "a")).toBe(true) + expect(ownsParent(states, "s-a", "b")).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-review-state.test.ts b/packages/kilo-vscode/tests/unit/project-review-state.test.ts new file mode 100644 index 00000000000..0f77113666e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-review-state.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test" +import { + reviewComments, + reviewKey, + reviewOpen, + pruneReviewState, + setReviewComments, + setReviewOpen, +} from "../../webview-ui/agent-manager/project/review-state" + +describe("project review state", () => { + it("keeps identical contexts separate by project", () => { + let open = setReviewOpen({}, "a", "local", true) + open = setReviewOpen(open, "b", "local", false) + let comments = setReviewComments({}, "a", "local", [{ file: "a.ts" } as never]) + comments = setReviewComments(comments, "b", "local", []) + + expect(reviewKey("a", "local")).not.toBe(reviewKey("b", "local")) + expect(reviewOpen(open, "a", "local")).toBe(true) + expect(reviewOpen(open, "b", "local")).toBe(false) + expect(reviewComments(comments, "a", "local")).toHaveLength(1) + expect(reviewComments(comments, "b", "local")).toHaveLength(0) + }) + + it("prunes removed worktree contexts for one project only", () => { + const values = { "a:wt-1": true, "a:wt-2": true, "b:wt-2": true } + + expect(pruneReviewState(values, "a", new Set(["wt-1"]))).toEqual({ "a:wt-1": true, "b:wt-2": true }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-session-busy.test.ts b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts new file mode 100644 index 00000000000..27d480fcf56 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test" +import { createSessionBusy } from "../../webview-ui/agent-manager/project/session-busy" + +const busy = (statuses: Record) => + createSessionBusy({ + statuses: () => statuses, + permissions: () => [], + questions: () => [], + managed: () => [ + { id: "unknown", worktreeId: "wt-unknown" }, + { id: "idle", worktreeId: "wt-idle" }, + { id: "working", worktreeId: "wt-working" }, + ], + local: () => [], + projects: () => ({ background: [{ id: "unknown", worktreeId: "wt-unknown" }] }), + active: () => "project-a", + }) + +describe("createSessionBusy", () => { + it("does not mark stopped or unknown sessions as busy", () => { + const state = busy({ idle: { type: "idle" } }) + + expect(state.agent("wt-unknown")).toBe(false) + expect(state.agent("wt-idle")).toBe(false) + expect(state.project("background", "wt-unknown")).toBe(false) + }) + + it("marks sessions with an active status as busy", () => { + expect(busy({ working: { type: "busy" } }).agent("wt-working")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts b/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts new file mode 100644 index 00000000000..0825b116ba3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test" +import { createProjectStateHandlers } from "../../webview-ui/agent-manager/project/state-handlers" +import type { AgentManagerStateMessage } from "../../webview-ui/src/types/messages" + +const state = (projectId: string): AgentManagerStateMessage => ({ + type: "agentManager.state", + projectId, + worktrees: [], + sessions: [], + sections: [], + isGitRepo: true, +}) + +describe("createProjectStateHandlers", () => { + it("stores each project state before applying and routing it", () => { + const stored: Record = {} + const applied: AgentManagerStateMessage[] = [] + const routed: AgentManagerStateMessage[] = [] + const handler = createProjectStateHandlers({ + setMulti: () => {}, + setProjects: () => {}, + setStates: (update) => Object.assign(stored, update(stored)), + prune: () => {}, + ensure: () => ({ sections: () => [], applyState: (value) => applied.push(value) }), + active: () => ({ sections: () => [], applyState: (value) => applied.push(value) }), + routeCatalog: () => {}, + routeState: (value) => routed.push(value), + isActive: () => true, + pending: () => false, + setPending: () => {}, + rename: () => {}, + font: () => {}, + }) + const value = state("project-a") + + handler.state(value) + + expect(stored["project-a"]).toBe(value) + expect(applied).toEqual([value]) + expect(routed).toEqual([value]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/tab-order-sync.test.ts b/packages/kilo-vscode/tests/unit/tab-order-sync.test.ts index 6d0a25de610..85c1ed3eb96 100644 --- a/packages/kilo-vscode/tests/unit/tab-order-sync.test.ts +++ b/packages/kilo-vscode/tests/unit/tab-order-sync.test.ts @@ -36,7 +36,7 @@ function scene(init: { w.ids.map((id, i) => ({ id, createdAt: new Date(1700000000000 + wi * 1000 + i).toISOString() })), ), managedSessions: () => state.worktreeSessions.flatMap((w) => w.ids.map((id) => ({ id, worktreeId: w.key }))), - reviewOpenByContext: () => state.review, + reviewOpen: (key) => state.review[key] === true, terminalIdsFor: (key) => state.terminals[key] ?? [], } return { state, sync: createTabOrderSync(deps), deps } @@ -53,7 +53,7 @@ function render(deps: TabOrderSyncDeps, key: string): string[] { .filter((s) => deps.managedSessions().some((ms) => ms.id === s.id && ms.worktreeId === key)) .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) .map((s) => s.id) - const withReview = deps.reviewOpenByContext()[key] === true ? [...sids, deps.REVIEW_TAB_ID] : sids + const withReview = deps.reviewOpen(key) ? [...sids, deps.REVIEW_TAB_ID] : sids const base = [...withReview, ...deps.terminalIdsFor(key)] return applyTabOrder( base.map((id) => ({ id })), @@ -187,7 +187,7 @@ describe("createTabOrderSync persistence filter", () => { localSessionIDs: () => state.localIds, sessions: () => [], managedSessions: () => [], - reviewOpenByContext: () => state.review, + reviewOpen: (key) => state.review[key] === true, terminalIdsFor: (key) => state.terminals[key] ?? [], }) filteredSync.append("LOCAL", "pending_1") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index f520353b59d..b5b1ab4917b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -85,14 +85,32 @@ import { TabBar } from "./TabBar" import { createProjectLive } from "./project/live" import { createProjectSessionsLive } from "./project/sessions-live" import { applyProjectSelection, createTargetRememberer } from "./project/selection" -import { createLocalSessions, persistLocalTabs } from "./project/local-tabs" +import { createLocalSessions, persistLocalTabs, projectLocalIds, projectLocalSessions } from "./project/local-tabs" import { createProjectRegistry, type PersistedProjectTabs } from "./project/registry" import type { WorktreeBusyState } from "./project/store" import { rememberTarget, restoreProjectTarget } from "./project/restore" import { createProjectStateRouter } from "./project/state" +import { createSessionBusy } from "./project/session-busy" +import { switchProject } from "./project/switch" +import { createProjectStateHandlers } from "./project/state-handlers" +import { ownsParent as ownsParentSession, isCurrent } from "./project/message-ownership" +import { + reviewComments as readReviewComments, + reviewOpen as isReviewOpen, + createReviewState, + pruneReviewState, + setReviewComments, + setReviewOpen, +} from "./project/review-state" import { applyRunStatus } from "./project/run-status" import { clearMultiVersionBusy, markMultiVersionBusy } from "./project/progress" -import { createSessionRestore, selectLocalAction, selectWorktreeAction } from "./selection-actions" +import { + createSessionRestore, + createTabMemory, + rememberSelectionTab, + selectLocalAction, + selectWorktreeAction, +} from "./selection-actions" import { DataBridge } from "../src/App" import { LanguageBridge } from "../src/context/language-bridge" import { useLanguage } from "../src/context/language" @@ -261,8 +279,9 @@ const AgentManagerContent: Component = () => { const MIN_SIDEBAR_WIDTH = 200 const MAX_SIDEBAR_WIDTH_RATIO = 0.4 - // Recover persisted local session IDs from webview state - const persisted = vscode.getState() + const persisted = vscode.getState< + PersistedProjectTabs & { sidebarWidth?: number; sidePanelWidth?: number; sidebarCollapsed?: boolean } + >() const registry = createProjectRegistry({ persisted: persisted ?? {}, activeId: () => currentProjectId() ?? "single", @@ -283,7 +302,7 @@ const AgentManagerContent: Component = () => { setSessionsCollapsed(collapsed) vscode.postMessage({ type: "agentManager.setSessionsCollapsed", collapsed }) } - const sidebar = createSidebarCollapse(vscode) + const sidebar = createSidebarCollapse(vscode, { initial: persisted?.sidebarCollapsed }) const sidebarCollapsed = sidebar.collapsed const expandSidebar = sidebar.expand const toggleSidebar = sidebar.toggle @@ -304,7 +323,7 @@ const AgentManagerContent: Component = () => { if (!pr) return undefined return { pr, selected, wt: worktrees().find((w) => w.id === selected) } }) - const diffs = createWorktreeDiffs(vscode) + const diffs = createWorktreeDiffs(vscode, activeProjectId) const diffDatas = diffs.diffDatas const diffLoading = diffs.diffLoading const setDiffLoading = diffs.setDiffLoading @@ -317,9 +336,12 @@ const AgentManagerContent: Component = () => { setSidePanel(SidePanel.Terminal) } - const [reviewOpenByContext, setReviewOpenByContext] = createSignal>({}) - const [reviewCommentsByContext, setReviewCommentsByContext] = createSignal>({}) const reviewComposer = createReviewComposer() + const reviewState = createReviewState() + const reviewOpenByContext = reviewState.open + const setReviewOpenByContext = reviewState.setOpen + const reviewCommentsByContext = reviewState.comments + const setReviewCommentsByContext = reviewState.setComments const [reviewActive, setReviewActive] = createSignal(false) const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified") const documentInspector = createDocumentInspector( @@ -352,7 +374,6 @@ const AgentManagerContent: Component = () => { const subagents = subagentCtl.tabs const markdown = createMarkdownRender(vscode) const worktreeStats = () => registry.active().worktreeStats() - const prStatuses = () => registry.active().prStatuses() const runStatuses = () => registry.active().runStatuses() const setRunStatuses: Setter> = (v) => registry.active().setRunStatuses(v) @@ -505,12 +526,15 @@ const AgentManagerContent: Component = () => { const tabMemory = () => registry.active().tabMemory.all() - const reviewOpen = createMemo(() => selection() !== null && reviewOpenByContext()[selection()!] === true) + const reviewOpen = createMemo(() => { + const sel = selection() + if (sel === null) return false + return isReviewOpen(reviewOpenByContext(), currentProjectId() ?? "single", sel) + }) const setReviewOpenForContext = (context: string, open: boolean) => { setReviewOpenByContext((prev) => { - if (prev[context] === open) return prev - return { ...prev, [context]: open } + return setReviewOpen(prev, currentProjectId() ?? "single", context, open) }) } @@ -523,13 +547,13 @@ const AgentManagerContent: Component = () => { const reviewComments = createMemo(() => { const sel = selection() if (sel === null) return [] as ReviewComment[] - return reviewCommentsByContext()[sel] ?? [] + return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", sel) }) const setReviewCommentsForSelection = (comments: ReviewComment[]) => { const sel = selection() if (sel === null) return - setReviewCommentsByContext((prev) => ({ ...prev, [sel]: comments })) + setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", sel, comments)) } const apply = createApplyToLocal({ @@ -542,6 +566,7 @@ const AgentManagerContent: Component = () => { diffDatas, diffLoading, track: metrics.track, + projectId: activeProjectId, }) const openApplyDialog = apply.openApplyDialog @@ -561,7 +586,8 @@ const AgentManagerContent: Component = () => { // rather than waiting for the next poll cycle if (opening) { const sel = selection() - if (sel && sel !== LOCAL) vscode.postMessage({ type: "agentManager.refreshPR", worktreeId: sel }) + if (sel && sel !== LOCAL) + vscode.postMessage({ type: "agentManager.refreshPR", projectId: activeProjectId(), worktreeId: sel }) } } @@ -601,14 +627,12 @@ const AgentManagerContent: Component = () => { const releaseTabs = () => setTabWidths(false) const worktreeTabOrder = () => registry.active().tabOrder() const setWorktreeTabOrder: Setter> = (v) => registry.active().setTabOrder(v) - // Sidebar worktree order (persisted to extension state) const sidebarWorktreeOrder = () => registry.active().worktreeOrder() const setSidebarWorktreeOrder = (v: Parameters>[0]) => registry.active().setWorktreeOrder(v) const [draggingWorktree, setDraggingWorktree] = createSignal() const [renamingSection, setRenamingSection] = createSignal(null) let pendingNewSection = false - // Pin new tabs at the tail (see tab-order-sync); strip ephemeral ids so agent-manager.json stays clean. const persistTabOrder = (key: string, order: string[]) => { const durable = order.filter((id) => id !== REVIEW_TAB_ID && !isTerminalTabId(id)) vscode.postMessage({ type: "agentManager.setTabOrder", key, order: durable }) @@ -622,23 +646,20 @@ const AgentManagerContent: Component = () => { localSessionIDs, sessions: session.sessions, managedSessions, - reviewOpenByContext, + reviewOpen: (key) => isReviewOpen(reviewOpenByContext(), currentProjectId() ?? "single", key), terminalIdsFor: (key) => terms.forSelection(nsKey(key)).map((t) => t.id), }) const appendToTabOrder = tabOrderSync.append - const addPendingTab = () => { const id = `${PENDING_PREFIX}${crypto.randomUUID()}` const next = addLocalPendingTab({ ids: localSessionIDs(), active: activePendingId() }, id) setLocalSessionIDs(next.ids) appendToTabOrder(LOCAL, id) - // Deactivate any focused terminal so the new pending session is visible. terms.setActiveId(undefined) setActivePendingId(id) session.clearCurrentSession() return id } - const placeLocal = (id: string, pending: string | undefined, active: string | undefined) => { const next = pending ? replacePendingTab({ ids: localSessionIDs(), active }, pending, id) @@ -648,7 +669,17 @@ const AgentManagerContent: Component = () => { if (!pending) tabOrderSync.append(LOCAL, id) if (pending && pending === active) setActivePendingId(undefined) } - + const focusLocalSession = (id: string) => { + const pending = activePendingId() + const replace = pending && localSessionIDs().includes(pending) ? pending : undefined + placeLocal(id, replace, replace) + setActivePendingId(undefined) + terms.setActiveId(undefined) + setReviewActive(false) + setSelection(LOCAL) + session.selectSession(id) + requestChatFocus() + } persistLocalTabs({ tabs: () => { registry.version() @@ -660,16 +691,18 @@ const AgentManagerContent: Component = () => { get: () => vscode.getState>(), set: (value) => vscode.setState(value), }) - - // Save the currently active tab for the current sidebar context before switching away - const saveTabMemory = () => { - const sel = selection() - if (sel === null) return - const active = visibleTabId() - if (active) registry.active().tabMemory.set(sel === LOCAL ? LOCAL : sel, active) - } - - // Invalidate local session IDs if they no longer exist (preserve pending tabs) + const saveTabMemory = createTabMemory({ + selection, + tab: () => terms.activeId() ?? (reviewActive() ? REVIEW_TAB_ID : (session.currentSessionID() ?? activePendingId())), + multi: multiProject, + applied: currentProjectId, + active: activeProjectId, + owns: (sel) => worktrees().some((wt) => wt.id === sel), + pending: isPending, + locals: localSessionIDs, + localTab: (id) => id === REVIEW_TAB_ID || isTerminalTabId(id), + set: (sel, tab) => registry.active().tabMemory.set(sel, tab), + }) createEffect(() => { if (!worktreesLoaded()) return const all = session.sessions() @@ -684,16 +717,15 @@ const AgentManagerContent: Component = () => { for (const id of next.forget) vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) setLocalSessionIDs(next.ids) }) - // Drop in-memory review state for worktrees that no longer exist. createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) setReviewOpenByContext((prev) => { - const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id))) + const next = pruneReviewState(prev, currentProjectId() ?? "single", ids) if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) setReviewCommentsByContext((prev) => { - const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id))) + const next = pruneReviewState(prev, currentProjectId() ?? "single", ids) if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) @@ -710,7 +742,6 @@ const AgentManagerContent: Component = () => { const localSet = createMemo(() => new Set(localSessionIDs())) - // Sessions NOT in any worktree and not local const unassignedSessions = createMemo(() => filterUnassignedSessions(session.sessions(), worktreeSessionIds(), localSet()), ) @@ -726,13 +757,16 @@ const AgentManagerContent: Component = () => { const localSessions = createLocalSessions({ ids: localSessionIDs, - sessions: session.sessions, + sessions: () => { + if (!multiProject()) return session.sessions() + const pid = currentProjectId() ?? "" + return projectLocalSessions(projectSessionsLive()[pid] ?? [], projectLocalIds(projectStates()[pid]), isPending) + }, pending: isPending, root: isKnownRootSession, title: () => t("agentManager.session.newSession"), }) - // Oldest-first sort before applyTabOrder — worktree label and tab bar must agree on "first session". const sessionsForWorktree = (worktreeId: string): SessionInfo[] => { const ids = new Set( managedSessions() @@ -855,42 +889,19 @@ const AgentManagerContent: Component = () => { const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId) - const isAnySessionBusy = (ids: string[]): boolean => { - if (ids.length === 0) return false - const statuses = session.allStatusMap() - const perms = session.permissions() - const qs = session.questions() - for (const id of ids) { - const info = statuses[id] - if (!info || info.type === "idle") continue - const blocked = perms.some((p) => p.sessionID === id) || qs.some((q) => q.sessionID === id) - if (!blocked) return true - } - return false - } - - /** True when an agent session assigned to this worktree is actively working. */ - const isAgentBusy = (worktreeId: string): boolean => { - const ids = managedSessions() - .filter((ms) => ms.worktreeId === worktreeId) - .map((ms) => ms.id) - return isAnySessionBusy(ids) - } - - /** True when a local session is actively working. */ - const isLocalBusy = (): boolean => isAnySessionBusy(localSessionIDs()) - - const projectBusy = (projectId: string, worktreeId: string | null): boolean => { - if (projectId === activeProjectId()) { - return worktreeId === null ? isLocalBusy() : isAgentBusy(worktreeId) - } - const ids = (projectSessionsLive()[projectId] ?? []) - .filter((item) => item.worktreeId === worktreeId) - .map((item) => item.id) - return isAnySessionBusy(ids) - } - - const isSessionBusy = (id: string): boolean => isAnySessionBusy([id]) + const busy = createSessionBusy({ + statuses: session.allStatusMap, + permissions: session.permissions, + questions: session.questions, + managed: managedSessions, + local: localSessionIDs, + projects: projectSessionsLive, + active: activeProjectId, + }) + const isAgentBusy = busy.agent + const isLocalBusy = busy.local + const projectBusy = busy.project + const isSessionBusy = busy.session /** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */ const sortedWorktrees = createMemo(() => sortWorktrees(worktrees(), sidebarWorktreeOrder())) @@ -984,21 +995,30 @@ const AgentManagerContent: Component = () => { nsKey, activateTerminal: (id: string) => termHandlers.activate(id), setActivePendingId, + focusLocal: focusLocalSession, selectSession: session.selectSession, clearSession: session.clearCurrentSession, resetSession: () => session.setCurrentSessionID(undefined), isPending, isReviewTab: (remembered: string | undefined, sel: string) => - remembered === REVIEW_TAB_ID && reviewOpenByContext()[sel] === true, + remembered === REVIEW_TAB_ID && isReviewOpen(reviewOpenByContext(), currentProjectId() ?? "single", sel), } const selectLocal = () => { - selectLocalAction(selectionDeps, localSessions()) + const pid = currentProjectId() ?? "" + selectLocalAction( + selectionDeps, + localSessions(), + projectLocalIds(multiProject() ? projectStates()[pid] : undefined), + ) requestChatFocus() } const selectWorktree = (worktreeId: string) => { - selectWorktreeAction(selectionDeps, worktreeId, sessionsForWorktree(worktreeId)) + const ids = managedSessions() + .filter((item) => item.worktreeId === worktreeId) + .map((item) => item.id) + selectWorktreeAction(selectionDeps, worktreeId, sessionsForWorktree(worktreeId), ids) requestChatFocus() } @@ -1072,38 +1092,21 @@ const AgentManagerContent: Component = () => { pruneLive: (ids) => projectLive.prune(ids), }) - /** Store the project catalog pushed by the extension and drop states of removed projects. */ - const applyProjects = (msg: ExtensionMessage) => { - if (msg.type !== "agentManager.projects") return - const ev = msg as AgentManagerProjectsMessage - setMultiProject(ev.multiProject) - setProjectList(ev.projects) - const ids = new Set(ev.projects.map((p) => p.id)) - setProjectStates((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))) - router.routeCatalog(ev.projects) - } - - /** Apply one project state payload. Background payloads only feed their accordion summary. */ - const applyState = (msg: ExtensionMessage) => { - if (msg.type !== "agentManager.state") return - const state = msg as AgentManagerStateMessage - if (state.terminalFont) setTerminalFont(state.terminalFont) - const pid = state.projectId - if (pid) setProjectStates((prev) => ({ ...prev, [pid]: state })) - const store = pid ? registry.ensure(pid) : registry.active() - // A freshly created section needs the previous list to detect the new id, - // so it is handled before the data write below replaces it. - if (pendingNewSection && isActivePayload(pid)) { - const prev = new Set(store.sections().map((s) => s.id)) - const created = (state.sections ?? []).find((s) => !prev.has(s.id)) - pendingNewSection = false - if (created) setRenamingSection(created.id) - } - // Data lands in the payload's own store unconditionally; the router - // handles only the active-transition effects (selection/tab restore). - store.applyState(state) - router.routeState(state) - } + const stateHandlers = createProjectStateHandlers({ + setMulti: setMultiProject, + setProjects: setProjectList, + setStates: setProjectStates, + prune: (ids) => registry.prune(ids), + ensure: (id) => registry.ensure(id), + active: () => registry.active(), + routeCatalog: router.routeCatalog, + routeState: router.routeState, + isActive: isActivePayload, + pending: () => pendingNewSection, + setPending: (value) => (pendingNewSection = value), + rename: setRenamingSection, + font: (font) => font && setTerminalFont(font), + }) /** Apply the active-transition effects of a state payload (data already landed in the store). */ const applyActiveState = (state: AgentManagerStateMessage) => { @@ -1131,39 +1134,33 @@ const AgentManagerContent: Component = () => { applyTabOrder, ) if (restored) setLocalSessionIDs(restored) - ensurePendingTab(switched === "switched") + if (switched === "switched" && localSessionIDs().length === 0) addPendingTab() if (switched !== "same") { restoreProjectTarget(state, { selectLocal, selectWorktree, - selectSession: session.selectSession, + focusLocal: focusLocalSession, focusManaged: focusManagedSession, setSelection, setActivePendingId, }) requestChatFocus() } - // Recover sidebar collapsed state and mark hydrated so transitions enable sidebar.hydrate(state.sidebarCollapsed) } - /** Track project switches; full selection restore happens via restoreProjectTarget. */ const applyProjectSwitch = (state: AgentManagerStateMessage): "first" | "switched" | "same" => { - const pid = state.projectId - const previousProject = currentProjectId() - if (pid === previousProject) return "same" - setCurrentProjectId(pid) - if (previousProject === undefined) return "first" - setReviewActive(false) - setSidePanel(null) - return "switched" - } - - /** Guarantee a fresh "New Session" tab after switching to a project with no local sessions. */ - const ensurePendingTab = (switched: boolean) => { - if (switched && localSessionIDs().length === 0) addPendingTab() + return switchProject({ + id: state.projectId, + current: currentProjectId, + set: setCurrentProjectId, + first: () => undefined, + close: () => setReviewActive(false), + hide: () => setSidePanel(null), + history: () => setHistory(false), + reset: subagents.reset, + }) } - createTargetRememberer({ pid: activeProjectId, enabled: multiProject, @@ -1173,7 +1170,6 @@ const AgentManagerContent: Component = () => { sessionId: session.currentSessionID, post: vscode.postMessage, }) - onMount(() => { const handler = (event: MessageEvent) => { const msg = event.data @@ -1226,11 +1222,9 @@ const AgentManagerContent: Component = () => { const subagent = (event: Event) => { const detail = (event as CustomEvent<{ sessionID?: unknown; title?: unknown; parentSessionID?: unknown }>).detail if (typeof detail?.sessionID !== "string") return - subagents.open( - detail.sessionID, - typeof detail.title === "string" ? detail.title : undefined, - typeof detail.parentSessionID === "string" ? detail.parentSessionID : undefined, - ) + const parent = typeof detail.parentSessionID === "string" ? detail.parentSessionID : session.currentSessionID() + if (parent && !ownsParentSession(projectStates(), parent, currentProjectId())) return + subagents.open(detail.sessionID, typeof detail.title === "string" ? detail.title : undefined, parent) } window.addEventListener("message", handler) window.addEventListener("agentManager.openSubagent", subagent) @@ -1347,13 +1341,14 @@ const AgentManagerContent: Component = () => { const unsubSessions = vscode.onMessage((msg) => { if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true) if (msg.type === "agentManager.sessionClosed") { + if (!isCurrent(msg, currentProjectId())) return handleCloseTab(msg.sessionId, false) } }) const unsubRun = vscode.onMessage((msg) => applyRunStatus(msg, { ensure: (id) => registry.ensure(id), active: () => registry.active() }), ) - const unsubProjects = vscode.onMessage((msg) => applyProjects(msg)) + const unsubProjects = vscode.onMessage(stateHandlers.projects) // Terminal messages have their own subscription to keep main-handler complexity in check. const terminalDispatch = createTerminalMessageHandler({ @@ -1444,6 +1439,7 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.sessionAdded") { const ev = msg as { type: string; sessionId: string; worktreeId: string } + if (!isCurrent(msg, currentProjectId())) return saveTabMemory() appendToTabOrder(ev.worktreeId, ev.sessionId) setSelection(ev.worktreeId) @@ -1455,6 +1451,7 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.sessionForked") { const ev = msg as { type: string; sessionId: string; forkedFromId: string; worktreeId?: string } + if (!isCurrent(msg, currentProjectId())) return tabOrderSync.insertAfter(ev.worktreeId, ev.forkedFromId, ev.sessionId) if (!ev.worktreeId) { // Local session: insert new tab after the forked-from tab @@ -1480,7 +1477,8 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.focusContextRequested") focusCtl.report() - if (msg.type === "agentManager.state") applyState(msg) + if (msg.type === "agentManager.state" && msg.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true) + if (msg.type === "agentManager.state") stateHandlers.state(msg) // When a multi-version progress update arrives, mark newly created worktrees as loading if ((msg as { type: string }).type === "agentManager.multiVersionProgress") { @@ -1546,43 +1544,47 @@ const AgentManagerContent: Component = () => { } if (msg.type === "agentManager.worktreeDiff") { + if (!isCurrent(msg, currentProjectId())) return diffs.onWorktreeDiff(msg as AgentManagerWorktreeDiffMessage) } if (msg.type === "agentManager.worktreeDiffFile") { + if (!isCurrent(msg, currentProjectId())) return diffs.onWorktreeDiffFile(msg as AgentManagerWorktreeDiffFileMessage) } if (msg.type === "agentManager.worktreeDiffLoading") { + if (!isCurrent(msg, currentProjectId())) return diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage) } if (msg.type === "agentManager.worktreeDiffNotice") { + if (!isCurrent(msg, currentProjectId())) return diffs.onWorktreeDiffNotice(msg as AgentManagerWorktreeDiffNoticeMessage) } if (msg.type === "agentManager.diffBranches") { + if (!isCurrent(msg, currentProjectId())) return review.onBranches(msg as AgentManagerDiffBranchesMessage) } if (msg.type === "agentManager.applyWorktreeDiffResult") { + if (!isCurrent(msg, currentProjectId())) return apply.onApplyResult(msg as AgentManagerApplyWorktreeDiffResultMessage) } - if (msg.type === "agentManager.revertWorktreeFileResult") revertCtl.onResult(msg as never) + if (msg.type === "agentManager.revertWorktreeFileResult") { + if (!isCurrent(msg, currentProjectId())) return + revertCtl.onResult(msg as never) + } applyProjectSelection(msg, { - // The catalog push is synchronous, so activeProjectId is current when - // the ack arrives; currentProjectId may still be catching up after an - // async project reactivation, so it must not be part of the guard. active: (projectId) => activeProjectId() === projectId, + applied: (projectId) => currentProjectId() === projectId, managed: (projectId) => projectLive.sessions()[projectId] ?? projectStates()[projectId]?.sessions ?? [], local: () => selectLocal(), - // The active guard above already scopes by project; apply the worktree - // optimistically and let the arriving state reconcile. worktree: (projectId, worktreeId) => selectWorktree(worktreeId), - session: session.selectSession, - openTab: (id) => placeLocal(id, undefined, undefined), + focusLocal: focusLocalSession, managedSession: focusManagedSession, }) @@ -1618,7 +1620,6 @@ const AgentManagerContent: Component = () => { }) }) - // Always select local on mount to initialize branch info and session state onMount(() => { selectLocal() // Request worktree/session state from extension — handles race where @@ -1634,8 +1635,6 @@ const AgentManagerContent: Component = () => { const diffCtx = createMemo(() => selection() ?? undefined) - // Active session within the diff context. The Session scope follows it, so - // switching session tabs swaps only the session diff. const activeDiffSession = createMemo(() => { const sel = selection() if (!sel) return undefined @@ -1651,18 +1650,17 @@ const AgentManagerContent: Component = () => { return managedSessions().find((entry) => entry.worktreeId === sel)?.id }) - // Diff scope + base branch state, shared by the side panel and review tab. const review = createDiffReviewScope({ ctx: diffCtx, session: activeDiffSession, panelOpen: diffOpen, reviewActive, vscode, + project: activeProjectId, }) // The composite id (ctx#scope) the extension keys diff data by. const diffScopeId = review.id - // Shared scope + base-picker controls for the side panel and review tab. const diffScopeControls = (compact: boolean) => ( { const id = review.id() if ((panel || active) && id) { - vscode.postMessage({ type: "agentManager.startDiffWatch", ...wireDiffId(id) }) + vscode.postMessage({ type: "agentManager.startDiffWatch", projectId: activeProjectId(), ...wireDiffId(id) }) return } setDiffLoading(false) - vscode.postMessage({ type: "agentManager.stopDiffWatch" }) + vscode.postMessage({ type: "agentManager.stopDiffWatch", projectId: activeProjectId() }) }) onCleanup(() => { if (diffOpen() || reviewActive()) { - vscode.postMessage({ type: "agentManager.stopDiffWatch" }) + vscode.postMessage({ type: "agentManager.stopDiffWatch", projectId: activeProjectId() }) } }) @@ -1762,7 +1760,7 @@ const AgentManagerContent: Component = () => { const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId)) - const revertCtl = createRevertFile(diffScopeId, diffCtx, () => review.scope(), vscode, showToast, t) + const revertCtl = createRevertFile(diffScopeId, diffCtx, () => review.scope(), vscode, showToast, t, activeProjectId) const handleConfigureSetupScript = () => { vscode.postMessage({ type: "agentManager.configureSetupScript" }) @@ -2008,6 +2006,7 @@ const AgentManagerContent: Component = () => { const selectSessionTab = (id: string, pending: boolean) => { batch(() => { + rememberSelectionTab((sel, tab) => registry.active().tabMemory.set(sel, tab), selection(), id) setReviewActive(false) if (pending) { setActivePendingId(id) @@ -2685,6 +2684,7 @@ const AgentManagerContent: Component = () => { = (props) => { const unsubscribe = vscode.onMessage((message) => { if (message.type !== "agentManager.branches") return const event = message as AgentManagerBranchesMessage + if (event.projectId !== props.projectId) return setBranches(event.branches) setLoading(false) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index 4908e2616e9..f5200872a7a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -283,7 +283,10 @@ export const ProjectSidebarBody: Component = (props) => { onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })} onCopyPath={() => navigator.clipboard.writeText(worktree.path)} onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })} - onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })} + onOpenPR={() => { + const url = props.prs?.[worktree.id]?.url + post({ type: "agentManager.openPR", worktreeId: worktree.id, ...(url ? { url } : {}) }) + }} /> ) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx index ab2f69a98ac..ee75e92802a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx @@ -86,6 +86,9 @@ export const ProjectsSection: Component = (props) => ( if (project().missing) return const expanded = !project().expanded props.onExpand(project().id, expanded) + }} + onClick={() => { + if (project().missing) return if (!project().active) props.onSelect(project().id) }} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx index c71afe23305..2c4de2b253f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -40,6 +40,7 @@ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigat /** Everything the legacy single-project sidebar body reads from the app. */ export interface SidebarBodyProps { t: LanguageContextValue["t"] + projectId?: string selection: () => string | null currentSessionID: () => string | undefined selectLocal: () => void @@ -340,9 +341,15 @@ export const SidebarBody: Component = (props) => { : undefined } runStatus={props.runStatuses()[wt.id]} - onOpenPR={props.track("open_pull_request", "worktree_menu", () => - vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id }), - )} + onOpenPR={props.track("open_pull_request", "worktree_menu", () => { + const url = props.prStatuses()[wt.id]?.url + vscode.postMessage({ + type: "agentManager.openPR", + projectId: props.projectId, + worktreeId: wt.id, + ...(url ? { url } : {}), + }) + })} sections={props.sections()} currentSectionId={wt.sectionId} onMoveToSection={(secId) => props.moveToSection([wt.id], secId)} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx index bb0be227cd9..ad5a8304de3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx @@ -5,6 +5,7 @@ interface Props { label: JSX.Element expanded?: boolean onToggle?: () => void + onClick?: () => void count?: JSX.Element actions?: JSX.Element class?: string @@ -20,7 +21,7 @@ export const SidebarSectionHeader: Component = (props) => { class={`am-sidebar-header${props.onToggle ? " am-sidebar-header-toggleable" : ""}${props.class ? ` ${props.class}` : ""}`} title={props.title} onClick={(event) => { - if (event.button === 0 && !props.disabled) props.onToggle?.() + if (event.button === 0 && !props.disabled) (props.onClick ?? props.onToggle)?.() }} >
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx index 5635b1bfaec..d57c3a39c50 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx @@ -40,6 +40,7 @@ interface ApplyToLocalOptions { diffLoading: Accessor /** Telemetry: metrics.track(name, surface, data). */ track: ReturnType["track"] + projectId?: Accessor } export function createApplyToLocal(opts: ApplyToLocalOptions) { @@ -116,7 +117,12 @@ export function createApplyToLocal(opts: ApplyToLocalOptions) { conflicts: [], }, })) - vscode.postMessage({ type: "agentManager.applyWorktreeDiff", worktreeId, selectedFiles }) + vscode.postMessage({ + type: "agentManager.applyWorktreeDiff", + projectId: opts.projectId?.(), + worktreeId, + selectedFiles, + }) } const resetApplyDialog = () => { @@ -177,7 +183,7 @@ export function createApplyToLocal(opts: ApplyToLocalOptions) { setApplyTarget(sel) setApplySelectionTouched(false) setApplySelectedFiles([]) - vscode.postMessage({ type: "agentManager.requestWorktreeDiff", sessionId: sel }) + vscode.postMessage({ type: "agentManager.requestWorktreeDiff", projectId: opts.projectId?.(), sessionId: sel }) setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts index 5ab26be7815..28eecfa1193 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts @@ -25,6 +25,7 @@ export interface DiffReviewScopeOptions { /** Whether the full-screen review tab is active. */ reviewActive: Accessor vscode: VsCode + project: Accessor } export function createDiffReviewScope(opts: DiffReviewScopeOptions) { @@ -82,7 +83,13 @@ export function createDiffReviewScope(opts: DiffReviewScopeOptions) { // Optimistic update; the extension echoes authoritative state back. setCurrentBase(branch ?? autoBase()) setIsAuto(branch === undefined) - opts.vscode.postMessage({ type: "agentManager.setDiffBaseBranch", sessionId: ctx, scope: scope.scope(), branch }) + opts.vscode.postMessage({ + type: "agentManager.setDiffBaseBranch", + projectId: opts.project(), + sessionId: ctx, + scope: scope.scope(), + branch, + }) } // Fetch branch picker data whenever the Branch scope becomes active for the @@ -93,7 +100,12 @@ export function createDiffReviewScope(opts: DiffReviewScopeOptions) { if (!ctx) return if (!opts.panelOpen() && !opts.reviewActive()) return setLoading(true) - opts.vscode.postMessage({ type: "agentManager.requestDiffBranches", sessionId: ctx, scope: scope.scope() }) + opts.vscode.postMessage({ + type: "agentManager.requestDiffBranches", + projectId: opts.project(), + sessionId: ctx, + scope: scope.scope(), + }) }) /** Handle the extension's diffBranches push, ignoring stale contexts. */ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx index e2b00c0545c..884a6175dc8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx @@ -13,6 +13,7 @@ import { SectionHeading } from "./SectionHeading" interface Props { comments: NonNullable + projectId?: string worktreeId: string activeTerminalId?: string onOpenFile?: (file: string, line?: number) => void @@ -68,6 +69,7 @@ export function PRComments(props: Props) { const unresolveResult = msg?.type === "agentManager.unresolveCommentResult" if (!resolveResult && !unresolveResult) return if (msg.worktreeId !== props.worktreeId) return + if (props.projectId && msg.projectId !== props.projectId) return // Success waits for the poll to report the new server state. if (msg.success) return const id = msg.threadId as string @@ -100,6 +102,7 @@ export function PRComments(props: Props) { })) vscode.postMessage({ type: next ? "agentManager.resolveComment" : "agentManager.unresolveComment", + projectId: props.projectId, worktreeId: props.worktreeId, threadId: comment.threadId, } as never) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index 35708e592f0..5ea55150096 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -17,6 +17,7 @@ import "./pr-panel.css" interface PRPanelProps { pr: PRStatus worktree?: WorktreeState + projectId?: string worktreeId: string activeTerminalId?: string onClose: () => void @@ -146,6 +147,7 @@ export const PRPanel: Component = (props) => {
item.worktreeId === null).map((item) => item.id) ?? [] +} + +export function projectLocalSessions( + live: ProjectSessionInfo[], + ids: string[], + isPending: (id: string) => boolean, +): SessionInfo[] { + const now = new Date().toISOString() + const known = new Map(live.filter((item) => item.worktreeId === null).map((item) => [item.id, item])) + for (const id of ids) { + if (isPending(id) || known.has(id)) continue + known.set(id, { id, parentID: null, createdAt: now, updatedAt: now, worktreeId: null }) + } + return [...known.values()] +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/message-ownership.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/message-ownership.ts new file mode 100644 index 00000000000..a35ec136fd4 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/message-ownership.ts @@ -0,0 +1,16 @@ +export function ownsProject(message: { projectId?: string }, project: string | undefined): boolean { + return !message.projectId || message.projectId === project +} + +export function ownsParent( + projects: Record }>, + parent: string, + project: string | undefined, +): boolean { + const owner = Object.entries(projects).find(([, state]) => state.sessions.some((item) => item.id === parent)) + return !owner || owner[0] === project +} + +export function isCurrent(message: { projectId?: string }, project: string | undefined): boolean { + return ownsProject(message, project) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/restore.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/restore.ts index cf551bea4dc..95a4f14007f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/restore.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/restore.ts @@ -18,7 +18,7 @@ import { LOCAL } from "../navigate" export interface RestoreDeps { selectLocal: () => void selectWorktree: (id: string) => void - selectSession: (id: string) => void + focusLocal: (id: string) => void focusManaged: (worktreeId: string, sessionId: string) => void setSelection: (id: string | null) => void setActivePendingId: (id: string | undefined) => void @@ -40,9 +40,8 @@ export function restoreProjectTarget(state: AgentManagerStateMessage, deps: Rest deps.focusManaged(managed.worktreeId, managed.id) return } - // Local or unmanaged live session: go to Local and select it directly. - deps.setSelection(LOCAL) - deps.selectSession(target.sessionId) + // Local or unmanaged live session: add it to Local and select it directly. + deps.focusLocal(target.sessionId) return } deps.selectLocal() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts new file mode 100644 index 00000000000..ce0aa9e51c6 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts @@ -0,0 +1,57 @@ +import type { ReviewComment } from "../../src/types/messages" +import { createSignal } from "solid-js" + +export function createReviewState() { + const [open, setOpen] = createSignal>({}) + const [comments, setComments] = createSignal>({}) + return { open, setOpen, comments, setComments } +} + +export function reviewKey(project: string, context: string): string { + return `${project}:${context}` +} + +export function reviewOpen(values: Record, project: string, context: string): boolean { + return values[reviewKey(project, context)] === true +} + +export function setReviewOpen( + values: Record, + project: string, + context: string, + open: boolean, +): Record { + const key = reviewKey(project, context) + if (values[key] === open) return values + return { ...values, [key]: open } +} + +export function reviewComments( + values: Record, + project: string, + context: string, +): ReviewComment[] { + return values[reviewKey(project, context)] ?? [] +} + +export function setReviewComments( + values: Record, + project: string, + context: string, + comments: ReviewComment[], +): Record { + return { ...values, [reviewKey(project, context)]: comments } +} + +export function pruneReviewState( + values: Record, + project: string, + contexts: Set, +): Record { + return Object.fromEntries( + Object.entries(values).filter(([key]) => { + const [owner, context] = key.split(":") + return owner !== project || context === "local" || contexts.has(context) + }), + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/selection.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/selection.ts index e2b9ef94b1e..0d12f48d39e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/selection.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/selection.ts @@ -7,11 +7,11 @@ export function applyProjectSelection( msg: ExtensionMessage, deps: { active: (projectId: string) => boolean + applied: (projectId: string) => boolean managed: (projectId: string) => ManagedSessionState[] local: (projectId: string) => void worktree: (projectId: string, worktreeId: string) => void - session: (sessionId: string) => void - openTab: (sessionId: string) => void + focusLocal: (sessionId: string) => void managedSession: (worktreeId: string, sessionId: string) => void }, ): boolean { @@ -20,6 +20,11 @@ export function applyProjectSelection( // A selection acknowledgement can arrive after the user switched again. // Ignore it unless this project's catalog entry and state are both active. if (!deps.active(target.projectId)) return true + // State application owns the project-scoped stores used by the callbacks. + // A cold reactivation can acknowledge before that state arrives; waiting for + // the state transition prevents the previous project's store from handling + // this target. restoreProjectTarget applies the persisted target afterward. + if (!deps.applied(target.projectId)) return true // Scope by project like the session branch: a selection ack must never act on // another project's data if it lands before that project's state push. if (target.kind === "local") deps.local(target.projectId) @@ -28,11 +33,9 @@ export function applyProjectSelection( const session = deps.managed(target.projectId).find((item) => item.id === target.sessionId) if (session?.worktreeId) deps.managedSession(session.worktreeId, target.sessionId) else { - // An unassigned session joins the project's local tabs, mirroring what - // selecting it from the legacy sidebar does, before it becomes current. - deps.openTab(target.sessionId) - deps.local(target.projectId) - deps.session(target.sessionId) + // An unassigned session joins the project's local tabs before it becomes + // current, replacing any temporary New Session draft. + deps.focusLocal(target.sessionId) } } return true diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts new file mode 100644 index 00000000000..d417c0209a5 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts @@ -0,0 +1,45 @@ +interface Item { + id: string + worktreeId?: string | null +} + +interface Status { + type: string +} + +interface Prompt { + sessionID: string +} + +export function createSessionBusy(opts: { + statuses: () => Record + permissions: () => Prompt[] + questions: () => Prompt[] + managed: () => Item[] + local: () => string[] + projects: () => Record + active: () => string | undefined +}) { + const any = (ids: string[]) => { + if (ids.length === 0) return false + const statuses = opts.statuses() + const blocked = new Set([...opts.permissions(), ...opts.questions()].map((item) => item.sessionID)) + return ids.some((id) => { + const status = statuses[id] + return !!status && status.type !== "idle" && !blocked.has(id) + }) + } + const agent = (id: string) => + any( + opts + .managed() + .filter((item) => item.worktreeId === id) + .map((item) => item.id), + ) + const local = () => any(opts.local()) + const project = (id: string, worktreeId: string | null) => { + if (id === opts.active()) return worktreeId === null ? local() : agent(worktreeId) + return any((opts.projects()[id] ?? []).filter((item) => item.worktreeId === worktreeId).map((item) => item.id)) + } + return { any, agent, local, project, session: (id: string) => any([id]) } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/state-handlers.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/state-handlers.ts new file mode 100644 index 00000000000..742bee0b4bc --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/state-handlers.ts @@ -0,0 +1,49 @@ +import type { ExtensionMessage, AgentManagerProjectsMessage, AgentManagerStateMessage } from "../../src/types/messages" + +export function createProjectStateHandlers(opts: { + setMulti: (value: boolean) => void + setProjects: (value: AgentManagerProjectsMessage["projects"]) => void + setStates: ( + value: (prev: Record) => Record, + ) => void + prune: (ids: Set) => void + ensure: (id: string) => { + sections: () => Array<{ id: string }> + applyState: (state: AgentManagerStateMessage) => void + } + active: () => { sections: () => Array<{ id: string }>; applyState: (state: AgentManagerStateMessage) => void } + routeCatalog: (projects: AgentManagerProjectsMessage["projects"]) => void + routeState: (state: AgentManagerStateMessage) => void + isActive: (id: string | undefined) => boolean + pending: () => boolean + setPending: (value: boolean) => void + rename: (id: string) => void + font: (font: AgentManagerStateMessage["terminalFont"]) => void +}) { + const projects = (msg: ExtensionMessage) => { + if (msg.type !== "agentManager.projects") return + const ids = new Set(msg.projects.map((item) => item.id)) + opts.setMulti(msg.multiProject) + opts.setProjects(msg.projects) + opts.setStates((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))) + opts.prune(ids) + opts.routeCatalog(msg.projects) + } + + const state = (msg: ExtensionMessage) => { + if (msg.type !== "agentManager.state") return + if (msg.terminalFont) opts.font(msg.terminalFont) + if (msg.projectId) opts.setStates((prev) => ({ ...prev, [msg.projectId!]: msg })) + const store = msg.projectId ? opts.ensure(msg.projectId) : opts.active() + if (opts.pending() && opts.isActive(msg.projectId)) { + const prior = new Set(store.sections().map((item) => item.id)) + const created = (msg.sections ?? []).find((item) => !prior.has(item.id)) + opts.setPending(false) + if (created) opts.rename(created.id) + } + store.applyState(msg) + opts.routeState(msg) + } + + return { projects, state } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/switch.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/switch.ts new file mode 100644 index 00000000000..d40568660bb --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/switch.ts @@ -0,0 +1,24 @@ +export function switchProject(opts: { + id: string | undefined + current: () => string | undefined + set: (id: string | undefined) => void + first: () => void + close: () => void + hide: () => void + history: () => void + reset: () => void +}): "first" | "switched" | "same" { + const previous = opts.current() + if (opts.id === previous) return "same" + if (previous === undefined) { + opts.set(opts.id) + opts.first() + return "first" + } + opts.close() + opts.hide() + opts.history() + opts.reset() + opts.set(opts.id) + return "switched" +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts index b6a237937e3..3f2a5285180 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts @@ -18,6 +18,7 @@ export function createRevertFile( vscode: VsCode, showToast: (t: Toast) => void, t: (key: string) => string, + projectId?: Accessor, ) { const [files, setFiles] = createSignal>>({}) @@ -36,7 +37,13 @@ export function createRevertFile( set.add(file) return { ...prev, [id]: set } }) - vscode.postMessage({ type: "agentManager.revertWorktreeFile", sessionId: context, file, scope: scope() }) + vscode.postMessage({ + type: "agentManager.revertWorktreeFile", + projectId: projectId?.(), + sessionId: context, + file, + scope: scope(), + }) } function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts b/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts index 5beec40163e..0fdf31a0d26 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts @@ -18,6 +18,40 @@ interface SessionLike { id: string } +export function rememberSelectionTab( + set: (selection: string, tab: string) => void, + selection: string | null, + tab: string, +) { + if (selection !== null) set(selection === LOCAL ? LOCAL : selection, tab) +} + +export function createTabMemory(opts: { + selection: () => string | null + tab: () => string | undefined + multi: () => boolean + applied: () => string | undefined + active: () => string | undefined + owns: (selection: string) => boolean + pending: (id: string) => boolean + locals: () => string[] + localTab?: (id: string) => boolean + set: (selection: string, tab: string) => void +}) { + return () => { + const sel = opts.selection() + const tab = opts.tab() + if (sel === null || !tab) return + if (opts.multi() && opts.applied() !== opts.active()) return + if ( + opts.multi() && + !(sel === LOCAL ? (opts.localTab?.(tab) ?? (opts.pending(tab) || opts.locals().includes(tab))) : opts.owns(sel)) + ) + return + rememberSelectionTab(opts.set, sel, tab) + } +} + export interface SelectionActionDeps { saveTabMemory: () => void setReviewActive: (open: boolean) => void @@ -30,6 +64,7 @@ export interface SelectionActionDeps { nsKey: (sel: string) => string activateTerminal: (id: string) => void setActivePendingId: (id: string | undefined) => void + focusLocal: (id: string) => void selectSession: (id: string) => void clearSession: () => void resetSession: () => void @@ -85,7 +120,11 @@ export function createSessionRestore(deps: { } /** Select the Local context: restore its remembered tab or fall back to the first session/draft. */ -export function selectLocalAction(deps: SelectionActionDeps, locals: T[]): void { +export function selectLocalAction( + deps: SelectionActionDeps, + locals: T[], + ids: string[] = [], +): void { deps.saveTabMemory() deps.post({ type: "agentManager.requestRepoInfo" }) const remembered = deps.tabMemory()[LOCAL] @@ -97,13 +136,21 @@ export function selectLocalAction(deps: SelectionActionDe return } deps.terms.setActiveId(undefined) - const target = remembered ? locals.find((s) => s.id === remembered) : undefined - const fallback = target ?? locals[0] - if (fallback && !deps.isPending(fallback.id)) { + const real = locals.filter((item) => !deps.isPending(item.id)) + const target = remembered ? real.find((s) => s.id === remembered) : undefined + const draft = remembered && deps.isPending(remembered) ? remembered : undefined + const fallback = + target?.id ?? + draft ?? + (remembered && ids.includes(remembered) ? remembered : undefined) ?? + real[0]?.id ?? + ids[0] ?? + locals.find((item) => deps.isPending(item.id))?.id + if (fallback && !deps.isPending(fallback)) { deps.setActivePendingId(undefined) - deps.selectSession(fallback.id) + deps.focusLocal(fallback) } else { - deps.setActivePendingId(fallback && deps.isPending(fallback.id) ? fallback.id : undefined) + deps.setActivePendingId(fallback && deps.isPending(fallback) ? fallback : undefined) deps.clearSession() deps.post({ type: "agentManager.showExistingLocalTerminal" }) } @@ -116,6 +163,7 @@ export function selectWorktreeAction( deps: SelectionActionDeps, worktreeId: string, sessions: T[], + ids: string[] = [], ): void { deps.saveTabMemory() const remembered = deps.tabMemory()[worktreeId] @@ -127,8 +175,9 @@ export function selectWorktreeAction( } deps.terms.setActiveId(undefined) const target = remembered ? sessions.find((s) => s.id === remembered) : undefined - const fallback = target ?? sessions[0] - if (fallback) deps.selectSession(fallback.id) + const fallback = + target?.id ?? (remembered && ids.includes(remembered) ? remembered : undefined) ?? sessions[0]?.id ?? ids[0] + if (fallback) deps.selectSession(fallback) else deps.resetSession() deps.setReviewActive(deps.isReviewTab(remembered, worktreeId)) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-collapse.ts b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-collapse.ts index 01d232f8680..5d4723d9a02 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-collapse.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-collapse.ts @@ -5,18 +5,24 @@ export interface VsCodePoster { postMessage: (msg: WebviewMessage) => void } +interface Options { + initial?: boolean + persist?: (collapsed: boolean) => void +} + /** * Encapsulates the sidebar collapsed signal, persistence postMessage, and * a single-frame "hydrated" flag that gates the width transition so the * initial render (after restart with a persisted-collapsed state) does not * animate from open to closed. */ -export function createSidebarCollapse(vscode: VsCodePoster) { - const [collapsed, setCollapsed] = createSignal(false) +export function createSidebarCollapse(vscode: VsCodePoster, opts: Options = {}) { + const [collapsed, setCollapsed] = createSignal(opts.initial ?? false) const [hydrated, setHydrated] = createSignal(false) const persist = (next: boolean) => { setCollapsed(next) + opts.persist?.(next) vscode.postMessage({ type: "agentManager.setSidebarCollapsed", collapsed: next }) } @@ -24,7 +30,7 @@ export function createSidebarCollapse(vscode: VsCodePoster) { collapsed, hydrated, /** Apply state from extension push without re-broadcasting. */ - hydrate: (value: boolean | undefined) => { + hydrate: (value?: boolean) => { if (value !== undefined) setCollapsed(value) if (!hydrated()) requestAnimationFrame(() => setHydrated(true)) }, diff --git a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts index 1380c08b87e..3a98927064f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts @@ -112,7 +112,15 @@ export function createSubagentTabs(opts: Options) { }) } - return { tabs: list, active: selected, open, select, close, closeOthers, reorder } + const reset = () => { + const scope = key() + for (const tab of tabs()[scope] ?? []) opts.unsync(tab.id) + setTabs((prev) => ({ ...prev, [scope]: [] })) + setActive((prev) => ({ ...prev, [scope]: undefined })) + opts.hide() + } + + return { tabs: list, active: selected, open, select, close, closeOthers, reorder, reset } } export function availableSubagents(parts: ToolPart[]): SubagentTab[] { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/tab-order-sync.ts b/packages/kilo-vscode/webview-ui/agent-manager/tab-order-sync.ts index cf17875e942..f3ca256f3e8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/tab-order-sync.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/tab-order-sync.ts @@ -20,7 +20,7 @@ export interface TabOrderSyncDeps { localSessionIDs: () => string[] sessions: () => { id: string; createdAt: string }[] managedSessions: () => { id: string; worktreeId?: string | null }[] - reviewOpenByContext: () => Record + reviewOpen: (key: string) => boolean terminalIdsFor: (key: string) => string[] } @@ -34,7 +34,7 @@ export function createTabOrderSync(deps: TabOrderSyncDeps) { .filter((s) => deps.managedSessions().some((ms) => ms.id === s.id && ms.worktreeId === key)) .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) .map((s) => s.id) - const withReview = deps.reviewOpenByContext()[key] === true ? [...sids, deps.REVIEW_TAB_ID] : sids + const withReview = deps.reviewOpen(key) ? [...sids, deps.REVIEW_TAB_ID] : sids return [...withReview, ...deps.terminalIdsFor(key)] } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts index 57692d649ca..60ba7cb5991 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -29,7 +29,10 @@ export function wireDiffId(id: string) { return { sessionId: ctx, scope, diffSessionId: sessionId } } -export function createWorktreeDiffs(vscode: ReturnType) { +export function createWorktreeDiffs( + vscode: ReturnType, + project: () => string | undefined = () => undefined, +) { const [diffDatas, setDiffDatas] = createSignal>({}) const [diffLoading, setDiffLoading] = createSignal(false) const [diffNotices, setDiffNotices] = createSignal>({}) @@ -65,7 +68,7 @@ export function createWorktreeDiffs(vscode: ReturnType) { const requestDiffFile = (id: string, file: string) => { if (diffFileLoading()[id]?.[file]) return setDiffFilePending(id, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wireDiffId(id) }) + vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", projectId: project(), file, ...wireDiffId(id) }) } /** Files the backend flagged as stale in a merged update need a fresh fetch. */ @@ -74,7 +77,12 @@ export function createWorktreeDiffs(vscode: ReturnType) { for (const file of files) { if (loading[file]) continue setDiffFilePending(id, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wireDiffId(id) }) + vscode.postMessage({ + type: "agentManager.requestWorktreeDiffFile", + projectId: project(), + file, + ...wireDiffId(id), + }) } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-project.ts b/packages/kilo-vscode/webview-ui/src/context/session-project.ts new file mode 100644 index 00000000000..8c8134b4d24 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-project.ts @@ -0,0 +1,10 @@ +import type { ExtensionMessage } from "../types/messages" + +export function agentProject(message: ExtensionMessage): string | undefined { + if (message.type === "agentManager.projects") return message.projects.find((item) => item.active)?.id + if (message.type === "agentManager.selectionActivated") return message.target.projectId +} + +export function isStaleAgentSession(message: ExtensionMessage, projectId: string | undefined): boolean { + return message.type === "sessionCreated" && !!message.projectId && !!projectId && message.projectId !== projectId +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 573dce5180c..bf6019f83cb 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -51,6 +51,7 @@ import type { MessageLoadMode, ToolPart, } from "../types/messages" +import { agentProject, isStaleAgentSession } from "./session-project" import { removeSessionPermissions, upsertPermission } from "./permission-queue" import { computeStatus, @@ -313,6 +314,13 @@ export const SessionProvider: ParentComponent = (props) => { // Current session ID const [currentSessionID, setCurrentSessionID] = createSignal() + const [agentProjectId, setAgentProjectId] = createSignal() + + const trackAgentProject = (message: ExtensionMessage): boolean => { + if (message.type !== "agentManager.projects" && message.type !== "agentManager.selectionActivated") return false + setAgentProjectId(agentProject(message)) + return true + } const [draftSessionID, setDraftSessionID] = createSignal() const [userClearedSession, setUserClearedSession] = createSignal(false) @@ -1244,9 +1252,15 @@ export const SessionProvider: ParentComponent = (props) => { // Handle messages from extension onMount(() => { - const unsubscribe = vscode.onMessage(handleExtensionMessage) + const unsubscribeProject = vscode.onMessage(trackAgentProject) + const unsubscribe = vscode.onMessage((message) => { + if (!isStaleAgentSession(message, agentProjectId())) handleExtensionMessage(message) + }) setModelUsageReady(true) - onCleanup(unsubscribe) + onCleanup(() => { + unsubscribeProject() + unsubscribe() + }) }) // Event handlers diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 66bf4c6bb70..45fd6436102 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -200,6 +200,7 @@ export interface TodoUpdatedMessage { export interface SessionCreatedMessage { type: "sessionCreated" + projectId?: string session: SessionInfo draftID?: string activate?: boolean @@ -207,6 +208,7 @@ export interface SessionCreatedMessage { export interface SessionForkedMessage { type: "sessionForked" + projectId?: string sessionID: string forkedFromID: string } @@ -729,6 +731,7 @@ export interface AgentManagerRepoInfoMessage { type: "agentManager.repoInfo" branch: string defaultBranch?: string + projectId?: string } // Agent Manager worktree setup progress @@ -747,6 +750,7 @@ export interface AgentManagerWorktreeSetupMessage { // Agent Manager session added to an existing worktree (no setup overlay needed) export interface AgentManagerSessionAddedMessage { type: "agentManager.sessionAdded" + projectId?: string sessionId: string worktreeId: string } @@ -754,6 +758,7 @@ export interface AgentManagerSessionAddedMessage { // Agent Manager session forked from an existing session export interface AgentManagerSessionForkedMessage { type: "agentManager.sessionForked" + projectId?: string sessionId: string forkedFromId: string worktreeId?: string @@ -761,6 +766,7 @@ export interface AgentManagerSessionForkedMessage { export interface AgentManagerSessionClosedMessage { type: "agentManager.sessionClosed" + projectId?: string sessionId: string } @@ -1002,12 +1008,14 @@ export interface AgentManagerImportResultMessage { // Agent Manager: Diff data push (extension → webview) export interface AgentManagerWorktreeDiffMessage { type: "agentManager.worktreeDiff" + projectId?: string sessionId: string diffs: WorktreeFileDiff[] } export interface AgentManagerWorktreeDiffFileMessage { type: "agentManager.worktreeDiffFile" + projectId?: string sessionId: string file: string diff: WorktreeFileDiff | null @@ -1029,6 +1037,7 @@ export interface AgentManagerDocumentMessage { // Agent Manager: Diff loading state (extension → webview) export interface AgentManagerWorktreeDiffLoadingMessage { type: "agentManager.worktreeDiffLoading" + projectId?: string sessionId: string loading: boolean } @@ -1036,12 +1045,14 @@ export interface AgentManagerWorktreeDiffLoadingMessage { // Agent Manager: Source-level diff notice (extension → webview) export interface AgentManagerWorktreeDiffNoticeMessage { type: "agentManager.worktreeDiffNotice" + projectId?: string sessionId: string notice?: DiffViewerNotice } export interface AgentManagerApplyWorktreeDiffResultMessage { type: "agentManager.applyWorktreeDiffResult" + projectId?: string worktreeId: string status: AgentManagerApplyWorktreeDiffStatus message: string @@ -1051,6 +1062,7 @@ export interface AgentManagerApplyWorktreeDiffResultMessage { // Agent Manager: Revert single file result (extension → webview) export interface AgentManagerRevertWorktreeFileResultMessage { type: "agentManager.revertWorktreeFileResult" + projectId?: string sessionId: string file: string status: "success" | "error" @@ -1060,6 +1072,7 @@ export interface AgentManagerRevertWorktreeFileResultMessage { // Agent Manager: Branch picker data for a diff context (extension → webview) export interface AgentManagerDiffBranchesMessage { type: "agentManager.diffBranches" + projectId?: string sessionId: string branches: BranchInfo[] defaultBranch: string diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 72001bf6d98..ecfa8329c30 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -998,12 +998,14 @@ export interface ImportFromPRRequest { // Agent Manager: Request one-shot diff fetch (webview → extension) export interface RequestWorktreeDiffMessage { type: "agentManager.requestWorktreeDiff" + projectId?: string sessionId: string scope?: string } export interface RequestWorktreeDiffFileMessage { type: "agentManager.requestWorktreeDiffFile" + projectId?: string sessionId: string file: string scope?: string @@ -1012,6 +1014,7 @@ export interface RequestWorktreeDiffFileMessage { // Agent Manager: Start polling for live diff updates (webview → extension) export interface StartDiffWatchMessage { type: "agentManager.startDiffWatch" + projectId?: string sessionId: string scope?: string } @@ -1019,11 +1022,13 @@ export interface StartDiffWatchMessage { // Agent Manager: Stop polling for diff updates (webview → extension) export interface StopDiffWatchMessage { type: "agentManager.stopDiffWatch" + projectId?: string } // Agent Manager: Request branch picker data for a diff context (webview → extension) export interface RequestDiffBranchesMessage { type: "agentManager.requestDiffBranches" + projectId?: string sessionId: string scope?: string } @@ -1031,6 +1036,7 @@ export interface RequestDiffBranchesMessage { // Agent Manager: Set or clear the base branch override for a diff context (webview → extension) export interface SetDiffBaseBranchMessage { type: "agentManager.setDiffBaseBranch" + projectId?: string sessionId: string scope?: string branch?: string @@ -1039,6 +1045,7 @@ export interface SetDiffBaseBranchMessage { // Agent Manager: PR messages (webview → extension) export interface RefreshPRMessage { type: "agentManager.refreshPR" + projectId?: string worktreeId: string } @@ -1051,12 +1058,14 @@ export interface OpenPRMessage { export interface CommentActionMessage { type: "agentManager.resolveComment" | "agentManager.unresolveComment" + projectId?: string worktreeId: string threadId: string } export interface ApplyWorktreeDiffMessage { type: "agentManager.applyWorktreeDiff" + projectId?: string worktreeId: string selectedFiles?: string[] }