diff --git a/docs/plugins.md b/docs/plugins.md index f76bb00905..03a2c3a81f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -712,6 +712,17 @@ 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. `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/plugin-composers-by-reference.spec.ts b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts index 4878ab76e6..62c4c4cad0 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,7 @@ import { test } from "../support/fixtures"; import { CHECKLIST, withComposerToolsExample, + withPluginFocusingAComposer, withPluginWritingToAnotherWorkspace, } from "../support/helpers/plugin-composers-by-reference"; @@ -51,3 +52,56 @@ 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(); + }); +}); 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..29a9d195e2 100644 --- a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts +++ b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts @@ -12,6 +12,7 @@ import { getServerId } from "./server-id"; import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs"; const WIDE = { width: 1440, height: 900 }; +const COMPACT = { width: 390, height: 844 }; const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n"; type Workspace = Awaited>; @@ -205,4 +206,188 @@ 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} })) { + await 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 }; diff --git a/packages/app/src/composer/agent-composer/index.test.ts b/packages/app/src/composer/agent-composer/index.test.ts index 3eb859d726..895c9b6484 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,92 @@ 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("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("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 +272,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 +304,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 +324,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 +503,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 +514,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..99b3d07cb3 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,18 @@ 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 { + options: ComposerFocusOptions | undefined; + settle: { resolve: () => void; reject: (error: Error) => void }; + timeout: ReturnType; } type Catalog = { status: "loading" } | { status: "ready"; entries: ProviderSnapshotEntry[] | null }; @@ -66,6 +82,7 @@ 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 last: { agent: Agent | null; draft: DraftRecord | undefined; @@ -209,6 +226,19 @@ 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 { options, settle } = takePendingFocus()!; + input.focus(options).then(settle.resolve, settle.reject); + } + function unheld() { if (listeners.size === 0 && mounted === null) deps.onUnheld?.(); } @@ -237,6 +267,7 @@ export function createAgentComposer( }, attachInput(input) { mounted = input; + focusIfRevealed(input); return () => { if (mounted === input) mounted = null; unheld(); @@ -253,6 +284,26 @@ 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.")); + } + if (mounted?.isRevealed()) return mounted.focus(options); + // A newer request replaces this one: the user asked to be there, and is about to be. + takePendingFocus()?.settle.resolve(); + return new Promise((resolve, reject) => { + pendingFocus = { + options, + settle: { resolve, reject }, + timeout: setTimeout(() => { + takePendingFocus(); + reject(new Error("The composer did not come on screen.")); + }, REVEAL_TIMEOUT_MS), + }; + deps.reveal(); + }); + }, changeAttachments(updater) { useDraftStore.getState().updateDraftInput({ draftKey, @@ -304,6 +355,7 @@ export function agentComposer(serverId: string, agentId: string): AgentComposer composer = createAgentComposer(serverId, agentId, { queryClient: singletonQueryClient, onUnheld: releaseReleasableComposers, + reveal: () => navigateToAgent({ serverId, agentId }), }); 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 a3bb484b94..7b6d1f0df7 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -1172,6 +1172,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 ec0c688176..242e61adfd 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -153,6 +153,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"; @@ -1432,6 +1434,36 @@ 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 takeInputFocus = useCallback((options?: ComposerFocusOptions) => { + const place = () => { + messageInputRef.current?.focus(); + if (options?.selection) messageInputRef.current?.setSelection(options.selection); + }; + if (isNative) { + place(); + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + focusWithRetries({ + focus: place, + isFocused: () => { + const element = messageInputRef.current?.getNativeElement?.() ?? null; + return element !== null && document.activeElement === element; + }, + onSuccess: resolve, + onTimeout: () => reject(new Error("The composer input could not take focus.")), + }); + }); + }, []); const isComposerLockedRef = useRef(isComposerLocked); isComposerLockedRef.current = isComposerLocked; const composerInput = useMemo( @@ -1439,6 +1471,9 @@ function ComposerContentImpl({ locked: isComposerLocked, isLocked: () => isComposerLockedRef.current, isLive: isInputLive, + revealed: inputRevealed, + isRevealed: isInputRevealed, + focus: takeInputFocus, textSource, setText: replaceUserInput, insertText: insertUserInput, @@ -1447,11 +1482,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 48c4132b9d..5e4dc968d3 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -228,6 +228,8 @@ export interface MessageInputRef { 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. @@ -1493,6 +1495,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/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/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/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 b79064ed15..2c2b06ce8e 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. | @@ -1992,11 +1993,39 @@ 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. 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. 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"; @@ -2054,6 +2083,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()`. @@ -2158,6 +2188,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..02a1053713 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 无法获得焦点时 `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) 会在每次快照时替换注册,并在入口清理时中止观察,包括尚未完成的引导过程。