Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subagent-inspector-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Open delegated subagent sessions in Agent Manager inspector tabs alongside terminals.
73 changes: 64 additions & 9 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private readonly openSessionIds = new Set<string>()
private modelUsageSessionIds: Set<string> = new Set()
private syncedChildSessions: Set<string> = new Set()
private readonly inspectorSessionIds = new Set<string>()
private readonly checkpoints = new Map<string, Promise<void>>()
private readonly sessionCreations = new Map<string, Promise<{ sid: string; dir: string } | undefined>>()
private readonly draftSessions = new Map<string, { sid: string; dir: string; expires: number }>()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
if (message.scope === "inspector") 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<typeof handleEditorAction>[0]): boolean {
return handleEditorAction(message, {
// An explicit sessionID (e.g. from validateFiles) takes precedence over
Expand Down Expand Up @@ -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<void> {
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 })
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,7 @@ interface LoadMessagesIn {
type: "loadMessages"
sessionID: string
mode?: "replace" | "prepend" | "focus"
focus?: boolean
before?: string
limit?: number
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -25,13 +26,28 @@ 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[] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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*\?/)
})
})
Expand All @@ -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")
})
})
92 changes: 92 additions & 0 deletions packages/kilo-vscode/tests/unit/subagent-tabs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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<string | undefined>("parent")
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++,
})
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.unsynced).toEqual(["two", "three", "one"])
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.unsynced).toEqual(["two", "three"])
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()
})
})
})
Loading
Loading