diff --git a/docs/plugins.md b/docs/plugins.md
index f76bb00905..d64e7ed150 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -712,6 +712,29 @@ implementations provide it:
in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments),
`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
+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
+re-runs `attachInput`, which is what tells the pending request the input is on screen. The draft
+half has nothing to navigate to and does not pull the user back to a draft they left, so it rejects
+when not revealed. Sheets, menus, pickers and modals the composer opens over itself do not count
+against `revealed`: `takeInputFocus` closes them through the `OverlayDismissalScope` in
+`components/ui/overlay-dismissal.tsx`, and places the cursor only after the commit that removes
+them. A web modal hands focus back to the button that opened it in that commit, so placing the
+cursor earlier loses it. The overlay primitives register while open: every controlled bottom
+sheet through `useIsolatedBottomSheetVisibility`, `AdaptiveModalSheet`'s desktop dialog, `MenuRoot`,
+`Combobox`, and `Modal` through its `onRequestClose`. Their close must close; a surface built on none
+of them must call `useDismissibleOverlay`. Overlapping requests share each overlay's close, and the
+latest request places the cursor while the earlier ones settle with it. A native input reports
+focus synchronously and refuses it while not editable, such as during dictation, so the native
+branch checks `isFocused()` instead of trusting the call. The agent panel opens the scope around its whole chat
+because the tracks above the composer render outside `Composer`, and the composer's own scope
+joins it. `toPluginComposer` clears the Command Center's focus restore, which would
+otherwise take the cursor back when the Command Center closes. Native `focus` opens the keyboard, so
+the app's web-only `focusInput` stays separate and plugins go through `takeInputFocus`.
+
`ComposerAgent` stays for the built-in controls. Add a capability to the `ComposerModel`, both
implementations and `toPluginComposer`; never branch on `context` in the host.
diff --git a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
index 5b68663862..cf64451c45 100644
--- a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
+++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
@@ -19,6 +19,7 @@ import {
openSheetAsTab,
expectDeclinedDiscardPrompt,
expectSheetAtSnap,
+ promptMentioning,
replaceSheetFileText,
selectTabFromSwitcher,
sheet,
@@ -28,17 +29,6 @@ import {
const FILE_LINK = "linked.ts:3";
-function promptMentioning(target: string): string {
- return [
- "Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
- "Return JSON only with fields 'title' and 'branch'.",
- "",
- "",
- `Open \`${target}\` now`,
- "",
- ].join("\n");
-}
-
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
page,
}) => {
diff --git a/packages/app/e2e/browser/explorer-sidebar.spec.ts b/packages/app/e2e/browser/explorer-sidebar.spec.ts
index 06a6994332..b63775ab2b 100644
--- a/packages/app/e2e/browser/explorer-sidebar.spec.ts
+++ b/packages/app/e2e/browser/explorer-sidebar.spec.ts
@@ -13,6 +13,7 @@ import {
ensureExplorerSidebar,
openFilesPanel,
waitForWorkspaceTabsVisible,
+ dragTabIntoExplorer,
} from "../support/helpers/workspace-tabs";
function explorerSidebar(page: Parameters[0]) {
@@ -194,26 +195,6 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
}
});
-async function dragTabIntoExplorer(
- page: Parameters[0],
- tabId: string,
-) {
- const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
- const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
- const sourceBox = await source.boundingBox();
- const destinationBox = await destination.boundingBox();
- if (!sourceBox || !destinationBox)
- throw new Error("Tab drag requires visible source and destination");
- const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
- await page.mouse.move(start.x, start.y);
- await page.mouse.down();
- await page.mouse.move(start.x + 12, start.y + 4);
- await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
- steps: 20,
- });
- await page.mouse.up();
-}
-
test("moves agents into Explorer and keeps the shared Agent launcher available", async ({
page,
}, testInfo) => {
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 4878ab76e6..1d8ddf5b7f 100644
--- a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts
+++ b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts
@@ -2,6 +2,10 @@ import { test } from "../support/fixtures";
import {
CHECKLIST,
withComposerToolsExample,
+ withPluginFocusingAComposer,
+ withPluginFocusingASubagent,
+ withPluginFocusingAnAgentWithContextUsage,
+ withPluginHoldingNewWorkspaceComposer,
withPluginWritingToAnotherWorkspace,
} from "../support/helpers/plugin-composers-by-reference";
@@ -39,6 +43,17 @@ test("the panel follows which agent is focused", async ({ page }) => {
});
});
+test("an Action in the compact controls sheet closes the sheet and focuses the composer", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ await withComposerToolsExample(page, async (author) => {
+ await author.useCompactLayout();
+ await author.insertChecklistFromControlsSheet();
+ await author.expectComposerReadyToType(CHECKLIST);
+ });
+});
+
test("a plugin writes to an agent's composer while its workspace is not on screen", async ({
page,
}) => {
@@ -51,3 +66,158 @@ test("a plugin writes to an agent's composer while its workspace is not on scree
await author.expectComposerText("A note from a plugin");
});
});
+
+test("a pill that inserts text and focuses leaves the cursor in the composer, ready to type", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ await withPluginFocusingAComposer(page, async (author) => {
+ await author.openTargetAgent();
+ await author.moveFocusOutOfComposer();
+ await author.pressPill();
+ await author.expectComposerFocused();
+ await author.typeAtCursor(" world");
+ await author.expectComposerText("HELLO world");
+ });
+});
+
+test("focus reaches an agent in another tab of the workspace", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginFocusingAComposer(page, async (author) => {
+ await author.openTargetAgent();
+ await author.openOtherAgent();
+ await author.focusFromCommandCenter();
+ await author.expectOnTargetAgent();
+ await author.expectComposerFocused();
+ });
+});
+
+test("focus reaches an agent in another workspace, with the selection asked for", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ await withPluginFocusingAComposer(page, async (author) => {
+ await author.openTargetAgent();
+ await author.fillComposer("typed before");
+ await author.goToAnotherWorkspace();
+ await author.selectPromptFromCommandCenter();
+ await author.expectOnTargetAgent();
+ await author.expectComposerFocused();
+ await author.typeAtCursor("replaced");
+ await author.expectComposerText("replaced");
+ });
+});
+
+test("focus closes the compact Explorer panel covering the composer", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginFocusingAComposer(page, async (author) => {
+ await author.useCompactLayout();
+ await author.openTargetAgent();
+ await author.coverComposerWithExplorerPanel();
+ await author.pressPanelFocusButton();
+ await author.expectExplorerPanelClosed();
+ await author.expectComposerFocused();
+ });
+});
+
+test("focus rejects on a New workspace composer the user has left", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
+ await author.openNewWorkspace();
+ await author.expectHeldComposerFocusResult("focused");
+ await author.openSettings();
+ await author.expectHeldComposerFocusResult("The composer is not on screen.");
+ });
+});
+
+test("focus closes a picker the composer opened over itself", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
+ await author.openNewWorkspace();
+ await author.openThinkingPicker();
+ await author.expectHeldComposerFocusResult("focused");
+ await author.expectHeldComposerReadyToType();
+ });
+});
+
+test("focus closes an image preview the composer opened over itself", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
+ await author.openNewWorkspace();
+ await author.previewAttachedImage();
+ await author.expectHeldComposerFocusResult("focused");
+ await author.expectHeldComposerReadyToType();
+ });
+});
+
+test("overlapping focus calls end at the later selection while a preview closes", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
+ await author.openNewWorkspace();
+ await author.typeInHeldComposer("HELLO");
+ await author.previewAttachedImage();
+ await author.expectOverlappingFocusesKeepTheLatestSelection();
+ });
+});
+
+test("focus closes the context usage sheet on a phone", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginFocusingAnAgentWithContextUsage(page, async (author) => {
+ await author.openContextUsageOnPhone();
+ await author.focusAgent();
+ await author.expectReadyToType();
+ });
+});
+
+test("focus closes a page the composer's Subagents track opened over it", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginFocusingASubagent(page, async (author) => {
+ await author.openParentAgent();
+ await author.openSubagentPageInParentComposer();
+ await author.focusParent();
+ await author.expectParentReadyToType();
+ });
+});
+
+test("focus reaches a New workspace composer opened from a compact Explorer", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
+ await author.leaveCompactExplorerForNewWorkspace();
+ await author.expectHeldComposerFocusResult("focused");
+ });
+});
+
+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.openExplorer();
+ await author.focusSubagent();
+ await author.expectSubagentTabReadyToType();
+ });
+});
+
+test("focus closes a file sheet still opening over an Explorer composer", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginFocusingASubagent(page, async (author) => {
+ await author.keepParentInExplorerOnCompactScreen();
+ await author.focusParent();
+ await author.expectParentReadyToType();
+ await author.focusParentAsAFileSheetOpens();
+ await author.expectParentReadyToType();
+ });
+});
+
+test("focus reaches an agent kept in the Explorer on a compact screen", async ({ page }) => {
+ test.setTimeout(120_000);
+ await withPluginFocusingASubagent(page, async (author) => {
+ await author.keepParentInExplorerOnCompactScreen();
+ await author.focusParent();
+ await author.expectParentReadyToType();
+ await author.coverExplorerComposerWithFileSheet();
+ await author.focusParent();
+ await author.expectParentReadyToType();
+ });
+});
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 d761ba966d..bfba91d2e5 100644
--- a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts
+++ b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts
@@ -3,16 +3,37 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
+import { attachComposerTestImage, openImageLightbox } from "./composer";
+import {
+ contextWindowDetails,
+ pressContextWindowMeter,
+ seedAgentWithContextWindow,
+} from "./context-window";
+import { gotoWorkspace } from "./launcher";
import { openCommandCenter } from "./command-center";
-import { openAgentRoute } from "./mock-agent";
-import { connectNewWorkspaceDaemonClient } from "./new-workspace";
+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,
+ promptMentioning,
+ sheet,
+ tapSheetBackdrop,
+} from "./sheet";
+import { openSubagentFromCompactTrack, seedSubagent } from "./subagents";
import { getServerId } from "./server-id";
-import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
+import {
+ createAgentTabFromMenu,
+ dragTabIntoExplorer,
+ ensureExplorerSidebar,
+} from "./workspace-tabs";
const WIDE = { width: 1440, height: 900 };
+const COMPACT = { width: 390, height: 844 };
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
+const FILE_LINK = "linked.ts:3";
type Workspace = Awaited>;
@@ -50,6 +71,9 @@ export async function withComposerToolsExample(
focusTheAgentTab(): Promise;
expectPanelFocusedAgent(focused: boolean): Promise;
expectPanelShowsReview(needsReview: boolean): Promise;
+ useCompactLayout(): Promise;
+ insertChecklistFromControlsSheet(): Promise;
+ expectComposerReadyToType(text: string): Promise;
}) => Promise,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-tools-", title: "Composer tools" });
@@ -115,6 +139,23 @@ export async function withComposerToolsExample(
expect(
page.getByText(`Needs review: ${needsReview ? "yes" : "no"}`, { exact: true }),
).toBeVisible()),
+ useCompactLayout: () =>
+ test.step("narrow the window to a phone", async () => {
+ await page.setViewportSize(COMPACT);
+ await expect(prompt(page).filter({ visible: true })).toBeVisible();
+ }),
+ insertChecklistFromControlsSheet: () =>
+ test.step("press the Action in the composer's controls sheet", async () => {
+ await page.getByRole("button", { name: /^Select model/ }).click();
+ await page.getByText("Insert checklist", { exact: true }).click();
+ }),
+ expectComposerReadyToType: (text) =>
+ test.step("the sheet is closed and the cursor is in the composer", async () => {
+ await expectNoSheetOpen(page);
+ const input = prompt(page).filter({ visible: true });
+ await expect(input).toHaveValue(text);
+ await expect(input).toBeFocused();
+ }),
});
} finally {
await client.removePlugin("composer-controls-example");
@@ -205,4 +246,516 @@ export async function withPluginWritingToAnotherWorkspace(
}
}
+/**
+ * A plugin that reaches one agent's composer from a pill and from Command Center items, and asks
+ * for focus, wherever the user is.
+ */
+export async function withPluginFocusingAComposer(
+ page: Page,
+ run: (author: {
+ openOtherAgent(): Promise;
+ openTargetAgent(): Promise;
+ goToAnotherWorkspace(): Promise;
+ moveFocusOutOfComposer(): Promise;
+ pressPill(): Promise;
+ focusFromCommandCenter(): Promise;
+ selectPromptFromCommandCenter(): Promise;
+ useCompactLayout(): Promise;
+ coverComposerWithExplorerPanel(): Promise;
+ pressPanelFocusButton(): Promise;
+ expectExplorerPanelClosed(): Promise;
+ fillComposer(text: string): Promise;
+ expectOnTargetAgent(): Promise;
+ expectComposerFocused(): Promise;
+ typeAtCursor(text: string): Promise;
+ expectComposerText(text: string): Promise;
+ }) => Promise,
+) {
+ const workspace = await seedWorkspace({ repoPrefix: "composer-focus-", title: "Composer focus" });
+ const elsewhere = await seedWorkspace({ repoPrefix: "focus-away-", title: "Focus away" });
+ const target = await createMockAgent(workspace, "Target agent");
+ const other = await createMockAgent(workspace, "Other agent");
+ const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
+ const previous = await client.getDaemonConfig();
+ const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-focus-"));
+ const targetId = JSON.stringify(target.id);
+ await writeFile(
+ path.join(directory, "paseo-plugin.json"),
+ JSON.stringify({ id: "composer-focus", requirements: pluginRequirements }),
+ );
+ await writeFile(
+ path.join(directory, "index.client.tsx"),
+ `import { Pressable, Text } from "react-native";
+
+export default function contribute(client) {
+ client.addComposerPill({
+ id: "hello",
+ workspaceId: ${JSON.stringify(workspace.workspaceId)},
+ agentId: ${targetId},
+ button: {
+ title: "Add HELLO to the composer",
+ icon: "Hand",
+ label: "Hello",
+ behavior: {
+ kind: "action",
+ async onPress() {
+ for (const composer of client.composers({ agentId: ${targetId} })) {
+ composer.insertText("HELLO");
+ await composer.focus();
+ }
+ },
+ },
+ },
+ });
+ client.addWorkspacePanel({
+ id: "tools",
+ title: "Focus tools",
+ icon: "ListChecks",
+ locations: ["explorer"],
+ Component() {
+ return (
+ client.composers({ agentId: ${targetId} })[0]?.focus()}
+ >
+ Focus the target agent
+
+ );
+ },
+ });
+ client.addCommandCenterItem({
+ id: "focus",
+ title: "Composer focus: focus the target",
+ icon: "Flag",
+ context: "global",
+ onSelect({ composers }) {
+ return composers({ agentId: ${targetId} })[0].focus();
+ },
+ });
+ client.addCommandCenterItem({
+ id: "select",
+ title: "Composer focus: select the prompt",
+ icon: "Flag",
+ context: "global",
+ onSelect({ composers }) {
+ const composer = composers({ agentId: ${targetId} })[0];
+ return composer.focus({ selection: { start: 0, end: composer.prompt.length } });
+ },
+ });
+ return () => {};
+}`,
+ );
+ const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`);
+ const workspaceRow = (id: string) =>
+ page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`);
+ const panelFocusButton = () =>
+ page.getByRole("button", { name: "Focus the target agent", exact: true });
+ try {
+ await client.patchDaemonConfig({ pluginsEnabled: true });
+ await client.installDirectoryPlugin(directory);
+ await page.setViewportSize(WIDE);
+ await gotoAppShell(page);
+ await run({
+ openOtherAgent: () =>
+ test.step("open another agent, so the target's tab is not showing", async () => {
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: other.id });
+ await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
+ }),
+ openTargetAgent: () =>
+ test.step("open the target agent", async () => {
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id });
+ await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
+ }),
+ goToAnotherWorkspace: () =>
+ test.step("go to another workspace", async () => {
+ await workspaceRow(elsewhere.workspaceId).click();
+ await expect(targetTab().first()).toBeHidden();
+ }),
+ moveFocusOutOfComposer: () =>
+ test.step("the composer does not have focus", async () => {
+ await prompt(page).blur();
+ await expect(prompt(page)).not.toBeFocused();
+ }),
+ pressPill: () =>
+ test.step("press the pill", () => page.getByRole("button", { name: "Hello" }).click()),
+ focusFromCommandCenter: () =>
+ test.step("run the plugin's focus command", () =>
+ runCommand(page, "Composer focus: focus the target")),
+ selectPromptFromCommandCenter: () =>
+ test.step("run the plugin's select command", () =>
+ runCommand(page, "Composer focus: select the prompt")),
+ useCompactLayout: () =>
+ test.step("use a phone-sized window", () => page.setViewportSize(COMPACT)),
+ coverComposerWithExplorerPanel: () =>
+ test.step("open the plugin's panel in the Explorer, covering the composer", async () => {
+ const explorer = await ensureExplorerSidebar(page);
+ await explorer.getByRole("button", { name: "New tab", exact: true }).click();
+ await page
+ .getByTestId("workspace-new-tab-menu")
+ .filter({ visible: true })
+ .getByRole("menuitem", { name: "Focus tools", exact: true })
+ .click();
+ await expect(panelFocusButton()).toBeVisible();
+ }),
+ pressPanelFocusButton: () =>
+ test.step("press the panel's button", () => panelFocusButton().click()),
+ expectExplorerPanelClosed: () =>
+ test.step("the Explorer no longer covers the composer", () =>
+ expect(panelFocusButton()).toBeHidden({ timeout: 30_000 })),
+ fillComposer: (text) =>
+ test.step(`write "${text}" in the composer`, () => prompt(page).fill(text)),
+ expectOnTargetAgent: () =>
+ test.step("the target agent's tab is showing", async () => {
+ await expect(targetTab().filter({ visible: true }).first()).toHaveAttribute(
+ "aria-selected",
+ "true",
+ { timeout: 30_000 },
+ );
+ }),
+ expectComposerFocused: () =>
+ test.step("the composer input has focus", () =>
+ expect(prompt(page)).toBeFocused({ timeout: 30_000 })),
+ typeAtCursor: (text) => test.step(`type "${text}"`, () => page.keyboard.type(text)),
+ expectComposerText: (text) =>
+ test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
+ });
+ } finally {
+ await client.removePlugin("composer-focus");
+ await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
+ await client.close();
+ await rm(directory, { recursive: true, force: true });
+ await elsewhere.cleanup();
+ await workspace.cleanup();
+ }
+}
+
export { CHECKLIST };
+
+type HeldComposerGlobal = typeof globalThis & {
+ heldComposer?: {
+ focus(options?: { selection?: { start: number; end: number } }): Promise;
+ };
+};
+
+export async function withPluginHoldingNewWorkspaceComposer(
+ page: Page,
+ run: (author: {
+ openNewWorkspace(): Promise;
+ leaveCompactExplorerForNewWorkspace(): Promise;
+ openSettings(): Promise;
+ openThinkingPicker(): Promise;
+ previewAttachedImage(): Promise;
+ typeInHeldComposer(text: string): Promise;
+ expectOverlappingFocusesKeepTheLatestSelection(): Promise;
+ expectHeldComposerFocusResult(result: string): Promise;
+ expectHeldComposerReadyToType(): Promise;
+ }) => Promise,
+) {
+ const workspace = await seedWorkspace({ repoPrefix: "held-composer-", title: "Held composer" });
+ const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
+ const previous = await client.getDaemonConfig();
+ const directory = await mkdtemp(path.join(tmpdir(), "paseo-held-composer-"));
+ await writeFile(
+ path.join(directory, "paseo-plugin.json"),
+ JSON.stringify({ id: "held-composer", requirements: pluginRequirements }),
+ );
+ await writeFile(
+ path.join(directory, "index.client.tsx"),
+ `import { useComposer } from "@getpaseo/plugin/client";
+
+function Holder() {
+ globalThis.heldComposer = useComposer();
+ return null;
+}
+
+export default function contribute(client) {
+ client.addComposerItem({ id: "holder", Component: Holder });
+ return () => {};
+}`,
+ );
+ try {
+ await client.patchDaemonConfig({ pluginsEnabled: true });
+ await client.installDirectoryPlugin(directory);
+ await page.setViewportSize(WIDE);
+ await gotoAppShell(page);
+ await run({
+ openNewWorkspace: () =>
+ test.step("open the New workspace composer", async () => {
+ await openGlobalNewWorkspaceComposer(page);
+ await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
+ }),
+ leaveCompactExplorerForNewWorkspace: () =>
+ test.step("leave a compact Explorer for New workspace with the shortcut", async () => {
+ await gotoWorkspace(page, workspace.workspaceId);
+ await page.setViewportSize(COMPACT);
+ await expect(await ensureExplorerSidebar(page)).toBeVisible();
+ await page.evaluate(() => {
+ (globalThis as HeldComposerGlobal).heldComposer = undefined;
+ });
+ await page.keyboard.press("ControlOrMeta+n");
+ await expect(page).toHaveURL(/\/new(?:\?.*)?$/);
+ // A compact composer mounts plugin items inside its controls sheet.
+ await page.getByRole("button", { name: /^Select model/ }).click();
+ await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
+ await tapSheetBackdrop(page);
+ await expectNoSheetOpen(page);
+ }),
+ openSettings: () =>
+ test.step("push Settings over the New workspace screen", async () => {
+ await page.getByTestId("sidebar-settings").filter({ visible: true }).click();
+ await expect(page.getByTestId("settings-sidebar")).toBeVisible();
+ }),
+ openThinkingPicker: () =>
+ test.step("open the thinking picker in the New workspace composer", async () => {
+ await page
+ .getByRole("button", { name: /^Select thinking option/ })
+ .filter({ visible: true })
+ .click();
+ await expect(page.getByText("High", { exact: true })).toBeVisible();
+ }),
+ previewAttachedImage: () =>
+ test.step("attach an image to the New workspace composer and preview it", async () => {
+ await attachComposerTestImage(page);
+ await openImageLightbox(page);
+ }),
+ typeInHeldComposer: (text) =>
+ test.step(`type ${text} in the New workspace composer`, async () => {
+ await prompt(page).filter({ visible: true }).fill(text);
+ }),
+ expectOverlappingFocusesKeepTheLatestSelection: () =>
+ test.step("two overlapping focus() calls leave the later selection", async () => {
+ const selection = await page.evaluate(async () => {
+ const composer = (globalThis as HeldComposerGlobal).heldComposer!;
+ await Promise.all([
+ composer.focus({ selection: { start: 0, end: 1 } }),
+ composer.focus({ selection: { start: 2, end: 4 } }),
+ ]);
+ const input = document.activeElement as HTMLTextAreaElement;
+ return [input.selectionStart, input.selectionEnd];
+ });
+ expect(selection).toEqual([2, 4]);
+ }),
+ expectHeldComposerReadyToType: () =>
+ test.step("nothing covers the composer and the cursor is in it", async () => {
+ await expect(page.getByText("High", { exact: true })).toHaveCount(0);
+ await expect(page.getByTestId("attachment-lightbox-close")).toHaveCount(0);
+ await expect(prompt(page).filter({ visible: true })).toBeFocused();
+ }),
+ expectHeldComposerFocusResult: (result) =>
+ test.step(`the held composer's focus() reports ${result}`, async () => {
+ const actual = await page.evaluate(() =>
+ (globalThis as HeldComposerGlobal).heldComposer?.focus().then(
+ () => "focused",
+ (error: Error) => error.message,
+ ),
+ );
+ expect(actual).toBe(result);
+ }),
+ });
+ } finally {
+ await client.removePlugin("held-composer");
+ await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
+ await client.close();
+ await rm(directory, { recursive: true, force: true });
+ await workspace.cleanup();
+ }
+}
+
+type FocusAgentGlobal = typeof globalThis & {
+ focusAgent?: (agentId: string) => Promise;
+};
+
+export async function withPluginFocusingASubagent(
+ page: Page,
+ run: (author: {
+ openParentAgent(): Promise;
+ openExplorer(): Promise;
+ focusSubagent(): Promise;
+ expectSubagentTabReadyToType(): Promise;
+ keepParentInExplorerOnCompactScreen(): Promise;
+ focusParent(): Promise;
+ expectParentReadyToType(): Promise;
+ coverExplorerComposerWithFileSheet(): Promise;
+ focusParentAsAFileSheetOpens(): Promise;
+ openSubagentPageInParentComposer(): Promise;
+ }) => Promise,
+) {
+ const workspace = await seedMockAgentWorkspace({
+ repoPrefix: "focus-subagent-",
+ title: "Focus parent",
+ initialPrompt: promptMentioning(FILE_LINK),
+ });
+ await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
+ const child = await seedSubagent(
+ { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
+ { parentId: workspace.agentId, title: "Focus child" },
+ );
+ const plugin = await installFocusingPlugin(page);
+ const { focusAgent } = plugin;
+ try {
+ await page.setViewportSize(COMPACT);
+ await gotoAppShell(page);
+ await run({
+ keepParentInExplorerOnCompactScreen: () =>
+ test.step("move the parent into the Explorer, then narrow the window", async () => {
+ await page.setViewportSize(WIDE);
+ await openAgentRoute(page, {
+ workspaceId: workspace.workspaceId,
+ agentId: workspace.agentId,
+ });
+ await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
+ // The seeded turn must finish first, so the drag isn't racing its updates to the tab.
+ await expect(page.getByText(FILE_LINK, { exact: true })).toBeVisible({ timeout: 15_000 });
+ const explorer = await ensureExplorerSidebar(page);
+ await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
+ await expect(
+ explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`),
+ ).toBeVisible();
+ await page.setViewportSize(COMPACT);
+ // The phone layout hides the Explorer; focus must find it there, not in the wide layout.
+ await expect(explorer).toBeHidden();
+ }),
+ focusParent: () =>
+ test.step("focus the agent kept in the Explorer", () => focusAgent(workspace.agentId)),
+ expectParentReadyToType: () =>
+ test.step("no sheet is open and the cursor is in the composer", async () => {
+ await expectNoSheetOpen(page);
+ await expect(prompt(page).filter({ visible: true })).toBeFocused();
+ }),
+ openSubagentPageInParentComposer: () =>
+ test.step("open the subagent's page from the parent's Subagents track", async () => {
+ await openSubagentFromCompactTrack(page, child.id);
+ await expect(sheet(page, child.title)).toBeVisible();
+ }),
+ coverExplorerComposerWithFileSheet: () =>
+ test.step("open a file's sheet over the Explorer's composer", async () => {
+ await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
+ await expect(sheet(page, "linked.ts")).toBeVisible();
+ }),
+ focusParentAsAFileSheetOpens: () =>
+ test.step("focus the agent while a file's sheet is opening over it", async () => {
+ // The click looks the file up first. Focus in the commit that mounts the sheet, before
+ // its opening animation has started.
+ await page.evaluate((id) => {
+ const global = globalThis as FocusAgentGlobal & {
+ focusedAsSheetOpens?: Promise;
+ };
+ global.focusedAsSheetOpens = new Promise((resolve) => {
+ const observer = new MutationObserver(() => {
+ if (!document.querySelector('[aria-label="Bottom sheet backdrop"]')) return;
+ observer.disconnect();
+ void global.focusAgent?.(id).then(
+ () => resolve("focused"),
+ (error: Error) => resolve(error.message),
+ );
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ });
+ }, workspace.agentId);
+ await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
+ const outcome = await page.evaluate(
+ () => (globalThis as { focusedAsSheetOpens?: Promise }).focusedAsSheetOpens,
+ );
+ expect(outcome).toBe("focused");
+ }),
+ 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);
+ }),
+ openExplorer: () =>
+ test.step("open the Explorer over the parent's composer", async () => {
+ await expect(await ensureExplorerSidebar(page)).toBeVisible();
+ }),
+ focusSubagent: () =>
+ test.step("focus the subagent, which has no tab", () => focusAgent(child.id)),
+ 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 plugin.remove();
+ await workspace.cleanup();
+ }
+}
+
+/** A plugin that focuses any agent's composer, as a callback holding the agent's id would. */
+async function installFocusingPlugin(page: Page) {
+ const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
+ const previous = await client.getDaemonConfig();
+ const directory = await mkdtemp(path.join(tmpdir(), "paseo-focus-agent-"));
+ await writeFile(
+ path.join(directory, "paseo-plugin.json"),
+ JSON.stringify({ id: "focus-agent", requirements: pluginRequirements }),
+ );
+ await writeFile(
+ path.join(directory, "index.client.tsx"),
+ `export default function contribute(client) {
+ globalThis.focusAgent = (agentId) => client.composers({ agentId })[0].focus();
+ return () => {};
+}`,
+ );
+ await client.patchDaemonConfig({ pluginsEnabled: true });
+ await client.installDirectoryPlugin(directory);
+ return {
+ focusAgent: async (agentId: string) => {
+ const result = await page.evaluate(
+ (id) =>
+ (globalThis as FocusAgentGlobal).focusAgent?.(id).then(
+ () => "focused",
+ (error: Error) => error.message,
+ ),
+ agentId,
+ );
+ expect(result).toBe("focused");
+ },
+ remove: async () => {
+ await client.removePlugin("focus-agent");
+ await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
+ await client.close();
+ await rm(directory, { recursive: true, force: true });
+ },
+ };
+}
+
+export async function withPluginFocusingAnAgentWithContextUsage(
+ page: Page,
+ run: (author: {
+ openContextUsageOnPhone(): Promise;
+ focusAgent(): Promise;
+ expectReadyToType(): Promise;
+ }) => Promise,
+) {
+ const agent = await seedAgentWithContextWindow();
+ const plugin = await installFocusingPlugin(page);
+ try {
+ await page.setViewportSize(COMPACT);
+ await gotoAppShell(page);
+ await run({
+ openContextUsageOnPhone: () =>
+ test.step("open the agent's context usage on a phone", async () => {
+ await openAgentRoute(page, agent);
+ await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
+ await pressContextWindowMeter(page);
+ }),
+ focusAgent: () => test.step("focus the agent", () => plugin.focusAgent(agent.agentId)),
+ expectReadyToType: () =>
+ test.step("the context usage is closed and the cursor is in the composer", async () => {
+ await expect(contextWindowDetails(page)).toHaveCount(0);
+ await expect(prompt(page).filter({ visible: true })).toBeFocused();
+ }),
+ });
+ } finally {
+ await plugin.remove();
+ await agent.cleanup();
+ }
+}
diff --git a/packages/app/e2e/support/helpers/sheet.ts b/packages/app/e2e/support/helpers/sheet.ts
index 94c33747d0..36dcb61a85 100644
--- a/packages/app/e2e/support/helpers/sheet.ts
+++ b/packages/app/e2e/support/helpers/sheet.ts
@@ -112,3 +112,15 @@ export async function expectDeclinedDiscardPrompt(
await close();
expect(await prompt).toContain("discard the draft");
}
+
+/** A seeded prompt the mock agent echoes, so the chat shows `target` as a tappable file link. */
+export function promptMentioning(target: string): string {
+ return [
+ "Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
+ "Return JSON only with fields 'title' and 'branch'.",
+ "",
+ "",
+ `Open \`${target}\` now`,
+ "",
+ ].join("\n");
+}
diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts
index 8ddd507563..c124e12df2 100644
--- a/packages/app/e2e/support/helpers/workspace-tabs.ts
+++ b/packages/app/e2e/support/helpers/workspace-tabs.ts
@@ -61,6 +61,23 @@ function explorerSidebar(page: Page) {
return visibleTestId(page, "workspace-explorer-sidebar").first();
}
+export async function dragTabIntoExplorer(page: Page, tabId: string): Promise {
+ const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
+ const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
+ const sourceBox = await source.boundingBox();
+ const destinationBox = await destination.boundingBox();
+ if (!sourceBox || !destinationBox)
+ throw new Error("Tab drag requires visible source and destination");
+ const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
+ await page.mouse.move(start.x, start.y);
+ await page.mouse.down();
+ await page.mouse.move(start.x + 12, start.y + 4);
+ await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
+ steps: 20,
+ });
+ await page.mouse.up();
+}
+
async function selectWorkspaceTab(tab: Locator): Promise {
if ((await tab.getAttribute("aria-selected")) !== "true") {
// The close action overlays the chip's trailing edge on hover. Click the
diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx
index 7e28f3d81a..3ff2020955 100644
--- a/packages/app/src/components/adaptive-modal-sheet.tsx
+++ b/packages/app/src/components/adaptive-modal-sheet.tsx
@@ -28,6 +28,7 @@ import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
+import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
// Horizontal indent token shared by the sheet header (title, back arrow,
@@ -654,6 +655,9 @@ export function AdaptiveModalSheet({
const isMobile = useIsCompactFormFactor();
const isKeyboardVisible = useKeyboardVisibility(visible);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
+ const isSheet = isMobile || !isWeb;
+ // The sheet registers itself; the desktop dialog does not use it.
+ useDismissibleOverlay(visible && !isSheet, onClose);
const footerView = useMemo(
() => (footer ? {footer} : null),
[footer, footerContainerStyle],
@@ -668,7 +672,7 @@ export function AdaptiveModalSheet({
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
- isEnabled: isMobile || !isWeb,
+ isEnabled: isSheet,
onClose,
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx
index 4b5c08f425..cae61ea100 100644
--- a/packages/app/src/components/ui/combobox.tsx
+++ b/packages/app/src/components/ui/combobox.tsx
@@ -63,6 +63,7 @@ import {
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
+import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import { density, densityIconBox } from "@/styles/density";
import { DensityIcon } from "@/components/ui/density-icon";
@@ -1369,6 +1370,7 @@ export function Combobox({
useEffect(() => {
if (!active && isOpen) handleClose();
}, [active, isOpen, handleClose]);
+ useDismissibleOverlay(isOpen, handleClose);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx
index 639804bd53..2498ed2a7b 100644
--- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx
+++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx
@@ -5,6 +5,7 @@ import {
} from "@gorhom/bottom-sheet";
import React from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
+import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import type { ElementRef, ReactNode } from "react";
import { systemBackPress } from "./back-press";
import {
@@ -163,6 +164,7 @@ export function useIsolatedBottomSheetVisibility({
useEffect(() => {
tracker.syncDesired({ visible, isEnabled });
}, [isEnabled, tracker, visible]);
+ useDismissibleOverlay(visible && isEnabled !== false, onClose);
return {
sheetRef: setSheetRef,
diff --git a/packages/app/src/components/ui/menu/menu-context.ts b/packages/app/src/components/ui/menu/menu-context.ts
index 502580c308..69c1dfa191 100644
--- a/packages/app/src/components/ui/menu/menu-context.ts
+++ b/packages/app/src/components/ui/menu/menu-context.ts
@@ -11,6 +11,7 @@ import {
type MenuPath,
} from "./menu-navigation";
import type { Rect } from "../anchor";
+import { useDismissibleOverlay } from "../overlay-dismissal";
/**
* How a menu draws itself once it is open.
@@ -117,6 +118,7 @@ export function useMenuState({
const [isOpen, setIsOpenState] = useControllableOpenState({ open, defaultOpen, onOpenChange });
useDismissKeyboardOnOpen(isOpen);
+ useDismissibleOverlay(isOpen, () => setOpen(false));
// A menu always reopens on its root page, against a freshly measured anchor. Keeping either
// would reopen the surface three levels deep, or at the coordinates of the last right click.
diff --git a/packages/app/src/components/ui/modal.tsx b/packages/app/src/components/ui/modal.tsx
index 884377fd8c..68ede2af37 100644
--- a/packages/app/src/components/ui/modal.tsx
+++ b/packages/app/src/components/ui/modal.tsx
@@ -1,13 +1,23 @@
-import { Modal as NativeModal, type ModalProps } from "react-native";
+import { Modal as NativeModal, type ModalProps as NativeModalProps } from "react-native";
+import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
-const orientations: NonNullable = [
+const orientations: NonNullable = [
"portrait",
"portrait-upside-down",
"landscape-left",
"landscape-right",
];
+type ModalProps = Omit & {
+ /** Back, Escape, and a composer taking focus all close the modal through this. */
+ onRequestClose?: () => void;
+};
+
+function noClose() {}
+
export function Modal(props: ModalProps) {
+ const { visible = true, onRequestClose } = props;
+ useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
return ;
}
diff --git a/packages/app/src/components/ui/overlay-dismissal.tsx b/packages/app/src/components/ui/overlay-dismissal.tsx
new file mode 100644
index 0000000000..44eb239e33
--- /dev/null
+++ b/packages/app/src/components/ui/overlay-dismissal.tsx
@@ -0,0 +1,90 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type PropsWithChildren,
+ type ReactElement,
+} from "react";
+
+interface OverlayDismissal {
+ register(close: () => void): () => void;
+ dismissAll(): Promise;
+}
+
+interface OpenOverlay {
+ close: () => void;
+ /** Set once the overlay is asked to close; every caller waits on the same one. */
+ closing: { gone: Promise; settle: () => void } | null;
+}
+
+function createOverlayDismissal(): OverlayDismissal {
+ const open = new Set();
+
+ function dismiss(overlay: OpenOverlay): Promise {
+ if (!overlay.closing) {
+ let settle = () => {};
+ const gone = new Promise((resolve) => {
+ settle = resolve;
+ });
+ overlay.closing = { gone, settle };
+ overlay.close();
+ }
+ return overlay.closing.gone;
+ }
+
+ return {
+ register(close) {
+ const overlay: OpenOverlay = { close, closing: null };
+ open.add(overlay);
+ return () => {
+ open.delete(overlay);
+ overlay.closing?.settle();
+ };
+ },
+ dismissAll() {
+ // An overlay is gone in the commit that unregisters it. That commit can also move focus, such
+ // as a web modal handing it back to the button that opened it.
+ return Promise.all([...open].map(dismiss)).then(() => undefined);
+ },
+ };
+}
+
+const OverlayDismissalContext = createContext(null);
+
+/**
+ * A region that can close the sheets, menus and popovers opened from inside it. The composer is
+ * one: putting the cursor in it closes whatever it opened over itself. A scope inside another joins
+ * it, so a host widens the region by opening a scope around the composer and its neighbours.
+ */
+export function OverlayDismissalScope({ children }: PropsWithChildren): ReactElement {
+ const enclosing = useContext(OverlayDismissalContext);
+ const [own] = useState(createOverlayDismissal);
+ return (
+
+ {children}
+
+ );
+}
+
+/** While `open`, the enclosing scope, if any, can close this overlay through `close`. */
+export function useDismissibleOverlay(open: boolean, close: () => void): void {
+ const scope = useContext(OverlayDismissalContext);
+ const closeRef = useRef(close);
+ closeRef.current = close;
+ useEffect(() => {
+ if (!scope || !open) return;
+ return scope.register(() => closeRef.current());
+ }, [scope, open]);
+}
+
+/**
+ * Closes every overlay open in the enclosing scope, and resolves once they are gone. Outside a
+ * scope there is nothing to close. A registered `close` must close the overlay.
+ */
+export function useDismissOverlays(): () => Promise {
+ const scope = useContext(OverlayDismissalContext);
+ return useCallback(() => scope?.dismissAll() ?? Promise.resolve(), [scope]);
+}
diff --git a/packages/app/src/composer/agent-composer/index.test.ts b/packages/app/src/composer/agent-composer/index.test.ts
index 3eb859d726..b16b583455 100644
--- a/packages/app/src/composer/agent-composer/index.test.ts
+++ b/packages/app/src/composer/agent-composer/index.test.ts
@@ -1,5 +1,5 @@
import { QueryClient } from "@tanstack/react-query";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
@@ -142,8 +142,25 @@ function runtimeModel(model: string): Agent["runtimeInfo"] {
return { provider: "claude", sessionId: null, model };
}
-function composer() {
- return createAgentComposer(serverId, agentId, { queryClient });
+function composer(deps: { reveal?: () => void } = {}) {
+ return createAgentComposer(serverId, agentId, { queryClient, reveal: () => {}, ...deps });
+}
+
+/** A mounted input that records the focus it takes. */
+function mountedInput(on: { revealed: boolean }) {
+ const focused: unknown[] = [];
+ return {
+ focused,
+ input: {
+ isLive: () => on.revealed,
+ setText() {},
+ insertText() {},
+ isRevealed: () => on.revealed,
+ focus: async (options?: unknown) => {
+ focused.push(options);
+ },
+ },
+ };
}
beforeEach(() => {
@@ -157,6 +174,143 @@ afterEach(() => {
useSessionStore.getState().clearSession(serverId);
});
+describe("agent composer focus", () => {
+ it("only focuses an input that is already on screen", async () => {
+ openSession(agent());
+ let reveals = 0;
+ const model = composer({ reveal: () => (reveals += 1) });
+ const { input, focused } = mountedInput({ revealed: true });
+ model.attachInput(input);
+
+ await model.focus({ selection: { start: 1, end: 3 } });
+
+ expect(focused).toEqual([{ selection: { start: 1, end: 3 } }]);
+ expect(reveals).toBe(0);
+ });
+
+ it("reveals an agent with no mounted composer and focuses the input that arrives", async () => {
+ openSession(agent());
+ let reveals = 0;
+ const model = composer({ reveal: () => (reveals += 1) });
+
+ const focusing = model.focus();
+ expect(reveals).toBe(1);
+
+ const { input, focused } = mountedInput({ revealed: true });
+ model.attachInput(input);
+ await focusing;
+ 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;
+ const model = composer({ reveal: () => (reveals += 1) });
+ const on = { revealed: false };
+ const { input, focused } = mountedInput(on);
+ const detach = model.attachInput(input);
+
+ const focusing = model.focus();
+ expect(reveals).toBe(1);
+ expect(focused).toEqual([]);
+
+ // The mounted composer re-attaches when it comes on screen.
+ on.revealed = true;
+ detach();
+ model.attachInput(input);
+ await focusing;
+ expect(focused).toEqual([undefined]);
+ });
+
+ it("applies only the latest of two focus requests", async () => {
+ openSession(agent());
+ const model = composer();
+ const first = model.focus({ selection: { start: 0, end: 0 } });
+ const second = model.focus({ selection: { start: 2, end: 2 } });
+ const { input, focused } = mountedInput({ revealed: true });
+ model.attachInput(input);
+ await Promise.all([first, second]);
+ expect(focused).toEqual([{ selection: { start: 2, end: 2 } }]);
+ });
+
+ it("lets a call made while an arriving input is being focused decide the selection", async () => {
+ openSession(agent());
+ const model = composer();
+ const first = model.focus({ selection: { start: 0, end: 1 } });
+ const { input, focused } = mountedInput({ revealed: true });
+ model.attachInput(input);
+ const second = model.focus({ selection: { start: 2, end: 4 } });
+ await Promise.all([first, second]);
+ expect(focused.at(-1)).toEqual({ selection: { start: 2, end: 4 } });
+ });
+
+ it("settles a replaced focus request with the request that replaced it", async () => {
+ vi.useFakeTimers();
+ try {
+ openSession(agent());
+ const model = composer();
+ const settled: string[] = [];
+ const record = (name: string, request: Promise) =>
+ request.then(
+ () => settled.push(`${name} focused`),
+ () => settled.push(`${name} failed`),
+ );
+ const first = record("first", model.focus());
+ const second = record("second", model.focus());
+ await vi.advanceTimersByTimeAsync(60_000);
+ await Promise.all([first, second]);
+ expect(settled).toEqual(["second failed", "first failed"]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("rejects when the composer never comes on screen, and a later mount does not take focus", async () => {
+ vi.useFakeTimers();
+ try {
+ openSession(agent());
+ const model = composer();
+ const focusing = model.focus();
+ const failed = expect(focusing).rejects.toThrow("did not come on screen");
+ await vi.advanceTimersByTimeAsync(60_000);
+ await failed;
+
+ const { input, focused } = mountedInput({ revealed: true });
+ model.attachInput(input);
+ expect(focused).toEqual([]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("rejects for an archived agent without navigating to it", async () => {
+ openSession(agent({ archivedAt: new Date() }));
+ let reveals = 0;
+ const model = composer({ reveal: () => (reveals += 1) });
+ await expect(model.focus()).rejects.toThrow("archived");
+ expect(reveals).toBe(0);
+ });
+});
+
describe("agent composer", () => {
it("reads without notifying anyone, so a read during a render updates no other component", () => {
const running = agent();
@@ -169,8 +323,8 @@ describe("agent composer", () => {
[other.id, other],
]),
);
- const watched = createAgentComposer(serverId, running.id, { queryClient });
- const reader = createAgentComposer(serverId, other.id, { queryClient });
+ const watched = createAgentComposer(serverId, running.id, { queryClient, reveal: () => {} });
+ const reader = createAgentComposer(serverId, other.id, { queryClient, reveal: () => {} });
let notifications = 0;
const stop = watched.subscribe(() => {
notifications += 1;
@@ -201,6 +355,8 @@ describe("agent composer", () => {
isLive: () => true,
setText: (text) => mounted.push(`set:${text}`),
insertText: (text) => mounted.push(`insert:${text}`),
+ isRevealed: () => false,
+ focus: async () => {},
});
model.insertText("at the cursor");
model.setPrompt("replaced");
@@ -219,6 +375,8 @@ describe("agent composer", () => {
isLive: () => false,
setText: (text) => mounted.push(`set:${text}`),
insertText: (text) => mounted.push(`insert:${text}`),
+ isRevealed: () => false,
+ focus: async () => {},
});
model.insertText("while away");
expect(mounted).toEqual([]);
@@ -396,7 +554,7 @@ describe("agent composer", () => {
it("is shared while its agent is in the session, across closing and reopening its tab", () => {
openSession(agent());
const shared = agentComposer(serverId, agentId);
- const detach = shared.attachInput({ isLive: () => true, setText() {}, insertText() {} });
+ const detach = shared.attachInput(mountedInput({ revealed: true }).input);
detach();
expect(agentComposer(serverId, agentId)).toBe(shared);
const stop = shared.subscribe(() => {});
@@ -407,7 +565,7 @@ describe("agent composer", () => {
it("is released when the last holder lets go of a composer whose agent was removed meanwhile", () => {
openSession(agent());
const mounted = agentComposer(serverId, agentId);
- const detach = mounted.attachInput({ isLive: () => true, setText() {}, insertText() {} });
+ const detach = mounted.attachInput(mountedInput({ revealed: true }).input);
useSessionStore.getState().setAgents(serverId, new Map());
// Held, so the session change did not release it, and nothing changes in the session after.
expect(agentComposer(serverId, agentId)).toBe(mounted);
diff --git a/packages/app/src/composer/agent-composer/index.ts b/packages/app/src/composer/agent-composer/index.ts
index e7aaf6e833..086aa90253 100644
--- a/packages/app/src/composer/agent-composer/index.ts
+++ b/packages/app/src/composer/agent-composer/index.ts
@@ -8,13 +8,14 @@ import {
resolveAgentModelSelection,
resolveProviderSnapshotEntry,
} from "@/composer/agent-controls/utils";
-import type { ComposerModel, ComposerState } from "@/composer/model";
+import type { ComposerFocusOptions, ComposerModel, ComposerState } from "@/composer/model";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { buildDraftStoreKey } from "@/stores/draft-keys";
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";
/** The mounted composer's input. While attached, prompt commands go through it. */
export interface MountedComposerInput {
@@ -23,6 +24,9 @@ export interface MountedComposerInput {
setText(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
+ /** On screen and able to take the keyboard: `focus` needs nothing revealed first. */
+ isRevealed(): boolean;
+ focus(options?: ComposerFocusOptions): Promise;
}
/**
@@ -41,6 +45,20 @@ interface AgentComposerDeps {
cache?: ProviderSnapshotCache;
/** The last watcher or mounted composer let go, so the composer may be releasable. */
onUnheld?: () => void;
+ /** Brings the agent's composer on screen, wherever the user is. */
+ reveal: () => void;
+}
+
+// Long enough for a cold workspace to load its chat over a relay.
+const REVEAL_TIMEOUT_MS = 15_000;
+
+/** A focus waiting for the revealed input to come on screen. */
+interface PendingFocus {
+ /** Which `focus()` call this is; a later call decides where the cursor goes. */
+ call: number;
+ options: ComposerFocusOptions | undefined;
+ settle: { resolve: () => void; reject: (error: Error) => void };
+ timeout: ReturnType;
}
type Catalog = { status: "loading" } | { status: "ready"; entries: ProviderSnapshotEntry[] | null };
@@ -66,6 +84,9 @@ export function createAgentComposer(
// One pair of store subscriptions serves every listener, from the first to the last.
let stopWatchingStores: (() => void) | null = null;
let mounted: MountedComposerInput | null = null;
+ let pendingFocus: PendingFocus | null = null;
+ let focusCalls = 0;
+ let latestFocus: Promise = Promise.resolve();
let last: {
agent: Agent | null;
draft: DraftRecord | undefined;
@@ -209,6 +230,25 @@ export function createAgentComposer(
return last.state;
}
+ function takePendingFocus(): PendingFocus | null {
+ const pending = pendingFocus;
+ pendingFocus = null;
+ if (pending) clearTimeout(pending.timeout);
+ return pending;
+ }
+
+ function focusIfRevealed(input: MountedComposerInput) {
+ if (!pendingFocus || !input.isRevealed()) return;
+ const { call, options, settle } = takePendingFocus()!;
+ // 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(() => {
+ // A call made since then, such as from a mounting item, decides; this one ends with it.
+ const outcome = call === focusCalls ? input.focus(options) : latestFocus;
+ outcome.then(settle.resolve, settle.reject);
+ });
+ }
+
function unheld() {
if (listeners.size === 0 && mounted === null) deps.onUnheld?.();
}
@@ -237,6 +277,7 @@ export function createAgentComposer(
},
attachInput(input) {
mounted = input;
+ focusIfRevealed(input);
return () => {
if (mounted === input) mounted = null;
unheld();
@@ -253,6 +294,34 @@ export function createAgentComposer(
useDraftStore.getState().replaceDraftText({ draftKey, text: current + text });
}
},
+ focus(options) {
+ const agent = readAgent();
+ if (!agent || agent.archivedAt != null) {
+ return Promise.reject(new Error("The agent is gone or archived."));
+ }
+ const call = ++focusCalls;
+ if (mounted?.isRevealed()) {
+ latestFocus = mounted.focus(options);
+ return latestFocus;
+ }
+ const replaced = takePendingFocus();
+ const request = new Promise((resolve, reject) => {
+ pendingFocus = {
+ call,
+ options,
+ settle: { resolve, reject },
+ timeout: setTimeout(() => {
+ takePendingFocus();
+ reject(new Error("The composer did not come on screen."));
+ }, REVEAL_TIMEOUT_MS),
+ };
+ deps.reveal();
+ });
+ // The latest request decides where the cursor goes, and the one it replaced ends with it.
+ if (replaced) request.then(replaced.settle.resolve, replaced.settle.reject);
+ latestFocus = request;
+ return request;
+ },
changeAttachments(updater) {
useDraftStore.getState().updateDraftInput({
draftKey,
@@ -304,6 +373,7 @@ export function agentComposer(serverId: string, agentId: string): AgentComposer
composer = createAgentComposer(serverId, agentId, {
queryClient: singletonQueryClient,
onUnheld: releaseReleasableComposers,
+ reveal: () => navigateToAgent({ serverId, agentId, asTab: true }),
});
composers.set(key, composer);
}
diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx
index c7f46cbbf8..6a1549aa62 100644
--- a/packages/app/src/composer/agent-controls/index.tsx
+++ b/packages/app/src/composer/agent-controls/index.tsx
@@ -1181,6 +1181,8 @@ export const AgentControls = memo(function AgentControls({
isLive: composerInput.isLive,
setText: composerInput.setText,
insertText: composerInput.insertText,
+ isRevealed: composerInput.isRevealed,
+ focus: composerInput.focus,
})
: undefined,
[composer, composerInput],
diff --git a/packages/app/src/composer/halves-model.ts b/packages/app/src/composer/halves-model.ts
index 3ff5eebca9..729e78e501 100644
--- a/packages/app/src/composer/halves-model.ts
+++ b/packages/app/src/composer/halves-model.ts
@@ -49,6 +49,12 @@ export function composerModelFromHalves(input: {
assertUnlocked(composerInput.isLocked);
composerInput.insertText(text);
},
+ // Only an item inside this composer can hold it, so the user was here when they asked. Paseo
+ // has no way to name a draft from outside it and does not pull the user back to one.
+ async focus(options) {
+ if (!composerInput.isRevealed()) throw new Error("The composer is not on screen.");
+ await composerInput.focus(options);
+ },
changeAttachments(updater) {
assertUnlocked(composerInput.isLocked);
composerInput.changeAttachments(updater);
diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx
index d824c0dd63..4b31c4e0c6 100644
--- a/packages/app/src/composer/index.tsx
+++ b/packages/app/src/composer/index.tsx
@@ -42,7 +42,7 @@ import {
type ToolbarGlyph,
toolbarSlot,
} from "@/components/ui/control-geometry";
-import { usePanelStore } from "@/stores/panel-store";
+import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
import { useShallow } from "zustand/shallow";
import {
@@ -137,6 +137,9 @@ import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
+import { usePaneHost } from "@/panels/pane-context";
+import { OverlayDismissalScope, useDismissOverlays } from "@/components/ui/overlay-dismissal";
+import type { PaneHost } from "@/panels/panel-manifest";
import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
@@ -152,6 +155,8 @@ import type {
import type { SelectedFile } from "@/attachments/selected-file";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { ComposerInputProvider, type ComposerInput } from "@/composer/session";
+import type { ComposerFocusOptions } from "@/composer/model";
+import { useRetainedPanelActive } from "@/components/retained-panel";
import { insertAtSelection } from "@/composer/insert-text";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
@@ -1212,6 +1217,24 @@ function ComposerVoiceModeButton({
);
}
+// A plugin modal can refuse to close; the request then tries the cursor anyway and settles.
+const OVERLAY_CLOSE_TIMEOUT_MS = 1_000;
+
+function closedWithin(closed: Promise, timeoutMs: number): Promise {
+ let timeout: ReturnType | undefined;
+ const expired = new Promise((resolve) => {
+ timeout = setTimeout(resolve, timeoutMs);
+ });
+ return Promise.race([closed, expired]).finally(() => clearTimeout(timeout));
+}
+
+/** Only the agent list can cover a screen outside a workspace: the Explorer lives in the workspace. */
+function isMobilePanelCoveringHost(target: MobilePanelView, host: PaneHost | null): boolean {
+ if (target === "agent-list") return true;
+ if (host === null) return false;
+ return target !== (host === "explorer" ? "file-explorer" : "agent");
+}
+
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// A compact overlay panel (agent list, explorer) covers the pane without taking its
// focus. The composer gives up input from the moment the user asks for the panel,
@@ -1219,16 +1242,21 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
// user's own pane: closing the panel does not bring focus back.
const isCompact = useIsCompactFormFactor();
- const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
+ const paneHost = usePaneHost();
+ const overlayPanelRequested = usePanelStore((state) =>
+ isMobilePanelCoveringHost(state.mobilePanel.target, paneHost),
+ );
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
return (
-
-
-
+
+
+
+
+
);
}
@@ -1431,6 +1459,52 @@ function ComposerContentImpl({
[onChangeText, textSource],
);
const isInputLive = useIsInputLive(messageInputRef);
+ const isPanelActive = useRetainedPanelActive();
+ const { isActiveComposer } = useComposerKeyboardScope();
+ const inputRevealed = isPanelActive && isActiveComposer;
+ const isActiveComposerRef = useRef(isActiveComposer);
+ isActiveComposerRef.current = isActiveComposer;
+ const isInputRevealed = useCallback(
+ () => isInputLive() && isActiveComposerRef.current,
+ [isInputLive],
+ );
+ const dismissOverlays = useDismissOverlays();
+ const latestFocusRef = useRef | null>(null);
+ const takeInputFocus = useCallback(
+ (options?: ComposerFocusOptions) => {
+ const isFocused = () => messageInputRef.current?.isFocused() ?? false;
+ const place = () => {
+ messageInputRef.current?.focus();
+ if (options?.selection) messageInputRef.current?.setSelection(options.selection);
+ };
+ // The sheets, menus and modals this composer opened would otherwise cover the cursor, or take
+ // focus back as they close.
+ const request: Promise = closedWithin(dismissOverlays(), OVERLAY_CLOSE_TIMEOUT_MS).then(
+ () => {
+ // A newer request decides where the cursor goes, and this one ends with it.
+ const latest = latestFocusRef.current;
+ if (latest && latest !== request) return latest;
+ if (isNative) {
+ place();
+ // A native input refuses focus without saying so, such as while dictation holds it.
+ if (!isFocused()) throw new Error("The composer input could not take focus.");
+ return;
+ }
+ return new Promise((resolve, reject) => {
+ focusWithRetries({
+ focus: place,
+ isFocused,
+ onSuccess: resolve,
+ onTimeout: () => reject(new Error("The composer input could not take focus.")),
+ });
+ });
+ },
+ );
+ latestFocusRef.current = request;
+ return request;
+ },
+ [dismissOverlays],
+ );
const isComposerLockedRef = useRef(isComposerLocked);
isComposerLockedRef.current = isComposerLocked;
const composerInput = useMemo(
@@ -1438,6 +1512,9 @@ function ComposerContentImpl({
locked: isComposerLocked,
isLocked: () => isComposerLockedRef.current,
isLive: isInputLive,
+ revealed: inputRevealed,
+ isRevealed: isInputRevealed,
+ focus: takeInputFocus,
textSource,
setText: replaceUserInput,
insertText: insertUserInput,
@@ -1446,11 +1523,14 @@ function ComposerContentImpl({
}),
[
attachments,
+ inputRevealed,
insertUserInput,
isComposerLocked,
isInputLive,
+ isInputRevealed,
replaceUserInput,
setSelectedAttachments,
+ takeInputFocus,
textSource,
],
);
diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx
index 3464efd16e..a3ae8652ae 100644
--- a/packages/app/src/composer/input/input.tsx
+++ b/packages/app/src/composer/input/input.tsx
@@ -225,9 +225,12 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
+ isFocused: () => boolean;
getText: () => string;
getInputSnapshot: () => ComposerInputSnapshot;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
+ /** Clamped to the text the input holds. */
+ setSelection: (selection: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
@@ -1486,6 +1489,7 @@ export const MessageInput = forwardRef(
focus: () => {
textInputRef.current?.focus();
},
+ isFocused: () => textInputRef.current?.isFocused() ?? false,
blur: () => {
textInputRef.current?.blur();
},
@@ -1493,6 +1497,12 @@ export const MessageInput = forwardRef(
getInputSnapshot: () =>
getComposerInputSnapshot(textInputRef.current, valueRef.current, selectionRef.current),
replaceText,
+ setSelection: (selection) => {
+ const text = textInputRef.current?.getText() ?? valueRef.current;
+ const clamped = resolveRetainedSelection(selection, text.length);
+ selectionRef.current = clamped;
+ textInputRef.current?.setSelection(clamped);
+ },
runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(),
diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts
index 99cd6fc076..888e5caed0 100644
--- a/packages/app/src/composer/model.ts
+++ b/packages/app/src/composer/model.ts
@@ -17,6 +17,12 @@ export interface ComposerState {
metadata: Record;
}
+/** Where the cursor goes when a composer takes focus. */
+export interface ComposerFocusOptions {
+ /** Offsets into the prompt, clamped to its length. Omit to keep the cursor where it is. */
+ selection?: { start: number; end: number };
+}
+
/**
* A composer as plugins use it: its state, a way to hear about changes, and the commands that
* change it. The commands enforce the rules the built-in controls follow (a draft being created
@@ -29,6 +35,8 @@ export interface ComposerModel {
setPrompt(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
+ /** Puts the cursor in the input. Resolves once it is there; rejects when it cannot get there. */
+ focus(options?: ComposerFocusOptions): Promise;
changeAttachments(updater: (current: UserComposerAttachment[]) => UserComposerAttachment[]): void;
setProvider(provider: string): Promise;
setModel(modelId: string): Promise;
diff --git a/packages/app/src/composer/session.tsx b/packages/app/src/composer/session.tsx
index eaa96130c6..cb46f2b3dd 100644
--- a/packages/app/src/composer/session.tsx
+++ b/packages/app/src/composer/session.tsx
@@ -1,6 +1,7 @@
import { createContext, useContext, type ReactNode } from "react";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
+import type { ComposerFocusOptions } from "@/composer/model";
import type { ComposerTextSource } from "@/composer/text-source";
/**
@@ -17,6 +18,14 @@ export interface ComposerInput {
* cannot take the cursor-aware commands below.
*/
isLive: () => boolean;
+ /**
+ * The input is showing and nothing stands between it and the keyboard: its pane is focused and
+ * no compact overlay panel covers it. A new value of `revealed` is a new `ComposerInput`, which
+ * is how a request waiting for the input to come on screen hears about it.
+ */
+ revealed: boolean;
+ isRevealed: () => boolean;
+ focus: (options?: ComposerFocusOptions) => Promise;
textSource: ComposerTextSource;
setText: (text: string) => void;
/** Inserts at the cursor, replacing the selection. */
diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx
index ba13bb8a9f..cf582a99f5 100644
--- a/packages/app/src/panels/agent-panel.tsx
+++ b/packages/app/src/panels/agent-panel.tsx
@@ -81,6 +81,7 @@ import {
import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab";
import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline";
import { AgentTracks } from "@/panels/agent-tracks";
+import { OverlayDismissalScope } from "@/components/ui/overlay-dismissal";
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
@@ -1317,10 +1318,14 @@ function ChatSurface({
disabled: boolean;
fullscreen: boolean;
}) {
+ // The tracks above the composer open their pages from outside it, so focusing the composer
+ // closes everything the chat opened.
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx
index 52b33e5960..956293e6a0 100644
--- a/packages/app/src/panels/pane-context.tsx
+++ b/packages/app/src/panels/pane-context.tsx
@@ -74,6 +74,11 @@ export function usePaneContext(): PaneContextValue {
return value;
}
+/** The host of the pane around this component, or null on a screen with no panes. */
+export function usePaneHost(): PaneHost | null {
+ return useContext(PaneContext)?.host ?? null;
+}
+
export function usePaneFocus(): PaneFocusContextValue {
const value = useContext(PaneFocusContext);
invariant(value, "PaneFocusContext is required");
diff --git a/packages/app/src/plugins/composer-items/model.test.ts b/packages/app/src/plugins/composer-items/model.test.ts
index 59e8e79804..234e06ed90 100644
--- a/packages/app/src/plugins/composer-items/model.test.ts
+++ b/packages/app/src/plugins/composer-items/model.test.ts
@@ -7,6 +7,10 @@ import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import { composerModelFromHalves } from "@/composer/halves-model";
import type { ComposerAgent, ComposerInput } from "@/composer/session";
+import {
+ setCommandCenterFocusRestoreElement,
+ takeCommandCenterFocusRestoreElement,
+} from "@/utils/command-center-focus-restore";
import type { InstalledPlugin } from "../types";
import { toPluginComposer } from "./model";
@@ -94,10 +98,16 @@ function fakeAgent(context: ComposerAgent["context"], locked = false) {
function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
const state = { text: "", attachments: initial };
+ const view = { revealed: true, focused: [] as unknown[] };
const input: ComposerInput = {
locked,
isLocked: () => locked,
isLive: () => true,
+ revealed: true,
+ isRevealed: () => view.revealed,
+ focus: async (options) => {
+ view.focused.push(options);
+ },
textSource: { getSnapshot: () => state.text, subscribe: () => () => {} },
setText: (text) => {
state.text = text;
@@ -112,7 +122,7 @@ function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
state.attachments = updater(state.attachments);
},
};
- return { input, state };
+ return { input, state, view };
}
function compose(agent: ComposerAgent, input: ComposerInput) {
@@ -221,6 +231,47 @@ describe("toPluginComposer", () => {
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
});
+ it("focuses the input of the draft composer on screen, with the selection asked for", async () => {
+ const { agent } = fakeAgent("draft");
+ const { input, view } = fakeInput();
+ const composer = compose(agent, input);
+
+ await composer.focus({ selection: { start: 0, end: 4 } });
+
+ expect(view.focused).toEqual([{ selection: { start: 0, end: 4 } }]);
+ });
+
+ it("rejects focus on a draft composer that is not on screen, leaving the user where they are", async () => {
+ const { agent } = fakeAgent("draft");
+ const { input, view } = fakeInput();
+ view.revealed = false;
+ const composer = compose(agent, input);
+
+ await expect(composer.focus()).rejects.toThrow("not on screen");
+ expect(view.focused).toEqual([]);
+ });
+
+ it("focuses while the composer is locked, since the cursor changes nothing it holds", async () => {
+ const { agent } = fakeAgent("new-workspace");
+ const { input, view } = fakeInput([], true);
+ const composer = compose(agent, input);
+
+ await composer.focus();
+
+ expect(view.focused).toEqual([undefined]);
+ });
+
+ it("keeps the Command Center from taking focus back when it closes", async () => {
+ const { agent } = fakeAgent("draft");
+ const { input } = fakeInput();
+ const composer = compose(agent, input);
+ setCommandCenterFocusRestoreElement({} as HTMLElement);
+
+ await composer.focus();
+
+ expect(takeCommandCenterFocusRestoreElement()).toBeNull();
+ });
+
it("hands out options and metadata a plugin cannot change", () => {
const { agent } = fakeAgent("draft");
agent.metadata = { nested: { list: [1] } };
diff --git a/packages/app/src/plugins/composer-items/model.ts b/packages/app/src/plugins/composer-items/model.ts
index c39c0cabea..15de534ff3 100644
--- a/packages/app/src/plugins/composer-items/model.ts
+++ b/packages/app/src/plugins/composer-items/model.ts
@@ -5,6 +5,7 @@ import type { ComposerModel, ComposerState } from "@/composer/model";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment } from "../attachments/model";
import { cloneAndFreeze, ownCopy } from "../freeze";
+import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
function isPluginAttachment(
attachment: UserComposerAttachment,
@@ -49,6 +50,11 @@ export function toPluginComposer(input: {
},
setPrompt: (text) => model.setPrompt(text),
insertText: (text) => model.insertText(text),
+ focus(options) {
+ // The Command Center, which may have run this command, gives focus back when it closes.
+ clearCommandCenterFocusRestoreElement();
+ return model.focus(options);
+ },
addAttachment({ sourceId, item }) {
const source = plugin.attachmentSources.find((candidate) => candidate.id === sourceId);
if (!source) throw new Error(`Unknown attachment source: ${sourceId}`);
diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx
index 900fdad7d7..bab4df8f01 100644
--- a/packages/app/src/screens/new-workspace-screen.tsx
+++ b/packages/app/src/screens/new-workspace-screen.tsx
@@ -8,6 +8,7 @@ import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
+import { useIsFocused } from "expo-router/react-navigation";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -909,6 +910,7 @@ export function NewWorkspaceScreen({
const hostPickerAnchorRef = useRef(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
+ const isScreenFocused = useIsFocused();
// Launch target: what the composer submits to (chat agent, or a terminal
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
@@ -1544,7 +1546,7 @@ export function NewWorkspaceScreen({
submitLabel={terminalSubmitLabel}
agentId={draftKey}
serverId={selectedServerId}
- isPaneFocused={true}
+ isPaneFocused={isScreenFocused}
onSubmitMessage={handleSubmitTerminalLaunch}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
@@ -1568,7 +1570,7 @@ export function NewWorkspaceScreen({
editingSession={composerEditing.chat}
agentId={draftKey}
serverId={selectedServerId}
- isPaneFocused={true}
+ isPaneFocused={isScreenFocused}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.create")}
diff --git a/packages/app/src/screens/workspace/explorer-sidebar.tsx b/packages/app/src/screens/workspace/explorer-sidebar.tsx
index e39106b65b..c43e87c45b 100644
--- a/packages/app/src/screens/workspace/explorer-sidebar.tsx
+++ b/packages/app/src/screens/workspace/explorer-sidebar.tsx
@@ -38,6 +38,7 @@ interface ExplorerSidebarDockProps extends Pick<
normalizedServerId: string;
normalizedWorkspaceId: string;
isWorkspaceFocused: boolean;
+ paneFocusSuspended: boolean;
closingTabIds: Set;
onSelectTab: (paneId: string, tabId: string) => void;
onCloseTab: (tabId: string) => Promise | void;
@@ -56,6 +57,7 @@ export function ExplorerSidebarDock({
normalizedServerId,
normalizedWorkspaceId,
isWorkspaceFocused,
+ paneFocusSuspended,
closingTabIds,
onSelectTab,
onCloseTab,
@@ -167,7 +169,7 @@ export function ExplorerSidebarDock({
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
- isPaneFocused
+ isPaneFocused={!paneFocusSuspended}
buildPaneContentModel={buildPaneContentModel}
/>
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index cb4d863448..185a1dbac3 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -3726,6 +3726,7 @@ function WorkspaceScreenContent({
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
+ paneFocusSuspended={paneFocusSuspended}
closingTabIds={closingTabIds}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -3767,6 +3768,7 @@ function WorkspaceScreenContent({
normalizedServerId,
normalizedWorkspaceId,
isRouteFocused,
+ paneFocusSuspended,
isExplorerSidebarShowing,
closingTabIds,
hoveredCloseTabKey,
diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts
index c3cf58a3bd..5dbc292524 100644
--- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts
+++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts
@@ -145,6 +145,22 @@ describe("workspace navigation", () => {
]);
});
+ it("carries a request for a tab through a deferred open", () => {
+ const { deps, deferredTabs } = createFakeDeps({ getSessionWorkspaces: () => new Map() });
+
+ navigateToWorkspace(
+ {
+ serverId: "server-1",
+ workspaceId: "workspace-a",
+ target: { kind: "agent", agentId: "agent-1" },
+ asTab: true,
+ },
+ deps,
+ );
+
+ expect(deferredTabs[0]).toMatchObject({ asTab: true });
+ });
+
it("defers an agent tab until a missing workspace is recovered", () => {
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map(),
diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts
index 6d94ed81a3..bbc55701d6 100644
--- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts
+++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts
@@ -25,6 +25,7 @@ export interface NavigateToWorkspaceInput {
target?: WorkspaceTabTarget;
pin?: boolean;
placement?: WorkspaceTabPlacement;
+ asTab?: boolean;
}
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts
index 9542e4b3f6..6bb930334a 100644
--- a/packages/app/src/stores/workspace-layout-store.test.ts
+++ b/packages/app/src/stores/workspace-layout-store.test.ts
@@ -4569,6 +4569,24 @@ describe("compact workspace sheet", () => {
);
}
+ it("opens a subagent as a tab when a deferred request asks for one", () => {
+ const store = createCompactStore();
+ seedAgents([
+ { id: "parent", parentAgentId: null, workspaceId: "sheet-ws" },
+ { id: "child", parentAgentId: "parent", workspaceId: "sheet-ws" },
+ ]);
+
+ store.getState().deferOpenTab({
+ workspaceKey,
+ target: { kind: "agent", agentId: "child" },
+ intent: "reveal",
+ asTab: true,
+ });
+ store.getState().consumePendingOpen(workspaceKey);
+
+ expect(topSheet(store)).toBeUndefined();
+ expect(tabKinds(store)).toContain("agent");
+ });
it("reveals a file in the sheet and leaves the tab strip unchanged", () => {
const store = createCompactStore();
const before = tabKinds(store);
diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts
index f66650c7ca..3e0d14ba6b 100644
--- a/packages/app/src/stores/workspace-layout-store.ts
+++ b/packages/app/src/stores/workspace-layout-store.ts
@@ -105,6 +105,8 @@ export interface OpenWorkspaceTabInput {
/** Keeps an explicitly opened agent visible even when it is archived. */
pin?: boolean;
placement?: WorkspaceTabPlacement;
+ /** Opens a tab where a compact layout would otherwise use a sheet. */
+ asTab?: boolean;
insertionPosition?: WorkspaceTabInsertionPosition;
parentTabId?: string;
state?: JsonValue;
@@ -1108,7 +1110,7 @@ export function createWorkspaceLayoutStore(
input.parentTabId,
);
const sheetPlacement =
- input.placement?.mode === "pane"
+ input.placement?.mode === "pane" || input.asTab
? null
: resolveSheetPlacement(
get(),
diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts
index f768edfce5..c06d20d096 100644
--- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts
+++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts
@@ -40,6 +40,14 @@ function createFakeNavigators(target: AgentNavTarget): {
}
describe("resolveNavigateToAgent", () => {
+ it("carries a request for a tab to the workspace navigation", () => {
+ const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: WORKSPACE_ID });
+
+ resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, asTab: true }, deps);
+
+ expect(tabNavigations[0]).toMatchObject({ asTab: true });
+ });
+
it("opens the workspace tab carried by the agent's workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
agentWorkspaceId: WORKSPACE_ID,
diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts
index 842760d793..4cd47ee14b 100644
--- a/packages/app/src/utils/navigate-to-agent/resolve.ts
+++ b/packages/app/src/utils/navigate-to-agent/resolve.ts
@@ -9,6 +9,8 @@ export interface NavigateToAgentInput {
// (cold deep-links). Otherwise the workspace is read from the store.
workspaceId?: string | null;
pin?: boolean;
+ /** Opens the agent with its composer, where a compact layout would show a sheet without one. */
+ asTab?: boolean;
}
export interface AgentNavTarget {
@@ -41,5 +43,6 @@ export function resolveNavigateToAgent(
workspaceId,
target: { kind: "agent", agentId: input.agentId },
pin: input.pin,
+ asTab: input.asTab,
});
}
diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts
index 5d05bff60c..e2787a9806 100644
--- a/packages/app/src/utils/prepare-workspace-tab.ts
+++ b/packages/app/src/utils/prepare-workspace-tab.ts
@@ -8,6 +8,7 @@ export interface PrepareWorkspaceTabInput {
target: WorkspaceTabTarget;
pin?: boolean;
placement?: WorkspaceTabPlacement;
+ asTab?: boolean;
}
export interface PrepareWorkspaceTabDeps {
@@ -17,6 +18,7 @@ export interface PrepareWorkspaceTabDeps {
intent: "reveal";
pin?: boolean;
placement?: WorkspaceTabPlacement;
+ asTab?: boolean;
}) => string | null;
}
@@ -44,5 +46,6 @@ export function prepareWorkspaceTab(
intent: "reveal",
pin: input.pin === true && target.kind === "agent",
placement: input.placement,
+ asTab: input.asTab,
});
}
diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts
index 1b093019c3..2a1ddae8f7 100644
--- a/packages/plugin/src/boundaries.test.ts
+++ b/packages/plugin/src/boundaries.test.ts
@@ -230,6 +230,7 @@ describe("plugin composer SDK boundary", () => {
composer.setOptions({ effort: null });
composer.setMetadata({ flag: true, stale: null });
composer.insertText("text");
+ composer.focus({ selection: { start: 0, end: composer.prompt.length } }).then(() => composer.focus());
composer.addAttachment({ sourceId: "issues", item: { id: "1", identifier: "1", title: "t", url: "https://e.com", text: "t", resourceType: "issue" } });
// @ts-expect-error commands take JSON values
composer.setMetadata({ fn: () => {} });
diff --git a/packages/plugin/src/client/composer.tsx b/packages/plugin/src/client/composer.tsx
index 0055729fcc..c3c33ddeca 100644
--- a/packages/plugin/src/client/composer.tsx
+++ b/packages/plugin/src/client/composer.tsx
@@ -16,6 +16,12 @@ export interface PluginComposerAttachment {
readonly item: PluginAttachmentItem;
}
+/** Where the cursor goes when a composer takes focus. */
+export interface PluginComposerFocusOptions {
+ /** Offsets into `prompt`, clamped to its length. Equal offsets place the cursor. Omit to keep the cursor where it is. */
+ selection?: { start: number; end: number };
+}
+
/**
* The composer the item sits in: the prompt, the attachments and the agent being configured.
* The commands behave the same in every composer. `context` says where the composer is; it does
@@ -39,6 +45,11 @@ export interface PluginComposer {
setPrompt(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
+ /**
+ * Puts the cursor in this composer, ready to type. Paseo opens the agent's tab or workspace when
+ * it is not on screen. Resolves once the input has focus; rejects when it could not get there.
+ */
+ focus(options?: PluginComposerFocusOptions): Promise;
addAttachment(input: { sourceId: string; item: PluginAttachmentItem }): void;
removeAttachment(input: { sourceId: string; itemId: string }): void;
/** Resets the model like the built-in control. Rejects on a running agent, which keeps its provider. */
diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts
index 712e128afa..0597e56afe 100644
--- a/packages/plugin/src/client/index.ts
+++ b/packages/plugin/src/client/index.ts
@@ -86,6 +86,7 @@ export { useComposer, useComposers } from "./composer.js";
export type {
PluginComposer,
PluginComposerAttachment,
+ PluginComposerFocusOptions,
PluginComposerItemContribution,
PluginComposerItemProps,
PluginComposerSelector,
diff --git a/patches/@gorhom+bottom-sheet+5.2.14.patch b/patches/@gorhom+bottom-sheet+5.2.14.patch
index e0af7b8dcb..13d7df0565 100644
--- a/patches/@gorhom+bottom-sheet+5.2.14.patch
+++ b/patches/@gorhom+bottom-sheet+5.2.14.patch
@@ -1,8 +1,18 @@
diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js
-index ee51e76..dc1bc17 100644
+index ee51e76..5fe8f75 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js
-@@ -315,7 +315,9 @@ function BottomSheetModalComponent(props, ref) {
+@@ -272,6 +272,9 @@ function BottomSheetModalComponent(props, ref) {
+ });
+ }
+ if (statusRef.current === _constants.MODAL_STATUS.INITIAL) {
++ if (mountRef.current) {
++ unmount();
++ }
+ return;
+ }
+
+@@ -315,7 +318,9 @@ function BottomSheetModalComponent(props, ref) {
}
currentIndexRef.current = _index;
nextIndexRef.current = null;
@@ -61,10 +71,20 @@ index e44ad86..96555ef 100644
var _useGestureEventsHandlersDefault = require("./hooks/useGestureEventsHandlersDefault");
var _useBottomSheetGestureHandlers = require("./hooks/useBottomSheetGestureHandlers");
diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js
-index 94fabdb..60ca66a 100644
+index 94fabdb..d870aa7 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js
-@@ -308,7 +308,9 @@ function BottomSheetModalComponent(props, ref) {
+@@ -265,6 +265,9 @@ function BottomSheetModalComponent(props, ref) {
+ });
+ }
+ if (statusRef.current === MODAL_STATUS.INITIAL) {
++ if (mountRef.current) {
++ unmount();
++ }
+ return;
+ }
+
+@@ -308,7 +311,9 @@ function BottomSheetModalComponent(props, ref) {
}
currentIndexRef.current = _index;
nextIndexRef.current = null;
@@ -122,10 +142,24 @@ index ffc6caa..550f1b3 100644
export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault';
export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers';
diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx
-index 2954b3f..7260656 100644
+index 2954b3f..936300b 100644
--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx
+++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx
-@@ -432,8 +432,15 @@ function BottomSheetModalComponent(
+@@ -373,7 +373,13 @@ function BottomSheetModalComponent(
+ });
+ }
+
++ // A modal that unmounts after presenting but before its first animation is still INITIAL;
++ // returning would leave its portal on the host, sliding open with nothing to close it.
++ // An INITIAL status with nothing mounted is this callback running after `unmount()`.
+ if (statusRef.current === MODAL_STATUS.INITIAL) {
++ if (mountRef.current) {
++ unmount();
++ }
+ return;
+ }
+
+@@ -432,8 +438,15 @@ function BottomSheetModalComponent(
currentIndexRef.current = _index;
nextIndexRef.current = null;
diff --git a/plugin-examples/composer-controls/client/controls.tsx b/plugin-examples/composer-controls/client/controls.tsx
index 2ed137d307..51d52991bb 100644
--- a/plugin-examples/composer-controls/client/controls.tsx
+++ b/plugin-examples/composer-controls/client/controls.tsx
@@ -72,6 +72,9 @@ export function PlanFirst() {
/** Works the same in the agent, draft and New workspace composers. */
export function Checklist() {
const composer = useComposer();
- const insert = useCallback(() => composer.insertText("- [ ] Tests\n- [ ] Docs\n"), [composer]);
+ const insert = useCallback(async () => {
+ composer.insertText("- [ ] Tests\n- [ ] Docs\n");
+ await composer.focus();
+ }, [composer]);
return ;
}
diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md
index 6e4513f6b9..c3f60393cd 100644
--- a/public-docs/plugins/reference.md
+++ b/public-docs/plugins/reference.md
@@ -1953,6 +1953,7 @@ export default function contribute(client: PluginClientContext) {
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `context` | `"agent"` for a running agent, `"draft"` for a new agent in a workspace, `"new-workspace"` for the New workspace screen. |
| `prompt`, `setPrompt(text)`, `insertText(text)` | The prompt. `insertText` replaces the selection or inserts at the cursor. |
+| `focus(options?)` | Puts the cursor in this composer, ready to type, and resolves when it is there. `selection: { start, end }` places the cursor or selects text, as offsets into `prompt` clamped to its length; omit it to keep the cursor where it is. See [focus a composer](#focus-a-composer). |
| `attachments`, `addAttachment`, `removeAttachment` | Items from [your own attachment sources](#add-a-composer-attachment-source). Attachments from other plugins and Paseo's own are not listed. |
| `agent.provider`, `model`, `mode`, `thinking` | What the agent runs with, or what the draft will create. |
| `agent.options` | The provider options in effect, defaults included. In a draft: the options the agent will be created with, including your changes. |
@@ -1993,11 +1994,42 @@ The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plug
registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`, plus an
Explorer panel and a Command Center item that reach a composer by reference.
+### Focus a composer
+
+`insertText` and `setPrompt` do not move the cursor. Call `focus()` after them so the user can keep
+typing:
+
+```tsx
+composer.insertText("- [ ] Tests\n");
+await composer.focus();
+```
+
+`await composer.focus({ selection: { start: 0, end: composer.prompt.length } })` selects the prompt.
+
+You do not need to know where the user is. On a running agent's composer, Paseo navigates to the
+agent's tab or workspace when it is not on screen, closes a phone's agent list or Explorer panel
+that covers it, and moves focus to the agent's pane, then focuses the input. When the input is
+already on screen and focused in its pane, `focus()` only puts the cursor there. Every composer also
+closes the sheets, menus, pickers and image previews it opened over itself first, such as the
+controls sheet that holds your items on a phone. A phone opens its keyboard.
+
+`focus()` rejects when the composer cannot take focus: the agent was archived, its composer did not
+come on screen within 15 seconds, or the input refused focus, as it does on a phone while dictation
+is running. When calls overlap, the latest one decides where the cursor goes and the earlier ones
+settle with it. A rejected promise returned from a button's `onPress` or a Command Center item's
+`onSelect` shows the user an error toast.
+
+Drafts and the New workspace composer are only reachable from an item inside them, so `focus()` does
+not navigate to them. It works while the composer is on screen, and rejects with "The composer is
+not on screen." when the user has already moved on. A locked composer accepts focus.
+
### Reach a composer by reference
A running agent has a composer whether or not its tab is open. `useComposers(selector)` in any plugin
-component, and `composers(selector)` on every command context, return the same `PluginComposer` that
-`useComposer()` gives an item, for an agent you name or for the focused one:
+component, `composers(selector)` on every command context, and `client.composers(selector)` on the
+client context return the same `PluginComposer` that `useComposer()` gives an item, for an agent you
+name or for the focused one. Use `client.composers` in a callback that receives no context, such as a
+[pill's `onPress`](#composer-pills):
```tsx
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
@@ -2055,6 +2087,7 @@ you reach from outside:
rejects with the provider's message. `model` and `thinking` read what the model picker
shows at that moment: until the provider's catalog loads that is the id the agent reports or was
created with, which may be an alias, and once it loads the catalog's own id.
+- `focus()` reveals the composer first, as [described above](#focus-a-composer).
- Drafts and the New workspace screen are not addressable by reference. Only an item inside one
reaches its composer, through `useComposer()`.
@@ -2159,6 +2192,31 @@ const pill = client.addComposerPill({
});
```
+A pill's `onPress` receives no arguments. Reach its agent's composer with
+[`client.composers`](#reach-a-composer-by-reference):
+
+```tsx
+const pill = client.addComposerPill({
+ id: "hello",
+ workspaceId,
+ agentId,
+ button: {
+ title: "Add HELLO to the composer",
+ icon: "Hand",
+ label: "Hello",
+ behavior: {
+ kind: "action",
+ async onPress() {
+ for (const composer of client.composers({ agentId })) {
+ composer.insertText("HELLO");
+ await composer.focus();
+ }
+ },
+ },
+ },
+});
+```
+
For pills that follow the agent directory, use an explicit [owned list subscription](/docs/sdk/events#follow-one-agents-status).
The [local plugin example](https://github.com/getpaseo/paseo/blob/main/plugin-examples/local-plugin/client/main.tsx)
replaces registrations on each snapshot and aborts the observation during entry cleanup, including pending bootstrap.
diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md
index 2262399a5b..7331cb086b 100644
--- a/public-docs/plugins/reference.zh-CN.md
+++ b/public-docs/plugins/reference.zh-CN.md
@@ -1650,6 +1650,7 @@ export default function contribute(client: PluginClientContext) {
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `context` | `"agent"` 表示运行中的 Agent,`"draft"` 表示工作区中的新 Agent,`"new-workspace"` 表示新建工作区界面。 |
| `prompt`、`setPrompt(text)`、`insertText(text)` | 提示词。`insertText` 会替换选区,或在光标处插入。 |
+| `focus(options?)` | 把光标放进这个 composer,准备输入,光标就位后完成。`selection: { start, end }` 放置光标或选中文本,为 `prompt` 中的偏移量,并限制在其长度内;省略时光标保持原位。参见[聚焦 composer](#focus-a-composer)。 |
| `attachments`、`addAttachment`、`removeAttachment` | 来自[你自己的附件来源](#add-a-composer-attachment-source)的项目。其他插件和 Paseo 自身的附件不会列出。 |
| `agent.provider`、`model`、`mode`、`thinking` | Agent 当前使用的配置,或草稿将要创建的配置。 |
| `agent.options` | 当前生效的 Provider 选项,包含默认值。在草稿中:创建 Agent 时将使用的选项,包含你所做的修改。 |
@@ -1669,9 +1670,26 @@ export default function contribute(client: PluginClientContext) {
[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`,还有一个资源管理器面板和一个 Command Center 条目,它们按引用访问 composer。
+### 聚焦 composer {#focus-a-composer}
+
+`insertText` 和 `setPrompt` 不会移动光标。在它们之后调用 `focus()`,用户就可以继续输入:
+
+```tsx
+composer.insertText("- [ ] Tests\n");
+await composer.focus();
+```
+
+`await composer.focus({ selection: { start: 0, end: composer.prompt.length } })` 会选中提示词。
+
+你不需要知道用户在哪里。对于运行中 Agent 的 composer,当它不在屏幕上时,Paseo 会导航到该 Agent 的标签页或工作区,关闭手机上遮挡它的 Agent 列表或资源管理器面板,并把焦点移到该 Agent 的窗格,然后聚焦输入框。输入框已在屏幕上且其窗格已聚焦时,`focus()` 只放置光标。每个 composer 也会先关闭它在自身上方打开的面板、菜单、选择器和图片预览,例如手机上放置你的项的控件面板。手机会弹出键盘。
+
+composer 无法获得焦点时 `focus()` 会被拒绝:Agent 已归档、其 composer 未在 15 秒内出现在屏幕上,或输入框拒绝获得焦点(例如手机上正在听写时)。多次调用重叠时,由最后一次决定光标位置,之前的调用随其一起完成。按钮的 `onPress` 或命令中心项的 `onSelect` 返回被拒绝的 promise 时,会向用户显示错误提示。
+
+草稿和新建工作区 composer 只能从其中的项访问,所以 `focus()` 不会导航到它们。composer 在屏幕上时可用,用户已离开时会以 “The composer is not on screen.” 拒绝。锁定的 composer 接受聚焦。
+
### 按引用访问 composer {#reach-a-composer-by-reference}
-运行中的 Agent 无论标签页是否打开,都有自己的 composer。任意插件组件中的 `useComposers(selector)`,以及每个命令上下文中的 `composers(selector)`,都会返回与 `useComposer()` 给 composer 项的相同的 `PluginComposer`,对象可以是你指定的 Agent,也可以是当前聚焦的 Agent:
+运行中的 Agent 无论标签页是否打开,都有自己的 composer。任意插件组件中的 `useComposers(selector)`、每个命令上下文中的 `composers(selector)`,以及客户端上下文中的 `client.composers(selector)`,都会返回与 `useComposer()` 给 composer 项的相同的 `PluginComposer`,对象可以是你指定的 Agent,也可以是当前聚焦的 Agent。在不接收上下文的回调中(例如[胶囊的 `onPress`](#composer-pills))使用 `client.composers`:
```tsx
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
@@ -1721,6 +1739,7 @@ export default function contribute(client: PluginClientContext) {
- 运行中 Agent 的 composer 从不锁定。
- `setProvider` 会被拒绝,与在 Agent 自己的 composer 内一样。
- `setModel` 和 `setThinking` 不加检查地把值发给 Provider。Provider 拒绝的值会以 Provider 的消息拒绝。`model` 和 `thinking` 读取的是模型选择器此刻显示的值:在 Provider 的模型目录加载完成之前,是 Agent 上报或创建时使用的 id(可能是别名),加载完成后是目录中的 id。
+- `focus()` 会先让 composer 显示出来,如[上文所述](#focus-a-composer)。
- 草稿和新建工作区界面不能按引用访问,只有其中的项能通过 `useComposer()` 访问其 composer。
## 新建工作区项 {#new-workspace-items}
@@ -1805,6 +1824,30 @@ const pill = client.addComposerPill({
});
```
+胶囊的 `onPress` 不接收参数。通过 [`client.composers`](#reach-a-composer-by-reference) 访问其 Agent 的 composer:
+
+```tsx
+const pill = client.addComposerPill({
+ id: "hello",
+ workspaceId,
+ agentId,
+ button: {
+ title: "Add HELLO to the composer",
+ icon: "Hand",
+ label: "Hello",
+ behavior: {
+ kind: "action",
+ async onPress() {
+ for (const composer of client.composers({ agentId })) {
+ composer.insertText("HELLO");
+ await composer.focus();
+ }
+ },
+ },
+ },
+});
+```
+
对于随 Agent 目录变化的胶囊,请使用显式的[自有列表订阅](/docs/sdk/events#follow-one-agents-status)。
[本地插件示例](https://github.com/getpaseo/paseo/blob/main/plugin-examples/local-plugin/client/main.tsx)
会在每次快照时替换注册,并在入口清理时中止观察,包括尚未完成的引导过程。
diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs
index e6b9195a94..b67cd113aa 100644
--- a/scripts/postinstall-patches.mjs
+++ b/scripts/postinstall-patches.mjs
@@ -62,7 +62,8 @@ const patchedPackages = [
// Exports BottomSheetModalInternalProvider so a subtree can host its own sheets on the shared
// modal stack (see packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx), and
// keeps a dismissing modal's status through its final onChange(-1), so a modal with
- // enableDismissOnClose=false that unmounts while presented still leaves the portal.
+ // enableDismissOnClose=false that unmounts while presented still leaves the portal, and removes
+ // the portal of a modal that unmounts before its opening animation starts.
// Dimensions subscription cleanup: https://github.com/gorhom/react-native-bottom-sheet/issues/2734
// and https://github.com/gorhom/react-native-bottom-sheet/pull/2751. Remove the useAnimatedLayout
// hunks once an installed release includes the cleanup; retain the other local hunks.