diff --git a/.changeset/background-subagent-reasoning-preview.md b/.changeset/background-subagent-reasoning-preview.md new file mode 100644 index 000000000000..f6e3e3365746 --- /dev/null +++ b/.changeset/background-subagent-reasoning-preview.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep background subagent task cards collapsed and show their reasoning as a compact preview instead of expanding while the agent runs. diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index a5717bbbd337..4b7a43af0a3b 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -162,6 +162,9 @@ export interface MessagePartProps { * lets that one nested item open instead of every file in the patch. */ forceOpenFile?: string reasoningAutoCollapse?: boolean + /** Show reasoning as a capped preview that starts open and never auto-expands + * while streaming. Used for background subagent transcripts. */ + reasoningCapped?: boolean /** True when the stream has moved past this reasoning part. Encrypted * reasoning items hold every summary's `time.end` until the whole item * finishes, so the caller settles finished summaries from the part order. */ @@ -1081,6 +1084,7 @@ export function Part(props: MessagePartProps) { forceOpen={props.forceOpen} forceOpenFile={props.forceOpenFile} reasoningAutoCollapse={props.reasoningAutoCollapse} + reasoningCapped={props.reasoningCapped} settled={props.settled} showAssistantCopyPartID={props.showAssistantCopyPartID} showTurnDiffSummary={props.showTurnDiffSummary} @@ -1884,12 +1888,15 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp // Auto-collapse mode: streaming or streamed this session -> open (capped), // historical -> collapsed, unless the user toggled it. Expanded mode: open - // unless the user explicitly collapsed this reasoning part. - const initial = props.reasoningAutoCollapse - ? !userCollapsed.has(id) && (streamed.has(id) || userOpened.has(id)) - : !userCollapsed.has(id) + // unless the user explicitly collapsed this reasoning part. Background + // transcripts always start open in the capped preview so they stay compact. + const capped = () => props.reasoningAutoCollapse || props.reasoningCapped + const initial = + props.reasoningAutoCollapse && !props.reasoningCapped + ? !userCollapsed.has(id) && (streamed.has(id) || userOpened.has(id)) + : !userCollapsed.has(id) const [open, setOpen] = createSignal(initial) - const [manual, setManual] = createSignal(props.reasoningAutoCollapse && userOpened.has(id)) + const [manual, setManual] = createSignal(capped() && userOpened.has(id)) const title = createMemo(() => { const value = view().title if (value) return value @@ -1908,7 +1915,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp const track = (value: boolean) => { if (value) userCollapsed.delete(id) else rememberReasoningState(userCollapsed, id) - if (props.reasoningAutoCollapse) { + if (capped()) { if (value) rememberReasoningState(userOpened, id) else userOpened.delete(id) setManual(value) @@ -1924,7 +1931,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp createEffect(() => { if (!props.forceOpen || open()) return userCollapsed.delete(id) - if (props.reasoningAutoCollapse) { + if (capped()) { rememberReasoningState(userOpened, id) setManual(true) } @@ -2007,7 +2014,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
() private providers = new Map() + private backgrounds = new Map() constructor( private readonly extensionUri: vscode.Uri, @@ -21,10 +22,17 @@ export class SubAgentViewerProvider implements vscode.Disposable { private readonly context: vscode.ExtensionContext, ) {} - openPanel(sessionID: string, title?: string, directory?: string): void { + openPanel(sessionID: string, title?: string, directory?: string, background?: boolean): void { const existing = this.panels.get(sessionID) if (existing) { if (directory) this.providers.get(sessionID)?.setSessionDirectory(sessionID, directory) + // A reused panel keeps its original flag. Re-post when the caller reports + // a different one so a transcript that first opened before the task + // metadata landed still gets the reasoning cap. + if (background !== undefined && this.backgrounds.get(sessionID) !== background) { + this.backgrounds.set(sessionID, background) + this.providers.get(sessionID)?.postMessage({ type: "viewSubAgentSession", sessionID, background }) + } existing.reveal(vscode.ViewColumn.One) return } @@ -61,7 +69,7 @@ export class SubAgentViewerProvider implements vscode.Disposable { if (msg.type !== "webviewReady") return readyDisposable.dispose() - provider.postMessage({ type: "viewSubAgentSession", sessionID }) + provider.postMessage({ type: "viewSubAgentSession", sessionID, background }) void provider.loadMessages(sessionID) try { @@ -86,6 +94,7 @@ export class SubAgentViewerProvider implements vscode.Disposable { this.panels.set(sessionID, panel) this.providers.set(sessionID, provider) + this.backgrounds.set(sessionID, background === true) panel.onDidDispose(() => { console.log("[Kilo New] Sub-agent viewer panel disposed:", sessionID) @@ -93,6 +102,7 @@ export class SubAgentViewerProvider implements vscode.Disposable { provider.dispose() this.panels.delete(sessionID) this.providers.delete(sessionID) + this.backgrounds.delete(sessionID) }) } @@ -102,5 +112,6 @@ export class SubAgentViewerProvider implements vscode.Disposable { } this.panels.clear() this.providers.clear() + this.backgrounds.clear() } } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 13c624e7298f..75f495769e43 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -598,8 +598,8 @@ export async function activate(context: vscode.ExtensionContext) { ), vscode.commands.registerCommand( "kilo-code.new.openSubAgentViewer", - (sessionID: string, title?: string, directory?: string) => { - subAgentViewerProvider.openPanel(sessionID, title, directory) + (sessionID: string, title?: string, directory?: string, background?: boolean) => { + subAgentViewerProvider.openPanel(sessionID, title, directory, background) }, ), vscode.commands.registerCommand("kilo-code.new.agentManager.previousSession", () => { diff --git a/packages/kilo-vscode/tests/unit/background-agents.test.ts b/packages/kilo-vscode/tests/unit/background-agents.test.ts index 0c4d28fef2ec..4ad436eef29b 100644 --- a/packages/kilo-vscode/tests/unit/background-agents.test.ts +++ b/packages/kilo-vscode/tests/unit/background-agents.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test" import { backgroundAgents, + backgroundChildren, backgroundJobAgents, fitBackgroundAgents, showBackgroundAgent, @@ -73,6 +74,33 @@ describe("fitBackgroundAgents", () => { }) }) +describe("backgroundChildren", () => { + it("collects only children spawned with the background flag", () => { + const tools = [ + taskPart({ id: "part_1", child: "ses_a", background: true }), + taskPart({ id: "part_2", child: "ses_b", background: false }), + taskPart({ id: "part_3", child: "ses_c" }), + ] + + expect([...backgroundChildren(tools)]).toEqual(["ses_a"]) + }) + + it("reads the flag from either the state or the part metadata", () => { + const tools = [ + taskPart({ id: "part_1", child: "ses_a", background: true }), + taskPart({ id: "part_2", child: "ses_b", background: true, onPart: true }), + ] + + expect([...backgroundChildren(tools)].sort()).toEqual(["ses_a", "ses_b"]) + }) + + it("ignores non-task tools and parts without a child session", () => { + const bash = { id: "part_3", type: "tool", tool: "bash", state: { status: "running", input: {} } } as ToolPart + + expect([...backgroundChildren([bash, taskPart({ id: "part_4", background: true })])]).toEqual([]) + }) +}) + describe("backgroundAgents", () => { it("lists a running background agent from tool state metadata", () => { const tools = [taskPart({ child: "ses_child", background: true, description: "Audit deps", agent: "explore" })] diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx index 1bef5cae1ff5..331bb0a19790 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -11,7 +11,7 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" import { createEffect, createMemo, on, type Accessor, type Component } from "solid-js" import { DataBridge } from "../src/App" import { ChatView } from "../src/components/chat" -import { children } from "../src/components/chat/background-agents" +import { children, backgroundChildren } from "../src/components/chat/background-agents" import { useLanguage } from "../src/context/language" import { SessionProvider, useSession, useSessionVisibility } from "../src/context/session" import { description, label, type Activity } from "../src/utils/session-activity" @@ -32,7 +32,7 @@ interface Props { onClosePanel: () => void } -const SubagentChat: Component<{ active: Accessor }> = (props) => { +const SubagentChat: Component<{ active: Accessor; capped: Accessor }> = (props) => { const session = useSession() createEffect( @@ -44,12 +44,22 @@ const SubagentChat: Component<{ active: Accessor }> = (props return ( - + ) } -const SubagentContent: Component Activity }> = (props) => { +interface ContentProps extends Props { + activity: (id: string) => Activity + background: Accessor> +} + +const SubagentContent: Component = (props) => { const session = useSession() const language = useLanguage() const ids = () => props.tabs().map((tab) => tab.id) @@ -131,7 +141,7 @@ const SubagentContent: Component Activity }> }} />
- + props.background().has(props.active() ?? "")} />
) @@ -145,10 +155,15 @@ export const SubagentPanel: Component = (props) => { const id = session.currentSessionID() return id ? children(session.getSessionToolParts(id)) : [] }) + // Background subagent transcripts show reasoning as a compact capped preview. + const background = createMemo(() => { + const id = session.currentSessionID() + return id ? backgroundChildren(session.getSessionToolParts(id)) : new Set() + }) return ( - + ) diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 8c148bada417..a6f895c12832 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -14,6 +14,7 @@ import { ChatView } from "./components/chat" import { SidebarEmptyState } from "./components/chat/SidebarEmptyState" import { SidebarTopBar } from "./components/chat/SidebarTopBar" import { openSubagent } from "./components/chat/open-subagent" +import { backgroundChildren } from "./components/chat/background-agents" import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded" import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides" import { useWorktreeMode } from "./context/worktree-mode" @@ -132,10 +133,12 @@ export const DataBridge: Component<{ children: any }> = (props) => { const openAgent = (id: string, title?: string) => { const parent = session.sessions().find((item) => item.id === id)?.parentID ?? session.currentSessionID() + const background = parent ? backgroundChildren(session.getSessionToolParts(parent)).has(id) : false openSubagent({ sessionID: id, title, parentSessionID: parent, + background, worktree: !!worktree, post: vscode.postMessage, }) @@ -243,6 +246,7 @@ const AppContent: Component = () => { const [currentView, setCurrentView] = createSignal("newTask") const [settingsTab, setSettingsTab] = createSignal() const [agentManagerProjectId, setAgentManagerProjectId] = createSignal() + const [subAgentCapped, setSubAgentCapped] = createSignal(false) const [migration, setMigration] = createSignal(false) const session = useSession() const tabs = useLocalTabs() @@ -345,6 +349,7 @@ const AppContent: Component = () => { handleForked(message) if (message?.type === "viewSubAgentSession" && message.sessionID) { console.log("[Kilo New] App: 🔍 viewSubAgentSession:", message.sessionID) + setSubAgentCapped(message.background === true) session.setCurrentSessionID(message.sessionID) setCurrentView("subAgentViewer") } @@ -439,7 +444,7 @@ const AppContent: Component = () => { /> - + } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index e3e0f335ca1f..e677be1a5ac7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -117,6 +117,8 @@ interface AssistantMessageProps { highlight?: () => TimelineHighlight | undefined readonly?: boolean interactivePrompts?: boolean + /** Show reasoning as a compact capped preview (background subagent transcripts). */ + reasoningCapped?: boolean } type ToolStateProps = { @@ -364,6 +366,7 @@ export const AssistantMessage: Component = (props) => { forceOpen={forceOpen()} forceOpenFile={forceOpen() ? props.forceOpenFile : undefined} reasoningAutoCollapse={display.reasoningAutoCollapse()} + reasoningCapped={props.reasoningCapped} settled={settled()} feedback={props.feedback} throughput={throughputEl()} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx index a6785db74a28..4f8f2158b743 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx @@ -196,6 +196,7 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { sessionID: agent.id, title: agent.description, parentSessionID: session.currentSessionID(), + background: true, worktree: !!worktree, post: vscode.postMessage, }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 916815bc1e01..aa2339304836 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -40,6 +40,8 @@ interface ChatViewProps { onForkMessage?: (sessionId: string, messageId: string) => void onForkSession?: (sessionId: string) => void readonly?: boolean + /** Show reasoning as a compact capped preview (background subagent transcripts). */ + reasoningCapped?: boolean /** Whether this chat owns actionable prompt controls. Defaults to true. */ interactivePrompts?: boolean /** When true, show the "Continue in Worktree" button. Defaults to true in the sidebar. */ @@ -399,6 +401,7 @@ export const ChatView: Component = (props) => { questions={standaloneQuestions} suggestions={standaloneSuggestions} readonly={props.readonly} + reasoningCapped={props.reasoningCapped} interactivePrompts={ownsPrompts()} emptyState={props.emptyState} introduction={props.introduction} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 9825c9bc0eb0..4dec2d080bfb 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -96,6 +96,8 @@ interface MessageListProps { suggestions?: () => SuggestionRequest[] /** When true (subagent viewer), replace the welcome screen with an initializing indicator */ readonly?: boolean + /** Show reasoning as a compact capped preview (background subagent transcripts). */ + reasoningCapped?: boolean /** Whether inline questions and suggestions are actionable on this surface. */ interactivePrompts?: boolean queuedDisabled?: boolean @@ -1362,6 +1364,7 @@ export const MessageList: Component = (props) => { activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined} readonly={props.readonly} interactivePrompts={props.interactivePrompts} + reasoningCapped={props.reasoningCapped} /> )} @@ -1382,6 +1385,7 @@ export const MessageList: Component = (props) => { activeSearchPartFile={activeKey() === key ? activeMatch()?.partFile : undefined} readonly={props.readonly} interactivePrompts={props.interactivePrompts} + reasoningCapped={props.reasoningCapped} /> )} @@ -1404,6 +1408,7 @@ export const MessageList: Component = (props) => { activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined} readonly={props.readonly} interactivePrompts={props.interactivePrompts} + reasoningCapped={props.reasoningCapped} /> )} 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 c7fe361a569d..86552bb7b62f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -59,6 +59,16 @@ const TaskToolRenderer: Component = (props) => { ) const running = createMemo(() => taskRunning(props.status)) + // Background task cards stay collapsed: they must not auto-open or show the + // "Starting..." status, which would flicker the transcript as the child runs. + // The input carries `background` from the first part update; promoted tasks + // only gain the state metadata flag later. + const backgroundTask = createMemo( + () => + props.input.background === true || + ((props.partMetadata as Record | undefined)?.background ?? + (props.metadata as Record | undefined)?.background) === true, + ) const avatar = createMemo(() => { const id = childSessionId() return taskAvatarStatus(id, props.status, session.allStatusMap()) @@ -77,6 +87,10 @@ const TaskToolRenderer: Component = (props) => { { defer: true }, ), ) + // Auto-open only once the call is running: while "pending" the streamed + // input cannot yet tell a background task from a foreground one, and a + // background card must never open on its own. + const auto = () => props.status === "running" && !backgroundTask() // BasicTool's forceOpen effect only fires onOpenChange on a false->true // transition — a virtualized remount that starts with forceOpen already // true never transitions, so this local signal must also seed itself from @@ -86,10 +100,27 @@ const TaskToolRenderer: Component = (props) => { initialOpen({ tool: props.tool, partID: props.partID, - defaultOpen: running(), + defaultOpen: auto(), forceOpen: props.forceOpen, }), ) + // The open state is controlled so the card settles once the input arrives. + // A stored preference, a search match, or a manual toggle wins over it. + const [touched, setTouched] = createSignal( + !!props.forceOpen || initialOpen({ tool: props.tool, partID: props.partID }) !== undefined, + ) + const change = (value: boolean) => { + setTouched(true) + setOpen(value) + } + createEffect(() => { + if (touched()) return + if (backgroundTask()) { + setOpen(false) + return + } + if (props.status === "running") setOpen(true) + }) let synced: string | undefined createEffect(() => { @@ -171,6 +202,7 @@ const TaskToolRenderer: Component = (props) => { sessionID: id, title: description(), parentSessionID: session.currentSessionID(), + background: backgroundTask(), worktree: !!worktree, post: vscode.postMessage, }) @@ -240,14 +272,15 @@ const TaskToolRenderer: Component = (props) => { tool={props.tool} partID={props.partID} trigger={trigger()} - defaultOpen={running()} + defaultOpen={auto()} + open={open()} forceOpen={props.forceOpen} defer - onOpenChange={setOpen} + onOpenChange={change} >
- +
{language.t("session.messages.taskStarting")}
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx index 45ef07287df8..222a517de285 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx @@ -31,6 +31,8 @@ interface TranscriptRowViewProps { activeSearchPartFile?: string readonly?: boolean interactivePrompts?: boolean + /** Show reasoning as a compact capped preview (background subagent transcripts). */ + reasoningCapped?: boolean queuedDisabled?: boolean editDisabled?: boolean } @@ -113,6 +115,7 @@ export const TranscriptRowView: Component = (props) => { highlight={props.highlight} readonly={props.readonly} interactivePrompts={props.interactivePrompts} + reasoningCapped={props.reasoningCapped} feedback={{ enabled: feedback.telemetryEnabled(), rating: feedback.getRating(row().message.id), diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/background-agents.ts b/packages/kilo-vscode/webview-ui/src/components/chat/background-agents.ts index e276668f8136..48e29c8072b5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/background-agents.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/background-agents.ts @@ -78,6 +78,18 @@ export function children(tools: ToolPart[]): string[] { return ids } +/** Child session IDs spawned as background jobs, which show a capped reasoning preview. */ +export function backgroundChildren(tools: ToolPart[]): Set { + const ids = new Set() + for (const part of tools) { + if (part.tool !== "task") continue + if (meta(part, "background") !== true) continue + const id = text(meta(part, "sessionId")) + if (id) ids.add(id) + } + return ids +} + function working(status: SessionStatusInfo | undefined): boolean { return status?.type === "busy" || status?.type === "retry" } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/open-subagent.ts b/packages/kilo-vscode/webview-ui/src/components/chat/open-subagent.ts index 5d055ae0174a..e60c264d8f5f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/open-subagent.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/open-subagent.ts @@ -12,6 +12,8 @@ interface OpenSubagent { sessionID: string title?: string parentSessionID?: string + /** True for async background agents, whose reasoning shows a capped preview. */ + background?: boolean /** True inside Agent Manager, where the inspector replaces the editor tab. */ worktree: boolean post: (message: WebviewMessage) => void @@ -32,5 +34,6 @@ export function openSubagent(input: OpenSubagent) { sessionID: input.sessionID, title: input.title, parentSessionID: input.parentSessionID, + background: input.background, }) } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 6d312e5397e5..6fd60c9266a1 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -1296,6 +1296,8 @@ export interface EnhancePromptErrorMessage { export interface ViewSubAgentSessionMessage { type: "viewSubAgentSession" sessionID: string + /** True for async background agents, whose reasoning shows a capped preview. */ + background?: boolean } export interface DiffViewerContextMessage { 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 2dac6ce191f4..2d338815a56a 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 @@ -1261,6 +1261,8 @@ export interface OpenSubAgentViewerRequest { sessionID: string title?: string parentSessionID?: string + /** True for async background agents, whose reasoning shows a capped preview. */ + background?: boolean } // Preview an image attachment in VS Code's built-in image viewer