diff --git a/packages/coding-agent/.changes/res-1327-expand-arrow-summary-line.md b/packages/coding-agent/.changes/res-1327-expand-arrow-summary-line.md new file mode 100644 index 0000000000..50b2d1aeb4 --- /dev/null +++ b/packages/coding-agent/.changes/res-1327-expand-arrow-summary-line.md @@ -0,0 +1,2 @@ +- 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. +- Changed the agents view hint tray to describe the arrow keys in context — `→ open`, `→ expand`/`→ collapse` on a subagent summary line, and `← parent` only inside an agent scope — in place of the `?` actions hint. diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts index 973167826a..a2a96dce06 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts @@ -664,7 +664,6 @@ export class AgentsViewMode implements Component, Focusable { private deleteConfirmTimer: ReturnType | undefined; private workingIconFrame = 0; private rows: AgentsViewRow[] = []; - private allRows: AgentsViewRow[] = []; private lastListedSummaries: SessionSummary[] = []; private lastVisibleSummaries: SessionSummary[] = []; private savedSessions: AgentConnectionSavedSessionInfo[] = []; @@ -960,7 +959,7 @@ export class AgentsViewMode implements Component, Focusable { if (!this.replyTarget && this.editor.getText().length === 0) { 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; } } @@ -1254,6 +1253,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; @@ -1303,7 +1304,7 @@ export class AgentsViewMode implements Component, Focusable { /** Rebuild rows from the last fetched summaries, keeping selection on the same row. */ private rebuildRows(): void { const selectedIdentity = this.rows[this.selectedIndex]?.identity; - this.allRows = buildAgentsViewRows( + this.rows = buildAgentsViewRows( this.getFilteredRecords(), this.expandedSubagentParents, this.programShownParents, @@ -1311,7 +1312,6 @@ export class AgentsViewMode implements Component, Focusable { computeRecursiveRollups(this.unifiedRecords, this.unifiedIndex), this.anchorSessionId, ); - this.rows = compactSessionRows(this.allRows); const index = selectedIdentity === undefined ? -1 : this.rows.findIndex((row) => row.identity === selectedIdentity); if (index >= 0) { @@ -1391,6 +1391,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; @@ -1411,7 +1415,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); @@ -1455,7 +1459,7 @@ export class AgentsViewMode implements Component, Focusable { /** Whether any subagent under the given agent identity carries spawn code. */ private targetHasSpawnCode(target: string): boolean { - for (const row of this.allRows) { + for (const row of this.rows) { if (row.parentIdentity !== target) { continue; } @@ -2170,7 +2174,7 @@ export class AgentsViewMode implements Component, Focusable { } } this.scopedRecords = scopeToSessionSubtree(this.unifiedRecords, this.scopeKey, this.unifiedIndex); - this.allRows = buildAgentsViewRows( + this.rows = buildAgentsViewRows( this.getFilteredRecords(), this.expandedSubagentParents, this.programShownParents, @@ -2178,7 +2182,6 @@ export class AgentsViewMode implements Component, Focusable { computeRecursiveRollups(this.unifiedRecords, this.unifiedIndex), this.anchorSessionId, ); - this.rows = compactSessionRows(this.allRows); this.applyPendingAncestorExpansion(); this.restoreSelection(); this.ui.requestRender(); @@ -2309,10 +2312,11 @@ export class AgentsViewMode implements Component, Focusable { this.selectedActiveSessionId = undefined; return; } + const selectedIdentity = this.selectedRowIdentity ?? this.persistentState.selectedRowIdentity; const resolution = resolveAgentsViewSelectionState( this.rows, this.selectedIndex, - this.selectedRowIdentity ?? this.persistentState.selectedRowIdentity, + selectedIdentity, this.selectedSessionKey ?? this.persistentState.selectedSessionKey, ); this.selectedIndex = resolution.index; @@ -2471,7 +2475,7 @@ export class AgentsViewMode implements Component, Focusable { } private getAgentCountsText(): string { - const counts = countRowsBySection(this.allRows); + const counts = countRowsBySection(this.rows); return `${counts.running} running, ${counts.idle} idle, ${counts.inactive} inactive`; } @@ -2479,20 +2483,13 @@ export class AgentsViewMode implements Component, Focusable { if (maxRows <= 0) return []; const layout = buildCompactAgentsViewLayout(this.rows, width); const displayItems: DisplayItem[] = []; - const counts = countRowsBySection(this.allRows.length > 0 ? this.allRows : this.rows); + const counts = countRowsBySection(this.rows); for (const section of ["running", "idle", "inactive"] as const) { if (counts[section] === 0) continue; if (displayItems.length > 0) displayItems.push({ type: "spacer" }); 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) { @@ -2517,14 +2514,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.fg("muted", truncateToWidth(`${sectionTitle(item.section)} (${counts[item.section]})`, width)); } @@ -2545,6 +2534,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) ?? ""; @@ -2574,7 +2567,6 @@ export class AgentsViewMode implements Component, Focusable { cells.push(theme.fg("dim", details)); return markRow(formatTableCell(cells.join(" "), width)); } - // Spawn-code rows are read-only context. They render deemphasized — muted // text on a panel background (applied in finalizeRenderedLine) so the program // reads as one quiet segmented block rather than competing with agent rows. @@ -2661,7 +2653,18 @@ export class AgentsViewMode implements Component, Focusable { if (this.replyTarget) { return truncateToWidth(theme.fg("muted", this.renderReplyComposerHints()), width); } - const hints = `${keyText("tui.select.up")}/${keyText("tui.select.down")} navigate ${keyText("tui.select.confirm")} open ${keyText("app.agents.new")} new`; + const selected = this.rows[this.selectedIndex]; + // Enter and Right both toggle the list on a summary row and open everywhere + // else; Left only has a parent scope to return to below the root view. + const rightAction = selected?.kind === "subagent-summary" ? (selected.expanded ? "collapse" : "expand") : "open"; + const hints = [ + `${keyText("tui.select.up")}/${keyText("tui.select.down")} navigate`, + `${keyText("tui.select.confirm")}/${keyText("app.agents.open")} ${rightAction}`, + this.scopeRootSummary ? `${keyText("app.agents.back")} parent` : undefined, + `${keyText("app.agents.new")} new`, + ] + .filter((hint): hint is string => hint !== undefined) + .join(" "); return truncateToWidth(theme.fg("muted", hints), width); } @@ -2729,14 +2732,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[] { diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts index 85d9c93ff6..3db4ff780f 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts @@ -748,6 +748,9 @@ export function resolveAgentsViewSelectionIndex( ): number { const findSelectable = (predicate: (row: AgentsViewRow) => boolean): number => rows.findIndex((row) => row.selectable && predicate(row)); + const selectedSyntheticKind = identity?.startsWith("subagents:") ? "subagent-summary" : undefined; + const preservesSelectedKind = (row: AgentsViewRow): boolean => + selectedSyntheticKind === undefined || row.kind === selectedSyntheticKind; if (identity !== undefined) { const index = findSelectable((row) => row.identity === identity); @@ -759,7 +762,9 @@ export function resolveAgentsViewSelectionIndex( } if (key?.activeSessionId !== undefined) { const activeSessionId = key.activeSessionId; - const index = findSelectable((row) => (row.summary.activeSessionId ?? row.summary.id) === activeSessionId); + const index = findSelectable( + (row) => preservesSelectedKind(row) && (row.summary.activeSessionId ?? row.summary.id) === activeSessionId, + ); if (index >= 0) { return index; } @@ -772,7 +777,7 @@ export function resolveAgentsViewSelectionIndex( } if (key?.sessionId !== undefined) { const sessionId = key.sessionId; - return findSelectable((row) => row.summary.sessionId === sessionId); + return findSelectable((row) => preservesSelectedKind(row) && row.summary.sessionId === sessionId); } return -1; } diff --git a/packages/coding-agent/test/agent-traces.test.ts b/packages/coding-agent/test/agent-traces.test.ts index 8d73ad9655..5169bacee0 100644 --- a/packages/coding-agent/test/agent-traces.test.ts +++ b/packages/coding-agent/test/agent-traces.test.ts @@ -116,7 +116,7 @@ function writeLedgerOutboxEntry(agentDir: string, ledgerFile: string, uploadedBy } async function advanceTimersUntil(condition: () => boolean): Promise { - for (let step = 0; step < 200 && !condition(); step += 1) { + for (let step = 0; step < 1_000 && !condition(); step += 1) { await stat(new URL(import.meta.url)); if (!condition() && vi.getTimerCount() > 0) { await vi.advanceTimersToNextTimerAsync(); @@ -410,6 +410,9 @@ describe("agent trace upload", () => { sessionManager.appendMessage(createAssistantMessage("hi")); await advanceTimersUntil(() => calls.length === 1); expect(calls[0].url).toBe("https://api.example.test/api/v1/agent-traces/sessions/listener-session"); + const sessionFile = sessionManager.getSessionFile()!; + const signature = await stat(sessionFile); + await advanceTimersUntil(() => readOutboxEntry(tempDir, sessionFile)?.size === signature.size); }); it("coalesces new content that persists during an in-flight upload into one follow-up upload", async () => { diff --git a/packages/coding-agent/test/agents-view-mode.test.ts b/packages/coding-agent/test/agents-view-mode.test.ts index fe0c6523bb..25bd430004 100644 --- a/packages/coding-agent/test/agents-view-mode.test.ts +++ b/packages/coding-agent/test/agents-view-mode.test.ts @@ -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, ); @@ -704,6 +704,63 @@ 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; + 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("keeps a subagent summary selected across roster refreshes", () => { + const parent = summary({ sessionName: "parent", sessionFile: undefined }); + 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); + invoke("moveSelection", view, 1); + const selectedRow = () => { + const rows = Reflect.get(view, "rows") as AgentsViewRow[]; + return rows[Reflect.get(view, "selectedIndex") as number]; + }; + expect(selectedRow()?.kind).toBe("subagent-summary"); + const provisionalIdentity = selectedRow()?.identity; + + Reflect.set(view, "lastListedSummaries", [{ ...parent, sessionFile: "/tmp/parent.jsonl" }, child]); + invoke("reconcileCatalogs", view); + + expect(selectedRow()?.kind).toBe("subagent-summary"); + expect(selectedRow()?.identity).not.toBe(provisionalIdentity); + } finally { + stopThemeWatcher(); + } + }); + it("toggles subagent list expansion from the parent row", () => { const expandedSubagentParents = new Set(["root-row"]); const programShownParents = new Set(["root-row"]); @@ -903,7 +960,7 @@ describe("AgentsViewMode", () => { expect(lines).not.toContain("Inactive (0)"); 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"); } @@ -1078,7 +1135,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); @@ -1091,7 +1148,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 { @@ -1241,7 +1297,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", @@ -1260,41 +1316,82 @@ 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); - const collapsedRow = invoke("renderRow", view, rows()[0], 120) as string; - expect(collapsedRow).not.toContain("▸"); - expect(collapsedRow).not.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"); - const expandedRow = invoke("renderRow", view, rows()[0], 120) as string; - expect(expandedRow).not.toContain("▸"); - expect(expandedRow).not.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(); + } + }); + + it("adapts the tray hints to the selected row and the scope", () => { + 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() }, {}); + const hints = () => stripAnsi(invoke("renderHints", view, 200) as string); + try { + Reflect.set(view, "lastListedSummaries", [parent, child]); + invoke("reconcileCatalogs", view); + Reflect.set(view, "selectedIndex", 0); + expect(hints()).toBe("↑/↓ navigate Enter/→ open Ctrl+N new"); + // Right toggles the summary row, so its hint follows the expansion state. + Reflect.set(view, "selectedIndex", 1); + expect(hints()).toBe("↑/↓ navigate Enter/→ expand Ctrl+N new"); + view.handleInput("\x1b[C"); + expect(hints()).toBe("↑/↓ navigate Enter/→ collapse Ctrl+N new"); + // Only a scoped view has a parent to return to. + Reflect.set(view, "scopeRootSummary", parent); + expect(hints()).toBe("↑/↓ navigate Enter/→ collapse ← parent Ctrl+N new"); } finally { stopThemeWatcher(); }