Skip to content
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/derive-scoped-heartbeats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Kept heartbeat lists current when session or subagent scope changes.
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,21 @@ const HEARTBEAT_SCROLL_INDICATOR_ROWS = 1;
type HeartbeatManagerMode = { type: "list" } | { type: "actions"; heartbeatId: string; selectedIndex: number };

export interface HeartbeatManagerOptions {
getHeartbeats: () => readonly AgentConnectionHeartbeat[];
getRows: () => number;
onAction: (heartbeat: AgentConnectionHeartbeat, action: AgentHeartbeatManagementAction) => Promise<void>;
onClose: () => void;
requestRender: () => void;
}

export class HeartbeatManagerComponent implements Component, Focusable {
private heartbeats: AgentConnectionHeartbeat[] = [];
private selectedIndex = 0;
private selectedHeartbeatId: string | undefined;
private mode: HeartbeatManagerMode = { type: "list" };
private busy = false;
private error: string | undefined;
private _focused = false;

constructor(
heartbeats: readonly AgentConnectionHeartbeat[],
private readonly options: HeartbeatManagerOptions,
) {
this.setHeartbeats(heartbeats);
}
constructor(private readonly options: HeartbeatManagerOptions) {}

get focused(): boolean {
return this._focused;
Expand All @@ -45,22 +40,13 @@ export class HeartbeatManagerComponent implements Component, Focusable {

invalidate(): void {}

setHeartbeats(heartbeats: readonly AgentConnectionHeartbeat[]): void {
const selectedId = this.heartbeats[this.selectedIndex]?.job.id;
this.heartbeats = [...heartbeats].sort((left, right) => {
private get heartbeats(): AgentConnectionHeartbeat[] {
return [...this.options.getHeartbeats()].sort((left, right) => {
const sessionOrder = this.sessionLabel(left).localeCompare(this.sessionLabel(right));
if (sessionOrder !== 0) return sessionOrder;
if (left.job.source !== right.job.source) return left.job.source === "heartbeat" ? -1 : 1;
return left.job.createdAt.localeCompare(right.job.createdAt);
});
const nextIndex = selectedId
? this.heartbeats.findIndex((heartbeat) => heartbeat.job.id === selectedId)
: this.selectedIndex;
this.selectedIndex = Math.max(0, Math.min(nextIndex < 0 ? 0 : nextIndex, this.heartbeats.length - 1));
if (this.mode.type !== "list" && !this.findHeartbeat(this.mode.heartbeatId)) {
this.mode = { type: "list" };
}
this.options.requestRender();
}

handleInput(data: string): void {
Expand Down Expand Up @@ -98,6 +84,14 @@ export class HeartbeatManagerComponent implements Component, Focusable {
}

render(width: number): string[] {
const heartbeats = this.heartbeats;
if (!heartbeats.some((heartbeat) => heartbeat.job.id === this.selectedHeartbeatId)) {
this.selectedHeartbeatId = heartbeats[0]?.job.id;
}
if (this.mode.type !== "list") {
const heartbeatId = this.mode.heartbeatId;
if (!heartbeats.some((heartbeat) => heartbeat.job.id === heartbeatId)) this.mode = { type: "list" };
}
const panel = this.mode.type === "list" ? this.createHeartbeatListPanel() : this.createActionPanel(this.mode);
const safeWidth = Math.max(1, width);
const panelWidth = Math.min(safeWidth, HEARTBEAT_PANEL_MAX_WIDTH);
Expand Down Expand Up @@ -127,19 +121,21 @@ export class HeartbeatManagerComponent implements Component, Focusable {
}

private populateHeartbeatList(list: MenuList): void {
if (this.heartbeats.length === 0) {
const heartbeats = this.heartbeats;
if (heartbeats.length === 0) {
list.addChild(new TruncatedText(theme.fg("muted", "No running or paused heartbeats"), 1, 0));
return;
}
const selectedIndex = this.getSelectedIndex(heartbeats);
const visibleItems = this.getListLayout().visibleItems;
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(visibleItems / 2), this.heartbeats.length - visibleItems),
Math.min(selectedIndex - Math.floor(visibleItems / 2), heartbeats.length - visibleItems),
);
const endIndex = Math.min(startIndex + visibleItems, this.heartbeats.length);
const endIndex = Math.min(startIndex + visibleItems, heartbeats.length);

for (let index = startIndex; index < endIndex; index++) {
const heartbeat = this.heartbeats[index];
const heartbeat = heartbeats[index];
if (!heartbeat) continue;
const source = this.sourceLabel(heartbeat);
const label = heartbeat.job.label?.trim();
Expand All @@ -152,15 +148,13 @@ export class HeartbeatManagerComponent implements Component, Focusable {
primary: label || this.singleLine(heartbeat.job.prompt) || this.defaultHeartbeatName(heartbeat),
secondary: details,
meta: this.formatStatus(heartbeat),
selected: index === this.selectedIndex,
selected: index === selectedIndex,
}),
);
}

if (startIndex > 0 || endIndex < this.heartbeats.length) {
list.addChild(
new TruncatedText(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.heartbeats.length})`), 1, 0),
);
if (startIndex > 0 || endIndex < heartbeats.length) {
list.addChild(new TruncatedText(theme.fg("muted", ` (${selectedIndex + 1}/${heartbeats.length})`), 1, 0));
}
}

Expand Down Expand Up @@ -198,8 +192,11 @@ export class HeartbeatManagerComponent implements Component, Focusable {

private moveSelection(delta: number): void {
if (this.mode.type === "list") {
if (this.heartbeats.length === 0) return;
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex + delta, this.heartbeats.length - 1));
const heartbeats = this.heartbeats;
if (heartbeats.length === 0) return;
const selectedIndex = this.getSelectedIndex(heartbeats);
const nextIndex = Math.max(0, Math.min(selectedIndex + delta, heartbeats.length - 1));
this.selectedHeartbeatId = heartbeats[nextIndex]?.job.id;
} else {
const count = this.availableActions(this.findHeartbeat(this.mode.heartbeatId)).length;
this.mode = { ...this.mode, selectedIndex: Math.max(0, Math.min(this.mode.selectedIndex + delta, count - 1)) };
Expand All @@ -209,7 +206,8 @@ export class HeartbeatManagerComponent implements Component, Focusable {

private async confirmSelection(): Promise<void> {
if (this.mode.type === "list") {
const heartbeat = this.heartbeats[this.selectedIndex];
const heartbeats = this.heartbeats;
const heartbeat = heartbeats[this.getSelectedIndex(heartbeats)];
if (heartbeat) {
this.mode = { type: "actions", heartbeatId: heartbeat.job.id, selectedIndex: 0 };
this.options.requestRender();
Expand Down Expand Up @@ -254,6 +252,11 @@ export class HeartbeatManagerComponent implements Component, Focusable {
];
}

private getSelectedIndex(heartbeats: readonly AgentConnectionHeartbeat[]): number {
const index = heartbeats.findIndex((heartbeat) => heartbeat.job.id === this.selectedHeartbeatId);
return index < 0 ? 0 : index;
}

private findHeartbeat(id: string): AgentConnectionHeartbeat | undefined {
return this.heartbeats.find((heartbeat) => heartbeat.job.id === id);
}
Expand Down
40 changes: 14 additions & 26 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -990,7 +990,6 @@ export class InteractiveMode {
private connectionState: AgentConnectionState | undefined;
private connectionResourceSnapshot: AgentConnectionResourceSnapshot | undefined;
private heartbeatCatalog: AgentConnectionHeartbeat[] = [];
private heartbeats: AgentConnectionHeartbeat[] = [];
private heartbeatRefreshPromise: Promise<void> | undefined;
private heartbeatRefreshRequested = false;
private heartbeatManager: HeartbeatManagerComponent | undefined;
Expand Down Expand Up @@ -2553,32 +2552,19 @@ export class InteractiveMode {

private applyHeartbeatCatalog(heartbeats: AgentConnectionHeartbeat[]): void {
this.heartbeatCatalog = heartbeats;
this.updateScopedHeartbeats();
}

private updateScopedHeartbeats(): void {
const heartbeats = scopeHeartbeatsToSession(
this.heartbeatCatalog,
this.connectionState,
this.subagentSnapshots.values(),
);
if (
heartbeats.length === this.heartbeats.length &&
heartbeats.every((heartbeat, index) => heartbeat === this.heartbeats[index])
) {
return;
}
this.heartbeats = heartbeats;
this.heartbeatManager?.setHeartbeats(heartbeats);
this.scheduleHeartbeatManagerRefresh();
this.updateSubagentSummaryLine();
this.ui.requestRender();
}

private getScopedHeartbeats(): AgentConnectionHeartbeat[] {
return scopeHeartbeatsToSession(this.heartbeatCatalog, this.connectionState, this.subagentSnapshots.values());
}

private applyConnectionStateSnapshot(state: AgentConnectionState): void {
this.bindPromptStashSession(state.sessionId);
this.connectionState = state;
this.updateScopedHeartbeats();
this.scheduleHeartbeatManagerRefresh();
// Don't touch contextUsageTokenBaseline: a mid-stream snapshot reflects only completed
// turns (the in-flight message isn't persisted yet), so the in-flight delta must keep
// accumulating. The baseline is managed at turn end (refreshConnectionContextUsage) and
Expand Down Expand Up @@ -5941,7 +5927,7 @@ export class InteractiveMode {
}

private refreshSubagentSummary(): void {
this.updateScopedHeartbeats();
Comment thread
snimu marked this conversation as resolved.
this.scheduleHeartbeatManagerRefresh();
this.updateSubagentSummaryLine();
this.updateWorkingPulse();
this.syncWorkingLoader();
Expand Down Expand Up @@ -5972,7 +5958,7 @@ export class InteractiveMode {
this.subagentSnapshots.clear();
this.rlmNodeId = undefined;
this.updateSubagentSummaryLine();
this.updateScopedHeartbeats();
this.scheduleHeartbeatManagerRefresh();
// Clearing snapshots can drop the last running subagent; reconcile the
// pulse and loader so neither lingers when nothing is in flight.
this.updateWorkingPulse();
Expand Down Expand Up @@ -6093,11 +6079,12 @@ export class InteractiveMode {
}

private getTrayHeartbeatLabel(): string | undefined {
if (this.heartbeats.length === 0) {
const heartbeats = this.getScopedHeartbeats();
if (heartbeats.length === 0) {
return undefined;
}
const paused = this.heartbeats.filter((heartbeat) => heartbeat.job.status === "paused").length;
const count = `${this.heartbeats.length} heartbeat${this.heartbeats.length === 1 ? "" : "s"}`;
const paused = heartbeats.filter((heartbeat) => heartbeat.job.status === "paused").length;
const count = `${heartbeats.length} heartbeat${heartbeats.length === 1 ? "" : "s"}`;
const pausedLabel = paused ? ` · ${paused} paused` : "";
const shortcut = keyText("app.heartbeats.open");
return `${count}${pausedLabel}${shortcut ? ` (${shortcut})` : ""}`;
Expand Down Expand Up @@ -9547,7 +9534,8 @@ export class InteractiveMode {
this.showError(error instanceof Error ? error.message : String(error));
return;
}
const manager = new HeartbeatManagerComponent(this.heartbeats, {
const manager = new HeartbeatManagerComponent({
getHeartbeats: () => this.getScopedHeartbeats(),
getRows: () => this.ui.terminal.rows,
onAction: (heartbeat, action) => this.manageHeartbeat(heartbeat, action),
onClose: () => this.closeHeartbeatManager(),
Expand Down Expand Up @@ -9580,7 +9568,7 @@ export class InteractiveMode {
if (!this.heartbeatManager) {
return;
}
const nextRunAt = this.heartbeats
const nextRunAt = this.getScopedHeartbeats()
.filter((heartbeat) => heartbeat.job.status === "active" && heartbeat.job.nextRunAt)
.map((heartbeat) => Date.parse(heartbeat.job.nextRunAt!))
.filter(Number.isFinite)
Expand Down
32 changes: 26 additions & 6 deletions packages/coding-agent/test/heartbeat-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ describe("HeartbeatManagerComponent", () => {
});

it("groups user and agent heartbeats and stays within terminal width", () => {
const component = new HeartbeatManagerComponent(
[
const component = new HeartbeatManagerComponent({
getHeartbeats: () => [
heartbeat("user", { source: "heartbeat" }),
heartbeat("agent", {
source: "rlm_heartbeat",
Expand All @@ -59,8 +59,11 @@ describe("HeartbeatManagerComponent", () => {
lastError: "the previous delivery failed",
}),
],
{ getRows: () => 20, onAction: async () => {}, onClose: () => {}, requestRender: () => {} },
);
getRows: () => 20,
onAction: async () => {},
onClose: () => {},
requestRender: () => {},
});
for (const width of [32, 48, 80]) {
const lines = component.render(width);
expect(lines.every((line) => visibleWidth(line) === width)).toBe(true);
Expand All @@ -81,9 +84,25 @@ describe("HeartbeatManagerComponent", () => {
expect(rendered.find((line) => line.includes("Esc close"))?.indexOf("Esc close")).toBe(titleColumn);
});

it("reads the current heartbeat list when rendered", () => {
let heartbeats = [heartbeat("user", { source: "heartbeat" })];
const component = new HeartbeatManagerComponent({
getHeartbeats: () => heartbeats,
getRows: () => 20,
onAction: async () => {},
onClose: () => {},
requestRender: () => {},
});

expect(stripAnsi(component.render(80).join("\n"))).toContain("1 heartbeat.");
heartbeats = [heartbeat("user", { source: "heartbeat" }), heartbeat("agent", { source: "rlm_heartbeat" })];
expect(stripAnsi(component.render(80).join("\n"))).toContain("2 heartbeats.");
});

it("uses arrows to open and go back, and closes with escape or the toggle shortcut", () => {
let closeCount = 0;
const component = new HeartbeatManagerComponent([heartbeat("user", { source: "heartbeat" })], {
const component = new HeartbeatManagerComponent({
getHeartbeats: () => [heartbeat("user", { source: "heartbeat" })],
getRows: () => 20,
onAction: async () => {},
onClose: () => closeCount++,
Expand Down Expand Up @@ -114,7 +133,8 @@ describe("HeartbeatManagerComponent", () => {

it("pauses and stops individual heartbeats immediately", async () => {
const actions: Array<{ id: string; action: AgentHeartbeatManagementAction }> = [];
const component = new HeartbeatManagerComponent([heartbeat("user", { source: "heartbeat" })], {
const component = new HeartbeatManagerComponent({
getHeartbeats: () => [heartbeat("user", { source: "heartbeat" })],
getRows: () => 20,
onAction: async (entry, action) => {
actions.push({ id: entry.job.id, action });
Expand Down
Loading
Loading