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.
This commit is contained in:
Mohamed Boudra
2026-11-09 16:09:15 +01:00
parent bee9f94a49
commit 80462a44d8
6 changed files with 150 additions and 12 deletions
+1 -1
View File
@@ -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
@@ -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();
});
});
@@ -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<void>;
};
export async function withPluginFocusingASubagent(
page: Page,
run: (author: {
openParentAgent(): Promise<void>;
focusSubagent(): Promise<void>;
expectSubagentTabReadyToType(): Promise<void>;
}) => Promise<void>,
) {
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();
}
}
@@ -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;
@@ -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);
}
@@ -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);
}