Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Changed the agents view subagent expand/collapse control: the arrow now sits on the always-visible subagent summary line instead of hiding on the session row.
40 changes: 13 additions & 27 deletions packages/coding-agent/src/modes/agents-view/agents-view-mode.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -980,7 +980,7 @@ export class AgentsViewMode implements Component, Focusable {
}
if (this.keybindings.matches(data, "app.agents.expand")) {
const row = this.rows[this.selectedIndex];
if (row && row.descendantCount > 0) this.toggleSubagentList(row);
if (row && (row.kind === "subagent-summary" || row.descendantCount > 0)) this.toggleSubagentList(row);
return;
}
}
Expand Down Expand Up @@ -1320,7 +1320,7 @@ export class AgentsViewMode implements Component, Focusable {
computeRecursiveRollups(this.unifiedRecords, this.unifiedIndex),
this.anchorSessionId,
);
this.rows = compactSessionRows(this.allRows);
this.rows = this.allRows;
Comment thread
snimu marked this conversation as resolved.
Outdated
const index =
selectedIdentity === undefined ? -1 : this.rows.findIndex((row) => row.identity === selectedIdentity);
if (index >= 0) {
Expand Down Expand Up @@ -1400,6 +1400,10 @@ export class AgentsViewMode implements Component, Focusable {
if (!row?.selectable || this.isPendingDeleteRow(row)) {
return;
}
if (row.kind === "subagent-summary") {
this.toggleSubagentList(row);
return;
}
if (row.kind === "subagent") {
this.openSelectedSubagent(row);
return;
Expand All @@ -1420,7 +1424,7 @@ export class AgentsViewMode implements Component, Focusable {
}

private toggleSubagentList(row: AgentsViewRow): void {
const target = row.identity;
const target = row.kind === "subagent-summary" ? (row.parentIdentity ?? row.identity) : row.identity;
if (this.expandedSubagentParents.has(target)) {
this.expandedSubagentParents.delete(target);
this.programShownParents.delete(target);
Expand Down Expand Up @@ -2187,7 +2191,7 @@ export class AgentsViewMode implements Component, Focusable {
computeRecursiveRollups(this.unifiedRecords, this.unifiedIndex),
this.anchorSessionId,
);
this.rows = compactSessionRows(this.allRows);
this.rows = this.allRows;
this.applyPendingAncestorExpansion();
this.restoreSelection();
this.ui.requestRender();
Expand Down Expand Up @@ -2496,13 +2500,6 @@ export class AgentsViewMode implements Component, Focusable {
displayItems.push({ type: "heading", section });
for (const row of getDisplayRowsForSection(this.rows, section)) {
displayItems.push({ type: "row", row });
if (
(row.kind === "agent" || row.kind === "subagent") &&
row.runningSubagentCount > 0 &&
!this.expandedSubagentParents.has(row.identity)
) {
displayItems.push({ type: "running-subagents", row });
}
}
}
if (displayItems.length === 0) {
Expand All @@ -2525,14 +2522,6 @@ export class AgentsViewMode implements Component, Focusable {
const sliceStart = selectedDisplayIndex >= start + contentRows ? selectedDisplayIndex - contentRows + 1 : start;
const lines = displayItems.slice(sliceStart, sliceStart + contentRows).map((item) => {
if (item.type === "spacer") return "";
if (item.type === "running-subagents") {
const count = item.row.runningSubagentCount;
const indent = " ".repeat(item.row.depth + 1);
return theme.fg(
"success",
truncateToWidth(`${indent}${count} subagent${count === 1 ? "" : "s"} running`, width),
);
}
if (item.type === "heading") {
return theme.bold(truncateToWidth(`${sectionTitle(item.section)} (${counts[item.section]})`, width));
}
Expand All @@ -2552,6 +2541,10 @@ export class AgentsViewMode implements Component, Focusable {
const selected = row.selectable && row.identity === this.rows[this.selectedIndex]?.identity;
const markRow = (line: string): string => (selected ? `${SELECTED_ROW_MARKER}${line}` : line);
if (row.kind === "subagent-code") return this.renderCodeRow(row);
if (row.kind === "subagent-summary") {
const indent = " ".repeat(row.depth);
return markRow(formatTableCell(`${indent}${row.expanded ? "▾" : "▸"} ${row.title}`, width));
}
const pendingDelete = row.kind === "agent" && this.isPendingDeleteRow(row);
const pendingKill = row.kind === "subagent" && this.isPendingKillSubagentRow(row);
const details = layout.details.get(row.identity) ?? "";
Expand All @@ -2565,10 +2558,9 @@ export class AgentsViewMode implements Component, Focusable {
return markRow(formatTableCell(theme.fg("error", title), width));
}
const icon = this.formatRowIcon(row.section, this.getRowIcon(row.section));
const expand = row.descendantCount > 0 ? (this.expandedSubagentParents.has(row.identity) ? "▾" : "▸") : " ";
const badge = formatHeartbeatBadge(row.heartbeat);
const heartbeat = badge ? `${theme.fg((row.heartbeat?.activeCount ?? 0) > 0 ? "error" : "dim", badge)} ` : "";
const title = `${" ".repeat(row.depth)}${icon}${expand} ${heartbeat}${styleRowTitle(row)}`;
const title = `${" ".repeat(row.depth)}${icon} ${heartbeat}${styleRowTitle(row)}`;
const status =
row.summary.statusLabel !== undefined || row.summary.lastHeardFromAt !== undefined
? row.statusLabel
Expand Down Expand Up @@ -2764,14 +2756,8 @@ export class AgentsViewMode implements Component, Focusable {
type DisplayItem =
| { type: "spacer" }
| { type: "heading"; section: AgentsViewSection }
| { type: "running-subagents"; row: AgentsViewRow }
| { type: "row"; row: AgentsViewRow };

// Summary rows fold into the running-subagents display items.
function compactSessionRows(rows: readonly AgentsViewRow[]): AgentsViewRow[] {
return rows.filter((row) => row.kind !== "subagent-summary");
}

// Nested rows (subagent summaries and expanded subagents) always render in
// their top-level agent's section block, regardless of their own section.
function getDisplayRowsForSection(rows: readonly AgentsViewRow[], section: AgentsViewSection): AgentsViewRow[] {
Expand Down
60 changes: 37 additions & 23 deletions packages/coding-agent/test/agents-view-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ describe("AgentsViewMode", () => {
expect(
expandedRows.find((row) => row.kind === "agent" && row.summary.sessionId === "root-session")?.identity,
).toBe("file:/tmp/root.jsonl");
expect(expandedRows.some((row) => row.kind === "subagent-summary")).toBe(false);
expect(expandedRows.some((row) => row.kind === "subagent-summary")).toBe(true);
expect(expandedRows.some((row) => row.kind === "subagent" && row.summary.sessionId === "child-session")).toBe(
true,
);
Expand Down Expand Up @@ -854,7 +854,7 @@ describe("AgentsViewMode", () => {
expect(lines.some((line) => line.startsWith("Idle"))).toBe(true);
expect(lines.join("\n")).not.toMatch(/show program|#sub|\$agent|↑in|↓out/);
const rows = Reflect.get(view, "rows") as AgentsViewRow[];
expect(rows.filter((row) => row.kind === "subagent-summary")).toHaveLength(0);
expect(rows.filter((row) => row.kind === "subagent-summary")).toHaveLength(1);
for (const line of rendered) {
expect(invoke("finalizeRenderedLine", view, line, 120)).not.toContain("\x1b[48");
}
Expand Down Expand Up @@ -922,7 +922,7 @@ describe("AgentsViewMode", () => {
try {
Reflect.set(view, "lastListedSummaries", [parent, child]);
invoke("reconcileCatalogs", view);
expect(rows().map((row) => row.kind)).toEqual(["agent"]);
expect(rows().map((row) => row.kind)).toEqual(["agent", "subagent-summary"]);
const finish = vi.fn();
Reflect.set(view, "finish", finish);
invoke("openSelected", view);
Expand All @@ -935,7 +935,6 @@ describe("AgentsViewMode", () => {
invoke("cycleProgramForSelected", view);
expect(rows().some((row) => row.kind === "subagent-code" && row.code === child.spawnCode)).toBe(true);
expect(rows().some((row) => row.kind === "subagent" && row.summary.sessionId === child.sessionId)).toBe(true);
expect(rows().some((row) => row.kind === "subagent-summary")).toBe(false);
invoke("cycleProgramForSelected", view);
expect(rows().some((row) => row.kind === "subagent-code")).toBe(false);
} finally {
Expand Down Expand Up @@ -993,7 +992,7 @@ describe("AgentsViewMode", () => {
}
});

it("shows running-subagent counts only while collapsed and work remains", () => {
it("puts the expand affordance on the subagent summary line instead of the session row", () => {
const parent = summary({ sessionName: "parent" });
const child = summary({
id: "child",
Expand All @@ -1012,37 +1011,52 @@ describe("AgentsViewMode", () => {
sessionId: "child-session-2",
sessionFile: "/tmp/child-2.jsonl",
};
const childless = summary({
id: "solo",
activeSessionId: "solo",
sessionId: "solo-session",
sessionName: "solo",
});
const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {});
const rows = () => Reflect.get(view, "rows") as AgentsViewRow[];
const lines = () => (invoke("renderSessionRows", view, 120, 20) as string[]).map(stripAnsi);
const renderRow = (row: AgentsViewRow) =>
(invoke("renderRow", view, row, 120) as string).replace("\0agents-view-selected-row\0", "");
const summaryRow = () => rows().find((row) => row.kind === "subagent-summary")!;
try {
Reflect.set(view, "lastListedSummaries", [parent, child, secondChild]);
Reflect.set(view, "lastListedSummaries", [parent, child, secondChild, childless]);
invoke("reconcileCatalogs", view);
expect(rows()).toHaveLength(1);
expect(invoke("renderRow", view, rows()[0], 120)).toContain("▸");
const collapsed = lines();
const parentIndex = collapsed.findIndex((line) => line.includes("parent"));
expect(collapsed[parentIndex + 1]).toBe(" 2 subagents running");
invoke("moveSelection", view, 1);
expect(Reflect.get(view, "selectedIndex")).toBe(0);
view.handleInput("\x1b[1;3C");
expect(rows().map((row) => row.kind)).toEqual(["agent", "subagent", "subagent"]);
expect(lines().join("\n")).not.toContain("subagents running");
expect(invoke("renderRow", view, rows()[0], 120)).toContain("▾");
Reflect.set(view, "selectedIndex", -1);
expect(rows().map((row) => row.kind)).toEqual(["agent", "subagent-summary", "agent"]);
// Session rows carry no arrow; the summary line is the visible control.
for (const row of rows().filter((r) => r.kind === "agent")) {
expect(stripAnsi(renderRow(row))).not.toMatch(/[▸▾]/);
}
const collapsedLine = renderRow(summaryRow());
expect(stripAnsi(collapsedLine).trimEnd()).toBe(" ▸ 2 subagents running");
// Normal foreground: no dim/success styling on the summary line.
expect(collapsedLine).toBe(stripAnsi(collapsedLine));
// Expand from the summary row itself (keybinding unchanged).
Reflect.set(view, "selectedIndex", 1);
view.handleInput("\x1b[1;3C");
expect(rows()).toHaveLength(1);
expect(lines()).toContain(" 2 subagents running");
expect(rows().map((row) => row.kind)).toEqual(["agent", "subagent-summary", "subagent", "subagent", "agent"]);
expect(stripAnsi(renderRow(summaryRow())).trimEnd()).toBe(" ▾ 2 subagents running");
// Enter on the summary row collapses it again.
invoke("openSelected", view);
expect(rows().some((row) => row.kind === "subagent")).toBe(false);
expect(stripAnsi(renderRow(summaryRow()))).toContain("▸ 2 subagents running");
const idleChild = { ...child, activity: "idle", isStreaming: false };
Reflect.set(view, "lastListedSummaries", [parent, idleChild, secondChild]);
Reflect.set(view, "lastListedSummaries", [parent, idleChild, secondChild, childless]);
invoke("reconcileCatalogs", view);
expect(lines()).toContain(" 1 subagent running");
expect(stripAnsi(renderRow(summaryRow()))).toContain(" 1 subagent running");
Reflect.set(view, "lastListedSummaries", [
parent,
idleChild,
{ ...secondChild, activity: "idle", isStreaming: false },
childless,
]);
invoke("reconcileCatalogs", view);
expect(lines().join("\n")).not.toMatch(/subagents? running/);
// Finished subagents keep a visible, expandable summary line.
expect(stripAnsi(renderRow(summaryRow()))).toContain("▸ 2 subagents");
} finally {
stopThemeWatcher();
}
Expand Down
Loading