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.