From 4c9c59ea2b95564c46da6c625c35308e6f14bcc9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 10 Nov 2026 14:22:44 +0700 Subject: [PATCH] Let plugins put the cursor in any agent's composer (#9207) * feat(plugins): let a plugin put the cursor in any agent's composer A pill could insert text but left the cursor elsewhere, so the user had to click the composer before typing. Plugins can address any agent's composer, and an author cannot know where the user is, so focus() owns revealing the agent as well as focusing it. A running agent's composer holds one pending request and navigates to the agent, completing when an input that can take the keyboard attaches. Drafts and the New workspace composer have nothing to navigate to and must not pull the user back, so they reject when off screen. * fix(app): stop treating a covered New workspace composer as on screen The screen passed isPaneFocused={true}, so with Settings pushed over it the input still counted as revealed. focus() on a held composer resolved on native with no visible cursor, and rejected with the wrong error on web. The screen now reports its real route focus. * fix(app): reach a compact subagent's composer and keep a focus selection A compact sheet has no composer, so focus() on a subagent without a tab opened a read-only sheet and timed out. It now promotes the sheet to a tab, the same path as Open as tab. A pending focus ran inside the effect that attached the input, before a later effect in that commit applied a prompt set alongside it, so its selection was clamped against the old text. It now runs after the commit. * fix(app): show a compact subagent's tab when focus promotes its sheet navigateToAgent stops at the sheet, so nothing closed an Explorer panel covering the promoted tab and focus() timed out. The promoted tab now goes through the same reveal as any other navigated tab. * fix(app): open focus targets as tabs and focus Explorer composers on compact Promoting a sheet after navigating could not reach an open deferred until the workspace loaded, so the request now asks for a tab up front with asTab, which travels with the deferred open. That removes the post-navigation promotion. A compact composer counted as covered whenever the panel target was not the agent panel, so one hosted in the Explorer could never take focus. Coverage now follows the composer's own host. * fix(app): judge compact composer coverage by where it is hosted The Explorer dock always reported its pane as focused, so a workspace sheet over an Explorer composer still counted as on screen and focus() put the cursor behind the sheet. It now suspends pane focus like the main panes do. A composer on a screen with no panes counted as a main pane, so an Explorer selection left over from the workspace marked New workspace as covered and focus() rejected on a visible input. Only the agent list can cover those screens, because the Explorer lives in the workspace. * fix(app): close the composer's own sheets and menus when a plugin focuses it focus() judged a composer on screen by its pane and panel alone, so a sheet or menu the composer opened over itself, like the compact controls sheet that hosts plugin Actions or a Subagents track page, stayed up while the cursor went behind it. Navigation cannot close those, so focus() now closes them itself. Sheets and menus register with the nearest OverlayDismissalScope while open. The composer opens one, and the agent panel opens one around its whole chat because the tracks render outside the composer; a nested scope joins the enclosing one. * fix(app): close a composer's open picker when a plugin focuses it The thinking, provider-option and plugin selectors render Combobox, which never joined the composer's overlay dismissal scope. focus() left the picker open, resolved on the textarea's brief focus, and the picker's focus enforcement then took the cursor back. Combobox now registers its close while open, like sheets and menus. * fix(app): close a composer's modals before focus places the cursor An image preview opened from the composer stayed over it after focus() succeeded: the shared Modal never joined the overlay dismissal scope. Modal now registers its onRequestClose while visible. Closing it then exposed a second race: a web modal hands focus back to the button that opened it in the commit that removes it, after the cursor was placed. Dismissal now resolves once every closed overlay has unregistered, and the composer places the cursor after that, waiting at most 1s so a plugin modal that refuses to close cannot hold the promise forever. A focus request replaced by a newer one resolved immediately, even when the composer never came on screen. It now settles with the newer one. * fix(app): close every composer sheet on focus and keep the latest request The context usage sheet opens through the bottom sheet visibility hook directly, so it stayed over the composer after focus(). Registration moves to that hook, which every controlled bottom sheet uses; AdaptiveModalSheet registers only its desktop dialog. Two focus() calls in one turn overwrote each other's wait for an overlay to close, so the older one resumed a second later and put its selection back. Callers now share each overlay's close, and the latest request places the cursor while earlier ones settle with it. On native, focus() reported success when the input refused focus, as a non-editable input does during dictation. It now checks isFocused(). * fix(app): let a focus call made while the input attaches decide the selection A queued focus was dispatched in a microtask after the input attached, so a focus() made in between (from a mounting item, for example) was overwritten by the older selection. Each call now carries its sequence number; a queued request that is no longer the latest settles with the newer one instead of placing the cursor. * fix(app): close a workspace sheet that focus dismisses before it animates open A bottom sheet modal that unmounts after presenting but before its first animation is still INITIAL, and the library's portal unmount returned early for that status. Its portal stayed on the host and kept sliding open with no owner, so composer.focus() removed the sheet from the workspace but left it covering the input. The patch unmounts the portal in that case. The compact Explorer focus test also waits for the phone layout before focusing, since focus otherwise resolved against the wide layout. --- docs/plugins.md | 23 + .../browser/compact-workspace-sheet.spec.ts | 12 +- .../app/e2e/browser/explorer-sidebar.spec.ts | 21 +- .../plugin-composers-by-reference.spec.ts | 170 ++++++ .../helpers/plugin-composers-by-reference.ts | 559 +++++++++++++++++- packages/app/e2e/support/helpers/sheet.ts | 12 + .../app/e2e/support/helpers/workspace-tabs.ts | 17 + .../src/components/adaptive-modal-sheet.tsx | 6 +- packages/app/src/components/ui/combobox.tsx | 2 + .../ui/isolated-bottom-sheet-modal/index.tsx | 2 + .../src/components/ui/menu/menu-context.ts | 2 + packages/app/src/components/ui/modal.tsx | 14 +- .../src/components/ui/overlay-dismissal.tsx | 90 +++ .../src/composer/agent-composer/index.test.ts | 172 +++++- .../app/src/composer/agent-composer/index.ts | 72 ++- .../app/src/composer/agent-controls/index.tsx | 2 + packages/app/src/composer/halves-model.ts | 6 + packages/app/src/composer/index.tsx | 90 ++- packages/app/src/composer/input/input.tsx | 10 + packages/app/src/composer/model.ts | 8 + packages/app/src/composer/session.tsx | 9 + packages/app/src/panels/agent-panel.tsx | 11 +- packages/app/src/panels/pane-context.tsx | 5 + .../src/plugins/composer-items/model.test.ts | 53 +- .../app/src/plugins/composer-items/model.ts | 6 + .../app/src/screens/new-workspace-screen.tsx | 6 +- .../screens/workspace/explorer-sidebar.tsx | 4 +- .../screens/workspace/workspace-screen.tsx | 2 + .../navigation.test.ts | 16 + .../navigation.ts | 1 + .../src/stores/workspace-layout-store.test.ts | 18 + .../app/src/stores/workspace-layout-store.ts | 4 +- .../utils/navigate-to-agent/resolve.test.ts | 8 + .../src/utils/navigate-to-agent/resolve.ts | 3 + .../app/src/utils/prepare-workspace-tab.ts | 3 + packages/plugin/src/boundaries.test.ts | 1 + packages/plugin/src/client/composer.tsx | 11 + packages/plugin/src/client/index.ts | 1 + patches/@gorhom+bottom-sheet+5.2.14.patch | 46 +- .../composer-controls/client/controls.tsx | 5 +- public-docs/plugins/reference.md | 62 +- public-docs/plugins/reference.zh-CN.md | 45 +- scripts/postinstall-patches.mjs | 3 +- 43 files changed, 1544 insertions(+), 69 deletions(-) create mode 100644 packages/app/src/components/ui/overlay-dismissal.tsx 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.