mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92f610fc7 | ||
|
|
354165964f | ||
|
|
80462a44d8 | ||
|
|
bee9f94a49 | ||
|
|
b10647cae3 |
@@ -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.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ensureExplorerSidebar,
|
||||
openFilesPanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
dragTabIntoExplorer,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
@@ -194,26 +195,6 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
|
||||
}
|
||||
});
|
||||
|
||||
async function dragTabIntoExplorer(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
tabId: string,
|
||||
) {
|
||||
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
|
||||
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
|
||||
const sourceBox = await source.boundingBox();
|
||||
const destinationBox = await destination.boundingBox();
|
||||
if (!sourceBox || !destinationBox)
|
||||
throw new Error("Tab drag requires visible source and destination");
|
||||
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 12, start.y + 4);
|
||||
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("moves agents into Explorer and keeps the shared Agent launcher available", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -2,6 +2,9 @@ import { test } from "../support/fixtures";
|
||||
import {
|
||||
CHECKLIST,
|
||||
withComposerToolsExample,
|
||||
withPluginFocusingAComposer,
|
||||
withPluginFocusingASubagent,
|
||||
withPluginHoldingNewWorkspaceComposer,
|
||||
withPluginWritingToAnotherWorkspace,
|
||||
} from "../support/helpers/plugin-composers-by-reference";
|
||||
|
||||
@@ -51,3 +54,85 @@ test("a plugin writes to an agent's composer while its workspace is not on scree
|
||||
await author.expectComposerText("A note from a plugin");
|
||||
});
|
||||
});
|
||||
|
||||
test("a pill that inserts text and focuses leaves the cursor in the composer, ready to type", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingAComposer(page, async (author) => {
|
||||
await author.openTargetAgent();
|
||||
await author.moveFocusOutOfComposer();
|
||||
await author.pressPill();
|
||||
await author.expectComposerFocused();
|
||||
await author.typeAtCursor(" world");
|
||||
await author.expectComposerText("HELLO world");
|
||||
});
|
||||
});
|
||||
|
||||
test("focus reaches an agent in another tab of the workspace", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingAComposer(page, async (author) => {
|
||||
await author.openTargetAgent();
|
||||
await author.openOtherAgent();
|
||||
await author.focusFromCommandCenter();
|
||||
await author.expectOnTargetAgent();
|
||||
await author.expectComposerFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus reaches an agent in another workspace, with the selection asked for", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingAComposer(page, async (author) => {
|
||||
await author.openTargetAgent();
|
||||
await author.fillComposer("typed before");
|
||||
await author.goToAnotherWorkspace();
|
||||
await author.selectPromptFromCommandCenter();
|
||||
await author.expectOnTargetAgent();
|
||||
await author.expectComposerFocused();
|
||||
await author.typeAtCursor("replaced");
|
||||
await author.expectComposerText("replaced");
|
||||
});
|
||||
});
|
||||
|
||||
test("focus closes the compact Explorer panel covering the composer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingAComposer(page, async (author) => {
|
||||
await author.useCompactLayout();
|
||||
await author.openTargetAgent();
|
||||
await author.coverComposerWithExplorerPanel();
|
||||
await author.pressPanelFocusButton();
|
||||
await author.expectExplorerPanelClosed();
|
||||
await author.expectComposerFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus rejects on a New workspace composer the user has left", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
|
||||
await author.openNewWorkspace();
|
||||
await author.expectHeldComposerFocusResult("focused");
|
||||
await author.openSettings();
|
||||
await author.expectHeldComposerFocusResult("The composer is not on screen.");
|
||||
});
|
||||
});
|
||||
|
||||
test("focus opens a compact subagent as a tab with its composer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingASubagent(page, async (author) => {
|
||||
await author.openParentAgent();
|
||||
await author.openExplorer();
|
||||
await author.focusSubagent();
|
||||
await author.expectSubagentTabReadyToType();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus reaches an agent kept in the Explorer on a compact screen", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingASubagent(page, async (author) => {
|
||||
await author.keepParentInExplorerOnCompactScreen();
|
||||
await author.focusParent();
|
||||
await author.expectParentReadyToTypeInExplorer();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,21 @@ import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { expectNoSheetOpen, expectOpenTabCount } from "./sheet";
|
||||
import { seedSubagent } from "./subagents";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
|
||||
import {
|
||||
createAgentTabFromMenu,
|
||||
dragTabIntoExplorer,
|
||||
ensureExplorerSidebar,
|
||||
} from "./workspace-tabs";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
|
||||
|
||||
type Workspace = Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
@@ -205,4 +212,365 @@ 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} })) {
|
||||
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 };
|
||||
|
||||
type HeldComposerGlobal = typeof globalThis & {
|
||||
heldComposer?: { focus(): Promise<void> };
|
||||
};
|
||||
|
||||
export async function withPluginHoldingNewWorkspaceComposer(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openNewWorkspace(): Promise<void>;
|
||||
openSettings(): Promise<void>;
|
||||
expectHeldComposerFocusResult(result: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "held-composer-", title: "Held composer" });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-held-composer-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "held-composer", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { useComposer } from "@getpaseo/plugin/client";
|
||||
|
||||
function Holder() {
|
||||
globalThis.heldComposer = useComposer();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addComposerItem({ id: "holder", Component: Holder });
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await run({
|
||||
openNewWorkspace: () =>
|
||||
test.step("open the New workspace composer", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
|
||||
}),
|
||||
openSettings: () =>
|
||||
test.step("push Settings over the New workspace screen", async () => {
|
||||
await page.getByTestId("sidebar-settings").filter({ visible: true }).click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}),
|
||||
expectHeldComposerFocusResult: (result) =>
|
||||
test.step(`the held composer's focus() reports ${result}`, async () => {
|
||||
const actual = await page.evaluate(() =>
|
||||
(globalThis as HeldComposerGlobal).heldComposer?.focus().then(
|
||||
() => "focused",
|
||||
(error: Error) => error.message,
|
||||
),
|
||||
);
|
||||
expect(actual).toBe(result);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("held-composer");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
type FocusAgentGlobal = typeof globalThis & {
|
||||
focusAgent?: (agentId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function withPluginFocusingASubagent(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openParentAgent(): Promise<void>;
|
||||
openExplorer(): Promise<void>;
|
||||
focusSubagent(): Promise<void>;
|
||||
expectSubagentTabReadyToType(): Promise<void>;
|
||||
keepParentInExplorerOnCompactScreen(): Promise<void>;
|
||||
focusParent(): Promise<void>;
|
||||
expectParentReadyToTypeInExplorer(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "focus-subagent-",
|
||||
title: "Focus parent",
|
||||
});
|
||||
const child = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Focus child" },
|
||||
);
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-focus-subagent-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "focus-subagent", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`export default function contribute(client) {
|
||||
globalThis.focusAgent = (agentId) => client.composers({ agentId })[0].focus();
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await gotoAppShell(page);
|
||||
const focusAgent = async (agentId: string) => {
|
||||
const result = await page.evaluate(
|
||||
(id) =>
|
||||
(globalThis as FocusAgentGlobal).focusAgent?.(id).then(
|
||||
() => "focused",
|
||||
(error: Error) => error.message,
|
||||
),
|
||||
agentId,
|
||||
);
|
||||
expect(result).toBe("focused");
|
||||
};
|
||||
await run({
|
||||
keepParentInExplorerOnCompactScreen: () =>
|
||||
test.step("move the parent into the Explorer, then narrow the window", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
|
||||
await expect(
|
||||
explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`),
|
||||
).toBeVisible();
|
||||
await page.setViewportSize(COMPACT);
|
||||
}),
|
||||
focusParent: () =>
|
||||
test.step("focus the agent kept in the Explorer", () => focusAgent(workspace.agentId)),
|
||||
expectParentReadyToTypeInExplorer: () =>
|
||||
test.step("the cursor is in the Explorer's composer", () =>
|
||||
expect(prompt(page).filter({ visible: true })).toBeFocused()),
|
||||
openParentAgent: () =>
|
||||
test.step("open the parent agent on a phone", async () => {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectOpenTabCount(page, 1);
|
||||
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
|
||||
}),
|
||||
openExplorer: () =>
|
||||
test.step("open the Explorer over the parent's composer", async () => {
|
||||
await expect(await ensureExplorerSidebar(page)).toBeVisible();
|
||||
}),
|
||||
focusSubagent: () =>
|
||||
test.step("focus the subagent, which has no tab", () => focusAgent(child.id)),
|
||||
expectSubagentTabReadyToType: () =>
|
||||
test.step("the subagent is a tab with the cursor in its composer", async () => {
|
||||
await expectNoSheetOpen(page);
|
||||
await expectOpenTabCount(page, 2);
|
||||
await expect(prompt(page)).toBeFocused();
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("focus-subagent");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,23 @@ function explorerSidebar(page: Page) {
|
||||
return visibleTestId(page, "workspace-explorer-sidebar").first();
|
||||
}
|
||||
|
||||
export async function dragTabIntoExplorer(page: Page, tabId: string): Promise<void> {
|
||||
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
|
||||
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
|
||||
const sourceBox = await source.boundingBox();
|
||||
const destinationBox = await destination.boundingBox();
|
||||
if (!sourceBox || !destinationBox)
|
||||
throw new Error("Tab drag requires visible source and destination");
|
||||
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 12, start.y + 4);
|
||||
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
if ((await tab.getAttribute("aria-selected")) !== "true") {
|
||||
// The close action overlays the chip's trailing edge on hover. Click the
|
||||
|
||||
@@ -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,111 @@ afterEach(() => {
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
});
|
||||
|
||||
describe("agent composer focus", () => {
|
||||
it("only focuses an input that is already on screen", async () => {
|
||||
openSession(agent());
|
||||
let reveals = 0;
|
||||
const model = composer({ reveal: () => (reveals += 1) });
|
||||
const { input, focused } = mountedInput({ revealed: true });
|
||||
model.attachInput(input);
|
||||
|
||||
await model.focus({ selection: { start: 1, end: 3 } });
|
||||
|
||||
expect(focused).toEqual([{ selection: { start: 1, end: 3 } }]);
|
||||
expect(reveals).toBe(0);
|
||||
});
|
||||
|
||||
it("reveals an agent with no mounted composer and focuses the input that arrives", async () => {
|
||||
openSession(agent());
|
||||
let reveals = 0;
|
||||
const model = composer({ reveal: () => (reveals += 1) });
|
||||
|
||||
const focusing = model.focus();
|
||||
expect(reveals).toBe(1);
|
||||
|
||||
const { input, focused } = mountedInput({ revealed: true });
|
||||
model.attachInput(input);
|
||||
await focusing;
|
||||
expect(focused).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("focuses after the commit that revealed the input has applied a pending prompt", async () => {
|
||||
openSession(agent());
|
||||
const model = composer({ reveal() {} });
|
||||
const focusing = model.focus({ selection: { start: 0, end: 5 } });
|
||||
let text = "";
|
||||
const textWhenFocused: string[] = [];
|
||||
model.attachInput({
|
||||
...mountedInput({ revealed: true }).input,
|
||||
focus: async () => {
|
||||
textWhenFocused.push(text);
|
||||
},
|
||||
});
|
||||
text = "HELLO";
|
||||
|
||||
await focusing;
|
||||
|
||||
expect(textWhenFocused).toEqual(["HELLO"]);
|
||||
});
|
||||
|
||||
it("waits for a retained input to come on screen before focusing it", async () => {
|
||||
openSession(agent());
|
||||
let reveals = 0;
|
||||
const model = composer({ reveal: () => (reveals += 1) });
|
||||
const on = { revealed: false };
|
||||
const { input, focused } = mountedInput(on);
|
||||
const detach = model.attachInput(input);
|
||||
|
||||
const focusing = model.focus();
|
||||
expect(reveals).toBe(1);
|
||||
expect(focused).toEqual([]);
|
||||
|
||||
// The mounted composer re-attaches when it comes on screen.
|
||||
on.revealed = true;
|
||||
detach();
|
||||
model.attachInput(input);
|
||||
await focusing;
|
||||
expect(focused).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("applies only the latest of two focus requests", async () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
const first = model.focus({ selection: { start: 0, end: 0 } });
|
||||
const second = model.focus({ selection: { start: 2, end: 2 } });
|
||||
const { input, focused } = mountedInput({ revealed: true });
|
||||
model.attachInput(input);
|
||||
await Promise.all([first, second]);
|
||||
expect(focused).toEqual([{ selection: { start: 2, end: 2 } }]);
|
||||
});
|
||||
|
||||
it("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 +291,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 +323,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 +343,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 +522,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 +533,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,23 @@ 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()!;
|
||||
// Attaching runs in a commit's effects, and a prompt set alongside this request can be applied
|
||||
// by a later effect in that commit. A selection has to see that text.
|
||||
queueMicrotask(() => {
|
||||
input.focus(options).then(settle.resolve, settle.reject);
|
||||
});
|
||||
}
|
||||
|
||||
function unheld() {
|
||||
if (listeners.size === 0 && mounted === null) deps.onUnheld?.();
|
||||
}
|
||||
@@ -237,6 +271,7 @@ export function createAgentComposer(
|
||||
},
|
||||
attachInput(input) {
|
||||
mounted = input;
|
||||
focusIfRevealed(input);
|
||||
return () => {
|
||||
if (mounted === input) mounted = null;
|
||||
unheld();
|
||||
@@ -253,6 +288,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 +359,7 @@ export function agentComposer(serverId: string, agentId: string): AgentComposer
|
||||
composer = createAgentComposer(serverId, agentId, {
|
||||
queryClient: singletonQueryClient,
|
||||
onUnheld: releaseReleasableComposers,
|
||||
reveal: () => navigateToAgent({ serverId, agentId, asTab: true }),
|
||||
});
|
||||
composers.set(key, composer);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -138,6 +138,7 @@ import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { usePaneHost } from "@/panels/pane-context";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { AfterPaintPublication } from "@/composer/after-paint-publication";
|
||||
@@ -153,6 +154,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";
|
||||
@@ -1220,7 +1223,10 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) {
|
||||
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
|
||||
// user's own pane: closing the panel does not bring focus back.
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
|
||||
const hostPanelTarget = usePaneHost() === "explorer" ? "file-explorer" : "agent";
|
||||
const overlayPanelRequested = usePanelStore(
|
||||
(state) => state.mobilePanel.target !== hostPanelTarget,
|
||||
);
|
||||
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider
|
||||
@@ -1432,6 +1438,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 +1475,9 @@ function ComposerContentImpl({
|
||||
locked: isComposerLocked,
|
||||
isLocked: () => isComposerLockedRef.current,
|
||||
isLive: isInputLive,
|
||||
revealed: inputRevealed,
|
||||
isRevealed: isInputRevealed,
|
||||
focus: takeInputFocus,
|
||||
textSource,
|
||||
setText: replaceUserInput,
|
||||
insertText: insertUserInput,
|
||||
@@ -1447,11 +1486,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. */
|
||||
|
||||
@@ -74,6 +74,11 @@ export function usePaneContext(): PaneContextValue {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The host of the pane around this component; a screen with no panes counts as the main host. */
|
||||
export function usePaneHost(): PaneHost {
|
||||
return useContext(PaneContext)?.host ?? "main";
|
||||
}
|
||||
|
||||
export function usePaneFocus(): PaneFocusContextValue {
|
||||
const value = useContext(PaneFocusContext);
|
||||
invariant(value, "PaneFocusContext is required");
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { encodeImages } from "@/utils/encode-images";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useIsFocused } from "expo-router/react-navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -909,6 +910,7 @@ export function NewWorkspaceScreen({
|
||||
const hostPickerAnchorRef = useRef<View | null>(null);
|
||||
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
||||
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
|
||||
const isScreenFocused = useIsFocused();
|
||||
|
||||
// Launch target: what the composer submits to (chat agent, or a terminal
|
||||
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
|
||||
@@ -1544,7 +1546,7 @@ export function NewWorkspaceScreen({
|
||||
submitLabel={terminalSubmitLabel}
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
isPaneFocused={isScreenFocused}
|
||||
onSubmitMessage={handleSubmitTerminalLaunch}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
|
||||
@@ -1568,7 +1570,7 @@ export function NewWorkspaceScreen({
|
||||
editingSession={composerEditing.chat}
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
isPaneFocused={isScreenFocused}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
|
||||
@@ -145,6 +145,22 @@ describe("workspace navigation", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries a request for a tab through a deferred open", () => {
|
||||
const { deps, deferredTabs } = createFakeDeps({ getSessionWorkspaces: () => new Map() });
|
||||
|
||||
navigateToWorkspace(
|
||||
{
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
asTab: true,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(deferredTabs[0]).toMatchObject({ asTab: true });
|
||||
});
|
||||
|
||||
it("defers an agent tab until a missing workspace is recovered", () => {
|
||||
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map(),
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface NavigateToWorkspaceInput {
|
||||
target?: WorkspaceTabTarget;
|
||||
pin?: boolean;
|
||||
placement?: WorkspaceTabPlacement;
|
||||
asTab?: boolean;
|
||||
}
|
||||
|
||||
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
|
||||
|
||||
@@ -4569,6 +4569,24 @@ describe("compact workspace sheet", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("opens a subagent as a tab when a deferred request asks for one", () => {
|
||||
const store = createCompactStore();
|
||||
seedAgents([
|
||||
{ id: "parent", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "parent", workspaceId: "sheet-ws" },
|
||||
]);
|
||||
|
||||
store.getState().deferOpenTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "child" },
|
||||
intent: "reveal",
|
||||
asTab: true,
|
||||
});
|
||||
store.getState().consumePendingOpen(workspaceKey);
|
||||
|
||||
expect(topSheet(store)).toBeUndefined();
|
||||
expect(tabKinds(store)).toContain("agent");
|
||||
});
|
||||
it("reveals a file in the sheet and leaves the tab strip unchanged", () => {
|
||||
const store = createCompactStore();
|
||||
const before = tabKinds(store);
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface OpenWorkspaceTabInput {
|
||||
/** Keeps an explicitly opened agent visible even when it is archived. */
|
||||
pin?: boolean;
|
||||
placement?: WorkspaceTabPlacement;
|
||||
/** Opens a tab where a compact layout would otherwise use a sheet. */
|
||||
asTab?: boolean;
|
||||
insertionPosition?: WorkspaceTabInsertionPosition;
|
||||
parentTabId?: string;
|
||||
state?: JsonValue;
|
||||
@@ -1108,7 +1110,7 @@ export function createWorkspaceLayoutStore(
|
||||
input.parentTabId,
|
||||
);
|
||||
const sheetPlacement =
|
||||
input.placement?.mode === "pane"
|
||||
input.placement?.mode === "pane" || input.asTab
|
||||
? null
|
||||
: resolveSheetPlacement(
|
||||
get(),
|
||||
|
||||
@@ -40,6 +40,14 @@ function createFakeNavigators(target: AgentNavTarget): {
|
||||
}
|
||||
|
||||
describe("resolveNavigateToAgent", () => {
|
||||
it("carries a request for a tab to the workspace navigation", () => {
|
||||
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: WORKSPACE_ID });
|
||||
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, asTab: true }, deps);
|
||||
|
||||
expect(tabNavigations[0]).toMatchObject({ asTab: true });
|
||||
});
|
||||
|
||||
it("opens the workspace tab carried by the agent's workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: WORKSPACE_ID,
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface NavigateToAgentInput {
|
||||
// (cold deep-links). Otherwise the workspace is read from the store.
|
||||
workspaceId?: string | null;
|
||||
pin?: boolean;
|
||||
/** Opens the agent with its composer, where a compact layout would show a sheet without one. */
|
||||
asTab?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentNavTarget {
|
||||
@@ -41,5 +43,6 @@ export function resolveNavigateToAgent(
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId: input.agentId },
|
||||
pin: input.pin,
|
||||
asTab: input.asTab,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface PrepareWorkspaceTabInput {
|
||||
target: WorkspaceTabTarget;
|
||||
pin?: boolean;
|
||||
placement?: WorkspaceTabPlacement;
|
||||
asTab?: boolean;
|
||||
}
|
||||
|
||||
export interface PrepareWorkspaceTabDeps {
|
||||
@@ -17,6 +18,7 @@ export interface PrepareWorkspaceTabDeps {
|
||||
intent: "reveal";
|
||||
pin?: boolean;
|
||||
placement?: WorkspaceTabPlacement;
|
||||
asTab?: boolean;
|
||||
}) => string | null;
|
||||
}
|
||||
|
||||
@@ -44,5 +46,6 @@ export function prepareWorkspaceTab(
|
||||
intent: "reveal",
|
||||
pin: input.pin === true && target.kind === "agent",
|
||||
placement: input.placement,
|
||||
asTab: input.asTab,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: () => {} });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
会在每次快照时替换注册,并在入口清理时中止观察,包括尚未完成的引导过程。
|
||||
|
||||
Reference in New Issue
Block a user