Merge pull request #13261 from Kilo-Org/fix-worktree-sidebar-agent-switching

fix(agent-manager): scope subagent inspector by session
This commit is contained in:
Marius
2026-08-20 11:03:06 +02:00
committed by GitHub
5 changed files with 233 additions and 35 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Agent Manager subagent inspector tabs aligned with the selected session and worktree.
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSubagentTabs } from "../../webview-ui/agent-manager/subagent-tabs"
import { availableSubagents, createSubagentTabs } from "../../webview-ui/agent-manager/subagent-tabs"
function scene() {
const [current] = createSignal<string | undefined>("parent")
@@ -89,4 +89,68 @@ describe("Agent Manager subagent tabs", () => {
dispose()
})
})
it("keeps tabs and active children separate for each context", () => {
createRoot((dispose) => {
const [context, setContext] = createSignal("worktree-a")
const calls = {
synced: [] as Array<[string, string | undefined]>,
unsynced: [] as string[],
shown: 0,
hidden: 0,
}
const item = createSubagentTabs({
current: () => "parent",
context: () => context(),
sync: (id, parent) => calls.synced.push([id, parent]),
unsync: (id) => calls.unsynced.push(id),
show: () => calls.shown++,
hide: () => calls.hidden++,
})
item.open("child-a", "A", "parent-a")
setContext("worktree-b")
item.open("child-b", "B", "parent-b")
expect(item.tabs().map((tab) => tab.id)).toEqual(["child-b"])
expect(item.active()).toBe("child-b")
setContext("worktree-a")
expect(item.tabs().map((tab) => tab.id)).toEqual(["child-a"])
expect(item.active()).toBe("child-a")
expect(calls.synced).toEqual([
["child-a", "parent-a"],
["child-b", "parent-b"],
])
dispose()
})
})
it("finds direct subagent sessions in task tool parts", () => {
const tabs = availableSubagents([
{
id: "task-1",
type: "tool",
tool: "task",
state: {
status: "completed",
input: { description: "Inspect files", subagent_type: "explore" },
output: "",
title: "",
},
metadata: { sessionId: "child-1" },
},
{
id: "task-2",
type: "tool",
tool: "task",
state: { status: "running", input: { subagent_type: "general" } },
metadata: { sessionId: "child-2" },
},
])
expect(tabs).toEqual([
{ id: "child-1", title: "Inspect files" },
{ id: "child-2", title: "general" },
])
})
})
@@ -179,7 +179,7 @@ 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 { createSubagentController } from "./subagent-tabs"
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import { createChatFocus, createFocusBridge, createPromptFocus, forgetTerminalFocus, hasQuestionOption } from "./focus"
@@ -308,8 +308,6 @@ const AgentManagerContent: Component = () => {
const diffLoading = diffs.diffLoading
const setDiffLoading = diffs.setDiffLoading
const diffNotices = diffs.diffNotices
// 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 = () => {
@@ -323,8 +321,12 @@ const AgentManagerContent: Component = () => {
const reviewComposer = createReviewComposer()
const [reviewActive, setReviewActive] = createSignal(false)
const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified")
const subagents = createSubagentTabs({
const subagentCtl = createSubagentController({
project: currentProjectId,
current: session.currentSessionID,
selection,
parts: session.getSessionToolParts,
visible: () => sidePanel() === SidePanel.Subagents,
sync: (id, parentID) => session.syncSession(id, parentID, "inspector"),
unsync: (id) => session.unsyncSession(id, "inspector"),
show: () => {
@@ -334,8 +336,8 @@ const AgentManagerContent: Component = () => {
},
hide: () => setSidePanel(null),
})
const subagents = subagentCtl.tabs
const markdown = createMarkdownRender(vscode)
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
const worktreeStats = () => registry.active().worktreeStats()
const prStatuses = () => registry.active().prStatuses()
@@ -344,7 +346,6 @@ const AgentManagerContent: Component = () => {
const runScriptConfigured = () => registry.active().runScriptConfigured()
const setRunScriptConfigured = (v: Parameters<Setter<boolean>>[0]) => registry.active().setRunScriptConfigured(v)
// Local repo git stats (branch name, diff additions/deletions, commits)
const localStats = () => registry.active().localStats()
const projectLive = createProjectLive({
ensure: (pid) => (pid ? registry.ensure(pid) : registry.active()),
@@ -459,7 +460,6 @@ const AgentManagerContent: Component = () => {
{ defer: true },
),
)
// Ambient setup reveal restores the panel after success unless the user engaged.
const ambientSetup = createAmbientSetup({
terms,
selection: () => {
@@ -471,7 +471,6 @@ const AgentManagerContent: Component = () => {
})
const cancelAmbientSetup = ambientSetup.cancel
// Inline delete confirmation: tracks which worktree is awaiting a second click/press
const [pendingDelete, setPendingDelete] = createSignal<string | null>(null)
let pendingDeleteTimer: ReturnType<typeof setTimeout> | undefined
const cancelPendingDelete = () => {
@@ -2455,6 +2454,9 @@ const AgentManagerContent: Component = () => {
prStatus={() => activePR()?.pr}
prOpen={prOpen}
onTogglePR={togglePRPanel}
subagentsAvailable={() => subagentCtl.tabs.tabs().length > 0 || subagentCtl.toolbar.available().length > 0}
subagentsOpen={() => sidePanel() === SidePanel.Subagents}
onToggleSubagents={subagentCtl.toolbar.toggle}
terminalDestination={sideCtl.destination}
terminalDestinationActive={() => sidePanel() === SidePanel.Terminal}
terminalKeybind={() => kb().showTerminal ?? ""}
@@ -58,6 +58,9 @@ export interface TabBarProps {
prStatus: () => PRStatus | undefined
prOpen: () => boolean
onTogglePR: () => void
subagentsAvailable: () => boolean
subagentsOpen: () => boolean
onToggleSubagents: () => void
terminalDestination: () => TerminalDestination
terminalDestinationActive: () => boolean
terminalKeybind: () => string
@@ -232,6 +235,18 @@ export const TabBar: Component<TabBarProps> = (props) => (
</Tooltip>
)}
</Show>
<Show when={props.subagentsAvailable()}>
<Tooltip value="Subagents" placement="bottom">
<IconButton
icon="task"
size="small"
variant="ghost"
label="Subagents"
class={props.subagentsOpen() ? "am-tab-diff-btn-active" : ""}
onClick={props.onToggleSubagents}
/>
</Tooltip>
</Show>
<TooltipKeybind
title={props.t("agentManager.diff.toggle")}
keybind={props.bindings().toggleDiff ?? ""}
@@ -1,5 +1,7 @@
import { batch, createSignal, type Accessor } from "solid-js"
import { batch, createEffect, createMemo, createSignal, on, type Accessor } from "solid-js"
import { reorderTabs } from "../src/utils/tab-order"
import { childID } from "../src/context/session-utils"
import type { ToolPart } from "../src/types/messages"
export interface SubagentTab {
id: string
@@ -8,83 +10,193 @@ export interface SubagentTab {
interface Options {
current: Accessor<string | undefined>
context?: (parentID?: string) => string
sync: (id: string, parentID?: string) => void
unsync: (id: string) => void
show: () => void
hide: () => void
}
export function createSubagentContext(opts: {
project: Accessor<string | undefined>
current: Accessor<string | undefined>
selection: Accessor<string | null>
}) {
return (parentID?: string) => {
const project = opts.project() ?? "single"
return `${project}:${parentID ?? opts.current() ?? opts.selection() ?? "unassigned"}`
}
}
export function createSubagentTabs(opts: Options) {
const [tabs, setTabs] = createSignal<SubagentTab[]>([])
const [active, setActive] = createSignal<string>()
const [tabs, setTabs] = createSignal<Record<string, SubagentTab[]>>({})
const [active, setActive] = createSignal<Record<string, string | undefined>>({})
const key = (parentID?: string) => opts.context?.(parentID) ?? "default"
const list = () => tabs()[key()] ?? []
const selected = () => active()[key()]
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)
const scope = key(parentID)
const existing = (tabs()[scope] ?? []).some((tab) => tab.id === id)
batch(() => {
setTabs((prev) => {
const existing = prev.find((tab) => tab.id === id)
if (!existing) return [...prev, { id, title: label }]
const current = prev[scope] ?? []
const existing = current.find((tab) => tab.id === id)
if (!existing) return { ...prev, [scope]: [...current, { id, title: label }] }
if (title?.trim() && existing.title !== label) {
return prev.map((tab) => (tab.id === id ? { ...tab, title: label } : tab))
return { ...prev, [scope]: current.map((tab) => (tab.id === id ? { ...tab, title: label } : tab)) }
}
return prev
})
setActive(id)
setActive((prev) => ({ ...prev, [scope]: id }))
opts.show()
})
if (!existing) opts.sync(id, parentID ?? opts.current())
}
const select = (id: string) => {
if (!tabs().some((tab) => tab.id === id)) return
setActive(id)
if (!list().some((tab) => tab.id === id)) return
setActive((prev) => ({ ...prev, [key()]: id }))
opts.show()
}
const close = (id: string) => {
const current = tabs()
const scope = key()
const current = tabs()[scope] ?? []
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
setTabs((prev) => ({ ...prev, [scope]: next }))
if (selected() !== id) return
const replacement = next[Math.min(index, next.length - 1)]
if (replacement) {
setActive(replacement.id)
setActive((prev) => ({ ...prev, [scope]: replacement.id }))
return
}
setActive(undefined)
setActive((prev) => ({ ...prev, [scope]: undefined }))
opts.hide()
}
const closeOthers = (id: string) => {
if (!tabs().some((tab) => tab.id === id)) return
for (const tab of tabs()) {
const scope = key()
const current = tabs()[scope] ?? []
if (!current.some((tab) => tab.id === id)) return
for (const tab of current) {
if (tab.id !== id) opts.unsync(tab.id)
}
setTabs((prev) => prev.filter((tab) => tab.id === id))
setActive(id)
setTabs((prev) => ({ ...prev, [scope]: current.filter((tab) => tab.id === id) }))
setActive((prev) => ({ ...prev, [scope]: id }))
opts.show()
}
const reorder = (from: string, to: string) => {
const order = reorderTabs(
tabs().map((tab) => tab.id),
list().map((tab) => tab.id),
from,
to,
)
if (!order) return
const scope = key()
setTabs((prev) => {
const lookup = new Map(prev.map((tab) => [tab.id, tab]))
return order.flatMap((id) => {
const tab = lookup.get(id)
return tab ? [tab] : []
})
const lookup = new Map((prev[scope] ?? []).map((tab) => [tab.id, tab]))
return {
...prev,
[scope]: order.flatMap((id) => {
const tab = lookup.get(id)
return tab ? [tab] : []
}),
}
})
}
return { tabs, active, open, select, close, closeOthers, reorder }
return { tabs: list, active: selected, open, select, close, closeOthers, reorder }
}
export function availableSubagents(parts: ToolPart[]): SubagentTab[] {
const seen = new Set<string>()
return parts.flatMap((part) => {
const child = childID(part as Parameters<typeof childID>[0])
if (!child || seen.has(child)) return []
seen.add(child)
const input = part.state.input
const description = input.description
const type = input.subagent_type
const title = typeof description === "string" ? description : typeof type === "string" ? type : "Sub-agent"
return [{ id: child, title }]
})
}
export function createSubagentToolbar(opts: {
context: Accessor<string>
current: Accessor<string | undefined>
parts: (id: string) => ToolPart[]
tabs: Accessor<SubagentTab[]>
open: (id: string, title: string, parentID: string) => void
visible: Accessor<boolean>
show: () => void
hide: () => void
}) {
const available = createMemo(() => {
const id = opts.current()
return id ? availableSubagents(opts.parts(id)) : []
})
const toggle = () => {
if (opts.visible()) {
opts.hide()
return
}
if (opts.tabs().length > 0) {
opts.show()
return
}
const id = opts.current()
if (!id) return
for (const tab of available()) opts.open(tab.id, tab.title, id)
}
createEffect(
on(
opts.context,
() => {
if (opts.visible() && opts.tabs().length === 0) opts.hide()
},
{ defer: true },
),
)
return { available, toggle }
}
export function createSubagentController(opts: {
project: Accessor<string | undefined>
current: Accessor<string | undefined>
selection: Accessor<string | null>
parts: (id: string) => ToolPart[]
visible: Accessor<boolean>
show: () => void
sync: (id: string, parentID?: string) => void
unsync: (id: string) => void
hide: () => void
}) {
const context = createSubagentContext(opts)
const tabs = createSubagentTabs({
current: opts.current,
context,
sync: opts.sync,
unsync: opts.unsync,
show: opts.show,
hide: opts.hide,
})
const toolbar = createSubagentToolbar({
context: createMemo(() => context()),
current: opts.current,
parts: opts.parts,
tabs: tabs.tabs,
open: tabs.open,
visible: opts.visible,
show: opts.show,
hide: opts.hide,
})
return { tabs, toolbar }
}