Merge PR #7500: perf(agent-hub): bound roster rendering to viewport (@hancens1024)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user