From 7939a021cb05ac73c0fcbe48048407a1fee7e9ed Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 15 Apr 2026 10:40:20 +0200 Subject: [PATCH 1/6] fix(cli): re-apply dead-stream detection to SSE endpoints to prevent memory leak on Windows (#8952) * fix(cli): re-apply dead-stream detection to SSE endpoints to prevent memory leak on Windows The upstream OpenCode v1.3.0 merge rewrote SSE routes with AsyncQueue but dropped our dead-stream detection. On Windows, stream.onAbort() may never fire after client disconnect (IOCP delays TCP RST detection), leaking a GlobalBus listener, heartbeat interval, and AsyncQueue per dead connection. Wrap writeSSE in try/catch to clean up eagerly on write failure. * fix(cli): log dead-stream cleanup in SSE endpoints --- packages/opencode/src/bus/global.ts | 1 + packages/opencode/src/server/routes/event.ts | 15 ++++++++++++++- packages/opencode/src/server/routes/global.ts | 15 ++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 43386dd6b20..dc23020d27c 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -8,3 +8,4 @@ export const GlobalBus = new EventEmitter<{ }, ] }>() +GlobalBus.setMaxListeners(50) // kilocode_change — surface warning if SSE listeners accumulate diff --git a/packages/opencode/src/server/routes/event.ts b/packages/opencode/src/server/routes/event.ts index 989b857710d..565486d3c3a 100644 --- a/packages/opencode/src/server/routes/event.ts +++ b/packages/opencode/src/server/routes/event.ts @@ -70,14 +70,27 @@ export const EventRoutes = () => stream.onAbort(stop) + // kilocode_change start + // On Windows, stream.onAbort() may never fire after a client disconnects + // (delayed TCP RST detection via IOCP). Without this try/catch, the + // GlobalBus listener, heartbeat interval, and AsyncQueue stay alive + // indefinitely for each dead connection — leaking memory on every + // SSE reconnect. Catching write errors lets us clean up eagerly. try { for await (const data of q) { if (data === null) return - await stream.writeSSE({ data }) + try { + await stream.writeSSE({ data }) + } catch { + log.info("event write failed, cleaning up dead stream") + stop() + return + } } } finally { stop() } + // kilocode_change end }) }, ) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 733a0ef6b27..ddfa4c9acad 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -56,14 +56,27 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue stream.onAbort(stop) + // kilocode_change start + // On Windows, stream.onAbort() may never fire after a client disconnects + // (delayed TCP RST detection via IOCP). Without this try/catch, the + // GlobalBus listener, heartbeat interval, and AsyncQueue stay alive + // indefinitely for each dead connection — leaking memory on every + // SSE reconnect. Catching write errors lets us clean up eagerly. try { for await (const data of q) { if (data === null) return - await stream.writeSSE({ data }) + try { + await stream.writeSSE({ data }) + } catch { + log.info("global event write failed, cleaning up dead stream") + stop() + return + } } } finally { stop() } + // kilocode_change end }) } From aa9af01fefdbcfce08335ace4a51481833abfe10 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 15 Apr 2026 10:40:49 +0200 Subject: [PATCH 2/6] fix(agent-manager): fix tab header tooltip-trigger breaking height chain (#8955) * fix(agent-manager): fix tab header tooltip-trigger breaking height chain The Tooltip component wraps its trigger in a div with display:flex but no explicit height, breaking the height chain from .am-tab-bar (36px) through to .am-tab. This caused tabs to collapse to content height instead of stretching to the full tab bar, clipping the 2px active indicator border. Add scoped CSS rules targeting only the outer tooltip-trigger inside .am-tab-sortable (via direct child and context-menu-trigger selectors) to restore height:100% without affecting inner tooltip triggers like the close button. Add three TabBar visual regression stories that reproduce the real DOM structure (ContextMenu.Trigger with display:contents, nested TooltipKeybind wrappers) to catch future regressions. * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: github-actions[bot] --- .../tab-bar-multiple-tabs-chromium-linux.png | 3 + .../tab-bar-single-tab-chromium-linux.png | 3 + ...tab-bar-with-review-tab-chromium-linux.png | 3 + .../agent-manager/agent-manager.css | 5 + .../src/stories/agent-manager.stories.tsx | 130 +++++++++++++++++- 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-multiple-tabs-chromium-linux.png create mode 100644 packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-single-tab-chromium-linux.png create mode 100644 packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-with-review-tab-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-multiple-tabs-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-multiple-tabs-chromium-linux.png new file mode 100644 index 00000000000..bc4bc545585 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-multiple-tabs-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee8d8e04b09b58973a1ff66d5a3d8648ba590f88312cf1f37412c39ccdac0480 +size 3973 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-single-tab-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-single-tab-chromium-linux.png new file mode 100644 index 00000000000..999c6b2f9e9 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-single-tab-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0e9ea1be8fbb44f05bed7960d1ba13f0d4c5e904e4025e4ab4743c4991e93c8 +size 4696 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-with-review-tab-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-with-review-tab-chromium-linux.png new file mode 100644 index 00000000000..0760abd4b86 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-with-review-tab-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66a7ecedac685e17c442bb036d7392320e0642ef43da77ed8dc23be29bc7988e +size 2935 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 a13c96c5edc..6f3b8f791ce 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1143,6 +1143,11 @@ button.am-section-toggle:hover .am-section-label { touch-action: none; } +.am-tab-sortable > [data-component="tooltip-trigger"], +.am-tab-sortable > [data-slot="context-menu-trigger"] > [data-component="tooltip-trigger"] { + height: 100%; +} + .am-tab-dragging { opacity: 0.25; } diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 4134b90beeb..ce7d852dc8a 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource solid-js */ /** * Stories for Agent Manager components: - * FileTree, DiffPanel, FullScreenDiffView, WorktreeItem + * FileTree, DiffPanel, FullScreenDiffView, WorktreeItem, TabBar */ import type { Meta, StoryObj } from "storybook-solidjs-vite" @@ -10,6 +10,10 @@ import { FileTree } from "../../agent-manager/FileTree" import { DiffPanel } from "../../agent-manager/DiffPanel" import { FullScreenDiffView } from "../../agent-manager/FullScreenDiffView" import { WorktreeItem } from "../../agent-manager/WorktreeItem" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Icon } from "@kilocode/kilo-ui/icon" +import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import type { WorktreeFileDiff, WorktreeState, WorktreeGitStats, PRStatus } from "../types/messages" import "../../agent-manager/agent-manager.css" import "../../agent-manager/agent-manager-review.css" @@ -444,3 +448,127 @@ export const WorktreeItemGrouped: Story = { ) }, } + +// --------------------------------------------------------------------------- +// TabBar — renders tab bar structure matching SortableTab / SortableReviewTab +// DOM to verify the tooltip-trigger height chain is correct. +// --------------------------------------------------------------------------- + +/** + * Mock tab matching the real SortableTab DOM: + * .am-tab-sortable > [context-menu-trigger] > [tooltip-trigger] > .am-tab + */ +const MockTab = (props: { title: string; active?: boolean }) => ( +
+ + + +
+ {props.title} + + + +
+
+
+
+
+) + +/** Mock review tab matching SortableReviewTab DOM (no ContextMenu wrapper). */ +const MockReviewTab = (props: { active?: boolean }) => ( +
+ +
+ + Review + + + +
+
+
+) + +export const TabBarMultipleTabs: Story = { + name: "TabBar — multiple tabs with active", + render: () => ( + +
+
+
+ + + +
+
+ + + +
+ + +
+
+
+ ), +} + +export const TabBarWithReviewTab: Story = { + name: "TabBar — with review tab", + render: () => ( + +
+
+
+ + +
+
+ + + +
+ + +
+
+
+ ), +} + +export const TabBarSingleTab: Story = { + name: "TabBar — single active tab", + render: () => ( + +
+
+
+ +
+
+ + + +
+ + +
+
+
+ ), +} From 22806d496613fbb09b847967a5e48a564747e4d1 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 15 Apr 2026 10:41:00 +0200 Subject: [PATCH 3/6] fix(cli): avoid worktree diff patch generation (#8953) --- .changeset/large-worktree-diffs.md | 5 +++++ .../opencode/src/kilocode/review/worktree-diff.ts | 3 +-- .../opencode/test/kilocode/worktree-diff.test.ts | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .changeset/large-worktree-diffs.md diff --git a/.changeset/large-worktree-diffs.md b/.changeset/large-worktree-diffs.md new file mode 100644 index 00000000000..72bdbbcdde1 --- /dev/null +++ b/.changeset/large-worktree-diffs.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Reduce Agent Manager memory usage when viewing large worktree diffs. diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts index 3bccf81c79c..2d85677c9a9 100644 --- a/packages/opencode/src/kilocode/review/worktree-diff.ts +++ b/packages/opencode/src/kilocode/review/worktree-diff.ts @@ -1,6 +1,5 @@ // kilocode_change - new file import { $ } from "bun" -import { createTwoFilesPatch } from "diff" import fs from "node:fs/promises" import path from "node:path" import z from "zod" @@ -245,7 +244,7 @@ export namespace WorktreeDiff { const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? lines(after) : meta.additions return { file: meta.file, - patch: createTwoFilesPatch(meta.file, meta.file, before, after), + patch: "", before, after, additions, diff --git a/packages/opencode/test/kilocode/worktree-diff.test.ts b/packages/opencode/test/kilocode/worktree-diff.test.ts index 9a284a3add5..cb6d08794ae 100644 --- a/packages/opencode/test/kilocode/worktree-diff.test.ts +++ b/packages/opencode/test/kilocode/worktree-diff.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe } from "bun:test" import { $ } from "bun" import { tmpdir } from "../fixture/fixture" +import { WorktreeDiff } from "../../src/kilocode/review/worktree-diff" import path from "path" /** @@ -145,6 +146,19 @@ describe("worktree diff git commands", () => { expect(allFiles).toContain("new-file.py") }) + test("worktree detail does not include unused patch content", async () => { + await using tmp = await setupRepo() + const dir = tmp.path + + await Bun.write(path.join(dir, "existing.txt"), "hello\nmodified\n") + + const detail = await WorktreeDiff.detail({ dir, base: "HEAD", file: "existing.txt" }) + + expect(detail?.patch).toBe("") + expect(detail?.before).toBe("hello\n") + expect(detail?.after).toBe("hello\nmodified\n") + }) + test("worktree scenario: branch with no new commits, only untracked files", async () => { // This is the exact scenario from the screenshot: // - Worktree created from main From b9ecdb16a6bff912fdb29839c0eb6518f3f75eb7 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 15 Apr 2026 10:58:38 +0200 Subject: [PATCH 4/6] fix(ui): use LTR isolate instead of LTR embedding for directory paths in diff headers (#8959) The trailing / in directory paths was visually disappearing in diff viewer headers because CSS direction:rtl interacted poorly with the weaker LRE/PDF bidi embedding characters (\u202A/\u202C). Replace with LRI/PDI isolate characters (\u2066/\u2069) which fully prevent the surrounding RTL context from reordering boundary characters like /. --- packages/kilo-ui/src/components/message-part.tsx | 6 +++--- packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx | 2 +- .../webview-ui/agent-manager/FullScreenDiffView.tsx | 2 +- .../kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx | 2 +- .../webview-ui/src/components/chat/PermissionDiff.tsx | 2 +- .../webview-ui/src/components/chat/VscodeSessionTurn.tsx | 2 +- packages/ui/src/components/message-part.tsx | 4 ++-- packages/ui/src/components/session-review.tsx | 2 +- packages/ui/src/components/session-turn.tsx | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 41388c8622a..0bd5c666168 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1005,7 +1005,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
- {`\u202A${getDirectory(props.path)}\u202C`} + {`\u2066${getDirectory(props.path)}\u2069`} {getFilename(props.path)}
@@ -1517,7 +1517,7 @@ function ToolMetaLine(props: { > {props.filename} - {props.path} + {`\u2066${props.path}\u2069`} {(changes) => } @@ -2196,7 +2196,7 @@ ToolRegistry.register({
- {`\u202A${getDirectory(file.relativePath)}\u202C`} + {`\u2066${getDirectory(file.relativePath)}\u2069`} = (props) => {
- {getDirectory(diff.file)} + {`\u2066${getDirectory(diff.file)}\u2069`} {getFilename(diff.file)} 0}> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index d43797d2ef9..fa8a5389c24 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -507,7 +507,7 @@ export const FullScreenDiffView: Component = (props) =>
- {`\u202A${getDirectory(diff.file)}\u202C`} + {`\u2066${getDirectory(diff.file)}\u2069`} {getFilename(diff.file)} 0}> diff --git a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx index 50fd704c536..96841f7b7f6 100644 --- a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx @@ -85,7 +85,7 @@ const DiffVirtualContent: Component = () => { - {`\u202A${directory()}/\u202C`} + {`\u2066${directory()}/\u2069`} {filename()} +{d().additions} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx index 110fe1b6fb0..1e2ce56c5cd 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx @@ -64,7 +64,7 @@ export const PermissionDiff: Component = (props) => {
- {directory() && {directory()}/} + {directory() && {`\u2066${directory()}/\u2069`}} {filename()}
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx index ce36fe84673..49fdda93a4a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx @@ -246,7 +246,7 @@ export const VscodeSessionTurn: Component = (props) => { - {`\u202A${getDirectory(diff.file)}\u202C`} + {`\u2066${getDirectory(diff.file)}\u2069`} {getFilename(diff.file)} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 63542d48e5b..527fcc92931 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1301,7 +1301,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
- {`\u202A${getDirectory(props.path)}\u202C`} + {`\u2066${getDirectory(props.path)}\u2069`} {getFilename(props.path)}
@@ -2112,7 +2112,7 @@ ToolRegistry.register({
- {`\u202A${getDirectory(file.relativePath)}\u202C`} + {`\u2066${getDirectory(file.relativePath)}\u2069`} {getFilename(file.relativePath)}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 398755d01f9..d874697c6c8 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -509,7 +509,7 @@ export const SessionReview = (props: SessionReviewProps) => {
- {`\u202A${getDirectory(file)}\u202C`} + {`\u2066${getDirectory(file)}\u2069`} {getFilename(file)} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 29301462ec3..92e5eab8e14 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -499,7 +499,7 @@ export function SessionTurn( - {`\u202A${getDirectory(diff.file)}\u202C`} + {`\u2066${getDirectory(diff.file)}\u2069`} {getFilename(diff.file)} From 0679fdfcd89a55cff546d159aa5aaa6d3af6fc51 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 15 Apr 2026 10:59:55 +0200 Subject: [PATCH 5/6] perf(agent-manager): skip git stats polling for worktrees in collapsed sections (#8903) Sync GitStatsPoller skip set with collapsed section state via pushState(), saving 1 aheadBehind subprocess + 1 diffSummary HTTP call per hidden worktree per poll cycle. Closes #8900 --- .../src/agent-manager/AgentManagerProvider.ts | 16 ++++++++++++++++ .../src/agent-manager/GitStatsPoller.ts | 10 ++++++---- .../src/agent-manager/WorktreeStateManager.ts | 9 +++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 382c7fe8ef6..0b0aac29b88 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -789,6 +789,7 @@ export class AgentManagerProvider implements Disposable { return null } // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree. + // Pre-emptive skip covers any in-flight poll that already captured getWorktrees(). this.statsPoller.skipWorktree(worktreeId) this.prBridge.remove(worktreeId) this.run.remove(worktreeId) @@ -1251,6 +1252,18 @@ export class AgentManagerProvider implements Disposable { } } + /** Sync the poller's skip set with currently collapsed sections. */ + private syncPollerSkips(): void { + const state = this.state + if (!state) return + const skipped = new Set() + for (const sec of state.getSections()) { + if (!sec.collapsed) continue + for (const id of state.getWorktreesInSection(sec.id)) skipped.add(id) + } + this.statsPoller.syncSkips(skipped) + } + private pushState(): void { const state = this.state if (!state) return @@ -1272,6 +1285,9 @@ export class AgentManagerProvider implements Disposable { ...run, }) + // Sync skip set before enabling the poller so the first poll cycle + // already excludes worktrees in collapsed sections. + this.syncPollerSkips() this.statsPoller.setEnabled(worktrees.length > 0 || this.panel !== undefined) this.prBridge.poller.setEnabled(worktrees.length > 0) } diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 8b283946f71..b815658168d 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -84,12 +84,14 @@ export class GitStatsPoller { } } - skipWorktree(id: string): void { - this.skipWorktreeIds.add(id) + /** Replace the entire skip set with the given IDs. */ + syncSkips(ids: Set): void { + this.skipWorktreeIds = ids } - unskipWorktree(id: string): void { - this.skipWorktreeIds.delete(id) + /** Pre-emptively exclude a single worktree (e.g. before deletion). */ + skipWorktree(id: string): void { + this.skipWorktreeIds.add(id) } setEnabled(enabled: boolean): void { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index eacf044ae56..e31affefe71 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -352,6 +352,15 @@ export class WorktreeStateManager { return this.sections.get(id) } + /** Return IDs of worktrees assigned to the given section. */ + getWorktreesInSection(id: string): string[] { + const result: string[] = [] + for (const wt of this.worktrees.values()) { + if (wt.sectionId === id) result.push(wt.id) + } + return result + } + addSection(name: string, color: string | null, worktreeIds?: string[]): Section { this.setNormalizedWorktreeOrder(this.worktreeOrder) const id = generateId("sec") From fb6beddffc38f06aa7e416d463417609f5d02eaa Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 15 Apr 2026 11:07:58 +0200 Subject: [PATCH 6/6] fix(agent-manager): preserve worktree sessions when backend listing fails (#8902) When the CLI backend is temporarily unhealthy, session.list() for worktree directories throws and the catch returns []. The webview reconciliation then deletes those sessions from the store, making them disappear. Track failed directories and pass preserveSessionIds so the webview skips deletion for sessions whose absence is transient. Closes #7466 --- .../kilo-vscode/src/kilo-provider-utils.ts | 12 +++ .../kilo-provider-session-refresh.test.ts | 80 +++++++++++++++++++ .../webview-ui/src/context/session.tsx | 8 +- .../webview-ui/src/types/messages.ts | 1 + 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 1b5d5812a53..1f9bf18fd3f 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -195,10 +195,12 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise() const extra = await Promise.all( [...worktreeDirs].map((dir) => list(dir).catch((err: unknown) => { console.error(`[Kilo] Failed to list sessions for ${dir}:`, err) + failed.add(dir) return [] as Session[] }), ), @@ -212,9 +214,19 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise sessionToWebview(s)), + ...(preserve.length ? { preserveSessionIds: preserve } : {}), }) return projectID diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index 7747f980adb..2dd31cf7b9b 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -157,6 +157,86 @@ describe("KiloProvider pending session refresh", () => { expect((sent[0] as { sessions: { id: string }[] }).sessions.map((s) => s.id)).toEqual(["ses_worktree"]) }) + it("preserves session ids when worktree directory listing fails", async () => { + const sent: unknown[] = [] + const ctx = createContext({ + connectionState: "connected", + sessionDirectories: new Map([ + ["ses_wt1", "/worktree1"], + ["ses_wt2", "/worktree2"], + ]), + listSessions: async (dir) => { + if (dir === "/repo") { + return [ + { + id: "ses_root", + projectID: "project", + title: "root", + directory: "/repo", + time: { created: 1, updated: 1 }, + }, + ] as never + } + if (dir === "/worktree1") throw new Error("backend not ready") + return [ + { + id: "ses_wt2", + projectID: "project", + title: "wt2", + directory: "/worktree2", + time: { created: 2, updated: 2 }, + }, + ] as never + }, + postMessage: (msg) => sent.push(msg), + }) + + await loadSessions(ctx) + + expect(sent).toHaveLength(1) + const msg = sent[0] as { sessions: { id: string }[]; preserveSessionIds?: string[] } + expect(msg.sessions.map((s) => s.id)).toEqual(["ses_root", "ses_wt2"]) + expect(msg.preserveSessionIds).toEqual(["ses_wt1"]) + }) + + it("omits preserveSessionIds when all directories succeed", async () => { + const sent: unknown[] = [] + const ctx = createContext({ + connectionState: "connected", + sessionDirectories: new Map([["ses_wt", "/worktree"]]), + listSessions: async (dir) => { + if (dir === "/repo") { + return [ + { + id: "ses_root", + projectID: "project", + title: "root", + directory: "/repo", + time: { created: 1, updated: 1 }, + }, + ] as never + } + return [ + { + id: "ses_wt", + projectID: "project", + title: "wt", + directory: "/worktree", + time: { created: 2, updated: 2 }, + }, + ] as never + }, + postMessage: (msg) => sent.push(msg), + }) + + await loadSessions(ctx) + + expect(sent).toHaveLength(1) + const msg = sent[0] as { sessions: { id: string }[]; preserveSessionIds?: string[] } + expect(msg.sessions.map((s) => s.id)).toEqual(["ses_root", "ses_wt"]) + expect(msg.preserveSessionIds).toBeUndefined() + }) + it("flushes deferred refresh via flushPendingSessionRefresh", async () => { const { calls, fn } = createListSessions() const ctx = createContext() diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 323cd509593..ed1c38b7ef3 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -687,7 +687,7 @@ export const SessionProvider: ParentComponent = (props) => { break case "sessionsLoaded": - handleSessionsLoaded(message.sessions) + handleSessionsLoaded(message.sessions, message.preserveSessionIds) break case "sessionUpdated": @@ -1120,16 +1120,20 @@ export const SessionProvider: ParentComponent = (props) => { setStore("todos", sessionID, items) } - function handleSessionsLoaded(loaded: SessionInfo[]) { + function handleSessionsLoaded(loaded: SessionInfo[], preserve?: string[]) { + const kept = preserve?.length ? new Set(preserve) : undefined batch(() => { // Reconcile: remove sessions not in the loaded list to prevent stale // entries from other projects accumulating in the store. + // Sessions whose worktree directories failed to list are preserved — + // their absence is transient, not a real deletion. const ids = new Set(loaded.map((s) => s.id)) setStore( "sessions", produce((sessions) => { for (const id of Object.keys(sessions)) { if (id.startsWith("cloud:")) continue + if (kept?.has(id)) continue if (!ids.has(id)) delete sessions[id] } }), diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index e14f5c01847..e2b194a4a03 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -580,6 +580,7 @@ export interface MessageCreatedMessage { export interface SessionsLoadedMessage { type: "sessionsLoaded" sessions: SessionInfo[] + preserveSessionIds?: string[] } export interface CloudSessionsLoadedMessage {