Merge PR #7500: perf(agent-hub): bound roster rendering to viewport (@hancens1024)

This commit is contained in:
can1357
2026-08-05 01:11:59 +02:00
5 changed files with 174 additions and 58 deletions
+1
View File
@@ -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.
@@ -183,6 +183,7 @@ export class AgentHubOverlayComponent extends Container {
// Table state
#rows: AgentRef[] = [];
#statusCounts: Record<AgentStatus, number> = { 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<AgentStatus, number> = { 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<number, string[]>();
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<AgentStatus, number> = { 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);
@@ -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[] = [];
@@ -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) => {
@@ -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,