From 80462a44d88026e75555db9dcb075ced1b5bacdd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Nov 2026 16:09:15 +0100 Subject: [PATCH] fix(app): reach a compact subagent's composer and keep a focus selection A compact sheet has no composer, so focus() on a subagent without a tab opened a read-only sheet and timed out. It now promotes the sheet to a tab, the same path as Open as tab. A pending focus ran inside the effect that attached the input, before a later effect in that commit applied a prompt set alongside it, so its selection was clamped against the old text. It now runs after the commit. --- docs/plugins.md | 2 +- .../plugin-composers-by-reference.spec.ts | 10 +++ .../helpers/plugin-composers-by-reference.ts | 82 ++++++++++++++++++- .../src/composer/agent-composer/index.test.ts | 19 +++++ .../app/src/composer/agent-composer/index.ts | 10 ++- .../app/src/utils/navigate-to-agent/index.ts | 39 +++++++-- 6 files changed, 150 insertions(+), 12 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 03a2c3a81f..19b961f6bb 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -713,7 +713,7 @@ implementations provide it: `DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock. `focus` differs per implementation on purpose. A running agent's model reveals the agent with -`navigateToAgent` when `ComposerInput.isRevealed()` is false, and holds one pending request until an +`navigateToAgentComposer` when `ComposerInput.isRevealed()` is false, and holds one pending request until an input that is revealed attaches. `isRevealed` is stricter than `isLive`: a compact overlay panel and an unfocused pane leave the input live but unable to take the keyboard, so the agent is revealed first, which also moves pane focus. `Composer` makes `revealed` part of `ComposerInput`, so a change diff --git a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts index 375375c457..520d355655 100644 --- a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts +++ b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts @@ -3,6 +3,7 @@ import { CHECKLIST, withComposerToolsExample, withPluginFocusingAComposer, + withPluginFocusingASubagent, withPluginHoldingNewWorkspaceComposer, withPluginWritingToAnotherWorkspace, } from "../support/helpers/plugin-composers-by-reference"; @@ -116,3 +117,12 @@ test("focus rejects on a New workspace composer the user has left", async ({ pag await author.expectHeldComposerFocusResult("The composer is not on screen."); }); }); + +test("focus opens a compact subagent as a tab with its composer", async ({ page }) => { + test.setTimeout(120_000); + await withPluginFocusingASubagent(page, async (author) => { + await author.openParentAgent(); + await author.focusSubagent(); + await author.expectSubagentTabReadyToType(); + }); +}); diff --git a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts index c280b448d5..91fe96a381 100644 --- a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts +++ b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts @@ -4,10 +4,12 @@ import path from "node:path"; import { expect, test, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { openCommandCenter } from "./command-center"; -import { openAgentRoute } from "./mock-agent"; +import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent"; import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace"; import { copyPluginExample, pluginRequirements } from "./plugin-fixture"; import { seedWorkspace } from "./seed-client"; +import { expectNoSheetOpen, expectOpenTabCount } from "./sheet"; +import { seedSubagent } from "./subagents"; import { getServerId } from "./server-id"; import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs"; @@ -461,3 +463,81 @@ export default function contribute(client) { await workspace.cleanup(); } } + +type FocusAgentGlobal = typeof globalThis & { + focusAgent?: (agentId: string) => Promise; +}; + +export async function withPluginFocusingASubagent( + page: Page, + run: (author: { + openParentAgent(): Promise; + focusSubagent(): Promise; + expectSubagentTabReadyToType(): Promise; + }) => Promise, +) { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "focus-subagent-", + title: "Focus parent", + }); + const child = await seedSubagent( + { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId }, + { parentId: workspace.agentId, title: "Focus child" }, + ); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const directory = await mkdtemp(path.join(tmpdir(), "paseo-focus-subagent-")); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "focus-subagent", requirements: pluginRequirements }), + ); + await writeFile( + path.join(directory, "index.client.tsx"), + `export default function contribute(client) { + globalThis.focusAgent = (agentId) => client.composers({ agentId })[0].focus(); + return () => {}; +}`, + ); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await page.setViewportSize(COMPACT); + await gotoAppShell(page); + await run({ + openParentAgent: () => + test.step("open the parent agent on a phone", async () => { + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + await expect(prompt(page)).toBeVisible({ timeout: 30_000 }); + await expectOpenTabCount(page, 1); + await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent); + }), + focusSubagent: () => + test.step("focus the subagent, which has no tab", async () => { + const result = await page.evaluate( + (agentId) => + (globalThis as FocusAgentGlobal).focusAgent?.(agentId).then( + () => "focused", + (error: Error) => error.message, + ), + child.id, + ); + expect(result).toBe("focused"); + }), + expectSubagentTabReadyToType: () => + test.step("the subagent is a tab with the cursor in its composer", async () => { + await expectNoSheetOpen(page); + await expectOpenTabCount(page, 2); + await expect(prompt(page)).toBeFocused(); + }), + }); + } finally { + await client.removePlugin("focus-subagent"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await client.close(); + await rm(directory, { recursive: true, force: true }); + await workspace.cleanup(); + } +} diff --git a/packages/app/src/composer/agent-composer/index.test.ts b/packages/app/src/composer/agent-composer/index.test.ts index 895c9b6484..d7dacbd950 100644 --- a/packages/app/src/composer/agent-composer/index.test.ts +++ b/packages/app/src/composer/agent-composer/index.test.ts @@ -202,6 +202,25 @@ describe("agent composer focus", () => { expect(focused).toEqual([undefined]); }); + it("focuses after the commit that revealed the input has applied a pending prompt", async () => { + openSession(agent()); + const model = composer({ reveal() {} }); + const focusing = model.focus({ selection: { start: 0, end: 5 } }); + let text = ""; + const textWhenFocused: string[] = []; + model.attachInput({ + ...mountedInput({ revealed: true }).input, + focus: async () => { + textWhenFocused.push(text); + }, + }); + text = "HELLO"; + + await focusing; + + expect(textWhenFocused).toEqual(["HELLO"]); + }); + it("waits for a retained input to come on screen before focusing it", async () => { openSession(agent()); let reveals = 0; diff --git a/packages/app/src/composer/agent-composer/index.ts b/packages/app/src/composer/agent-composer/index.ts index 99b3d07cb3..75aa099645 100644 --- a/packages/app/src/composer/agent-composer/index.ts +++ b/packages/app/src/composer/agent-composer/index.ts @@ -15,7 +15,7 @@ import { useDraftStore } from "@/stores/draft-store"; import { toDraftInputIfReady, type DraftRecord } from "@/stores/draft-store/state"; import { useSessionStore, type Agent } from "@/stores/session-store"; import type { UserComposerAttachment } from "@/attachments/types"; -import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { navigateToAgentComposer } from "@/utils/navigate-to-agent"; /** The mounted composer's input. While attached, prompt commands go through it. */ export interface MountedComposerInput { @@ -236,7 +236,11 @@ export function createAgentComposer( function focusIfRevealed(input: MountedComposerInput) { if (!pendingFocus || !input.isRevealed()) return; const { options, settle } = takePendingFocus()!; - input.focus(options).then(settle.resolve, settle.reject); + // Attaching runs in a commit's effects, and a prompt set alongside this request can be applied + // by a later effect in that commit. A selection has to see that text. + queueMicrotask(() => { + input.focus(options).then(settle.resolve, settle.reject); + }); } function unheld() { @@ -355,7 +359,7 @@ export function agentComposer(serverId: string, agentId: string): AgentComposer composer = createAgentComposer(serverId, agentId, { queryClient: singletonQueryClient, onUnheld: releaseReleasableComposers, - reveal: () => navigateToAgent({ serverId, agentId }), + reveal: () => navigateToAgentComposer({ serverId, agentId }), }); composers.set(key, composer); } diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index 8ccb75d68d..fddcc4ea3f 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -1,22 +1,47 @@ import { router, type Href } from "expo-router"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve"; export type { NavigateToAgentInput } from "./resolve"; +function readAgentNavTarget({ serverId, agentId }: { serverId: string; agentId: string }) { + const session = useSessionStore.getState().sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return { + agentWorkspaceId: agent?.workspaceId, + }; +} + export function navigateToAgent(input: NavigateToAgentInput): string { return resolveNavigateToAgent(input, { - readAgentNavTarget: ({ serverId, agentId }) => { - const session = useSessionStore.getState().sessions[serverId]; - const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); - return { - agentWorkspaceId: agent?.workspaceId, - }; - }, + readAgentNavTarget, navigateToHostAgent: (route) => { router.navigate(route as Href); }, navigateToWorkspace, }); } + +/** + * Navigates to the agent where its composer is. A compact sheet is for looking and has no composer, + * so a subagent that lands in one becomes a tab, as Open as tab does. + */ +export function navigateToAgentComposer(input: NavigateToAgentInput): void { + navigateToAgent(input); + const workspaceId = normalizeWorkspaceOpaqueId( + readAgentNavTarget(input).agentWorkspaceId ?? input.workspaceId, + ); + const workspaceKey = workspaceId + ? buildWorkspaceTabPersistenceKey({ serverId: input.serverId, workspaceId }) + : null; + if (!workspaceKey) return; + const layout = useWorkspaceLayoutStore.getState(); + const sheet = layout.sheetsByWorkspace[workspaceKey]?.find( + ({ tab }) => tab.target.kind === "agent" && tab.target.agentId === input.agentId, + ); + if (sheet) layout.openTabInFront(workspaceKey, sheet); +}