fix(web): collapse completed silent subagent cards (#1901)
Subagent cards keyed their auto-expand on `isActive || Boolean(resultText)`. Silent (Auggie-style) subagents return a result_text but stream no child tool calls, so `resultText` stays populated forever and the card rendered permanently expanded, unlike subagents that emit child tool calls (which collapse to their header + metadata row on completion). A render-phase reset also wiped any manual collapse the moment result_text arrived. Key auto-expand on the running state alone and drop the reset, so completed subagents collapse consistently and the result text is one click away. Add component coverage for the silent-subagent collapse, expand-on-click, and manual-collapse-persists paths. Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ToolSubagentMessage } from "@/components/task/chat/messages/tool-subagent-message";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
|
||||
// Guards the subagent card's expand/collapse behavior. Regression coverage for
|
||||
// "silent" subagents (a result_text with no child tool calls) rendering
|
||||
// permanently expanded: auto-expand must key on the running state alone, not on
|
||||
// result_text, so completed cards collapse to their header + metadata row like
|
||||
// subagents that stream child tool calls.
|
||||
|
||||
function subagentMessage(opts: {
|
||||
status: string;
|
||||
result_text?: string;
|
||||
description?: string;
|
||||
}): Message {
|
||||
return {
|
||||
id: "m1",
|
||||
content: "Subagent",
|
||||
metadata: {
|
||||
status: opts.status,
|
||||
normalized: {
|
||||
kind: "subagent_task",
|
||||
subagent_task: {
|
||||
description: opts.description ?? "Summarize the repo",
|
||||
subagent_type: "general-purpose",
|
||||
status: opts.status,
|
||||
result_text: opts.result_text,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
const noopRenderChild = () => null;
|
||||
const SILENT_RESULT = "Found 42 files across 7 packages.";
|
||||
const RESULT_TEXT = "subagent-result-text";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ToolSubagentMessage expansion", () => {
|
||||
it("collapses a completed subagent with child tool calls to its header", () => {
|
||||
const child = { id: "c1", content: "sleep 30", metadata: {} } as unknown as Message;
|
||||
render(
|
||||
<ToolSubagentMessage
|
||||
comment={subagentMessage({ status: "complete" })}
|
||||
childMessages={[child]}
|
||||
renderChild={() => <div data-testid="child-body">sleep 30</div>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("child-body")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses a completed silent subagent (result_text, no children)", () => {
|
||||
render(
|
||||
<ToolSubagentMessage
|
||||
comment={subagentMessage({
|
||||
status: "complete",
|
||||
result_text: SILENT_RESULT,
|
||||
})}
|
||||
childMessages={[]}
|
||||
renderChild={noopRenderChild}
|
||||
/>,
|
||||
);
|
||||
// Completed and no longer running -> collapsed: result text stays hidden
|
||||
// until the user opens the card. The metadata row remains visible.
|
||||
expect(screen.queryByTestId(RESULT_TEXT)).toBeNull();
|
||||
expect(screen.getByTestId("subagent-type")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reveals the silent subagent's result text when expanded", () => {
|
||||
render(
|
||||
<ToolSubagentMessage
|
||||
comment={subagentMessage({
|
||||
status: "complete",
|
||||
result_text: SILENT_RESULT,
|
||||
})}
|
||||
childMessages={[]}
|
||||
renderChild={noopRenderChild}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByTestId(RESULT_TEXT).textContent).toContain(SILENT_RESULT);
|
||||
});
|
||||
|
||||
it("auto-expands while the subagent is running, then auto-collapses on completion", () => {
|
||||
const running = subagentMessage({ status: "running" });
|
||||
const { rerender } = render(
|
||||
<ToolSubagentMessage comment={running} childMessages={[]} renderChild={noopRenderChild} />,
|
||||
);
|
||||
// Running -> auto-expanded working indicator is shown.
|
||||
expect(screen.queryByText("Subagent working...")).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<ToolSubagentMessage
|
||||
comment={subagentMessage({ status: "complete", result_text: "Done." })}
|
||||
childMessages={[]}
|
||||
renderChild={noopRenderChild}
|
||||
/>,
|
||||
);
|
||||
// Completed without a manual override -> auto-collapsed.
|
||||
expect(screen.queryByTestId(RESULT_TEXT)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a manual collapse after the subagent completes", () => {
|
||||
const { rerender } = render(
|
||||
<ToolSubagentMessage
|
||||
comment={subagentMessage({ status: "running" })}
|
||||
childMessages={[]}
|
||||
renderChild={noopRenderChild}
|
||||
/>,
|
||||
);
|
||||
// User collapses the running card.
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.queryByText("Subagent working...")).toBeNull();
|
||||
|
||||
// Completion emits result_text; the manual collapse must survive (it used
|
||||
// to be wiped, forcing the card back open).
|
||||
rerender(
|
||||
<ToolSubagentMessage
|
||||
comment={subagentMessage({ status: "complete", result_text: "Final summary." })}
|
||||
childMessages={[]}
|
||||
renderChild={noopRenderChild}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId(RESULT_TEXT)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -182,23 +182,13 @@ export const ToolSubagentMessage = memo(function ToolSubagentMessage({
|
||||
// Track manual override state - null means "use auto behavior"
|
||||
const [manualExpandState, setManualExpandState] = useState<boolean | null>(null);
|
||||
|
||||
// Auto behavior: expand if active or if we have a result text to surface
|
||||
// (silent Auggie-style subagents have no child messages, so we want the
|
||||
// final summary visible without an extra click).
|
||||
const autoExpanded = isActive || Boolean(resultText);
|
||||
|
||||
// Reset the manual override the first time a result_text arrives so a card
|
||||
// the user collapsed while the subagent was running auto-opens to show the
|
||||
// summary. "Adjust state during render" pattern (preferred over useEffect):
|
||||
// https://react.dev/learn/you-might-not-need-an-effect — only fires on the
|
||||
// empty -> non-empty transition; later user collapses persist.
|
||||
const [prevResultText, setPrevResultText] = useState(resultText);
|
||||
if (resultText !== prevResultText) {
|
||||
setPrevResultText(resultText);
|
||||
if (resultText && !prevResultText) {
|
||||
setManualExpandState(null);
|
||||
}
|
||||
}
|
||||
// Auto behavior: expand only while the subagent is active. On completion the
|
||||
// card auto-collapses to its header + metadata row, matching subagents that
|
||||
// stream child tool calls; the result text is one click away. Previously
|
||||
// auto-expand was also keyed on result_text, which left silent (Auggie-style)
|
||||
// subagents — the ones with no child messages — permanently expanded because
|
||||
// result_text never clears.
|
||||
const autoExpanded = isActive;
|
||||
|
||||
// Derive expanded state: manual override takes precedence, otherwise use auto
|
||||
const isExpanded = manualExpandState ?? autoExpanded;
|
||||
|
||||
Reference in New Issue
Block a user