From 53d0af74f61c1d5760ad10d0fddafde6ddef4bad Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 14:29:53 +0200 Subject: [PATCH 1/5] feat(vscode): add subagent inspector tabs --- .changeset/subagent-inspector-tabs.md | 5 + .../tests/unit/agent-manager-arch.test.ts | 3 + .../agent-manager-terminal-layout.test.ts | 15 +- .../tests/unit/subagent-tabs.test.ts | 84 ++++++ .../agent-manager/AgentManagerApp.tsx | 47 +++- .../webview-ui/agent-manager/ClosableTab.tsx | 155 +++++++++++ .../agent-manager/InspectorTabStrip.tsx | 108 ++++++++ .../agent-manager/SubagentPanel.tsx | 120 +++++++++ .../agent-manager/agent-manager.css | 97 +++++-- .../webview-ui/agent-manager/index.tsx | 5 + .../agent-manager/side-panel-layout.ts | 1 + .../webview-ui/agent-manager/subagent-tabs.ts | 84 ++++++ .../terminal/SideTerminalPanel.tsx | 244 +++++------------ .../terminal/SortableTerminalTab.tsx | 250 ++++++------------ .../agent-manager/terminal/render.tsx | 5 +- .../src/components/chat/SessionTabMenu.tsx | 23 +- .../src/components/chat/TaskToolExpanded.tsx | 15 +- .../webview-ui/src/context/session.tsx | 6 +- 18 files changed, 880 insertions(+), 387 deletions(-) create mode 100644 .changeset/subagent-inspector-tabs.md create mode 100644 packages/kilo-vscode/tests/unit/subagent-tabs.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/ClosableTab.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/InspectorTabStrip.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts diff --git a/.changeset/subagent-inspector-tabs.md b/.changeset/subagent-inspector-tabs.md new file mode 100644 index 00000000000..0ab93ea7e5f --- /dev/null +++ b/.changeset/subagent-inspector-tabs.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Open delegated subagent sessions in Agent Manager inspector tabs alongside terminals. 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 37ad20e2070..84c63ad8d3b 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -21,6 +21,7 @@ const CSS_FILES = [ ] const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"), + path.join(ROOT, "webview-ui/agent-manager/SubagentPanel.tsx"), path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"), path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"), @@ -51,6 +52,8 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/SidebarBody.tsx"), path.join(ROOT, "webview-ui/agent-manager/Skeleton.tsx"), path.join(ROOT, "webview-ui/agent-manager/TabBar.tsx"), + path.join(ROOT, "webview-ui/agent-manager/ClosableTab.tsx"), + path.join(ROOT, "webview-ui/agent-manager/InspectorTabStrip.tsx"), path.join(ROOT, "webview-ui/agent-manager/ProjectBranchDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/DefaultBaseBranchDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"), 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 e39170102d1..263ac2a3997 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 @@ -10,6 +10,7 @@ import { const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8") const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8") +const subagent = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/SubagentPanel.tsx"), "utf8") const terminal = readFileSync( resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"), "utf8", @@ -25,13 +26,25 @@ test("xterm owns the padding used by FitAddon", () => { expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/) }) -test("uses one persisted width for the diff and terminal inspector", () => { +test("uses one persisted width for every inspector panel", () => { expect(app).toContain("persisted?.sidePanelWidth") expect(app).toContain("createPanelResize(setPanelWidth") + expect(app).toContain("style={{ width: `${panelWidth()}px` }}") + expect(subagent).toContain("InspectorTabStrip") expect(app).not.toContain("diffWidth") expect(app).not.toContain("terminalWidth") }) +test("hides keyboard hints only in inspector tabs", () => { + const side = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), "utf8") + + expect(subagent).toContain("showKeybind={false}") + expect(side).toContain("showKeybind={false}") + expect(readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/render.tsx"), "utf8")).not.toContain( + "showKeybind={false}", + ) +}) + test("limits inspector layout updates during resize", () => { const frames: ((time: number) => void)[] = [] const widths: number[] = [] diff --git a/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts b/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts new file mode 100644 index 00000000000..5276fac4cbd --- /dev/null +++ b/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createSubagentTabs } from "../../webview-ui/agent-manager/subagent-tabs" + +function scene() { + const [current] = createSignal("parent") + const calls = { synced: [] as Array<[string, string | undefined]>, shown: 0, hidden: 0 } + const tabs = createSubagentTabs({ + current, + sync: (id, parent) => calls.synced.push([id, parent]), + show: () => calls.shown++, + hide: () => calls.hidden++, + }) + return { tabs, calls } +} + +describe("Agent Manager subagent tabs", () => { + it("opens multiple child sessions and syncs each to its parent", () => { + createRoot((dispose) => { + const item = scene() + item.tabs.open("child-1", "First", "parent-1") + item.tabs.open("child-2", "Second", "parent-2") + + expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["child-1", "child-2"]) + expect(item.tabs.active()).toBe("child-2") + expect(item.calls.synced).toEqual([ + ["child-1", "parent-1"], + ["child-2", "parent-2"], + ]) + expect(item.calls.shown).toBe(2) + dispose() + }) + }) + + it("closes the active tab onto its nearest survivor and hides when empty", () => { + createRoot((dispose) => { + const item = scene() + item.tabs.open("one", "One") + item.tabs.open("two", "Two") + item.tabs.open("three", "Three") + + item.tabs.close("two") + expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one", "three"]) + expect(item.tabs.active()).toBe("three") + + item.tabs.close("three") + item.tabs.close("one") + expect(item.tabs.tabs()).toEqual([]) + expect(item.tabs.active()).toBeUndefined() + expect(item.calls.hidden).toBe(1) + dispose() + }) + }) + + it("supports Close Others and preserves the selected child", () => { + createRoot((dispose) => { + const item = scene() + item.tabs.open("one") + item.tabs.open("two") + item.tabs.open("three") + + item.tabs.closeOthers("one") + expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one"]) + expect(item.tabs.active()).toBe("one") + expect(item.calls.shown).toBe(4) + dispose() + }) + }) + + it("reorders tabs without changing the active child", () => { + createRoot((dispose) => { + const item = scene() + item.tabs.open("one") + item.tabs.open("two") + item.tabs.open("three") + item.tabs.select("two") + + item.tabs.reorder("three", "one") + expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["three", "one", "two"]) + expect(item.tabs.active()).toBe("two") + dispose() + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 29f979dd05b..a1c5cd9ef6e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -178,6 +178,8 @@ import { initialMessage, seedInitialVariant } from "./initial-message" import { SidebarToggleButton } from "./SidebarToggleButton" import { setTabWidths } from "./tab-widths" import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout" +import { SubagentPanel } from "./SubagentPanel" +import { createSubagentTabs } from "./subagent-tabs" import { buildShortcutCategories } from "./shortcuts" import { tracker } from "./telemetry" import { createChatFocus, createPromptFocus, hasQuestionOption } from "./focus" @@ -306,8 +308,8 @@ const AgentManagerContent: Component = () => { const diffLoading = diffs.diffLoading const setDiffLoading = diffs.setDiffLoading const diffNotices = diffs.diffNotices - // Diff and terminal views share one inspector width, restored from webview - // state so the user's divider position survives panel reloads. + // Diff, PR, terminal, and subagent views share one inspector width, restored + // from webview state so the user's divider position survives panel reloads. const [panelWidth, setPanelWidth] = createSignal(clampPanelWidth(persisted?.sidePanelWidth, window.innerWidth)) const resizeSide = createPanelResize(setPanelWidth, () => window.innerWidth) const showSideTerminal = () => { @@ -321,6 +323,16 @@ const AgentManagerContent: Component = () => { const reviewComposer = createReviewComposer() const [reviewActive, setReviewActive] = createSignal(false) const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified") + const subagents = createSubagentTabs({ + current: session.currentSessionID, + sync: session.syncSession, + show: () => { + setHistory(false) + setReviewActive(false) + setSidePanel(SidePanel.Subagents) + }, + hide: () => setSidePanel(null), + }) const markdown = createMarkdownRender(vscode) // Per-worktree git stats (diff additions/deletions, commits missing from origin) const worktreeStats = () => registry.active().worktreeStats() @@ -1214,7 +1226,17 @@ const AgentManagerContent: Component = () => { if (match) projectNav.jump(parseInt(match[1]!) - 1) } } + const subagent = (event: Event) => { + const detail = (event as CustomEvent<{ sessionID?: unknown; title?: unknown; parentSessionID?: unknown }>).detail + if (typeof detail?.sessionID !== "string") return + subagents.open( + detail.sessionID, + typeof detail.title === "string" ? detail.title : undefined, + typeof detail.parentSessionID === "string" ? detail.parentSessionID : undefined, + ) + } window.addEventListener("message", handler) + window.addEventListener("agentManager.openSubagent", subagent) // Prevent Cmd/Ctrl shortcuts from triggering native browser actions const preventDefaults = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey)) return @@ -1263,6 +1285,7 @@ const AgentManagerContent: Component = () => { confirmDeleteWorktree(sel) } window.addEventListener("keydown", deleteKeyHandler) + onCleanup(() => window.removeEventListener("agentManager.openSubagent", subagent)) // Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held. // Capture phase so the terminal's key handlers can't swallow them; blur resets state @@ -2157,6 +2180,10 @@ const AgentManagerContent: Component = () => { // Close the currently active tab via keyboard shortcut. // If no tabs remain, fall through to close the selected worktree. const closeActiveTab = () => { + if (sidePanel() === SidePanel.Subagents && subagents.active()) { + subagents.close(subagents.active()!) + return + } // A focused side terminal owns Cmd+W while its panel is visible. // Closing a chat tab out from under the user's cursor would be surprising. if (sidePanel() === SidePanel.Terminal && terms.sideFocusedId()) { @@ -2587,7 +2614,7 @@ 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}> + 0 || subagents.tabs().length > 0}>
{ } /> + 0}> + sidePanel() === SidePanel.Subagents} + nextKeybind={kb().nextTab ?? ""} + closeKeybind={kb().closeTab ?? ""} + onSelect={subagents.select} + onClose={subagents.close} + onCloseOthers={subagents.closeOthers} + onReorder={subagents.reorder} + onClosePanel={() => setSidePanel(null)} + /> + = T | (() => T) + +function value(input: Value): T { + return typeof input === "function" ? (input as () => T)() : input +} + +export interface ClosableTabProps { + id?: string + label: Value + tooltip: Value + icon: Value + iconStatus?: Value<"success" | "failure" | undefined> + class?: string + focused?: boolean + active: boolean + closeable?: boolean + showKeybind?: boolean + keybind?: string + closeKeybind?: string + role?: "tab" + selected?: boolean + tabIndex?: number + onKeyDown?: JSX.EventHandlerUnion + onSelect: () => void + onMiddleClick?: (event: MouseEvent) => void + onClose: () => void + trailing?: JSX.Element +} + +export const ClosableTabChrome: Component = (props) => { + const { t } = useLanguage() + const label = () => value(props.label) + const tooltip = () => value(props.tooltip) + const icon = () => value(props.icon) + const status = () => (props.iconStatus ? value(props.iconStatus) : undefined) + const keybind = () => (props.showKeybind === false ? "" : (props.keybind ?? "")) + const closeKeybind = () => (props.showKeybind === false ? "" : (props.closeKeybind ?? "")) + return ( +
+
+ + + + }> + + + + {label()} + + +
+ {props.trailing} + + + { + event.stopPropagation() + props.onClose() + }} + /> + + +
+ ) +} + +export const SortableClosableTab: Component< + ClosableTabProps & { + id: string + onCloseOthers: () => void + } +> = (props) => ( + + + {parseBindingTokens(props.closeKeybind).map((token) => ( + {token} + ))} + + ) : undefined + } + > + + + +) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/InspectorTabStrip.tsx b/packages/kilo-vscode/webview-ui/agent-manager/InspectorTabStrip.tsx new file mode 100644 index 00000000000..5ec8efac2fd --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/InspectorTabStrip.tsx @@ -0,0 +1,108 @@ +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" +import { For, Show, createSignal, type Accessor, type Component, type JSX } from "solid-js" +import { ConstrainDragYAxis } from "../src/components/chat/TabDnd" +import { createTabFocus } from "../src/utils/tab-navigation" +import { useTabScroll } from "../src/utils/tab-scroll" +import { setTabWidths } from "../src/utils/tab-widths" + +const TABLIST = ".am-inspector-tablist" + +type InspectorTabFocus = ReturnType + +interface InspectorTabStripApi { + focus: InspectorTabFocus + freeze: () => void + release: () => void +} + +interface Props { + ids: Accessor + active: Accessor + label: string + renderTab: (id: string, api: InspectorTabStripApi) => JSX.Element + overlay: (id: string) => string + onSelect: (id: string) => void + onReorder: (from: string, to: string) => void + action?: (api: InspectorTabStripApi) => JSX.Element +} + +export const InspectorTabStrip: Component = (props) => { + let host!: HTMLDivElement + const scroll = useTabScroll(props.ids, props.active) + const focus = createTabFocus({ ids: props.ids, select: props.onSelect, root: () => host }) + const [dragging, setDragging] = createSignal<{ id: string; width: number }>() + const freeze = () => setTabWidths(true, host, TABLIST) + const release = () => setTabWidths(false, host, TABLIST) + const api = { focus, freeze, release } + const start = (event: DragEvent) => { + const id = event.draggable?.id + if (typeof id !== "string") return + const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width + freeze() + setDragging({ id, width }) + } + const end = () => { + setDragging(undefined) + release() + } + const over = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id + if (typeof from !== "string" || typeof to !== "string") return + props.onReorder(from, to) + } + + return ( +
{ + if (event.target instanceof Element && event.target.closest(".am-tab-close[data-tab-close]")) freeze() + }} + onPointerLeave={() => { + if (!dragging()) release() + }} + > + + + +
+
+
+
{ + scroll.setRef(el) + }} + role={props.ids().length > 0 ? "tablist" : undefined} + aria-label={props.ids().length > 0 ? props.label : undefined} + style={{ "--tab-count": `${props.ids().length}` } as JSX.CSSProperties} + > + + {(id) => props.renderTab(id, api)} + +
+
+
+
+ + + {(tab) => ( +
+ {props.overlay(tab().id)} +
+ )} +
+
+ + {props.action?.(api)} +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx new file mode 100644 index 00000000000..3e8b39c4a2c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -0,0 +1,120 @@ +/** + * Read-only subagent chats for the Agent Manager inspector. + * + * The nested session provider keeps the parent chat selection independent from + * the child transcript while still consuming the same webview event stream. + */ + +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { createEffect, type Accessor, type Component } from "solid-js" +import { DataBridge } from "../src/App" +import { ChatView } from "../src/components/chat" +import { SessionProvider, useSession } from "../src/context/session" +import { SortableClosableTab } from "./ClosableTab" +import { InspectorTabStrip } from "./InspectorTabStrip" +import type { SubagentTab } from "./subagent-tabs" + +interface Props { + tabs: Accessor + active: Accessor + visible: Accessor + nextKeybind: string + closeKeybind: string + onSelect: (id: string) => void + onClose: (id: string) => void + onCloseOthers: (id: string) => void + onReorder: (from: string, to: string) => void + onClosePanel: () => void +} + +const SubagentChat: Component<{ active: Accessor }> = (props) => { + const session = useSession() + + createEffect(() => { + const id = props.active() + if (!id) return + session.selectSession(id) + }) + + return ( + + + + ) +} + +export const SubagentPanel: Component = (props) => { + const ids = () => props.tabs().map((tab) => tab.id) + const title = (id: string) => props.tabs().find((tab) => tab.id === id)?.title ?? "Sub-agent" + const close = (id: string, focus: { restore: () => void }) => { + props.onClose(id) + if (ids().length > 0) focus.restore() + } + + return ( + +
+
+
+ + Subagents + {props.tabs().length} +
+ +
+ { + const label = title(id) + return ( + api.focus.key(id, event)} + onSelect={() => props.onSelect(id)} + onMiddleClick={(event) => { + if (event.button !== 1) return + event.preventDefault() + event.stopPropagation() + close(id, api.focus) + }} + onClose={() => close(id, api.focus)} + onCloseOthers={() => props.onCloseOthers(id)} + /> + ) + }} + /> +
+ +
+
+
+ ) +} 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 e58b9c7b608..3dfbe58ecda 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1514,7 +1514,7 @@ html[data-theme="kilo-vscode"] color: var(--vscode-testing-iconFailed, #f87171); } -.am-terminal-tab-spinner { +.am-tab-spinner { width: 12px; height: 12px; } @@ -4841,8 +4841,9 @@ body.vscode-high-contrast-light { } } -/* Experimental terminal tabs (feature-flagged) */ +/* Shared sortable inspector tabs. */ +.am-tab-closable, .am-tab-terminal { display: flex; align-items: center; @@ -4850,12 +4851,13 @@ body.vscode-high-contrast-light { border-left: 1px solid var(--border-weak-base); } +.am-tab-closable [data-component="icon"], .am-tab-terminal [data-component="icon"] { flex-shrink: 0; opacity: 0.7; } -.am-tab-terminal-focused { +.am-tab-focused { background: var(--surface-base-hover); } @@ -4978,15 +4980,14 @@ body.vscode-high-contrast-light { color: var(--text-weak); } -/* Side terminal tab strip — one row of tabs reusing the top bar's - .am-tab chrome, plus the "+" action. Height matches .am-diff-header +/* Inspector tab strip — one row of tabs reusing the top bar's + .am-tab chrome, plus an optional action. Height matches .am-diff-header (32px) so switching inspector modes does not shift the panel chrome. No vertical padding: tabs fill the strip like they fill .am-tab-bar, - which also keeps the "+" optically centered. The strip itself never - scrolls; the tab list does, so a narrow panel never pushes the "+" - action out of view (same split as .am-tab-list-wrap / - .am-tab-add-wrap). */ -.am-side-terminal-tabs { + which also keeps actions optically centered. The strip itself never + scrolls; the tab list does, so a narrow panel never pushes an action out + of view (same split as .am-tab-list-wrap / .am-tab-add-wrap). */ +.am-inspector-tabs { display: flex; align-items: stretch; height: 32px; @@ -5001,10 +5002,10 @@ body.vscode-high-contrast-light { /* Same width model as .am-tab-list: tabs claim an equal share of the strip up to a maximum, and the list itself only grows as wide as its - tabs, so the "+" action stays glued to the last tab instead of + tabs, so an action stays glued to the last tab instead of drifting to the far edge of a wide panel. The cap is smaller than the top bar's 240px because the panel is narrow. */ -.am-side-terminal-tablist { +.am-inspector-tablist { --am-tab-max-width: 180px; --am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width)); display: flex; @@ -5020,17 +5021,16 @@ body.vscode-high-contrast-light { scrollbar-width: none; } -.am-side-terminal-tablist::-webkit-scrollbar { +.am-inspector-tablist::-webkit-scrollbar { display: none; } -.am-side-terminal-tablist[data-tab-widths-frozen] .am-tab-sortable { +.am-inspector-tablist[data-tab-widths-frozen] .am-tab-sortable { transition: none; } -/* The left divider marks a terminal among session tabs in the top bar. - Every tab here is a terminal, so it would just be noise. */ -.am-side-terminal-tablist .am-tab-terminal { +/* The left divider belongs to the top-level mixed tab bar. */ +.am-inspector-tablist .am-tab-closable { border-left-color: transparent; } @@ -5054,6 +5054,69 @@ body.vscode-high-contrast-light { pointer-events: none; } +/* Subagent inspector panel. It remains mounted while another inspector mode + is active so switching back keeps the child transcript and scroll position. */ +.am-subagent-panel { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + opacity: 0; + pointer-events: none; + z-index: 1; + background: var(--surface-base); + will-change: opacity; +} + +.am-subagent-panel-visible { + opacity: 1; + pointer-events: auto; +} + +.am-subagent-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + height: 32px; + padding: 0 4px 0 8px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-weak-base); + background: var(--surface-base); +} + +.am-subagent-heading { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; + color: var(--text-base); + font-size: var(--font-size-small); + font-weight: 600; +} + +.am-subagent-count { + color: var(--text-weak); + font-size: var(--kilo-font-size-10); + font-variant-numeric: tabular-nums; +} + +.am-subagent-chat { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; +} + +.am-subagent-chat > [data-component="data-provider"], +.am-subagent-chat [class~="chat-view"] { + min-width: 0; + min-height: 0; + flex: 1; +} + .am-terminal-host { flex: 1; min-height: 0; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/index.tsx b/packages/kilo-vscode/webview-ui/agent-manager/index.tsx index 46518799928..dd358a2859c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/index.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/index.tsx @@ -5,8 +5,13 @@ import { render } from "solid-js/web" import "@kilocode/kilo-ui/styles" import "../src/styles/chat.css" +import { registerExpandedTaskTool } from "../src/components/chat/TaskToolExpanded" +import { registerVscodeToolOverrides } from "../src/components/chat/VscodeToolOverrides" import { AgentManagerApp } from "./AgentManagerApp" +registerExpandedTaskTool() +registerVscodeToolOverrides() + const root = document.getElementById("root") if (root) { render(() => , root) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts index 2fb492bcfa7..d89341e31f3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts @@ -9,6 +9,7 @@ export enum SidePanel { Diff = "diff", PR = "pr", Terminal = "terminal", + Subagents = "subagents", } function viewportWidth(viewport: number): number { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts new file mode 100644 index 00000000000..db94aa8beeb --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts @@ -0,0 +1,84 @@ +import { batch, createSignal, type Accessor } from "solid-js" +import { reorderTabs } from "../src/utils/tab-order" + +export interface SubagentTab { + id: string + title: string +} + +interface Options { + current: Accessor + sync: (id: string, parentID?: string) => void + show: () => void + hide: () => void +} + +export function createSubagentTabs(opts: Options) { + const [tabs, setTabs] = createSignal([]) + const [active, setActive] = createSignal() + + const open = (id: string, title?: string, parentID?: string) => { + if (!id) return + const label = title?.trim() || "Sub-agent" + batch(() => { + setTabs((prev) => { + const existing = prev.find((tab) => tab.id === id) + if (!existing) return [...prev, { id, title: label }] + if (title?.trim() && existing.title !== label) { + return prev.map((tab) => (tab.id === id ? { ...tab, title: label } : tab)) + } + return prev + }) + setActive(id) + opts.show() + }) + opts.sync(id, parentID ?? opts.current()) + } + + const select = (id: string) => { + if (!tabs().some((tab) => tab.id === id)) return + setActive(id) + opts.show() + } + + const close = (id: string) => { + const current = tabs() + const index = current.findIndex((tab) => tab.id === id) + if (index < 0) return + const next = current.filter((tab) => tab.id !== id) + setTabs(next) + if (active() !== id) return + const replacement = next[Math.min(index, next.length - 1)] + if (replacement) { + setActive(replacement.id) + return + } + setActive(undefined) + opts.hide() + } + + const closeOthers = (id: string) => { + if (!tabs().some((tab) => tab.id === id)) return + setTabs((prev) => prev.filter((tab) => tab.id === id)) + setActive(id) + opts.show() + } + + const reorder = (from: string, to: string) => { + const order = reorderTabs( + tabs().map((tab) => tab.id), + from, + to, + ) + if (!order) return + setTabs((prev) => { + const lookup = new Map(prev.map((tab) => [tab.id, tab])) + return order.flatMap((id) => { + const tab = lookup.get(id) + return tab ? [tab] : [] + }) + }) + } + + return { tabs, active, open, select, close, closeOthers, reorder } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx index b5986463466..06fbcebfffc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -1,48 +1,26 @@ /** * Right-side terminal panel for the Agent Manager inspector. * - * Lives inside the shared `.am-diff-panel-wrapper` host next to the diff - * and PR panels, so all three inspector modes share one resize handle - * and one width. + * Lives inside the shared inspector host next to diff, PR, and subagent + * panels, so every mode uses the same persisted resize width. The tab row is + * the shared inspector strip used by subagents as well. * - * A context can own several side terminals. The header is a tab strip - * that reuses the top tab bar's whole chrome: `SortableTerminalTab` - * (icon, title, X close, right-click Close / Close Others), the same - * `@thisbeyond/solid-dnd` reorder stack, the same overflow scrolling - * with edge fades, the same width freeze while tabs close, and the same - * arrow-key tab navigation, so a terminal behaves identically in - * either surface. Reorder state lives in the terminal state, so it is - * preserved across sidebar context switches for the webview's lifetime. - * - * The `+` action sits directly after the last tab (outside the - * scrolling region, like the tab bar's `am-tab-add-wrap`), so it never - * scrolls away and never drifts to the far edge of a wide panel. The - * strip stays visible even when empty so `+` is always reachable. - * - * Visibility is opacity-based, never unmount: the xterm render loop - * dies when its subtree leaves the paint tree (see `render.tsx`). + * Visibility is opacity-based, never unmount: the xterm render loop dies when + * its subtree leaves the paint tree (see `render.tsx`). */ -import type { Accessor, Component, JSX } from "solid-js" -import { For, Show, createEffect, createSignal } from "solid-js" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" -import { IconButton } from "@kilocode/kilo-ui/icon-button" +import type { Accessor, Component } from "solid-js" +import { Show, createEffect } from "solid-js" 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 { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useLanguage } from "../../src/context/language" -import { ConstrainDragYAxis } from "../../src/components/chat/TabDnd" -import { useTabScroll } from "../../src/utils/tab-scroll" -import { setTabWidths } from "../../src/utils/tab-widths" -import { createTabFocus } from "../../src/utils/tab-navigation" +import { InspectorTabStrip } from "../InspectorTabStrip" import { renderSideTerminalLayer } from "./render" import { SortableTerminalTab } from "./SortableTerminalTab" import type { TerminalStateControls } from "./state" -/** Only this strip's tabs freeze; the top tab bar keeps its own widths. */ -const TABLIST = ".am-side-terminal-tablist" - interface Props { state: TerminalStateControls /** Context the panel currently shows (`state.sideKey`). */ @@ -67,65 +45,18 @@ interface Props { export const SideTerminalPanel: Component = (props) => { const { t } = useLanguage() let panel!: HTMLElement - let strip!: HTMLDivElement createEffect(() => { panel.inert = !props.visible() }) - const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>() const sides = () => props.state.sidesForContext(props.contextKey()) const ids = () => sides().map((term) => term.id) const active = () => props.state.sideActiveFor(props.contextKey()) const pending = () => props.state.pendingSide(props.contextKey()) - const scroll = useTabScroll(ids, active) - // Scoped to `strip` so arrow keys and focus restore never jump to a - // tab in the top bar, which uses the same role="tab" markup. - const focus = createTabFocus({ ids, select: props.onSelect, root: () => strip }) - // Only freeze while the pointer is over the strip: the widths must - // survive until the pointer leaves, so the remaining X buttons stay - // put across repeated closes. Releasing on the next frame would undo - // the freeze before it is ever painted (rAF runs before paint). - // "Close others" needs none of this: its context menu is portaled, so - // the pointer is off the strip, and the survivor spans the strip anyway. - const freeze = () => { - if (strip.closest(".am-side-terminal-tabs")?.matches(":hover")) setTabWidths(true, document, TABLIST) - } - const release = () => setTabWidths(false, document, TABLIST) - const close = (id: string) => { - freeze() + const close = (id: string, focus: { restore: () => void }) => { props.onClose(id) - // Restore focus inside the strip only while it still owns a tab. - // Falling through to `focusPrompt` would pull focus into the chat - // composer while the panel is still open on its empty state. if (ids().length > 0) focus.restore() } - // Adding a tab shrinks every tab's equal share, so any freeze left - // over from a close in the same hover has to go first. `+` lives - // inside the strip, so no pointerleave happens between the two - // clicks and the surviving tabs would keep their wider pixel widths. - const start = () => { - release() - props.onStart() - } - const onDragStart = (event: DragEvent) => { - const id = event.draggable?.id - if (typeof id !== "string") return - // Pin the overlay to the tab's width: the overlay container uses - // min-width, so a long OSC title would otherwise overflow it and - // shift the visual center off the cursor (the "drag offset" bug). - const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width - setTabWidths(true, document, TABLIST) - setDragging({ id, width }) - } - const onDragEnd = () => { - setDragging(undefined) - release() - } - const onDragOver = (event: DragEvent) => { - const from = event.draggable?.id - const to = event.droppable?.id - if (typeof from !== "string" || typeof to !== "string") return - props.state.reorderSideDrag(props.contextKey(), from, to) - } + return (
= (props) => { aria-label={t("agentManager.tab.terminal")} aria-hidden={!props.visible()} > -
{ - if (!dragging()) release() + props.state.title(id) ?? t("agentManager.tab.terminal")} + onSelect={props.onSelect} + onReorder={(from, to) => props.state.reorderSideDrag(props.contextKey(), from, to)} + renderTab={(id, api) => { + const term = sides().find((item) => item.id === id) + if (!term) return null + return ( + api.focus.key(term.id, event)} + onSelect={() => props.onSelect(term.id)} + onMiddleClick={(event) => { + if (event.button !== 1) return + event.preventDefault() + event.stopPropagation() + close(term.id, api.focus) + }} + onClose={() => close(term.id, api.focus)} + onCloseOthers={() => props.onCloseOthers(term.id)} + onStop={(event) => { + event.stopPropagation() + props.onStop(term.id) + }} + /> + ) }} - > - - - - {/* Overflow chrome copied from the top tab bar: the list is the - only scrolling element, wrapped by a fade host, so the "+" - action stays pinned next to the last tab. */} -
-
-
- {/* role="tablist" only when tabs exist: axe - aria-required-children rejects an empty tablist. */} -
{ - strip = el - scroll.setRef(el) + action={(api) => ( +
+ + { + api.release() + props.onStart() }} - role={sides().length > 0 ? "tablist" : undefined} - aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined} - style={{ "--tab-count": `${sides().length}` } as JSX.CSSProperties} - > - - - {(term) => ( - focus.key(term.id, event)} - onSelect={() => props.onSelect(term.id)} - onMiddleClick={(e: MouseEvent) => { - if (e.button !== 1) return - e.preventDefault() - e.stopPropagation() - close(term.id) - }} - onClose={(e: MouseEvent) => { - e.stopPropagation() - close(term.id) - }} - onCloseOthers={() => props.onCloseOthers(term.id)} - onStop={(e: MouseEvent) => { - e.stopPropagation() - props.onStop(term.id) - }} - /> - )} - - -
-
-
+ /> +
- {/* Cursor-following clone of the dragged tab (same pattern as - the top tab bar). The overlay is what makes the in-list - original use solid-dnd's slot-compensated transform, so the - dragged tab tracks the cursor without a jump/offset. The - original stays dimmed in its slot via .am-tab-dragging. */} - - - {(tab) => ( -
- {props.state.title(tab().id) ?? t("agentManager.tab.terminal")} -
- )} -
-
- -
- - - -
-
+ )} + /> {renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx index 99b25001d2d..25e4ffd0f2e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx @@ -1,191 +1,99 @@ /** - * Tab chrome for xterm terminals. + * Terminal-specific adapter for the shared sortable inspector tab. * - * `TerminalTabChrome` is the shared visual tab: console icon, title, - * tooltip/keybinding hints, and the X close button — the same - * `am-tab*` structure the session tabs use. `SortableTerminalTab` - * wraps it with drag-and-drop and a right-click context menu; both the - * top tab bar and the side terminal panel render that wrapper, so a - * terminal tab behaves identically in either surface. + * PTY status determines the icon and whether a Setup tab can be closed. The + * tab chrome, drag wrapper, context menu, and close behavior are shared with + * subagent tabs. */ -import { Component, Show, type JSX } from "solid-js" +import { Show, type Component } from "solid-js" import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Icon } from "@kilocode/kilo-ui/icon" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" -import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { useLanguage } from "../../src/context/language" -import { SortableTabContainer } from "../../src/components/chat/TabDnd" -import { parseBindingTokens } from "../keybind-tokens" +import { SortableClosableTab, type ClosableTabProps } from "../ClosableTab" import { terminalChrome, terminalClosable, terminalStoppable } from "./chrome" import type { ScriptTerminalStatus } from "./state" -export const TerminalTabChrome: Component<{ +interface Props extends Omit { label: string tooltip: string status?: ScriptTerminalStatus - keybind?: string - closeKeybind?: string - focused?: boolean - active: boolean - role?: "tab" - selected?: boolean - tabIndex?: number - onKeyDown?: JSX.EventHandlerUnion - onSelect: () => void - onMiddleClick?: (e: MouseEvent) => void - onClose: (e: MouseEvent) => void - onStop?: (e: MouseEvent) => void -}> = (props) => { + onClose: () => void + onStop?: (event: MouseEvent) => void +} + +const StopButton: Component<{ active: boolean; tabIndex: number; onStop?: (event: MouseEvent) => void }> = (props) => { const { t } = useLanguage() - const chrome = () => terminalChrome(props.tooltip, props.status) - const icon = () => { - const kind = chrome().icon - if (kind === "success") return "check-small" - if (kind === "failure") return "warning" - return "console" - } return ( -
-
+ - - - - }> - - - - {props.label} - - -
- - - - - - - - - - -
+ { + event.stopPropagation() + props.onStop?.(event) + }} + /> + + ) } -export const SortableTerminalTab: Component<{ - id: string - label: string - tooltip: string - status?: ScriptTerminalStatus - keybind?: string - closeKeybind?: string - focused?: boolean - active: boolean - role?: "tab" - selected?: boolean - tabIndex?: number - onKeyDown?: JSX.EventHandlerUnion - onSelect: () => void - onMiddleClick: (e: MouseEvent) => void - onClose: (e: MouseEvent) => void - onCloseOthers: () => void - onStop?: (e: MouseEvent) => void -}> = (props) => { - const { t } = useLanguage() - return ( - - - - - - - - props.onClose(new MouseEvent("click", { bubbles: true, cancelable: true }) as MouseEvent)} - > - - {t("agentManager.tab.close")} - - - {parseBindingTokens(props.closeKeybind ?? "").map((token) => ( - {token} - ))} - - - - - - {t("agentManager.tab.closeOthers")} - - - - - - ) +function icon(status: ScriptTerminalStatus | undefined) { + const value = terminalChrome("", status).icon + if (value === "success") return "check-small" as const + if (value === "failure") return "warning" as const + if (value === "spinner") return "spinner" as const + return "console" as const +} + +function iconStatus(status: ScriptTerminalStatus | undefined) { + const value = terminalChrome("", status).icon + if (value === "success") return "success" as const + if (value === "failure") return "failure" as const + return undefined } + +export const SortableTerminalTab: Component< + Props & { + id: string + onCloseOthers: () => void + } +> = (props) => ( + icon(props.status)} + iconStatus={() => iconStatus(props.status)} + class="am-tab-terminal" + focused={props.focused} + active={props.active} + closeable={terminalClosable(props.status)} + keybind={props.keybind} + closeKeybind={props.closeKeybind} + role={props.role} + selected={props.selected} + tabIndex={props.tabIndex} + onKeyDown={props.onKeyDown} + onSelect={props.onSelect} + onMiddleClick={props.onMiddleClick} + onClose={props.onClose} + onCloseOthers={props.onCloseOthers} + trailing={ + + } + /> +) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx index 611f9d20139..032147fa63f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx @@ -59,10 +59,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element { onKeyDown={deps.onKeyDown} onSelect={() => deps.onSelect(deps.id)} onMiddleClick={(e: MouseEvent) => deps.onMiddleClick(deps.id, e)} - onClose={(e: MouseEvent) => { - e.stopPropagation() - deps.onClose(deps.id) - }} + onClose={() => deps.onClose(deps.id)} onCloseOthers={() => deps.onCloseOthers(deps.id)} /> ) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx index 1e03df08a58..dd03d3e796c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx @@ -8,6 +8,7 @@ export const SessionTabMenu: ParentComponent<{ onFork?: () => void onClose: () => void onCloseOthers?: () => void + closeable?: boolean closeShortcut?: JSX.Element }> = (props) => { const { t } = useLanguage() @@ -23,18 +24,22 @@ export const SessionTabMenu: ParentComponent<{ {t("agentManager.tab.forkSession")} - + + + - - - {t("agentManager.tab.close")} - {props.closeShortcut} - - - props.onCloseOthers?.()}> + + - {t("agentManager.tab.closeOthers")} + {t("agentManager.tab.close")} + {props.closeShortcut} + + props.onCloseOthers?.()}> + + {t("agentManager.tab.closeOthers")} + + 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 5138f071c9e..8ad09e38079 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -18,6 +18,7 @@ import { useI18n } from "@kilocode/kilo-ui/context/i18n" 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 { taskResult, taskRunning, taskVisible } from "./task-tool-state" @@ -26,6 +27,7 @@ const TaskToolRenderer: Component = (props) => { const language = useLanguage() const session = useSession() const vscode = useVSCode() + const worktree = useWorktreeMode() const childSessionId = () => childID({ @@ -115,7 +117,16 @@ const TaskToolRenderer: Component = (props) => { e.stopPropagation() const id = childSessionId() if (!id) return - vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title: description() }) + const title = description() + if (worktree) { + window.dispatchEvent( + new CustomEvent("agentManager.openSubagent", { + detail: { sessionID: id, title, parentSessionID: session.currentSessionID() }, + }), + ) + return + } + vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title }) } const trigger = () => ( @@ -138,7 +149,7 @@ const TaskToolRenderer: Component = (props) => { icon="square-arrow-top-right" size="small" variant="ghost" - aria-label="Open sub-agent in tab" + aria-label={worktree ? "Open sub-agent in panel" : "Open sub-agent in tab"} onClick={openInTab} /> diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index ed5b5d07ffc..87b388c9e98 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -290,7 +290,7 @@ interface SessionContextValue { deleteSession: (id: string) => void renameSession: (id: string, title: string) => void exportSessionTranscript: (id: string) => void - syncSession: (sessionID: string) => void + syncSession: (sessionID: string, parentSessionID?: string) => void // Cloud session preview cloudPreviewId: Accessor @@ -2822,8 +2822,8 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "deleteMessage", sessionID, messageID }) } - function syncSession(sessionID: string) { - vscode.postMessage({ type: "syncSession", sessionID, parentSessionID: currentSessionID() }) + function syncSession(sessionID: string, parentSessionID = currentSessionID()) { + vscode.postMessage({ type: "syncSession", sessionID, parentSessionID }) } const todos = () => { From d95c5d460fa350d3569310108e314053e367b1f0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 14:37:22 +0200 Subject: [PATCH 2/5] fix(vscode): format subagent inspector files --- .../tests/unit/agent-manager-terminal-layout.test.ts | 11 +++++++---- .../webview-ui/agent-manager/SubagentPanel.tsx | 8 ++++---- .../agent-manager/terminal/SideTerminalPanel.tsx | 8 ++++---- 3 files changed, 15 insertions(+), 12 deletions(-) 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 263ac2a3997..c9d0fdeebc2 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 @@ -36,13 +36,16 @@ test("uses one persisted width for every inspector panel", () => { }) test("hides keyboard hints only in inspector tabs", () => { - const side = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), "utf8") + const side = readFileSync( + resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), + "utf8", + ) expect(subagent).toContain("showKeybind={false}") expect(side).toContain("showKeybind={false}") - expect(readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/render.tsx"), "utf8")).not.toContain( - "showKeybind={false}", - ) + expect( + readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/render.tsx"), "utf8"), + ).not.toContain("showKeybind={false}") }) test("limits inspector layout updates during resize", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx index 3e8b39c4a2c..68a6a8515c8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -88,10 +88,10 @@ export const SubagentPanel: Component = (props) => { = (props) => { Date: Mon, 17 Aug 2026 15:26:38 +0200 Subject: [PATCH 3/5] fix(vscode): isolate subagent inspector sessions --- packages/kilo-vscode/src/KiloProvider.ts | 73 +++++++-- .../kilo-vscode/src/agent-manager/types.ts | 1 + .../src/kilo-provider/visible-task-streams.ts | 4 + .../tests/unit/subagent-tabs.test.ts | 10 +- .../agent-manager/AgentManagerApp.tsx | 3 +- .../agent-manager/SubagentPanel.tsx | 139 ++++++++++-------- .../webview-ui/agent-manager/subagent-tabs.ts | 8 +- .../src/components/chat/SessionTabMenu.tsx | 14 +- .../src/components/chat/TaskToolExpanded.tsx | 7 + .../webview-ui/src/context/session.tsx | 43 ++++-- .../src/types/messages/webview-messages.ts | 9 ++ 11 files changed, 216 insertions(+), 95 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 144b7fdd613..57dc4279f6d 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -379,6 +379,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly openSessionIds = new Set() private modelUsageSessionIds: Set = new Set() private syncedChildSessions: Set = new Set() + private readonly inspectorSessionIds = new Set() private readonly checkpoints = new Map>() private readonly sessionCreations = new Map>() private readonly draftSessions = new Map() @@ -1083,6 +1084,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (await this.handleModelSelectorExpandedMessage(message)) return this.handleWebviewFocusMessage(message) this.visibleTaskStreams.handle(message) + this.handleStreamVisibilityMessage(message) + if (this.handleChildSyncMessage(message)) return if (await this.handleMemoryMessage(message)) return if (this.handleLegacyMigrationMessage(message)) return switch (message.type) { @@ -1171,15 +1174,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // isn't blocked by slow responses for earlier sessions. void this.handleLoadMessages(message.sessionID, { mode: message.mode, + focus: message.focus, before: message.before, limit: message.limit, }) break - case "syncSession": - this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) => - console.error("[Kilo New] handleSyncSession failed:", e), - ) - break case "loadSessions": this.handleLoadSessions().catch((e) => console.error("[Kilo New] handleLoadSessions failed:", e)) break @@ -1572,6 +1571,33 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private handleChildSyncMessage( + message: TypedWebviewMessage & { sessionID?: unknown; parentSessionID?: unknown; scope?: unknown }, + ): boolean { + if (message.type !== "syncSession" && message.type !== "unsyncSession") return false + if (typeof message.sessionID !== "string") return true + if (message.type === "syncSession") { + if (message.scope === "inspector") this.inspectorSessionIds.add(message.sessionID) + const parent = typeof message.parentSessionID === "string" ? message.parentSessionID : undefined + this.handleSyncSession(message.sessionID, parent).catch((e) => + console.error("[Kilo New] handleSyncSession failed:", e), + ) + return true + } + this.inspectorSessionIds.delete(message.sessionID) + this.releaseChildSession(message.sessionID) + return true + } + + private handleStreamVisibilityMessage( + message: TypedWebviewMessage & { sessionID?: unknown; visible?: unknown }, + ): void { + if (message.type !== "streamSessionVisible" || message.visible !== false || typeof message.sessionID !== "string") { + return + } + this.releaseChildSession(message.sessionID) + } + private handleEditorOpenMessage(message: Parameters[0]): boolean { return handleEditorAction(message, { // An explicit sessionID (e.g. from validateFiles) takes precedence over @@ -1976,14 +2002,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async handleLoadMessages( sessionID: string, - options: { mode?: MessageLoadMode; before?: string; limit?: number; preserveStream?: boolean } = {}, + options: { + mode?: MessageLoadMode + focus?: boolean + before?: string + limit?: number + preserveStream?: boolean + } = {}, ): Promise { const mode = options.mode ?? "replace" if (mode === "replace" || mode === "focus") { - this.stopCurrentSessionProcesses(sessionID) this.trackedSessionIds.add(sessionID) - this.focusSession(sessionID) - this.contextSessionID = sessionID + if (options.focus !== false) { + this.stopCurrentSessionProcesses(sessionID) + this.focusSession(sessionID) + this.contextSessionID = sessionID + } } if (!this.client) { this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID }) @@ -2119,6 +2153,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private releaseChildSession(sessionID: string): void { + if ( + this.inspectorSessionIds.has(sessionID) || + this.visibleTaskStreams.has(sessionID) || + this.currentSession?.id === sessionID || + this.openSessionIds.has(sessionID) + ) { + return + } + if (!this.syncedChildSessions.delete(sessionID)) return + this.trackedSessionIds.delete(sessionID) + this.streams.drop(sessionID) + this.visibleTaskStreams.delete(sessionID) + this.sessionDirectories.delete(sessionID) + this.sessionGitDirectories.delete(sessionID) + this.sessionGitRecoveries.delete(sessionID) + this.connectionService.pruneSession(sessionID) + } + /** * Build the context object used by the extracted session-refresh helpers. */ @@ -2252,6 +2305,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.streams.drop(sessionID) this.visibleTaskStreams.delete(sessionID) this.syncedChildSessions.delete(sessionID) + this.inspectorSessionIds.delete(sessionID) this.sessionDirectories.delete(sessionID) this.sessionGitDirectories.delete(sessionID) this.sessionGitRecoveries.delete(sessionID) @@ -5091,6 +5145,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.clear() this.openSessionIds.clear() this.syncedChildSessions.clear() + this.inspectorSessionIds.clear() this.draftSessions.clear() this.sessionDirectories.clear() this.anacondaDesktop.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 1d76a49abfe..6df88ab49f9 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -817,6 +817,7 @@ interface LoadMessagesIn { type: "loadMessages" sessionID: string mode?: "replace" | "prepend" | "focus" + focus?: boolean before?: string limit?: number } diff --git a/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts b/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts index 83b97c3103e..66ad8789369 100644 --- a/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts +++ b/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts @@ -25,6 +25,10 @@ export class VisibleTaskStreams { this.refs.delete(id) } + has(id: string): boolean { + return this.refs.has(id) + } + setActive(active: boolean): void { if (this.active === active) return this.active = active diff --git a/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts b/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts index 5276fac4cbd..d5c618b21cc 100644 --- a/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts +++ b/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts @@ -4,10 +4,16 @@ import { createSubagentTabs } from "../../webview-ui/agent-manager/subagent-tabs function scene() { const [current] = createSignal("parent") - const calls = { synced: [] as Array<[string, string | undefined]>, shown: 0, hidden: 0 } + const calls = { + synced: [] as Array<[string, string | undefined]>, + unsynced: [] as string[], + shown: 0, + hidden: 0, + } const tabs = createSubagentTabs({ current, sync: (id, parent) => calls.synced.push([id, parent]), + unsync: (id) => calls.unsynced.push(id), show: () => calls.shown++, hide: () => calls.hidden++, }) @@ -47,6 +53,7 @@ describe("Agent Manager subagent tabs", () => { item.tabs.close("one") expect(item.tabs.tabs()).toEqual([]) expect(item.tabs.active()).toBeUndefined() + expect(item.calls.unsynced).toEqual(["two", "three", "one"]) expect(item.calls.hidden).toBe(1) dispose() }) @@ -62,6 +69,7 @@ describe("Agent Manager subagent tabs", () => { item.tabs.closeOthers("one") expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one"]) expect(item.tabs.active()).toBe("one") + expect(item.calls.unsynced).toEqual(["two", "three"]) expect(item.calls.shown).toBe(4) dispose() }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a1c5cd9ef6e..10e72057ade 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -325,7 +325,8 @@ const AgentManagerContent: Component = () => { const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified") const subagents = createSubagentTabs({ current: session.currentSessionID, - sync: session.syncSession, + sync: (id, parentID) => session.syncSession(id, parentID, "inspector"), + unsync: (id) => session.unsyncSession(id, "inspector"), show: () => { setHistory(false) setReviewActive(false) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx index 68a6a8515c8..ddc20fc7a05 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -34,7 +34,7 @@ const SubagentChat: Component<{ active: Accessor }> = (props createEffect(() => { const id = props.active() if (!id) return - session.selectSession(id) + session.selectSession(id, { focus: false }) }) return ( @@ -44,77 +44,88 @@ const SubagentChat: Component<{ active: Accessor }> = (props ) } -export const SubagentPanel: Component = (props) => { +const SubagentContent: Component = (props) => { + const session = useSession() const ids = () => props.tabs().map((tab) => tab.id) const title = (id: string) => props.tabs().find((tab) => tab.id === id)?.title ?? "Sub-agent" const close = (id: string, focus: { restore: () => void }) => { props.onClose(id) + session.releaseSession(id) if (ids().length > 0) focus.restore() } + const closeOthers = (id: string) => { + const gone = ids().filter((item) => item !== id) + props.onCloseOthers(id) + for (const item of gone) session.releaseSession(item) + } return ( - -
-
-
- - Subagents - {props.tabs().length} -
- -
- { - const label = title(id) - return ( - api.focus.key(id, event)} - onSelect={() => props.onSelect(id)} - onMiddleClick={(event) => { - if (event.button !== 1) return - event.preventDefault() - event.stopPropagation() - close(id, api.focus) - }} - onClose={() => close(id, api.focus)} - onCloseOthers={() => props.onCloseOthers(id)} - /> - ) - }} - /> -
- +
+
+
+ + Subagents + {props.tabs().length}
-
- + + + { + const label = title(id) + return ( + api.focus.key(id, event)} + onSelect={() => props.onSelect(id)} + onMiddleClick={(event) => { + if (event.button !== 1) return + event.preventDefault() + event.stopPropagation() + close(id, api.focus) + }} + onClose={() => close(id, api.focus)} + onCloseOthers={() => closeOthers(id)} + /> + ) + }} + /> +
+ +
+
) } + +export const SubagentPanel: Component = (props) => ( + + + +) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts index db94aa8beeb..4ab84f8f20c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts @@ -9,6 +9,7 @@ export interface SubagentTab { interface Options { current: Accessor sync: (id: string, parentID?: string) => void + unsync: (id: string) => void show: () => void hide: () => void } @@ -20,6 +21,7 @@ export function createSubagentTabs(opts: Options) { const open = (id: string, title?: string, parentID?: string) => { if (!id) return const label = title?.trim() || "Sub-agent" + const existing = tabs().some((tab) => tab.id === id) batch(() => { setTabs((prev) => { const existing = prev.find((tab) => tab.id === id) @@ -32,7 +34,7 @@ export function createSubagentTabs(opts: Options) { setActive(id) opts.show() }) - opts.sync(id, parentID ?? opts.current()) + if (!existing) opts.sync(id, parentID ?? opts.current()) } const select = (id: string) => { @@ -46,6 +48,7 @@ export function createSubagentTabs(opts: Options) { const index = current.findIndex((tab) => tab.id === id) if (index < 0) return const next = current.filter((tab) => tab.id !== id) + opts.unsync(id) setTabs(next) if (active() !== id) return const replacement = next[Math.min(index, next.length - 1)] @@ -59,6 +62,9 @@ export function createSubagentTabs(opts: Options) { const closeOthers = (id: string) => { if (!tabs().some((tab) => tab.id === id)) return + for (const tab of tabs()) { + if (tab.id !== id) opts.unsync(tab.id) + } setTabs((prev) => prev.filter((tab) => tab.id === id)) setActive(id) opts.show() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx index dd03d3e796c..0dd87709cd4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx @@ -24,7 +24,7 @@ export const SessionTabMenu: ParentComponent<{ {t("agentManager.tab.forkSession")} - + @@ -34,12 +34,12 @@ export const SessionTabMenu: ParentComponent<{ {t("agentManager.tab.close")} {props.closeShortcut} - - props.onCloseOthers?.()}> - - {t("agentManager.tab.closeOthers")} - - + + + props.onCloseOthers?.()}> + + {t("agentManager.tab.closeOthers")} + 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 8ad09e38079..501b3b84345 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -52,11 +52,18 @@ const TaskToolRenderer: Component = (props) => { }), ) + let synced: string | undefined createEffect(() => { const id = taskVisible(open(), childSessionId()) + if (synced === id) return + if (synced) session.unsyncSession(synced) + synced = id if (!id) return session.syncSession(id) }) + onCleanup(() => { + if (synced) session.unsyncSession(synced) + }) const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 87b388c9e98..e0a490f4e6a 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -286,11 +286,13 @@ interface SessionContextValue { clearCurrentSession: () => void loadSessions: () => void loadOlderMessages: () => boolean - selectSession: (id: string) => void + selectSession: (id: string, options?: { focus?: boolean }) => void + releaseSession: (id: string) => void deleteSession: (id: string) => void renameSession: (id: string, title: string) => void exportSessionTranscript: (id: string) => void - syncSession: (sessionID: string, parentSessionID?: string) => void + syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void + unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void // Cloud session preview cloudPreviewId: Accessor @@ -2585,9 +2587,9 @@ export const SessionProvider: ParentComponent = (props) => { // Session whose message fetch was deferred because the backend was offline at // selection time. Replayed by the reconnect effect below. - let deferredFetch: string | undefined + let deferredFetch: { id: string; focus: boolean } | undefined - function selectSession(id: string) { + function selectSession(id: string, options: { focus?: boolean } = {}) { // Cloud preview sessions use a separate keyed path (selectCloudSession). if (id.startsWith("cloud:")) { console.warn("[Kilo New] Cannot select cloud preview session via selectSession") @@ -2608,15 +2610,26 @@ export const SessionProvider: ParentComponent = (props) => { // load message is what re-focuses the backend (focusSession, contextSessionID, // SSE tracking, active worktree) and runs the reconcile self-heal, so skipping // it would leave the extension focused on the previously selected session. + const focus = options.focus !== false if (!server.isConnected()) { - deferredFetch = id + deferredFetch = { id, focus } return } deferredFetch = undefined - loadFocusedMessages(id, ready) + loadFocusedMessages(id, ready, focus) } - function loadFocusedMessages(id: string, ready: boolean) { + function loadFocusedMessages(id: string, ready: boolean, focus = true) { + if (!focus) { + vscode.postMessage({ + type: "loadMessages", + sessionID: id, + mode: "replace", + focus: false, + limit: MESSAGE_PAGE_LIMIT, + }) + return + } vscode.postMessage( ready ? { type: "loadMessages", sessionID: id, mode: "focus" } @@ -2631,10 +2644,10 @@ export const SessionProvider: ParentComponent = (props) => { createEffect( on(server.isConnected, (connected) => { if (!connected) return - const id = deferredFetch + const pending = deferredFetch deferredFetch = undefined - if (!id || id !== currentSessionID()) return - loadFocusedMessages(id, loaded().has(id)) + if (!pending || pending.id !== currentSessionID()) return + loadFocusedMessages(pending.id, loaded().has(pending.id), pending.focus) }), ) @@ -2822,8 +2835,12 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "deleteMessage", sessionID, messageID }) } - function syncSession(sessionID: string, parentSessionID = currentSessionID()) { - vscode.postMessage({ type: "syncSession", sessionID, parentSessionID }) + function syncSession(sessionID: string, parentSessionID = currentSessionID(), scope: "task" | "inspector" = "task") { + vscode.postMessage({ type: "syncSession", sessionID, parentSessionID, scope }) + } + + function unsyncSession(sessionID: string, scope: "task" | "inspector" = "task") { + vscode.postMessage({ type: "unsyncSession", sessionID, scope }) } const todos = () => { @@ -3018,10 +3035,12 @@ export const SessionProvider: ParentComponent = (props) => { loadSessions, loadOlderMessages, selectSession, + releaseSession: handleSessionDeleted, deleteSession, renameSession, exportSessionTranscript, syncSession, + unsyncSession, cloudPreviewId, selectCloudSession, draftSessionID, diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 5192242d787..a93d067e3a3 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -82,6 +82,7 @@ export interface LoadMessagesRequest { type: "loadMessages" sessionID: string mode?: MessageLoadMode + focus?: boolean before?: string limit?: number } @@ -591,6 +592,13 @@ export interface SyncSessionRequest { type: "syncSession" sessionID: string parentSessionID?: string + scope?: "task" | "inspector" +} + +export interface UnsyncSessionRequest { + type: "unsyncSession" + sessionID: string + scope?: "task" | "inspector" } // Agent Manager worktree messages @@ -1488,6 +1496,7 @@ export type WebviewMessage = | ResetReadNotificationsRequest | SettingsTabChangedMessage | SyncSessionRequest + | UnsyncSessionRequest | CreateWorktreeSessionRequest | RequestNotificationsMessage | DismissNotificationMessage From 4279750e0ca04231d8a3045228cde386395fbae4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 15:29:51 +0200 Subject: [PATCH 4/5] test(vscode): update session selection contract --- .../tests/unit/session-select-connection.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/session-select-connection.test.ts b/packages/kilo-vscode/tests/unit/session-select-connection.test.ts index 08798e4a239..7fd6a265e9e 100644 --- a/packages/kilo-vscode/tests/unit/session-select-connection.test.ts +++ b/packages/kilo-vscode/tests/unit/session-select-connection.test.ts @@ -50,7 +50,7 @@ describe("selectSession keeps the chat in sync with the selection while offline" // Queue a replay unconditionally. The earlier `deferredFetch = ready ? undefined : id` // form skipped cached sessions, so a reconnect never re-sent the focus load that // re-focuses the backend (focusSession/contextSessionID/SSE tracking/reconcile). - expect(body).toContain("deferredFetch = id") + expect(body).toContain("deferredFetch = { id, focus }") expect(body).not.toMatch(/deferredFetch\s*=\s*ready\s*\?/) }) }) @@ -61,12 +61,15 @@ describe("a deferred fetch is replayed on reconnect", () => { const effect = source.slice(source.indexOf("on(server.isConnected")) expect(effect).toContain("deferredFetch") // Replays with the focus/replace choice so cached sessions still re-focus the backend. - expect(effect).toMatch(/loadFocusedMessages\(\s*id,\s*loaded\(\)\.has\(id\)\s*\)/) + expect(effect).toMatch( + /loadFocusedMessages\(\s*pending\.id,\s*loaded\(\)\.has\(pending\.id\),\s*pending\.focus\s*\)/, + ) }) it("the focused load helper sends focus for cached sessions and replace otherwise", () => { const helper = source.slice(source.indexOf("function loadFocusedMessages(")) expect(helper).toMatch(/mode: "focus"/) expect(helper).toMatch(/mode: "replace"/) + expect(helper).toContain("focus: false") }) }) From 0067dbac39447dc27bd36ee1b88431a709e76744 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 15:39:47 +0200 Subject: [PATCH 5/5] fix(vscode): preserve inspector sync scope --- packages/kilo-vscode/src/KiloProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 57dc4279f6d..457bdc53559 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1584,7 +1584,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) return true } - this.inspectorSessionIds.delete(message.sessionID) + if (message.scope === "inspector") this.inspectorSessionIds.delete(message.sessionID) this.releaseChildSession(message.sessionID) return true }