From e779480abc7959738203fa04c632c6608080efaa Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 17:08:16 +0200 Subject: [PATCH 01/50] fix(vscode): hide manual interruption warning --- .changeset/quiet-manual-interruptions.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 11 +- .../src/agent-manager/AgentManagerProvider.ts | 2 +- .../src/agent-manager/continue-in-worktree.ts | 18 ++- .../kilo-vscode/src/kilo-provider/abort.ts | 15 +- .../cli-backend/connection-service.test.ts | 69 +++++++++ .../cli-backend/connection-service.ts | 60 ++++++-- .../services/cli-backend/explicit-abort.ts | 112 +++++++++++++++ packages/kilo-vscode/tests/unit/abort.test.ts | 29 +++- .../tests/unit/continue-in-worktree.test.ts | 61 ++++---- .../tests/unit/explicit-abort.test.ts | 131 ++++++++++++++++++ .../unit/kilo-provider-load-messages.test.ts | 4 +- 12 files changed, 465 insertions(+), 52 deletions(-) create mode 100644 .changeset/quiet-manual-interruptions.md create mode 100644 packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts create mode 100644 packages/kilo-vscode/tests/unit/explicit-abort.test.ts diff --git a/.changeset/quiet-manual-interruptions.md b/.changeset/quiet-manual-interruptions.md new file mode 100644 index 00000000000..07927ead3ee --- /dev/null +++ b/.changeset/quiet-manual-interruptions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Stop manually aborted turns without briefly showing an interruption warning. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 903ea4101f5..24f0ddaa40d 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -4036,7 +4036,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.cancelRetry(sid) const client = this.client if (!client) return Promise.resolve(false) - return this.aborts.stop(client, sid, this.getWorkspaceDirectory(sid)) + const directory = this.getWorkspaceDirectory(sid) + const dirs = this.aborts.directories(sid, directory) + const ids = new Map(dirs.map((dir) => [dir, this.connectionService.beginExplicitAbort(sid, dir)])) + return this.aborts.stop(client, sid, directory, dirs).then((result) => { + for (const attempt of result.attempts) { + this.connectionService.finishExplicitAbort(sid, attempt.dir, ids.get(attempt.dir)!, attempt.aborted) + } + return result.complete + }) } private async handleAbort(sessionID?: string): Promise { @@ -4044,7 +4052,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!sid || !(await this.stopSession(sid))) return this.sessionStatusMap.set(sid, "idle") this.streams.flush(sid) - this.postMessage({ type: "sessionTurnClosed", sessionID: sid, reason: "interrupted" }) this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d4abec3fe97..ce865e0d282 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1804,7 +1804,7 @@ export class AgentManagerProvider implements Disposable { await continueInWorktree( { root, - getClient: () => this.connectionService.getClient(), + connection: this.connectionService, createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts), runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id), cleanupWorktree: async (id) => { diff --git a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts index 77a51d50311..676a8cbfa61 100644 --- a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts @@ -8,7 +8,10 @@ import { recordForkHandoff } from "./fork-handoff" export interface ContinueContext { root: string - getClient: () => KiloClient + connection: { + getClient: () => KiloClient + runExplicitAbort: (sessionId: string, directory: string, action: () => Promise) => Promise + } createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{ worktree: { id: string } result: CreateWorktreeResult @@ -30,10 +33,13 @@ export type StepResult = { ok: true; value: T } | { ok: false; error: string /** Abort a running session. Best-effort — failures are logged but not fatal. */ export async function abortSession(ctx: ContinueContext, sessionId: string): Promise { try { - const client = ctx.getClient() - await client.session.abort({ sessionID: sessionId }).catch((err) => { - ctx.log("Session abort failed (may already be idle):", getErrorMessage(err)) - }) + await ctx.connection + .runExplicitAbort(sessionId, ctx.root, async () => { + await ctx.connection.getClient().session.abort({ sessionID: sessionId }, { throwOnError: true }) + }) + .catch((err) => { + ctx.log("Session abort failed (may already be idle):", getErrorMessage(err)) + }) } catch (err) { ctx.log("Client not available for abort, continuing:", getErrorMessage(err)) } @@ -96,7 +102,7 @@ async function rollback( export async function forkSession(ctx: ContinueContext, sessionId: string, dir: string): Promise> { let client: KiloClient try { - client = ctx.getClient() + client = ctx.connection.getClient() } catch (err) { ctx.log("Client not available for session fork:", getErrorMessage(err)) return { ok: false, error: "Not connected to CLI backend" } diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index 7bb355b5b26..15f7c7f48a1 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -27,20 +27,27 @@ export class SessionAbort { this.observe(sessionID, status, dir) } - async stop(client: KiloClient, sessionID: string, fallback: string) { - const known = this.active.has(sessionID) + directories(sessionID: string, fallback: string) { const dirs = [...(this.active.get(sessionID) ?? [])] if (!dirs.some((dir) => sameDirectory(dir, fallback))) dirs.push(fallback) + return dirs + } + + async stop(client: KiloClient, sessionID: string, fallback: string, dirs = this.directories(sessionID, fallback)) { + const known = this.active.has(sessionID) const results = await Promise.allSettled(dirs.map((dir) => abortSession({ client, sessionID, dir }))) const failures = results.flatMap((result, index) => result.status === "rejected" ? [{ dir: dirs[index], error: result.reason }] : [], ) if (failures.length > 0) { console.error("[Kilo New] KiloProvider: Failed to abort session in one or more directories:", failures) - return false + return { + complete: false, + attempts: results.map((result, index) => ({ dir: dirs[index], aborted: result.status === "fulfilled" })), + } } if (known) this.active.delete(sessionID) - return known + return { complete: known, attempts: dirs.map((dir) => ({ dir, aborted: true })) } } dispose(dir: string) { diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts index 262b2d64bf8..9ec1e5ccf4f 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import * as vscode from "vscode" import { KiloConnectionService } from "./connection-service" +import type { SSEPayload } from "./sdk-sse-adapter" function state(value: boolean) { return { @@ -39,6 +40,74 @@ describe("KiloConnectionService clients", () => { }) }) +describe("KiloConnectionService explicit aborts", () => { + const close = { + id: "event-close", + type: "session.turn.close", + properties: { sessionID: "session", reason: "interrupted" }, + } as SSEPayload + const status = { + type: "session.status", + properties: { sessionID: "session", status: { type: "busy" } }, + } as SSEPayload + + test("suppresses a successful explicit abort for every subscriber", () => { + const service = new KiloConnectionService({} as any) + const raw: SSEPayload[] = [] + const first: SSEPayload[] = [] + const second: SSEPayload[] = [] + service.onEvent((event) => raw.push(event)) + service.onEventFiltered( + () => true, + (event) => first.push(event), + ) + service.onEventFiltered( + () => true, + (event) => second.push(event), + ) + ;(service as any).broadcast(status, "/repo") + raw.length = 0 + first.length = 0 + second.length = 0 + + const id = service.beginExplicitAbort("session", "/repo") + ;(service as any).broadcast(close, "/repo") + service.finishExplicitAbort("session", "/repo", id, true) + + expect(first).toEqual([]) + expect(second).toEqual([]) + expect(raw).toEqual([close]) + }) + + test("replays a failed explicit abort for every subscriber", () => { + const service = new KiloConnectionService({} as any) + const raw: SSEPayload[] = [] + const first: SSEPayload[] = [] + const second: SSEPayload[] = [] + service.onEvent((event) => raw.push(event)) + service.onEventFiltered( + () => true, + (event) => first.push(event), + ) + service.onEventFiltered( + () => true, + (event) => second.push(event), + ) + ;(service as any).broadcast(status, "/repo") + raw.length = 0 + first.length = 0 + second.length = 0 + + const id = service.beginExplicitAbort("session", "/repo") + ;(service as any).broadcast(close, "/repo") + service.finishExplicitAbort("session", "/repo", id, false) + + expect(first).toEqual([close]) + expect(second).toEqual([close]) + expect(raw).toEqual([close]) + }) +}) + describe("KiloConnectionService viewed sessions", () => { test("keeps Agent Manager sessions when sidebar visibility changes during a flush", async () => { const service = new KiloConnectionService({} as any) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index d202eeedba9..79398b2a528 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -5,6 +5,7 @@ import { SdkSSEAdapter, type SSEPayload } from "./sdk-sse-adapter" import type { ServerConfig } from "./types" import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" import { SandboxPreference } from "../sandbox-preference" +import { ExplicitAbortState } from "./explicit-abort" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" type SSEEventListener = (event: SSEPayload, directory?: string) => void @@ -96,6 +97,8 @@ export class KiloConnectionService { private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null private readonly eventListeners: Set = new Set() + private readonly filteredListeners = new Set<{ filter: SSEEventFilter; listener: SSEEventListener }>() + private readonly explicitAborts = new ExplicitAbortState() private readonly stateListeners: Set = new Set() private readonly notificationDismissListeners: Set = new Set() private readonly languageChangeListeners: Set = new Set() @@ -276,13 +279,34 @@ export class KiloConnectionService { * Subscribe to SSE events with a filter. The filter runs for every incoming SSE event. */ onEventFiltered(filter: SSEEventFilter, listener: SSEEventListener): () => void { - const wrapped: SSEEventListener = (event, directory) => { - if (!filter(event, directory)) { - return - } - listener(event, directory) + const entry = { filter, listener } + this.filteredListeners.add(entry) + return () => { + this.filteredListeners.delete(entry) } - return this.onEvent(wrapped) + } + + beginExplicitAbort(sessionID: string, directory: string): number | undefined { + return this.explicitAborts.begin(sessionID, directory) + } + + finishExplicitAbort(sessionID: string, directory: string, id: number | undefined, stopped: boolean): void { + for (const item of this.explicitAborts.finish(sessionID, directory, id, stopped)) + this.broadcastFiltered(item.event, item.directory) + } + + async runExplicitAbort(sessionID: string, directory: string, action: () => Promise): Promise { + const id = this.beginExplicitAbort(sessionID, directory) + return action().then( + (result) => { + this.finishExplicitAbort(sessionID, directory, id, true) + return result + }, + (error) => { + this.finishExplicitAbort(sessionID, directory, id, false) + throw error + }, + ) } /** @@ -305,6 +329,7 @@ export class KiloConnectionService { * id after external (CLI/TUI/cascade) deletes arrive via SSE. */ pruneSession(sessionId: string): void { + this.explicitAborts.remove(sessionId) for (const [mid, sid] of this.messageSessionIdsByMessageId) { if (sid === sessionId) this.messageSessionIdsByMessageId.delete(mid) } @@ -681,6 +706,8 @@ export class KiloConnectionService { this.sseClient?.dispose() this.serverManager.dispose() this.eventListeners.clear() + this.filteredListeners.clear() + this.explicitAborts.clear() this.stateListeners.clear() this.notificationDismissListeners.clear() this.profileChangeListeners.clear() @@ -780,6 +807,7 @@ export class KiloConnectionService { this.stopHealthPoll() this.stopCheckin() const sse = this.sseClient + this.explicitAborts.clear() this.sseClient = null sse?.disconnect() this.client = null @@ -837,11 +865,7 @@ export class KiloConnectionService { // Wire SSE events → broadcast to all registered listeners sse.onEvent((event, directory) => { if (this.sseClient !== sse) return - this.handlePermissionEvent(event, directory) - this.handleQuestionEvent(event, directory) - for (const listener of this.eventListeners) { - listener(event, directory) - } + this.broadcast(event, directory) }) sse.onError((error) => { @@ -887,6 +911,20 @@ export class KiloConnectionService { this.startHealthPoll(config.baseUrl, config.password) } + private broadcast(event: SSEPayload, directory?: string): void { + this.handlePermissionEvent(event, directory) + this.handleQuestionEvent(event, directory) + for (const listener of this.eventListeners) listener(event, directory) + if (!this.explicitAborts.event(event, directory)) return + this.broadcastFiltered(event, directory) + } + + private broadcastFiltered(event: SSEPayload, directory?: string): void { + for (const entry of this.filteredListeners) { + if (entry.filter(event, directory)) entry.listener(event, directory) + } + } + private startCheckin(): void { this.stopCheckin() this.checkinTimer = setInterval(() => this.flushViewed(), 60_000) diff --git a/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts new file mode 100644 index 00000000000..b86e9121a2b --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts @@ -0,0 +1,112 @@ +import path from "node:path" +import type { SSEPayload } from "./sdk-sse-adapter" + +type Buffered = { event: SSEPayload; directory?: string } +type State = { attempts: Set; stopped: boolean; buffered: Buffered[]; generation: number; idle: boolean } + +export class ExplicitAbortState { + private readonly active = new Set() + private readonly states = new Map() + private readonly generations = new Map() + private next = 0 + + begin(sessionID: string, directory: string): number | undefined { + const key = scope(sessionID, directory) + if (!this.active.has(key)) return + const id = ++this.next + const state = this.states.get(key) ?? { + attempts: new Set(), + stopped: false, + buffered: [], + generation: this.generations.get(key) ?? 0, + idle: false, + } + state.attempts.add(id) + this.states.set(key, state) + return id + } + + finish(sessionID: string, directory: string, id: number | undefined, stopped: boolean): Buffered[] { + if (id === undefined) return [] + const key = scope(sessionID, directory) + const state = this.states.get(key) + if (!state || !state.attempts.delete(id)) return [] + if (stopped) { + state.stopped = true + state.buffered = [] + return [] + } + if (state.stopped || state.attempts.size > 0) return [] + this.states.delete(key) + return state.buffered + } + + event(event: SSEPayload, directory?: string): boolean { + if (event.type === "session.status" && directory) return this.status(event, directory) + if (event.type === "session.turn.open") return this.open(event.properties.sessionID, directory) + if (event.type !== "session.turn.close") return true + const keys = this.keys(event.properties.sessionID, directory).filter((key) => this.states.has(key)) + if (keys.length !== 1) return true + const key = keys[0] + const state = this.states.get(key) + if (!state) return true + if (state.generation !== (this.generations.get(key) ?? 0) || event.properties.reason !== "interrupted") { + this.states.delete(key) + return true + } + if (state.stopped) return false + if (state.attempts.size === 0) { + this.states.delete(key) + return true + } + state.buffered.push({ event, directory }) + return false + } + + private status(event: Extract, directory: string) { + const key = scope(event.properties.sessionID, directory) + const state = this.states.get(key) + if (event.properties.status.type === "idle") { + this.active.delete(key) + if (state) state.idle = true + return true + } + this.active.add(key) + if (state?.idle) this.states.delete(key) + return true + } + + private open(sessionID: string, directory?: string) { + for (const key of this.keys(sessionID, directory)) { + this.generations.set(key, (this.generations.get(key) ?? 0) + 1) + this.states.delete(key) + } + return true + } + + clear() { + this.active.clear() + this.states.clear() + this.generations.clear() + } + + remove(sessionID: string) { + for (const key of this.keys(sessionID)) { + this.active.delete(key) + this.states.delete(key) + this.generations.delete(key) + } + } + + private keys(sessionID: string, directory?: string): string[] { + if (directory) return [scope(sessionID, directory)] + const prefix = `${sessionID}\0` + return [...new Set([...this.active, ...this.states.keys(), ...this.generations.keys()])].filter((key) => + key.startsWith(prefix), + ) + } +} + +function scope(sessionID: string, directory: string) { + return `${sessionID}\0${path.resolve(directory)}` +} diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index e971a1211b8..af80429270a 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -20,7 +20,13 @@ describe("SessionAbort", () => { const aborts = new SessionAbort() aborts.observe("session_1", "busy", "/repo") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(true) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toEqual({ + complete: true, + attempts: [ + { dir: "/repo", aborted: true }, + { dir: "/repo/worktree", aborted: true }, + ], + }) expect(calls).toEqual([ { type: "abort", @@ -41,7 +47,10 @@ describe("SessionAbort", () => { aborts.observe("session_1", "busy", "/repo") aborts.observe("session_1", "idle", "/repo") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(false) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toEqual({ + complete: false, + attempts: [{ dir: "/repo/worktree", aborted: true }], + }) expect(calls).toEqual([ { type: "abort", @@ -56,9 +65,23 @@ describe("SessionAbort", () => { const aborts = new SessionAbort() aborts.observe("session_1", "busy", "/repo/worktree") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toBe(true) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toEqual({ + complete: true, + attempts: [{ dir: "/repo/worktree", aborted: true }], + }) expect(calls).toHaveLength(1) }) + + it("reports a failed HTTP abort separately from ownership", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo") + + expect(await aborts.stop(client(calls, true), "session_1", "/repo")).toEqual({ + complete: false, + attempts: [{ dir: "/repo", aborted: false }], + }) + }) }) describe("abortSession", () => { diff --git a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts index 86d341b938f..52c4e337487 100644 --- a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts +++ b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts @@ -72,8 +72,11 @@ function result(path: string): CreateWorktreeResult { function ctx(overrides: Partial = {}): ContinueContext { return { root: "/tmp/test", - getClient: () => { - throw new Error("no client") + connection: { + getClient: () => { + throw new Error("no client") + }, + runExplicitAbort: async (_sessionId, _directory, action) => action(), }, createWorktreeOnDisk: async () => null, runSetupScript: async () => {}, @@ -98,10 +101,12 @@ describe("continue-in-worktree steps", () => { it("does not throw when abort rejects", async () => { const c = ctx({ - getClient: () => - ({ - session: { abort: () => Promise.reject(new Error("fail")) }, - }) as never, + connection: { + getClient: () => ({ session: { abort: async () => undefined } }) as never, + runExplicitAbort: async () => { + throw new Error("fail") + }, + }, }) await abortSession(c, "session-1") }) @@ -109,15 +114,17 @@ describe("continue-in-worktree steps", () => { it("calls abort on the client", async () => { let called = false const c = ctx({ - getClient: () => - ({ - session: { - abort: () => { - called = true - return Promise.resolve() + connection: { + getClient: () => + ({ + session: { + abort: async () => { + called = true + }, }, - }, - }) as never, + }) as never, + runExplicitAbort: async (_sessionId, _directory, action) => action(), + }, }) await abortSession(c, "session-1") expect(called).toBe(true) @@ -134,10 +141,13 @@ describe("continue-in-worktree steps", () => { it("returns error when fork rejects", async () => { const c = ctx({ - getClient: () => - ({ - session: { fork: () => Promise.reject(new Error("fork failed")) }, - }) as never, + connection: { + getClient: () => + ({ + session: { fork: () => Promise.reject(new Error("fork failed")) }, + }) as never, + runExplicitAbort: async (_s, _d, action) => action(), + }, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(false) @@ -148,10 +158,13 @@ describe("continue-in-worktree steps", () => { const forked = session("forked-1") const promptAsync = mock(async () => ({})) const c = ctx({ - getClient: () => - ({ - session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, - }) as never, + connection: { + getClient: () => + ({ + session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, + }) as never, + runExplicitAbort: async (_s, _d, action) => action(), + }, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(true) @@ -215,7 +228,7 @@ describe("continueInWorktree", () => { let created: CreateWorktreeResult | undefined const c = ctx({ root, - getClient: () => api, + connection: { getClient: () => api, runExplicitAbort: async (_s, _d, action) => action() }, createWorktreeOnDisk: async (opts) => { const value = await manager.createWorktree(opts) created = value @@ -246,7 +259,7 @@ describe("continueInWorktree", () => { let created: CreateWorktreeResult | undefined const c = ctx({ root, - getClient: () => client(), + connection: { getClient: () => client(), runExplicitAbort: async (_s, _d, action) => action() }, createWorktreeOnDisk: async (opts) => { const value = await manager.createWorktree(opts) created = value diff --git a/packages/kilo-vscode/tests/unit/explicit-abort.test.ts b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts new file mode 100644 index 00000000000..68944787091 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test" +import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" +import { ExplicitAbortState } from "../../src/services/cli-backend/explicit-abort" + +const open = (sessionID = "session") => + ({ id: "event-open", type: "session.turn.open", properties: { sessionID } }) as SSEPayload + +const status = (type: "idle" | "busy", sessionID = "session") => + ({ type: "session.status", properties: { sessionID, status: { type } } }) as SSEPayload + +const close = (reason: "completed" | "interrupted", sessionID = "session") => + ({ id: `event-${reason}`, type: "session.turn.close", properties: { sessionID, reason } }) as SSEPayload + +describe("explicit abort state", () => { + it("does not suppress an unexpected interruption", () => { + const state = new ExplicitAbortState() + + expect(state.event(close("interrupted"))).toBe(true) + }) + + it("drops an interrupted close after an explicit abort succeeds", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(false) + expect(state.finish("session", "/repo", id, true)).toEqual([]) + }) + + it("drops an interrupted close that arrives after abort success", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + + expect(state.event(close("interrupted"), "/repo")).toBe(false) + }) + + it("replays an interrupted close when the abort fails", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + const event = close("interrupted") + state.event(event, "/repo") + + expect(state.finish("session", "/repo", id, false)).toEqual([{ event, directory: "/repo" }]) + }) + + it("never suppresses a completed close", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + state.begin("session", "/repo") + + expect(state.event(close("completed"))).toBe(true) + }) + + it("waits for concurrent abort attempts before replaying", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const first = state.begin("session", "/repo") + const second = state.begin("session", "/repo") + state.event(close("interrupted"), "/repo") + + expect(state.finish("session", "/repo", first, false)).toEqual([]) + expect(state.finish("session", "/repo", second, true)).toEqual([]) + }) + + it("allows a later real interruption in the same session", () => { + const state = new ExplicitAbortState() + state.event(open(), "/repo") + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + expect(state.event(close("interrupted"), "/repo")).toBe(false) + state.event(status("idle"), "/repo") + state.event(open(), "/repo") + state.event(status("busy"), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("clears a pending abort when a new turn opens", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.event(open(), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + expect(state.finish("session", "/repo", id, true)).toEqual([]) + }) + + it("isolates identical session ids by directory", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo/a") + const id = state.begin("session", "/repo/a") + state.finish("session", "/repo/a", id, true) + + expect(state.event(close("interrupted"), "/repo/b")).toBe(true) + expect(state.event(close("interrupted"), "/repo/a")).toBe(false) + }) + + it("does not mark an already idle session", () => { + const state = new ExplicitAbortState() + state.event(status("idle"), "/repo") + + expect(state.begin("session", "/repo")).toBeUndefined() + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("clears suppression on a later busy status without turn-open", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + state.event(status("idle"), "/repo") + state.event(status("busy"), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("does not carry a pending abort into a new busy turn", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.event(status("idle"), "/repo") + state.event(status("busy"), "/repo") + state.finish("session", "/repo", id, true) + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 58de510b24d..24be2aaf257 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -191,6 +191,8 @@ function createConnection(client: ReturnType) { }, connect: async () => {}, getClient: () => client, + beginExplicitAbort: () => 1 as number | undefined, + finishExplicitAbort: () => undefined, onEventFiltered: () => () => undefined, onStateChange: (_l: (s: State) => void) => () => undefined, onNotificationDismissed: () => () => undefined, @@ -1238,7 +1240,7 @@ describe("KiloProvider.handleLoadMessages / slim payload", () => { expect(client.aborted).toContainEqual({ sessionID: "s1", directory: "/repo" }) expect(sent).toContainEqual({ type: "sessionCostAlertResolved", sessionID: "s1", limit: 1 }) - expect(sent).toContainEqual({ type: "sessionTurnClosed", sessionID: "s1", reason: "interrupted" }) + expect(sent).not.toContainEqual({ type: "sessionTurnClosed", sessionID: "s1", reason: "interrupted" }) }) it("strips transcript-only metadata before posting messages to the webview", async () => { From ea74bda3996d55ace921a80b96e3ddb65636652b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 12:17:33 +0200 Subject: [PATCH 02/50] perf(agent-manager): optimize worktree diff loading --- .changeset/fast-agent-manager-diffs.md | 5 + .../src/agent-manager/AgentManagerProvider.ts | 2 +- .../kilo-vscode/src/agent-manager/GitOps.ts | 13 +- .../src/agent-manager/local-diff.ts | 125 ++++++++++++++---- .../src/agent-manager/semaphore.ts | 33 +++-- .../kilo-vscode/src/diff/SourceController.ts | 16 ++- .../kilo-vscode/src/diff/sources/catalog.ts | 13 +- .../kilo-vscode/src/diff/sources/worktree.ts | 16 ++- .../tests/unit/agent-manager-arch.test.ts | 2 + .../unit/agent-manager-worktree-diffs.test.ts | 11 ++ .../kilo-vscode/tests/unit/local-diff.test.ts | 30 +++++ .../kilo-vscode/tests/unit/semaphore.test.ts | 16 +++ .../tests/unit/source-controller.test.ts | 30 +++++ .../agent-manager/AgentManagerApp.tsx | 99 ++++++++------ .../webview-ui/agent-manager/DiffPanel.tsx | 11 +- .../agent-manager/DiffPanelCache.tsx | 110 +++++++++++++++ .../agent-manager/agent-manager.css | 18 +++ .../webview-ui/agent-manager/revert-file.ts | 14 +- .../agent-manager/review-composers.ts | 24 ++++ .../agent-manager/worktree-diffs.ts | 34 ++++- .../webview-ui/diff-viewer/diff-requests.ts | 2 +- 21 files changed, 519 insertions(+), 105 deletions(-) create mode 100644 .changeset/fast-agent-manager-diffs.md create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts diff --git a/.changeset/fast-agent-manager-diffs.md b/.changeset/fast-agent-manager-diffs.md new file mode 100644 index 00000000000..8a51a2f6c98 --- /dev/null +++ b/.changeset/fast-agent-manager-diffs.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Load Agent Manager worktree diffs faster and keep warmed reviews visible when switching worktrees. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 99eab29e3eb..68e24959d13 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -212,7 +212,7 @@ export class AgentManagerProvider implements Disposable { log: (msg) => this.log(msg), }) const local = createLocalDiff(this.gitOps, (...args) => this.log(...args)) - this.diffCatalog = new DiffSourceCatalog(this.connectionService) + this.diffCatalog = new DiffSourceCatalog(this.connectionService, local) this.diffs = new WorktreeDiffController({ getState: () => this.getStateManager(), getRoot: () => this.getRoot(), diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 8b14d972773..e31ebc00c7e 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -44,6 +44,7 @@ interface ExecOptions { env?: NodeJS.ProcessEnv stdin?: string timeout?: number + signal?: AbortSignal } export interface ExecResult { @@ -594,12 +595,12 @@ export class GitOps { * suitable for callers that need to tolerate legitimate failures (e.g. * `merge-base` on an orphan branch, `ls-files --error-unmatch`). */ - execGit(args: string[], cwd: string, options?: { stdin?: string }): Promise { + execGit(args: string[], cwd: string, options?: { stdin?: string; signal?: AbortSignal }): Promise { return this.exec(args, cwd, options) } - execGitBuffer(args: string[], cwd: string): Promise { - return this.execBuffer(args, cwd) + execGitBuffer(args: string[], cwd: string, options?: { signal?: AbortSignal }): Promise { + return this.execBuffer(args, cwd, options) } private async exec(args: string[], cwd: string, options?: ExecOptions): Promise { @@ -616,7 +617,7 @@ export class GitOps { return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" } } const invoke = () => this.invoke(cmd, args, cwd, options) - return this.semaphore ? this.semaphore.run(invoke) : invoke() + return this.semaphore ? this.semaphore.run(invoke, options?.signal) : invoke() } private executable(): Promise { @@ -642,7 +643,7 @@ export class GitOps { } private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise { - if (this.controller.signal.aborted) { + if (this.controller.signal.aborted || options?.signal?.aborted) { return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }) } @@ -664,6 +665,7 @@ export class GitOps { : undefined this.controller.signal.addEventListener("abort", abort, { once: true }) + options?.signal?.addEventListener("abort", abort, { once: true }) child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) @@ -673,6 +675,7 @@ export class GitOps { child.on("close", (code) => { if (timeout) clearTimeout(timeout) this.controller.signal.removeEventListener("abort", abort) + options?.signal?.removeEventListener("abort", abort) resolve({ code: code ?? 1, stdout: Buffer.concat(out), diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index e643e866270..83eff8b4f7a 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -166,16 +166,16 @@ function statusFromCode(code: string): Status { } async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise { - const nameStatus = await git.execGit( - ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc], - dir, - ) + const [nameStatus, counts, untracked] = await Promise.all([ + git.execGit(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc], dir), + numstat(git, dir, anc), + git.execGit(["ls-files", "--others", "--exclude-standard"], dir), + ]) if (nameStatus.code !== 0) { log?.("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() }) return [] } - const counts = await numstat(git, dir, anc) const result: Meta[] = [] const seen = new Set() @@ -201,7 +201,6 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise }>() + const generations = new Map() + const details = new Map() + const pending = new Map }>() + let bytes = 0 + + const remember = (id: string, value: WorktreeDiffEntry) => { + const size = + (value.before?.length ?? 0) + + (value.after?.length ?? 0) + + (value.patch?.length ?? 0) + + (value.image?.before?.data?.length ?? 0) + + (value.image?.after?.data?.length ?? 0) + const current = details.get(id) + if (current) bytes -= current.bytes + details.delete(id) + details.set(id, { value, bytes: size }) + bytes += size + while (details.size > 128 || bytes > 64 * 1024 * 1024) { + const key = details.keys().next().value! + bytes -= details.get(key)!.bytes + details.delete(key) + } + } return { summary: async (dir: string, base: string): Promise => { const id = `${dir}\0${base}` + const generation = (generations.get(id) ?? 0) + 1 + generations.set(id, generation) const anc = await ancestor(git, dir, base, log) if (!anc) { - states.delete(id) + if (generations.get(id) === generation) states.delete(id) return [] } const items = await list(git, dir, anc, log) + if (generations.get(id) !== generation) return items.map(summarize) states.delete(id) states.set(id, { anc, metas: new Map(items.map((item) => [item.file, item])) }) if (states.size > 8) states.delete(states.keys().next().value!) return items.map(summarize) }, - file: async (dir: string, base: string, file: string): Promise => { + file: async (dir: string, base: string, file: string, signal?: AbortSignal): Promise => { const state = states.get(`${dir}\0${base}`) if (!state) return diffFile(git, dir, base, file, log) const meta = state.metas.get(file) if (!meta) return null - return materialize(git, dir, state.anc, meta, log) + const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.stamp}` + const cached = details.get(id) + if (cached) { + remember(id, cached.value) + return cached.value + } + const current = pending.get(id) + if (current && !current.signal?.aborted) return current.work + const work = materialize(git, dir, state.anc, meta, log, signal) + pending.set(id, { signal, work }) + work.then( + (value) => { + if (pending.get(id)?.work !== work) return + pending.delete(id) + remember(id, value) + }, + () => { + if (pending.get(id)?.work === work) pending.delete(id) + }, + ) + return work }, } } @@ -343,8 +388,8 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string): } } -async function blobSize(git: GitOps, dir: string, anc: string, file: string): Promise { - const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir) +async function blobSize(git: GitOps, dir: string, anc: string, file: string, signal?: AbortSignal): Promise { + const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir, { signal }) if (result.code !== 0) return 0 return parseInt(result.stdout.trim(), 10) || 0 } @@ -356,8 +401,14 @@ async function fileSize(dir: string, file: string): Promise { return stat?.size ?? 0 } -async function readBlob(git: GitOps, dir: string, ref: string, file: string): Promise { - const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir) +async function readBlob( + git: GitOps, + dir: string, + ref: string, + file: string, + signal?: AbortSignal, +): Promise { + const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir, { signal }) return result.code === 0 ? result.stdout : undefined } @@ -369,9 +420,16 @@ async function readFile(dir: string, file: string): Promise return readImageFile(full) } -async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise { +async function readBefore( + git: GitOps, + dir: string, + anc: string, + file: string, + status: Status, + signal?: AbortSignal, +): Promise { if (status === "added") return "" - const result = await git.execGit(["show", `${anc}:${file}`], dir) + const result = await git.execGit(["show", `${anc}:${file}`], dir, { signal }) return result.code === 0 ? result.stdout : "" } @@ -386,10 +444,17 @@ async function readAfter(dir: string, file: string, status: Status): Promise "") } -async function unifiedPatch(git: GitOps, dir: string, anc: string, file: string): Promise { +async function unifiedPatch( + git: GitOps, + dir: string, + anc: string, + file: string, + signal?: AbortSignal, +): Promise { const result = await git.execGit( ["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", anc, "--", file], dir, + { signal }, ) return result.code === 0 ? result.stdout : "" } @@ -418,15 +483,26 @@ export async function diffFile( return materialize(git, dir, anc, meta, log) } -async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, log?: Log): Promise { +async function materialize( + git: GitOps, + dir: string, + anc: string, + meta: Meta, + log?: Log, + signal?: AbortSignal, +): Promise { const mime = imageMime(meta.file) if (meta.binary && !mime) return summarize(meta) - const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file) - const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file) + const [beforeBytes, afterBytes] = await Promise.all([ + meta.status === "added" ? 0 : blobSize(git, dir, anc, meta.file, signal), + meta.status === "deleted" ? 0 : fileSize(dir, meta.file), + ]) if (mime) { const image = await loadImage( meta.file, - meta.status === "added" ? undefined : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file) }, + meta.status === "added" + ? undefined + : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file, signal) }, meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) }, ) return { ...summarize(meta), summarized: false, image } @@ -444,9 +520,12 @@ async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, lo return summarize(meta) } - const before = await readBefore(git, dir, anc, meta.file, meta.status) - const after = await readAfter(dir, meta.file, meta.status) - const patch = meta.tracked ? await unifiedPatch(git, dir, anc, meta.file) : buildUntrackedPatch(meta.file, after) + const [before, after, tracked] = await Promise.all([ + readBefore(git, dir, anc, meta.file, meta.status, signal), + readAfter(dir, meta.file, meta.status), + meta.tracked ? unifiedPatch(git, dir, anc, meta.file, signal) : Promise.resolve(""), + ]) + const patch = meta.tracked ? tracked : buildUntrackedPatch(meta.file, after) const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions return { file: meta.file, diff --git a/packages/kilo-vscode/src/agent-manager/semaphore.ts b/packages/kilo-vscode/src/agent-manager/semaphore.ts index 37f3ab9cdd2..a5e52d3eaf7 100644 --- a/packages/kilo-vscode/src/agent-manager/semaphore.ts +++ b/packages/kilo-vscode/src/agent-manager/semaphore.ts @@ -7,12 +7,12 @@ */ export class Semaphore { private running = 0 - private readonly pending: (() => void)[] = [] + private readonly pending: { resolve: () => void; abort?: () => void }[] = [] constructor(private readonly limit: number) {} - async run(fn: () => Promise): Promise { - await this.acquire() + async run(fn: () => Promise, signal?: AbortSignal): Promise { + await this.acquire(signal) try { return await fn() } finally { @@ -20,22 +20,33 @@ export class Semaphore { } } - private acquire(): Promise { + private acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason) if (this.running < this.limit) { this.running++ return Promise.resolve() } - return new Promise((resolve) => { - this.pending.push(() => { - this.running++ - resolve() - }) + return new Promise((resolve, reject) => { + const item = { + resolve: () => { + if (item.abort) signal?.removeEventListener("abort", item.abort) + this.running++ + resolve() + }, + abort: undefined as (() => void) | undefined, + } + item.abort = () => { + const index = this.pending.indexOf(item) + if (index !== -1) this.pending.splice(index, 1) + reject(signal?.reason) + } + signal?.addEventListener("abort", item.abort, { once: true }) + this.pending.push(item) }) } private release(): void { this.running-- - const next = this.pending.shift() - if (next) next() + this.pending.shift()?.resolve() } } diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index 11a51ed3449..1d454fdf8d2 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -180,6 +180,13 @@ export class SourceController { this.send(this.messages.diffFile(source, file, null)) return } + // Yield once so a worktree switch can advance the epoch before queued + // detail work enters the shared Git semaphore. + await new Promise((resolve) => setTimeout(resolve, 0)) + if (this.epoch !== epoch || this.active !== source) { + this.send(this.messages.diffFile(source, file, null)) + return + } const diff = await source.fetchFile(file).catch(() => null) // Discard stale content after disposal/swap, but still complete the request // so consumers can clear per-file loading state. @@ -239,10 +246,15 @@ export class SourceController { private startPolling(source: DiffSource, epoch: number): void { this.stopPolling() + let busy = false this.interval = setInterval(async () => { + if (busy) return + busy = true // Self-cancel when the tick reports the source is done - const keep = await this.runFetch(source, epoch, false) - if (!keep) this.stopPolling() + const keep = await this.runFetch(source, epoch, false).finally(() => { + busy = false + }) + if (!keep && this.epoch === epoch && this.active === source) this.stopPolling() }, DIFF_POLL_INTERVAL_MS) } diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index 8eb2d0ffdfb..1cfb414a8cf 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -18,6 +18,12 @@ import { import { TURN_PREFIX, createTurnDiffSource, type TurnDiffFetch } from "./turn" import { STAGED_DESCRIPTOR, STAGED_SOURCE_ID, createStagedDiffSource } from "./staged" import { UNSTAGED_DESCRIPTOR, UNSTAGED_SOURCE_ID, createUnstagedDiffSource } from "./unstaged" +import type { WorktreeDiffEntry } from "../../agent-manager/types" + +export interface LocalDiffSource { + summary: (dir: string, base: string) => Promise + file: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise +} export interface WorkspaceBranchesResult { branches: BranchListItem[] @@ -68,7 +74,10 @@ export class DiffSourceCatalog implements vscode.Disposable { private branchGit: GitOps | undefined private branchOutput: vscode.OutputChannel | undefined - constructor(private readonly connection: KiloConnectionService) {} + constructor( + private readonly connection: KiloConnectionService, + private readonly local?: LocalDiffSource, + ) {} listAvailable(ctx: PanelContext): DiffSourceDescriptor[] { if (ctx.hidePicker) return [] @@ -96,6 +105,8 @@ export class DiffSourceCatalog implements vscode.Disposable { ...opts, baseBranchOverride: ctx.baseBranchOverride, baseBranch: ctx.baseBranch, + summary: this.local?.summary, + file: this.local?.file, }) } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 89e750a1c87..4aa8b690f1e 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -45,6 +45,8 @@ export interface WorktreeDiffSourceOptions { /** Shared GitOps / log so sources don't each spawn their own channel. */ git?: GitOps log?: (...args: unknown[]) => void + summary?: (dir: string, base: string) => Promise + file?: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise } /** @@ -57,6 +59,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace") const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args)) const git = opts.git ?? new GitOps({ log }) + const controller = new AbortController() const root = (): string | undefined => { const dir = opts.dir?.() @@ -101,7 +104,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): } const status: StatusResolver = async (current, file) => { - const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + const entry = opts.file + ? await opts.file(current.directory, current.baseBranch, file) + : await diffFile(git, current.directory, current.baseBranch, file, log) return entry?.status } @@ -112,7 +117,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const current = await resolveTarget() if (!current) return { diffs: [] } - const entries = await diffSummary(git, current.directory, current.baseBranch, log) + const entries = opts.summary + ? await opts.summary(current.directory, current.baseBranch) + : await diffSummary(git, current.directory, current.baseBranch, log) const diffs = entries.map(toDiffFile) log(`Diff: ${diffs.length} file(s)`) return { diffs } @@ -124,7 +131,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): if (!current) return null try { - const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + const entry = opts.file + ? await opts.file(current.directory, current.baseBranch, file, controller.signal) + : await diffFile(git, current.directory, current.baseBranch, file, log) if (!entry) return null return toDiffFile(entry) } catch (err) { @@ -152,6 +161,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): // owned by the caller. if (!opts.git) git.dispose() output?.dispose() + controller.abort() target = undefined }, } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index fff186c1890..39751504d62 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -29,6 +29,8 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"), + path.join(ROOT, "webview-ui/agent-manager/DiffPanelCache.tsx"), + path.join(ROOT, "webview-ui/agent-manager/review-composers.ts"), path.join(ROOT, "webview-ui/documents/DocumentPanel.tsx"), path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"), path.join(ROOT, "webview-ui/diff-viewer/ImageDiffView.tsx"), diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts index 5d2e5bc3e11..16bd99d63fc 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -65,11 +65,22 @@ describe("createWorktreeDiffs", () => { withDiffs((diffs) => { diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: true }) expect(diffs.diffLoading()).toBe(true) + expect(diffs.diffLoadingFor(() => "s1")).toBe(true) + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [] }) + expect(diffs.diffLoadingFor(() => "s1")).toBe(false) diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: false }) expect(diffs.diffLoading()).toBe(false) }) }) + it("keeps loading isolated to its composite diff id", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1#branch", loading: true }) + expect(diffs.diffLoadingFor(() => "s1#branch")).toBe(true) + expect(diffs.diffLoadingFor(() => "s2#branch")).toBe(false) + }) + }) + it("requestDiffFile marks a file pending, posts once, and ignores repeats", () => { withDiffs((diffs, sent) => { diffs.requestDiffFile("s1", "a.ts") diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index 68f8e8f89b8..1e947e34ae1 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -352,6 +352,36 @@ describe("diffFile", () => { }) }) + it("reuses cached detail while the summary stamp is unchanged", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n") + const local = createLocalDiff(git()) + await local.summary(dir, base) + + const first = await local.file(dir, base, "seed.txt") + const second = await local.file(dir, base, "seed.txt") + + expect(second).toBe(first) + }) + }) + + it("invalidates cached detail after the summary stamp changes", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nfirst\n") + const local = createLocalDiff(git()) + await local.summary(dir, base) + const first = await local.file(dir, base, "seed.txt") + + await new Promise((resolve) => setTimeout(resolve, 5)) + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nsecond value\n") + await local.summary(dir, base) + const second = await local.file(dir, base, "seed.txt") + + expect(second).not.toBe(first) + expect(second?.after).toBe("seed\nsecond value\n") + }) + }) + it("does not materialize binary detail from a cached summary", async () => { await withRepo(async (dir, base) => { await fs.writeFile(path.join(dir, "tone.wav"), Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x01, 0x02, 0x03])) diff --git a/packages/kilo-vscode/tests/unit/semaphore.test.ts b/packages/kilo-vscode/tests/unit/semaphore.test.ts index 697ba146b21..dc64b8b0e5a 100644 --- a/packages/kilo-vscode/tests/unit/semaphore.test.ts +++ b/packages/kilo-vscode/tests/unit/semaphore.test.ts @@ -69,6 +69,22 @@ describe("Semaphore", () => { expect(order).toEqual([1, 2, 3]) }) + it("removes an aborted task from the pending queue", async () => { + const sem = new Semaphore(1) + let release: () => void = () => {} + const first = sem.run(() => new Promise((resolve) => (release = resolve))) + const controller = new AbortController() + const aborted = sem.run(async () => "aborted", controller.signal) + const next = sem.run(async () => "next") + + controller.abort(new Error("cancelled")) + await expect(aborted).rejects.toThrow("cancelled") + release() + + expect(await next).toBe("next") + await first + }) + it("allows full concurrency when limit exceeds task count", async () => { const sem = new Semaphore(10) let running = 0 diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts index 3c14910e70e..c08673ced3e 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -346,6 +346,36 @@ describe("SourceController.requestFile", () => { controller.stop() }) + it("does not start queued detail work after the source changes", async () => { + let details = 0 + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async fetch() { + return { diffs: [] } + }, + async fetchFile() { + details++ + return null + }, + } + const session: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + return { diffs: [] } + }, + } + const { controller } = make({ workspace, "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("workspace") + const request = controller.requestFile("foo.ts") + await controller.activate("session:s1") + await request + + expect(details).toBe(0) + controller.stop() + }) + it("posts null when a pending fetchFile result is invalidated by stop", async () => { let release: () => void = () => {} const workspace: DiffSource = { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index ff426db1142..a0a2ffeb9db 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -162,14 +162,14 @@ import { import { createEmbeddedTerminalReader } from "./terminal/output" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" -import { DiffPanel } from "./DiffPanel" +import { DiffPanelCache } from "./DiffPanelCache" import { PRPanelHost } from "./pr/PRPanelHost" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { createApplyToLocal } from "./apply-to-local" import { createWorktreeDiffs, wireDiffId } from "./worktree-diffs" import type { ReviewComment } from "../diff-viewer/review-comments" -import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations" +import { createReviewComposers } from "./review-composers" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search" import { randomColor } from "./section-colors" @@ -309,6 +309,7 @@ const AgentManagerContent: Component = () => { let pendingSidebarWidth: number | undefined const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) + const [diffMounted, setDiffMounted] = createSignal(false) const diffOpen = () => sidePanel() === SidePanel.Diff const prOpen = () => sidePanel() === SidePanel.PR const activePR = createMemo(() => { @@ -319,6 +320,7 @@ const AgentManagerContent: Component = () => { return { pr, selected, wt: worktrees().find((w) => w.id === selected) } }) const diffs = createWorktreeDiffs(vscode, activeProjectId) + createEffect(on(activeProjectId, diffs.reset, { defer: true })) const diffDatas = diffs.diffDatas const diffLoading = diffs.diffLoading const setDiffLoading = diffs.setDiffLoading @@ -330,7 +332,7 @@ const AgentManagerContent: Component = () => { setReviewActive(false) setSidePanel(SidePanel.Terminal) } - const reviewComposer = createReviewComposer() + const composers = createReviewComposers(currentProjectId) const reviewState = createReviewState() const reviewOpenByContext = reviewState.open const setReviewOpenByContext = reviewState.setOpen @@ -524,7 +526,6 @@ const AgentManagerContent: Component = () => { setPendingDelete(null) } createEffect(on(selection, () => cancelPendingDelete(), { defer: true })) - createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true })) createEffect( on( selection, @@ -1752,9 +1753,12 @@ const AgentManagerContent: Component = () => { } const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId)) + const diffLoadingForCurrent = createMemo(() => diffs.diffLoadingFor(diffScopeId)) const revertCtl = createRevertFile(diffScopeId, diffCtx, () => review.scope(), vscode, showToast, t, activeProjectId) + createEffect(() => diffOpen() && setDiffMounted(true)) + const handleShowKeyboardShortcuts = () => { const categories = buildShortcutCategories(kb(), t) dialog.show(() => ( @@ -2603,7 +2607,9 @@ const AgentManagerContent: Component = () => { mounted while a side terminal is alive — hidden via .am-side-host-hidden (absolute + opacity), never unmounted, so xterm render loops keep streaming. */} - 0 || subagents.tabs().length > 0}> + 0 || subagents.tabs().length > 0} + >
{ />
- - metrics.track("send_review_comments", "side_review")} - onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))} - onExpand={ - selection() !== null - ? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" }) - : undefined - } - onRequestDiff={requestDiffFile} - onOpenFile={(file, line) => { - const id = diffCtx() - if (id) - vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line }) - }} - onOpenDocument={documentInspector.open} - onRevertFile={metrics.use("revert_file", "side_review", revertCtl.revert)} - revertingFiles={revertCtl.reverting()} - activeTerminalId={terms.activeId()} - /> - + diffOpen() && !history() && !reviewActive()} + data={diffDatas} + loading={(key) => diffs.diffLoadingFor(() => key)} + loadingFiles={(key) => diffs.diffFileLoadingFor(() => key)} + notice={(key) => diffNotices()[key]} + comments={(ctx) => + readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", ctx) + } + setComments={(ctx, comments) => + setReviewCommentsByContext((prev) => + setReviewComments(prev, currentProjectId() ?? "single", ctx, comments), + ) + } + composer={composers.get} + lead={() => diffScopeControls(true)} + canRevert={scopeCapabilities(review.scope()).revert} + diffStyle={diffStyle.style()} + onDiffStyleChange={setSharedDiffStyle} + markdownRender={markdown.render()} + onMarkdownRenderChange={markdown.update} + onSendClick={() => metrics.track("send_review_comments", "side_review")} + onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))} + onExpand={ + selection() !== null + ? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" }) + : undefined + } + onRequestDiff={diffs.requestDiffFile} + onOpenFile={(ctx, file, line) => + vscode.postMessage({ type: "agentManager.openFile", sessionId: ctx, filePath: file, line }) + } + onOpenDocument={documentInspector.open} + onRevertFile={(key, ctx, file) => { + metrics.track("revert_file", "side_review") + revertCtl.revertFor(key, ctx, review.scope(), file) + }} + revertingFiles={revertCtl.revertingFor} + activeTerminalId={terms.activeId()} + /> {
{ canComment={scopeCapabilities(review.scope()).comments} comments={reviewComments()} onCommentsChange={setReviewCommentsForSelection} - composer={reviewComposer} + composer={composers.get(`${activeProjectId() ?? "single"}\0${diffScopeId() ?? ""}`)} onSendAll={closeReviewTab} onSendClick={() => metrics.track("send_review_comments", "fullscreen_review")} diffStyle={diffStyle.style()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 9513aacae6a..803cbd45ea7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -8,7 +8,6 @@ import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs" import type { WorktreeFileDiff } from "../src/types/messages" @@ -74,6 +73,7 @@ const DIFF_NOTICE_KEYS: Record = { interface DiffPanelProps { diffs: WorktreeFileDiff[] loading: boolean + active?: boolean loadingFiles?: Set sessionId?: string sessionKey?: string @@ -264,7 +264,7 @@ export const DiffPanel: Component = (props) => { diffs: () => props.diffs, open, loading: () => props.loadingFiles, - send: () => props.onRequestDiff, + send: () => (props.active === false ? undefined : props.onRequestDiff), }) // --- CRUD --- @@ -327,6 +327,7 @@ export const DiffPanel: Component = (props) => { on( () => [props.diffs, comments()] as const, ([diffs, current]) => { + if (props.active === false) return const valid = sanitizeReviewComments(current, diffs) if (valid.length !== current.length) { setComments(valid) @@ -553,7 +554,6 @@ export const DiffPanel: Component = (props) => {
- {t("session.review.loadingChanges")}
@@ -714,10 +714,7 @@ export const DiffPanel: Component = (props) => { fallback={
Diff preview loads on demand.}> - <> - - Loading diff... - + Loading diff...
} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx new file mode 100644 index 00000000000..e6890125d82 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx @@ -0,0 +1,110 @@ +import { For, createEffect, createMemo, createSignal, type Accessor, type Component, type JSX } from "solid-js" +import type { WorktreeFileDiff } from "../src/types/messages" +import type { ReviewComment } from "../diff-viewer/review-comments" +import type { ReviewComposer } from "../diff-viewer/review-annotations" +import { DiffPanel } from "./DiffPanel" + +const CACHE_SIZE = 4 + +interface Entry { + key: string + cacheKey: string + ctx: string + used: number +} + +interface Props { + current: Accessor + context: Accessor + project: Accessor + active: Accessor + data: Accessor> + loading: (key: string) => boolean + loadingFiles: (key: string) => Set + notice: (key: string) => string | undefined + comments: (ctx: string) => ReviewComment[] + setComments: (ctx: string, comments: ReviewComment[]) => void + composer: (key: string) => ReviewComposer + lead: () => JSX.Element + canRevert: boolean + diffStyle: "unified" | "split" + onDiffStyleChange: (style: "unified" | "split") => void + markdownRender: boolean + onMarkdownRenderChange: (render: boolean) => void + onSendClick: () => void + onClose: () => void + onExpand?: () => void + onRequestDiff: (key: string, file: string) => void + onOpenFile: (ctx: string, file: string, line?: number) => void + onOpenDocument: (file: string) => void + onRevertFile: (key: string, ctx: string, file: string) => void + revertingFiles: (key: string) => Set + activeTerminalId?: string +} + +export const DiffPanelCache: Component = (props) => { + const [entries, setEntries] = createSignal([]) + let used = 0 + + createEffect(() => { + if (!props.active()) return + const key = props.current() + const ctx = props.context() + const project = props.project() ?? "single" + if (!key || !ctx) return + const cacheKey = `${project}\0${key}` + setEntries((prev) => { + const prefix = `${project}\0` + const scoped = prev.filter((item) => item.cacheKey.startsWith(prefix)) + const current = scoped.find((item) => item.cacheKey === cacheKey) + if (current) { + current.used = ++used + return scoped + } + const next = [...scoped, { key, cacheKey, ctx, used: ++used }] + if (next.length <= CACHE_SIZE) return next + const oldest = next.reduce((entry, item) => (item.used < entry.used ? item : entry)) + return next.filter((item) => item !== oldest) + }) + }) + + return ( + + {(entry) => { + const active = createMemo( + () => props.active() && `${props.project() ?? "single"}\0${props.current()}` === entry.cacheKey, + ) + return ( +
+ props.setComments(entry.ctx, comments)} + composer={props.composer(entry.cacheKey)} + onSendClick={props.onSendClick} + onClose={props.onClose} + onExpand={props.onExpand} + onRequestDiff={(file) => props.onRequestDiff(entry.key, file)} + onOpenFile={(file, line) => props.onOpenFile(entry.ctx, file, line)} + onOpenDocument={props.onOpenDocument} + onRevertFile={(file) => props.onRevertFile(entry.key, entry.ctx, file)} + revertingFiles={props.revertingFiles(entry.key)} + activeTerminalId={props.activeTerminalId} + /> +
+ ) + }} +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index ab10cd505db..0c8f0570d31 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1939,6 +1939,24 @@ body.am-wt-dragging-active * { overflow: hidden; } +.am-diff-panel-cache { + position: absolute; + inset: 0; + display: flex; + min-width: 0; + min-height: 0; + opacity: 0; + pointer-events: none; + z-index: 0; + background: var(--surface-base); +} + +.am-diff-panel-cache-active { + opacity: 1; + pointer-events: auto; + z-index: 2; +} + .am-diff-panel-wrapper > [data-component="resize-handle"]::after { background: var(--surface-interactive-base); } 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 3f2a5285180..fa2d340fda9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts @@ -28,9 +28,9 @@ export function createRevertFile( return files()[id] ?? new Set() }) - function revert(file: string) { - const id = diffScopeId() - const context = ctx() + const revertingFor = (id: string) => files()[id] ?? new Set() + + function revertFor(id: string | undefined, context: string | undefined, source: string, file: string) { if (!id || !context) return setFiles((prev) => { const set = new Set(prev[id] ?? []) @@ -42,10 +42,14 @@ export function createRevertFile( projectId: projectId?.(), sessionId: context, file, - scope: scope(), + scope: source, }) } + function revert(file: string) { + revertFor(diffScopeId(), ctx(), scope(), file) + } + function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) { setFiles((prev) => { const set = new Set(prev[ev.sessionId] ?? []) @@ -62,5 +66,5 @@ export function createRevertFile( } } - return { reverting, revert, onResult } + return { reverting, revertingFor, revert, revertFor, onResult } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts new file mode 100644 index 00000000000..2901c323ba1 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts @@ -0,0 +1,24 @@ +import type { Accessor } from "solid-js" +import { createReviewComposer, type ReviewComposer } from "../diff-viewer/review-annotations" + +export function createReviewComposers(project: Accessor) { + const values = new Map() + + const get = (key: string) => { + const current = values.get(key) + if (current) return current + const next = createReviewComposer() + values.set(key, next) + return next + } + + const clear = (ctx: string | null) => { + if (!ctx) return + const prefix = `${project() ?? "single"}\0${ctx}` + for (const key of values.keys()) { + if (key === prefix || key.startsWith(`${prefix}#`)) values.delete(key) + } + } + + return { get, clear } +} 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 60ba7cb5991..1de9f562193 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -34,10 +34,18 @@ export function createWorktreeDiffs( project: () => string | undefined = () => undefined, ) { const [diffDatas, setDiffDatas] = createSignal>({}) - const [diffLoading, setDiffLoading] = createSignal(false) + const [diffLoadings, setDiffLoadings] = createSignal>({}) + const diffLoading = () => Object.keys(diffLoadings()).length > 0 const [diffNotices, setDiffNotices] = createSignal>({}) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + const reset = () => { + setDiffDatas({}) + setDiffLoadings({}) + setDiffNotices({}) + setDiffFileLoading({}) + } + const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { setDiffFileLoading((prev) => { const session = prev[sessionId] ?? {} @@ -93,6 +101,13 @@ export function createWorktreeDiffs( return new Set(Object.keys(diffFileLoading()[id] ?? {})) } + /** Initial summary loading for one composite diff id. Cached results stay visible while refreshing. */ + const diffLoadingFor = (sessionId: Accessor) => { + const id = sessionId() + if (!id) return false + return diffLoadings()[id] === true && !(id in diffDatas()) + } + // Backend messages. const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => { @@ -122,7 +137,18 @@ export function createWorktreeDiffs( } const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => { - setDiffLoading(ev.loading) + // One source is active per project. Replacing the map on start also clears + // an interrupted source whose stale completion is intentionally discarded. + if (ev.loading) { + setDiffLoadings({ [ev.sessionId]: true }) + return + } + setDiffLoadings((prev) => { + if (!prev[ev.sessionId]) return prev + const next = { ...prev } + delete next[ev.sessionId] + return next + }) } const onWorktreeDiffNotice = (ev: AgentManagerWorktreeDiffNoticeMessage) => { @@ -132,11 +158,13 @@ export function createWorktreeDiffs( return { diffDatas, diffLoading, - setDiffLoading, + setDiffLoading: (loading: boolean) => setDiffLoadings(loading ? diffLoadings() : {}), diffNotices, requestDiffFile, refreshStaleDiffs, diffFileLoadingFor, + diffLoadingFor, + reset, onWorktreeDiff, onWorktreeDiffFile, onWorktreeDiffLoading, diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts index 904ceca13fc..d76728ebe59 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts @@ -36,7 +36,7 @@ export function createDiffRequests(opts: DiffRequestOptions) { createEffect( on( - () => [opts.open(), opts.diffs(), opts.loading()] as const, + () => [opts.open(), opts.diffs(), opts.loading(), opts.send()] as const, ([open, diffs]) => { const files = new Set(open) for (const file of requested.keys()) { From 6a0335704689e1b8aa4957b3bab93f9b8432f221 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 13:25:36 +0200 Subject: [PATCH 03/50] fix(agent-manager): close diff cache review gaps --- .../src/agent-manager/local-diff.ts | 109 +++++++++++++----- .../kilo-vscode/src/diff/SourceController.ts | 26 ++++- .../unit/agent-manager-worktree-diffs.test.ts | 4 +- .../agent-manager/AgentManagerApp.tsx | 48 ++++---- .../webview-ui/agent-manager/DiffPanel.tsx | 22 +++- .../agent-manager/DiffPanelCache.tsx | 29 ++++- .../agent-manager/apply-to-local.tsx | 3 +- .../agent-manager/project/review-state.ts | 5 +- .../webview-ui/agent-manager/revert-file.ts | 17 +-- .../agent-manager/review-composers.ts | 20 +++- .../webview-ui/diff-viewer/diff-requests.ts | 10 ++ 11 files changed, 217 insertions(+), 76 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index 83eff8b4f7a..d45bf1210b9 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -1,4 +1,5 @@ import * as fs from "fs/promises" +import { createHash } from "crypto" import { binaryFile } from "../diff/shared/binary" import { imageMime, loadImage, readImageFile } from "../diff/shared/image" import { resolveInside } from "../diff/shared/path" @@ -144,7 +145,46 @@ async function statStamp(dir: string, file: string): Promise { if (!full) return `missing:${file}` const stat = await fs.lstat(full).catch(() => undefined) if (!stat) return `missing:${file}` - return `${stat.size}:${stat.mtimeMs}` + return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino ?? 0}` +} + +async function contentStamp(dir: string, file: string, status: Status): Promise { + if (status === "deleted") return "deleted" + const full = resolveInside(dir, file) + if (!full) return `missing:${file}` + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat) return `missing:${file}` + const value = stat.isSymbolicLink() + ? Buffer.from(await fs.readlink(full)) + : stat.isFile() + ? await fs.readFile(full).catch(() => undefined) + : undefined + if (!value) return `unreadable:${file}` + return createHash("sha256").update(value).digest("hex") +} + +function detailStamp(value: WorktreeDiffEntry, meta: Meta): string { + if (meta.status === "deleted") return "deleted" + const data = value.image?.after?.data + if (data) return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex") + return createHash("sha256") + .update(value.after ?? "") + .digest("hex") +} + +async function detailReads(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { + return Promise.all([ + readBefore(git, dir, anc, meta.file, meta.status, signal), + readAfter(dir, meta.file, meta.status), + meta.tracked ? unifiedPatch(git, dir, anc, meta.file, signal) : Promise.resolve(""), + ]) +} + +async function sizes(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { + return Promise.all([ + meta.status === "added" ? 0 : blobSize(git, dir, anc, meta.file, signal), + meta.status === "deleted" ? 0 : fileSize(dir, meta.file), + ]) } async function lineCount(file: string): Promise { @@ -265,21 +305,26 @@ export async function diffSummary(git: GitOps, dir: string, base: string, log?: export function createLocalDiff(git: GitOps, log?: Log) { const states = new Map }>() const generations = new Map() - const details = new Map() + const details = new Map() const pending = new Map }>() let bytes = 0 - const remember = (id: string, value: WorktreeDiffEntry) => { - const size = - (value.before?.length ?? 0) + - (value.after?.length ?? 0) + - (value.patch?.length ?? 0) + - (value.image?.before?.data?.length ?? 0) + - (value.image?.after?.data?.length ?? 0) + const forget = (id: string) => { + const value = details.get(id) + if (!value) return + bytes -= value.bytes + details.delete(id) + } + + const remember = (id: string, value: WorktreeDiffEntry, stamp: string) => { + const size = [value.before, value.after, value.patch, value.image?.before?.data, value.image?.after?.data].reduce( + (sum, value) => sum + Buffer.byteLength(value ?? ""), + 0, + ) const current = details.get(id) if (current) bytes -= current.bytes details.delete(id) - details.set(id, { value, bytes: size }) + details.set(id, { value, bytes: size, stamp }) bytes += size while (details.size > 128 || bytes > 64 * 1024 * 1024) { const key = details.keys().next().value! @@ -311,11 +356,14 @@ export function createLocalDiff(git: GitOps, log?: Log) { if (!state) return diffFile(git, dir, base, file, log) const meta = state.metas.get(file) if (!meta) return null - const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.stamp}` + const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.tracked}\0${meta.status}\0${meta.additions}\0${meta.deletions}\0${meta.binary}\0${meta.stamp}` const cached = details.get(id) if (cached) { - remember(id, cached.value) - return cached.value + if (cached.stamp === (await contentStamp(dir, file, meta.status))) { + remember(id, cached.value, cached.stamp) + return cached.value + } + forget(id) } const current = pending.get(id) if (current && !current.signal?.aborted) return current.work @@ -325,7 +373,8 @@ export function createLocalDiff(git: GitOps, log?: Log) { (value) => { if (pending.get(id)?.work !== work) return pending.delete(id) - remember(id, value) + if (value.image?.before?.error === "unreadable" || value.image?.after?.error === "unreadable") return + remember(id, value, detailStamp(value, meta)) }, () => { if (pending.get(id)?.work === work) pending.delete(id) @@ -390,7 +439,7 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string): async function blobSize(git: GitOps, dir: string, anc: string, file: string, signal?: AbortSignal): Promise { const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir, { signal }) - if (result.code !== 0) return 0 + if (result.code !== 0) throw new Error(`Could not read base blob for ${file}`) return parseInt(result.stdout.trim(), 10) || 0 } @@ -430,18 +479,21 @@ async function readBefore( ): Promise { if (status === "added") return "" const result = await git.execGit(["show", `${anc}:${file}`], dir, { signal }) - return result.code === 0 ? result.stdout : "" + if (result.code !== 0) throw new Error(`Could not read base file for ${file}`) + return result.stdout } async function readAfter(dir: string, file: string, status: Status): Promise { if (status === "deleted") return "" const full = resolveInside(dir, file) - if (!full) return "" + if (!full) throw new Error(`Could not resolve working file for ${file}`) const stat = await fs.lstat(full).catch(() => undefined) - if (!stat) return "" + if (!stat) throw new Error(`Could not read working file for ${file}`) if (stat.isSymbolicLink()) return fs.readlink(full).catch(() => "") - if (!stat.isFile()) return "" - return fs.readFile(full, "utf-8").catch(() => "") + if (!stat.isFile()) throw new Error(`Working path is not a file: ${file}`) + return fs.readFile(full, "utf-8").catch(() => { + throw new Error(`Could not read working file for ${file}`) + }) } async function unifiedPatch( @@ -456,7 +508,8 @@ async function unifiedPatch( dir, { signal }, ) - return result.code === 0 ? result.stdout : "" + if (result.code !== 0) throw new Error(`Could not create diff for ${file}`) + return result.stdout } function linesOf(text: string): number { @@ -493,10 +546,8 @@ async function materialize( ): Promise { const mime = imageMime(meta.file) if (meta.binary && !mime) return summarize(meta) - const [beforeBytes, afterBytes] = await Promise.all([ - meta.status === "added" ? 0 : blobSize(git, dir, anc, meta.file, signal), - meta.status === "deleted" ? 0 : fileSize(dir, meta.file), - ]) + const [beforeBytes, afterBytes] = await sizes(git, dir, anc, meta, signal) + if (signal?.aborted) throw new Error("Diff detail aborted") if (mime) { const image = await loadImage( meta.file, @@ -505,6 +556,7 @@ async function materialize( : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file, signal) }, meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) }, ) + if (signal?.aborted) throw new Error("Diff detail aborted") return { ...summarize(meta), summarized: false, image } } // Cheap size probe before materializing content — protects the extension @@ -520,11 +572,8 @@ async function materialize( return summarize(meta) } - const [before, after, tracked] = await Promise.all([ - readBefore(git, dir, anc, meta.file, meta.status, signal), - readAfter(dir, meta.file, meta.status), - meta.tracked ? unifiedPatch(git, dir, anc, meta.file, signal) : Promise.resolve(""), - ]) + const [before, after, tracked] = await detailReads(git, dir, anc, meta, signal) + if (signal?.aborted) throw new Error("Diff detail aborted") const patch = meta.tracked ? tracked : buildUntrackedPatch(meta.file, after) const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions return { diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index 1d454fdf8d2..c05a826a180 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -67,6 +67,7 @@ export class SourceController { private interval: ReturnType | undefined private lastHash: string | undefined private epoch = 0 + private readonly fetches = new Map>() constructor( private readonly build: (id: string, ctx: PanelContext) => DiffSource, @@ -91,6 +92,7 @@ export class SourceController { stop(): void { this.epoch++ this.stopPolling() + this.fetches.clear() this.active?.dispose?.() this.active = undefined this.activeId = undefined @@ -117,7 +119,7 @@ export class SourceController { if (opts.fetch === false) return - const keepPolling = await this.runFetch(source, epoch, true) + const keepPolling = await this.fetch(source, epoch, true) // Prevents the polling interval from starting after teardown or swap. if (this.epoch !== epoch || this.activeId !== id) return if (opts.poll !== false && keepPolling) this.startPolling(source, epoch) @@ -151,7 +153,7 @@ export class SourceController { // Push fresh diffs immediately after a successful revert so the webview // doesn't have to wait for the next polling tick. if (result.ok && this.epoch === epoch && this.active === source) { - await this.runFetch(source, epoch, false) + await this.fetch(source, epoch, false) } } @@ -160,7 +162,7 @@ export class SourceController { const source = this.active if (!source) return const epoch = this.epoch - await this.runFetch(source, epoch, true) + await this.fetch(source, epoch, true) } /** @@ -251,7 +253,7 @@ export class SourceController { if (busy) return busy = true // Self-cancel when the tick reports the source is done - const keep = await this.runFetch(source, epoch, false).finally(() => { + const keep = await this.fetch(source, epoch, false).finally(() => { busy = false }) if (!keep && this.epoch === epoch && this.active === source) this.stopPolling() @@ -264,4 +266,20 @@ export class SourceController { this.interval = undefined } } + + private fetch(source: DiffSource, epoch: number, initial: boolean): Promise { + const current = this.fetches.get(source) + if (current) return current + const work = this.runFetch(source, epoch, initial) + this.fetches.set(source, work) + work.then( + () => { + if (this.fetches.get(source) === work) this.fetches.delete(source) + }, + () => { + if (this.fetches.get(source) === work) this.fetches.delete(source) + }, + ) + return work + } } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts index 16bd99d63fc..4965ca1bf1c 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -34,7 +34,7 @@ describe("createWorktreeDiffs", () => { it("stores full diffs per session", () => { withDiffs((diffs) => { diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) - expect(diffs.diffDatas()["s1"]).toHaveLength(1) + expect(diffs.diffDatas()["single\0s1"]).toHaveLength(1) }) }) @@ -56,7 +56,7 @@ describe("createWorktreeDiffs", () => { file: "a.ts", diff: diff("a.ts", 9), }) - expect(diffs.diffDatas()["s1"]![0]!.additions).toBe(9) + expect(diffs.diffDatas()["single\0s1"]![0]!.additions).toBe(9) expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a0a2ffeb9db..f0765306353 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -167,7 +167,7 @@ import { PRPanelHost } from "./pr/PRPanelHost" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { createApplyToLocal } from "./apply-to-local" -import { createWorktreeDiffs, wireDiffId } from "./worktree-diffs" +import { createWorktreeDiffs, diffDataKey, wireDiffId } from "./worktree-diffs" import type { ReviewComment } from "../diff-viewer/review-comments" import { createReviewComposers } from "./review-composers" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" @@ -333,6 +333,7 @@ const AgentManagerContent: Component = () => { setSidePanel(SidePanel.Terminal) } const composers = createReviewComposers(currentProjectId) + createEffect(on(activeProjectId, (_next, previous) => previous && composers.clearProject(previous), { defer: true })) const reviewState = createReviewState() const reviewOpenByContext = reviewState.open const setReviewOpenByContext = reviewState.setOpen @@ -549,16 +550,6 @@ const AgentManagerContent: Component = () => { if (sel === null) return setReviewOpenForContext(sel, open) } - const reviewComments = createMemo(() => { - const sel = selection() - if (sel === null) return [] as ReviewComment[] - return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", sel) - }) - const setReviewCommentsForSelection = (comments: ReviewComment[]) => { - const sel = selection() - if (sel === null) return - setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", sel, comments)) - } const apply = createApplyToLocal({ vscode, dialog, @@ -719,6 +710,8 @@ const AgentManagerContent: Component = () => { }) createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) + composers.prune(ids) + composers.prune(ids) setReviewOpenByContext((prev) => { const next = pruneReviewState(prev, currentProjectId() ?? "single", ids) if (Object.keys(next).length === Object.keys(prev).length) return prev @@ -1661,6 +1654,17 @@ const AgentManagerContent: Component = () => { // The composite id (ctx#scope) the extension keys diff data by. const diffScopeId = review.id + const reviewComments = createMemo(() => { + const key = diffScopeId() + if (!key) return [] as ReviewComment[] + return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key) + }) + const setReviewCommentsForSelection = (comments: ReviewComment[]) => { + const key = diffScopeId() + if (!key) return + setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", key, comments)) + } + const diffScopeControls = (compact: boolean) => ( { tabFocus.restore() } - // Data for the review tab / side panel: keyed by the composite diff id - // (ctx#scope) the extension pushes, so each scope keeps its own file set and - // switching back to a fetched scope is instant. const reviewDiffs = createMemo(() => { const data = diffDatas() const key = diffScopeId() if (!key) return [] - return data[key] ?? [] + return data[diffDataKey(activeProjectId(), key)] ?? [] }) const diffSessionKey = createMemo(() => diffScopeId() ?? "") - // Source-level notice for the active composite id (e.g. snapshots disabled - // for the Session scope), shown as a banner instead of the empty state. const diffNotice = createMemo(() => { const key = diffScopeId() if (!key) return undefined @@ -2634,13 +2633,13 @@ const AgentManagerContent: Component = () => { data={diffDatas} loading={(key) => diffs.diffLoadingFor(() => key)} loadingFiles={(key) => diffs.diffFileLoadingFor(() => key)} - notice={(key) => diffNotices()[key]} - comments={(ctx) => - readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", ctx) + notice={(key) => diffNotices()[diffDataKey(activeProjectId(), key)]} + comments={(key) => + readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key) } - setComments={(ctx, comments) => + setComments={(key, comments) => setReviewCommentsByContext((prev) => - setReviewComments(prev, currentProjectId() ?? "single", ctx, comments), + setReviewComments(prev, currentProjectId() ?? "single", key, comments), ) } composer={composers.get} @@ -2668,6 +2667,11 @@ const AgentManagerContent: Component = () => { }} revertingFiles={revertCtl.revertingFor} activeTerminalId={terms.activeId()} + contexts={() => new Set(worktrees().map((wt) => wt.id))} + onEvict={(key) => { + composers.drop(key) + diffs.drop(key) + }} /> = (props) => { }, ), ) + + createEffect( + on( + () => props.active, + (active) => { + if (!active) return + const value = reviewComposerDraft(composer()) + const edit = reviewComposerEdit(composer()) + setDraft(value) + setEditing(edit) + draftMeta = composer().draft + editMeta = composer().edit + }, + ), + ) const setOpen = (files: string[] | ((prev: string[]) => string[])) => { const key = props.sessionKey ?? "" const current = open() @@ -249,6 +264,7 @@ export const DiffPanel: Component = (props) => { on( () => props.sessionKey, () => { + if (props.active === false) return setDraft(null) draftMeta = null setEditing(null) @@ -393,8 +409,10 @@ export const DiffPanel: Component = (props) => { const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) draftMeta = result.draftMeta editMeta = result.editMeta - composer().draft = draft() ? draftMeta : null - composer().edit = editing() ? editMeta : null + if (props.active !== false) { + composer().draft = draft() ? draftMeta : null + composer().edit = editing() ? editMeta : null + } return result.annotations } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx index e6890125d82..0bf696f83c9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx @@ -3,6 +3,7 @@ import type { WorktreeFileDiff } from "../src/types/messages" import type { ReviewComment } from "../diff-viewer/review-comments" import type { ReviewComposer } from "../diff-viewer/review-annotations" import { DiffPanel } from "./DiffPanel" +import { diffDataKey } from "./worktree-diffs" const CACHE_SIZE = 4 @@ -18,6 +19,8 @@ interface Props { context: Accessor project: Accessor active: Accessor + onEvict?: (key: string) => void + contexts: Accessor> data: Accessor> loading: (key: string) => boolean loadingFiles: (key: string) => Set @@ -46,6 +49,15 @@ export const DiffPanelCache: Component = (props) => { const [entries, setEntries] = createSignal([]) let used = 0 + createEffect(() => { + const contexts = props.contexts() + setEntries((prev) => { + const next = prev.filter((entry) => entry.ctx === "local" || contexts.has(entry.ctx)) + for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey) + return next + }) + }) + createEffect(() => { if (!props.active()) return const key = props.current() @@ -59,12 +71,19 @@ export const DiffPanelCache: Component = (props) => { const current = scoped.find((item) => item.cacheKey === cacheKey) if (current) { current.used = ++used + for (const item of prev) if (!scoped.includes(item)) props.onEvict?.(item.cacheKey) return scoped } const next = [...scoped, { key, cacheKey, ctx, used: ++used }] - if (next.length <= CACHE_SIZE) return next + if (next.length <= CACHE_SIZE) { + for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey) + return next + } const oldest = next.reduce((entry, item) => (item.used < entry.used ? item : entry)) - return next.filter((item) => item !== oldest) + const result = next.filter((item) => item !== oldest) + props.onEvict?.(oldest.cacheKey) + for (const item of prev) if (!result.includes(item)) props.onEvict?.(item.cacheKey) + return result }) }) @@ -77,7 +96,7 @@ export const DiffPanelCache: Component = (props) => { return (
= (props) => { onDiffStyleChange={props.onDiffStyleChange} markdownRender={props.markdownRender} onMarkdownRenderChange={props.onMarkdownRenderChange} - comments={props.comments(entry.ctx)} - onCommentsChange={(comments) => props.setComments(entry.ctx, comments)} + comments={props.comments(entry.key)} + onCommentsChange={(comments) => props.setComments(entry.key, comments)} composer={props.composer(entry.cacheKey)} onSendClick={props.onSendClick} onClose={props.onClose} 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 d57c3a39c50..5fee2b5b3fc 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 @@ -15,6 +15,7 @@ import { showToast } from "@kilocode/kilo-ui/toast" import { groupApplyConflicts } from "./apply-conflicts" import { ApplyDialog } from "./ApplyDialog" import { composeDiffId } from "./diff-scope-state" +import { diffDataKey } from "./worktree-diffs" import type { tracker } from "./telemetry" import type { useDialog } from "@kilocode/kilo-ui/context/dialog" import type { useLanguage } from "../src/context/language" @@ -74,7 +75,7 @@ export function createApplyToLocal(opts: ApplyToLocalOptions) { const applyDiffs = createMemo(() => { const key = applyDiffKey() if (!key) return [] as WorktreeFileDiff[] - return diffDatas()[key] ?? ([] as WorktreeFileDiff[]) + return diffDatas()[diffDataKey(opts.projectId?.(), key)] ?? ([] as WorktreeFileDiff[]) }) const applyStateForTarget = createMemo(() => { 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 index ce0aa9e51c6..158b91cb54e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts @@ -50,8 +50,9 @@ export function pruneReviewState( ): Record { return Object.fromEntries( Object.entries(values).filter(([key]) => { - const [owner, context] = key.split(":") - return owner !== project || context === "local" || contexts.has(context) + const [owner, value] = key.split(":") + const context = value?.split("#", 1)[0] + return owner !== project || context === "local" || (context !== undefined && contexts.has(context)) }), ) } 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 fa2d340fda9..1b3605fae42 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts @@ -21,21 +21,23 @@ export function createRevertFile( projectId?: Accessor, ) { const [files, setFiles] = createSignal>>({}) + const key = (project: string | undefined, scope: string) => `${project ?? "single"}\0${scope}` const reverting = createMemo(() => { const id = diffScopeId() if (!id) return new Set() - return files()[id] ?? new Set() + return files()[key(projectId?.(), id)] ?? new Set() }) - const revertingFor = (id: string) => files()[id] ?? new Set() + const revertingFor = (id: string) => files()[key(projectId?.(), id)] ?? new Set() function revertFor(id: string | undefined, context: string | undefined, source: string, file: string) { if (!id || !context) return + const data = key(projectId?.(), id) setFiles((prev) => { - const set = new Set(prev[id] ?? []) + const set = new Set(prev[data] ?? []) set.add(file) - return { ...prev, [id]: set } + return { ...prev, [data]: set } }) vscode.postMessage({ type: "agentManager.revertWorktreeFile", @@ -51,12 +53,13 @@ export function createRevertFile( } function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) { + const data = key(ev.projectId, ev.sessionId) setFiles((prev) => { - const set = new Set(prev[ev.sessionId] ?? []) + const set = new Set(prev[data] ?? []) set.delete(ev.file) const next = { ...prev } - if (set.size === 0) delete next[ev.sessionId] - else next[ev.sessionId] = set + if (set.size === 0) delete next[data] + else next[data] = set return next }) if (ev.status === "success") { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts index 2901c323ba1..a82f0781be0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts @@ -20,5 +20,23 @@ export function createReviewComposers(project: Accessor) { } } - return { get, clear } + const drop = (key: string) => values.delete(key) + + const clearProject = (id: string) => { + const prefix = `${id}\0` + for (const key of values.keys()) { + if (key.startsWith(prefix)) values.delete(key) + } + } + + const prune = (contexts: Set) => { + const prefix = `${project() ?? "single"}\0` + for (const key of values.keys()) { + if (!key.startsWith(prefix)) continue + const ctx = key.slice(prefix.length).split("#", 1)[0] + if (ctx !== "local" && !contexts.has(ctx)) values.delete(key) + } + } + + return { get, clear, drop, clearProject, prune } } diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts index d76728ebe59..8d517795668 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts @@ -13,6 +13,7 @@ interface DiffRequestOptions { export function createDiffRequests(opts: DiffRequestOptions) { const requested = new Map() + let active = false createEffect( on( @@ -38,6 +39,15 @@ export function createDiffRequests(opts: DiffRequestOptions) { on( () => [opts.open(), opts.diffs(), opts.loading(), opts.send()] as const, ([open, diffs]) => { + if (!opts.send()) { + requested.clear() + active = false + return + } + if (!active) { + requested.clear() + active = true + } const files = new Set(open) for (const file of requested.keys()) { if (!files.has(file)) requested.delete(file) From 44b438363aae82aa6161e8cbe5c6acdd74bcbda1 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 13:29:03 +0200 Subject: [PATCH 04/50] fix(agent-manager): namespace diff data by project --- .../agent-manager/worktree-diffs.ts | 78 +++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) 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 1de9f562193..2b7604fd799 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -29,6 +29,14 @@ export function wireDiffId(id: string) { return { sessionId: ctx, scope, diffSessionId: sessionId } } +export function diffDataKey(project: string | undefined, id: string): string { + return `${project ?? "single"}\0${id}` +} + +function readData(data: Record, project: string | undefined, id: string) { + return data[diffDataKey(project, id)] +} + export function createWorktreeDiffs( vscode: ReturnType, project: () => string | undefined = () => undefined, @@ -39,6 +47,8 @@ export function createWorktreeDiffs( const [diffNotices, setDiffNotices] = createSignal>({}) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + const key = (id: string) => diffDataKey(project(), id) + const reset = () => { setDiffDatas({}) setDiffLoadings({}) @@ -46,6 +56,34 @@ export function createWorktreeDiffs( setDiffFileLoading({}) } + const drop = (id: string) => { + const data = id.includes("\0") ? id : key(id) + setDiffDatas((prev) => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + }) + setDiffLoadings((prev) => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + }) + setDiffNotices((prev) => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + }) + setDiffFileLoading((prev) => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + }) + } + const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { setDiffFileLoading((prev) => { const session = prev[sessionId] ?? {} @@ -74,17 +112,19 @@ export function createWorktreeDiffs( /** Lazily load a single file's full diff for the given composite diff id. */ const requestDiffFile = (id: string, file: string) => { - if (diffFileLoading()[id]?.[file]) return - setDiffFilePending(id, file, true) + const data = key(id) + if (diffFileLoading()[data]?.[file]) return + setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", projectId: project(), file, ...wireDiffId(id) }) } /** Files the backend flagged as stale in a merged update need a fresh fetch. */ const refreshStaleDiffs = (id: string, files: Set) => { - const loading = diffFileLoading()[id] ?? {} + const data = key(id) + const loading = diffFileLoading()[data] ?? {} for (const file of files) { if (loading[file]) continue - setDiffFilePending(id, file, true) + setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", projectId: project(), @@ -98,61 +138,65 @@ export function createWorktreeDiffs( const diffFileLoadingFor = (sessionId: Accessor) => { const id = sessionId() if (!id) return new Set() - return new Set(Object.keys(diffFileLoading()[id] ?? {})) + return new Set(Object.keys(diffFileLoading()[key(id)] ?? {})) } /** Initial summary loading for one composite diff id. Cached results stay visible while refreshing. */ const diffLoadingFor = (sessionId: Accessor) => { const id = sessionId() if (!id) return false - return diffLoadings()[id] === true && !(id in diffDatas()) + const data = key(id) + return diffLoadings()[data] === true && !(data in diffDatas()) } // Backend messages. const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) let staleFiles: Set | undefined setDiffDatas((prev) => { - const existing = prev[ev.sessionId] + const existing = readData(prev, project(), ev.sessionId) const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set() } staleFiles = merged.stale const next = merged.diffs if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev - return { ...prev, [ev.sessionId]: next } + return { ...prev, [data]: next } }) if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) } const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) if (ev.diff) { setDiffDatas((prev) => { - const existing = prev[ev.sessionId] ?? [] + const existing = readData(prev, project(), ev.sessionId) ?? [] const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) - return { ...prev, [ev.sessionId]: next } + return { ...prev, [data]: next } }) - setDiffFilePending(ev.sessionId, ev.diff.file, false) + setDiffFilePending(data, ev.diff.file, false) return } - setDiffFilePending(ev.sessionId, ev.file, false) + setDiffFilePending(data, ev.file, false) } const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) // One source is active per project. Replacing the map on start also clears // an interrupted source whose stale completion is intentionally discarded. if (ev.loading) { - setDiffLoadings({ [ev.sessionId]: true }) + setDiffLoadings({ [data]: true }) return } setDiffLoadings((prev) => { - if (!prev[ev.sessionId]) return prev + if (!prev[data]) return prev const next = { ...prev } - delete next[ev.sessionId] + delete next[data] return next }) } const onWorktreeDiffNotice = (ev: AgentManagerWorktreeDiffNoticeMessage) => { - setDiffNotices((prev) => ({ ...prev, [ev.sessionId]: ev.notice })) + setDiffNotices((prev) => ({ ...prev, [diffDataKey(ev.projectId, ev.sessionId)]: ev.notice })) } return { @@ -164,6 +208,8 @@ export function createWorktreeDiffs( refreshStaleDiffs, diffFileLoadingFor, diffLoadingFor, + diffDataKey, + drop, reset, onWorktreeDiff, onWorktreeDiffFile, From befab09aa4f50ff574f40fdac447fc6447562922 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 13:35:04 +0200 Subject: [PATCH 05/50] test(agent-manager): cover diff cancellation races --- .../kilo-vscode/tests/unit/git-ops.test.ts | 14 +++++++++++ .../kilo-vscode/tests/unit/local-diff.test.ts | 16 ++++++++++++ .../tests/unit/source-controller.test.ts | 25 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 1 - 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index 05c768c93a3..c90aa3a7c05 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -667,6 +667,20 @@ describe("GitOps", () => { }) }) + it("kills an in-flight exec when its request signal aborts", async () => { + await withRepo(async (cwd) => { + const git = new GitOps({ log: () => undefined, binary: async () => process.execPath }) + const ctl = new AbortController() + const pending = git.execGit(["-e", "setTimeout(() => {}, 5000)"], cwd, { signal: ctl.signal }) + await sleep(25) + ctl.abort() + + const result = await pending + expect(result.code).not.toBe(0) + git.dispose() + }) + }) + it("is safe to call multiple times", () => { const git = ops(async () => "ok") git.dispose() diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index 1e947e34ae1..595d0bf349c 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -365,6 +365,22 @@ describe("diffFile", () => { }) }) + it("does not cache detail that is aborted before Git completes", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n") + const local = createLocalDiff(git()) + await local.summary(dir, base) + + const ctl = new AbortController() + const pending = local.file(dir, base, "seed.txt", ctl.signal) + ctl.abort() + await expect(pending).rejects.toThrow() + + const result = await local.file(dir, base, "seed.txt") + expect(result?.after).toBe("seed\ncached\n") + }) + }) + it("invalidates cached detail after the summary stamp changes", async () => { await withRepo(async (dir, base) => { await fs.writeFile(path.join(dir, "seed.txt"), "seed\nfirst\n") diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts index c08673ced3e..915cdc13fbf 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -489,4 +489,29 @@ describe("SourceController.refresh", () => { controller.stop() }) + + it("shares an in-flight fetch between refresh and polling callers", async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let fetches = 0 + const source: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + fetches++ + await gate + return { diffs: [] } + }, + } + const { controller } = make({ "session:s1": source }) + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + const activation = controller.activate("session:s1", { poll: false }) + const refresh = controller.refresh() + release() + await Promise.all([activation, refresh]) + + expect(fetches).toBe(1) + controller.stop() + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index f0765306353..aa879913b59 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -711,7 +711,6 @@ const AgentManagerContent: Component = () => { createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) composers.prune(ids) - composers.prune(ids) setReviewOpenByContext((prev) => { const next = pruneReviewState(prev, currentProjectId() ?? "single", ids) if (Object.keys(next).length === Object.keys(prev).length) return prev From b1f1dec04e2cf49f85c8745f0b480c9f53a4f3ff Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:40:06 +0200 Subject: [PATCH 06/50] fix(agent-manager): use valid terminal close code --- .changeset/terminal-replay-close-code.md | 5 +++++ .../tests/unit/agent-manager-terminal-layout.test.ts | 5 +++++ .../webview-ui/agent-manager/terminal/TerminalTab.tsx | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/terminal-replay-close-code.md diff --git a/.changeset/terminal-replay-close-code.md b/.changeset/terminal-replay-close-code.md new file mode 100644 index 00000000000..2be3e869de9 --- /dev/null +++ b/.changeset/terminal-replay-close-code.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use a browser-valid close code when Agent Manager terminal replay exceeds its buffer limit diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts index bbf0eee69ed..9f9220edf54 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -85,6 +85,11 @@ test("orders local terminal status lines through the output batcher", () => { expect(terminal).not.toContain("term.writeln(") }) +test("uses a browser-valid close code when replay overflows", () => { + expect(terminal).not.toContain("close(1009,") + expect(terminal).toContain('close(4009, "terminal replay exceeded limit")') +}) + test("keeps raw PTY line endings and initializes Unicode widths before attaching", () => { expect(terminal).toContain("convertEol: false") expect(terminal).toContain('term.unicode.activeVersion = "15-graphemes"') diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 54ef6689981..a58e421de62 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -334,7 +334,7 @@ export const TerminalTab: Component = (props) => { if (typeof event.data === "string") { if (!replay.output(event.data)) { input.clear() - next.close(1009, "terminal replay exceeded limit") + next.close(4009, "terminal replay exceeded limit") return } scheduleFlush() @@ -345,7 +345,7 @@ export const TerminalTab: Component = (props) => { if (replay.frame(bytes)) return if (!replay.output(bytes)) { input.clear() - next.close(1009, "terminal replay exceeded limit") + next.close(4009, "terminal replay exceeded limit") return } scheduleFlush() From 45695e6190687a8d8cde17c09263799f7b793747 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:41:38 +0200 Subject: [PATCH 07/50] fix(vscode): bound sync filter state --- .changeset/fix-sync-filter-lifecycle.md | 5 + .../cli-backend/connection-service.ts | 4 +- .../services/cli-backend/connection-utils.ts | 3 +- .../tests/unit/connection-utils.test.ts | 135 +++++++++++++++++- 4 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-sync-filter-lifecycle.md diff --git a/.changeset/fix-sync-filter-lifecycle.md b/.changeset/fix-sync-filter-lifecycle.md new file mode 100644 index 00000000000..47964a30b15 --- /dev/null +++ b/.changeset/fix-sync-filter-lifecycle.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent duplicate-event tracking from suppressing delayed sync events after reconnects or high event bursts. diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 1b0e8679d38..a40ffb60d8f 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -96,7 +96,6 @@ export class KiloConnectionService { private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null private readonly eventListeners: Set = new Set() - private readonly duplicateEvent = createDuplicateEventFilter() private readonly stateListeners: Set = new Set() private readonly notificationDismissListeners: Set = new Set() private readonly languageChangeListeners: Set = new Set() @@ -821,6 +820,7 @@ export class KiloConnectionService { }, }) const sse = new SdkSSEAdapter(client) + const duplicateEvent = createDuplicateEventFilter() this.client = client this.sseClient = sse @@ -839,7 +839,7 @@ export class KiloConnectionService { sse.onEvent((event, directory) => { if (this.sseClient !== sse) return // EventV2Bridge also emits these durable compatibility envelopes after their normal live events. - if (this.duplicateEvent(event)) return + if (duplicateEvent(event)) return this.handlePermissionEvent(event, directory) this.handleQuestionEvent(event, directory) for (const listener of this.eventListeners) { diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index 271eec41148..97ea3a568ff 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -24,8 +24,7 @@ export function createDuplicateEventFilter() { } if (duplicateLiveEvents.has(event.type)) { - seen.add(event.id) - if (seen.size > DUPLICATE_EVENT_LIMIT) seen.delete(seen.values().next().value!) + if (seen.size < DUPLICATE_EVENT_LIMIT) seen.add(event.id) } return false } diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index c1865616e7d..cde5796e8b5 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -173,7 +173,7 @@ describe("resolveEventSessionId", () => { }) }) -describe("isDuplicateSyncEvent", () => { +describe("createDuplicateEventFilter", () => { it("drops a compatibility envelope only after its live event", () => { const filter = createDuplicateEventFilter() const live = { @@ -233,4 +233,137 @@ describe("isDuplicateSyncEvent", () => { ), ).toBe(false) }) + + it("does not evict pending live events when the cap is reached", () => { + const filter = createDuplicateEventFilter() + for (let index = 0; index < 1024; index++) { + expect( + filter({ + id: `live-${index}`, + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + } + + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-0", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + expect( + filter({ + id: "live-1024", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-1024", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + }) + + it("passes overflow events through without evicting pending IDs", () => { + const filter = createDuplicateEventFilter() + for (let index = 0; index < 1024; index++) { + expect( + filter({ + id: `pending-${index}`, + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + } + + expect( + filter({ + id: "overflow", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "overflow", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "pending-0", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + expect( + filter({ + id: "after-free", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "after-free", + seq: 10, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + }) + + it("does not carry duplicate IDs between connections", () => { + const first = createDuplicateEventFilter() + const second = createDuplicateEventFilter() + const live = { + id: "connection-event", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + } satisfies Payload + + expect(first(live)).toBe(false) + expect( + second( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "connection-event", + seq: 11, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + }) }) From 1aeb626047f16193b1072b8b98919e95a2be9d3d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:41:52 +0200 Subject: [PATCH 08/50] fix(cli): align file location cache keys --- .changeset/steady-location-keys.md | 5 +++ .../routes/instance/httpapi/handlers/file.ts | 11 +++++- .../kilocode/shared-location-map-key.test.ts | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .changeset/steady-location-keys.md create mode 100644 packages/opencode/test/kilocode/shared-location-map-key.test.ts diff --git a/.changeset/steady-location-keys.md b/.changeset/steady-location-keys.md new file mode 100644 index 00000000000..1607aba1a28 --- /dev/null +++ b/.changeset/steady-location-keys.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep file route location services on the same cache key as workspace-aware server routes. diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 026fee106bc..181a2bdd7fb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,4 +1,5 @@ import * as InstanceState from "@/effect/instance-state" +import { WorkspaceRef } from "@/effect/instance-ref" // kilocode_change - preserve the shared location key shape import { FileSystem } from "@opencode-ai/core/filesystem" import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Ripgrep } from "@opencode-ai/core/ripgrep" @@ -17,11 +18,19 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const locations = yield* LocationServiceMap.Service const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { + // kilocode_change start - preserve the shared location key shape + const workspaceID = yield* WorkspaceRef return yield* effect.pipe( Effect.provide( - locations.get(Location.Ref.make({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })), + locations.get( + Location.Ref.make({ + directory: AbsolutePath.make((yield* InstanceState.context).directory), + workspaceID, + }), + ), ), ) + // kilocode_change end }) const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { diff --git a/packages/opencode/test/kilocode/shared-location-map-key.test.ts b/packages/opencode/test/kilocode/shared-location-map-key.test.ts new file mode 100644 index 00000000000..3fa931e7798 --- /dev/null +++ b/packages/opencode/test/kilocode/shared-location-map-key.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Equal, Hash } from "effect" +import { readFileSync } from "node:fs" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" + +const opencode = new URL("../../src/", import.meta.url) +const server = new URL("../../../server/src/", import.meta.url) + +function source(root: URL, path: string) { + return readFileSync(new URL(path, root), "utf8") +} + +describe("shared location service map keys", () => { + test("all location route consumers include workspaceID in their cache key", () => { + const consumers = [ + [opencode, "server/routes/instance/httpapi/handlers/file.ts"], + [opencode, "server/routes/instance/httpapi/handlers/pty.ts"], + [server, "middleware/session-location.ts"], + ] as const + + for (const [root, path] of consumers) { + expect(source(root, path), path).toMatch(/Location\.Ref\.make\(\{[^}]*workspaceID/) + } + }) + + test("omitted and explicit undefined workspace IDs are distinct keys", () => { + const directory = AbsolutePath.make("/workspace") + const omitted = Location.Ref.make({ directory }) + const explicit = Location.Ref.make({ directory, workspaceID: undefined }) + + expect(Object.hasOwn(omitted, "workspaceID")).toBe(false) + expect(Object.hasOwn(explicit, "workspaceID")).toBe(true) + expect(Equal.equals(omitted, explicit)).toBe(false) + expect(Hash.hash(omitted)).not.toBe(Hash.hash(explicit)) + }) +}) From 651dab1a12cc4837039a049075f21cd1d1af9a97 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:42:51 +0200 Subject: [PATCH 09/50] fix(agent-manager): address diff review feedback --- .../src/agent-manager/local-diff.ts | 31 ++------------- .../kilo-vscode/src/diff/SourceController.ts | 14 +++++-- .../tests/unit/source-controller.test.ts | 4 +- .../webview-ui/agent-manager/DiffPanel.tsx | 39 ++++++++++++------- .../agent-manager/worktree-diffs.ts | 15 +++---- 5 files changed, 44 insertions(+), 59 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index d45bf1210b9..0594d299fca 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -1,5 +1,4 @@ import * as fs from "fs/promises" -import { createHash } from "crypto" import { binaryFile } from "../diff/shared/binary" import { imageMime, loadImage, readImageFile } from "../diff/shared/image" import { resolveInside } from "../diff/shared/path" @@ -148,30 +147,6 @@ async function statStamp(dir: string, file: string): Promise { return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino ?? 0}` } -async function contentStamp(dir: string, file: string, status: Status): Promise { - if (status === "deleted") return "deleted" - const full = resolveInside(dir, file) - if (!full) return `missing:${file}` - const stat = await fs.lstat(full).catch(() => undefined) - if (!stat) return `missing:${file}` - const value = stat.isSymbolicLink() - ? Buffer.from(await fs.readlink(full)) - : stat.isFile() - ? await fs.readFile(full).catch(() => undefined) - : undefined - if (!value) return `unreadable:${file}` - return createHash("sha256").update(value).digest("hex") -} - -function detailStamp(value: WorktreeDiffEntry, meta: Meta): string { - if (meta.status === "deleted") return "deleted" - const data = value.image?.after?.data - if (data) return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex") - return createHash("sha256") - .update(value.after ?? "") - .digest("hex") -} - async function detailReads(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { return Promise.all([ readBefore(git, dir, anc, meta.file, meta.status, signal), @@ -359,8 +334,8 @@ export function createLocalDiff(git: GitOps, log?: Log) { const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.tracked}\0${meta.status}\0${meta.additions}\0${meta.deletions}\0${meta.binary}\0${meta.stamp}` const cached = details.get(id) if (cached) { - if (cached.stamp === (await contentStamp(dir, file, meta.status))) { - remember(id, cached.value, cached.stamp) + if (cached.stamp === meta.stamp) { + remember(id, cached.value, meta.stamp) return cached.value } forget(id) @@ -374,7 +349,7 @@ export function createLocalDiff(git: GitOps, log?: Log) { if (pending.get(id)?.work !== work) return pending.delete(id) if (value.image?.before?.error === "unreadable" || value.image?.after?.error === "unreadable") return - remember(id, value, detailStamp(value, meta)) + remember(id, value, meta.stamp) }, () => { if (pending.get(id)?.work === work) pending.delete(id) diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index c05a826a180..09eb6fb775c 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -153,7 +153,7 @@ export class SourceController { // Push fresh diffs immediately after a successful revert so the webview // doesn't have to wait for the next polling tick. if (result.ok && this.epoch === epoch && this.active === source) { - await this.fetch(source, epoch, false) + await this.fetch(source, epoch, true, true) } } @@ -162,7 +162,7 @@ export class SourceController { const source = this.active if (!source) return const epoch = this.epoch - await this.fetch(source, epoch, true) + await this.fetch(source, epoch, true, true) } /** @@ -267,9 +267,15 @@ export class SourceController { } } - private fetch(source: DiffSource, epoch: number, initial: boolean): Promise { + private fetch(source: DiffSource, epoch: number, initial: boolean, force = false): Promise { const current = this.fetches.get(source) - if (current) return current + if (current && !force) return current + if (current) { + return current.then(() => { + if (this.epoch !== epoch || this.active !== source) return false + return this.fetch(source, epoch, initial) + }) + } const work = this.runFetch(source, epoch, initial) this.fetches.set(source, work) work.then( diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts index 915cdc13fbf..c7517fde756 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -490,7 +490,7 @@ describe("SourceController.refresh", () => { controller.stop() }) - it("shares an in-flight fetch between refresh and polling callers", async () => { + it("runs a forced refresh after an in-flight fetch", async () => { let release!: () => void const gate = new Promise((resolve) => { release = resolve @@ -511,7 +511,7 @@ describe("SourceController.refresh", () => { release() await Promise.all([activation, refresh]) - expect(fetches).toBe(1) + expect(fetches).toBe(2) controller.stop() }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 7a088d7ab49..4dbb6611ac9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -1,4 +1,13 @@ -import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js" +import { + type Component, + createSignal, + createMemo, + Show, + createEffect, + createRenderEffect, + on, + type JSXElement, +} from "solid-js" import type { VirtualizerHandle } from "virtua/solid" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" @@ -160,20 +169,6 @@ export const DiffPanel: Component = (props) => { ), ) - createEffect( - on( - () => props.active, - (active) => { - if (!active) return - const value = reviewComposerDraft(composer()) - const edit = reviewComposerEdit(composer()) - setDraft(value) - setEditing(edit) - draftMeta = composer().draft - editMeta = composer().edit - }, - ), - ) const setOpen = (files: string[] | ((prev: string[]) => string[])) => { const key = props.sessionKey ?? "" const current = open() @@ -212,6 +207,20 @@ export const DiffPanel: Component = (props) => { // so pierre's annotation cache doesn't invalidate and destroy the textarea. let draftMeta: AnnotationMeta | null = composer().draft let editMeta: AnnotationMeta | null = composer().edit + createRenderEffect( + on( + () => props.active, + (active) => { + if (!active) return + const value = reviewComposerDraft(composer()) + const edit = reviewComposerEdit(composer()) + setDraft(value) + setEditing(edit) + draftMeta = composer().draft + editMeta = composer().edit + }, + ), + ) // Ref to the scrollable container — used to preserve scroll position when // annotation changes cause pierre to fully re-render diffs 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 2b7604fd799..4ca050eff4a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -33,10 +33,6 @@ export function diffDataKey(project: string | undefined, id: string): string { return `${project ?? "single"}\0${id}` } -function readData(data: Record, project: string | undefined, id: string) { - return data[diffDataKey(project, id)] -} - export function createWorktreeDiffs( vscode: ReturnType, project: () => string | undefined = () => undefined, @@ -119,15 +115,14 @@ export function createWorktreeDiffs( } /** Files the backend flagged as stale in a merged update need a fresh fetch. */ - const refreshStaleDiffs = (id: string, files: Set) => { - const data = key(id) + const refreshStaleDiffs = (id: string, files: Set, data = key(id), owner = project()) => { const loading = diffFileLoading()[data] ?? {} for (const file of files) { if (loading[file]) continue setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", - projectId: project(), + projectId: owner, file, ...wireDiffId(id), }) @@ -155,21 +150,21 @@ export function createWorktreeDiffs( const data = diffDataKey(ev.projectId, ev.sessionId) let staleFiles: Set | undefined setDiffDatas((prev) => { - const existing = readData(prev, project(), ev.sessionId) + const existing = prev[data] const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set() } staleFiles = merged.stale const next = merged.diffs if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev return { ...prev, [data]: next } }) - if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles, data, ev.projectId) } const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => { const data = diffDataKey(ev.projectId, ev.sessionId) if (ev.diff) { setDiffDatas((prev) => { - const existing = readData(prev, project(), ev.sessionId) ?? [] + const existing = prev[data] ?? [] const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) return { ...prev, [data]: next } }) From 78692a7f2a06d6b30e1b75385888fdb2f823a26a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:46:33 +0200 Subject: [PATCH 10/50] fix(agent-manager): allow explicit provider selection --- .changeset/explicit-agent-manager-provider.md | 6 +++ .../kilo-docs/pages/automate/agent-manager.md | 2 +- .../src/kilocode/tool/agent-manager-models.ts | 2 +- .../kilocode/tool/agent-manager-models.txt | 4 +- .../src/kilocode/tool/agent-manager.ts | 29 +++++++++-- .../src/kilocode/tool/agent-manager.txt | 2 +- .../test/kilocode/agent-manager-tool.test.ts | 51 +++++++++++++++++++ 7 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 .changeset/explicit-agent-manager-provider.md diff --git a/.changeset/explicit-agent-manager-provider.md b/.changeset/explicit-agent-manager-provider.md new file mode 100644 index 00000000000..31ed86c6abb --- /dev/null +++ b/.changeset/explicit-agent-manager-provider.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Allow Agent Manager task model overrides to specify an explicit provider when resolving model names. diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 70a4dc10dbb..8c4fa2c2332 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -214,7 +214,7 @@ The tool supports two modes: | `worktree` | Creates one Agent Manager git worktree and session per task | | `local` | Creates Agent Manager sessions in the current workspace without git worktree isolation | -Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Prompted tasks inherit the model and reasoning variant used by the chat turn that starts them. A task can override that selection with a `model` (by name, e.g. `Claude Opus 4.1`) when you explicitly request a different model, or with one of the current model's reasoning `variant` values when you request a different variant. Agent Manager resolves the provider for a model override, preferring the provider used by the current turn and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted to force a specific provider. Prepared sessions without an initial prompt use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. +Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Prompted tasks inherit the model and reasoning variant used by the chat turn that starts them. A task can override that selection with a `model` (by name, e.g. `Claude Opus 4.1`) when you explicitly request a different model, or with one of the current model's reasoning `variant` values when you request a different variant. Add `provider` beside `model` to force a model-name match to one of the listed provider IDs. Agent Manager resolves the provider for a model override when `provider` is omitted, preferring the provider used by the current turn and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted. Prepared sessions without an initial prompt use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context. diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.ts b/packages/opencode/src/kilocode/tool/agent-manager-models.ts index 613489a1c89..d70d697db16 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.ts @@ -89,7 +89,7 @@ export const AgentManagerModelsTool = Tool.define< offset, total: matches.length, nextOffset, - hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Agent Manager picks the provider, preferring the one used by the current turn.", + hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Add the task `provider` to force one of the listed providers; otherwise Agent Manager prefers the provider used by the current turn.", }), metadata: { count: models.length, total: matches.length }, } diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.txt b/packages/opencode/src/kilocode/tool/agent-manager-models.txt index 8c73797bde6..8571d6045d9 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.txt @@ -1,5 +1,5 @@ Search the models available to Agent Manager sessions and inspect their reasoning variants. -Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, because you select a model and Agent Manager chooses the provider for you. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name. +Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, and list every provider that offers each model so you can constrain the provider when needed. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name. -Each result includes the model name, its reasoning variant names, and the providers that offer it (informational only). Pass the model name back as the `agent_manager` task `model`. Agent Manager resolves the provider automatically, preferring the provider used by the current turn and falling back to the Kilo Gateway, so you do not need to choose a provider yourself. +Each result includes the model name, its reasoning variant names, and the providers that offer it. Pass the model name back as the `agent_manager` task `model`; pass one of the listed provider IDs as the task `provider` when the provider must be explicit. When `provider` is omitted, Agent Manager resolves it automatically, preferring the one used by the current turn and falling back to the Kilo Gateway. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 8166f8302ee..a1b098d0fff 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -28,6 +28,10 @@ const Task = Schema.Struct({ description: "Optional model override from agent_manager_models (e.g. 'Claude Opus 4.1'). Omit unless the user requests a different model. Agent Manager otherwise inherits the current turn's model. A qualified provider/model ID is also accepted to force a specific provider.", }), + provider: Schema.optional(Schema.NullOr(Schema.String)).annotate({ + description: + "Optional provider ID to constrain model resolution (e.g. 'anthropic'). Use with model to select a model from a specific provider; omit to use the current-turn provider preference.", + }), variant: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Optional reasoning variant override from agent_manager_models. Specify it without model to override the inherited model's variant. Omit both to inherit the current turn's selection.", @@ -41,6 +45,9 @@ const Task = Schema.Struct({ Schema.makeFilter((task) => task.model?.trim() && !task.prompt?.trim() ? "A task model requires an initial prompt" : undefined, ), + Schema.makeFilter((task) => + task.provider?.trim() && !task.model?.trim() ? "A task provider requires a model" : undefined, + ), Schema.makeFilter((task) => task.variant?.trim() && !task.prompt?.trim() ? "A task variant requires an initial prompt" : undefined, ), @@ -245,7 +252,9 @@ function select( ...(task.branchName != null ? { branchName: task.branchName } : {}), } const value = task.model?.trim() + const provider = task.provider?.trim() const variant = task.variant?.trim() + if (provider && !value) return { error: `Task ${index + 1} provider requires a model.` } if (!value) { if (!variant) { if (!task.prompt?.trim() || !source) return { task: base } @@ -271,12 +280,21 @@ function select( return { task: { ...base, model: source.model, variant } } } - const { pool, names } = lookup(all, value) + const scope = provider ? all.filter((item) => item.providerID === provider) : all + if (provider && scope.length === 0) { + return { + error: `Task ${index + 1} provider is not available for model selection: ${provider}. Requested model: ${value}.`, + } + } + + const { pool, names } = lookup(scope, value) if (pool.length === 0) { - const close = suggest(all, value) + const close = suggest(scope, value) const hint = close.length ? ` Closest matches: ${close.join(", ")}.` : "" return { - error: `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`, + error: provider + ? `Task ${index + 1} model is not available from provider "${provider}": ${value}.${hint} Use agent_manager_models to search models.` + : `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`, } } if (names.length > 1) { @@ -479,8 +497,9 @@ export const AgentManagerTool = Tool.define< ...(msg.model.variant ? { variant: msg.model.variant } : {}), } : undefined - const need = params.tasks.some((task) => task.model?.trim() || task.variant?.trim()) - const all = need ? candidates(yield* provider.list()) : [] + const need = params.tasks.some((task) => task.model?.trim() || task.provider?.trim() || task.variant?.trim()) + const providers = need ? yield* provider.list() : undefined + const all = providers ? candidates(providers) : [] const preferred = need ? (source?.model.providerID ?? (yield* provider.defaultModel().pipe( diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 1d081b91dfb..4dbb410b840 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -14,7 +14,7 @@ Modes: - `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog. - `local`: creates Agent Manager sessions in the current workspace directory without git worktree isolation. -Each task may provide a prompt, a short display name, a branch name, a `model`, and a model-specific reasoning `variant`. By default, omit `model` and `variant`: prompted tasks inherit the exact model and reasoning variant used by the current turn. Only specify `model` when the user explicitly asks to use or compare a different model, and only specify `variant` when the user explicitly asks for a different reasoning variant. A variant can be specified without a model to override the inherited model's variant. Never choose a different model merely because work is being fanned out. Specify an override `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider used by the current turn and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model or variant selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Prepared sessions without an initial prompt use the normal defaults. The agent and base branch settings always use the normal defaults. +Each task may provide a prompt, a short display name, a branch name, a `model`, an optional `provider`, and a model-specific reasoning `variant`. By default, omit `model`, `provider`, and `variant`: prompted tasks inherit the exact model and reasoning variant used by the current turn. Only specify `model` when the user explicitly asks to use or compare a different model, and only specify `variant` when the user explicitly asks for a different reasoning variant. A variant can be specified without a model to override the inherited model's variant. Specify `provider` with `model` to force a model-name match to one provider ID. Never choose a different model merely because work is being fanned out. Specify an override `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider used by the current turn and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model or variant selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Prepared sessions without an initial prompt use the normal defaults. The agent and base branch settings always use the normal defaults. By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes. diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 69b33d42384..411d2d173f3 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -229,6 +229,14 @@ describe("agent_manager tool", () => { expect(Schema.is(Params)({ action: "stop", sessionID: "invalid" })).toBe(false) }) + test("validates provider selectors at the task level", () => { + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: "kilo" }] })).toBe( + true, + ) + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", provider: "kilo" }] })).toBe(false) + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: 42 }] })).toBe(false) + }) + // Regression for #13029: the OpenAI Responses API forces a value for every // advertised property. With action nullable the model can decline it and the // start request survives; with a populated action the action wins instead. @@ -673,6 +681,12 @@ describe("agent_manager tool", () => { expect(task?.variant).toBe("low") }) + test("uses an explicitly selected provider for a shared model name", async () => { + const task = await publish(runtime, { prompt: "Fix", model: " Shared ", provider: " kilo " }) + expect(String(task?.model?.providerID)).toBe("kilo") + expect(String(task?.model?.modelID)).toBe("kilo/shared") + }) + test("uses the provider of a different default model when that is the user's choice", async () => { const rt = makeRuntime("kilo") const task = await publish(rt, { prompt: "Fix", model: "Shared", variant: "low" }) @@ -717,6 +731,43 @@ describe("agent_manager tool", () => { expect(result.metadata.count).toBe(0) }) + test("reports a model unavailable from an explicit provider", async () => { + const tool = await init() + const calls: unknown[] = [] + + const result = await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix", model: "Reasoning Model", provider: "kilo" }] }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([]) + expect(result.output).toContain('model is not available from provider "kilo": Reasoning Model') + expect(result.metadata.count).toBe(0) + }) + + test("rejects an unknown provider without touching inherited object properties", async () => { + const tool = await init() + const calls: unknown[] = [] + + const result = await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: "__proto__" }] }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([]) + expect(result.output).toContain("provider is not available for model selection: __proto__") + expect(result.output).toContain("Requested model: Shared") + expect(result.metadata.count).toBe(0) + }) + test("echoes how each named model resolved", async () => { const tool = await init() const result = await runtime.runPromise( From 5fea23bbc9749b966c055c21acb143493d1768ac Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:47:07 +0200 Subject: [PATCH 11/50] chore(agent-manager): stay under app line cap --- .../webview-ui/agent-manager/AgentManagerApp.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index aa879913b59..ffe685a6a75 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -2387,9 +2387,6 @@ const AgentManagerContent: Component = () => {
- {/* Tab bar — full version with tabs renders when a section is selected - and has tabs; otherwise a minimal version still renders so the - sidebar toggle button stays at a fixed position. */} { /> - {/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
- {/* Chat/terminal + side diff panel. Keep it mounted under the - review tab so live xterm canvases never leave the paint tree. */}
- {/* Keep terminal tabs mounted so output streams across worktree switches. */} {renderTerminalLayer({ state: terms, onFocusPrompt: focusCtl.focus, onFocusChange: focusCtl.report, })} - {/* Session-less context (e.g. a worktree mid-provisioning): the - empty state lives in the main pane so the side terminal - panel can render next to it. */}
{
- {/* One inspector host for all right-side modes. It stays - mounted while a side terminal is alive — hidden via - .am-side-host-hidden (absolute + opacity), never - unmounted, so xterm render loops keep streaming. */} 0 || subagents.tabs().length > 0} > From 184ed23007d14e48d42a6f8f1d82113cb97e5b46 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:54:59 +0200 Subject: [PATCH 12/50] fix(cli): address startup review feedback --- .changeset/fix-cli-startup-followup.md | 5 +++++ .../opencode/src/kilocode/cli/lazy-commands.ts | 14 ++++++++++---- packages/opencode/src/kilocode/cli/setup.ts | 1 - packages/opencode/src/kilocode/help-command.ts | 9 ++++++--- .../test/kilocode/cli/bootstrap-runtime.test.ts | 9 +++++++++ 5 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-cli-startup-followup.md diff --git a/.changeset/fix-cli-startup-followup.md b/.changeset/fix-cli-startup-followup.md new file mode 100644 index 00000000000..23d9ac25a06 --- /dev/null +++ b/.changeset/fix-cli-startup-followup.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix CLI help disposal and shell completion after startup optimization. diff --git a/packages/opencode/src/kilocode/cli/lazy-commands.ts b/packages/opencode/src/kilocode/cli/lazy-commands.ts index ee49d9d5b6a..c9490e833b7 100644 --- a/packages/opencode/src/kilocode/cli/lazy-commands.ts +++ b/packages/opencode/src/kilocode/cli/lazy-commands.ts @@ -19,6 +19,10 @@ export function hasLazyCommandSelection() { return selected } +export function markLazyCommandSelection() { + selected = true +} + export function lazy(input: { command: string | readonly string[] aliases?: string | readonly string[] @@ -29,9 +33,11 @@ export function lazy(input: { const load = () => (state.task ??= input.load()) if (completion) { tasks.push( - load().then((command) => { - state.command = command - }), + load() + .then((command) => { + state.command = command + }) + .catch(() => undefined), ) } return { @@ -39,7 +45,7 @@ export function lazy(input: { aliases: input.aliases, describe: input.describe, builder: ((args: Argv) => { - selected = true + markLazyCommandSelection() if (state.command) return build(state.command, args) return load().then((command) => build(command, args)) }) as never, diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index 7557d3c88a8..3d1e8bae985 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -153,7 +153,6 @@ export namespace KiloCli { if (narrow) { const { KiloCliBootstrapRuntime } = await import("@/kilocode/cli/bootstrap-runtime") await KiloCliBootstrapRuntime.dispose() - return } const { InstanceRuntime } = await import("@/project/instance-runtime") await InstanceRuntime.disposeAllInstances() // safety net (no-op if already disposed) diff --git a/packages/opencode/src/kilocode/help-command.ts b/packages/opencode/src/kilocode/help-command.ts index ecb5f90f14d..fa92607751a 100644 --- a/packages/opencode/src/kilocode/help-command.ts +++ b/packages/opencode/src/kilocode/help-command.ts @@ -1,13 +1,15 @@ import { cmd } from "../cli/cmd/cmd" import { generateHelp } from "./help" import type { Argv } from "yargs" +import { markLazyCommandSelection } from "@/kilocode/cli/lazy-commands" export function createHelpCommand(root?: () => Argv) { return cmd({ command: "help [command]", describe: "show full CLI reference", - builder: (yargs) => - yargs + builder: (yargs) => { + markLazyCommandSelection() + return yargs .positional("command", { describe: "command to show help for", type: "string", @@ -22,7 +24,8 @@ export function createHelpCommand(root?: () => Argv) { type: "string", choices: ["md", "text"] as const, default: "md" as const, - }), + }) + }, async handler(args) { if (!args.command && !args.all) { if (root) { diff --git a/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts index 4ac32559b59..4bcbf15995a 100644 --- a/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts +++ b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" import { KiloCli } from "../../../src/kilocode/cli/setup" +import { createHelpCommand } from "../../../src/kilocode/help-command" +import yargs from "yargs" describe("CLI bootstrap runtime selection", () => { test("uses the narrow runtime for worker-backed TUI launches", () => { @@ -11,4 +13,11 @@ describe("CLI bootstrap runtime selection", () => { expect(KiloCli.workerTui({ _: [], mini: true })).toBe(false) expect(KiloCli.workerTui({ _: [], worktree: "feature" })).toBe(false) }) + + test("keeps full bootstrap when the eager help command is selected", () => { + const command = createHelpCommand() + if (typeof command.builder !== "function") throw new Error("help builder is not a function") + command.builder(yargs([])) + expect(KiloCli.workerTui({ _: [] })).toBe(false) + }) }) From 0f576d0866b56780b22ab60701e03f2780835d49 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:55:44 +0200 Subject: [PATCH 13/50] fix(agent-manager): preserve scoped history activation --- .../agent-manager-history-routing-fix.md | 5 +++++ .../src/agent-manager/project/messages.ts | 3 +++ .../src/agent-manager/project/wiring.ts | 1 + .../unit/agent-project-selection.test.ts | 22 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 11 ++++++---- 5 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 .changeset/agent-manager-history-routing-fix.md diff --git a/.changeset/agent-manager-history-routing-fix.md b/.changeset/agent-manager-history-routing-fix.md new file mode 100644 index 00000000000..a015ab31474 --- /dev/null +++ b/.changeset/agent-manager-history-routing-fix.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix project-scoped Agent Manager history activation and session placement. diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index 55deaf69801..d3fa3bb80b9 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -45,6 +45,8 @@ export interface ProjectMessageDeps { expand: (ctx: ProjectContext) => void /** Push the current project snapshots to the webview. */ push: () => void + /** Push one project's managed state to the webview. */ + pushState?: (ctx: ProjectContext) => void /** Acknowledge an atomically validated sidebar selection. */ selected: (target: SidebarTarget) => void /** Show a user-facing error. */ @@ -156,6 +158,7 @@ async function openSessionLocally(projectId: string, sessionId: string, deps: Pr } state?.moveSession(sessionId, null) deps.routeSession?.(projectId, sessionId, ctx.root, ctx.generation) + deps.pushState?.(ctx) deps.push() finish({ projectId, kind: "session", sessionId }, deps) } diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index c4a0044a3db..59a47ed0115 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -67,6 +67,7 @@ export function createProjectWiring(opts: { expand: opts.expand, ready: opts.ready, push: opts.push, + pushState: opts.pushState, selected: opts.selected, routeSession: opts.routeSession, error: (message) => opts.host.showError(message), diff --git a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts index 7e7e746022f..171df5b67de 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts @@ -19,6 +19,7 @@ function fakeState(persisted?: { current?: unknown }) { return { getWorktree: (id: string) => (id === "wt1" ? { path: "/repo/prj-extra/wt1" } : undefined), getSession: (id: string) => (id === "sess1" ? {} : undefined), + moveSession: () => {}, getActiveTarget: () => store.current, setActiveTarget: (target: unknown) => { store.current = target @@ -172,6 +173,27 @@ describe("activateSelection — cross-project selection", () => { expect(calls.error).toEqual([]) }) + it("pushes moved-session state before acknowledging local activation", async () => { + const { contexts, deps, calls, extra } = setup() + const ctx = contexts.expand(extra)! + ctx.stateManager() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + contexts.activate(extra) + + const order: string[] = [] + deps.push = () => order.push("projects") + deps.pushState = () => order.push("state") + deps.selected = () => order.push("selected") + + await handleProjectMessage( + { type: "agentManager.openSessionLocally", projectId: extra, sessionId: "sess1" } as never, + deps, + ) + + expect(order).toEqual(["state", "projects", "projects", "selected"]) + expect(calls.error).toEqual([]) + }) + it("restores the persisted target when the selection asks for it", async () => { const persisted = { current: undefined as unknown } const { contexts, deps, calls, extra } = setup({ state: () => fakeState(persisted) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 113e28acd81..e0a6cd85641 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -300,13 +300,18 @@ const AgentManagerContent: Component = () => { const [history, setHistory] = createSignal(false) /** Project whose sessions the history view is scoped to (multi-project). */ const [historyProject, setHistoryProject] = createSignal() + const [historySwitch, setHistorySwitch] = createSignal() const closeHistory = () => { setHistory(false) setHistoryProject(undefined) + setHistorySwitch(undefined) } /** Open the sessions view; a project id scopes it and activates that project. */ const openHistory = (pid?: string) => { const scoped = pid !== undefined && multiProject() + setHistorySwitch(scoped && currentProjectId() !== pid ? pid : undefined) + setHistoryProject(scoped ? pid : undefined) + setHistory(true) if (scoped) { // Activating the target project first lets the shared session store and // the pick routing operate in that project only. @@ -315,8 +320,6 @@ const AgentManagerContent: Component = () => { target: { projectId: pid, kind: "local" }, } as never) } - setHistoryProject(scoped ? pid : undefined) - setHistory(true) } const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === SidePanel.Diff @@ -765,7 +768,7 @@ const AgentManagerContent: Component = () => { const pid = historyProject() if (!pid || !multiProject()) return undefined const sessions = projectSessionsLive()[pid] - if (!sessions) return undefined + if (!sessions) return new Set() return new Set(sessions.filter(isKnownRootSession).map((s) => s.id)) }) @@ -1161,7 +1164,7 @@ const AgentManagerContent: Component = () => { first: () => undefined, close: () => setReviewActive(false), hide: () => setSidePanel(null), - history: () => closeHistory(), + history: () => (historySwitch() === state.projectId ? setHistorySwitch(undefined) : closeHistory()), reset: subagents.reset, }) } From 13023c9dae635a1e08b59121f6e0625839f6bb65 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 15:04:27 +0200 Subject: [PATCH 14/50] refactor(agent-manager): simplify cancellation cleanup --- .../kilo-vscode/src/agent-manager/semaphore.ts | 15 +++++++-------- packages/kilo-vscode/src/diff/SourceController.ts | 12 ++++-------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/semaphore.ts b/packages/kilo-vscode/src/agent-manager/semaphore.ts index a5e52d3eaf7..9006c7bf673 100644 --- a/packages/kilo-vscode/src/agent-manager/semaphore.ts +++ b/packages/kilo-vscode/src/agent-manager/semaphore.ts @@ -27,18 +27,17 @@ export class Semaphore { return Promise.resolve() } return new Promise((resolve, reject) => { - const item = { + const item: { resolve: () => void; abort: () => void } = { resolve: () => { - if (item.abort) signal?.removeEventListener("abort", item.abort) + signal?.removeEventListener("abort", item.abort) this.running++ resolve() }, - abort: undefined as (() => void) | undefined, - } - item.abort = () => { - const index = this.pending.indexOf(item) - if (index !== -1) this.pending.splice(index, 1) - reject(signal?.reason) + abort: () => { + const index = this.pending.indexOf(item) + if (index !== -1) this.pending.splice(index, 1) + reject(signal?.reason) + }, } signal?.addEventListener("abort", item.abort, { once: true }) this.pending.push(item) diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index 09eb6fb775c..8b7aa57d21b 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -278,14 +278,10 @@ export class SourceController { } const work = this.runFetch(source, epoch, initial) this.fetches.set(source, work) - work.then( - () => { - if (this.fetches.get(source) === work) this.fetches.delete(source) - }, - () => { - if (this.fetches.get(source) === work) this.fetches.delete(source) - }, - ) + const clear = () => { + if (this.fetches.get(source) === work) this.fetches.delete(source) + } + void work.finally(clear).catch(() => undefined) return work } } From 71211f14082e140756ef89d76916e4707310c9c2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 15:05:52 +0200 Subject: [PATCH 15/50] refactor(agent-manager): share diff cleanup callback --- .../agent-manager/worktree-diffs.ts | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) 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 4ca050eff4a..350ba0ea5cd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -54,30 +54,16 @@ export function createWorktreeDiffs( const drop = (id: string) => { const data = id.includes("\0") ? id : key(id) - setDiffDatas((prev) => { + const remove = >(prev: T): T => { if (!(data in prev)) return prev const next = { ...prev } delete next[data] return next - }) - setDiffLoadings((prev) => { - if (!(data in prev)) return prev - const next = { ...prev } - delete next[data] - return next - }) - setDiffNotices((prev) => { - if (!(data in prev)) return prev - const next = { ...prev } - delete next[data] - return next - }) - setDiffFileLoading((prev) => { - if (!(data in prev)) return prev - const next = { ...prev } - delete next[data] - return next - }) + } + setDiffDatas(remove) + setDiffLoadings(remove) + setDiffNotices(remove) + setDiffFileLoading(remove) } const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { From 057b48cc05d01d396575ed770d8794eb3b04dc8e Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 09:23:33 -0400 Subject: [PATCH 16/50] fix(jetbrains): render worktree session titles in regular weight Session rows in the worktree editor are all peers, so bolding every title added noise without conveying hierarchy. ActiveListConfig now carries a bold flag that defaults to on and the worktree session list opts out, leaving the worktree, history and settings lists unchanged. --- .../worktree/WorktreeSessionEditorPanel.kt | 1 + .../client/ui/list/ActiveListModel.kt | 9 ++++---- .../client/ui/list/ActiveListRenderer.kt | 7 +++--- .../WorktreeSessionEditorPanelTest.kt | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index d87cb64c021..5508a03eb2d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -95,6 +95,7 @@ class WorktreeSessionEditorPanel( description = false, selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, hoverActions = true, + bold = false, ), surface = ActiveListSurface.ToolWindow, showSearch = false, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index bce69427479..adc42a6b2ec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -39,6 +39,7 @@ internal data class ActiveListConfig( val tooltip: Boolean = true, val selection: Int = ListSelectionModel.SINGLE_SELECTION, val hoverActions: Boolean = false, + val bold: Boolean = true, ) { companion object { val Equal = ActiveListConfig(ActiveListRowHeight.EQUAL) @@ -83,10 +84,10 @@ internal interface ActiveListHitCell { /** * A row in an [ActiveList]. Carries the display contract shared by settings pages, the worktree - * list, and the session history stack: a leading icon, a bold title with an inline [note], a - * secondary [description] line, inline [badges], optional right-aligned [trailing] text, and - * action [cells]. Action cells are shown only for the active focused selection unless - * [ActiveListCell.alwaysVisible] is true. + * list, and the session history stack: a leading icon, a title whose weight follows + * [ActiveListConfig.bold] with an inline [note], a secondary [description] line, inline [badges], + * optional right-aligned [trailing] text, and action [cells]. Action cells are shown only for the + * active focused selection unless [ActiveListCell.alwaysVisible] is true. */ internal interface ActiveListItem { val key: String diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 9c603785a2b..dc0d8cd535e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -220,9 +220,10 @@ internal class ActiveListRenderer( layers.isVisible = true title.clear() - // Bold carries the row: the description under it and the icon beside it both render in the - // muted secondary color, so weight is what separates the two lines rather than color alone. - title.append(value.title, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, titleFg)) + // Bold carries most rows by default: the description under it and the icon beside it both + // render in the muted secondary color, so weight separates the two lines when enabled. + val style = if (cfg.bold) SimpleTextAttributes.STYLE_BOLD else SimpleTextAttributes.STYLE_PLAIN + title.append(value.title, SimpleTextAttributes(style, titleFg)) value.note?.takeIf { it.isNotBlank() }?.let { title.append(" $it", SimpleTextAttributes.GRAYED_ATTRIBUTES) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index 9dbc014c804..53381d7858b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -35,6 +35,8 @@ import com.intellij.openapi.ui.TestDialogManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.OnePixelSplitter import com.intellij.ui.SearchTextField +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil @@ -236,6 +238,26 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertEquals("new", edt { (list.selectedValue as ActiveListItem).key }) } + fun `test session row title uses regular font`() { + rpc.listed += session("ses_1", nowSeconds()) + edt { controller.reload() } + flush() + + val style = edt { + @Suppress("UNCHECKED_CAST") + val list = UIUtil.findComponentOfType(panel, JBList::class.java)!! as JBList + val row = list.model.getElementAt(0) as ActiveListItem + val comp = list.cellRenderer.getListCellRendererComponent(list, row, 0, true, true) + val title = components(comp).filterIsInstance().single() + val iter = title.iterator() + assertTrue(iter.hasNext()) + iter.next() + iter.textAttributes.style + } + + assertEquals(SimpleTextAttributes.STYLE_PLAIN, style) + } + fun `test running session row shows activity badge without leading icon`() { manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING) val session = session("ses_1", nowSeconds()) From a2cd74bc5f380aeaff5548c2e46cabe97764c239 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 09:23:50 -0400 Subject: [PATCH 17/50] fix(jetbrains): keep the account overlay hidden after a prompted worktree start Creating a worktree with a prompt opens the new session and dispatches the prompt in the same EDT event, so the history load resolves afterwards with no messages and fired ViewChanged.ShowEmpty. That re-showed the account overlay and wedged the view state, because showSession() early-returns once model.showSession is set, so hideAccountOverlay() could never run again and the account chip stayed on top of a running session. setControllerViewState now ignores ShowEmpty once the transcript is shown. --- .../session/controller/SessionController.kt | 3 +++ .../client/session/SessionUiLayoutTest.kt | 16 +++++++++++++++ .../session/controller/HistoryLoadingTest.kt | 20 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index d64c4c77673..6a7737d0229 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -2308,6 +2308,9 @@ class SessionController( private fun setControllerViewState(event: SessionControllerEvent.ViewChanged) { assertEdt() if (disposed) return + // A late empty history load must not re-show the empty screen after a prompt opened the + // transcript. + if (event is SessionControllerEvent.ViewChanged.ShowEmpty && model.showSession) return if (event is SessionControllerEvent.ViewChanged.ShowSession) openLocal() if (viewState == event) return fire(event) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 4429858362e..9b2afc4400a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -932,6 +932,22 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertFalse(overlay.isVisible) } + fun `test account overlay stays hidden when prompt races empty history load`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = ProfileDto(email = "user@example.com")) + val gate = CompletableDeferred() + rpc.historyGate = gate + ui = newUi(id = "ses_test") + + ApplicationManager.getApplication().invokeAndWait { + controller().prompt("hello") + } + gate.complete(Unit) + settle() + + val overlay = find(ui) + assertFalse(overlay.isVisible) + } + fun `test non-empty explicit session does not show overlay`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) ui = newUi(id = "ses_test") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt index e61bee996e2..0a3206b1012 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt @@ -11,6 +11,7 @@ import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderDto +import kotlinx.coroutines.CompletableDeferred class HistoryLoadingTest : SessionControllerTestBase() { @@ -91,6 +92,25 @@ class HistoryLoadingTest : SessionControllerTestBase() { ) } + fun `test prompt during history load keeps the session view`() { + val gate = CompletableDeferred() + rpc.historyGate = gate + + val c = controller("ses_test") + val events = collect(c) + edt { c.prompt("hello") } + gate.complete(Unit) + flush() + + assertControllerEvents(""" + AccountOverlayChanged hide + AppChanged + WorkspaceChanged + ViewChanged progress + ViewChanged session + """, events) + } + fun `test loaded history derives agent from latest message`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) projectRpc.state.value = workspaceReady(agents = agents(), default = "plan") From 235d98d953e437a1af6bafe02d400fda32b8ba08 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 09:25:01 -0400 Subject: [PATCH 18/50] fix(jetbrains): add new worktrees to the top of the list New worktrees were appended, so the row the user just created landed at the bottom of a long list. The optimistic row is now inserted at index 0 for create, import-PR and move, reload keeps pending rows on top newest-first, and the backend records the created path at the head of the persisted order so the row stays there across reloads and restarts. Drag reorder still overrides it. --- .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 11 +++++----- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 9 +++++--- .../worktree/WorktreeController.kt | 12 +++++------ .../agentManager/AgentManagerPanelTest.kt | 9 ++++---- .../agentManager/WorktreeControllerTest.kt | 21 ++++++++++++++++++- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 44bd19f386f..80c0e642f93 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -338,7 +338,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val items = if (list.ok) managedWorktrees(parseWorktreeList(list.stdout)) else emptyList() val store = worktreeNameStore(items) ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE) val paths = worktreePaths(items).ifEmpty { listOf(path) } - appendWorktreeOrder(store, path, paths) + prependWorktreeOrder(store, path, paths) return CreateWorktreeResultDto(worktree = WorktreeDto(path, dir.fileName.toString(), branch, path)) } @@ -748,13 +748,12 @@ private fun syncWorktreeState(file: Path, paths: List): WorktreeState { return next } -private fun appendWorktreeOrder(file: Path, path: String, paths: List) { +private fun prependWorktreeOrder(file: Path, path: String, paths: List) { val state = readWorktreeState(file) val set = paths.toSet() - val order = state.worktreeOrder.filter { it in set && !samePath(it, path) } + - paths.filter { it !in state.worktreeOrder && !samePath(it, path) } + - path - writeWorktreeState(file, state.copy(worktreeOrder = order.distinct())) + val rest = state.worktreeOrder.filter { it in set && !samePath(it, path) } + + paths.filter { it !in state.worktreeOrder && !samePath(it, path) } + writeWorktreeState(file, state.copy(worktreeOrder = (listOf(path) + rest).distinct())) } private fun removeWorktreeState(file: Path, path: String) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index e19a9a386b1..58a5e6ed526 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -233,15 +233,18 @@ class KiloWorktreeRpcApiImplTest { } @Test - fun `create records order so reload keeps creation order`() = runBlocking { + fun `create records newest worktree first so reload keeps it on top`() = runBlocking { initRepo() val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree) val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree) val listed = api.list(repo.toString()).worktrees.filter { !it.main } - assertEquals(listOf(first.path, second.path), listed.map { it.path }) - assertEquals(listOf(first.path, second.path), readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).worktreeOrder) + assertEquals(listOf(second.path, first.path), listed.map { it.path }) + assertEquals( + listOf(second.path, first.path), + readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).worktreeOrder, + ) } @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt index 2a7e8ff20a1..a0ba0f8fb4c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt @@ -89,7 +89,7 @@ class WorktreeController( edt { val main = result.worktrees.firstOrNull { it.main } val extra = result.worktrees.filter { !it.main } - val rows = extra + pending.values + val rows = pending.values.toList().asReversed() + extra current = main model.replaceAll(rows) cache().putAll(rows) @@ -121,7 +121,7 @@ class WorktreeController( edt { pending[temp.id] = temp tasks[temp.id] = KiloBundle.message("worktree.progress.creating") - model.add(temp) + model.add(0, temp) onSelect?.invoke(temp.id) } cs.launch { @@ -136,7 +136,7 @@ class WorktreeController( edt { pending[temp.id] = temp tasks[temp.id] = KiloBundle.message("worktree.progress.creating") - model.add(temp) + model.add(0, temp) onSelect?.invoke(temp.id) } cs.launch { @@ -157,7 +157,7 @@ class WorktreeController( tasks.remove(temp.id) val idx = model.getElementIndex(temp) if (created != null) { - if (idx >= 0) model.setElementAt(created, idx) else model.add(created) + if (idx >= 0) model.setElementAt(created, idx) else model.add(0, created) cache().put(created) prompt?.let { service().put(created.path, it) } onSelect?.invoke(created.id) @@ -225,7 +225,7 @@ class WorktreeController( val temp = WorktreeDto("pending:$branch:${System.nanoTime()}", branch, branch, "pending:$branch") pending[temp.id] = temp tasks[temp.id] = label(MoveStage.CAPTURING) - model.add(temp) + model.add(0, temp) onSelect?.invoke(temp.id) cs.launch { var stage = MoveStage.CAPTURING @@ -243,7 +243,7 @@ class WorktreeController( tasks.remove(temp.id) val worktree = event.worktree ?: return@edt val idx = model.getElementIndex(temp) - if (idx >= 0) model.setElementAt(worktree, idx) else model.add(worktree) + if (idx >= 0) model.setElementAt(worktree, idx) else model.add(0, worktree) cache().put(worktree) // Queue the forked session for the editor the selection is about to // open; the tab's identity stays the worktree path alone. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index f436a11ab0d..0b6f195bb99 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -98,13 +98,13 @@ class AgentManagerPanelTest : BasePlatformTestCase() { edt { controller.create("feature/y", null) } val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! } - val pendingId = edt { controller.model.getElementAt(controller.model.size - 1).id } + val pendingId = edt { controller.model.getElementAt(0).id } assertEquals(pendingId, edt { (list.selectedValue as ActiveListItem).key }) gate.complete(Unit) flush() - val created = edt { controller.model.getElementAt(controller.model.size - 1) } + val created = edt { controller.model.getElementAt(0) } assertEquals("feature/y", created.branch) assertEquals(created.id, edt { (list.selectedValue as ActiveListItem).key }) } @@ -776,10 +776,9 @@ class AgentManagerPanelTest : BasePlatformTestCase() { layout(view) edt { - val size = view.list.model.size - // Row 0 is the current (main) row; the last row is the pending create. + // Row 0 is the current (main) row; row 1 is the pending create. assertNull(view.pickable(rowCenter(view, 0))) - assertNull(view.pickable(rowCenter(view, size - 1))) + assertNull(view.pickable(rowCenter(view, 1))) } gate.complete(Unit) flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index 9e1aca1bc63..4da22e5c487 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -102,6 +102,25 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertEquals("feature/y", selected.last()) } + fun `test create prepends placeholder and created worktree`() { + rpc.listed += WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") + val gate = CompletableDeferred() + rpc.beforeCreate = { gate.await() } + val controller = controller() + controller.reload() + flush() + + ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) } + + assertEquals("feature/y", controller.model.getElementAt(0).branch) + assertTrue(controller.isPending(controller.model.getElementAt(0).id)) + gate.complete(Unit) + flush() + + assertEquals("feature/y", controller.model.getElementAt(0).branch) + assertFalse(controller.isPending(controller.model.getElementAt(0).id)) + } + fun `test create failure removes placeholder and reports the error`() { rpc.createResult = { CreateWorktreeResultDto(error = "boom") } val controller = controller() @@ -129,7 +148,7 @@ class WorktreeControllerTest : BasePlatformTestCase() { controller.reload() flush() - assertEquals(listOf("feature/x", "feature/y"), (0 until controller.model.size).map { controller.model.getElementAt(it).branch }) + assertEquals(listOf("feature/y", "feature/x"), (0 until controller.model.size).map { controller.model.getElementAt(it).branch }) assertTrue(controller.isPending(id)) gate.complete(Unit) flush() From 53770f6c4b01e14533e802ed45bdb49078fd9e5f Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 09:25:19 -0400 Subject: [PATCH 19/50] fix(jetbrains): keep the running icon after resuming a stopped session A Stop makes the CLI publish session.error with MessageAbortedError, which the activity manager keeps in a sticky errors set that only session.turn.open cleared. kind() checked that set before busy, so a resumed session kept the resting glyph whenever the turn event was missed or arrived after the busy status: the status stream and the chat events are separate collectors, so their order is not guaranteed. Busy now outranks a pending error and also clears it, and the per-directory aggregate prefers running over error so one stopped session cannot mask a sibling that is still working. An error that is never resumed still persists through idle as before. --- .../backend/app/KiloBackendActivityManager.kt | 14 ++++++-- .../app/KiloBackendActivityManagerTest.kt | 33 +++++++++++++++++-- .../agentManager/worktree/WorktreeActivity.kt | 9 +++-- .../worktree/WorktreeActivityTest.kt | 6 ++-- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index d8bfd6d1d11..7cd72053882 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -89,7 +89,14 @@ class KiloBackendActivityManager( is ChatEventDto.Error -> event.sessionID?.let { errors.add(it) } is ChatEventDto.TurnOpen -> errors.remove(event.sessionID) is ChatEventDto.SessionIdle -> clear(event.sessionID) - is ChatEventDto.SessionStatusChanged -> if (event.status.type == "idle") clear(event.sessionID) + is ChatEventDto.SessionStatusChanged -> when (event.status.type) { + "idle" -> clear(event.sessionID) + // Work restarted, so whatever ended the previous turn (a Stop publishes + // MessageAbortedError) is stale. Not every resume path publishes a turn event, so + // busy has to clear the error itself. + "busy" -> errors.remove(event.sessionID) + else -> Unit + } else -> Unit } } @@ -115,8 +122,11 @@ class KiloBackendActivityManager( if (pending.values.any { it }) return SessionActivityKindDto.PLAN return SessionActivityKindDto.QUESTION } - if (id in errors) return SessionActivityKindDto.ERROR + // Live work outranks a past error: the status stream and the chat events are separate + // collectors, so a resumed session can go busy before the event that clears its error + // arrives, and the row must keep spinning instead of resting on the stale error. if (busy) return SessionActivityKindDto.RUNNING + if (id in errors) return SessionActivityKindDto.ERROR return null } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt index 1a6d1f9f4ef..a06aa09b640 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt @@ -102,10 +102,8 @@ class KiloBackendActivityManagerTest { statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) start() + // Turn ends on an error: the session goes idle but the error must stay visible. events.emit(ChatEventDto.Error("ses_1")) - await("ses_1", SessionActivityKindDto.ERROR) - - // Turn ends: session goes idle but the error must stay visible. statuses.value = mapOf("ses_1" to SessionStatusDto("idle")) events.emit(ChatEventDto.SessionIdle("ses_1")) await("ses_1", SessionActivityKindDto.ERROR) @@ -116,6 +114,35 @@ class KiloBackendActivityManagerTest { assertFalse("ses_1" in manager.activity.value) } + @Test + fun `busy outranks a pending error so a resumed session runs`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + + // A Stop leaves the session errored and idle. + events.emit(ChatEventDto.Error("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + // Resumed: busy arrives before anything clears the error. + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + + await("ses_1", SessionActivityKindDto.RUNNING) + } + + @Test + fun `busy status event clears a pending error`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + + events.emit(ChatEventDto.Error("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + events.emit(ChatEventDto.SessionStatusChanged("ses_1", SessionStatusDto("busy"))) + + withTimeout(5_000) { manager.activity.first { "ses_1" !in it } } + assertFalse("ses_1" in manager.activity.value) + } + @Test fun `global error without session is ignored`() = runBlocking { directories["ses_1"] = "/repo/wt" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt index 8422c556bf2..555d2d6031b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt @@ -22,11 +22,16 @@ private fun kind(kind: SessionActivityKindDto): SessionActivityKind = when (kind SessionActivityKindDto.ERROR -> SessionActivityKind.ERROR } +/** + * Precedence for a worktree holding several sessions: anything waiting on the user first, then live + * work, then a session left in an error. Running beats error so one stopped session cannot hide the + * spinner of a sibling that is still working. + */ private fun rank(kind: SessionActivityKind): Int = when (kind) { SessionActivityKind.PERMISSION -> 0 SessionActivityKind.QUESTION -> 1 SessionActivityKind.PLAN -> 2 - SessionActivityKind.ERROR -> 3 - SessionActivityKind.RUNNING -> 4 + SessionActivityKind.RUNNING -> 3 + SessionActivityKind.ERROR -> 4 SessionActivityKind.LOGIN_REQUIRED -> 5 } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt index 02b4292854e..5270e31c182 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt @@ -32,12 +32,12 @@ class WorktreeActivityTest { } @Test - fun `error outranks running but yields to interactive prompts`() { - val errorOverRunning = aggregateWorktreeActivity(mapOf( + fun `running outranks a sibling error but yields to interactive prompts`() { + val runningOverError = aggregateWorktreeActivity(mapOf( "ses_run" to SessionActivityDto("/repo/wt", SessionActivityKindDto.RUNNING), "ses_error" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), )) - assertEquals(SessionActivityKind.ERROR, errorOverRunning["/repo/wt"]) + assertEquals(SessionActivityKind.RUNNING, runningOverError["/repo/wt"]) val questionOverError = aggregateWorktreeActivity(mapOf( "ses_error" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), From 047c9893a209aee6d0c0df98fbdf07325c69af6b Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 09:25:35 -0400 Subject: [PATCH 20/50] fix(jetbrains): keep session popups inside the visible view Header popups budgeted their height against the IDE layered pane and anchored on the session panel's full bounds, so a session in a short tool window or an editor tab got balloons spanning the whole window. Placement now uses the session panel's visible rect for the height budget and the vertical clamp, mirroring SessionHoverCopyOverlay. The pane still decides only which side has horizontal room: clamping the width to the view rect would collapse the popup, since it deliberately sits beside the session. --- .changeset/jetbrains-worktree-list-fixes.md | 5 ++ .../session/ui/popup/HeaderPopupController.kt | 16 ++++--- .../session/ui/popup/HeaderPopupGeometry.kt | 21 ++++++--- .../ui/popup/HeaderPopupGeometryTest.kt | 47 ++++++++++++++----- 4 files changed, 63 insertions(+), 26 deletions(-) create mode 100644 .changeset/jetbrains-worktree-list-fixes.md diff --git a/.changeset/jetbrains-worktree-list-fixes.md b/.changeset/jetbrains-worktree-list-fixes.md new file mode 100644 index 00000000000..7aa15c9a92c --- /dev/null +++ b/.changeset/jetbrains-worktree-list-fixes.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, and keep session card popups inside the visible session view. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt index 7995bb51295..94bb2ff7782 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt @@ -161,8 +161,9 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { /** * Resolves the pointer target beside the session chat, sizing the body to the space available on - * the chosen side. Anchoring on the chat rather than the hovered row is what keeps the popup off - * the transcript instead of covering the row the user is reading. + * the chosen side and to the visible height of the chat. Anchoring on the chat rather than the + * hovered row is what keeps the popup off the transcript instead of covering the row the user is + * reading. * * Returns null when the chat is not on screen yet, in which case there is nothing to sit beside. */ @@ -177,10 +178,13 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { // The shadow is reserved on every side, so it counts twice on each axis. val shadow = UiStyle.Balloon.shadow() * 2 val chromeHeight = insets.top + insets.bottom + shadow - val bounds = Rectangle(pane.size) + // The visible chat rect, not the whole panel: a session clipped by a short tool window or a + // scrolled editor tab must keep its popups inside the part the user can actually see. + val area = SwingUtilities.convertRectangle(chat, chat.visibleRect, pane) + if (area.isEmpty) return null val spot = HeaderPopupGeometry.beside( - pane = bounds, - chat = SwingUtilities.convertRectangle(chat.parent, chat.bounds, pane), + pane = Rectangle(pane.size), + chat = area, fit = HeaderPopupFit( chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow, chromeHeight = chromeHeight, @@ -192,7 +196,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { built.fitWithin(spot.maxWidth, spot.maxHeight) val row = SwingUtilities.convertPoint(anchor, Point(0, anchor.height / 2), pane) val height = built.component.preferredSize.height + chromeHeight - return Spot(pane, Point(spot.x, HeaderPopupGeometry.centerY(bounds, row.y, height, gap)), spot.position) + return Spot(pane, Point(spot.x, HeaderPopupGeometry.centerY(area, row.y, height, gap)), spot.position) } private class Spot(val pane: JComponent, val point: Point, val position: Balloon.Position) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt index 6a790e9b3d6..212c6c37739 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt @@ -40,7 +40,12 @@ internal data class HeaderPopupFit( */ internal object HeaderPopupGeometry { - /** Picks the side of [chat] with more room inside [pane] and the body box that fits there. */ + /** + * Picks the side of [chat] with more room inside [pane] and the body box that fits there. + * + * [pane] only decides which side has room; the body is bounded by [chat] so the popup stays + * within the visible session view. + */ fun beside(pane: Rectangle, chat: Rectangle, fit: HeaderPopupFit): HeaderPopupPlacement { val left = (chat.x - pane.x).coerceAtLeast(0) val right = (pane.x + pane.width - (chat.x + chat.width)).coerceAtLeast(0) @@ -51,20 +56,22 @@ internal object HeaderPopupGeometry { position = if (useRight) Balloon.Position.atRight else Balloon.Position.atLeft, x = if (useRight) chat.x + chat.width else chat.x, maxWidth = room.coerceIn(0, fit.maxWidth), - maxHeight = (pane.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), + // Height is budgeted against the chat, not the pane: the popup belongs to the session + // view, so it must not run past it into editor tabs or neighbouring tool windows. + maxHeight = (chat.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), ) } /** * Vertical pointer target for a body of [height], preferring [y] but keeping the balloon inside - * [pane]. The balloon centres its body on the target, so an unclamped target near an edge would + * [chat]. The balloon centres its body on the target, so an unclamped target near an edge would * overflow and trigger the same re-pointing that [beside] avoids horizontally. */ - fun centerY(pane: Rectangle, y: Int, height: Int, gap: Int): Int { + fun centerY(chat: Rectangle, y: Int, height: Int, gap: Int): Int { val half = height / 2 - val top = pane.y + gap + half - val bottom = pane.y + pane.height - gap - half - if (bottom < top) return pane.y + pane.height / 2 + val top = chat.y + gap + half + val bottom = chat.y + chat.height - gap - half + if (bottom < top) return chat.y + chat.height / 2 return y.coerceIn(top, bottom) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt index 2c4dec004fc..a262e543190 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt @@ -93,34 +93,55 @@ class HeaderPopupGeometryTest { } @Test - fun `height is capped to the pane minus gaps`() { + fun `height is capped to the chat minus gaps`() { val short = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 200), chat = Rectangle(0, 0, 300, 200), fit = fit(), ) - // 200 pane, minus both gaps and the chrome the balloon reserves vertically. + // 200 chat, minus both gaps and the chrome the balloon reserves vertically. assertEquals(200 - GAP * 2 - CHROME_HEIGHT, short.maxHeight) } @Test - fun `pointer target keeps a tall body inside the pane`() { - val pane = Rectangle(0, 0, 2000, 1000) + fun `height follows a short chat inside a tall pane`() { + // Session in an editor tab or a short tool window: the window has room the session does not. + val spot = HeaderPopupGeometry.beside( + pane = Rectangle(0, 0, 2000, 1000), + chat = Rectangle(0, 100, 300, 300), + fit = fit(), + ) - // Row near the top: target pushed down so the centred body clears the top edge. - assertEquals(310, HeaderPopupGeometry.centerY(pane, y = 20, height = 600, gap = GAP)) - // Row near the bottom: target pulled up. - assertEquals(690, HeaderPopupGeometry.centerY(pane, y = 980, height = 600, gap = GAP)) - // Row with room on both sides is left alone. - assertEquals(500, HeaderPopupGeometry.centerY(pane, y = 500, height = 600, gap = GAP)) + assertEquals(300 - GAP * 2 - CHROME_HEIGHT, spot.maxHeight) } @Test - fun `body taller than the pane is centred instead of clamped to an empty range`() { - val pane = Rectangle(0, 0, 2000, 400) + fun `pointer target keeps the body inside an offset chat`() { + val chat = Rectangle(0, 400, 300, 400) - assertEquals(200, HeaderPopupGeometry.centerY(pane, y = 10, height = 900, gap = GAP)) + // Rows above and below the chat are pulled back into it. + assertEquals(560, HeaderPopupGeometry.centerY(chat, y = 0, height = 300, gap = GAP)) + assertEquals(640, HeaderPopupGeometry.centerY(chat, y = 1000, height = 300, gap = GAP)) + } + + @Test + fun `pointer target keeps a tall body inside the chat`() { + val chat = Rectangle(0, 0, 300, 1000) + + // Row near the top: target pushed down so the centred body clears the top edge. + assertEquals(310, HeaderPopupGeometry.centerY(chat, y = 20, height = 600, gap = GAP)) + // Row near the bottom: target pulled up. + assertEquals(690, HeaderPopupGeometry.centerY(chat, y = 980, height = 600, gap = GAP)) + // Row with room on both sides is left alone. + assertEquals(500, HeaderPopupGeometry.centerY(chat, y = 500, height = 600, gap = GAP)) + } + + @Test + fun `body taller than the chat is centred instead of clamped to an empty range`() { + val chat = Rectangle(0, 0, 300, 400) + + assertEquals(200, HeaderPopupGeometry.centerY(chat, y = 10, height = 900, gap = GAP)) } private fun beside(chat: Rectangle) = HeaderPopupGeometry.beside( From e748515a2c45b7833cc4ffd626b0df9d6f925a76 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 15:35:44 +0200 Subject: [PATCH 21/50] fix(vscode): guard subagent promotion edge cases --- .../fix-background-promotion-edge-cases.md | 6 +++ packages/kilo-vscode/src/KiloProvider.ts | 7 ++-- packages/kilo-vscode/src/features.ts | 16 +++++++- .../src/kilo-provider/config-snapshot.ts | 9 +++-- packages/kilo-vscode/src/provider-actions.ts | 9 +++-- .../tests/unit/background-agents.test.ts | 38 +++++++++++++----- .../tests/unit/indexing-utils.test.ts | 5 +++ .../kilo-provider-indexing-refresh.test.ts | 5 +++ .../tests/unit/sandboxing-settings.test.ts | 2 +- .../src/components/chat/TaskToolExpanded.tsx | 12 +++++- .../src/components/chat/task-tool-state.ts | 7 +++- .../webview-ui/src/context/config.tsx | 6 ++- .../webview-ui/src/context/session-utils.ts | 6 +++ .../webview-ui/src/stories/StoryProviders.tsx | 1 + .../webview-ui/src/types/messages/config.ts | 1 + .../server/httpapi/handlers/kilocode.ts | 3 ++ .../test/server/session-actions.test.ts | 40 ++++++++++++++++++- 17 files changed, 142 insertions(+), 31 deletions(-) create mode 100644 .changeset/fix-background-promotion-edge-cases.md diff --git a/.changeset/fix-background-promotion-edge-cases.md b/.changeset/fix-background-promotion-edge-cases.md new file mode 100644 index 00000000000..25df1e2f7c8 --- /dev/null +++ b/.changeset/fix-background-promotion-edge-cases.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Prevent stale subagent cards from showing background promotion and respect the background-subagent capability when promoting running tasks. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 6112de0eb40..25210bb4cf2 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -163,7 +163,7 @@ import type { StoredProviderKey } from "./provider-actions" import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" -import { configFeatures } from "./features" +import { configFeatures, serverFeatures } from "./features" import { fetchSnapshot } from "./kilo-provider/config-snapshot" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" @@ -3394,6 +3394,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const global = snapshot.targets.global.raw as Config const projectConfig = bindings.project ? (snapshot.targets.project.raw as Config) : undefined this.cachedGlobalConfig = global + const features = configFeatures(snapshot.effective, await serverFeatures(this.client, dir)) this.cachedConfigMessage = { type: "configLoaded", config: snapshot.effective, @@ -3401,7 +3402,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper projectConfig, bindings, settings: this.configSettings(), - features: configFeatures(snapshot.effective), + features, } this.postMessage({ type: "configUpdated", @@ -3410,7 +3411,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper projectConfig, bindings, settings: this.configSettings(), - features: configFeatures(snapshot.effective), + features, }) await Promise.all([ refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), diff --git a/packages/kilo-vscode/src/features.ts b/packages/kilo-vscode/src/features.ts index 0b0b7275282..c2d3a1f38f6 100644 --- a/packages/kilo-vscode/src/features.ts +++ b/packages/kilo-vscode/src/features.ts @@ -1,4 +1,5 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" +import type { KiloClient } from "@kilocode/sdk/v2" type PluginSpec = string | [string, Record] @@ -9,11 +10,24 @@ type ConfigLike = { export type Features = { indexing: boolean sandboxControls: boolean + backgroundSubagents: boolean } -export function configFeatures(config?: ConfigLike | null): Features { +export function configFeatures(config?: ConfigLike | null, backgroundSubagents = false): Features { return { indexing: hasIndexingPlugin(config?.plugin ?? []), sandboxControls: process.platform !== "win32", + backgroundSubagents, + } +} + +export async function serverFeatures(client: Pick, dir: string) { + if (!client.experimental?.capabilities?.get) return false + try { + const { data } = await client.experimental.capabilities.get({ directory: dir }, { throwOnError: true }) + return data?.backgroundSubagents === true + } catch (error) { + console.warn("[Kilo New] Failed to fetch server capabilities:", error) + return false } } diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts index 90ca1ac6fca..088c8b3c20d 100644 --- a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -1,15 +1,16 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -import { configFeatures } from "../features" +import { configFeatures, serverFeatures } from "../features" import { retry } from "../services/cli-backend/retry" import type { ConfigTarget } from "./config-bindings" -type Client = Pick +type Client = Pick type Settings = { maxCost: number; languageCommitMessage: string; multiProject: boolean } export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) { - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + const [{ data: config }, { data: global }, { data: overlay }, capabilities] = await Promise.all([ retry(() => client.config.get({ directory: dir }, { throwOnError: true })), client.global.config.get({ throwOnError: true }), client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + retry(() => serverFeatures(client, dir)), ]) return { config, @@ -17,6 +18,6 @@ export async function fetchSnapshot(client: Client, dir: string, settings: () => targets: overlay?.targets as { global: ConfigTarget; project: ConfigTarget } | undefined, collections: overlay?.collections, settings: settings(), - features: configFeatures(config), + features: configFeatures(config, capabilities), } } diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 742380b5604..9d67b2797f9 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -10,7 +10,7 @@ import { withCustomProviderDeletions, } from "./shared/custom-provider" import { isCustomProviderPackage, KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "./shared/provider-model" -import { configFeatures } from "./features" +import { configFeatures, serverFeatures } from "./features" /** * Compute the default model selection from CLI config, VS Code settings, or hardcoded fallback. @@ -240,7 +240,7 @@ async function refreshConfig(ctx: ActionContext, setCachedConfig: SetCachedConfi ctx.client.global.config.get({ throwOnError: true }), ]) if (!config) return - const features = configFeatures(config) + const features = configFeatures(config, await serverFeatures(ctx.client, ctx.workspaceDir)) setCachedConfig({ type: "configLoaded", config, globalConfig: global, features }) ctx.postMessage({ type: "configUpdated", config, globalConfig: global, features }) } @@ -464,9 +464,10 @@ export async function saveCustomProvider( const merged = await ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }) const config = merged.data ?? updated - const msg = { type: "configLoaded", config, globalConfig: updated, features: configFeatures(config) } + const features = configFeatures(config, await serverFeatures(ctx.client, ctx.workspaceDir)) + const msg = { type: "configLoaded", config, globalConfig: updated, features } setCachedConfig(msg) - ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features: configFeatures(config) }) + ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features }) const auth = resolveCustomProviderAuth(apiKey, apiKeyChanged) diff --git a/packages/kilo-vscode/tests/unit/background-agents.test.ts b/packages/kilo-vscode/tests/unit/background-agents.test.ts index 4689ddb8101..567e663b400 100644 --- a/packages/kilo-vscode/tests/unit/background-agents.test.ts +++ b/packages/kilo-vscode/tests/unit/background-agents.test.ts @@ -5,6 +5,7 @@ import { showBackgroundAgent, } from "../../webview-ui/src/components/chat/background-agents" import { childForeground, showChildPromotion } from "../../webview-ui/src/components/chat/task-tool-state" +import { latestTaskPart } from "../../webview-ui/src/context/session-utils" import type { BackgroundJobInfo, PermissionRequest, @@ -77,17 +78,32 @@ describe("backgroundAgents", () => { it("identifies each parallel foreground child independently", () => { const status = { ses_a: busy, ses_b: busy } - expect(childForeground("ses_a", {}, {}, status)).toBe(true) - expect(childForeground("ses_b", {}, {}, status)).toBe(true) - expect(childForeground("ses_a", { background: true }, {}, status)).toBe(false) - expect(childForeground("ses_b", {}, { background: true }, status)).toBe(false) - expect(childForeground("ses_a", {}, {}, { ses_a: idle })).toBe(false) - expect(childForeground("ses_a", {}, {}, { ses_a: { type: "retry", attempt: 1, message: "retry", next: 1 } })).toBe( - true, - ) - expect(childForeground(undefined, {}, {}, status)).toBe(false) - expect(showChildPromotion("ses_a", {}, {}, status, false)).toBe(true) - expect(showChildPromotion("ses_a", {}, {}, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, status, true)).toBe(true) + expect(childForeground("ses_b", {}, {}, status, true)).toBe(true) + expect(childForeground("ses_a", { background: true }, {}, status, true)).toBe(false) + expect(childForeground("ses_b", {}, { background: true }, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, { ses_a: idle }, true)).toBe(false) + expect( + childForeground("ses_a", {}, {}, { ses_a: { type: "retry", attempt: 1, message: "retry", next: 1 } }, true), + ).toBe(true) + expect(childForeground(undefined, {}, {}, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, status, false)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, true, false, true)).toBe(true) + expect(showChildPromotion("ses_a", {}, {}, status, true, true, true)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, false, false, true)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, undefined, false, true)).toBe(false) + }) + + it("only promotes the latest task part for a resumed child", () => { + const parts = [ + taskPart({ id: "part_old", child: "ses_a" }), + taskPart({ id: "part_new", child: "ses_a" }), + taskPart({ id: "part_other", child: "ses_b" }), + ] + + expect(latestTaskPart("part_old", "ses_a", parts)).toBe(false) + expect(latestTaskPart("part_new", "ses_a", parts)).toBe(true) + expect(latestTaskPart("part_other", "ses_b", parts)).toBe(true) }) it("ignores agents whose session is no longer working", () => { diff --git a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts index ad6551e802d..9e9e43d2573 100644 --- a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts @@ -121,6 +121,11 @@ describe("indexing SSE mapping", () => { }) describe("indexing feature detection", () => { + it("keeps background subagent capability disabled unless the server reports it", () => { + expect(configFeatures().backgroundSubagents).toBe(false) + expect(configFeatures({}, true).backgroundSubagents).toBe(true) + }) + it("enables indexing settings when the indexing plugin is present", () => { expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(true) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index 82621a7e460..ebb3ad18332 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -87,6 +87,11 @@ function createConnection() { return { data: snapshot } }, }, + experimental: { + capabilities: { + get: async () => ({ data: { backgroundSubagents: true } }), + }, + }, } return { diff --git a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts index 55c251d07d9..63767afb9a3 100644 --- a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts +++ b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { configFeatures } from "../../src/features" import { visible } from "../../webview-ui/src/components/settings/sandboxing" -const features = { indexing: false, sandboxControls: false } +const features = { indexing: false, sandboxControls: false, backgroundSubagents: false } const platform = Object.getOwnPropertyDescriptor(process, "platform") function setPlatform(value: string) { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index 3a8d4f72daf..568245bcc6c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -20,7 +20,8 @@ import { createAutoScroll } from "@kilocode/kilo-ui/hooks" import { useSession } from "../../context/session" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" -import { childID } from "../../context/session-utils" +import { childID, latestTaskPart } from "../../context/session-utils" +import { useConfig } from "../../context/config" import { openSubagent } from "./open-subagent" import { showChildPromotion, taskResult, taskRunning, taskVisible } from "./task-tool-state" @@ -28,6 +29,7 @@ const TaskToolRenderer: Component = (props) => { const i18n = useI18n() const language = useLanguage() const session = useSession() + const { features } = useConfig() const vscode = useVSCode() const worktree = useWorktreeMode() @@ -45,7 +47,13 @@ const TaskToolRenderer: Component = (props) => { props.partMetadata as Record | undefined, props.metadata as Record | undefined, session.allStatusMap(), + features().backgroundSubagents, props.readonly, + latestTaskPart( + props.partID, + childSessionId(), + session.currentSessionID() ? session.getSessionToolParts(session.currentSessionID()!) : [], + ), ), ) @@ -168,7 +176,7 @@ const TaskToolRenderer: Component = (props) => {
- + | undefined, state: Record | undefined, status: Record, + latest: boolean, ) { - if (!id) return false + if (!id || !latest) return false if (part?.background === true || state?.background === true) return false return status[id]?.type === "busy" || status[id]?.type === "retry" } @@ -20,9 +21,11 @@ export function showChildPromotion( part: Record | undefined, state: Record | undefined, status: Record, + enabled: boolean | undefined, readonly: boolean | undefined, + latest: boolean, ) { - return !readonly && childForeground(id, part, state, status) + return enabled === true && !readonly && childForeground(id, part, state, status, latest) } export function taskVisible(open: boolean | undefined, id: string | undefined) { diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 53b3bfafc3e..4fc09d1e8da 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -88,7 +88,11 @@ export const ConfigProvider: ParentComponent = (props) => { const [projectConfig, setProjectConfig] = createSignal({}) const [collections, setCollections] = createSignal({}) const [settings, setSettings] = createSignal>({}) - const [features, setFeatures] = createSignal({ indexing: false, sandboxControls: false }) + const [features, setFeatures] = createSignal({ + indexing: false, + sandboxControls: false, + backgroundSubagents: false, + }) const [loading, setLoading] = createSignal(true) const [draft, setDraft] = createSignal>({}) const [globalDraft, setGlobalDraft] = createSignal>({}) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index b783496f7a7..84212c2c4b6 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -105,6 +105,7 @@ type ToolState = { } type TaskPart = { + id?: string type: string tool?: string metadata?: { sessionId?: string } @@ -116,6 +117,11 @@ export function childID(part: TaskPart): string | undefined { return part.metadata?.sessionId ?? part.state?.metadata?.sessionId } +export function latestTaskPart(partID: string | undefined, child: string | undefined, parts: readonly TaskPart[]) { + if (!partID || !child) return false + return parts.findLast((part) => childID(part) === child)?.id === partID +} + function stringField(value: unknown): string | undefined { return typeof value === "string" ? value : undefined } diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 00cf1e48759..d682b3ab6bb 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -337,6 +337,7 @@ const ConfigWrapper: ParentComponent<{ return { indexing: props.features?.indexing ?? hasIndexingPlugin(config.plugin ?? []), sandboxControls: props.features?.sandboxControls ?? false, + backgroundSubagents: props.features?.backgroundSubagents ?? false, } }) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index ccca1426483..de4014161a1 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -173,4 +173,5 @@ export interface Config { export interface FeatureFlags { indexing: boolean sandboxControls: boolean + backgroundSubagents: boolean } diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 10fa39f21d6..19633573202 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -25,6 +25,7 @@ import { Skill } from "@/skill" import { BackgroundJob } from "@/background/job" import { SessionRunState } from "@/session/run-state" import { SessionID } from "@/session/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" import { AgentManagerRejectPayload, AgentManagerReplyPayload, @@ -48,6 +49,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const notebook = yield* Notebook.Service const background = yield* BackgroundJob.Service const runState = yield* SessionRunState.Service + const flags = yield* RuntimeFlags.Service const locations = yield* LocationServiceMap.Service // Location-scoped services, keyed by the request's directory and workspace. @@ -237,6 +239,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const backgroundJobPromote = Effect.fn("KilocodeHttpApi.backgroundJobPromote")(function* (ctx: { params: { jobID: string } }) { + if (!flags.experimentalBackgroundSubagents) return false const job = yield* background.get(ctx.params.jobID) if (!job) return yield* new HttpApiError.NotFound({}) const promoted = yield* background.promote(ctx.params.jobID) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index ee318274b3d..306c3cf5c3b 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Fiber, Layer } from "effect" // kilocode_change +import { ConfigProvider, Effect, Fiber, Layer } from "effect" // kilocode_change import { BackgroundJob } from "@/background/job" // kilocode_change import { Session as SessionNs } from "@/session/session" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -9,7 +9,23 @@ import { httpApiLayer, requestInDirectory } from "./httpapi-layer" // kilocode_change start - provide the background-job service for promotion coverage const it = testEffect( - Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(BackgroundJob.node), httpApiLayer), // kilocode_change + Layer.mergeAll( + LayerNode.compile(SessionNs.node), + LayerNode.compile(BackgroundJob.node), + httpApiLayer, + ), // kilocode_change +) +const disabled = testEffect( + Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(BackgroundJob.node), httpApiLayer).pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_EXPERIMENTAL_BACKGROUND_SUBAGENTS: "false", + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + }), + ), + ), + ), ) // kilocode_change end @@ -127,6 +143,26 @@ describe("session action routes", () => { { git: true }, ) + disabled.instance( + "background job promotion is disabled when the flag is off", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ type: "task", metadata: { parentSessionId: "ses_parent" }, run: Effect.never }) + + const res = yield* requestInDirectory(`/kilocode/background-jobs/${job.id}/promote`, test.directory, { + method: "POST", + }) + + expect(res.status).toBe(200) + expect(yield* res.json).toBe(false) + expect((yield* jobs.get(job.id))?.metadata?.background).toBeUndefined() + yield* jobs.cancel(job.id) + }), + { git: true }, + ) + // kilocode_change start - verify HTTP promotion of a running task it.instance( "experimental background route backgrounds a synchronous subagent", From 1f6c8ef0c9c2b42e23c004845c2cba962c267ce0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:25:03 +0200 Subject: [PATCH 22/50] fix(agent-manager): handle overlapping history switches --- .../webview-ui/agent-manager/AgentManagerApp.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index e0a6cd85641..e61dcee4c45 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -298,18 +298,16 @@ const AgentManagerContent: Component = () => { let sidebarRaf: number | undefined let pendingSidebarWidth: number | undefined const [history, setHistory] = createSignal(false) - /** Project whose sessions the history view is scoped to (multi-project). */ const [historyProject, setHistoryProject] = createSignal() - const [historySwitch, setHistorySwitch] = createSignal() + const [historySwitches, setHistorySwitches] = createSignal([]) const closeHistory = () => { setHistory(false) setHistoryProject(undefined) - setHistorySwitch(undefined) + setHistorySwitches([]) } - /** Open the sessions view; a project id scopes it and activates that project. */ const openHistory = (pid?: string) => { const scoped = pid !== undefined && multiProject() - setHistorySwitch(scoped && currentProjectId() !== pid ? pid : undefined) + if (scoped) setHistorySwitches((prev) => (prev.includes(pid) ? prev : [...prev, pid])) setHistoryProject(scoped ? pid : undefined) setHistory(true) if (scoped) { @@ -1164,7 +1162,10 @@ const AgentManagerContent: Component = () => { first: () => undefined, close: () => setReviewActive(false), hide: () => setSidePanel(null), - history: () => (historySwitch() === state.projectId ? setHistorySwitch(undefined) : closeHistory()), + history: () => + state.projectId && historySwitches().includes(state.projectId) + ? setHistorySwitches((prev) => prev.filter((id) => id !== state.projectId)) + : closeHistory(), reset: subagents.reset, }) } From 3f834675819a6599d5c8d0102c20f07be5f58269 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:25:38 +0200 Subject: [PATCH 23/50] fix(vscode): keep sync filter window moving --- .../services/cli-backend/connection-utils.ts | 3 +- .../tests/unit/connection-utils.test.ts | 63 +++++++++---------- 2 files changed, 30 insertions(+), 36 deletions(-) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index 97ea3a568ff..8a25d852744 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -24,7 +24,8 @@ export function createDuplicateEventFilter() { } if (duplicateLiveEvents.has(event.type)) { - if (seen.size < DUPLICATE_EVENT_LIMIT) seen.add(event.id) + if (seen.size >= DUPLICATE_EVENT_LIMIT) seen.delete(seen.values().next().value!) + seen.add(event.id) } return false } diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index cde5796e8b5..3a31216509d 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -234,7 +234,7 @@ describe("createDuplicateEventFilter", () => { ).toBe(false) }) - it("does not evict pending live events when the cap is reached", () => { + it("continues tracking new live events after the cap is reached", () => { const filter = createDuplicateEventFilter() for (let index = 0; index < 1024; index++) { expect( @@ -246,18 +246,6 @@ describe("createDuplicateEventFilter", () => { ).toBe(false) } - expect( - filter( - sync({ - type: "sync", - name: "message.part.updated.1", - id: "live-0", - seq: 8, - aggregateID: "s6", - data: { sessionID: "s6", part, time: 0 }, - }), - ), - ).toBe(true) expect( filter({ id: "live-1024", @@ -277,9 +265,33 @@ describe("createDuplicateEventFilter", () => { }), ), ).toBe(true) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-0", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-1024", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) }) - it("passes overflow events through without evicting pending IDs", () => { + it("forwards delayed envelopes for evicted IDs", () => { const filter = createDuplicateEventFilter() for (let index = 0; index < 1024; index++) { expect( @@ -303,7 +315,7 @@ describe("createDuplicateEventFilter", () => { sync({ type: "sync", name: "message.part.updated.1", - id: "overflow", + id: "pending-0", seq: 8, aggregateID: "s6", data: { sessionID: "s6", part, time: 0 }, @@ -315,32 +327,13 @@ describe("createDuplicateEventFilter", () => { sync({ type: "sync", name: "message.part.updated.1", - id: "pending-0", + id: "pending-1023", seq: 9, aggregateID: "s6", data: { sessionID: "s6", part, time: 0 }, }), ), ).toBe(true) - expect( - filter({ - id: "after-free", - type: "message.part.updated", - properties: { sessionID: "s6", part, delta: "x" }, - }), - ).toBe(false) - expect( - filter( - sync({ - type: "sync", - name: "message.part.updated.1", - id: "after-free", - seq: 10, - aggregateID: "s6", - data: { sessionID: "s6", part, time: 0 }, - }), - ), - ).toBe(true) }) it("does not carry duplicate IDs between connections", () => { From 0ab894d649f53a661bca6ba38bf45769286a3518 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:26:30 +0200 Subject: [PATCH 24/50] fix(agent-manager): remove unreachable provider check --- packages/opencode/src/kilocode/tool/agent-manager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index a1b098d0fff..630f08200f8 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -254,7 +254,6 @@ function select( const value = task.model?.trim() const provider = task.provider?.trim() const variant = task.variant?.trim() - if (provider && !value) return { error: `Task ${index + 1} provider requires a model.` } if (!value) { if (!variant) { if (!task.prompt?.trim() || !source) return { task: base } From 9af0f67c00df7e6d51c505f9c05aea47a044f520 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:32:33 +0200 Subject: [PATCH 25/50] fix(cli): avoid narrow shutdown runtime load --- packages/opencode/src/kilocode/cli/lazy-commands.ts | 4 ++++ packages/opencode/src/kilocode/cli/setup.ts | 1 + .../opencode/test/kilocode/cli/bootstrap-runtime.test.ts | 6 +++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/cli/lazy-commands.ts b/packages/opencode/src/kilocode/cli/lazy-commands.ts index c9490e833b7..b917fab6d1a 100644 --- a/packages/opencode/src/kilocode/cli/lazy-commands.ts +++ b/packages/opencode/src/kilocode/cli/lazy-commands.ts @@ -19,6 +19,10 @@ export function hasLazyCommandSelection() { return selected } +export function resetLazyCommandSelection() { + selected = false +} + export function markLazyCommandSelection() { selected = true } diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index 3d1e8bae985..7557d3c88a8 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -153,6 +153,7 @@ export namespace KiloCli { if (narrow) { const { KiloCliBootstrapRuntime } = await import("@/kilocode/cli/bootstrap-runtime") await KiloCliBootstrapRuntime.dispose() + return } const { InstanceRuntime } = await import("@/project/instance-runtime") await InstanceRuntime.disposeAllInstances() // safety net (no-op if already disposed) diff --git a/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts index 4bcbf15995a..7f580e3b6fe 100644 --- a/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts +++ b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { KiloCli } from "../../../src/kilocode/cli/setup" import { createHelpCommand } from "../../../src/kilocode/help-command" +import { resetLazyCommandSelection } from "../../../src/kilocode/cli/lazy-commands" import yargs from "yargs" describe("CLI bootstrap runtime selection", () => { + beforeEach(resetLazyCommandSelection) + afterEach(resetLazyCommandSelection) + test("uses the narrow runtime for worker-backed TUI launches", () => { expect(KiloCli.workerTui({ _: [] })).toBe(true) expect(KiloCli.workerTui({ _: ["./project"] })).toBe(true) From 7230c71b21a678b693e81d725b73ce6e97963e2a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:32:39 +0200 Subject: [PATCH 26/50] fix(agent-manager): scope full-screen diff notices --- .../kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b2b31fb70fe..1599819baf9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1748,7 +1748,7 @@ const AgentManagerContent: Component = () => { const diffNotice = createMemo(() => { const key = diffScopeId() if (!key) return undefined - return diffNotices()[key] + return diffNotices()[diffDataKey(activeProjectId(), key)] }) const requestDiffFile = (file: string) => { From e0aeb847135bf7b3286a342e845832af9ecefd43 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:51:48 +0200 Subject: [PATCH 27/50] fix(agent-manager): avoid stale history switch entries --- .changeset/agent-manager-history-switch-queue.md | 5 +++++ .../webview-ui/agent-manager/AgentManagerApp.tsx | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/agent-manager-history-switch-queue.md diff --git a/.changeset/agent-manager-history-switch-queue.md b/.changeset/agent-manager-history-switch-queue.md new file mode 100644 index 00000000000..9cfd32747aa --- /dev/null +++ b/.changeset/agent-manager-history-switch-queue.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent overlapping Agent Manager history activations from leaving stale project-switch state. diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index e61dcee4c45..f5a772c667d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -307,12 +307,12 @@ const AgentManagerContent: Component = () => { } const openHistory = (pid?: string) => { const scoped = pid !== undefined && multiProject() - if (scoped) setHistorySwitches((prev) => (prev.includes(pid) ? prev : [...prev, pid])) + if (scoped && (currentProjectId() !== pid || historySwitches().length > 0)) + setHistorySwitches((prev) => (prev.includes(pid) ? prev : [...prev, pid])) setHistoryProject(scoped ? pid : undefined) setHistory(true) if (scoped) { - // Activating the target project first lets the shared session store and - // the pick routing operate in that project only. + // Activate the target so the session store and pick routing use that project. vscode.postMessage({ type: "agentManager.activateSelection", target: { projectId: pid, kind: "local" }, From b629accd3d7f0bde04f6dde5ffc635c6f10ca68d Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 11:00:00 -0400 Subject: [PATCH 28/50] fix(jetbrains): show failed sessions on their worktree row The leading icon dropped back to the resting branch glyph for an errored session, so a worktree whose session had just failed looked idle and the failure was only visible after opening the session. Error now takes the leading slot like the other kinds that need the user, and an operation on the row still outranks it. --- .../client/agentManager/worktree/WorktreeIcons.kt | 11 ++++++----- .../client/agentManager/AgentManagerPanelTest.kt | 4 ++-- .../client/agentManager/WorktreeControllerTest.kt | 7 +++++-- .../client/agentManager/WorktreeIconsTest.kt | 13 +++++++------ 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt index 5fe84fbd5bf..42272b58a2c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt @@ -21,9 +21,9 @@ internal object WorktreeIcons { /** * Leading icon for a worktree row. At rest the row shows what it is — the local machine, a locked - * checkout, or a branch checkout — while a running or waiting session takes the slot over so the - * list still surfaces activity at a glance. An operation on the row ([busy]) outranks all of it, - * and an errored session falls back to the resting glyph instead of shouting in the leading slot. + * checkout, or a branch checkout — while a running, waiting or failed session takes the slot over + * so the list still surfaces activity at a glance. An operation on the row ([busy]) outranks all + * of it. */ fun forRow( busy: Boolean, @@ -37,8 +37,9 @@ internal object WorktreeIcons { SessionActivityKind.QUESTION, SessionActivityKind.PERMISSION, SessionActivityKind.PLAN, - SessionActivityKind.LOGIN_REQUIRED -> kind.icon() - SessionActivityKind.ERROR, null -> when { + SessionActivityKind.LOGIN_REQUIRED, + SessionActivityKind.ERROR -> kind.icon() + null -> when { current -> local locked -> this.locked else -> branch diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 0b6f195bb99..79716e0534f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -531,7 +531,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertEquals(emptyList(), row.badges) } - fun `test worktree row uses the branch icon for error activity`() { + fun `test worktree row uses the error icon for error activity`() { val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") val activity = MutableStateFlow(mapOf( "ses_1" to SessionActivityDto(item.path, SessionActivityKindDto.ERROR), @@ -542,7 +542,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { edt { controller.reload() } flush() - assertSame(WorktreeIcons.branch, row(panel, 0).icon) + assertSame(SessionActivityKind.ERROR.icon(), row(panel, 0).icon) } fun `test idle worktree rows show the branch icon and the local row shows the monitor`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index 4da22e5c487..1c8de530f0a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -493,7 +493,7 @@ class WorktreeControllerTest : BasePlatformTestCase() { } } - fun `test worktree row icons show only while running or waiting`() { + fun `test worktree row icons show while running, waiting or failed`() { assertSame( WorktreeIcons.spinner, WorktreeIcons.forRow(busy = true, kind = SessionActivityKind.RUNNING), @@ -510,7 +510,10 @@ class WorktreeControllerTest : BasePlatformTestCase() { SessionActivityKind.PLAN.icon(), WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.PLAN), ) - assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR)) + assertSame( + SessionActivityKind.ERROR.icon(), + WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR), + ) assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = null)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt index 72a98c1dde6..4102c0c17fe 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt @@ -76,12 +76,13 @@ class WorktreeIconsTest : BasePlatformTestCase() { assertSame(WorktreeIcons.local, WorktreeIcons.forRow(busy = false, current = true)) } - fun `test errored session falls back to the resting glyph`() { - assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR)) - assertSame( - WorktreeIcons.local, - WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, current = true), - ) + fun `test errored session shows the error glyph over the resting one`() { + val error = SessionActivityKind.ERROR.icon() + assertSame(error, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR)) + assertSame(error, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, current = true)) + assertSame(error, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, locked = true)) + // An operation on the row still outranks it. + assertSame(WorktreeIcons.spinner, WorktreeIcons.forRow(busy = true, kind = SessionActivityKind.ERROR)) } fun `test activity outranks the resting glyph on the local row`() { From f80d7d3b50f7f21a0abc2c601f822fbb2ea97df3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 11:00:20 -0400 Subject: [PATCH 29/50] fix(jetbrains): clear the Agents tab dot once the attention has been read The dot mirrored the activity snapshot, and an error stays in that snapshot until its session runs again, so a failed session left the dot lit for good. AgentAttention now tracks which sessions the user has already seen: attention that is pending while the Agent Manager is on screen counts as read, because the rows carry the badge there. The dot is therefore re-evaluated on tab selection and tool window visibility as well as on activity, and a session that recovers and fails again lights it once more. --- .changeset/jetbrains-worktree-list-fixes.md | 2 +- .../kilocode/client/KiloToolWindowFactory.kt | 28 +++++++++--- .../client/agentManager/AgentAttention.kt | 31 +++++++++---- .../client/agentManager/AgentAttentionTest.kt | 43 +++++++++++++++++-- 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/.changeset/jetbrains-worktree-list-fixes.md b/.changeset/jetbrains-worktree-list-fixes.md index 7aa15c9a92c..124e3896483 100644 --- a/.changeset/jetbrains-worktree-list-fixes.md +++ b/.changeset/jetbrains-worktree-list-fixes.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, and keep session card popups inside the visible session view. +Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, mark failed sessions on their worktree row, clear the Agents tab notification dot once the attention has been read, and keep session card popups inside the visible session view. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index a44a7b0d108..7063e665136 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -11,11 +11,12 @@ import ai.kilocode.client.agentManager.SidePanelKeys import ai.kilocode.client.agentManager.SidePanelMode import ai.kilocode.client.agentManager.applySidePanelMode import ai.kilocode.client.agentManager.worktree.WorktreeController +import ai.kilocode.client.agentManager.AgentAttention import ai.kilocode.client.agentManager.AgentManagerPanel -import ai.kilocode.client.agentManager.sessionAttentionNeeded import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.AttentionDotIcon import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.SessionActivityDto import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.DataProvider @@ -27,6 +28,7 @@ import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowContentUiType import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.openapi.wm.ex.ToolWindowManagerListener import com.intellij.platform.project.projectIdOrNull import com.intellij.openapi.wm.impl.content.ToolWindowContentUi import com.intellij.ui.content.ContentManagerEvent @@ -34,7 +36,6 @@ import com.intellij.ui.content.ContentManagerListener import com.intellij.ui.content.ContentFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -151,23 +152,40 @@ internal class KiloToolWindowSetupService( agents() agentManagerPanel.move(id, dir) } + // Notification dot on the Agents tab: attention the user has not looked at yet. Having + // the tab on screen is the acknowledgement, so the dot has to be re-evaluated when the + // selected tab or the tool window visibility changes, not only when activity arrives. + val attention = AgentAttention() + var snapshot = emptyMap() + fun syncDot() { + val showing = toolWindow.isVisible && toolWindow.contentManager.selectedContent === agentContent + agentContent.icon = if (attention.update(snapshot, showing)) AttentionDotIcon else null + } + val listener = object : ContentManagerListener { override fun selectionChanged(event: ContentManagerEvent) { if (event.operation == ContentManagerEvent.ContentOperation.add && event.content === agentContent) { agentManagerPanel.refresh() } + syncDot() } } toolWindow.contentManager.addContentManagerListener(listener) Disposer.register(manager) { toolWindow.contentManager.removeContentManagerListener(listener) } + val windows = object : ToolWindowManagerListener { + override fun toolWindowShown(shown: ToolWindow) { + if (shown.id == toolWindow.id) syncDot() + } + } + project.messageBus.connect(manager).subscribe(ToolWindowManagerListener.TOPIC, windows) toolWindow.contentManager.setSelectedContent(chatContent) manager.newSession() - // Show a notification dot on the Agents tab whenever a worktree session needs attention. val dot = cs.launch { - project.service().activity.map(::sessionAttentionNeeded).collect { needed -> + project.service().activity.collect { current -> withContext(Dispatchers.Main) { - agentContent.icon = if (needed) AttentionDotIcon else null + snapshot = current + syncDot() } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt index b4506b00801..b89840e269d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt @@ -4,13 +4,28 @@ import ai.kilocode.rpc.dto.SessionActivityDto import ai.kilocode.rpc.dto.SessionActivityKindDto /** - * Whether any session in the activity snapshot is waiting on the user or has failed, - * i.e. the Agents tab should show a notification dot. + * Notification dot state for the Agents tab. + * + * The dot marks attention the user has not looked at yet. Sessions that need attention while the + * Agent Manager is on screen count as seen, because the rows already carry the badge there. That is + * what lets the dot clear for good: an error stays in the activity snapshot until its session runs + * again, so a dot driven by the snapshot alone would come back every time the user left the tab. + * A session that stops needing attention is forgotten again, so a later failure lights the dot. */ -internal fun sessionAttentionNeeded(activity: Map): Boolean = - activity.values.any { - it.kind == SessionActivityKindDto.QUESTION || - it.kind == SessionActivityKindDto.PLAN || - it.kind == SessionActivityKindDto.PERMISSION || - it.kind == SessionActivityKindDto.ERROR +internal class AgentAttention { + private var seen = emptySet() + + /** Whether the dot should be visible, where [showing] means the Agent Manager is on screen. */ + fun update(activity: Map, showing: Boolean): Boolean { + val pending = activity.filterValues(::attention).keys + seen = if (showing) pending else seen intersect pending + return (pending - seen).isNotEmpty() } +} + +/** Whether a session is waiting on the user or has failed. */ +private fun attention(item: SessionActivityDto): Boolean = + item.kind == SessionActivityKindDto.QUESTION || + item.kind == SessionActivityKindDto.PLAN || + item.kind == SessionActivityKindDto.PERMISSION || + item.kind == SessionActivityKindDto.ERROR diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt index e105475abc1..1a34e603991 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt @@ -15,13 +15,50 @@ class AgentAttentionTest { SessionActivityKindDto.PERMISSION, SessionActivityKindDto.ERROR, )) { - assertTrue(sessionAttentionNeeded(mapOf("ses" to SessionActivityDto("/repo/wt", kind))), kind.name) + assertTrue(AgentAttention().update(activity(kind), showing = false), kind.name) } } @Test fun `running and empty do not light up the dot`() { - assertFalse(sessionAttentionNeeded(emptyMap())) - assertFalse(sessionAttentionNeeded(mapOf("ses" to SessionActivityDto("/repo/wt", SessionActivityKindDto.RUNNING)))) + assertFalse(AgentAttention().update(emptyMap(), showing = false)) + assertFalse(AgentAttention().update(activity(SessionActivityKindDto.RUNNING), showing = false)) } + + @Test + fun `attention seen on screen stays clear after leaving the tab`() { + val attention = AgentAttention() + val errored = activity(SessionActivityKindDto.ERROR) + + assertFalse(attention.update(errored, showing = true)) + // The error sticks in the snapshot until the session runs again; the dot must not come back. + assertFalse(attention.update(errored, showing = false)) + assertFalse(attention.update(errored, showing = false)) + } + + @Test + fun `attention arriving while the tab is hidden lights the dot`() { + val attention = AgentAttention() + attention.update(activity(SessionActivityKindDto.ERROR), showing = true) + + val another = mapOf( + "ses_1" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), + "ses_2" to SessionActivityDto("/repo/other", SessionActivityKindDto.QUESTION), + ) + + assertTrue(attention.update(another, showing = false)) + } + + @Test + fun `a session that recovers and fails again lights the dot again`() { + val attention = AgentAttention() + val errored = activity(SessionActivityKindDto.ERROR) + attention.update(errored, showing = true) + + assertFalse(attention.update(emptyMap(), showing = false)) + assertTrue(attention.update(errored, showing = false)) + } + + private fun activity(kind: SessionActivityKindDto) = + mapOf("ses_1" to SessionActivityDto("/repo/wt", kind)) } From a1ccea47b49fcd9ec24f0c8132b08a01e861a3c7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 11:14:34 -0400 Subject: [PATCH 30/50] fix(jetbrains): badge failed sessions in session lists and raise the Agents dot activitySnapshot() only mapped busy statuses to RUNNING, so session and history rows had no source for the other kinds: a failed session showed no badge even though the backend already reported ERROR for it. The snapshot now derives from that activity map, with the busy statuses kept as a fallback for sessions whose directory the backend cannot resolve. Worktree rows and session rows therefore read the same source. The dot also treated 'the Agent Manager is on screen' as read, which suppressed it for a session that failed while the user was working in a session editor. Reading now means the panel is focused, so the dot appears on failure and still clears once the user looks at the tab. --- .changeset/jetbrains-worktree-list-fixes.md | 2 +- .../kilocode/client/KiloToolWindowFactory.kt | 14 ++++++---- .../client/agentManager/AgentAttention.kt | 18 +++++++------ .../agentManager/worktree/WorktreeActivity.kt | 12 ++------- .../kilocode/client/app/KiloSessionService.kt | 14 +++++++--- .../client/session/SessionActivityKind.kt | 13 ++++++++++ .../client/agentManager/AgentAttentionTest.kt | 26 +++++++++---------- .../client/app/KiloSessionServiceTest.kt | 24 +++++++++++++++++ 8 files changed, 82 insertions(+), 41 deletions(-) diff --git a/.changeset/jetbrains-worktree-list-fixes.md b/.changeset/jetbrains-worktree-list-fixes.md index 124e3896483..03e8017edbc 100644 --- a/.changeset/jetbrains-worktree-list-fixes.md +++ b/.changeset/jetbrains-worktree-list-fixes.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, mark failed sessions on their worktree row, clear the Agents tab notification dot once the attention has been read, and keep session card popups inside the visible session view. +Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, mark failed and waiting sessions on their worktree row and in session lists, raise the Agents tab notification dot for anything you have not read yet, and keep session card popups inside the visible session view. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 7063e665136..7e540a9be0e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -28,6 +28,7 @@ import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowContentUiType import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.openapi.wm.ToolWindowManager import com.intellij.openapi.wm.ex.ToolWindowManagerListener import com.intellij.platform.project.projectIdOrNull import com.intellij.openapi.wm.impl.content.ToolWindowContentUi @@ -152,14 +153,15 @@ internal class KiloToolWindowSetupService( agents() agentManagerPanel.move(id, dir) } - // Notification dot on the Agents tab: attention the user has not looked at yet. Having - // the tab on screen is the acknowledgement, so the dot has to be re-evaluated when the - // selected tab or the tool window visibility changes, not only when activity arrives. + // Notification dot on the Agents tab: attention the user has not looked at yet. Reading + // it means the panel had focus, not merely that it was on screen — a session that fails + // while the user works in a session editor must still raise the dot. That makes tool + // window activation part of the input, so the dot cannot be driven by activity alone. val attention = AgentAttention() var snapshot = emptyMap() fun syncDot() { - val showing = toolWindow.isVisible && toolWindow.contentManager.selectedContent === agentContent - agentContent.icon = if (attention.update(snapshot, showing)) AttentionDotIcon else null + val read = toolWindow.isActive && toolWindow.contentManager.selectedContent === agentContent + agentContent.icon = if (attention.update(snapshot, read)) AttentionDotIcon else null } val listener = object : ContentManagerListener { @@ -173,6 +175,8 @@ internal class KiloToolWindowSetupService( toolWindow.contentManager.addContentManagerListener(listener) Disposer.register(manager) { toolWindow.contentManager.removeContentManagerListener(listener) } val windows = object : ToolWindowManagerListener { + override fun stateChanged(manager: ToolWindowManager) = syncDot() + override fun toolWindowShown(shown: ToolWindow) { if (shown.id == toolWindow.id) syncDot() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt index b89840e269d..d510277a867 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt @@ -6,19 +6,21 @@ import ai.kilocode.rpc.dto.SessionActivityKindDto /** * Notification dot state for the Agents tab. * - * The dot marks attention the user has not looked at yet. Sessions that need attention while the - * Agent Manager is on screen count as seen, because the rows already carry the badge there. That is - * what lets the dot clear for good: an error stays in the activity snapshot until its session runs - * again, so a dot driven by the snapshot alone would come back every time the user left the tab. - * A session that stops needing attention is forgotten again, so a later failure lights the dot. + * The dot marks attention the user has not looked at yet. That distinction is what lets it clear for + * good: an error stays in the activity snapshot until its session runs again, so a dot driven by the + * snapshot alone would come back every time the user left the tab. A session that stops needing + * attention is forgotten again, so a later failure lights the dot once more. */ internal class AgentAttention { private var seen = emptySet() - /** Whether the dot should be visible, where [showing] means the Agent Manager is on screen. */ - fun update(activity: Map, showing: Boolean): Boolean { + /** + * Whether the dot should be visible. [read] means the user is looking at the Agent Manager, + * which marks everything currently pending as seen — the rows carry the badge there. + */ + fun update(activity: Map, read: Boolean): Boolean { val pending = activity.filterValues(::attention).keys - seen = if (showing) pending else seen intersect pending + seen = if (read) pending else seen intersect pending return (pending - seen).isNotEmpty() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt index 555d2d6031b..63321fc5bef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt @@ -1,27 +1,19 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.toKind import ai.kilocode.rpc.dto.SessionActivityDto -import ai.kilocode.rpc.dto.SessionActivityKindDto internal fun aggregateWorktreeActivity( activity: Map, ): Map = activity.values .groupBy { normalize(it.directory) } - .mapValues { (_, items) -> items.map { kind(it.kind) }.minBy(::rank) } + .mapValues { (_, items) -> items.map { it.kind.toKind() }.minBy(::rank) } internal fun normalizeWorktreePath(path: String): String = normalize(path) private fun normalize(path: String): String = path.trimEnd('/') -private fun kind(kind: SessionActivityKindDto): SessionActivityKind = when (kind) { - SessionActivityKindDto.RUNNING -> SessionActivityKind.RUNNING - SessionActivityKindDto.QUESTION -> SessionActivityKind.QUESTION - SessionActivityKindDto.PLAN -> SessionActivityKind.PLAN - SessionActivityKindDto.PERMISSION -> SessionActivityKind.PERMISSION - SessionActivityKindDto.ERROR -> SessionActivityKind.ERROR -} - /** * Precedence for a worktree holding several sessions: anything waiting on the user first, then live * work, then a session left in an error. Running beats error so one stopped session cannot hide the diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 0d1615644f2..93f0a9968fd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -5,6 +5,7 @@ package ai.kilocode.client.app import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.toKind import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -109,10 +110,15 @@ class KiloSessionService internal constructor( } } - internal fun activitySnapshot(): Map = - statuses.value - .filterValues { it.type == "busy" } - .mapValues { SessionActivityKind.RUNNING } + /** + * Per-session activity for history and session lists. [activity] is the richer source — it also + * carries waiting and failed sessions, and it covers sessions that are not open — but it drops + * sessions whose directory the backend cannot resolve, so the busy statuses stay as a fallback. + */ + internal fun activitySnapshot(): Map { + val busy = statuses.value.filterValues { it.type == "busy" }.mapValues { SessionActivityKind.RUNNING } + return busy + activity.value.mapValues { it.value.kind.toKind() } + } suspend fun list(dir: String): SessionListDto { val result = call { list(dir) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt index d82dae0e3bd..f7403c3f200 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.SessionActivityKindDto import javax.swing.Icon enum class SessionActivityKind { @@ -30,3 +31,15 @@ enum class SessionActivityKind { fun icon(): Icon = ActivityIcon.of(this) } + +/** + * The backend reports activity for every session it knows, open or not. LOGIN_REQUIRED has no DTO + * counterpart: it comes from live session UI state instead. + */ +internal fun SessionActivityKindDto.toKind(): SessionActivityKind = when (this) { + SessionActivityKindDto.RUNNING -> SessionActivityKind.RUNNING + SessionActivityKindDto.QUESTION -> SessionActivityKind.QUESTION + SessionActivityKindDto.PLAN -> SessionActivityKind.PLAN + SessionActivityKindDto.PERMISSION -> SessionActivityKind.PERMISSION + SessionActivityKindDto.ERROR -> SessionActivityKind.ERROR +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt index 1a34e603991..cbe1d0766ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt @@ -15,48 +15,48 @@ class AgentAttentionTest { SessionActivityKindDto.PERMISSION, SessionActivityKindDto.ERROR, )) { - assertTrue(AgentAttention().update(activity(kind), showing = false), kind.name) + assertTrue(AgentAttention().update(activity(kind), read = false), kind.name) } } @Test fun `running and empty do not light up the dot`() { - assertFalse(AgentAttention().update(emptyMap(), showing = false)) - assertFalse(AgentAttention().update(activity(SessionActivityKindDto.RUNNING), showing = false)) + assertFalse(AgentAttention().update(emptyMap(), read = false)) + assertFalse(AgentAttention().update(activity(SessionActivityKindDto.RUNNING), read = false)) } @Test - fun `attention seen on screen stays clear after leaving the tab`() { + fun `attention already read stays clear after leaving the tab`() { val attention = AgentAttention() val errored = activity(SessionActivityKindDto.ERROR) - assertFalse(attention.update(errored, showing = true)) + assertFalse(attention.update(errored, read = true)) // The error sticks in the snapshot until the session runs again; the dot must not come back. - assertFalse(attention.update(errored, showing = false)) - assertFalse(attention.update(errored, showing = false)) + assertFalse(attention.update(errored, read = false)) + assertFalse(attention.update(errored, read = false)) } @Test - fun `attention arriving while the tab is hidden lights the dot`() { + fun `attention arriving while the panel is unread lights the dot`() { val attention = AgentAttention() - attention.update(activity(SessionActivityKindDto.ERROR), showing = true) + attention.update(activity(SessionActivityKindDto.ERROR), read = true) val another = mapOf( "ses_1" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), "ses_2" to SessionActivityDto("/repo/other", SessionActivityKindDto.QUESTION), ) - assertTrue(attention.update(another, showing = false)) + assertTrue(attention.update(another, read = false)) } @Test fun `a session that recovers and fails again lights the dot again`() { val attention = AgentAttention() val errored = activity(SessionActivityKindDto.ERROR) - attention.update(errored, showing = true) + attention.update(errored, read = true) - assertFalse(attention.update(emptyMap(), showing = false)) - assertTrue(attention.update(errored, showing = false)) + assertFalse(attention.update(emptyMap(), read = false)) + assertTrue(attention.update(errored, read = false)) } private fun activity(kind: SessionActivityKindDto) = diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt index 715f1079464..2acea37fbad 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt @@ -1,9 +1,13 @@ package ai.kilocode.client.app +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.SessionActivityDto +import ai.kilocode.rpc.dto.SessionActivityKindDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope @@ -11,6 +15,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList @@ -139,6 +144,25 @@ class KiloSessionServiceTest : BasePlatformTestCase() { assertTrue(log.messages.joinToString("\n"), log.messages.any { it.contains("route=client-events stop=true failed message=stream failed") }) } + fun `test activity snapshot carries every kind the backend reports`() = runBlocking(Dispatchers.Default) { + // A busy session the backend cannot place in a directory, so only the status map has it. + rpc.statuses.value = mapOf("ses_busy" to SessionStatusDto("busy")) + rpc.activity.value = mapOf( + "ses_failed" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), + "ses_asking" to SessionActivityDto("/repo/wt", SessionActivityKindDto.QUESTION), + ) + service.activity.first { it.isNotEmpty() } + + assertEquals( + mapOf( + "ses_busy" to SessionActivityKind.RUNNING, + "ses_failed" to SessionActivityKind.ERROR, + "ses_asking" to SessionActivityKind.QUESTION, + ), + service.activitySnapshot(), + ) + } + private fun session(id: String, title: String) = SessionDto( id = id, projectID = "prj", From bf07f65711b925cd41088df9c4dbe03765c038ec Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 17:14:57 +0200 Subject: [PATCH 31/50] fix(agent-manager): keep merged app below lint limit --- .../webview-ui/agent-manager/AgentManagerApp.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 1599819baf9..8327050b867 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1498,18 +1498,11 @@ const AgentManagerContent: Component = () => { } } - // Set per-session model selection without clearing busy state. - // Used during Phase 1 of multi-version creation so the UI selector - // reflects the correct model as soon as the worktree appears. if ((msg as { type: string }).type === "agentManager.setSessionModel") { const ev = msg as { type: string; sessionId: string; providerID: string; modelID: string } session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID) } - // Handle initial message send for multi-version sessions. - // The extension creates the worktrees/sessions, then asks the webview - // to send the prompt through the normal KiloProvider sendMessage path. - // Once the message is sent, clear the loading state for that worktree. if ((msg as { type: string }).type === "agentManager.sendInitialMessage") { const ev = msg as unknown as AgentManagerSendInitialMessage From 5da8d6b851b70fb079bede223c771dab8c922072 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 17:20:03 +0200 Subject: [PATCH 32/50] fix(agent-manager): preserve worktree list scroll on deletion --- .changeset/sidebar-scroll-preservation.md | 5 + .../unit/agent-manager-sidebar-scroll.test.ts | 110 ++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 4 +- .../agent-manager/sidebar-scroll.ts | 35 ++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 .changeset/sidebar-scroll-preservation.md create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts diff --git a/.changeset/sidebar-scroll-preservation.md b/.changeset/sidebar-scroll-preservation.md new file mode 100644 index 00000000000..26c4cb0e3db --- /dev/null +++ b/.changeset/sidebar-scroll-preservation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve the Agent Manager sidebar scroll position when worktrees are deleted. diff --git a/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts new file mode 100644 index 00000000000..1ed8e4b046b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { Window } from "happy-dom" +import { createSidebarScrollPreserver } from "../../webview-ui/agent-manager/sidebar-scroll" + +const window = new Window() +const frames = new Map() +let id = 0 + +function schedule(fn: FrameRequestCallback) { + const next = ++id + frames.set(next, fn) + return next +} + +function cancel(id: number) { + frames.delete(id) +} + +function preserver() { + return createSidebarScrollPreserver(window.document, schedule, cancel) +} + +afterEach(() => { + window.document.body.innerHTML = "" + frames.clear() + id = 0 +}) + +function list(cls = "am-worktree-list") { + const el = window.document.createElement("div") + el.className = cls + Object.defineProperty(el, "scrollTop", { configurable: true, value: 0, writable: true }) + window.document.body.append(el) + return el +} + +function flush() { + for (let i = 0; i < 2; i++) { + const next = frames.entries().next().value + if (!next) return + frames.delete(next[0]) + next[1](0) + } +} + +describe("Agent Manager sidebar scroll preservation", () => { + it("restores the scroll offset after the state update has rendered", () => { + const el = list() + el.scrollTop = 240 + const preserve = preserver() + + preserve(() => { + el.scrollTop = 0 + }) + + expect(el.scrollTop).toBe(0) + flush() + expect(el.scrollTop).toBe(240) + }) + + it("tracks project and worktree scroll owners independently", () => { + const projects = list("am-projects-list") + const worktrees = list() + projects.scrollTop = 120 + worktrees.scrollTop = 80 + const preserve = preserver() + + preserve(() => { + projects.scrollTop = 0 + worktrees.scrollTop = 0 + }) + flush() + + expect(projects.scrollTop).toBe(120) + expect(worktrees.scrollTop).toBe(80) + }) + + it("cancels stale restores when a newer state arrives", () => { + const el = list() + const preserve = preserver() + el.scrollTop = 120 + + preserve(() => { + el.scrollTop = 0 + }) + el.scrollTop = 210 + preserve(() => { + el.scrollTop = 0 + }) + flush() + + expect(el.scrollTop).toBe(210) + expect(frames.size).toBe(0) + }) + + it("does not restore a container that was removed by the update", () => { + const el = list() + el.scrollTop = 160 + const preserve = preserver() + + preserve(() => { + el.remove() + el.scrollTop = 0 + }) + flush() + + expect(el.isConnected).toBe(false) + expect(el.scrollTop).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 113e28acd81..9511e624a22 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -213,6 +213,7 @@ import { defaultBase as projectDefaultBase } from "./project/default-base" import "./agent-manager.css" import "./agent-manager-review.css" import { cycleAgent as cycle } from "../src/context/session-agent" +import { createSidebarScrollPreserver } from "./sidebar-scroll" const REVIEW_TAB_ID = "review" interface SetupState { active: boolean @@ -1111,6 +1112,7 @@ const AgentManagerContent: Component = () => { rename: setRenamingSection, font: (font) => font && setTerminalFont(font), }) + const preserveSidebarScroll = createSidebarScrollPreserver() /** Apply the active-transition effects of a state payload (data already landed in the store). */ const applyActiveState = (state: AgentManagerStateMessage) => { @@ -1482,7 +1484,7 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.focusContextRequested") focusCtl.report() if (msg.type === "agentManager.state" && msg.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true) - if (msg.type === "agentManager.state") stateHandlers.state(msg) + if (msg.type === "agentManager.state") preserveSidebarScroll(() => stateHandlers.state(msg)) // When a multi-version progress update arrives, mark newly created worktrees as loading if ((msg as { type: string }).type === "agentManager.multiVersionProgress") { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts new file mode 100644 index 00000000000..7aa0abf2707 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts @@ -0,0 +1,35 @@ +type Entry = { + el: HTMLElement + top: number +} + +export function createSidebarScrollPreserver( + root: ParentNode = document, + schedule: typeof requestAnimationFrame = requestAnimationFrame, + cancel: typeof cancelAnimationFrame = cancelAnimationFrame, +) { + let frame: number | undefined + let inner: number | undefined + + return (fn: () => void): void => { + if (frame !== undefined) cancel(frame) + if (inner !== undefined) cancel(inner) + + const scrolls: Entry[] = [...root.querySelectorAll(".am-worktree-list, .am-projects-list")].map( + (el) => ({ + el, + top: el.scrollTop, + }), + ) + fn() + frame = schedule(() => { + frame = undefined + inner = schedule(() => { + inner = undefined + for (const item of scrolls) { + if (item.el.isConnected) item.el.scrollTop = item.top + } + }) + }) + } +} From 28d0f3f455584cb149d5c352f87738ccfa8c5f48 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 11:22:48 -0400 Subject: [PATCH 33/50] fix(jetbrains): keep the Agents dot up until the attention is resolved Tracking which sessions the user had looked at made the dot disappear on a tab round trip while a worktree was still failed or waiting, which is the opposite of what the signal is for. The dot mirrors the activity snapshot again: it stays up while any session in any worktree needs the user, and clears only when that state does. --- .changeset/jetbrains-worktree-list-fixes.md | 2 +- .../kilocode/client/KiloToolWindowFactory.kt | 34 +++------------ .../client/agentManager/AgentAttention.kt | 35 +++++---------- .../client/agentManager/AgentAttentionTest.kt | 43 +++++-------------- 4 files changed, 30 insertions(+), 84 deletions(-) diff --git a/.changeset/jetbrains-worktree-list-fixes.md b/.changeset/jetbrains-worktree-list-fixes.md index 03e8017edbc..6127487e5da 100644 --- a/.changeset/jetbrains-worktree-list-fixes.md +++ b/.changeset/jetbrains-worktree-list-fixes.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, mark failed and waiting sessions on their worktree row and in session lists, raise the Agents tab notification dot for anything you have not read yet, and keep session card popups inside the visible session view. +Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, mark failed and waiting sessions on their worktree row and in session lists, keep the Agents tab notification dot up until every session that needs you is resolved, and keep session card popups inside the visible session view. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 7e540a9be0e..5718081914b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -11,12 +11,11 @@ import ai.kilocode.client.agentManager.SidePanelKeys import ai.kilocode.client.agentManager.SidePanelMode import ai.kilocode.client.agentManager.applySidePanelMode import ai.kilocode.client.agentManager.worktree.WorktreeController -import ai.kilocode.client.agentManager.AgentAttention import ai.kilocode.client.agentManager.AgentManagerPanel +import ai.kilocode.client.agentManager.sessionAttentionNeeded import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.AttentionDotIcon import ai.kilocode.log.KiloLog -import ai.kilocode.rpc.dto.SessionActivityDto import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.DataProvider @@ -28,8 +27,6 @@ import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowContentUiType import com.intellij.openapi.wm.ToolWindowFactory -import com.intellij.openapi.wm.ToolWindowManager -import com.intellij.openapi.wm.ex.ToolWindowManagerListener import com.intellij.platform.project.projectIdOrNull import com.intellij.openapi.wm.impl.content.ToolWindowContentUi import com.intellij.ui.content.ContentManagerEvent @@ -37,6 +34,7 @@ import com.intellij.ui.content.ContentManagerListener import com.intellij.ui.content.ContentFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -153,43 +151,25 @@ internal class KiloToolWindowSetupService( agents() agentManagerPanel.move(id, dir) } - // Notification dot on the Agents tab: attention the user has not looked at yet. Reading - // it means the panel had focus, not merely that it was on screen — a session that fails - // while the user works in a session editor must still raise the dot. That makes tool - // window activation part of the input, so the dot cannot be driven by activity alone. - val attention = AgentAttention() - var snapshot = emptyMap() - fun syncDot() { - val read = toolWindow.isActive && toolWindow.contentManager.selectedContent === agentContent - agentContent.icon = if (attention.update(snapshot, read)) AttentionDotIcon else null - } - val listener = object : ContentManagerListener { override fun selectionChanged(event: ContentManagerEvent) { if (event.operation == ContentManagerEvent.ContentOperation.add && event.content === agentContent) { agentManagerPanel.refresh() } - syncDot() } } toolWindow.contentManager.addContentManagerListener(listener) Disposer.register(manager) { toolWindow.contentManager.removeContentManagerListener(listener) } - val windows = object : ToolWindowManagerListener { - override fun stateChanged(manager: ToolWindowManager) = syncDot() - - override fun toolWindowShown(shown: ToolWindow) { - if (shown.id == toolWindow.id) syncDot() - } - } - project.messageBus.connect(manager).subscribe(ToolWindowManagerListener.TOPIC, windows) toolWindow.contentManager.setSelectedContent(chatContent) manager.newSession() + // Notification dot on the Agents tab: up for as long as any worktree session is waiting + // on the user or has failed. Viewing the tab must not clear it — only resolving the + // attention does, so the dot stays a reliable "something still needs you" signal. val dot = cs.launch { - project.service().activity.collect { current -> + project.service().activity.map(::sessionAttentionNeeded).collect { needed -> withContext(Dispatchers.Main) { - snapshot = current - syncDot() + agentContent.icon = if (needed) AttentionDotIcon else null } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt index d510277a867..c5c13ede48e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt @@ -4,30 +4,17 @@ import ai.kilocode.rpc.dto.SessionActivityDto import ai.kilocode.rpc.dto.SessionActivityKindDto /** - * Notification dot state for the Agents tab. + * Whether any session in the activity snapshot is waiting on the user or has failed, i.e. the Agents + * tab should show a notification dot. * - * The dot marks attention the user has not looked at yet. That distinction is what lets it clear for - * good: an error stays in the activity snapshot until its session runs again, so a dot driven by the - * snapshot alone would come back every time the user left the tab. A session that stops needing - * attention is forgotten again, so a later failure lights the dot once more. + * The dot mirrors that state for as long as it lasts, across every worktree and session. Viewing the + * tab does not clear it: only resolving the attention does, by answering the prompt or running the + * session again. */ -internal class AgentAttention { - private var seen = emptySet() - - /** - * Whether the dot should be visible. [read] means the user is looking at the Agent Manager, - * which marks everything currently pending as seen — the rows carry the badge there. - */ - fun update(activity: Map, read: Boolean): Boolean { - val pending = activity.filterValues(::attention).keys - seen = if (read) pending else seen intersect pending - return (pending - seen).isNotEmpty() +internal fun sessionAttentionNeeded(activity: Map): Boolean = + activity.values.any { + it.kind == SessionActivityKindDto.QUESTION || + it.kind == SessionActivityKindDto.PLAN || + it.kind == SessionActivityKindDto.PERMISSION || + it.kind == SessionActivityKindDto.ERROR } -} - -/** Whether a session is waiting on the user or has failed. */ -private fun attention(item: SessionActivityDto): Boolean = - item.kind == SessionActivityKindDto.QUESTION || - item.kind == SessionActivityKindDto.PLAN || - item.kind == SessionActivityKindDto.PERMISSION || - item.kind == SessionActivityKindDto.ERROR diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt index cbe1d0766ba..14946e648ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt @@ -15,48 +15,27 @@ class AgentAttentionTest { SessionActivityKindDto.PERMISSION, SessionActivityKindDto.ERROR, )) { - assertTrue(AgentAttention().update(activity(kind), read = false), kind.name) + assertTrue(sessionAttentionNeeded(activity(kind)), kind.name) } } @Test fun `running and empty do not light up the dot`() { - assertFalse(AgentAttention().update(emptyMap(), read = false)) - assertFalse(AgentAttention().update(activity(SessionActivityKindDto.RUNNING), read = false)) + assertFalse(sessionAttentionNeeded(emptyMap())) + assertFalse(sessionAttentionNeeded(activity(SessionActivityKindDto.RUNNING))) } @Test - fun `attention already read stays clear after leaving the tab`() { - val attention = AgentAttention() - val errored = activity(SessionActivityKindDto.ERROR) - - assertFalse(attention.update(errored, read = true)) - // The error sticks in the snapshot until the session runs again; the dot must not come back. - assertFalse(attention.update(errored, read = false)) - assertFalse(attention.update(errored, read = false)) - } - - @Test - fun `attention arriving while the panel is unread lights the dot`() { - val attention = AgentAttention() - attention.update(activity(SessionActivityKindDto.ERROR), read = true) - - val another = mapOf( - "ses_1" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), - "ses_2" to SessionActivityDto("/repo/other", SessionActivityKindDto.QUESTION), + fun `one session needing attention lights the dot for the whole snapshot`() { + val mixed = mapOf( + "ses_running" to SessionActivityDto("/repo/a", SessionActivityKindDto.RUNNING), + "ses_failed" to SessionActivityDto("/repo/b", SessionActivityKindDto.ERROR), ) - assertTrue(attention.update(another, read = false)) - } - - @Test - fun `a session that recovers and fails again lights the dot again`() { - val attention = AgentAttention() - val errored = activity(SessionActivityKindDto.ERROR) - attention.update(errored, read = true) - - assertFalse(attention.update(emptyMap(), read = false)) - assertTrue(attention.update(errored, read = false)) + assertTrue(sessionAttentionNeeded(mixed)) + // Only resolving it clears the dot, however often the state is re-evaluated. + assertTrue(sessionAttentionNeeded(mixed)) + assertFalse(sessionAttentionNeeded(mixed - "ses_failed")) } private fun activity(kind: SessionActivityKindDto) = From 7427eedfd7c75c808ae051625507352c07d1e9b2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 17:27:31 +0200 Subject: [PATCH 34/50] perf(agent-manager): combine worktree diff summary scans --- .../src/agent-manager/local-diff.ts | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index 0594d299fca..c142bef0ee4 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -117,26 +117,29 @@ async function ancestor(git: GitOps, dir: string, base: string, log?: Log): Prom return result.stdout.trim() } -async function numstat(git: GitOps, dir: string, base: string, file?: string) { - const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base] - if (file) args.push("--", file) - const result = await git.execGit(args, dir) - const map = new Map() - if (result.code !== 0) return map - for (const line of result.stdout.trim().split("\n")) { - if (!line) continue +function counts(value: string) { + const result = new Map() + for (const line of value.trim().split("\n")) { + if (!line || line.startsWith(":")) continue const parts = line.split("\t") const add = parts[0] const del = parts[1] - const name = parts.slice(2).join("\t") - if (!name) continue - map.set(name, { + const file = parts.slice(2).join("\t") + if (!file) continue + result.set(file, { additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0, deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0, binary: add === "-" || del === "-", }) } - return map + return result +} + +async function numstat(git: GitOps, dir: string, base: string, file?: string) { + const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base] + if (file) args.push("--", file) + const result = await git.execGit(args, dir) + return counts(result.code === 0 ? result.stdout : "") } async function statStamp(dir: string, file: string): Promise { @@ -181,28 +184,28 @@ function statusFromCode(code: string): Status { } async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise { - const [nameStatus, counts, untracked] = await Promise.all([ - git.execGit(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc], dir), - numstat(git, dir, anc), + const [tracked, untracked] = await Promise.all([ + git.execGit(["-c", "core.quotepath=false", "diff", "--raw", "--numstat", "--no-renames", anc], dir), git.execGit(["ls-files", "--others", "--exclude-standard"], dir), ]) - if (nameStatus.code !== 0) { - log?.("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() }) + if (tracked.code !== 0) { + log?.("git diff --raw --numstat failed", { code: tracked.code, stderr: tracked.stderr.trim() }) return [] } const result: Meta[] = [] const seen = new Set() + const stats = counts(tracked.stdout) - for (const line of nameStatus.stdout.trim().split("\n")) { - if (!line) continue + for (const line of tracked.stdout.trim().split("\n")) { + if (!line.startsWith(":")) continue const parts = line.split("\t") - const code = parts[0] + const code = parts[0]?.split(" ").at(-1) const file = parts.slice(1).join("\t") if (!file || !code) continue seen.add(file) const status = statusFromCode(code) - const stat = counts.get(file) ?? { additions: 0, deletions: 0, binary: false } + const stat = stats.get(file) ?? { additions: 0, deletions: 0, binary: false } result.push({ file, additions: stat.additions, From 71869fd881d0044b82331fbc2d67cca1088b98cb Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 17:31:39 +0200 Subject: [PATCH 35/50] fix(agent-manager): preserve intentional sidebar scrolling --- .../unit/agent-manager-sidebar-scroll.test.ts | 25 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 2 -- .../agent-manager/sidebar-scroll.ts | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts index 1ed8e4b046b..7b9e8111b9c 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts @@ -75,6 +75,31 @@ describe("Agent Manager sidebar scroll preservation", () => { expect(worktrees.scrollTop).toBe(80) }) + it("does not override intentional selection scrolling", () => { + const el = list() + el.scrollTop = 240 + const preserve = preserver() + + preserve(() => { + el.scrollTop = 140 + }) + flush() + + expect(el.scrollTop).toBe(140) + }) + + it("keeps intentional scrolling from the top of the list", () => { + const el = list() + const preserve = preserver() + + preserve(() => { + el.scrollTop = 180 + }) + flush() + + expect(el.scrollTop).toBe(180) + }) + it("cancels stale restores when a newer state arrives", () => { const el = list() const preserve = preserver() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 9511e624a22..0b0ee261ffd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1096,7 +1096,6 @@ const AgentManagerContent: Component = () => { apply: (state) => applyActiveState(state), pruneLive: (ids) => projectLive.prune(ids), }) - const stateHandlers = createProjectStateHandlers({ setMulti: setMultiProject, setProjects: setProjectList, @@ -1113,7 +1112,6 @@ const AgentManagerContent: Component = () => { font: (font) => font && setTerminalFont(font), }) const preserveSidebarScroll = createSidebarScrollPreserver() - /** Apply the active-transition effects of a state payload (data already landed in the store). */ const applyActiveState = (state: AgentManagerStateMessage) => { const switched = applyProjectSwitch(state) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts index 7aa0abf2707..99dd8915ff1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts @@ -27,7 +27,7 @@ export function createSidebarScrollPreserver( inner = schedule(() => { inner = undefined for (const item of scrolls) { - if (item.el.isConnected) item.el.scrollTop = item.top + if (item.el.isConnected && item.top > 0 && item.el.scrollTop === 0) item.el.scrollTop = item.top } }) }) From 322426db17f9d9dc67992f4030e396bd0011dcb7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 11:38:22 -0400 Subject: [PATCH 36/50] fix(jetbrains): anchor session popups on the card, not the session edge Header popups pointed at the edge of the whole session, so a balloon for a card in the middle of a wide transcript was flung to the far side of the session and read as belonging to whatever panel it landed on. The pointer now lands on the edge of the card the popup describes, which keeps it attached to that card. Room is still measured against the window: cards are narrower than the session, so measuring inside the session would leave almost no width for a card in a split editor. Height still comes from the visible session, since a collapsed card header is only a couple of rows tall. --- .../session/ui/popup/HeaderPopupController.kt | 16 +-- .../session/ui/popup/HeaderPopupGeometry.kt | 37 ++++--- .../ui/popup/HeaderPopupGeometryTest.kt | 97 +++++++++++-------- 3 files changed, 89 insertions(+), 61 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt index 94bb2ff7782..4873f7c2971 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt @@ -120,7 +120,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { if (!onHeader && !onPopup) return hideAll() val req = view.headerPopup() ?: return hideAll() val built = req.build() - place(req.anchor, built)?.let { open(req, built, it) } ?: hideAll() + place(view, req.anchor, built)?.let { open(req, built, it) } ?: hideAll() } @RequiresEdt @@ -160,15 +160,16 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { } /** - * Resolves the pointer target beside the session chat, sizing the body to the space available on - * the chosen side and to the visible height of the chat. Anchoring on the chat rather than the - * hovered row is what keeps the popup off the transcript instead of covering the row the user is - * reading. + * Resolves the pointer target beside [card], the collapsible view the popup belongs to, sizing the + * body to the space available on the chosen side and to the visible height of the chat. Pointing at + * the card rather than the hovered row keeps the popup off the transcript instead of covering the + * row the user is reading, and pointing at the card rather than the session edge keeps the balloon + * attached to the thing it describes. * * Returns null when the chat is not on screen yet, in which case there is nothing to sit beside. */ @RequiresEdt - private fun place(anchor: JComponent, built: HeaderPopupBody): Spot? { + private fun place(card: JComponent, anchor: JComponent, built: HeaderPopupBody): Spot? { val pane = SwingUtilities.getRootPane(anchor)?.layeredPane val chat = ComponentUtil.getParentOfType(SessionRootPanel::class.java, anchor) // A showing anchor implies every ancestor, including the chat, is showing and laid out. @@ -184,7 +185,8 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { if (area.isEmpty) return null val spot = HeaderPopupGeometry.beside( pane = Rectangle(pane.size), - chat = area, + card = SwingUtilities.convertRectangle(card.parent, card.bounds, pane), + view = area, fit = HeaderPopupFit( chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow, chromeHeight = chromeHeight, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt index 212c6c37739..8fcd2529295 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt @@ -4,7 +4,7 @@ import com.intellij.openapi.ui.popup.Balloon import java.awt.Rectangle /** - * Where a header popup should sit relative to the session chat, and how large its body may be. + * Where a header popup should sit relative to its card, and how large its body may be. * * [x] is the pointer target in the same coordinate space the placement was computed in. */ @@ -33,7 +33,7 @@ internal data class HeaderPopupFit( /** * Geometry for header popups. Pure functions so the side and fit rules are testable without a frame. * - * Header popups only ever sit beside the chat, never over it and never above or below it. The fit part + * Header popups only ever sit beside their card, never over it and never above or below it. The fit part * is not cosmetic: `BalloonImpl.show` silently re-points a balloon to `BELOW`/`ABOVE` when the * requested rectangle does not fit inside the layered pane, so a body that overflows its side would * land in exactly the placement we are avoiding. Capping the body keeps the requested position. @@ -41,37 +41,42 @@ internal data class HeaderPopupFit( internal object HeaderPopupGeometry { /** - * Picks the side of [chat] with more room inside [pane] and the body box that fits there. + * Picks the side of [card] with more room inside [pane] and the body box that fits there. * - * [pane] only decides which side has room; the body is bounded by [chat] so the popup stays - * within the visible session view. + * The pointer lands on the edge of [card], the collapsible view the popup belongs to, so the + * balloon reads as attached to that card instead of docked to the far edge of the session. Room + * is still measured against [pane]: a card is narrower than the session, and cards near the + * middle of a split editor have almost no room beside them inside the session itself. + * + * [view] is the visible session and only budgets height. Using [card] there would collapse the + * body, since a collapsed card header is a couple of rows tall. */ - fun beside(pane: Rectangle, chat: Rectangle, fit: HeaderPopupFit): HeaderPopupPlacement { - val left = (chat.x - pane.x).coerceAtLeast(0) - val right = (pane.x + pane.width - (chat.x + chat.width)).coerceAtLeast(0) + fun beside(pane: Rectangle, card: Rectangle, view: Rectangle, fit: HeaderPopupFit): HeaderPopupPlacement { + val left = (card.x - pane.x).coerceAtLeast(0) + val right = (pane.x + pane.width - (card.x + card.width)).coerceAtLeast(0) // Ties go right: it matches reading direction and the common tool-window-on-the-left setup. val useRight = right >= left val room = (if (useRight) right else left) - fit.chromeWidth - fit.gap return HeaderPopupPlacement( position = if (useRight) Balloon.Position.atRight else Balloon.Position.atLeft, - x = if (useRight) chat.x + chat.width else chat.x, + x = if (useRight) card.x + card.width else card.x, maxWidth = room.coerceIn(0, fit.maxWidth), - // Height is budgeted against the chat, not the pane: the popup belongs to the session + // Height is budgeted against the session, not the pane: the popup belongs to the session // view, so it must not run past it into editor tabs or neighbouring tool windows. - maxHeight = (chat.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), + maxHeight = (view.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), ) } /** * Vertical pointer target for a body of [height], preferring [y] but keeping the balloon inside - * [chat]. The balloon centres its body on the target, so an unclamped target near an edge would + * [view]. The balloon centres its body on the target, so an unclamped target near an edge would * overflow and trigger the same re-pointing that [beside] avoids horizontally. */ - fun centerY(chat: Rectangle, y: Int, height: Int, gap: Int): Int { + fun centerY(view: Rectangle, y: Int, height: Int, gap: Int): Int { val half = height / 2 - val top = chat.y + gap + half - val bottom = chat.y + chat.height - gap - half - if (bottom < top) return chat.y + chat.height / 2 + val top = view.y + gap + half + val bottom = view.y + view.height - gap - half + if (bottom < top) return view.y + view.height / 2 return y.coerceIn(top, bottom) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt index a262e543190..c984f5ca478 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt @@ -16,25 +16,40 @@ class HeaderPopupGeometryTest { } @Test - fun `chat on the left points right`() { + fun `card on the left points right`() { // Tool window on the left: the editor area to its right is the roomier side. - val spot = beside(chat = Rectangle(0, 0, 300, 1000)) + val spot = beside(card = Rectangle(0, 0, 300, 40)) assertEquals(Balloon.Position.atRight, spot.position) assertEquals(300, spot.x) } @Test - fun `chat on the right points left`() { - val spot = beside(chat = Rectangle(1700, 0, 300, 1000)) + fun `card on the right points left`() { + val spot = beside(card = Rectangle(1700, 0, 300, 40)) assertEquals(Balloon.Position.atLeft, spot.position) assertEquals(1700, spot.x) } + @Test + fun `the pointer lands on the card edge, not the session edge`() { + // Left-docked chat: cards are inset from the session, so the balloon hugs the card at 760 + // rather than docking to the session edge at 800. + val spot = HeaderPopupGeometry.beside( + pane = Rectangle(0, 0, 2000, 1000), + card = Rectangle(60, 300, 700, 40), + view = Rectangle(0, 0, 800, 1000), + fit = fit(), + ) + + assertEquals(Balloon.Position.atRight, spot.position) + assertEquals(760, spot.x) + } + @Test fun `side with more room wins even when both sides fit`() { - val spot = beside(chat = Rectangle(1200, 0, 300, 1000)) + val spot = beside(card = Rectangle(1200, 0, 300, 40)) // Left room is 1200, right room is 500. assertEquals(Balloon.Position.atLeft, spot.position) @@ -43,14 +58,14 @@ class HeaderPopupGeometryTest { @Test fun `equal room points right`() { - val spot = beside(chat = Rectangle(850, 0, 300, 1000)) + val spot = beside(card = Rectangle(850, 0, 300, 40)) assertEquals(Balloon.Position.atRight, spot.position) } @Test fun `body is capped to the free space on the chosen side`() { - val spot = beside(chat = Rectangle(0, 0, 1800, 1000)) + val spot = beside(card = Rectangle(0, 0, 1800, 40)) // 200 free on the right, minus chrome and gap. assertEquals(200 - CHROME - GAP, spot.maxWidth) @@ -58,14 +73,14 @@ class HeaderPopupGeometryTest { @Test fun `body is capped to the shared max when the side is roomy`() { - val spot = beside(chat = Rectangle(0, 0, 300, 1000)) + val spot = beside(card = Rectangle(0, 0, 300, 40)) assertEquals(CAP, spot.maxWidth) } @Test - fun `a chat filling the pane yields no room rather than a negative width`() { - val spot = beside(chat = Rectangle(0, 0, 2000, 1000)) + fun `a card filling the pane yields no room rather than a negative width`() { + val spot = beside(card = Rectangle(0, 0, 2000, 40)) assertEquals(0, spot.maxWidth) } @@ -73,19 +88,15 @@ class HeaderPopupGeometryTest { @Test fun `chrome is reserved so the balloon still fits its side`() { // The side has 400px; a body of the full 400 would overflow once the balloon adds its border, - // pointer and shadow, and an overflowing balloon gets re-pointed above or below the chat. - val spot = beside(chat = Rectangle(0, 0, 1600, 1000)) + // pointer and shadow, and an overflowing balloon gets re-pointed above or below the card. + val spot = beside(card = Rectangle(0, 0, 1600, 40)) assertTrue(spot.maxWidth + CHROME <= 400) } @Test - fun `a chat with no usable room on either side still resolves to a horizontal side`() { - val tight = HeaderPopupGeometry.beside( - pane = Rectangle(0, 0, 2000, 1000), - chat = Rectangle(0, 0, 1980, 1000), - fit = fit(), - ) + fun `a card with no usable room on either side still resolves to a horizontal side`() { + val tight = beside(card = Rectangle(0, 0, 1980, 40)) // Neither side can fit the chrome, but above/below must never be the answer. assertTrue(tight.position == Balloon.Position.atRight || tight.position == Balloon.Position.atLeft) @@ -93,23 +104,25 @@ class HeaderPopupGeometryTest { } @Test - fun `height is capped to the chat minus gaps`() { + fun `height is capped to the session minus gaps`() { val short = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 200), - chat = Rectangle(0, 0, 300, 200), + card = Rectangle(0, 0, 300, 40), + view = Rectangle(0, 0, 300, 200), fit = fit(), ) - // 200 chat, minus both gaps and the chrome the balloon reserves vertically. + // 200 session, minus both gaps and the chrome the balloon reserves vertically. assertEquals(200 - GAP * 2 - CHROME_HEIGHT, short.maxHeight) } @Test - fun `height follows a short chat inside a tall pane`() { + fun `height follows a short session inside a tall pane`() { // Session in an editor tab or a short tool window: the window has room the session does not. val spot = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 1000), - chat = Rectangle(0, 100, 300, 300), + card = Rectangle(0, 100, 300, 40), + view = Rectangle(0, 100, 300, 300), fit = fit(), ) @@ -117,36 +130,44 @@ class HeaderPopupGeometryTest { } @Test - fun `pointer target keeps the body inside an offset chat`() { - val chat = Rectangle(0, 400, 300, 400) + fun `height follows the session even when the card is a collapsed header`() { + val spot = beside(card = Rectangle(0, 0, 300, 30)) - // Rows above and below the chat are pulled back into it. - assertEquals(560, HeaderPopupGeometry.centerY(chat, y = 0, height = 300, gap = GAP)) - assertEquals(640, HeaderPopupGeometry.centerY(chat, y = 1000, height = 300, gap = GAP)) + assertEquals(CAP_HEIGHT, spot.maxHeight) } @Test - fun `pointer target keeps a tall body inside the chat`() { - val chat = Rectangle(0, 0, 300, 1000) + fun `pointer target keeps the body inside an offset session`() { + val view = Rectangle(0, 400, 300, 400) + + // Rows above and below the session are pulled back into it. + assertEquals(560, HeaderPopupGeometry.centerY(view, y = 0, height = 300, gap = GAP)) + assertEquals(640, HeaderPopupGeometry.centerY(view, y = 1000, height = 300, gap = GAP)) + } + + @Test + fun `pointer target keeps a tall body inside the session`() { + val view = Rectangle(0, 0, 300, 1000) // Row near the top: target pushed down so the centred body clears the top edge. - assertEquals(310, HeaderPopupGeometry.centerY(chat, y = 20, height = 600, gap = GAP)) + assertEquals(310, HeaderPopupGeometry.centerY(view, y = 20, height = 600, gap = GAP)) // Row near the bottom: target pulled up. - assertEquals(690, HeaderPopupGeometry.centerY(chat, y = 980, height = 600, gap = GAP)) + assertEquals(690, HeaderPopupGeometry.centerY(view, y = 980, height = 600, gap = GAP)) // Row with room on both sides is left alone. - assertEquals(500, HeaderPopupGeometry.centerY(chat, y = 500, height = 600, gap = GAP)) + assertEquals(500, HeaderPopupGeometry.centerY(view, y = 500, height = 600, gap = GAP)) } @Test - fun `body taller than the chat is centred instead of clamped to an empty range`() { - val chat = Rectangle(0, 0, 300, 400) + fun `body taller than the session is centred instead of clamped to an empty range`() { + val view = Rectangle(0, 0, 300, 400) - assertEquals(200, HeaderPopupGeometry.centerY(chat, y = 10, height = 900, gap = GAP)) + assertEquals(200, HeaderPopupGeometry.centerY(view, y = 10, height = 900, gap = GAP)) } - private fun beside(chat: Rectangle) = HeaderPopupGeometry.beside( + private fun beside(card: Rectangle) = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 1000), - chat = chat, + card = card, + view = Rectangle(0, 0, 2000, 1000), fit = fit(), ) From 2a001759d29fb23c99aed55840a1aed42a5d6674 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 17:44:59 +0200 Subject: [PATCH 37/50] fix(vscode): isolate streamed parts across session stores --- .changeset/isolated-reasoning-streams.md | 5 ++ .../tests/unit/session-parts.test.ts | 61 ++++++++++++++++++- .../webview-ui/src/context/session-parts.ts | 9 ++- .../webview-ui/src/context/session.tsx | 10 +-- 4 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 .changeset/isolated-reasoning-streams.md diff --git a/.changeset/isolated-reasoning-streams.md b/.changeset/isolated-reasoning-streams.md new file mode 100644 index 00000000000..10d21a53450 --- /dev/null +++ b/.changeset/isolated-reasoning-streams.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent duplicate reasoning and response text while subagent sessions are open. diff --git a/packages/kilo-vscode/tests/unit/session-parts.test.ts b/packages/kilo-vscode/tests/unit/session-parts.test.ts index 16cebaf7ac3..1c951eb8da3 100644 --- a/packages/kilo-vscode/tests/unit/session-parts.test.ts +++ b/packages/kilo-vscode/tests/unit/session-parts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" -import { mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts" +import { createStore, produce } from "solid-js/store" +import { isolate, mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts" import type { Part } from "../../webview-ui/src/types/messages" function text(id: string, value: string, time: { start?: number; end?: number } = {}): Part { @@ -20,6 +21,39 @@ function value(parts: Part[], id: string) { return part.text } +describe("isolate", () => { + it("keeps shared reasoning snapshots independent across session stores", () => { + const shared = { + id: "r1", + messageID: "m1", + type: "reasoning", + text: "Thinking", + time: { start: 1 }, + } satisfies Part + const [first, setFirst] = createStore({ parts: [shared].map(isolate) }) + const [second, setSecond] = createStore({ parts: [shared].map(isolate) }) + + setFirst( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "reasoning") part.text += " once" + }), + ) + setSecond( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "reasoning") part.text += " once" + }), + ) + + expect(first.parts[0]?.type === "reasoning" && first.parts[0].text).toBe("Thinking once") + expect(second.parts[0]?.type === "reasoning" && second.parts[0].text).toBe("Thinking once") + expect(shared.text).toBe("Thinking") + }) +}) + describe("mergeParts", () => { it("keeps a final streamed tail part created after the reconcile snapshot started", () => { const parts = mergeParts( @@ -120,6 +154,31 @@ describe("mergeOptimisticPart", () => { expect(result.parts.map((part) => part.id)).toEqual(["client-text", "server-file"]) expect(result.replaced).toBe("client-file") }) + + it("keeps streamed deltas independent across session stores", () => { + const shared = text("server", "start") + const [first, setFirst] = createStore({ parts: mergeOptimisticPart([], new Set(), shared).parts }) + const [second, setSecond] = createStore({ parts: mergeOptimisticPart([], new Set(), shared).parts }) + + setFirst( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "text") part.text += " chunk" + }), + ) + setSecond( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "text") part.text += " chunk" + }), + ) + + expect(value(first.parts, "server")).toBe("start chunk") + expect(value(second.parts, "server")).toBe("start chunk") + expect(value([shared], "server")).toBe("start") + }) }) describe("sameParts", () => { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-parts.ts b/packages/kilo-vscode/webview-ui/src/context/session-parts.ts index 883a2b71483..b025feabd7d 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-parts.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-parts.ts @@ -1,5 +1,9 @@ import type { Part } from "../types/messages" +export function isolate(part: Part): Part { + return { ...part } +} + function stream(part: Part): part is Extract { return part.type === "text" || part.type === "reasoning" } @@ -27,10 +31,11 @@ export function sameParts(local: Part[] = [], snapshot: Part[] = []): boolean { export function mergeOptimisticPart(current: Part[], ids: ReadonlySet, part: Part) { const index = current.findIndex((item) => ids.has(item.id) && item.type === part.type) - if (index < 0) return { parts: [...current, part] } + const copy = isolate(part) + if (index < 0) return { parts: [...current, copy] } const old = current[index]! const next = current.slice() - next[index] = part + next[index] = copy return { parts: next, replaced: old.id } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 64c1753c5f7..54841acdae6 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -79,7 +79,7 @@ import { getAgentModel } from "./session-model-store" import { resolveMessagePrefs } from "./session-preferences" import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors" import { PartStash } from "./part-stash" -import { mergeOptimisticPart, mergeParts } from "./session-parts" +import { isolate, mergeOptimisticPart, mergeParts } from "./session-parts" import { mergeMessages, sameReconcileShape } from "./session-merge" import { state as todoState } from "./todo-revert" import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store" @@ -1457,7 +1457,7 @@ export const SessionProvider: ParentComponent = (props) => { const cutoff = Math.max(0, messages.length - 15) for (let i = 0; i < messages.length; i++) { const msg = messages[i]! - const parts = msg.parts ?? [] + const parts = msg.parts?.map(isolate) ?? [] if (mode === "reconcile" && store.parts[msg.id] && !optimisticParts.has(msg.id)) { const merged = mergeParts(store.parts[msg.id], parts, input.since ?? Number.POSITIVE_INFINITY) setStore("parts", msg.id, reconcile(merged, { key: "id" })) @@ -1546,7 +1546,7 @@ export const SessionProvider: ParentComponent = (props) => { if (message.parts && message.parts.length > 0) { optimisticParts.delete(message.id) stash.remove(message.id) - setStore("parts", message.id, message.parts) + setStore("parts", message.id, message.parts.map(isolate)) } rebuildToolParts(message.sessionID, store.messages[message.sessionID] ?? []) } @@ -1624,7 +1624,7 @@ export const SessionProvider: ParentComponent = (props) => { } } else { // Add new part - list.push(part) + list.push(isolate(part)) } }), ) @@ -2067,7 +2067,7 @@ export const SessionProvider: ParentComponent = (props) => { setStore("messages", key, messages) for (const msg of messages) { if (msg.parts && msg.parts.length > 0) { - setStore("parts", msg.id, msg.parts) + setStore("parts", msg.id, msg.parts.map(isolate)) } } rebuildToolParts(key, messages) From 2127b8b4ebb379ab5734dcf989e4817423655f13 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 11:46:29 -0400 Subject: [PATCH 38/50] fix(jetbrains): harden agent manager worktrees --- .changeset/jetbrains-worktree-safety.md | 5 + .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 4 + .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 82 +++++++-- .../backend/workspace/KiloBackendWorkspace.kt | 7 + .../backend/workspace/KiloWorkspaceState.kt | 1 + .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 174 ++++++++++++++++++ .../workspace/KiloBackendWorkspaceTest.kt | 69 +++++-- .../session/controller/SessionController.kt | 8 + .../resources/messages/KiloBundle.properties | 2 + .../agentManager/WorktreeControllerTest.kt | 19 ++ .../session/controller/ConnectionDelayTest.kt | 24 +++ .../kilocode/rpc/dto/KiloWorkspaceStateDto.kt | 1 + .../kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt | 1 + 13 files changed, 369 insertions(+), 28 deletions(-) create mode 100644 .changeset/jetbrains-worktree-safety.md diff --git a/.changeset/jetbrains-worktree-safety.md b/.changeset/jetbrains-worktree-safety.md new file mode 100644 index 00000000000..40e619f1b0c --- /dev/null +++ b/.changeset/jetbrains-worktree-safety.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep JetBrains Agent Manager worktrees in the main repository storage, prevent nested worktree deletion from removing child worktrees, and show a clear missing-folder error for deleted workspaces. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 4abe1975d67..15bfc066427 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -508,6 +508,10 @@ class KiloWorkspaceRpcApiImpl internal constructor( status = KiloWorkspaceStatusDto.UNSUPPORTED, error = state.reason, ) + is KiloWorkspaceState.Missing -> KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.MISSING, + error = state.path, + ) is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, error = state.message, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 44bd19f386f..dc5d0b9e1f6 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -83,9 +83,10 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val res = runGit(base, "worktree", "list", "--porcelain") if (!res.ok) return@withContext WorktreeListDto() val items = managedWorktrees(parseWorktreeList(res.stdout)) - val store = worktreeNameStore(items) - val state = store?.let { syncWorktreeState(it, worktreePaths(items)) } ?: WorktreeState() - val named = overlayWorktreeNames(items, state.names) + val alive = items.filter { it.main || Files.isDirectory(Path.of(it.path)) } + val store = worktreeNameStore(alive) + val state = store?.let { syncWorktreeState(it, worktreePaths(alive)) } ?: WorktreeState() + val named = overlayWorktreeNames(alive, state.names) WorktreeListDto(orderWorktrees(named, state.worktreeOrder)) } @@ -276,6 +277,14 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return Path.of(lines[0]).normalize() != Path.of(lines[1]).normalize() } + /** Main working tree for the repo containing [base]; falls back to [base] when git fails. */ + private fun mainWorktree(base: Path): Path { + val res = runGit(base, "worktree", "list", "--porcelain") + if (!res.ok) return base + val main = parseWorktreeList(res.stdout).firstOrNull { it.main } ?: return base + return Path.of(main.path).normalize() + } + override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto = withContext(Dispatchers.IO) { val base = Path.of(directory).normalize() @@ -312,7 +321,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { /** Runs `git worktree add` under `/.kilo/worktrees/` and records list bookkeeping. */ private fun addWorktree(base: Path, branch: String, existing: Boolean, baseRef: String?): CreateWorktreeResultDto { - val dir = base.resolve(".kilo").resolve("worktrees").resolve(branch.replace('/', '-')) + val root = mainWorktree(base) + val storage = root.resolve(".kilo").resolve("worktrees").normalize() + val parts = branch.split('/') + if (parts.any { it.isBlank() || it == "." || it == ".." }) return CreateWorktreeResultDto(error = "Invalid branch name") + val dir = storage.resolve(branch.replace('/', '-')).normalize() + if (dir.parent != storage) return CreateWorktreeResultDto(error = "Invalid branch name") Files.createDirectories(dir.parent) val args = buildList { addAll(listOf("worktree", "add")) @@ -327,7 +341,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } LOG.info("worktree add requested: branch=$branch existing=$existing base=${baseRef ?: "(current)"} dir=$dir") - val res = runGit(base, *args.toTypedArray()) + val res = add(base, args) if (!res.ok) { LOG.warn("worktree add failed: branch=$branch exit=${res.exit} stderr=${res.stderr.trim()}") return CreateWorktreeResultDto(error = res.stderr.ifBlank { "git worktree add failed" }) @@ -347,15 +361,37 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val base = Path.of(directory).normalize() LOG.info("worktree remove requested: path=$path branch=${branch ?: "(none)"} force=$force base=$base") val list = runGit(base, "worktree", "list", "--porcelain") - val store = (if (list.ok) worktreeNameStore(managedWorktrees(parseWorktreeList(list.stdout))) else null) - ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE) + if (!list.ok) return@withContext RemoveWorktreeResultDto(error = list.stderr.ifBlank { "git worktree list failed" }) + val all = parseWorktreeList(list.stdout) + val items = managedWorktrees(all) + val main = all.firstOrNull { it.main } + val storage = main?.let { Path.of(it.path).normalize().resolve(".kilo").resolve("worktrees").normalize() } + val target = all.firstOrNull { + val item = Path.of(it.path).normalize() + !it.main && samePath(it.path, path) && item.parent == storage + } + ?: return@withContext RemoveWorktreeResultDto(error = "Refusing to remove unmanaged worktree: $path") + val root = Path.of(path).normalize() + val nested = all.filter { + val item = Path.of(it.path).normalize() + !it.prunable && Files.isDirectory(item) && !samePath(it.path, path) && item.startsWith(root) + } + if (nested.isNotEmpty()) { + val names = nested.joinToString("\n") { it.path } + return@withContext RemoveWorktreeResultDto(error = "Delete nested worktrees first:\n$names") + } + val store = worktreeNameStore(items) ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE) // Force means the user accepted removing a locked worktree; unlock first so the plain // remove succeeds. Unlock fails harmlessly when the tree isn't actually locked. if (force) { - val unlock = runGit(base, "worktree", "unlock", path) + val unlock = runGit(base, "worktree", "unlock", target.path) if (!unlock.ok) LOG.info("worktree unlock skipped: path=$path exit=${unlock.exit} stderr=${unlock.stderr.trim()}") } - val res = runGit(base, "worktree", "remove", "--force", path) + val res = if (target.prunable || !Files.isDirectory(Path.of(target.path))) { + GitResult(0, "", "") + } else { + runGit(base, "worktree", "remove", "--force", target.path) + } if (!res.ok) { val locked = res.stderr.contains("locked working tree", ignoreCase = true) LOG.warn("worktree remove failed: path=$path locked=$locked exit=${res.exit} stderr=${res.stderr.trim()}") @@ -370,7 +406,11 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { if (!del.ok) LOG.warn("worktree branch delete failed: branch=$it exit=${del.exit} stderr=${del.stderr.trim()}") } LOG.info("worktree removed: path=$path branch=${branch ?: "(none)"}") - removeWorktreeState(store, path) + removeWorktreeState(store, target.path) + val prune = runGit(base, "worktree", "prune") + if (!prune.ok) LOG.warn("worktree prune failed: exit=${prune.exit} stderr=${prune.stderr.trim()}") + runCatching { service().workspaces.remove(target.path) } + .onFailure { err -> LOG.info("workspace cache eviction skipped: path=${target.path} message=${err.message}") } RemoveWorktreeResultDto(ok = true) } @@ -471,6 +511,20 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } + private fun add(base: Path, args: List): GitResult { + val first = runGit(base, *args.toTypedArray()) + if (first.ok || !stale(first.stderr)) return first + val prune = runGit(base, "worktree", "prune") + if (!prune.ok) LOG.warn("worktree prune before retry failed: exit=${prune.exit} stderr=${prune.stderr.trim()}") + return runGit(base, *args.toTypedArray()) + } + + private fun stale(text: String): Boolean { + return text.contains("is already checked out", ignoreCase = true) || + text.contains("already used by worktree", ignoreCase = true) || + text.contains("missing but already registered worktree", ignoreCase = true) + } + private suspend fun parallel(items: List, block: suspend (T) -> R): List = coroutineScope { val sem = Semaphore(4) items.map { item -> async { sem.withPermit { block(item) } } }.map { it.await() } @@ -637,16 +691,18 @@ internal fun parseWorktreeList(raw: String): List { var branch = "(detached)" var locked = false var lockReason: String? = null + var prunable = false var first = true fun flush() { val p = path ?: return val name = p.substringAfterLast('/').ifBlank { p } - out.add(WorktreeDto(p, name, branch, p, main = first, locked = locked, lockReason = lockReason)) + out.add(WorktreeDto(p, name, branch, p, main = first, locked = locked, lockReason = lockReason, prunable = prunable)) first = false path = null branch = "(detached)" locked = false lockReason = null + prunable = false } for (line in raw.lines()) { when { @@ -656,6 +712,7 @@ internal fun parseWorktreeList(raw: String): List { locked = true lockReason = line.removePrefix("locked").trim().takeIf { it.isNotEmpty() } } + line == "prunable" || line.startsWith("prunable ") -> prunable = true line.isBlank() -> flush() } } @@ -669,8 +726,9 @@ internal fun managedWorktrees(items: List): List { val storage = root.resolve(".kilo").resolve("worktrees").normalize() return items.filter { item -> if (item.main) return@filter true + if (item.prunable) return@filter false val path = Path.of(item.path).normalize() - path.startsWith(storage) && path != storage + path.parent == storage } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index cd700fc2094..c35d1e17a03 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import java.nio.file.Files +import java.nio.file.Path import java.util.concurrent.atomic.AtomicReference /** @@ -77,6 +79,11 @@ class KiloBackendWorkspace( _state.value = KiloWorkspaceState.Unsupported(reason) return@launch } + if (!Files.isDirectory(Path.of(directory))) { + log.info("Workspace directory is missing: $directory") + _state.value = KiloWorkspaceState.Missing(directory) + return@launch + } val progress = AtomicReference(KiloWorkspaceLoadProgress()) _state.value = KiloWorkspaceState.Loading(progress.get()) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index c9a543b1efc..239ba178c80 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -20,6 +20,7 @@ sealed class KiloWorkspaceState { val skills: List, ) : KiloWorkspaceState() data class Unsupported(val reason: String) : KiloWorkspaceState() + data class Missing(val path: String) : KiloWorkspaceState() data class Error(val message: String, val errors: List = emptyList()) : KiloWorkspaceState() } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index e19a9a386b1..dcdb5991ec2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -80,6 +80,26 @@ class KiloWorktreeRpcApiImplTest { assertEquals("Air Agent worktree", list[1].lockReason) } + @Test + fun `parseWorktreeList captures the prunable flag`() { + val raw = """ + worktree /repo + HEAD 1111111111111111111111111111111111111111 + branch refs/heads/main + + worktree /repo/.kilo/worktrees/hyper-video + HEAD 2222222222222222222222222222222222222222 + branch refs/heads/hyper-video + prunable gitdir file points to non-existent location + + """.trimIndent() + + val list = parseWorktreeList(raw) + + assertFalse(list[0].prunable, "main tree is not prunable") + assertTrue(list[1].prunable, "second tree should be flagged prunable") + } + @Test fun `managedWorktrees keeps only agent manager worktrees`() { val raw = """ @@ -124,6 +144,33 @@ class KiloWorktreeRpcApiImplTest { assertEquals(listOf("/repo"), list.map { it.path }) } + @Test + fun `managedWorktrees rejects nested and prunable worktrees`() { + val raw = """ + worktree /repo + HEAD 1111111111111111111111111111111111111111 + branch refs/heads/main + + worktree /repo/.kilo/worktrees/feature-x + HEAD 2222222222222222222222222222222222222222 + branch refs/heads/feature/x + + worktree /repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested + HEAD 3333333333333333333333333333333333333333 + branch refs/heads/nested + + worktree /repo/.kilo/worktrees/dead + HEAD 4444444444444444444444444444444444444444 + branch refs/heads/dead + prunable gitdir file points to non-existent location + + """.trimIndent() + + val list = managedWorktrees(parseWorktreeList(raw)) + + assertEquals(listOf("/repo", "/repo/.kilo/worktrees/feature-x"), list.map { it.path }) + } + @Test fun `classifyGhError detects missing and unauthorized gh states`() { assertEquals(GhAvailability.UNAUTH, classifyGhError("You are not logged into any GitHub hosts. Run gh auth login to authenticate.")) @@ -232,6 +279,44 @@ class KiloWorktreeRpcApiImplTest { assertFalse(after.any { it.branch == "feature/x" }, "removed worktree should be gone") } + @Test + fun `create from inside linked worktree uses main worktree storage`() = runBlocking { + initRepo() + val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + + val result = api.create(first.path, CreateWorktreeRequestDto("feature/y")) + val created = assertNotNull(result.worktree, "create failed: ${result.error}") + + assertEquals(repo.resolve(".kilo").resolve("worktrees").resolve("feature-y").toRealPath().toString(), created.path) + assertFalse( + Files.exists(Path.of(first.path).resolve(".kilo").resolve("worktrees").resolve("feature-y")), + "creating from a linked worktree must not nest storage inside it", + ) + } + + @Test + fun `create rejects a branch slug that escapes storage`() = runBlocking { + initRepo() + + val result = api.create(repo.toString(), CreateWorktreeRequestDto("../escape")) + + assertNull(result.worktree) + assertEquals("Invalid branch name", result.error) + assertFalse(Files.exists(repo.resolve(".kilo").resolve("escape"))) + } + + @Test + fun `create succeeds after pruning a deleted checked out branch`() = runBlocking { + initRepo() + val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + delete(Path.of(first.path)) + + val result = api.create(repo.toString(), CreateWorktreeRequestDto("feature/x", existingBranch = true)) + + val created = assertNotNull(result.worktree, "create should prune stale metadata and retry: ${result.error}") + assertTrue(Files.isDirectory(Path.of(created.path))) + } + @Test fun `create records order so reload keeps creation order`() = runBlocking { initRepo() @@ -359,6 +444,84 @@ class KiloWorktreeRpcApiImplTest { assertTrue(result.error != null, "failure should carry an error message") } + @Test + fun `remove refuses a path outside managed storage`() = runBlocking { + initRepo() + val outside = repo.resolve("outside") + Files.createDirectories(outside) + + val result = api.remove(repo.toString(), outside.toString(), null) + + assertFalse(result.ok) + assertTrue(result.error?.contains("Refusing") == true) + assertTrue(Files.isDirectory(outside), "unmanaged directory must not be touched") + } + + @Test + fun `remove refuses a worktree containing a live nested worktree`() = runBlocking { + initRepo() + val parent = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val nested = assertNotNull(api.create(parent.path, CreateWorktreeRequestDto("feature/y")).worktree) + val old = Path.of(parent.path).resolve(".kilo").resolve("worktrees").resolve("nested") + Files.createDirectories(old.parent) + git(parent.path, "worktree", "move", nested.path, old.toString()) + + val result = api.remove(repo.toString(), parent.path, parent.branch) + + assertFalse(result.ok) + assertTrue(result.error?.contains(old.toString()) == true, "error should name the blocker: ${result.error}") + assertTrue(Files.isDirectory(Path.of(parent.path))) + assertTrue(Files.isDirectory(old)) + } + + @Test + fun `remove succeeds when nested worktree directory is already gone`() = runBlocking { + initRepo() + val parent = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val nested = assertNotNull(api.create(parent.path, CreateWorktreeRequestDto("feature/y")).worktree) + val old = Path.of(parent.path).resolve(".kilo").resolve("worktrees").resolve("nested") + Files.createDirectories(old.parent) + git(parent.path, "worktree", "move", nested.path, old.toString()) + delete(old) + + val result = api.remove(repo.toString(), parent.path, parent.branch) + + assertTrue(result.ok, "remove should succeed despite dead nested metadata: ${result.error}") + assertFalse(Files.exists(Path.of(parent.path))) + } + + @Test + fun `remove prunes dangling metadata on success`() = runBlocking { + initRepo() + val dead = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("dead")).worktree) + delete(Path.of(dead.path)) + val live = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("live")).worktree) + + val result = api.remove(repo.toString(), live.path, live.branch) + + assertTrue(result.ok, "remove should succeed: ${result.error}") + val out = output(repo, "worktree", "list", "--porcelain") + assertFalse(out.contains(dead.path), "remove should prune unrelated dangling worktree metadata") + } + + @Test + fun `list drops missing worktrees and reconciles stored state`() = runBlocking { + initRepo() + val live = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("live")).worktree) + val dead = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("dead")).worktree) + assertNotNull(api.rename(repo.toString(), live.path, "Live").worktree) + assertNotNull(api.rename(repo.toString(), dead.path, "Dead").worktree) + delete(Path.of(dead.path)) + + val listed = api.list(repo.toString()).worktrees + + assertTrue(listed.any { it.path == live.path }) + assertFalse(listed.any { it.path == dead.path }) + val state = readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")) + assertEquals(mapOf(live.path to "Live"), state.names) + assertEquals(listOf(live.path), state.worktreeOrder) + } + @Test fun `listBranches returns local branches and the current one`() = runBlocking { initRepo() @@ -592,6 +755,17 @@ class KiloWorktreeRpcApiImplTest { assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}") } + private fun git(dir: String, vararg args: String) { + git(Path.of(dir), *args) + } + + private fun output(dir: Path, vararg args: String): String { + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile()) + val out = CapturingProcessHandler(cmd).runProcess(30_000) + assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}") + return out.stdout + } + private fun delete(dir: Path) { if (!Files.exists(dir)) return Files.walk(dir).use { paths -> diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index b3358b033bd..93227e52548 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -25,6 +25,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull +import java.nio.file.Files +import java.nio.file.Path import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -39,6 +41,8 @@ class KiloBackendWorkspaceTest { private val log = TestLog() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val apps = mutableListOf() + private val root: Path = Files.createTempDirectory("kilo-backend-workspace") + private val project: Path = Files.createDirectories(root.resolve("project")) @AfterTest fun tearDown() { @@ -48,6 +52,7 @@ class KiloBackendWorkspaceTest { scope.cancel() mock.close() withTimeout(10_000) { scope.coroutineContext[Job]?.join() } + delete(root) } } @@ -69,9 +74,11 @@ class KiloBackendWorkspaceTest { private suspend fun ready(app: KiloBackendAppService): KiloBackendWorkspace { connect(app) - return app.workspaces.get("/test/project") + return app.workspaces.get(project.toString()) } + private fun dir(name: String): String = Files.createDirectories(root.resolve(name)).toString() + private suspend fun loaded(ws: KiloBackendWorkspace) { withTimeout(15_000) { ws.state.first { it is KiloWorkspaceState.Ready } @@ -112,8 +119,9 @@ class KiloBackendWorkspaceTest { val app = setup() connect(app) - val ws1 = app.workspaces.get("/test") - val ws2 = app.workspaces.get("/test") + val path = dir("same") + val ws1 = app.workspaces.get(path) + val ws2 = app.workspaces.get(path) // LLM note: get() starts background loading; settle it so teardown is not racing active HTTP calls in CI. loaded(ws1) assertTrue(ws1 === ws2) @@ -124,14 +132,16 @@ class KiloBackendWorkspaceTest { val app = setup() connect(app) - val ws1 = app.workspaces.get("/project-a") - val ws2 = app.workspaces.get("/project-b") + val first = dir("project-a") + val second = dir("project-b") + val ws1 = app.workspaces.get(first) + val ws2 = app.workspaces.get(second) // LLM note: get() starts background loading; settle both loads before the scope-cancelling teardown. loaded(ws1) loaded(ws2) assertTrue(ws1 !== ws2) - assertEquals("/project-a", ws1.directory) - assertEquals("/project-b", ws2.directory) + assertEquals(first, ws1.directory) + assertEquals(second, ws2.directory) } @Test @@ -150,7 +160,7 @@ class KiloBackendWorkspaceTest { // Manager should throw since app is disconnected assertFailsWith { - app.workspaces.get("/test/project") + app.workspaces.get(project.toString()) } } @@ -187,7 +197,7 @@ class KiloBackendWorkspaceTest { connect(app) // get() creates workspace and starts loading immediately - val ws = app.workspaces.get("/test") + val ws = app.workspaces.get(dir("plain")) withTimeout(15_000) { ws.state.first { it is KiloWorkspaceState.Ready } @@ -211,7 +221,7 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers")) assertTrue(err.errors.any { it.resource == "providers" }) - assertTrue(log.messages.any { it.contains("Workspace error [/test/project]: Failed to load:") && it.contains("providers") }) + assertTrue(log.messages.any { it.contains("Workspace error [${project}]: Failed to load:") && it.contains("providers") }) } @Test @@ -298,6 +308,26 @@ class KiloBackendWorkspaceTest { assertEquals(0, mock.requestCount("/agent")) } + @Test + fun `missing directory transitions to Missing without fetching workspace data`() = runBlocking { + val app = setup() + connect(app) + mock.resetCounts() + val dir = Files.createTempDirectory("kilo-missing-workspace") + Files.delete(dir) + val ws = app.workspaces.get(dir.toString()) + + val state = withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Missing } + } as KiloWorkspaceState.Missing + + assertEquals(dir.toString(), state.path) + assertEquals(0, mock.requestCount("/agent")) + assertEquals(0, mock.requestCount("/provider")) + assertEquals(0, mock.requestCount("/command")) + assertEquals(0, mock.requestCount("/skill")) + } + @Test fun `commands failure transitions to Error`() = runBlocking { mock.commandsStatus = 500 @@ -446,7 +476,7 @@ class KiloBackendWorkspaceTest { @Test fun `workspace exposes sessions for its directory`() = runBlocking { mock.sessions = """[ - {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":1,"updated":1}} + {"id":"ses_1","slug":"s","projectID":"p","directory":"${project}","title":"T","version":"1","time":{"created":1,"updated":1}} ]""" val app = setup() val ws = ready(app) @@ -460,7 +490,7 @@ class KiloBackendWorkspaceTest { @Test fun `workspace maps missing session timestamps to zero`() = runBlocking { mock.sessions = """[ - {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}} + {"id":"ses_1","slug":"s","projectID":"p","directory":"${project}","title":"T","version":"1","time":{"created":null,"updated":null}} ]""" val app = setup() val ws = ready(app) @@ -473,14 +503,14 @@ class KiloBackendWorkspaceTest { @Test fun `workspace creates session in its directory`() = runBlocking { - mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}""" + mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"${project}","title":"New","version":"1","time":{"created":1,"updated":1}}""" val app = setup() val ws = ready(app) loaded(ws) val session = ws.createSession() assertEquals("ses_new", session.id) - assertEquals("/test/project", session.directory) + assertEquals(project.toString(), session.directory) } // ------ Concurrency tests ------ @@ -498,7 +528,7 @@ class KiloBackendWorkspaceTest { try { val results = (1..10).map { async(Dispatchers.Default) { - manager.get("/same/dir") + manager.get(dir("same-concurrent")) } }.awaitAll() @@ -572,7 +602,7 @@ class KiloBackendWorkspaceTest { ) withTimeout(15_000) { reload.await() } - val ws = app.workspaces.get("/test/project") + val ws = app.workspaces.get(project.toString()) assertTrue(ws !== initial) val state = withTimeout(15_000) { ws.state.first { @@ -646,4 +676,11 @@ class KiloBackendWorkspaceTest { {"name":"test-skill","description":"A test skill","location":"file:///test","content":"# Test"} ]""".trimIndent() } + + private fun delete(dir: Path) { + if (!Files.exists(dir)) return + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index d64c4c77673..0772c362721 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -2411,6 +2411,14 @@ class SessionController( ) } + if (workspace.status == KiloWorkspaceStatusDto.MISSING) { + return SessionControllerEvent.ConnectionChanged.ShowError( + KiloBundle.message("session.connection.missing"), + KiloBundle.message("session.connection.missing.detail", workspace.error ?: directory), + "workspace", + ) + } + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { return SessionControllerEvent.ConnectionChanged.ShowWarning( summary(app.warnings.size), diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index aeff0f994ae..a12c21fb71e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -13,6 +13,8 @@ session.connection.downloading.version=Downloading Kilo Core v{0} ({1})… {2}% session.connection.error.app=Connection failed session.connection.error.workspace=Workspace loading failed session.connection.error.unknown=Unknown error +session.connection.missing=Workspace folder missing +session.connection.missing.detail=Kilo can''t load this session because the workspace folder no longer exists: {0} session.connection.retry=Try again session.connection.unsupported=Workspace not supported session.connection.unsupported.devcontainer=Kilo runs on your host machine, so it can't reach the files inside this Dev Container. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index 9e1aca1bc63..f1030f2022f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -195,6 +195,25 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertTrue(failures.first().locked) } + fun `test refused nested remove keeps the row and surfaces the error`() { + val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") + rpc.listed += item + rpc.removeResult = { _, _, _ -> RemoveWorktreeResultDto(error = "Delete nested worktrees first:\n/repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested") } + val controller = controller() + controller.reload() + flush() + + val failures = mutableListOf() + controller.remove(controller.model.getElementAt(0), onFailure = { failures.add(it) }) + flush() + + assertEquals(1, controller.model.size) + assertEquals("feature/x", controller.model.getElementAt(0).branch) + assertNull(controller.progress(item.id)) + assertEquals(listOf(false), rpc.removeForces.toList()) + assertEquals("Delete nested worktrees first:\n/repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested", failures.single().error) + } + fun `test force remove passes the force flag and drops the row on success`() { val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x", locked = true) rpc.listed += item diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt index e64333062af..8d930b60771 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt @@ -193,6 +193,30 @@ class ConnectionDelayTest : SessionControllerTestBase() { ) } + fun `test missing workspace status shows missing folder message`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + projectRpc.state.value = KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.MISSING, + error = "/repo/.kilo/worktrees/deleted", + ) + pause(80) + + val event = events.filterIsInstance().single() + assertEquals("Workspace folder missing", event.summary) + assertEquals( + "Kilo can't load this session because the workspace folder no longer exists: /repo/.kilo/worktrees/deleted", + event.detail, + ) + assertEquals("workspace", event.source) + assertFalse(event.detail.orEmpty().contains("JetBrains Gateway")) + } + fun `test ready hides visible delayed connection banner immediately`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt index 487951a1121..aaf238e2f60 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -8,6 +8,7 @@ enum class KiloWorkspaceStatusDto { LOADING, READY, UNSUPPORTED, + MISSING, ERROR, } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt index b3a98d6d587..54ee736293d 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt @@ -11,6 +11,7 @@ data class WorktreeDto( val main: Boolean = false, // primary working tree — not deletable val locked: Boolean = false, // git worktree lock — blocks a plain remove val lockReason: String? = null, // optional reason recorded when the tree was locked + val prunable: Boolean = false, // git marks metadata stale because the directory is gone ) @Serializable From d81a0b9b89377c1a7983d4b61d8d52918c1d1ac1 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 17:50:10 +0200 Subject: [PATCH 39/50] fix(agent-manager): skip scroll restore on selection changes --- .../unit/agent-manager-sidebar-scroll.test.ts | 34 +++++++++++++++++-- .../agent-manager/AgentManagerApp.tsx | 2 +- .../agent-manager/sidebar-scroll.ts | 3 ++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts index 7b9e8111b9c..43c63160fc0 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts @@ -16,8 +16,8 @@ function cancel(id: number) { frames.delete(id) } -function preserver() { - return createSidebarScrollPreserver(window.document, schedule, cancel) +function preserver(active: () => string | null | undefined = () => undefined) { + return createSidebarScrollPreserver(active, window.document, schedule, cancel) } afterEach(() => { @@ -88,6 +88,36 @@ describe("Agent Manager sidebar scroll preservation", () => { expect(el.scrollTop).toBe(140) }) + it("does not restore when the selected worktree changes during the update", () => { + const el = list() + let selected = "first" + el.scrollTop = 240 + const preserve = preserver(() => selected) + + preserve(() => { + el.scrollTop = 0 + selected = "second" + }) + flush() + + expect(el.scrollTop).toBe(0) + }) + + it("does not restore when selection changes before the delayed frame", () => { + const el = list() + let selected = "first" + el.scrollTop = 240 + const preserve = preserver(() => selected) + + preserve(() => { + el.scrollTop = 0 + }) + selected = "second" + flush() + + expect(el.scrollTop).toBe(0) + }) + it("keeps intentional scrolling from the top of the list", () => { const el = list() const preserve = preserver() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 0b0ee261ffd..79c36b7639f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1111,7 +1111,7 @@ const AgentManagerContent: Component = () => { rename: setRenamingSection, font: (font) => font && setTerminalFont(font), }) - const preserveSidebarScroll = createSidebarScrollPreserver() + const preserveSidebarScroll = createSidebarScrollPreserver(() => selection() ?? session.currentSessionID()) /** Apply the active-transition effects of a state payload (data already landed in the store). */ const applyActiveState = (state: AgentManagerStateMessage) => { const switched = applyProjectSwitch(state) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts index 99dd8915ff1..7c81604c294 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts @@ -4,6 +4,7 @@ type Entry = { } export function createSidebarScrollPreserver( + active: () => string | null | undefined = () => undefined, root: ParentNode = document, schedule: typeof requestAnimationFrame = requestAnimationFrame, cancel: typeof cancelAnimationFrame = cancelAnimationFrame, @@ -15,6 +16,7 @@ export function createSidebarScrollPreserver( if (frame !== undefined) cancel(frame) if (inner !== undefined) cancel(inner) + const prior = active() const scrolls: Entry[] = [...root.querySelectorAll(".am-worktree-list, .am-projects-list")].map( (el) => ({ el, @@ -26,6 +28,7 @@ export function createSidebarScrollPreserver( frame = undefined inner = schedule(() => { inner = undefined + if (active() !== prior) return for (const item of scrolls) { if (item.el.isConnected && item.top > 0 && item.el.scrollTop === 0) item.el.scrollTop = item.top } From a5fcb33d62d1cb76792856f1dfaef712957c7e85 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 11:56:03 -0400 Subject: [PATCH 40/50] test(jetbrains): stop frontend tests opening a real browser ProvidersSettingsUiTest drove the real OAuth UI with a fixture URL of https://auth.openai.com/device, and ProvidersSettingsUi calls BrowserUtil.browse unstubbed. BrowserUtil is a static facade over the BrowserLauncher application service, so every run of the frontend suite launched an actual Chrome tab on the developer's machine. Replace BrowserLauncher with a recording fake via replaceService instead of adding a production seam, and assert the recorded URL so the browser handoff is verified rather than performed. Install the same fake in the GitHub/PR tests that reach other direct BrowserUtil call sites. --- .../agentManager/AgentManagerPanelTest.kt | 2 + .../agentManager/worktree/GhBannerTest.kt | 2 + .../worktree/GhStatusCoordinatorTest.kt | 2 + .../providers/ProvidersSettingsUiTest.kt | 3 ++ .../client/testing/FakeBrowserLauncher.kt | 37 +++++++++++++++++++ 5 files changed, 46 insertions(+) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index f436a11ab0d..8bc0864ed9c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -24,6 +24,7 @@ import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.testing.fire +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.ui.list.ActiveListBadge import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.ActiveListMetrics @@ -69,6 +70,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + installBrowser() coroutines = TestCoroutines() rpc = FakeWorktreeRpcApi() service = KiloWorktreeService(coroutines.scope, rpc) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt index 65282cabf0c..b878a7ba3ed 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import com.intellij.openapi.application.ApplicationManager @@ -22,6 +23,7 @@ class GhBannerTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + installBrowser() coroutines = TestCoroutines() rpc = FakeWorktreeRpcApi() timers = TestUiTimers() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt index c5f185a231c..309614052c2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import com.intellij.openapi.application.ApplicationManager @@ -21,6 +22,7 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + installBrowser() coroutines = TestCoroutines() rpc = FakeWorktreeRpcApi() timers = TestUiTimers() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 69911d94967..aed6ce37bca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.util.edtWait import ai.kilocode.client.app.KiloProviderService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.testing.FakeProviderRpcApi +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveListActionCell import ai.kilocode.client.ui.list.ActiveListConfig @@ -985,6 +986,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { fun `test provider oauth auto response shows device auth panel`() { val callback = CompletableDeferred() + val browser = installBrowser() val rpc = installProvider( ProviderSettingsDto( providers = listOf(provider("openai", "OpenAI")), @@ -1016,6 +1018,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { assertTrue(t, t.contains("Open Browser")) assertTrue(t, t.contains("Cancel")) assertEquals("https://auth.openai.com/device", fieldsByName(panel, "kilo.provider.oauth.url").single().text) + assertEquals(listOf("https://auth.openai.com/device"), browser.urls) val qr = components(panel).filterIsInstance().single { it.name == "kilo.provider.oauth.qr" } assertNotNull(qr.icon) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt new file mode 100644 index 00000000000..8870f98427b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt @@ -0,0 +1,37 @@ +package ai.kilocode.client.testing + +import com.intellij.ide.browsers.BrowserLauncher +import com.intellij.ide.browsers.WebBrowser +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService +import java.nio.file.Path + +class FakeBrowserLauncher : BrowserLauncher() { + val urls = mutableListOf() + val files = mutableListOf() + + override fun open(url: String) { + urls.add(url) + } + + @Suppress("DEPRECATION") + override fun browse(file: java.io.File) { + files.add(file.toPath()) + } + + override fun browse(file: Path) { + files.add(file) + } + + override fun browse(url: String, browser: WebBrowser?, project: Project?) { + urls.add(url) + } +} + +fun BasePlatformTestCase.installBrowser(): FakeBrowserLauncher { + val fake = FakeBrowserLauncher() + ApplicationManager.getApplication().replaceService(BrowserLauncher::class.java, fake, testRootDisposable) + return fake +} From 64ab942a147f3b8de98ea03a41c71240299298d9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 12:19:17 -0400 Subject: [PATCH 41/50] fix(jetbrains): keep shifted session popups pointing at the card Header popups now set the platform corner-to-pointer distance instead of moving the target point when a balloon body has to shift into the visible session. This keeps the arrow attached to the card or row the popup describes while still fitting the body in the viewport. The geometry tests cover top and bottom shifts, collapsed cards, offscreen fallback, and the platform-legal pointer distance range so the balloon keeps its pointer. --- .changeset/jetbrains-worktree-list-fixes.md | 2 +- .../session/ui/popup/HeaderPopupController.kt | 25 ++++-- .../session/ui/popup/HeaderPopupGeometry.kt | 47 +++++++++-- .../ui/popup/HeaderPopupGeometryTest.kt | 83 ++++++++++++++++--- 4 files changed, 127 insertions(+), 30 deletions(-) diff --git a/.changeset/jetbrains-worktree-list-fixes.md b/.changeset/jetbrains-worktree-list-fixes.md index 6127487e5da..83b48d0a9cf 100644 --- a/.changeset/jetbrains-worktree-list-fixes.md +++ b/.changeset/jetbrains-worktree-list-fixes.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, mark failed and waiting sessions on their worktree row and in session lists, keep the Agents tab notification dot up until every session that needs you is resolved, and keep session card popups inside the visible session view. +Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, mark failed and waiting sessions on their worktree row and in session lists, keep the Agents tab notification dot up until every session that needs you is resolved, and keep session card popups inside the visible session view while pointing at their card. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt index 4873f7c2971..38d9892c34f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt @@ -131,6 +131,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { .setBorderColor(UiStyle.Balloon.border()) .setBorderInsets(UiStyle.Balloon.insets()) .setPointerSize(UiStyle.Balloon.pointer()) + .setCornerToPointerDistance(spot.distance) .setCornerRadius(UiStyle.Balloon.arc()) .setHideOnClickOutside(true) .setHideOnKeyOutside(true) @@ -177,18 +178,19 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { val gap = UiStyle.Gap.pad() val insets = UiStyle.Balloon.insets() // The shadow is reserved on every side, so it counts twice on each axis. - val shadow = UiStyle.Balloon.shadow() * 2 - val chromeHeight = insets.top + insets.bottom + shadow + val shadow = UiStyle.Balloon.shadow() + val chromeHeight = insets.top + insets.bottom + shadow * 2 // The visible chat rect, not the whole panel: a session clipped by a short tool window or a // scrolled editor tab must keep its popups inside the part the user can actually see. val area = SwingUtilities.convertRectangle(chat, chat.visibleRect, pane) if (area.isEmpty) return null + val rect = SwingUtilities.convertRectangle(card.parent, card.bounds, pane) val spot = HeaderPopupGeometry.beside( pane = Rectangle(pane.size), - card = SwingUtilities.convertRectangle(card.parent, card.bounds, pane), + card = rect, view = area, fit = HeaderPopupFit( - chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow, + chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow * 2, chromeHeight = chromeHeight, gap = gap, maxWidth = JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH), @@ -197,11 +199,20 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { ) built.fitWithin(spot.maxWidth, spot.maxHeight) val row = SwingUtilities.convertPoint(anchor, Point(0, anchor.height / 2), pane) - val height = built.component.preferredSize.height + chromeHeight - return Spot(pane, Point(spot.x, HeaderPopupGeometry.centerY(area, row.y, height, gap)), spot.position) + val view = Rectangle(area.x, area.y + shadow, area.width, (area.height - shadow * 2).coerceAtLeast(0)) + val height = built.component.preferredSize.height + insets.top + insets.bottom + val aim = HeaderPopupGeometry.aim( + view = view, + card = rect, + y = row.y, + height = height, + gap = gap, + indent = UiStyle.Balloon.arc() + UiStyle.Balloon.pointer().width / 2, + ) + return Spot(pane, Point(spot.x, aim.y), spot.position, aim.distance) } - private class Spot(val pane: JComponent, val point: Point, val position: Balloon.Position) + private class Spot(val pane: JComponent, val point: Point, val position: Balloon.Position, val distance: Int) private companion object { const val SHOW_MS = 500 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt index 8fcd2529295..20b3ce80307 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt @@ -30,6 +30,11 @@ internal data class HeaderPopupFit( val maxHeight: Int, ) +/** + * Vertical pointer target and the distance from the balloon top to that target. + */ +internal data class HeaderPopupAim(val y: Int, val distance: Int) + /** * Geometry for header popups. Pure functions so the side and fit rules are testable without a frame. * @@ -68,15 +73,39 @@ internal object HeaderPopupGeometry { } /** - * Vertical pointer target for a body of [height], preferring [y] but keeping the balloon inside - * [view]. The balloon centres its body on the target, so an unclamped target near an edge would - * overflow and trigger the same re-pointing that [beside] avoids horizontally. + * Keeps the pointer on [card] while moving the balloon body into [view]. The returned [distance] + * is the value the platform uses as `cornerToPointerDistance`, which makes the body slide without + * moving the pointer target off the element it describes. */ - fun centerY(view: Rectangle, y: Int, height: Int, gap: Int): Int { - val half = height / 2 - val top = view.y + gap + half - val bottom = view.y + view.height - gap - half - if (bottom < top) return view.y + view.height / 2 - return y.coerceIn(top, bottom) + fun aim(view: Rectangle, card: Rectangle, y: Int, height: Int, gap: Int, indent: Int): HeaderPopupAim { + val hit = card.intersection(view) + if (hit.isEmpty) return fallback(view, height, gap, indent) + val pointer = clamp(y, hit.y + indent, hit.y + hit.height - indent) + val top = top(view, pointer, height, gap) + return HeaderPopupAim(y = pointer, distance = legal(pointer - top, height, indent)) + } + + private fun fallback(view: Rectangle, height: Int, gap: Int, indent: Int): HeaderPopupAim { + val y = view.y + view.height / 2 + val top = top(view, y, height, gap) + return HeaderPopupAim(y = y, distance = legal(y - top, height, indent)) + } + + private fun top(view: Rectangle, y: Int, height: Int, gap: Int): Int { + val min = view.y + gap + val max = view.y + view.height - gap - height + if (max < min) return view.y + (view.height - height) / 2 + return (y - height / 2).coerceIn(min, max) + } + + private fun legal(distance: Int, height: Int, indent: Int): Int { + val max = height - indent + if (max < indent) return height / 2 + return distance.coerceIn(indent, max) + } + + private fun clamp(value: Int, min: Int, max: Int): Int { + if (max < min) return min + (max - min) / 2 + return value.coerceIn(min, max) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt index c984f5ca478..39da002f2e5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt @@ -13,6 +13,7 @@ class HeaderPopupGeometryTest { const val GAP = 10 const val CAP = 700 const val CAP_HEIGHT = 450 + const val INDENT = 16 } @Test @@ -137,31 +138,77 @@ class HeaderPopupGeometryTest { } @Test - fun `pointer target keeps the body inside an offset session`() { - val view = Rectangle(0, 400, 300, 400) + fun `pointer stays on the row when the body already fits`() { + val aim = aim( + view = Rectangle(0, 0, 300, 1000), + card = Rectangle(0, 400, 300, 40), + y = 420, + height = 300, + ) - // Rows above and below the session are pulled back into it. - assertEquals(560, HeaderPopupGeometry.centerY(view, y = 0, height = 300, gap = GAP)) - assertEquals(640, HeaderPopupGeometry.centerY(view, y = 1000, height = 300, gap = GAP)) + assertEquals(420, aim.y) + assertEquals(150, aim.distance) } @Test - fun `pointer target keeps a tall body inside the session`() { + fun `body shifts down while the pointer stays on the top row`() { val view = Rectangle(0, 0, 300, 1000) + val aim = aim(view = view, card = Rectangle(0, 20, 300, 40), y = 40, height = 600) - // Row near the top: target pushed down so the centred body clears the top edge. - assertEquals(310, HeaderPopupGeometry.centerY(view, y = 20, height = 600, gap = GAP)) - // Row near the bottom: target pulled up. - assertEquals(690, HeaderPopupGeometry.centerY(view, y = 980, height = 600, gap = GAP)) - // Row with room on both sides is left alone. - assertEquals(500, HeaderPopupGeometry.centerY(view, y = 500, height = 600, gap = GAP)) + assertEquals(40, aim.y) + assertEquals(GAP, aim.y - aim.distance) + } + + @Test + fun `body shifts up while the pointer stays on the bottom row`() { + val view = Rectangle(0, 0, 300, 1000) + val aim = aim(view = view, card = Rectangle(0, 940, 300, 40), y = 960, height = 600) + + assertEquals(960, aim.y) + assertEquals(view.y + view.height - GAP, aim.y - aim.distance + 600) + } + + @Test + fun `pointer stays inside a collapsed card`() { + val card = Rectangle(0, 100, 300, 30) + val aim = aim(view = Rectangle(0, 0, 300, 1000), card = card, y = 115, height = 300) + + assertTrue(card.contains(0, aim.y)) + assertTrue(aim.distance in INDENT..300 - INDENT) + } + + @Test + fun `card outside the visible session falls back to the view centre`() { + val aim = aim( + view = Rectangle(0, 400, 300, 400), + card = Rectangle(0, 0, 300, 40), + y = 20, + height = 300, + ) + + assertEquals(600, aim.y) + assertEquals(150, aim.distance) } @Test fun `body taller than the session is centred instead of clamped to an empty range`() { val view = Rectangle(0, 0, 300, 400) + val aim = aim(view = view, card = Rectangle(0, 0, 300, 40), y = 20, height = 900) - assertEquals(200, HeaderPopupGeometry.centerY(view, y = 10, height = 900, gap = GAP)) + assertEquals(20, aim.y) + assertEquals(-250, aim.y - aim.distance) + assertTrue(aim.distance in INDENT..900 - INDENT) + } + + @Test + fun `pointer distance stays in the platform legal window`() { + listOf( + aim(view = Rectangle(0, 0, 300, 200), card = Rectangle(0, 0, 300, 30), y = 15, height = 160) to 160, + aim(view = Rectangle(0, 0, 300, 200), card = Rectangle(0, 170, 300, 30), y = 185, height = 160) to 160, + aim(view = Rectangle(0, 0, 300, 200), card = Rectangle(0, 80, 300, 40), y = 100, height = 500) to 500, + ).forEach { pair -> + assertTrue(pair.first.distance in INDENT..pair.second - INDENT) + } } private fun beside(card: Rectangle) = HeaderPopupGeometry.beside( @@ -178,4 +225,14 @@ class HeaderPopupGeometryTest { maxWidth = CAP, maxHeight = CAP_HEIGHT, ) + + private fun aim(view: Rectangle, card: Rectangle, y: Int, height: Int) = HeaderPopupGeometry.aim( + view = view, + card = card, + y = y, + height = height, + gap = GAP, + indent = INDENT, + ) + } From a6a6a3aca63765e18c711297821e40bf1a8143a4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 13:27:02 -0400 Subject: [PATCH 42/50] fix(jetbrains): resolve worktree paths and evict cache canonically --- .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 13 ++++++---- .../workspace/KiloBackendWorkspaceManager.kt | 24 +++++++++++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index dc5d0b9e1f6..b339ab36eaf 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -371,10 +371,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { !it.main && samePath(it.path, path) && item.parent == storage } ?: return@withContext RemoveWorktreeResultDto(error = "Refusing to remove unmanaged worktree: $path") - val root = Path.of(path).normalize() + // Compare canonical (symlink-resolved) paths: on macOS the temp/repo root is a symlink + // (/var -> /private/var), so a raw startsWith against normalized porcelain paths would miss + // a live child and let `git worktree remove --force` delete it recursively. + val root = realPath(path) val nested = all.filter { - val item = Path.of(it.path).normalize() - !it.prunable && Files.isDirectory(item) && !samePath(it.path, path) && item.startsWith(root) + !it.prunable && Files.isDirectory(Path.of(it.path)) && !samePath(it.path, path) && realPath(it.path).startsWith(root) } if (nested.isNotEmpty()) { val names = nested.joinToString("\n") { it.path } @@ -387,7 +389,10 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val unlock = runGit(base, "worktree", "unlock", target.path) if (!unlock.ok) LOG.info("worktree unlock skipped: path=$path exit=${unlock.exit} stderr=${unlock.stderr.trim()}") } - val res = if (target.prunable || !Files.isDirectory(Path.of(target.path))) { + // Only skip git's own removal when the checkout directory is actually gone. Git also flags a + // worktree prunable when its admin metadata is stale while the files remain; those must still + // be deleted so a later create of the same slug is not blocked by leftovers. + val res = if (!Files.isDirectory(Path.of(target.path))) { GitResult(0, "", "") } else { runGit(base, "worktree", "remove", "--force", target.path) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt index 93ec913349d..d7f5da9b112 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -8,6 +8,8 @@ import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow import okhttp3.OkHttpClient +import java.nio.file.Files +import java.nio.file.Path import java.util.concurrent.ConcurrentHashMap /** @@ -74,8 +76,26 @@ class KiloBackendWorkspaceManager( } } - /** Remove a workspace (e.g. when a worktree is deleted). */ + /** + * Remove any cached workspace whose directory resolves to the same real path as [dir]. + * Callers pass git porcelain paths, while workspaces are often keyed by the resolved + * (`toRealPath`) path or the IDE base path, so an exact-string match would miss the entry + * and leave a deleted worktree cached as Ready — still producing backend errors. + */ fun remove(dir: String) { - workspaces.remove(dir)?.stop() + val target = canonical(dir) + workspaces.keys.filter { canonical(it) == target }.forEach { key -> + log.info("Removing cached workspace for $key") + workspaces.remove(key)?.stop() + } + } + + /** Resolve symlinks on the parent so `/var/...` and `/private/var/...` compare equal even after the leaf is deleted. */ + private fun canonical(dir: String): String { + val path = Path.of(dir).normalize() + val parent = path.parent ?: return path.toString() + val name = path.fileName ?: return path.toString() + val root = runCatching { if (Files.exists(parent)) parent.toRealPath() else parent }.getOrDefault(parent) + return root.resolve(name).toString() } } From e1e0f7538142b9c86e09d7af6e5c803acac37db3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 13:50:46 -0400 Subject: [PATCH 43/50] fix(jetbrains): plain worktree labels, quieter icons, prune deleted session status Render the Agent Manager worktree list titles and section header in normal weight and soften the idle worktree glyphs to a mid-tone neutral. Tint only monochrome row icons to the selection foreground so colored status icons (running/question/error) keep their hue. Prune a deleted session's lingering question/error status locally so the session list, worktree list, and tab attention dot re-evaluate instead of showing a stale badge. --- .changeset/plain-worktree-headers.md | 5 + .../client/agentManager/AgentManagerPanel.kt | 8 +- .../agentManager/worktree/WorktreeIcons.kt | 3 + .../worktree/WorktreeSessionEditorPanel.kt | 3 +- .../kilocode/client/app/KiloSessionService.kt | 21 +++- .../client/ui/list/ActiveListModel.kt | 17 ++- .../client/ui/list/ActiveListRenderer.kt | 37 +++++-- .../main/resources/icons/worktree-local.svg | 4 +- .../resources/icons/worktree-local_dark.svg | 4 +- .../main/resources/icons/worktreeBranch.svg | 2 +- .../resources/icons/worktreeBranch_dark.svg | 2 +- .../src/main/resources/icons/worktreeLock.svg | 2 +- .../resources/icons/worktreeLock_dark.svg | 2 +- .../agentManager/AgentManagerPanelTest.kt | 34 ++++++ .../client/app/KiloSessionServiceTest.kt | 16 +++ .../settings/base/SettingsListViewTest.kt | 100 +++++++++++++++++- 16 files changed, 231 insertions(+), 29 deletions(-) create mode 100644 .changeset/plain-worktree-headers.md diff --git a/.changeset/plain-worktree-headers.md b/.changeset/plain-worktree-headers.md new file mode 100644 index 00000000000..ee268b38d64 --- /dev/null +++ b/.changeset/plain-worktree-headers.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render Agent Manager worktree list labels in normal weight with quieter idle icons, tint monochrome row icons to the selection foreground while leaving status icons colored, and clear a deleted session's question/error status from the session list, worktree list, and tab attention dot. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index fd8410e51f2..c575dcb7c39 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -35,6 +35,7 @@ import ai.kilocode.client.ui.list.ActiveListMetrics import ai.kilocode.client.ui.list.ActiveListReorder import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListSurface +import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.rpc.dto.RemoveWorktreeResultDto @@ -95,7 +96,11 @@ class AgentManagerPanel( private val group = ActionManager.getInstance().getAction("Kilo.Worktree.RowMenu") as? ActionGroup ?: DefaultActionGroup() private val list = ActiveList( KiloBundle.message("worktree.empty"), - cfg = ActiveListConfig(hoverActions = true), + cfg = ActiveListConfig( + hoverActions = true, + title = ActiveListWeight.PLAIN, + header = ActiveListWeight.PLAIN, + ), surface = ActiveListSurface.ToolWindow, showSearch = false, onCell = { _, _ -> }, @@ -494,6 +499,7 @@ class AgentManagerPanel( override val description: String get() = WorktreeTitle.fallback(dto.path) override val tooltip: String? get() = null override val icon = WorktreeIcons.forRow(progress != null, kind, dto.locked, current) + override val tinted: Boolean get() = WorktreeIcons.neutral(icon) override val section: String? get() = if (current) null else KiloBundle.message("worktree.section.local") override val search: String get() = listOfNotNull(dto.name, dto.branch, dto.path, dto.lockReason).joinToString(" ") private val customName: String? get() = WorktreeTitle.custom(dto.name, dto.path) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt index 42272b58a2c..d34b9fa8dec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt @@ -46,4 +46,7 @@ internal object WorktreeIcons { } } } + + /** The monochrome at-rest glyphs that follow the row text color; status icons are excluded. */ + fun neutral(icon: Icon?): Boolean = icon === local || icon === locked || icon === branch } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index 5508a03eb2d..234f3ad5969 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -23,6 +23,7 @@ import ai.kilocode.client.ui.list.ActiveListMenu import ai.kilocode.client.ui.list.ActiveListRowHeight import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListSurface +import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.vfs.KiloVfsManager @@ -95,7 +96,7 @@ class WorktreeSessionEditorPanel( description = false, selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, hoverActions = true, - bold = false, + title = ActiveListWeight.PLAIN, ), surface = ActiveListSurface.ToolWindow, showSearch = false, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 93f0a9968fd..422859cd886 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -35,10 +35,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -69,13 +71,21 @@ class KiloSessionService internal constructor( private val _sessions = MutableStateFlow>(emptyList()) val sessions: StateFlow> = _sessions.asStateFlow() - /** Live session status map from SSE events. */ - val statuses: StateFlow> = - stream { statuses() }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) + // Sessions deleted this run. The backend does not always emit a status/activity clear for a + // session left in a waiting or failed state, so a deleted question/error entry would otherwise + // linger and keep its badge on the session list, worktree list, and tab attention dot. Pruning + // it locally forces every derived status to re-evaluate the moment the delete resolves. + private val removed = MutableStateFlow>(emptySet()) - /** Live session activity map from backend global events. */ + /** Live session status map from SSE events, minus sessions deleted this run. */ + val statuses: StateFlow> = + combine(stream { statuses() }, removed) { map, gone -> map - gone } + .stateIn(cs, SharingStarted.Eagerly, emptyMap()) + + /** Live session activity map from backend global events, minus sessions deleted this run. */ val activity: StateFlow> = - stream { activity() }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) + combine(stream { activity() }, removed) { map, gone -> map - gone } + .stateIn(cs, SharingStarted.Eagerly, emptyMap()) /** * Session create/update/delete across every directory the CLI serves, including sessions @@ -165,6 +175,7 @@ class KiloSessionService internal constructor( log.info("${ChatLogSummary.sid(id)} kind=session delete=true dir=${ChatLogSummary.dir(dir)}") call { delete(id, dir) } log.info("${ChatLogSummary.sid(id)} kind=session delete=true ok=true dir=${ChatLogSummary.dir(dir)}") + removed.update { it + id } list(dir) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index adc42a6b2ec..ebe68d1584a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -32,6 +32,8 @@ internal data class ActiveListMetrics( internal enum class ActiveListRowHeight { EQUAL, PREFERRED } +internal enum class ActiveListWeight { PLAIN, BOLD } + internal data class ActiveListConfig( val height: ActiveListRowHeight = ActiveListRowHeight.EQUAL, val description: Boolean = true, @@ -39,7 +41,12 @@ internal data class ActiveListConfig( val tooltip: Boolean = true, val selection: Int = ListSelectionModel.SINGLE_SELECTION, val hoverActions: Boolean = false, - val bold: Boolean = true, + /** Weight used for the primary row title. */ + val title: ActiveListWeight = ActiveListWeight.BOLD, + /** Weight used for section headers. */ + val header: ActiveListWeight = ActiveListWeight.BOLD, + /** Show a separator line above section headers, except above the first row. */ + val divider: Boolean = true, ) { companion object { val Equal = ActiveListConfig(ActiveListRowHeight.EQUAL) @@ -85,7 +92,7 @@ internal interface ActiveListHitCell { /** * A row in an [ActiveList]. Carries the display contract shared by settings pages, the worktree * list, and the session history stack: a leading icon, a title whose weight follows - * [ActiveListConfig.bold] with an inline [note], a secondary [description] line, inline [badges], + * [ActiveListConfig.title] with an inline [note], a secondary [description] line, inline [badges], * optional right-aligned [trailing] text, and action [cells]. Action cells are shown only for the * active focused selection unless [ActiveListCell.alwaysVisible] is true. */ @@ -103,6 +110,12 @@ internal interface ActiveListItem { val tooltip: String? get() = description val doubleClick: String? get() = null val icon: Icon? get() = null + /** + * Recolor [icon] to the row foreground when the row is the focused selection. Enable it only for + * monochrome glyphs that should read as part of the highlighted text; leave it off for colored + * status icons (running, question, error) so they keep their own hue. + */ + val tinted: Boolean get() = false val section: String? get() = null val badges: List get() = emptyList() /** Right-aligned secondary text, such as a relative timestamp. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index dc0d8cd535e..7d7c1fcebd7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -12,9 +12,11 @@ import ai.kilocode.client.ui.layout.align import com.intellij.icons.AllIcons import com.intellij.ui.CollectionListModel import com.intellij.ui.GroupHeaderSeparator +import com.intellij.ui.RelativeFont import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel +import com.intellij.util.IconUtil import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -197,14 +199,7 @@ internal class ActiveListRenderer( background = list.background top.background = list.background wrap.update(list, selected, active) - sep.caption = section - sep.setHideLine(index == 0) - top.isVisible = section != null - top.setPreferredSize(section?.let { - val height = sep.preferredSize.height - .coerceAtLeast(sep.getFontMetrics(sep.font).height + insets.top + insets.bottom) - Dimension(0, height + JBUI.scale(2)) - }) + syncHeader(section, index) if (value is ActiveListGap) { gap = true @@ -221,14 +216,17 @@ internal class ActiveListRenderer( title.clear() // Bold carries most rows by default: the description under it and the icon beside it both - // render in the muted secondary color, so weight separates the two lines when enabled. - val style = if (cfg.bold) SimpleTextAttributes.STYLE_BOLD else SimpleTextAttributes.STYLE_PLAIN + // render in the muted secondary color, so cfg.title separates the two lines when enabled. + val style = if (cfg.title == ActiveListWeight.BOLD) SimpleTextAttributes.STYLE_BOLD else SimpleTextAttributes.STYLE_PLAIN title.append(value.title, SimpleTextAttributes(style, titleFg)) value.note?.takeIf { it.isNotBlank() }?.let { title.append(" $it", SimpleTextAttributes.GRAYED_ATTRIBUTES) } syncBadges(value) - icon.icon = value.icon + // A selected row paints its title in the selection foreground; recolor a tinted glyph to + // match so it reads as part of the highlighted text. Colored status icons opt out and keep + // their own hue. + icon.icon = value.icon?.let { if (active && value.tinted) IconUtil.colorize(it, fg, keepBrightness = false) else it } mark.isVisible = value.icon != null val note = if (cfg.description) value.description.orEmpty() else "" desc.text = note @@ -268,6 +266,23 @@ internal class ActiveListRenderer( return this } + private fun syncHeader(section: String?, index: Int) { + sep.caption = section + sep.setHideLine(!cfg.divider || index == 0) + val font = if (cfg.header == ActiveListWeight.BOLD) { + RelativeFont.BOLD.derive(sep.font) + } else { + RelativeFont.PLAIN.derive(sep.font) + } + if (sep.font != font) sep.font = font + top.isVisible = section != null + top.setPreferredSize(section?.let { + val height = sep.preferredSize.height + .coerceAtLeast(sep.getFontMetrics(sep.font).height + insets.top + insets.bottom) + Dimension(0, height + JBUI.scale(2)) + }) + } + override fun paintChildren(g: Graphics) { super.paintChildren(g) if (!gap) return diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg index c5481bc9297..d1ce9fbc689 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg @@ -1,4 +1,4 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg index d40fa8f60f4..8766d725434 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg @@ -1,4 +1,4 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg index 58184b7a91b..453719a6b27 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg index 00357612820..0f09a7c6881 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg index bf088c0803d..75e8439d11b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg index d679769eb60..9037685be8c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 79716e0534f..81ad5a82961 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -51,9 +51,13 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.SearchTextField import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container import java.awt.event.MouseEvent import java.awt.Point import javax.swing.JComponent @@ -182,6 +186,26 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertEquals(0, edt { scroll.viewportBorder.getBorderInsets(scroll).top }) } + fun `test worktree list renders row titles in plain weight`() { + rpc.listed += worktree("aardvark") + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + flush() + + @Suppress("UNCHECKED_CAST") + val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! as JBList } + val title = edt { + val row = list.model.getElementAt(0) + val comp = list.cellRenderer.getListCellRendererComponent(list, row, 0, false, false) + components(comp).filterIsInstance().single() + } + val iter = title.iterator() + iter.next() + + assertEquals(SimpleTextAttributes.STYLE_PLAIN, iter.textAttributes.style) + } + fun `test clicking a worktree opens the worktree session editor`() { val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x") rpc.listed += item @@ -849,6 +873,16 @@ class AgentManagerPanelTest : BasePlatformTestCase() { return edt { list.model.getElementAt(idx) as ActiveListItem } } + private fun components(root: Component): List { + val out = mutableListOf() + fun visit(item: Component) { + out += item + if (item is Container) item.components.forEach { visit(it) } + } + visit(root) + return out + } + private fun center(rect: java.awt.Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) private fun pump() = pumpEdt() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt index 2acea37fbad..dd1c2c89f54 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt @@ -163,6 +163,22 @@ class KiloSessionServiceTest : BasePlatformTestCase() { ) } + fun `test deleting a session prunes its lingering activity and status entries`() = runBlocking(Dispatchers.Default) { + rpc.statuses.value = mapOf("ses_asking" to SessionStatusDto("busy")) + rpc.activity.value = mapOf( + "ses_asking" to SessionActivityDto("/repo/wt", SessionActivityKindDto.QUESTION), + "ses_failed" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), + ) + service.activity.first { it.size == 2 } + + // The backend keeps reporting the question/error for a deleted session, so the entry must be + // pruned locally or the badge lingers on every derived surface. + service.deleteSession("ses_asking", "/repo/wt") + service.activity.first { "ses_asking" !in it } + + assertEquals(mapOf("ses_failed" to SessionActivityKind.ERROR), service.activitySnapshot()) + } + private fun session(id: String, title: String) = SessionDto( id = id, projectID = "prj", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 2837e0e58d0..aa911be2483 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -19,6 +19,7 @@ import ai.kilocode.client.ui.list.ActiveListRenderer import ai.kilocode.client.ui.list.ActiveListRowHeight import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListView +import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_MENU_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL @@ -30,6 +31,7 @@ import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.application.ApplicationManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.CollectionListModel +import com.intellij.ui.GroupHeaderSeparator import com.intellij.ui.ScrollingUtil import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes @@ -217,6 +219,61 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test renderer draws the row title in plain weight when configured`() { + edt { + val row = item("with", "Alpha", "Description") + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal.copy(title = ActiveListWeight.PLAIN)) + + renderer.getListCellRendererComponent(list, row, 0, true, true) + + val title = components(renderer).filterIsInstance().single() + val iter = title.iterator() + iter.next() + assertEquals(SimpleTextAttributes.STYLE_PLAIN, iter.textAttributes.style) + assertEquals("Alpha", iter.fragment) + } + } + + fun `test renderer styles section header weight from config`() { + edt { + val first = sectionItem("one", "Alpha", "Local") + val second = sectionItem("two", "Beta", "Remote") + val model = CollectionListModel(listOf(first, second)) + val list = JBList(model) + val bold = ActiveListRenderer(model, ActiveListConfig.Equal) + val plain = ActiveListRenderer(model, ActiveListConfig.Equal.copy(header = ActiveListWeight.PLAIN)) + + bold.getListCellRendererComponent(list, second, 1, false, false) + plain.getListCellRendererComponent(list, second, 1, false, false) + + assertTrue(components(bold).filterIsInstance().single().font.isBold) + assertFalse(components(plain).filterIsInstance().single().font.isBold) + } + } + + fun `test renderer reads section divider visibility from config`() { + edt { + val first = sectionItem("one", "Alpha", "Local") + val second = sectionItem("two", "Beta", "Remote") + val model = CollectionListModel(listOf(first, second)) + val list = JBList(model) + val divider = ActiveListRenderer(model, ActiveListConfig.Equal) + val none = ActiveListRenderer(model, ActiveListConfig.Equal.copy(divider = false)) + + divider.getListCellRendererComponent(list, first, 0, false, false) + assertTrue(components(divider).filterIsInstance().single().isHideLine) + divider.getListCellRendererComponent(list, second, 1, false, false) + assertFalse(components(divider).filterIsInstance().single().isHideLine) + + none.getListCellRendererComponent(list, first, 0, false, false) + assertTrue(components(none).filterIsInstance().single().isHideLine) + none.getListCellRendererComponent(list, second, 1, false, false) + assertTrue(components(none).filterIsInstance().single().isHideLine) + } + } + fun `test narrow row squeezes title but keeps tags full width`() { edt { val row = object : ActiveListItem { @@ -254,7 +311,7 @@ class SettingsListViewTest : BasePlatformTestCase() { val list = JBList(model) val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) - renderer.getListCellRendererComponent(list, row, 0, true, true) + renderer.getListCellRendererComponent(list, row, 0, false, false) renderer.setSize(320, renderer.preferredSize.height) layout(renderer) @@ -264,6 +321,47 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test renderer recolors a tinted leading icon to the foreground on selection`() { + edt { + val row = object : ActiveListItem { + override val key = "with" + override val title = "Alpha" + override val icon = AllIcons.Nodes.Plugin + override val tinted = true + } + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + val mark = components(renderer).filterIsInstance().single { it.icon === AllIcons.Nodes.Plugin } + renderer.getListCellRendererComponent(list, row, 0, true, true) + + // At rest the row keeps the icon's own theme color; a focused selection swaps in a + // foreground-tinted copy so the glyph matches the highlighted title. + assertNotSame(AllIcons.Nodes.Plugin, mark.icon) + } + } + + fun `test renderer keeps an untinted colored icon on selection`() { + edt { + val row = object : ActiveListItem { + override val key = "with" + override val title = "Alpha" + override val icon = AllIcons.Nodes.Plugin + } + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, true, true) + + // Colored status glyphs (running, question, error) opt out and keep their own hue: the + // leading label still holds the original icon by identity after a focused selection. + assertNotNull(components(renderer).filterIsInstance().single { it.icon === AllIcons.Nodes.Plugin }) + } + } + fun `test renderer shows optional trailing text`() { edt { val with = object : ActiveListItem { From 4078d7cf0d907ef62a81537e7abb70c40fb9efdb Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 14:04:17 -0400 Subject: [PATCH 44/50] fix(jetbrains): hide session hover popup behind a blocking overlay Gate the header hover popup on a suppression predicate so it neither opens nor stays alive while the connection banner or modal blocker covers the session, and dismiss any open popup when such an overlay appears. --- .changeset/hover-popup-overlay.md | 5 +++ .../ai/kilocode/client/session/SessionUi.kt | 15 ++++++-- .../session/ui/popup/HeaderPopupController.kt | 13 +++++-- .../ui/popup/HeaderPopupControllerTest.kt | 34 +++++++++++++++++-- 4 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 .changeset/hover-popup-overlay.md diff --git a/.changeset/hover-popup-overlay.md b/.changeset/hover-popup-overlay.md new file mode 100644 index 00000000000..70c75cf2426 --- /dev/null +++ b/.changeset/hover-popup-overlay.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Hide the session hover popup while a blocking overlay (connection banner or modal blocker) covers the chat. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 83789ad5a65..7bcb05e784a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -216,7 +216,7 @@ class SessionUi( private var modalFocus: (() -> JComponent)? = null private var style = SessionEditorStyle.current() private val selection = SessionSelection() - private val popup = HeaderPopupController(timers) + private val popup = HeaderPopupController(timers) { overlayShown() } private val readonly: Boolean get() = manager?.readonly == true private val provider = object : TextCopyProvider() { override fun getActionUpdateThread() = ActionUpdateThread.EDT @@ -336,9 +336,17 @@ class SessionUi( internal fun setModalContent(content: JComponent?, maxW: (() -> Int)? = null, focus: (() -> JComponent)? = null) { modalFocus = if (content == null) null else focus + if (content != null) popup.hideAll() root.setModalContent(content, maxW) } + // A blocking overlay — the modal blocker or the connection banner — must not have a hover popup + // floating on top of it, so the popup controller checks this before showing or keeping one alive. + @RequiresEdt + private fun overlayShown(): Boolean = + (this::root.isInitialized && root.blocker.isVisible) || + (this::connection.isInitialized && connection.isVisible) + private fun buildUi() { root = SessionRootPanel() // Containers stay transparent over the single self-rendered session root backdrop. @@ -627,7 +635,10 @@ class SessionUi( prompt.setReady(controller.model.isReady()) } - is SessionControllerEvent.ConnectionChanged -> Unit + // The banner reacts to the same event; drop any hover popup so it cannot linger on + // top of the overlay that is about to cover the session. + is SessionControllerEvent.ConnectionChanged -> + if (event !is SessionControllerEvent.ConnectionChanged.Hide) popup.hideAll() is SessionControllerEvent.AccountOverlayChanged -> account.onEvent(event) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt index 38d9892c34f..7c1405aca78 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt @@ -34,7 +34,15 @@ import javax.swing.SwingUtilities * Popup subtree hover is detected via [HoverListener] (an experimental IntelliJ API) so the nested * editor counts as "inside the popup". */ -class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { +/** + * @param suppressed reports whether a blocking overlay (connection banner, modal blocker) currently + * covers the session; while true the hover popup is neither opened nor kept alive so it cannot sit + * on top of the overlay. + */ +class HeaderPopupController( + timers: UiTimerSource = UiTimers, + private val suppressed: () -> Boolean = { false }, +) : Disposable { private var target: PartView? = null private var balloon: Balloon? = null private var body: Disposable? = null @@ -46,6 +54,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { @RequiresEdt fun show(view: PartView) { + if (suppressed()) return hideAll() if (target === view) { onHeader = true reevaluate() @@ -117,7 +126,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { @RequiresEdt private fun display() { val view = target ?: return - if (!onHeader && !onPopup) return hideAll() + if (suppressed() || (!onHeader && !onPopup)) return hideAll() val req = view.headerPopup() ?: return hideAll() val built = req.build() place(view, req.anchor, built)?.let { open(req, built, it) } ?: hideAll() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt index 5c61d86d539..f8ca3665dd4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt @@ -70,8 +70,38 @@ class HeaderPopupControllerTest : BasePlatformTestCase() { assertEquals(0, view.requests) } - private fun controller(): HeaderPopupController { - val item = HeaderPopupController(timers) + fun `test an overlay suppresses the hover popup`() { + var overlay = false + val controller = controller { overlay } + val view = view() + + overlay = true + controller.show(view) + + // A blocking overlay leaves no target pending and the dwell never asks for a popup body. + assertNull(target(controller)) + assertNull(guard(controller)) + timers.advanceBy(500) + assertEquals(0, view.requests) + } + + fun `test an overlay appearing during the dwell cancels the popup`() { + var overlay = false + val controller = controller { overlay } + val view = view() + + controller.show(view) + assertNotNull(target(controller)) + + overlay = true + timers.advanceBy(500) + + assertNull(target(controller)) + assertEquals(0, view.requests) + } + + private fun controller(suppressed: () -> Boolean = { false }): HeaderPopupController { + val item = HeaderPopupController(timers, suppressed) controllers.add(item) return item } From 80e82130cd3f40af5ae5977bec9245f5404fd4c7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 14:38:10 -0400 Subject: [PATCH 45/50] fix(jetbrains): let overlays take the pointer over from the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the popup-level overlay suppression with hit-testing truth. A card's hover exit test now asks which component is topmost at that point instead of only comparing bounds, so an overlay painted above the transcript counts as having left the row. An overlay child can declare that it blocks the content beneath it, and the layered panel then releases the hover of whatever the pointer rests on when such a cover appears, moves, or hides — Swing delivers no exit for that case on its own. The connection banner is the first blocking overlay, so a card it covers no longer stays lit and no longer keeps its hover popup open behind the banner. Also repair the worktree icon palette test the mid-tone glyph change broke. --- .changeset/hover-popup-overlay.md | 5 -- .changeset/overlay-takes-hover.md | 5 ++ .../ai/kilocode/client/session/SessionUi.kt | 19 ++-- .../session/ui/popup/HeaderPopupController.kt | 13 +-- .../views/base/AbstractSessionPartView.kt | 14 ++- .../kilocode/client/ui/LayeredOverlayPanel.kt | 89 +++++++++++++++++-- .../client/agentManager/WorktreeIconsTest.kt | 10 ++- .../ui/popup/HeaderPopupControllerTest.kt | 34 +------ .../views/base/AbstractSessionPartViewTest.kt | 41 +++++++++ .../client/ui/LayeredOverlayPanelTest.kt | 69 ++++++++++++++ 10 files changed, 223 insertions(+), 76 deletions(-) delete mode 100644 .changeset/hover-popup-overlay.md create mode 100644 .changeset/overlay-takes-hover.md diff --git a/.changeset/hover-popup-overlay.md b/.changeset/hover-popup-overlay.md deleted file mode 100644 index 70c75cf2426..00000000000 --- a/.changeset/hover-popup-overlay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Hide the session hover popup while a blocking overlay (connection banner or modal blocker) covers the chat. diff --git a/.changeset/overlay-takes-hover.md b/.changeset/overlay-takes-hover.md new file mode 100644 index 00000000000..eca333f1903 --- /dev/null +++ b/.changeset/overlay-takes-hover.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Let session overlays such as the connection banner take the pointer over from the transcript beneath them, so a covered card no longer stays hovered or keeps its popup open behind the overlay. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 7bcb05e784a..8d9a07ff8e3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -216,7 +216,7 @@ class SessionUi( private var modalFocus: (() -> JComponent)? = null private var style = SessionEditorStyle.current() private val selection = SessionSelection() - private val popup = HeaderPopupController(timers) { overlayShown() } + private val popup = HeaderPopupController(timers) private val readonly: Boolean get() = manager?.readonly == true private val provider = object : TextCopyProvider() { override fun getActionUpdateThread() = ActionUpdateThread.EDT @@ -336,17 +336,9 @@ class SessionUi( internal fun setModalContent(content: JComponent?, maxW: (() -> Int)? = null, focus: (() -> JComponent)? = null) { modalFocus = if (content == null) null else focus - if (content != null) popup.hideAll() root.setModalContent(content, maxW) } - // A blocking overlay — the modal blocker or the connection banner — must not have a hover popup - // floating on top of it, so the popup controller checks this before showing or keeping one alive. - @RequiresEdt - private fun overlayShown(): Boolean = - (this::root.isInitialized && root.blocker.isVisible) || - (this::connection.isInitialized && connection.isVisible) - private fun buildUi() { root = SessionRootPanel() // Containers stay transparent over the single self-rendered session root backdrop. @@ -469,7 +461,9 @@ class SessionUi( hostedInEditorTab = manager?.hostedInEditorTab == true, ) connection = ConnectionPanel(this, controller) - root.addOverlay(connection) { pane, child -> + // The banner reports a broken session, so it owns the pointer where it sits: the transcript + // under it must not stay hovered and keep a popup open behind it. + root.addOverlay(connection, blocks = true) { pane, child -> val size = child.preferredSize if (readonly) { val gap = SessionUiStyle.View.contentGap() @@ -635,10 +629,7 @@ class SessionUi( prompt.setReady(controller.model.isReady()) } - // The banner reacts to the same event; drop any hover popup so it cannot linger on - // top of the overlay that is about to cover the session. - is SessionControllerEvent.ConnectionChanged -> - if (event !is SessionControllerEvent.ConnectionChanged.Hide) popup.hideAll() + is SessionControllerEvent.ConnectionChanged -> Unit is SessionControllerEvent.AccountOverlayChanged -> account.onEvent(event) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt index 7c1405aca78..38d9892c34f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt @@ -34,15 +34,7 @@ import javax.swing.SwingUtilities * Popup subtree hover is detected via [HoverListener] (an experimental IntelliJ API) so the nested * editor counts as "inside the popup". */ -/** - * @param suppressed reports whether a blocking overlay (connection banner, modal blocker) currently - * covers the session; while true the hover popup is neither opened nor kept alive so it cannot sit - * on top of the overlay. - */ -class HeaderPopupController( - timers: UiTimerSource = UiTimers, - private val suppressed: () -> Boolean = { false }, -) : Disposable { +class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { private var target: PartView? = null private var balloon: Balloon? = null private var body: Disposable? = null @@ -54,7 +46,6 @@ class HeaderPopupController( @RequiresEdt fun show(view: PartView) { - if (suppressed()) return hideAll() if (target === view) { onHeader = true reevaluate() @@ -126,7 +117,7 @@ class HeaderPopupController( @RequiresEdt private fun display() { val view = target ?: return - if (suppressed() || (!onHeader && !onPopup)) return hideAll() + if (!onHeader && !onPopup) return hideAll() val req = view.headerPopup() ?: return hideAll() val built = req.build() place(view, req.anchor, built)?.let { open(req, built, it) } ?: hideAll() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 20453d2f366..551f6db45a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -301,9 +301,21 @@ abstract class AbstractSessionPartView( } } + /** + * Whether the pointer is still on the row. Bounds alone are not enough: an overlay painted above + * the transcript (the connection banner, the modal blocker) owns the pointer while sitting inside + * the row's rectangle, and Swing stops delivering to the row without ever leaving it + * geometrically. Asking which component is topmost at that point treats a covered row as left, so + * the exit clears the hover instead of keeping the row lit — and its popup alive — under the + * overlay. + */ private fun inside(e: MouseEvent): Boolean { val point = SwingUtilities.convertPoint(e.component, e.point, row) - return row.contains(point) + if (!row.contains(point)) return false + val pane = SwingUtilities.getRootPane(row)?.layeredPane ?: return true + val spot = SwingUtilities.convertPoint(e.component, e.point, pane) + val top = SwingUtilities.getDeepestComponentAt(pane, spot.x, spot.y) ?: return true + return SwingUtilities.isDescendingFrom(top, row) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt index a9aeedcf792..42b4a4b63fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -7,12 +7,20 @@ import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.Component import java.awt.Container import java.awt.Dimension +import java.awt.GraphicsEnvironment +import java.awt.MouseInfo +import java.awt.Point import java.awt.Rectangle +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.MouseEvent import javax.swing.JComponent import javax.swing.JLayeredPane import javax.swing.JPanel +import javax.swing.SwingUtilities open class LayeredOverlayPanel( content: JPanel = BorderLayoutPanel(), @@ -32,6 +40,17 @@ open class LayeredOverlayPanel( open val blocker: Blocker get() = baseBlocker + // An overlay that starts covering the pointer takes the hover over from the content below it. + // Swing already stops delivering mouse events to a covered component, but it sends no exit when + // the cover appears or moves without the pointer moving, so the content would keep its hover — + // and any hover-driven popup — alive behind the overlay. + private val cover = object : ComponentAdapter() { + override fun componentShown(e: ComponentEvent) = takeOverHover() + override fun componentHidden(e: ComponentEvent) = takeOverHover() + override fun componentMoved(e: ComponentEvent) = takeOverHover() + override fun componentResized(e: ComponentEvent) = takeOverHover() + } + init { layout = null add(baseContent) @@ -41,10 +60,17 @@ open class LayeredOverlayPanel( add(baseBlocker) setLayer(baseBlocker, MODAL_LAYER) baseBlocker.isVisible = false + baseOverlay.cover = cover + baseBlocker.addComponentListener(cover) } - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { - overlay.addOverlay(child, bounds) + /** + * Adds a floating child above the content. A child that [blocks] owns the pointer where it sits: + * it takes the hover over from the content beneath it, which a decoration painted for the content + * below (a hover affordance of the very row it sits on) must not do. + */ + fun addOverlay(child: JComponent, blocks: Boolean = false, bounds: (JPanel, JComponent) -> Rectangle) { + overlay.addOverlay(child, blocks, bounds) } @RequiresEdt @@ -89,6 +115,41 @@ open class LayeredOverlayPanel( } } + /** + * Hands the hover of the content under the pointer over to the overlay that now covers it. + * Deferred because the trigger can arrive mid-layout, while a hover handler is free to close a + * popup or re-lay out the card it belongs to. + */ + private fun takeOverHover() = SwingUtilities.invokeLater(::releaseHover) + + @RequiresEdt + private fun releaseHover() { + if (GraphicsEnvironment.isHeadless() || !isShowing) return + val point = MouseInfo.getPointerInfo()?.location ?: return + SwingUtilities.convertPointFromScreen(point, this) + releaseHover(point) + } + + /** Releases the hover of the content at [point], in this panel's coordinates, when covered. */ + @RequiresEdt + internal fun releaseHover(point: Point) { + if (!covered(point)) return + val local = SwingUtilities.convertPoint(this, point, content) + val below = SwingUtilities.getDeepestComponentAt(content, local.x, local.y) ?: return + val spot = SwingUtilities.convertPoint(this, point, below) + below.dispatchEvent( + MouseEvent(below, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, spot.x, spot.y, 0, false), + ) + } + + /** Whether the blocker or a blocking overlay child sits above the content at [point]. */ + private fun covered(point: Point): Boolean { + if (!Rectangle(size).contains(point)) return false + if (blocker.isVisible) return true + val local = SwingUtilities.convertPoint(this, point, overlay) + return overlay.blocks(local.x, local.y) + } + override fun getPreferredSize(): Dimension { val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0 val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0 @@ -99,22 +160,32 @@ open class LayeredOverlayPanel( private val items = linkedMapOf Rectangle>() + private val blocking = linkedSetOf() + + /** Notified when a blocking child is shown, hidden, moved, or resized. */ + internal var cover: ComponentAdapter? = null + init { layout = null isOpaque = false } - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + fun addOverlay(child: JComponent, blocks: Boolean = false, bounds: (JPanel, JComponent) -> Rectangle) { items[child] = bounds + if (blocks) { + blocking.add(child) + cover?.let(child::addComponentListener) + } add(child) } - override fun contains(x: Int, y: Int): Boolean { - for (child in components) { - if (child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y)) return true - } - return false - } + override fun contains(x: Int, y: Int): Boolean = components.any { hits(it, x, y) } + + /** Whether a child that blocks the content beneath it covers ([x], [y]). */ + internal fun blocks(x: Int, y: Int): Boolean = blocking.any { hits(it, x, y) } + + private fun hits(child: Component, x: Int, y: Int): Boolean = + child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y) override fun doLayout() { items.forEach { (child, bounds) -> diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt index 4102c0c17fe..37b9396f0b7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt @@ -23,13 +23,15 @@ class WorktreeIconsTest : BasePlatformTestCase() { fun `test resting row icons carry the muted palette in both themes`() { for (name in listOf("worktreeBranch", "worktreeLock", "worktree-local")) { - // The secondary New UI greys, which are also what Label.infoForeground resolves to, so a - // resting glyph sits at the weight of the description line under it rather than the title. - val light = svg(name).replace("#818594", "GLYPH") - val dark = svg("${name}_dark").replace("#6F737A", "GLYPH") + // The tertiary New UI greys: a resting glyph only says what the checkout is, so it sits a + // step quieter than the secondary grey the description line under it uses. + val light = svg(name).replace("#A8ADBD", "GLYPH") + val dark = svg("${name}_dark").replace("#9DA0A8", "GLYPH") assertFalse("$name still uses a primary grey", light.contains("#6C707E")) assertFalse("${name}_dark still uses a primary grey", dark.contains("#CED0D6")) + assertFalse("$name still uses the secondary grey", light.contains("#818594")) + assertFalse("${name}_dark still uses the secondary grey", dark.contains("#6F737A")) // Recoloring must be the only difference: the loader animates between the two. assertEquals("$name geometry drifted from its dark variant", light, dark) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt index f8ca3665dd4..5c61d86d539 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt @@ -70,38 +70,8 @@ class HeaderPopupControllerTest : BasePlatformTestCase() { assertEquals(0, view.requests) } - fun `test an overlay suppresses the hover popup`() { - var overlay = false - val controller = controller { overlay } - val view = view() - - overlay = true - controller.show(view) - - // A blocking overlay leaves no target pending and the dwell never asks for a popup body. - assertNull(target(controller)) - assertNull(guard(controller)) - timers.advanceBy(500) - assertEquals(0, view.requests) - } - - fun `test an overlay appearing during the dwell cancels the popup`() { - var overlay = false - val controller = controller { overlay } - val view = view() - - controller.show(view) - assertNotNull(target(controller)) - - overlay = true - timers.advanceBy(500) - - assertNull(target(controller)) - assertEquals(0, view.requests) - } - - private fun controller(suppressed: () -> Boolean = { false }): HeaderPopupController { - val item = HeaderPopupController(timers, suppressed) + private fun controller(): HeaderPopupController { + val item = HeaderPopupController(timers) controllers.add(item) return item } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index 8f1004f729e..b5b80a333cb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -13,7 +13,9 @@ import java.awt.image.BufferedImage import javax.swing.Icon import javax.swing.JComponent import javax.swing.JLabel +import javax.swing.JLayeredPane import javax.swing.JPanel +import javax.swing.JRootPane @Suppress("UnstableApiUsage") class AbstractSessionPartViewTest : BasePlatformTestCase() { @@ -193,6 +195,45 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) } + fun `test hover survives an exit that stays on the row`() { + val view = NestedView(JLabel("link")) + val row = view.component(0) as JPanel + pane(view) + + enter(row) + // Swing reports an exit for every nested crossing; one that lands back on the row is not a + // leave, so the fill must stay. + exit(row, 5, 5) + + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + } + + fun `test hover clears when an overlay covers the row under the pointer`() { + val view = NestedView(JLabel("link")) + val row = view.component(0) as JPanel + val pane = pane(view) + enter(row) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + + // A banner painted above the transcript owns the pointer even while it sits inside the row's + // bounds, so the row must not stay lit underneath it. + pane.add(JPanel().apply { setBounds(0, 0, 200, 40) }, JLayeredPane.PALETTE_LAYER) + exit(row, 5, 5) + + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) + } + + private fun pane(view: AbstractSessionPartView): JLayeredPane { + val root = JRootPane() + root.setSize(200, 40) + root.contentPane.add(view) + view.setSize(200, 40) + view.doLayout() + root.doLayout() + root.contentPane.doLayout() + return root.layeredPane + } + fun `test clicking a nested header child toggles the card`() { val child = JLabel("plain") val header = JPanel(BorderLayout()).apply { add(child, BorderLayout.WEST) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt index ef4f95f1c02..8867dbbc0e4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt @@ -3,7 +3,10 @@ package ai.kilocode.client.ui import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Dimension +import java.awt.Point import java.awt.Rectangle +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") @@ -102,6 +105,72 @@ class LayeredOverlayPanelTest : BasePlatformTestCase() { assertTrue(root.blocker.contains(50, 50)) } + fun `test a blocking overlay releases the hover of the content it covers`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + root.addOverlay(Probe(), blocks = true) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + root.releaseHover(Point(20, 10)) + + assertEquals(1, hovered.exits) + } + + fun `test content keeps its hover where no blocking overlay covers it`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + root.addOverlay(Probe(), blocks = true) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + root.releaseHover(Point(200, 200)) + + assertEquals(0, hovered.exits) + } + + fun `test a decorating overlay leaves the hover of the content below alone`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + // A hover affordance drawn for the row it sits on must not take that row's hover away. + root.addOverlay(Probe()) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + root.releaseHover(Point(20, 10)) + + assertEquals(0, hovered.exits) + } + + fun `test the blocker releases the hover of the content under the pointer`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + root.doLayout() + + root.releaseHover(Point(20, 10)) + assertEquals(0, hovered.exits) + + root.setBlocked(true) + root.releaseHover(Point(20, 10)) + + assertEquals(1, hovered.exits) + } + + private class Hovered : BorderLayoutPanel() { + var exits = 0 + private set + + init { + setBounds(0, 0, 400, 260) + addMouseListener(object : MouseAdapter() { + override fun mouseExited(e: MouseEvent) { + exits++ + } + }) + } + } + private class Probe : BorderLayoutPanel() { var laid = false From a5f62bc2dbfd5f857eaaab51003941c18c0a779a Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 15:41:47 -0400 Subject: [PATCH 46/50] fix(jetbrains): keep worktree PR badges clickable --- .../jetbrains-worktree-pr-badge-clicks.md | 5 ++ .../worktree/WorktreeStatsView.kt | 77 +++++++++++-------- .../client/ui/list/ActiveListModel.kt | 15 ++++ .../client/ui/list/ActiveListRenderer.kt | 7 +- .../settings/base/SettingsListViewTest.kt | 55 +++++++++++++ 5 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 .changeset/jetbrains-worktree-pr-badge-clicks.md diff --git a/.changeset/jetbrains-worktree-pr-badge-clicks.md b/.changeset/jetbrains-worktree-pr-badge-clicks.md new file mode 100644 index 00000000000..30303e33ef0 --- /dev/null +++ b/.changeset/jetbrains-worktree-pr-badge-clicks.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep the PR badge in the JetBrains Agent Manager worktree list clickable and aligned with the rest of the row. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt index 6dfabc84cc4..68671254350 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt @@ -22,16 +22,24 @@ import java.awt.BorderLayout import java.awt.Component import java.awt.Container import java.awt.Cursor -import java.awt.Dimension import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.Icon import javax.swing.JPanel +/** + * The trailing ahead/behind/diff and PR badges of a worktree row. + * + * Uses a real layout manager on purpose: a `null` layout resolves min/preferred size through the + * peer, which reports the component's *current* size. Inside the list this view is a single render + * stamp reused for every row, and [Stack] and [ai.kilocode.client.ui.layout.Align] clamp a child's + * preferred width into its `[min, max]` range - so a peer-reported minimum would carry the previous + * row's width into the next row's layout and drift the badges off their hit regions. + */ internal class WorktreeStatsView( openDiff: (() -> Unit)? = null, fill: Boolean = true, -) : JPanel(null) { +) : JPanel(BorderLayout()) { companion object { private val UP: Icon = IconLoader.getIcon("/icons/arrow-up.svg", WorktreeStatsView::class.java) private val DOWN: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", WorktreeStatsView::class.java) @@ -51,11 +59,10 @@ internal class WorktreeStatsView( // it is always the rightmost element. private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(prHit) private var url: String? = null - private var stats: WorktreeStatsDto? = null - private var pull: WorktreePrDto? = null + private var state: State? = null init { - add(row) + add(row, BorderLayout.CENTER) changeHit.act = openDiff prHit.act = { url?.let(BrowserUtil::browse) } diff.toolTipText = KiloBundle.message("worktree.stats.tooltip", 0, 0, 0, 0) @@ -83,21 +90,29 @@ internal class WorktreeStatsView( } fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?) { - if (this.stats == stats && this.pull == pull) return - this.stats = stats - this.pull = pull - sync(stats, pull?.let { ActiveListBadge("#${it.number}", style(it.state)) }, pull?.url, pull?.let(::prTooltip)) + sync( + State( + stats, + pull?.let { ActiveListBadge("#${it.number}", style(it.state)) }, + pull?.url, + pull?.let(::prTooltip), + ), + ) } fun update(stats: WorktreeStatsDto?, badge: ActiveListBadge?, prTip: String? = badge?.text) { - if (this.stats == stats && pull == null && (pr.icon as? FilledBadgeIcon)?.text == badge?.text && prHit.tip == prTip) return - this.stats = stats - this.pull = null - sync(stats, badge, null, prTip) + sync(State(stats, badge, null, prTip)) } - private fun sync(stats: WorktreeStatsDto?, badge: ActiveListBadge?, link: String?, tip: String?) { - val s = stats ?: WorktreeStatsDto("") + /** + * Applies [next] unless it is already rendered. The memo key must cover everything this method + * writes: inside the list one instance renders every row, so a field left out of the key would + * carry another row's badge, tooltip, or visibility. + */ + private fun sync(next: State) { + if (state == next) return + state = next + val s = next.stats ?: WorktreeStatsDto("") behind.text = s.behind.toString() behind.toolTipText = KiloBundle.message("worktree.stats.behind.tooltip") behind.isVisible = s.behind > 0 @@ -112,12 +127,12 @@ internal class WorktreeStatsView( diff.toolTipText = changeTip changeHit.tip = changeTip changeHit.toolTipText = changeTip - url = link - pr.icon = badge?.let { FilledBadgeIcon(it.text, it.style) } - pr.toolTipText = tip - prHit.tip = tip - prHit.toolTipText = tip - pr.isVisible = badge != null + url = next.link + pr.icon = next.badge?.let { FilledBadgeIcon(it.text, it.style) } + pr.toolTipText = next.tip + prHit.tip = next.tip + prHit.toolTipText = next.tip + pr.isVisible = next.badge != null val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible changeHit.isVisible = changesVisible prHit.isVisible = pr.isVisible @@ -145,18 +160,6 @@ internal class WorktreeStatsView( if (comp is Container) comp.components.forEach { applyCursor(it, active) } } - override fun getPreferredSize(): Dimension { - val ins = insets - val size = row.preferredSize - return Dimension(size.width + ins.left + ins.right, size.height + ins.top + ins.bottom) - } - - override fun doLayout() { - val ins = insets - val size = row.preferredSize - row.setBounds(ins.left, ins.top, minOf(size.width, width - ins.left - ins.right), minOf(size.height, height - ins.top - ins.bottom)) - } - private fun count(icon: Icon) = JBLabel().apply { this.icon = icon iconTextGap = UiStyle.Gap.xs() @@ -165,6 +168,14 @@ internal class WorktreeStatsView( border = JBUI.Borders.empty() } + /** Everything [sync] renders, so a repeated row can be skipped without leaking stale state. */ + private data class State( + val stats: WorktreeStatsDto?, + val badge: ActiveListBadge?, + val link: String?, + val tip: String?, + ) + /** A badge wrapper the ActiveList hit-tests for clicks, cursor, and tooltip. */ private class HitRegion(override val cellId: String) : JPanel(BorderLayout()), ActiveListHitCell { var act: (() -> Unit)? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index ebe68d1584a..9698bf5e087 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -250,6 +250,21 @@ internal fun activeListLayout(component: Component) { for (child in component.components) activeListLayout(child) } +/** + * Marks a rendered row and everything under it invalid. + * + * A list renderer is one component reused for every row, and it changes content without changing + * size. Swing caches each container's preferred/minimum size and - through + * [java.awt.Container.validate], the layout pass painting uses - skips subtrees that are still + * valid, so a row would otherwise be laid out with sizes measured for whichever row the renderer + * rendered before it. Invalidating the whole stamp keeps painting and the [activeListLayout] pass + * behind [activeListHits] on the same geometry. + */ +internal fun activeListInvalidate(component: Component) { + component.invalidate() + if (component is Container) for (child in component.components) activeListInvalidate(child) +} + private fun forEachHitCell(component: Component, action: (ActiveListHitCell) -> Unit) { fun visit(c: Component) { // Skip hidden subtrees so a badge left visible inside a hidden trailing panel is not diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 7d7c1fcebd7..735f46a9399 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -208,7 +208,7 @@ internal class ActiveListRenderer( glyph.isVisible = false wrap.update(list, false, false) wrap.setPreferredSize(Dimension(0, bodyHeight ?: value.height)) - top.invalidate() + activeListInvalidate(this) return this } gap = false @@ -262,7 +262,10 @@ internal class ActiveListRenderer( pill.background = if (selected && list.isEnabled) UIUtil.getListBackground(true, active) else list.background val height = bodyHeight wrap.setPreferredSize(height?.let { Dimension(0, it) }) - top.invalidate() + // Neither the content mutations above nor setPreferredSize invalidate reliably: a same-size + // icon swap, an equal label text, or an explicit preferred size leave the tree valid, and a + // valid subtree keeps the sizes it was measured with for another row. + activeListInvalidate(this) return this } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index aa911be2483..1e17cce1bca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -46,6 +46,7 @@ import java.awt.Dimension import java.awt.Point import java.awt.event.InputEvent import java.awt.event.MouseEvent +import java.awt.image.BufferedImage import javax.swing.JLayeredPane import javax.swing.JPanel import javax.swing.ListSelectionModel @@ -1114,6 +1115,60 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test pr badge hit region ignores the metrics of other rows`() { + edt { + val calls = mutableListOf() + val view = ActiveListView("Empty") { _, _ -> } + view.update( + listOf( + metricsItem( + "wide", + "Alpha", + ActiveListMetrics( + additions = 1234, + deletions = 987, + ahead = 42, + behind = 17, + pr = ActiveListBadge("#12345"), + onPr = { calls += "wide" }, + ), + ), + metricsItem("narrow", "Beta", ActiveListMetrics(pr = ActiveListBadge("#7"), onPr = { calls += "narrow" })), + ), + ) + view.list.size = Dimension(360, 160) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + + val wide = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_PR_CELL) + val narrow = activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL) + // Both badges trail their row, so they share a right edge no matter how wide the changes + // beside them are. + assertEquals(wide.x + wide.width, narrow.x + narrow.width) + + // The renderer is one reused stamp: rendering the wide row, or a full paint pass over + // every row, must not move the narrow row's hit region. + activeListCellBounds(view.list, 0, selected = false) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + paint(view.list) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + + click(view, center(narrow)) + click(view, center(wide)) + assertEquals(listOf("narrow", "wide"), calls) + } + } + + private fun paint(list: JBList<*>) { + val image = UIUtil.createImage(list, list.width, list.height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + try { + list.paint(g) + } finally { + g.dispose() + } + } + private fun item(id: String, name: String, note: String?, vararg cells: ActiveListCell) = object : ActiveListItem { override val key = id override val title = name From 20d547fd10bcce0c48a620592e66c7104516f564 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 15:45:14 -0400 Subject: [PATCH 47/50] chore: ignore jetbrains config --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 16f4034b7f6..bb34e8a9b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ tsconfig.tsbuildinfo .kilo/yarn.lock .kilo/node_modules .kilo/plans/*upstream-merge-report-*.md +jetbrains.json .kilocode/.gitignore .kilocode/package.json .kilocode/package-lock.json From cb7470b04d371e62d377d7e98b6b4ab18a1f13d4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 16:00:54 -0400 Subject: [PATCH 48/50] chore: ignore JetBrains worktree state --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bb34e8a9b6a..86e7e67c9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,7 @@ tsconfig.tsbuildinfo .kilo/yarn.lock .kilo/node_modules .kilo/plans/*upstream-merge-report-*.md -jetbrains.json +**/.kilo/jetbrains.json .kilocode/.gitignore .kilocode/package.json .kilocode/package-lock.json From 80e3b2ee40c11c2a21088813cb145ac680afc6ea Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 25 Aug 2026 20:15:33 +0000 Subject: [PATCH 49/50] release(jetbrains): v7.1.0-rc.4 --- packages/kilo-jetbrains/CHANGELOG.md | 39 +++++++++++++++++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index d33cdeb6e55..1260d30443d 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -240,6 +240,45 @@ ## [Unreleased] +## [7.1.0-rc.4] - 2026-08-25 + +### Added +- feat(agent-manager): replace sessions list with a per-project history button by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13407 + +### Fixed +- fix(vscode): remove model reset button by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13405 +- fix(vscode): promote parallel subagents independently by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13393 +- fix(vscode): eliminate streaming transcript flicker by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13408 +- fix(vscode): deduplicate sync event delivery by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13410 +- fix(agent-manager): remove unsafe WebGL terminal renderer by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13413 +- fix(cli): preserve editor context prompt prefix by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13379 +- fix(cli): share location services across server routes by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13378 +- fix(agent-manager): use valid terminal close code by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13416 +- fix(agent-manager): fix project-scoped history routing by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13421 +- fix(vscode): bound sync filter state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13417 +- fix(agent-manager): allow explicit provider selection by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13419 +- fix(vscode): guard subagent promotion edge cases by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13425 +- fix(cli): address startup review feedback by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13420 +- fix(agent-manager): avoid stale history switch entries by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13428 +- fix(cli): align file location cache keys by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13418 +- fix(vscode): prevent duplicate reasoning with subagent inspectors by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13430 +- fix(agent-manager): preserve worktree list scroll on deletion by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13429 +- fix(vscode): hide manual interruption warning by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13376 +- fix(jetbrains): harden agent manager worktrees by @kirillk in https://github.com/Kilo-Org/kilocode/pull/13431 +- fix(jetbrains): harden Agent Manager worktree flows by @kirillk in https://github.com/Kilo-Org/kilocode/pull/13423 + +### Changed +- release(jetbrains): v7.1.0-rc.3 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/13397 +- docs(kilo-docs): document sub-organizations by @jrf0110 in https://github.com/Kilo-Org/kilocode/pull/13050 +- docs(vscode): document background agent status strip by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13391 +- docs(agent-manager): clarify terminal context routing by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13385 +- perf(agent-manager): render terminal output with WebGL and pause hidden terminals by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13406 +- chore(cli): update Bun to 1.4.0 by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13409 +- perf(cli): optimize cold and warm startup by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13412 +- perf(agent-manager): optimize worktree diff loading by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13411 +- test(jetbrains): stop frontend tests opening a real browser by @kirillk in https://github.com/Kilo-Org/kilocode/pull/13432 + + ## [7.1.0-rc.3] - 2026-08-24 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 033219d8d8f..df9565b0042 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.1.0-rc.3 +kilo.jetbrains.version=7.1.0-rc.4 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. From 8986b9cf549e4b1510525bc523be07ec06457753 Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Tue, 25 Aug 2026 16:24:22 -0400 Subject: [PATCH 50/50] docs(jetbrains): edit changelog for v7.1.0-rc.4 --- packages/kilo-jetbrains/CHANGELOG.md | 42 ++++++++-------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 1260d30443d..cfb492fa252 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -243,41 +243,21 @@ ## [7.1.0-rc.4] - 2026-08-25 ### Added -- feat(agent-manager): replace sessions list with a per-project history button by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13407 +- Let Agent Manager tasks choose both provider and model so similarly named models across providers can be selected reliably. +- Show clearer missing-folder states for deleted or moved JetBrains worktrees. ### Fixed -- fix(vscode): remove model reset button by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13405 -- fix(vscode): promote parallel subagents independently by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13393 -- fix(vscode): eliminate streaming transcript flicker by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13408 -- fix(vscode): deduplicate sync event delivery by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13410 -- fix(agent-manager): remove unsafe WebGL terminal renderer by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13413 -- fix(cli): preserve editor context prompt prefix by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13379 -- fix(cli): share location services across server routes by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13378 -- fix(agent-manager): use valid terminal close code by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13416 -- fix(agent-manager): fix project-scoped history routing by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13421 -- fix(vscode): bound sync filter state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13417 -- fix(agent-manager): allow explicit provider selection by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13419 -- fix(vscode): guard subagent promotion edge cases by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13425 -- fix(cli): address startup review feedback by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13420 -- fix(agent-manager): avoid stale history switch entries by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13428 -- fix(cli): align file location cache keys by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13418 -- fix(vscode): prevent duplicate reasoning with subagent inspectors by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13430 -- fix(agent-manager): preserve worktree list scroll on deletion by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13429 -- fix(vscode): hide manual interruption warning by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13376 -- fix(jetbrains): harden agent manager worktrees by @kirillk in https://github.com/Kilo-Org/kilocode/pull/13431 -- fix(jetbrains): harden Agent Manager worktree flows by @kirillk in https://github.com/Kilo-Org/kilocode/pull/13423 +- Keep JetBrains Agent Manager worktrees anchored to the main repository's managed storage, preventing nested worktree data loss when the IDE is opened inside a linked worktree. +- Harden JetBrains worktree cleanup by pruning stale git metadata, hiding dead managed worktrees, refusing unmanaged paths, and blocking parent removal while nested worktrees are live. +- Surface failed JetBrains sessions consistently in worktree and session lists, and keep the Agents attention dot active until the problem is resolved. +- Restore running indicators when resuming sessions instead of leaving stale stopped or error state visible. +- Keep JetBrains session hover popups attached to the correct card, within the visible session area, and hidden behind blocking overlays. +- Keep worktree PR badges clickable after row reuse and layout changes. ### Changed -- release(jetbrains): v7.1.0-rc.3 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/13397 -- docs(kilo-docs): document sub-organizations by @jrf0110 in https://github.com/Kilo-Org/kilocode/pull/13050 -- docs(vscode): document background agent status strip by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13391 -- docs(agent-manager): clarify terminal context routing by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13385 -- perf(agent-manager): render terminal output with WebGL and pause hidden terminals by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13406 -- chore(cli): update Bun to 1.4.0 by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13409 -- perf(cli): optimize cold and warm startup by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13412 -- perf(agent-manager): optimize worktree diff loading by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/13411 -- test(jetbrains): stop frontend tests opening a real browser by @kirillk in https://github.com/Kilo-Org/kilocode/pull/13432 - +- Improve Kilo Core startup speed for JetBrains and other clients, especially default TUI launch and short-lived commands. +- Put new, imported, or moved JetBrains Agent Manager worktrees at the top of the list and keep that ordering across reloads unless manually reordered. +- Make JetBrains Agent Manager rows visually quieter with regular-weight labels, subdued idle icons, and pruning of stale deleted-session status. ## [7.1.0-rc.3] - 2026-08-24