Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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
74 changes: 40 additions & 34 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,12 +992,12 @@ export class InteractiveMode {
private connectionResourceSnapshot: AgentConnectionResourceSnapshot | undefined;
private sessionHasMessages = false;
private heartbeatCatalog: AgentConnectionHeartbeat[] = [];
private heartbeats: AgentConnectionHeartbeat[] = [];
private heartbeatRefreshPromise: Promise<void> | undefined;
private heartbeatRefreshRequested = false;
private heartbeatManager: HeartbeatManagerComponent | undefined;
private heartbeatManagerHandle: OverlayHandle | undefined;
private heartbeatManagerRefreshTimer: ReturnType<typeof setTimeout> | undefined;
private heartbeatManagerRefreshAt: number | undefined;

// Registry of images pasted this session, keyed by the `[image #N]` marker
// shown to the user. Insertion-ordered; the bytes persist (bounded by
Expand Down Expand Up @@ -2570,32 +2570,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 @@ -5938,7 +5925,7 @@ export class InteractiveMode {
}

private refreshSubagentSummary(): void {
this.updateScopedHeartbeats();
this.scheduleHeartbeatManagerRefresh();
this.updateSubagentSummaryLine();
this.updateWorkingPulse();
this.syncWorkingLoader();
Expand Down Expand Up @@ -5969,7 +5956,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 @@ -6099,11 +6086,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 @@ -9585,7 +9573,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 All @@ -9600,36 +9589,45 @@ export class InteractiveMode {
}

private closeHeartbeatManager(): void {
if (this.heartbeatManagerRefreshTimer) {
clearTimeout(this.heartbeatManagerRefreshTimer);
this.heartbeatManagerRefreshTimer = undefined;
}
this.clearHeartbeatManagerRefreshTimer();
this.heartbeatManagerHandle?.hide();
this.heartbeatManagerHandle = undefined;
this.heartbeatManager = undefined;
this.ui.requestRender();
}

private scheduleHeartbeatManagerRefresh(): void {
if (this.heartbeatManagerRefreshTimer) {
clearTimeout(this.heartbeatManagerRefreshTimer);
this.heartbeatManagerRefreshTimer = undefined;
}
if (!this.heartbeatManager) {
this.clearHeartbeatManagerRefreshTimer();
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)
.sort((left, right) => left - right)[0];
if (nextRunAt === undefined) {
this.clearHeartbeatManagerRefreshTimer();
return;
}
const untilNextRun = nextRunAt - Date.now();
const delay = untilNextRun > 0 ? Math.min(60_000, untilNextRun + 250) : 5_000;
const refreshAt = Date.now() + delay;
// Subagent snapshots re-derive this schedule constantly; keep an earlier
// pending refresh instead of re-arming, or an overdue heartbeat's 5s
// fallback would be postponed for as long as children stay busy.
if (
this.heartbeatManagerRefreshTimer &&
this.heartbeatManagerRefreshAt !== undefined &&
this.heartbeatManagerRefreshAt <= refreshAt
) {
return;
}
this.clearHeartbeatManagerRefreshTimer();
this.heartbeatManagerRefreshAt = refreshAt;
this.heartbeatManagerRefreshTimer = setTimeout(() => {
this.heartbeatManagerRefreshTimer = undefined;
this.heartbeatManagerRefreshAt = undefined;
if (!this.heartbeatManager) {
return;
}
Expand All @@ -9638,6 +9636,14 @@ export class InteractiveMode {
this.heartbeatManagerRefreshTimer.unref?.();
}

private clearHeartbeatManagerRefreshTimer(): void {
if (this.heartbeatManagerRefreshTimer) {
clearTimeout(this.heartbeatManagerRefreshTimer);
this.heartbeatManagerRefreshTimer = undefined;
}
this.heartbeatManagerRefreshAt = undefined;
}

private async manageHeartbeat(
heartbeat: AgentConnectionHeartbeat,
action: AgentHeartbeatManagementAction,
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