mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user