fix(vscode): keep background subagent cards collapsed and cap reasoning

This commit is contained in:
marius-kilocode
2026-09-14 09:35:52 +02:00
parent c36e226348
commit 3c040da646
18 changed files with 151 additions and 23 deletions
@@ -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.
@@ -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
<div
data-component="reasoning-part"
data-streaming={!done() ? "" : undefined}
data-auto-collapse={props.reasoningAutoCollapse ? "" : undefined}
data-auto-collapse={capped() ? "" : undefined}
data-manual={manual() ? "" : undefined}
>
<Show
+1
View File
@@ -1294,6 +1294,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
message.sessionID,
message.title,
this.getWorkspaceDirectory(message.parentSessionID),
message.background === true,
)
break
case "saveImage":
@@ -21,7 +21,7 @@ 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)
@@ -61,7 +61,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 {
+2 -2
View File
@@ -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", () => {
@@ -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" })]
@@ -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<string | undefined> }> = (props) => {
const SubagentChat: Component<{ active: Accessor<string | undefined>; capped: Accessor<boolean> }> = (props) => {
const session = useSession()
createEffect(
@@ -44,12 +44,22 @@ const SubagentChat: Component<{ active: Accessor<string | undefined> }> = (props
return (
<DataBridge>
<ChatView readonly interactivePrompts={false} promptBoxId="agent-manager:subagent" />
<ChatView
readonly
interactivePrompts={false}
reasoningCapped={props.capped()}
promptBoxId="agent-manager:subagent"
/>
</DataBridge>
)
}
const SubagentContent: Component<Props & { activity: (id: string) => Activity }> = (props) => {
interface ContentProps extends Props {
activity: (id: string) => Activity
background: Accessor<ReadonlySet<string>>
}
const SubagentContent: Component<ContentProps> = (props) => {
const session = useSession()
const language = useLanguage()
const ids = () => props.tabs().map((tab) => tab.id)
@@ -131,7 +141,7 @@ const SubagentContent: Component<Props & { activity: (id: string) => Activity }>
}}
/>
<div class="am-subagent-chat">
<SubagentChat active={props.active} />
<SubagentChat active={props.active} capped={() => props.background().has(props.active() ?? "")} />
</div>
</section>
)
@@ -145,10 +155,15 @@ export const SubagentPanel: Component<Props> = (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<string>()
})
return (
<AgentAvatarPalette ids={siblings()}>
<SessionProvider>
<SubagentContent {...props} activity={session.activityFor} />
<SubagentContent {...props} activity={session.activityFor} background={background} />
</SessionProvider>
</AgentAvatarPalette>
)
+6 -1
View File
@@ -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<ViewType>("newTask")
const [settingsTab, setSettingsTab] = createSignal<string | undefined>()
const [agentManagerProjectId, setAgentManagerProjectId] = createSignal<string | undefined>()
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 = () => {
/>
</Match>
<Match when={currentView() === "subAgentViewer"}>
<ChatView readonly />
<ChatView readonly reasoningCapped={subAgentCapped()} />
</Match>
</Switch>
}
@@ -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<AssistantMessageProps> = (props) => {
forceOpen={forceOpen()}
forceOpenFile={forceOpen() ? props.forceOpenFile : undefined}
reasoningAutoCollapse={display.reasoningAutoCollapse()}
reasoningCapped={props.reasoningCapped}
settled={settled()}
feedback={props.feedback}
throughput={throughputEl()}
@@ -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,
})
@@ -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<ChatViewProps> = (props) => {
questions={standaloneQuestions}
suggestions={standaloneSuggestions}
readonly={props.readonly}
reasoningCapped={props.reasoningCapped}
interactivePrompts={ownsPrompts()}
emptyState={props.emptyState}
introduction={props.introduction}
@@ -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<MessageListProps> = (props) => {
activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined}
readonly={props.readonly}
interactivePrompts={props.interactivePrompts}
reasoningCapped={props.reasoningCapped}
/>
)}
</Virtualizer>
@@ -1382,6 +1385,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
activeSearchPartFile={activeKey() === key ? activeMatch()?.partFile : undefined}
readonly={props.readonly}
interactivePrompts={props.interactivePrompts}
reasoningCapped={props.reasoningCapped}
/>
)}
</For>
@@ -1404,6 +1408,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined}
readonly={props.readonly}
interactivePrompts={props.interactivePrompts}
reasoningCapped={props.reasoningCapped}
/>
)}
</For>
@@ -59,6 +59,16 @@ const TaskToolRenderer: Component<ToolProps> = (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<string, unknown> | undefined)?.background ??
(props.metadata as Record<string, unknown> | undefined)?.background) === true,
)
const avatar = createMemo(() => {
const id = childSessionId()
return taskAvatarStatus(id, props.status, session.allStatusMap())
@@ -77,6 +87,10 @@ const TaskToolRenderer: Component<ToolProps> = (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<ToolProps> = (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<ToolProps> = (props) => {
sessionID: id,
title: description(),
parentSessionID: session.currentSessionID(),
background: backgroundTask(),
worktree: !!worktree,
post: vscode.postMessage,
})
@@ -240,14 +272,15 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
tool={props.tool}
partID={props.partID}
trigger={trigger()}
defaultOpen={running()}
defaultOpen={auto()}
open={open()}
forceOpen={props.forceOpen}
defer
onOpenChange={setOpen}
onOpenChange={change}
>
<div ref={viewport} onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable>
<div ref={content} data-component="task-tools">
<Show when={running() && childToolCount() === 0}>
<Show when={running() && childToolCount() === 0 && !backgroundTask()}>
<div data-slot="task-tool-item" data-state="starting">
<span data-slot="task-tool-title">{language.t("session.messages.taskStarting")}</span>
</div>
@@ -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<TranscriptRowViewProps> = (props) => {
highlight={props.highlight}
readonly={props.readonly}
interactivePrompts={props.interactivePrompts}
reasoningCapped={props.reasoningCapped}
feedback={{
enabled: feedback.telemetryEnabled(),
rating: feedback.getRating(row().message.id),
@@ -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<string> {
const ids = new Set<string>()
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"
}
@@ -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,
})
}
@@ -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 {
@@ -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