diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f25c214db..266d7fcf2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -66,6 +66,7 @@ ### Fixed +- Fixed Agent Hub opening and selection becoming O(all rows) on large rosters: row rendering is now lazy around the selected viewport, and observer lookup is O(1) by id instead of copy-sorting every session per row. - Fixed extension slash commands appearing as user prompts after being handled locally. - Preserved explicit session titles when branching from an earlier conversation turn. - Fixed an issue where unhandled JavaScript rejections in the browser guest could crash the main process and active sessions, converting them into tool errors instead. diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 0b20c0c4e..4d08ce484 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -183,6 +183,7 @@ export class AgentHubOverlayComponent extends Container { // Table state #rows: AgentRef[] = []; + #statusCounts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; #selectedRow = 0; #notice: string | undefined; /** Captured row order from the first refresh; keeps the hub stable while open. */ @@ -368,6 +369,9 @@ export class AgentHubOverlayComponent extends Container { #refreshRows(): void { const selectedId = this.#rows[this.#selectedRow]?.id; const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID); + const counts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; + for (const ref of refs) counts[ref.status]++; + this.#statusCounts = counts; if (!this.#rowOrder) { // First refresh (usually the constructor): order by status, then recency. @@ -398,7 +402,7 @@ export class AgentHubOverlayComponent extends Container { } #observableFor(id: string): ObservableSession | undefined { - return this.#observers.getSessions().find(s => s.id === id); + return this.#observers.getSession(id); } // ======================================================================== @@ -418,30 +422,46 @@ export class AgentHubOverlayComponent extends Container { const termHeight = process.stdout.rows || 40; // Chrome: 2 borders + title + notice? + blank + hints + border const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0)); - const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width)); - // Entries are 1-2 lines tall; grow a window around the selection until - // the line budget is spent, so the selected entry stays centered. + // Render outward from the selection and stop once the viewport is full. + // Cache rendered entries so a boundary probe is not paid twice when the + // same index is later accepted into the window. + const rendered = new Map(); + const entryAt = (index: number): string[] => { + const cached = rendered.get(index); + if (cached) return cached; + const ref = this.#rows[index]; + if (!ref) return []; + const entry = this.#renderEntry(ref, index === this.#selectedRow, width); + rendered.set(index, entry); + return entry; + }; let start = this.#selectedRow; let end = this.#selectedRow + 1; - let used = entries[start]?.length ?? 0; + let used = entryAt(start).length; for (let grew = true; grew; ) { grew = false; - if (end < entries.length && used + entries[end].length <= budget) { - used += entries[end].length; - end++; - grew = true; + if (end < this.#rows.length) { + const next = entryAt(end); + if (used + next.length <= budget) { + used += next.length; + end++; + grew = true; + } } - if (start > 0 && used + entries[start - 1].length <= budget) { - start--; - used += entries[start].length; - grew = true; + if (start > 0) { + const previous = entryAt(start - 1); + if (used + previous.length <= budget) { + start--; + used += previous.length; + grew = true; + } } } if (start > 0) { lines.push(` ${theme.fg("dim", `… ${start} more`)}`); } for (let i = start; i < end; i++) { - lines.push(...entries[i]); + lines.push(...entryAt(i)); } if (end < this.#rows.length) { lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); @@ -458,13 +478,9 @@ export class AgentHubOverlayComponent extends Container { } #statusSummary(): string { - const counts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; - for (const ref of this.#rows) { - counts[ref.status]++; - } const parts: string[] = []; for (const status of ["running", "idle", "parked", "aborted"] as const) { - const count = counts[status]; + const count = this.#statusCounts[status]; if (count > 0) parts.push(`${count} ${status}`); } return parts.join(theme.sep.dot); diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts index baabe2bb2..0b8b8c428 100644 --- a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -613,9 +613,7 @@ export class AgentTranscriptViewer implements Component { } #statsLine(): string { - const observed: ObservableSession | undefined = this.deps.observers - ?.getSessions() - .find(s => s.id === this.deps.agentId); + const observed: ObservableSession | undefined = this.deps.observers?.getSession(this.deps.agentId); const progress = observed?.progress; if (!progress) return ""; const stats: string[] = []; diff --git a/packages/coding-agent/src/modes/session-observer-registry.ts b/packages/coding-agent/src/modes/session-observer-registry.ts index e94ffa9d9..3074f073b 100644 --- a/packages/coding-agent/src/modes/session-observer-registry.ts +++ b/packages/coding-agent/src/modes/session-observer-registry.ts @@ -91,6 +91,11 @@ export class SessionObserverRegistry { this.#notifyListeners("main"); } + /** Return one tracked session without copying or sorting the registry. */ + getSession(id: string): ObservableSession | undefined { + return this.#sessions.get(id); + } + getSessions(): ObservableSession[] { const sessions = [...this.#sessions.values()]; sessions.sort((a, b) => { diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 2588dd3eb..6c834e70b 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -121,6 +121,108 @@ describe("Agent hub row ordering", () => { } }); + it("bounds observer lookups and entry rendering to the viewport on large rosters", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(12); + const agents = new AgentRegistry(); + for (let i = 0; i < 10_000; i++) { + const id = `Agent-${i.toString().padStart(5, "0")}`; + agents.register({ id, displayName: id, kind: "sub", session: null, status: "parked" }); + } + + const observers = new SessionObserverRegistry(); + const getSessions = vi.spyOn(observers, "getSessions"); + const getSession = vi.spyOn(observers, "getSession"); + const hub = new AgentHubOverlayComponent({ + observers, + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + getSessions.mockClear(); + getSession.mockClear(); + const visibleIds = renderedAgentIds(hub); + // rows=12 → line budget 5 for single-line parked rows; plus one failed + // boundary probe past the window. Must not scale with the 10_000 roster. + expect(visibleIds).toHaveLength(5); + expect(getSessions).not.toHaveBeenCalled(); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(8); + expect(getSession.mock.calls.length).toBeGreaterThan(0); + + const text = Bun.stripANSI(hub.render(120).join("\n")); + expect(text).toContain("10000 parked"); + expect(text).toMatch(/… \d+ more/); + + // Moving selection re-renders only the new viewport, not the whole roster. + getSessions.mockClear(); + getSession.mockClear(); + hub.handleInput("j"); + const afterMove = renderedAgentIds(hub); + expect(afterMove).toHaveLength(5); + expect(afterMove).toContain(visibleIds[1]!); + expect(getSessions).not.toHaveBeenCalled(); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(8); + } finally { + hub.dispose(); + } + }); + + it("sizes the lazy viewport by real entry height when rows have a task line", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(12); + const agents = new AgentRegistry(); + for (let i = 0; i < 100; i++) { + const id = `TaskAgent-${i.toString().padStart(3, "0")}`; + agents.register({ + id, + displayName: id, + kind: "sub", + session: null, + status: "parked", + }); + } + + const observers = new SessionObserverRegistry(); + const getSession = vi.spyOn(observers, "getSession"); + const hub = new AgentHubOverlayComponent({ + observers, + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + // Force a second task line via observer metadata so entry height is 2. + getSession.mockImplementation((id: string) => ({ + id, + kind: "subagent", + label: "Subagent", + status: "active", + description: `task for ${id}`, + lastUpdate: Date.now(), + })); + getSession.mockClear(); + const visibleIds = renderedAgentIds(hub); + // Each entry is 2 lines; budget 5 → at most 2 full entries + probes. + expect(visibleIds.length).toBeGreaterThan(0); + expect(visibleIds.length).toBeLessThanOrEqual(3); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(6); + const text = Bun.stripANSI(hub.render(120).join("\n")); + expect(text).toContain("task for"); + expect(text).toContain(visibleIds[0]!); + } finally { + hub.dispose(); + } + }); + it("truncates lines and sanitizes newlines to prevent terminal wrapping", () => { geometry = stubStdoutGeometry(80); const agents = new AgentRegistry(); @@ -133,16 +235,14 @@ describe("Agent hub row ordering", () => { }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "RevAgentStream", - kind: "subagent", - label: "Subagent", - status: "active", - description: "Complete the assignment below, thoroughly:\n- check performance\n- check leaks", - lastUpdate: Date.now(), - }, - ]); + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "RevAgentStream", + kind: "subagent", + label: "Subagent", + status: "active", + description: "Complete the assignment below, thoroughly:\n- check performance\n- check leaks", + lastUpdate: Date.now(), + }); const hub = new AgentHubOverlayComponent({ observers, @@ -174,19 +274,17 @@ describe("Agent hub row ordering", () => { agents.register({ id: "GuestAgent", displayName: "Guest Agent", kind: "sub", session: null }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "GuestAgent", - kind: "subagent", - label: "Subagent", - status: "active", - lastUpdate: Date.now(), - progress: { - resolvedModel: "openai/gpt-4o", - resolvedModelIsFallback: true, - } as never, - }, - ]); + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "GuestAgent", + kind: "subagent", + label: "Subagent", + status: "active", + lastUpdate: Date.now(), + progress: { + resolvedModel: "openai/gpt-4o", + resolvedModelIsFallback: true, + } as never, + }); const hub = new AgentHubOverlayComponent({ observers, @@ -216,19 +314,17 @@ describe("Agent hub row ordering", () => { agents.register({ id: "FastAgent", displayName: "Fast Agent", kind: "sub", session }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "FastAgent", - kind: "subagent", - label: "Subagent", - status: "active", - lastUpdate: Date.now(), - progress: { - resolvedModel: "fireworks/kimi-k2", - resolvedModelIsFallback: true, - } as never, - }, - ]); + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "FastAgent", + kind: "subagent", + label: "Subagent", + status: "active", + lastUpdate: Date.now(), + progress: { + resolvedModel: "fireworks/kimi-k2", + resolvedModelIsFallback: true, + } as never, + }); const hub = new AgentHubOverlayComponent({ observers,