fix(coding-agent): gated agent hub opening when no subagents are available

- Added an `isEmpty` getter on `AgentHubOverlayComponent` to report whether no subagent rows were loaded.
- Extended `showAgentHub` with an optional `requireContent` flag so the overlay is disposed early when empty under the double-<- path.
- Added tests for double-<- gating behavior with no subagents, with subagents, and explicit hub-open behavior.
This commit is contained in:
can1357
2026-06-14 05:13:33 +02:00
parent 0ee1b111ba
commit 86245d722a
6 changed files with 102 additions and 6 deletions
@@ -267,6 +267,15 @@ export class AgentHubOverlayComponent extends Container {
this.#refreshRows();
}
/**
* Whether the table view has no agents to show (every registered agent except
* Main, after the persisted-subagent scan in the constructor). The double-←
* gesture reads this to stay inert when there is nothing to open.
*/
get isEmpty(): boolean {
return this.#rows.length === 0;
}
/** Tear down every subscription and timer. Called by the overlay owner on close. */
dispose(): void {
for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe();
@@ -332,14 +332,16 @@ export class InputController {
// Double-tap left arrow on an empty editor: opens the agent hub from the
// main session, or returns the focused subagent view to the main session.
// Focused ←← intentionally matches Esc.
// Focused ←← intentionally matches Esc. From the main session the gesture
// stays inert when there are no subagents (requireContent); the explicit
// hub key still opens the empty roster.
this.ctx.editor.onLeftAtStart = () => {
if (this.ctx.focusedAgentId) {
this.#handleFocusedLeftTap();
return;
}
if (this.#detectLeftDoubleTap()) {
this.ctx.showAgentHub();
this.ctx.showAgentHub({ requireContent: true });
}
};
@@ -1203,7 +1203,7 @@ export class SelectorController {
});
}
showAgentHub(observers: SessionObserverRegistry): void {
showAgentHub(observers: SessionObserverRegistry, options?: { requireContent?: boolean }): void {
const hubKeys = [
...this.ctx.keybindings.getKeys("app.agents.hub"),
...this.ctx.keybindings.getKeys("app.session.observe"),
@@ -1235,6 +1235,15 @@ export class SelectorController {
sessionFile: this.ctx.sessionManager.getSessionFile() ?? null,
});
// The double-← gesture passes requireContent so it stays inert when there
// are no subagents to show; the explicit hub/observe keys still open the
// empty roster. The freshly built hub already ran the persisted-subagent
// scan, so its row count is the authoritative "is there anything to show".
if (options?.requireContent && hub.isEmpty) {
hub.dispose();
return;
}
overlayHandle = this.ctx.ui.showOverlay(hub, {
anchor: "bottom-center",
width: "100%",
@@ -3466,8 +3466,8 @@ export class InteractiveMode implements InteractiveModeContext {
await this.#selectorController.showDebugSelector();
}
showAgentHub(): void {
this.#selectorController.showAgentHub(this.#observerRegistry);
showAgentHub(options?: { requireContent?: boolean }): void {
this.#selectorController.showAgentHub(this.#observerRegistry, options);
}
resetObserverRegistry(): void {
+1 -1
View File
@@ -310,7 +310,7 @@ export interface InteractiveModeContext {
showProviderSetup(): Promise<void>;
showHookConfirm(title: string, message: string): Promise<boolean>;
showDebugSelector(): Promise<void>;
showAgentHub(): void;
showAgentHub(options?: { requireContent?: boolean }): void;
resetObserverRegistry(): void;
// Input handling
@@ -170,3 +170,79 @@ describe("Agent hub Enter activation", () => {
capturedHub!.dispose();
});
});
describe("Agent hub double-← gating", () => {
beforeAll(() => {
initTheme();
});
afterEach(() => {
resetSettingsForTest();
});
function setup(agents: AgentRegistry) {
let shown: AgentHubOverlayComponent | undefined;
const ctx = {
keybindings: { getKeys: () => [] },
ui: {
showOverlay: (component: AgentHubOverlayComponent) => {
shown = component;
return { hide: () => {} };
},
setFocus: () => {},
requestRender: () => {},
},
editor: {},
collabGuest: { agentRegistry: agents, hubRemote: undefined },
focusAgentSession: async () => {},
session: { getToolByName: () => undefined, extensionRunner: undefined },
sessionManager: { getCwd: () => "/tmp", getSessionFile: () => null },
hideThinkingBlock: false,
};
const controller = new SelectorController(ctx as unknown as InteractiveModeContext);
return { controller, shown: () => shown };
}
function registerWorker(agents: AgentRegistry) {
agents.register({
id: AGENT_ID,
displayName: AGENT_ID,
kind: "sub",
parentId: "Main",
session: { subscribe: () => () => {} } as unknown as AgentSession,
sessionFile: null,
status: "running",
});
}
it("requireContent keeps the hub closed when only Main is registered", () => {
const agents = new AgentRegistry();
agents.register({ id: "Main", displayName: "Main", kind: "main", session: null, sessionFile: null, status: "running" });
const { controller, shown } = setup(agents);
controller.showAgentHub(new SessionObserverRegistry(), { requireContent: true });
expect(shown()).toBeUndefined();
});
it("requireContent opens the hub once a subagent exists", () => {
const agents = new AgentRegistry();
registerWorker(agents);
const { controller, shown } = setup(agents);
controller.showAgentHub(new SessionObserverRegistry(), { requireContent: true });
expect(shown()).toBeDefined();
shown()!.dispose();
});
it("the explicit hub key opens the empty roster even with no subagents", () => {
const agents = new AgentRegistry();
const { controller, shown } = setup(agents);
controller.showAgentHub(new SessionObserverRegistry());
expect(shown()).toBeDefined();
shown()!.dispose();
});
});