Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
}
this.inspectorSessionIds.delete(message.sessionID)
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
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