feat(plugins): let a plugin put the cursor in any agent's composer

A pill could insert text but left the cursor elsewhere, so the user had
to click the composer before typing. Plugins can address any agent's
composer, and an author cannot know where the user is, so focus() owns
revealing the agent as well as focusing it.

A running agent's composer holds one pending request and navigates to
the agent, completing when an input that can take the keyboard attaches.
Drafts and the New workspace composer have nothing to navigate to and
must not pull the user back, so they reject when off screen.
This commit is contained in:
Mohamed Boudra
2026-11-09 15:39:46 +01:00
parent 0fa7f840b4
commit b10647cae3
19 changed files with 664 additions and 13 deletions
+11
View File
@@ -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.
@@ -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();
});
});
@@ -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<ReturnType<typeof seedWorkspace>>;
@@ -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<void>;
openTargetAgent(): Promise<void>;
goToAnotherWorkspace(): Promise<void>;
moveFocusOutOfComposer(): Promise<void>;
pressPill(): Promise<void>;
focusFromCommandCenter(): Promise<void>;
selectPromptFromCommandCenter(): Promise<void>;
useCompactLayout(): Promise<void>;
coverComposerWithExplorerPanel(): Promise<void>;
pressPanelFocusButton(): Promise<void>;
expectExplorerPanelClosed(): Promise<void>;
fillComposer(text: string): Promise<void>;
expectOnTargetAgent(): Promise<void>;
expectComposerFocused(): Promise<void>;
typeAtCursor(text: string): Promise<void>;
expectComposerText(text: string): Promise<void>;
}) => Promise<void>,
) {
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 (
<Pressable
accessibilityRole="button"
accessibilityLabel="Focus the target agent"
onPress={() => client.composers({ agentId: ${targetId} })[0]?.focus()}
>
<Text>Focus the target agent</Text>
</Pressable>
);
},
});
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 };
@@ -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);
@@ -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<void>;
}
/**
@@ -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<typeof setTimeout>;
}
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<void>((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);
}
@@ -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],
@@ -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);
+38
View File
@@ -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<void>((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<ComposerInput>(
@@ -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,
],
);
@@ -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<MessageInputRef, MessageInputProps>(
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(),
+8
View File
@@ -17,6 +17,12 @@ export interface ComposerState {
metadata: Record<string, JsonValue>;
}
/** 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<void>;
changeAttachments(updater: (current: UserComposerAttachment[]) => UserComposerAttachment[]): void;
setProvider(provider: string): Promise<void>;
setModel(modelId: string): Promise<void>;
+9
View File
@@ -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<void>;
textSource: ComposerTextSource;
setText: (text: string) => void;
/** Inserts at the cursor, replacing the selection. */
@@ -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] } };
@@ -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}`);
+1
View File
@@ -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: () => {} });
+11
View File
@@ -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<void>;
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. */
+1
View File
@@ -86,6 +86,7 @@ export { useComposer, useComposers } from "./composer.js";
export type {
PluginComposer,
PluginComposerAttachment,
PluginComposerFocusOptions,
PluginComposerItemContribution,
PluginComposerItemProps,
PluginComposerSelector,
@@ -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 <Action title="Insert checklist" icon="ListChecks" onPress={insert} />;
}
+57 -2
View File
@@ -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.
+44 -1
View File
@@ -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)
会在每次快照时替换注册,并在入口清理时中止观察,包括尚未完成的引导过程。