diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index d92bd6295..7e55064a1 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index ef1ac662e..64967535c 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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 }); } }; diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 50aae0aa0..9d56ad2c0 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -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%", diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 7e534e227..cfb94aae8 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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 { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 8e4c52754..188360a8a 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -310,7 +310,7 @@ export interface InteractiveModeContext { showProviderSetup(): Promise; showHookConfirm(title: string, message: string): Promise; showDebugSelector(): Promise; - showAgentHub(): void; + showAgentHub(options?: { requireContent?: boolean }): void; resetObserverRegistry(): void; // Input handling diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index a721cbf31..82627799d 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -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(); + }); +});