Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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.
49 changes: 21 additions & 28 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 @@ -1269,6 +1269,8 @@ export class AgentsViewMode implements Component, Focusable {
while (added) {
added = false;
for (const row of this.rows) {
// Summary/code rows reuse their parent's summary; only session rows own expansion keys.
if (row.kind !== "agent" && row.kind !== "subagent") continue;
if (wanted.has(row.summary.sessionId) && !this.expandedSubagentParents.has(row.identity)) {
this.expandedSubagentParents.add(row.identity);
added = true;
Expand Down Expand Up @@ -1320,7 +1322,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 +1402,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 +1426,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 +2193,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 +2502,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 +2524,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 +2543,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 +2560,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 All @@ -2584,7 +2578,12 @@ export class AgentsViewMode implements Component, Focusable {
}

private renderActions(width: number): string[] {
const row = this.rows[this.selectedIndex];
const selected = this.rows[this.selectedIndex];
// The summary row is an expansion control; its details are the owning row's.
const row =
selected?.kind === "subagent-summary"
? this.rows.find((candidate) => candidate.identity === selected.parentIdentity)
: selected;
const actions = [
`${keyText("tui.select.confirm")} open ${keyText("app.agents.open")} open ${keyText("app.agents.new")} new`,
`${keyText("app.agents.expand")} expand/collapse subagents ${keyText("app.agents.program")} program`,
Expand Down Expand Up @@ -2764,14 +2763,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
103 changes: 80 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 All @@ -704,6 +704,31 @@ describe("AgentsViewMode", () => {
expect(collapsedView.expandedSubagentParents.size).toBe(0);
});

it("records only session-row identities when re-expanding pending ancestors", () => {
const parent = summary({ sessionName: "parent" });
const child = summary({
id: "child",
activeSessionId: "child",
sessionId: "child-session",
sessionFile: "/tmp/child.jsonl",
runtimeKind: "subagent",
parentActiveSessionId: parent.activeSessionId,
});
const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {});
try {
Reflect.set(view, "lastListedSummaries", [parent, child]);
invoke("reconcileCatalogs", view);
const persistentState = Reflect.get(view, "persistentState") as AgentsViewPersistentState;
persistentState.pendingExpandedAncestorSessionIds = [parent.sessionId];
invoke("applyPendingAncestorExpansion", view);
const expanded = Reflect.get(view, "expandedSubagentParents") as Set<string>;
expect(expanded).toEqual(new Set(["file:/tmp/scope.jsonl"]));
expect((Reflect.get(view, "rows") as AgentsViewRow[]).some((row) => row.kind === "subagent")).toBe(true);
} finally {
stopThemeWatcher();
}
});

it("toggles subagent list expansion from the parent row", () => {
const expandedSubagentParents = new Set(["root-row"]);
const programShownParents = new Set(["root-row"]);
Expand Down Expand Up @@ -854,7 +879,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 +947,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 +960,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 @@ -977,12 +1001,30 @@ describe("AgentsViewMode", () => {
try {
Reflect.set(view, "lastListedSummaries", [
summary({ sessionName: "parent", usage: { inputTokens: 1234, outputTokens: 56, cost: 1.23 } }),
summary({
id: "child",
activeSessionId: "child",
sessionId: "child-session",
sessionFile: "/tmp/child.jsonl",
runtimeKind: "subagent",
parentActiveSessionId: "scope-active",
usage: { inputTokens: 10, outputTokens: 2, cost: 0.5 },
}),
]);
invoke("reconcileCatalogs", view);
// The summary row is an expansion control: its actions show the parent's data.
const builtRows = Reflect.get(view, "rows") as AgentsViewRow[];
Reflect.set(
view,
"selectedIndex",
builtRows.findIndex((row) => row.kind === "subagent-summary"),
);
view.handleInput("?");
const actions = (invoke("renderSessionRows", view, 120, 20) as string[]).map(stripAnsi).join("\n");
expect(actions).toContain("parent");
expect(actions).toContain("1234 in");
expect(actions).toContain("$1.23");
expect(actions).toContain("$1.73 including subagents");
view.handleInput("p");
expect(Reflect.get(view, "showActions")).toBe(false);
const rows = (invoke("renderSessionRows", view, 120, 20) as string[]).map(stripAnsi).join("\n");
Expand All @@ -993,7 +1035,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 +1054,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