mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
feat(plugins): reach a running agent's composer by reference
useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open. A running agent now has one store-backed composer model apart from any mounted tab. The mounted composer plugs its cursor-aware insert in while mounted; otherwise insertText and setPrompt write the agent's saved prompt. model and thinking resolve through the provider catalog with the picker's own function, so a read equals what the picker shows. The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.
This commit is contained in:
+25
-7
@@ -667,12 +667,30 @@ work inside it. See the maintained [composer pill example](../plugin-examples/lo
|
||||
`addComposerItem` mounts a plugin component in the agent, draft and New workspace composers. The
|
||||
public contract is the [composer items reference](../public-docs/plugins/reference.md#composer-items).
|
||||
|
||||
The composer is two contexts, provided by two owners (`packages/app/src/composer/session.tsx`).
|
||||
`Composer` provides `ComposerInput` (prompt and attachments). `AgentControls` and
|
||||
`DraftAgentControls` provide `ComposerAgent` (provider, model, mode, thinking, options, metadata and
|
||||
their commands). `toPluginComposer` (`packages/app/src/plugins/composer-items/`) joins them into the
|
||||
`PluginComposer` an item reads, so a plugin cannot tell which composer a command reaches. Add a
|
||||
capability to `ComposerAgent` and both implementations; never branch on `context` in the model.
|
||||
A plugin reads one `ComposerModel` (`packages/app/src/composer/model.ts`): state, subscribe and
|
||||
commands. `toPluginComposer` (`packages/app/src/plugins/composer-items/`) turns its state into the
|
||||
`PluginComposer` an item reads, and the host never learns which kind of composer it got. Two
|
||||
implementations provide it:
|
||||
|
||||
- A running agent's `agentComposer(serverId, agentId)` (`composer/agent-composer/`). It exists
|
||||
without a mounted tab: the prompt and attachments are the agent's draft in the draft store, the
|
||||
settings are the session store's agent, and `model` and `thinking` resolve through the provider
|
||||
catalog with `resolveAgentModelSelection`, the picker's own function, so a read equals what the
|
||||
picker shows. The model loads the catalog itself and reads `null` meanwhile. The mounted
|
||||
`AgentControls` plugs its input in with `attachInput` so `insertText` lands at the cursor;
|
||||
without it the model edits the stored prompt. The daemon is the authority for `setModel` and
|
||||
`setThinking`.
|
||||
- A draft or New workspace screen's `composerModelFromHalves`, an adapter over the two contexts
|
||||
in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments),
|
||||
`DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock.
|
||||
|
||||
`ComposerAgent` stays for the built-in controls. Add a capability to the `ComposerModel`, both
|
||||
implementations and `toPluginComposer`; never branch on `context` in the host.
|
||||
|
||||
`useComposers` and `composers()` reach an agent's model by reference (`plugins/composer-items/source.ts`):
|
||||
`PluginInstallationProvider` provides the plugin's source to every surface, and
|
||||
`createPluginCapabilities` puts `composers` on every command context. `{ focused: true }` resolves
|
||||
from the last viewed workspace on the plugin's host and its layout's focused tab.
|
||||
|
||||
Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all
|
||||
render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`),
|
||||
@@ -684,7 +702,7 @@ item that renders nothing still counts for density; there is no registration for
|
||||
A command runs against the owner's current state, not the render that built the callback. The
|
||||
draft's form (`openAgentForm`) and options (`openDraftOptions`) are plain stores for that reason,
|
||||
and `draftComposerAgent` rejects a provider the host does not offer or a model its loaded catalog
|
||||
does not list. `ComposerControl` owns the pending state and the error toast for callbacks that
|
||||
does not list; for a running agent the daemon rejects it. `ComposerControl` owns the pending state and the error toast for callbacks that
|
||||
return a promise.
|
||||
|
||||
The overflow sheet's content portals to the app root, away from the composer. Pass
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
CHECKLIST,
|
||||
withComposerToolsExample,
|
||||
withPluginWritingToAnotherAgent,
|
||||
} from "../support/helpers/plugin-composers-by-reference";
|
||||
|
||||
test("a panel in the Explorer inserts at the cursor of the focused agent's composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.openComposerToolsInExplorer();
|
||||
await author.typeInComposer("start end", 5);
|
||||
await author.insertChecklistFromPanel();
|
||||
await author.expectComposerText(`start${CHECKLIST} end`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a Command Center item changes an agent's metadata through its composer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.openComposerToolsInExplorer();
|
||||
await author.expectPanelShowsReview(false);
|
||||
await author.markForReviewFromCommandCenter();
|
||||
await author.expectPanelShowsReview(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("the panel follows which agent is focused", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.openComposerToolsInExplorer();
|
||||
await author.expectPanelFocusedAgent(true);
|
||||
await author.focusANewAgentTab();
|
||||
await author.expectPanelFocusedAgent(false);
|
||||
await author.focusTheAgentTab();
|
||||
await author.expectPanelFocusedAgent(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin writes to another agent's composer before its tab is opened", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginWritingToAnotherAgent(page, async (author) => {
|
||||
await author.openTheOtherAgent();
|
||||
await author.insertFromCommandCenter();
|
||||
await author.openTargetAgent();
|
||||
await author.expectComposerText("A note from a plugin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { buildAgentRoute, openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
|
||||
|
||||
type Workspace = Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
|
||||
function prompt(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." });
|
||||
}
|
||||
|
||||
async function createMockAgent(workspace: Workspace, title: string) {
|
||||
return workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, title: string) {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function withComposerToolsExample(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openComposerToolsInExplorer(): Promise<void>;
|
||||
typeInComposer(text: string, cursorAfter: number): Promise<void>;
|
||||
insertChecklistFromPanel(): Promise<void>;
|
||||
expectComposerText(text: string): Promise<void>;
|
||||
markForReviewFromCommandCenter(): Promise<void>;
|
||||
focusANewAgentTab(): Promise<void>;
|
||||
focusTheAgentTab(): Promise<void>;
|
||||
expectPanelFocusedAgent(focused: boolean): Promise<void>;
|
||||
expectPanelShowsReview(needsReview: boolean): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-tools-", title: "Composer tools" });
|
||||
const agent = await createMockAgent(workspace, "Composer tools agent");
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("composer-controls");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await run({
|
||||
openComposerToolsInExplorer: () =>
|
||||
test.step("open the Composer tools panel in the Explorer", 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: "Composer tools", exact: true })
|
||||
.click();
|
||||
await expect(page.getByText("Focused agent: mock", { exact: true })).toBeVisible();
|
||||
}),
|
||||
typeInComposer: (text, cursorAfter) =>
|
||||
test.step(`type "${text}" and put the cursor after ${cursorAfter} characters`, async () => {
|
||||
const input = prompt(page);
|
||||
await input.fill(text);
|
||||
await input.press("Home");
|
||||
for (let index = 0; index < cursorAfter; index += 1) await input.press("ArrowRight");
|
||||
}),
|
||||
insertChecklistFromPanel: () =>
|
||||
test.step("press the panel's button", () =>
|
||||
page
|
||||
.getByRole("button", { name: "Insert checklist into the focused agent", exact: true })
|
||||
.click()),
|
||||
expectComposerText: (text) =>
|
||||
test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
|
||||
markForReviewFromCommandCenter: () =>
|
||||
test.step("run the Command Center item", () =>
|
||||
runCommand(page, "Composer controls: mark for review")),
|
||||
focusANewAgentTab: () =>
|
||||
test.step("focus a new agent tab, which is a draft", () => createAgentTabFromMenu(page)),
|
||||
focusTheAgentTab: () =>
|
||||
test.step("focus the running agent's tab again", () =>
|
||||
page
|
||||
.getByTestId(`workspace-tab-agent_${agent.id}`)
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 13 } })),
|
||||
expectPanelFocusedAgent: (focused) =>
|
||||
test.step(`the panel ${focused ? "names" : "has no"} focused agent`, async () => {
|
||||
await expect(page.getByText("Focused agent: mock", { exact: true })).toHaveCount(
|
||||
focused ? 1 : 0,
|
||||
);
|
||||
await expect(page.getByText("No agent is focused", { exact: true })).toHaveCount(
|
||||
focused ? 0 : 1,
|
||||
);
|
||||
}),
|
||||
expectPanelShowsReview: (needsReview) =>
|
||||
test.step(`the panel's snapshot says needs review: ${needsReview}`, () =>
|
||||
expect(
|
||||
page.getByText(`Needs review: ${needsReview ? "yes" : "no"}`, { exact: true }),
|
||||
).toBeVisible()),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("composer-controls-example");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin that names an agent by id from a Command Center item, while another agent is open: the
|
||||
* target's tab is not what the plugin acts on.
|
||||
*/
|
||||
export async function withPluginWritingToAnotherAgent(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openTheOtherAgent(): Promise<void>;
|
||||
insertFromCommandCenter(): Promise<void>;
|
||||
openTargetAgent(): Promise<void>;
|
||||
expectComposerText(text: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-ref-", title: "Composer ref" });
|
||||
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-ref-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "composer-ref", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`export default function contribute(client) {
|
||||
client.addCommandCenterItem({
|
||||
id: "note",
|
||||
title: "Composer ref: add a note",
|
||||
icon: "Flag",
|
||||
context: "global",
|
||||
onSelect({ composers }) {
|
||||
for (const composer of composers({ agentId: ${JSON.stringify(target.id)} })) composer.insertText("A note from a plugin");
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await run({
|
||||
openTheOtherAgent: () =>
|
||||
test.step("open a different agent", () =>
|
||||
openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: other.id })),
|
||||
insertFromCommandCenter: () =>
|
||||
test.step("run the plugin's Command Center item", () =>
|
||||
runCommand(page, "Composer ref: add a note")),
|
||||
openTargetAgent: () =>
|
||||
test.step("open the agent the plugin wrote to", async () => {
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, target.id));
|
||||
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
|
||||
}),
|
||||
expectComposerText: (text) =>
|
||||
test.step("its composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("composer-ref");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export { CHECKLIST };
|
||||
@@ -0,0 +1,268 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it } 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";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { createAgentComposer } from "./index";
|
||||
|
||||
const serverId = "host-1";
|
||||
const agentId = "agent-1";
|
||||
const draftKey = buildDraftStoreKey({ serverId, agentId });
|
||||
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "opus",
|
||||
label: "Opus",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4",
|
||||
label: "Sonnet 4",
|
||||
aliases: ["sonnet"],
|
||||
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function agent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
serverId,
|
||||
id: agentId,
|
||||
provider: "claude",
|
||||
status: "idle",
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: new Date("2026-11-04T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-11-04T10:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date("2026-11-04T10:00:00.000Z"),
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
cwd: "/repo",
|
||||
workspaceId: "workspace-1",
|
||||
model: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
async getProvidersSnapshot() {
|
||||
return { entries, generatedAt: "2026-11-04T10:00:00.000Z", requestId: "snapshot" };
|
||||
},
|
||||
async setAgentModel(id: string, model: string | null) {
|
||||
calls.push(`model:${id}:${model}`);
|
||||
if (model === "nope") throw new Error("Model 'nope' is not available for provider 'claude'");
|
||||
},
|
||||
async setAgentMetadata(id: string, patch: unknown) {
|
||||
calls.push(`metadata:${id}:${JSON.stringify(patch)}`);
|
||||
},
|
||||
async setAgentOptions(id: string, patch: unknown) {
|
||||
calls.push(`options:${id}:${JSON.stringify(patch)}`);
|
||||
},
|
||||
async setAgentMode(id: string, mode: string) {
|
||||
calls.push(`mode:${id}:${mode}`);
|
||||
return null;
|
||||
},
|
||||
async setAgentThinkingOption(id: string, thinking: string) {
|
||||
calls.push(`thinking:${id}:${thinking}`);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return { client: client as unknown as DaemonClient, calls };
|
||||
}
|
||||
|
||||
function openSession(running: Agent) {
|
||||
const { client, calls } = fakeClient();
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { providersSnapshot: true },
|
||||
} as never);
|
||||
store.setAgents(serverId, new Map([[running.id, running]]));
|
||||
return calls;
|
||||
}
|
||||
|
||||
async function until(read: () => unknown, expected: unknown) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (Object.is(read(), expected)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
expect(read()).toBe(expected);
|
||||
}
|
||||
|
||||
function composer() {
|
||||
return createAgentComposer(serverId, agentId, { queryClient: new QueryClient() });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDraftStore.setState({ drafts: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
});
|
||||
|
||||
describe("agent composer", () => {
|
||||
it("keeps the prompt in the stored draft while no composer is mounted", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
model.insertText("Review ");
|
||||
model.insertText("this diff.\n");
|
||||
expect(model.getState().prompt).toBe("Review this diff.\n");
|
||||
expect(useDraftStore.getState().getDraftInput(draftKey)?.text).toBe("Review this diff.\n");
|
||||
model.setPrompt("Start over");
|
||||
expect(model.getState().prompt).toBe("Start over");
|
||||
});
|
||||
|
||||
it("sends prompt commands through the mounted composer while it is mounted", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
const mounted: string[] = [];
|
||||
const detach = model.attachInput({
|
||||
setText: (text) => mounted.push(`set:${text}`),
|
||||
insertText: (text) => mounted.push(`insert:${text}`),
|
||||
});
|
||||
model.insertText("at the cursor");
|
||||
model.setPrompt("replaced");
|
||||
expect(mounted).toEqual(["insert:at the cursor", "set:replaced"]);
|
||||
expect(useDraftStore.getState().getDraftInput(draftKey)).toBeUndefined();
|
||||
detach();
|
||||
model.insertText("stored");
|
||||
expect(model.getState().prompt).toBe("stored");
|
||||
});
|
||||
|
||||
it("notifies subscribers of a stored prompt and returns the same state until something changes", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
let notified = 0;
|
||||
const stop = model.subscribe(() => {
|
||||
notified += 1;
|
||||
});
|
||||
const before = model.getState();
|
||||
expect(model.getState()).toBe(before);
|
||||
model.insertText("x");
|
||||
expect(notified).toBeGreaterThan(0);
|
||||
expect(model.getState()).not.toBe(before);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("adds and removes attachments in the stored draft", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
const attachment = {
|
||||
kind: "plugin_resource" as const,
|
||||
pluginId: "review",
|
||||
sourceId: "issues",
|
||||
sourceTitle: "Issues",
|
||||
sourceIcon: "CircleDot",
|
||||
item: {
|
||||
id: "1",
|
||||
identifier: "ENG-1",
|
||||
title: "Title",
|
||||
subtitle: "Open",
|
||||
url: "https://example.com/1",
|
||||
text: "Issue ENG-1: Title",
|
||||
resourceType: "issue",
|
||||
},
|
||||
};
|
||||
model.changeAttachments((current) => [...current, attachment]);
|
||||
expect(model.getState().attachments).toHaveLength(1);
|
||||
model.changeAttachments(() => []);
|
||||
expect(model.getState().attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads mode, options and metadata from the agent, and sends commands to the daemon", async () => {
|
||||
const calls = openSession(
|
||||
agent({ currentModeId: "plan", options: { effort: "high" }, metadata: { flag: true } }),
|
||||
);
|
||||
const model = composer();
|
||||
expect(model.getState()).toMatchObject({
|
||||
context: "agent",
|
||||
provider: "claude",
|
||||
mode: "plan",
|
||||
options: { effort: "high" },
|
||||
metadata: { flag: true },
|
||||
});
|
||||
await model.setMetadata({ flag: null });
|
||||
await model.setOptions({ effort: "low" });
|
||||
await model.setMode("default");
|
||||
expect(calls).toEqual([
|
||||
'metadata:agent-1:{"flag":null}',
|
||||
'options:agent-1:{"effort":"low"}',
|
||||
"mode:agent-1:default",
|
||||
]);
|
||||
await expect(model.setProvider("codex")).rejects.toThrow(
|
||||
"A running agent cannot change provider.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unlisted model with the daemon's message", async () => {
|
||||
openSession(agent());
|
||||
await expect(composer().setModel("nope")).rejects.toThrow(
|
||||
"Model 'nope' is not available for provider 'claude'",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an alias of a listed model", { runtimeInfo: { model: "sonnet" } }, "claude-sonnet-4", "low"],
|
||||
["no reported model", { model: null }, "opus", "high"],
|
||||
["the configured model", { model: "opus", thinkingOptionId: "low" }, "opus", "low"],
|
||||
] as const)(
|
||||
"reads the model and thinking the picker shows for %s, once the catalog loads",
|
||||
async (_name, overrides, expectedModel, expectedThinking) => {
|
||||
const running = agent(overrides as unknown as Partial<Agent>);
|
||||
openSession(running);
|
||||
const model = composer();
|
||||
expect(model.getState()).toMatchObject({ model: null, thinking: null });
|
||||
const stop = model.subscribe(() => {});
|
||||
await until(() => model.getState().model, expectedModel);
|
||||
const picker = resolveAgentModelSelection({
|
||||
models: filterSelectableModels(entries[0]?.models ?? null),
|
||||
runtimeModelId: running.runtimeInfo?.model ?? null,
|
||||
configuredModelId: running.model,
|
||||
runtimeThinkingOptionId: running.runtimeInfo?.thinkingOptionId ?? null,
|
||||
explicitThinkingOptionId: running.thinkingOptionId,
|
||||
});
|
||||
expect(model.getState()).toMatchObject({
|
||||
model: picker.activeModelId,
|
||||
thinking: picker.selectedThinkingId,
|
||||
});
|
||||
expect(model.getState().thinking).toBe(expectedThinking);
|
||||
stop();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { providersSnapshotQuery } from "@/data/providers-snapshot";
|
||||
import { replicaQueryOptions } from "@/data/query";
|
||||
import { queryClient as singletonQueryClient } from "@/data/query-client";
|
||||
import type { ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
|
||||
import {
|
||||
resolveAgentModelSelection,
|
||||
resolveProviderSnapshotEntry,
|
||||
} from "@/composer/agent-controls/utils";
|
||||
import type { 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";
|
||||
|
||||
/** The mounted composer's input. While attached, prompt commands go through it. */
|
||||
export interface MountedComposerInput {
|
||||
setText(text: string): void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
insertText(text: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer of a running agent. It exists whether or not the agent's tab is open: the prompt
|
||||
* and attachments are the agent's draft in the draft store, its settings are the session store's
|
||||
* agent, and the model and thinking level resolve through the provider catalog exactly as the
|
||||
* model picker does.
|
||||
*/
|
||||
export interface AgentComposer extends ComposerModel {
|
||||
/** The mounted composer plugs in cursor-aware prompt commands for as long as it is mounted. */
|
||||
attachInput(input: MountedComposerInput): () => void;
|
||||
}
|
||||
|
||||
interface AgentComposerDeps {
|
||||
queryClient: QueryClient;
|
||||
cache?: ProviderSnapshotCache;
|
||||
}
|
||||
|
||||
type Catalog = { status: "loading" } | { status: "ready"; entries: ProviderSnapshotEntry[] | null };
|
||||
|
||||
const NO_VALUES = Object.freeze({});
|
||||
const NO_ATTACHMENTS: readonly UserComposerAttachment[] = Object.freeze([]);
|
||||
|
||||
export function createAgentComposer(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
deps: AgentComposerDeps,
|
||||
): AgentComposer {
|
||||
const draftKey = buildDraftStoreKey({ serverId, agentId });
|
||||
const listeners = new Set<() => void>();
|
||||
const catalogFailures = new Set<string>();
|
||||
const catalogRequests = new Set<string>();
|
||||
let mounted: MountedComposerInput | null = null;
|
||||
let last: {
|
||||
agent: Agent | null;
|
||||
draft: DraftRecord | undefined;
|
||||
catalog: Catalog;
|
||||
state: ComposerState;
|
||||
} | null = null;
|
||||
|
||||
function readAgent(): Agent | null {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
function client() {
|
||||
const connected = useSessionStore.getState().sessions[serverId]?.client;
|
||||
if (!connected) throw new Error("The host is not connected.");
|
||||
return connected;
|
||||
}
|
||||
|
||||
function catalogQuery(cwd: string) {
|
||||
return replicaQueryOptions(
|
||||
providersSnapshotQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
client,
|
||||
queryClient: deps.queryClient,
|
||||
cache: deps.cache,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
async function loadCatalog(query: ReturnType<typeof catalogQuery>, key: string) {
|
||||
try {
|
||||
await deps.queryClient.ensureQueryData(query);
|
||||
} catch {
|
||||
catalogFailures.add(key);
|
||||
} finally {
|
||||
catalogRequests.delete(key);
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
function readCatalog(agent: Agent): Catalog {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
// The picker never loads a catalog from a host without it and shows the configured model.
|
||||
if (session?.serverInfo?.features?.providersSnapshot !== true) {
|
||||
return { status: "ready", entries: null };
|
||||
}
|
||||
const query = catalogQuery(agent.cwd);
|
||||
const data = deps.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(
|
||||
query.queryKey,
|
||||
);
|
||||
if (data) return { status: "ready", entries: data.entries };
|
||||
const key = JSON.stringify(query.queryKey);
|
||||
if (catalogFailures.has(key)) return { status: "ready", entries: null };
|
||||
if (!session.client) return { status: "loading" };
|
||||
if (!catalogRequests.has(key)) {
|
||||
catalogRequests.add(key);
|
||||
void loadCatalog(query, key);
|
||||
}
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
function selectModel(agent: Agent, catalog: Catalog) {
|
||||
if (catalog.status !== "ready") return null;
|
||||
return resolveAgentModelSelection({
|
||||
models: filterSelectableModels(
|
||||
resolveProviderSnapshotEntry(catalog.entries ?? undefined, agent.provider)?.models ?? null,
|
||||
),
|
||||
runtimeModelId: agent.runtimeInfo?.model ?? null,
|
||||
configuredModelId: agent.model,
|
||||
runtimeThinkingOptionId: agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
explicitThinkingOptionId: agent.thinkingOptionId,
|
||||
});
|
||||
}
|
||||
|
||||
function buildState(
|
||||
agent: Agent | null,
|
||||
catalog: Catalog,
|
||||
record: DraftRecord | undefined,
|
||||
): ComposerState {
|
||||
const draft = toDraftInputIfReady(record);
|
||||
const selection = agent ? selectModel(agent, catalog) : null;
|
||||
return {
|
||||
context: "agent",
|
||||
prompt: draft?.text ?? "",
|
||||
attachments: draft?.attachments ?? NO_ATTACHMENTS,
|
||||
provider: agent?.provider ?? null,
|
||||
// Null until the catalog lands, so a read never changes from the configured id to the
|
||||
// catalog's canonical one.
|
||||
model: selection?.activeModelId ?? null,
|
||||
mode: agent?.currentModeId ?? null,
|
||||
thinking: selection?.selectedThinkingId ?? null,
|
||||
options: agent?.options ?? NO_VALUES,
|
||||
metadata: agent?.metadata ?? NO_VALUES,
|
||||
};
|
||||
}
|
||||
|
||||
function readDraft(): DraftRecord | undefined {
|
||||
const record = useDraftStore.getState().drafts[draftKey];
|
||||
return record?.lifecycle === "active" ? record : undefined;
|
||||
}
|
||||
|
||||
function getState(): ComposerState {
|
||||
const agent = readAgent();
|
||||
const draft = readDraft();
|
||||
const catalog = agent ? readCatalog(agent) : ({ status: "loading" } as const);
|
||||
if (
|
||||
last &&
|
||||
last.agent === agent &&
|
||||
last.draft === draft &&
|
||||
sameCatalog(last.catalog, catalog)
|
||||
) {
|
||||
return last.state;
|
||||
}
|
||||
last = { agent, draft, catalog, state: buildState(agent, catalog, draft) };
|
||||
return last.state;
|
||||
}
|
||||
|
||||
return {
|
||||
getState,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
getState();
|
||||
const stops = [
|
||||
useSessionStore.subscribe(notify),
|
||||
useDraftStore.subscribe(notify),
|
||||
deps.queryClient.getQueryCache().subscribe(notify),
|
||||
];
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
for (const stop of stops) stop();
|
||||
};
|
||||
},
|
||||
attachInput(input) {
|
||||
mounted = input;
|
||||
return () => {
|
||||
if (mounted === input) mounted = null;
|
||||
};
|
||||
},
|
||||
setPrompt(text) {
|
||||
if (mounted) mounted.setText(text);
|
||||
else useDraftStore.getState().editDraftText({ draftKey, text });
|
||||
},
|
||||
insertText(text) {
|
||||
if (mounted) mounted.insertText(text);
|
||||
else {
|
||||
const current = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
|
||||
useDraftStore.getState().editDraftText({ draftKey, text: current + text });
|
||||
}
|
||||
},
|
||||
changeAttachments(updater) {
|
||||
useDraftStore.getState().updateDraftInput({
|
||||
draftKey,
|
||||
update: (current) => ({ ...current, attachments: updater(current.attachments) }),
|
||||
});
|
||||
},
|
||||
async setProvider() {
|
||||
throw new Error("A running agent cannot change provider.");
|
||||
},
|
||||
async setModel(modelId) {
|
||||
await client().setAgentModel(agentId, modelId);
|
||||
},
|
||||
async setMode(modeId) {
|
||||
await client().setAgentMode(agentId, modeId);
|
||||
},
|
||||
async setThinking(thinkingOptionId) {
|
||||
await client().setAgentThinkingOption(agentId, thinkingOptionId);
|
||||
},
|
||||
async setOptions(patch) {
|
||||
await client().setAgentOptions(agentId, patch);
|
||||
},
|
||||
async setMetadata(patch) {
|
||||
await client().setAgentMetadata(agentId, patch);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sameCatalog(left: Catalog, right: Catalog): boolean {
|
||||
return (
|
||||
left.status === right.status &&
|
||||
(left.status === "loading" || left.entries === (right as typeof left).entries)
|
||||
);
|
||||
}
|
||||
|
||||
const composers = new Map<string, AgentComposer>();
|
||||
|
||||
/** The composer of a running agent; the same one for every caller. */
|
||||
export function agentComposer(serverId: string, agentId: string): AgentComposer {
|
||||
const key = `${serverId}:${agentId}`;
|
||||
let composer = composers.get(key);
|
||||
if (!composer) {
|
||||
composer = createAgentComposer(serverId, agentId, { queryClient: singletonQueryClient });
|
||||
composers.set(key, composer);
|
||||
}
|
||||
return composer;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { isUnlistedModel } from "@/provider-selection/resolve-agent-form";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
@@ -45,6 +44,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife
|
||||
import {
|
||||
getAgentControlHintKey,
|
||||
resolveAgentModelSelection,
|
||||
resolveProviderSnapshotEntry,
|
||||
} from "@/composer/agent-controls/utils";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { readMeasuredWidth } from "@/hooks/use-container-width";
|
||||
@@ -74,7 +74,10 @@ import { useAgentControlItems } from "@/composer/agent-controls/control-items";
|
||||
import { CompactModelSheet } from "@/composer/agent-controls/model-sheet";
|
||||
import { useComposerSheetBridge } from "@/composer/agent-controls/sheet-bridge";
|
||||
import { draftComposerAgent } from "@/composer/draft/composer-agent";
|
||||
import { ComposerAgentProvider, type ComposerAgent } from "@/composer/session";
|
||||
import { agentComposer } from "@/composer/agent-composer";
|
||||
import { composerModelFromHalves } from "@/composer/halves-model";
|
||||
import { ComposerModelProvider } from "@/composer/model";
|
||||
import { ComposerAgentProvider, useComposerInput } from "@/composer/session";
|
||||
import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items";
|
||||
import {
|
||||
useAgentProfileEditor,
|
||||
@@ -94,8 +97,6 @@ interface AgentControlOption {
|
||||
|
||||
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
|
||||
const EMPTY_OPTIONS: ProviderOptions = {};
|
||||
const EMPTY_METADATA: Record<string, JsonValue> = {};
|
||||
const neverLocked = () => false;
|
||||
|
||||
interface ControlledAgentControlsProps {
|
||||
provider: string;
|
||||
@@ -375,18 +376,8 @@ function selectAgentControlsSlice(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSnapshotSelectedEntry(
|
||||
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
|
||||
agentProvider: string | undefined,
|
||||
) {
|
||||
if (!snapshotEntries || !agentProvider) {
|
||||
return null;
|
||||
}
|
||||
return snapshotEntries.find((e) => e.provider === agentProvider) ?? null;
|
||||
}
|
||||
|
||||
function resolveSnapshotModeIds(
|
||||
entry: ReturnType<typeof resolveSnapshotSelectedEntry>,
|
||||
entry: ReturnType<typeof resolveProviderSnapshotEntry>,
|
||||
): string[] | null {
|
||||
if (entry?.status !== "ready" || !entry.modes) {
|
||||
return null;
|
||||
@@ -982,7 +973,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
} = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
|
||||
|
||||
const snapshotSelectedEntry = useMemo(
|
||||
() => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider),
|
||||
() => resolveProviderSnapshotEntry(snapshotEntries, agent?.provider),
|
||||
[snapshotEntries, agent?.provider],
|
||||
);
|
||||
|
||||
@@ -1136,45 +1127,20 @@ export const AgentControls = memo(function AgentControls({
|
||||
|
||||
const agentOptionControls = useAgentOptionControls(agent, handleSetOption);
|
||||
|
||||
const composerAgent = useMemo<ComposerAgent | null>(() => {
|
||||
if (!agent || !client) return null;
|
||||
return {
|
||||
context: "agent",
|
||||
locked: false,
|
||||
isLocked: neverLocked,
|
||||
provider: agent.provider,
|
||||
model: activeModelId,
|
||||
mode: modeControl?.selectedModeId ?? null,
|
||||
thinking: modelSelection.selectedThinkingId,
|
||||
options: agent.options ?? EMPTY_OPTIONS,
|
||||
metadata: agent.metadata ?? EMPTY_METADATA,
|
||||
setProvider: () => Promise.reject(new Error("A running agent cannot change provider.")),
|
||||
setModel: async (modelId) => {
|
||||
if (isUnlistedModel(models, modelId)) throw new Error(`Unknown model: ${modelId}`);
|
||||
await client.setAgentModel(agentId, modelId);
|
||||
},
|
||||
setMode: async (modeId) => {
|
||||
await client.setAgentMode(agentId, modeId);
|
||||
},
|
||||
setThinking: async (thinkingOptionId) => {
|
||||
await client.setAgentThinkingOption(agentId, thinkingOptionId);
|
||||
},
|
||||
setOptions: async (patch) => {
|
||||
await client.setAgentOptions(agentId, patch);
|
||||
},
|
||||
setMetadata: async (patch) => {
|
||||
await client.setAgentMetadata(agentId, patch);
|
||||
},
|
||||
};
|
||||
}, [
|
||||
activeModelId,
|
||||
agent,
|
||||
agentId,
|
||||
client,
|
||||
modeControl?.selectedModeId,
|
||||
modelSelection.selectedThinkingId,
|
||||
models,
|
||||
]);
|
||||
// The agent's composer exists apart from this component; while this is mounted, prompt
|
||||
// commands go through the input the user is typing in.
|
||||
const composer = useMemo(() => agentComposer(serverId, agentId), [agentId, serverId]);
|
||||
const composerInput = useComposerInput();
|
||||
useEffect(
|
||||
() =>
|
||||
composerInput
|
||||
? composer.attachInput({
|
||||
setText: composerInput.setText,
|
||||
insertText: composerInput.insertText,
|
||||
})
|
||||
: undefined,
|
||||
[composer, composerInput],
|
||||
);
|
||||
|
||||
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
|
||||
() => ({
|
||||
@@ -1237,7 +1203,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposerAgentProvider value={composerAgent}>
|
||||
<ComposerModelProvider value={composer}>
|
||||
{commandCenterRegistration}
|
||||
{profileEditor.element}
|
||||
<ControlledAgentControls
|
||||
@@ -1265,7 +1231,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
modelSelectorServerId={serverId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
</ComposerAgentProvider>
|
||||
</ComposerModelProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1399,35 +1365,46 @@ export function DraftAgentControls({
|
||||
],
|
||||
);
|
||||
|
||||
const composerInput = useComposerInput();
|
||||
const composerModel = useMemo(
|
||||
() =>
|
||||
composerInput
|
||||
? composerModelFromHalves({ agent: composerAgent, input: composerInput })
|
||||
: null,
|
||||
[composerAgent, composerInput],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComposerAgentProvider value={composerAgent}>
|
||||
{profileEditor.element}
|
||||
<ControlledAgentControls
|
||||
provider={selectedProvider ?? ""}
|
||||
modelSelectorProviders={modelSelectorProviders}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={onSelectModel}
|
||||
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||
isModelLoading={isAllModelsLoading}
|
||||
agentProfiles={agentProfiles}
|
||||
onApplyAgentProfile={agentProfiles?.applyProfile}
|
||||
onEditAgentProfiles={handleEditAgentProfiles}
|
||||
onCreateAgentProfile={profileActions.create}
|
||||
onEditAgentProfile={profileActions.edit}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
optionControls={optionControls}
|
||||
onDropdownClose={onDropdownClose}
|
||||
onModelSelectorOpen={onModelSelectorOpen}
|
||||
onRetryModelProvider={onRetryModelProvider}
|
||||
isRetryingModelProvider={isRetryingModelProvider}
|
||||
disabled={disabled}
|
||||
modeControl={modeControl}
|
||||
modelSelectorServerId={modelSelectorServerId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
<ComposerModelProvider value={composerModel}>
|
||||
{profileEditor.element}
|
||||
<ControlledAgentControls
|
||||
provider={selectedProvider ?? ""}
|
||||
modelSelectorProviders={modelSelectorProviders}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={onSelectModel}
|
||||
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||
isModelLoading={isAllModelsLoading}
|
||||
agentProfiles={agentProfiles}
|
||||
onApplyAgentProfile={agentProfiles?.applyProfile}
|
||||
onEditAgentProfiles={handleEditAgentProfiles}
|
||||
onCreateAgentProfile={profileActions.create}
|
||||
onEditAgentProfile={profileActions.edit}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
optionControls={optionControls}
|
||||
onDropdownClose={onDropdownClose}
|
||||
onModelSelectorOpen={onModelSelectorOpen}
|
||||
onRetryModelProvider={onRetryModelProvider}
|
||||
isRetryingModelProvider={isRetryingModelProvider}
|
||||
disabled={disabled}
|
||||
modeControl={modeControl}
|
||||
modelSelectorServerId={modelSelectorServerId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
</ComposerModelProvider>
|
||||
</ComposerAgentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
useComposerAgent,
|
||||
useComposerInput,
|
||||
} from "@/composer/session";
|
||||
import { ComposerModelProvider, useComposerModel } from "@/composer/model";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-api-context";
|
||||
|
||||
/**
|
||||
* A phone's overflow draws in a sheet whose content lands at the app root, away from the
|
||||
* composer. This carries across what a control in it reads from the composer: the agent and input
|
||||
* composer. This carries across what a control in it reads from the composer: the agent, model and input
|
||||
* a plugin's `useComposer()` sits on, the layout, and the toast and query client the controls use.
|
||||
* Call it from inside the composer, outside any sheet.
|
||||
*/
|
||||
@@ -20,6 +21,7 @@ export function useComposerSheetBridge(): ContextBridge {
|
||||
const layout = useComposerControlLayout();
|
||||
const agent = useComposerAgent();
|
||||
const input = useComposerInput();
|
||||
const model = useComposerModel();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback<ContextBridge>(
|
||||
@@ -28,16 +30,18 @@ export function useComposerSheetBridge(): ContextBridge {
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerControlLayoutProvider value={layout}>
|
||||
<ComposerAgentProvider value={agent}>
|
||||
{input ? (
|
||||
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
<ComposerModelProvider value={model}>
|
||||
{input ? (
|
||||
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</ComposerModelProvider>
|
||||
</ComposerAgentProvider>
|
||||
</ComposerControlLayoutProvider>
|
||||
</ToastApiProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
[agent, input, layout, queryClient, toast],
|
||||
[agent, input, layout, model, queryClient, toast],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
import {
|
||||
@@ -49,6 +49,14 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveProviderSnapshotEntry(
|
||||
entries: readonly ProviderSnapshotEntry[] | null | undefined,
|
||||
provider: string | null | undefined,
|
||||
): ProviderSnapshotEntry | null {
|
||||
if (!entries || !provider) return null;
|
||||
return entries.find((entry) => entry.provider === provider) ?? null;
|
||||
}
|
||||
|
||||
function findModelById(
|
||||
models: AgentModelDefinition[] | null,
|
||||
modelId: string | null,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options";
|
||||
import {
|
||||
buildDraftAgentControls,
|
||||
hasDraftContent,
|
||||
resolveDraftKey,
|
||||
type DraftKeyInput,
|
||||
} from "@/composer/draft/input-draft-core";
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useDraftStore, type DraftInput } from "@/stores/draft-store";
|
||||
import { AfterPaintPublication } from "@/composer/after-paint-publication";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import type { ComposerTextSource } from "@/composer/text-source";
|
||||
@@ -142,21 +141,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
);
|
||||
|
||||
const saveDraft = useCallback(
|
||||
(
|
||||
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
},
|
||||
) => {
|
||||
const store = useDraftStore.getState();
|
||||
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
|
||||
const next = update(current);
|
||||
if (!hasDraftContent(next)) {
|
||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
return;
|
||||
}
|
||||
store.saveDraftInput({ draftKey, draft: next });
|
||||
},
|
||||
(update: (draft: DraftInput) => DraftInput) =>
|
||||
useDraftStore.getState().updateDraftInput({ draftKey, update }),
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ComposerAgent, ComposerInput } from "@/composer/session";
|
||||
import type { ComposerModel, ComposerState } from "@/composer/model";
|
||||
|
||||
const LOCKED_MESSAGE = "The composer is locked while the agent is being created.";
|
||||
|
||||
function assertUnlocked(isLocked: () => boolean): void {
|
||||
if (isLocked()) throw new Error(LOCKED_MESSAGE);
|
||||
}
|
||||
|
||||
async function whenUnlocked(agent: ComposerAgent, command: () => Promise<void>): Promise<void> {
|
||||
assertUnlocked(agent.isLocked);
|
||||
await command();
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of a draft or New workspace composer, built from the two halves its mounted
|
||||
* composer provides. Its state belongs to the render that built it, so a change is a new model;
|
||||
* only the prompt changes underneath it.
|
||||
*/
|
||||
export function composerModelFromHalves(input: {
|
||||
agent: ComposerAgent;
|
||||
input: ComposerInput;
|
||||
}): ComposerModel {
|
||||
const { agent, input: composerInput } = input;
|
||||
let last: ComposerState | null = null;
|
||||
return {
|
||||
getState() {
|
||||
const prompt = composerInput.textSource.getSnapshot();
|
||||
if (last && last.prompt === prompt) return last;
|
||||
last = {
|
||||
context: agent.context,
|
||||
prompt,
|
||||
attachments: composerInput.attachments,
|
||||
provider: agent.provider,
|
||||
model: agent.model,
|
||||
mode: agent.mode,
|
||||
thinking: agent.thinking,
|
||||
options: agent.options,
|
||||
metadata: agent.metadata,
|
||||
};
|
||||
return last;
|
||||
},
|
||||
subscribe: composerInput.textSource.subscribe,
|
||||
setPrompt(text) {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.setText(text);
|
||||
},
|
||||
insertText(text) {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.insertText(text);
|
||||
},
|
||||
changeAttachments(updater) {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.changeAttachments(updater);
|
||||
},
|
||||
setProvider: (provider) => whenUnlocked(agent, () => agent.setProvider(provider)),
|
||||
setModel: (modelId) => whenUnlocked(agent, () => agent.setModel(modelId)),
|
||||
setMode: (modeId) => whenUnlocked(agent, () => agent.setMode(modeId)),
|
||||
setThinking: (thinkingOptionId) =>
|
||||
whenUnlocked(agent, () => agent.setThinking(thinkingOptionId)),
|
||||
setOptions: (patch) => whenUnlocked(agent, () => agent.setOptions(patch)),
|
||||
setMetadata: (patch) => whenUnlocked(agent, () => agent.setMetadata(patch)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
|
||||
/** Everything a plugin can read of a composer. A new object whenever any of it changes. */
|
||||
export interface ComposerState {
|
||||
/** `"agent"`: a running agent. `"draft"`: a new agent in a workspace. `"new-workspace"`: the New workspace screen. */
|
||||
context: "agent" | "draft" | "new-workspace";
|
||||
prompt: string;
|
||||
attachments: readonly UserComposerAttachment[];
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
mode: string | null;
|
||||
thinking: string | null;
|
||||
/** The options the agent runs with, defaults included. */
|
||||
options: ProviderOptions;
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* is locked, a running agent keeps its provider). A running agent's model lives in the stores and
|
||||
* exists whether or not its tab is open; a draft's is built from its mounted composer.
|
||||
*/
|
||||
export interface ComposerModel {
|
||||
getState(): ComposerState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
setPrompt(text: string): void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
insertText(text: string): void;
|
||||
changeAttachments(updater: (current: UserComposerAttachment[]) => UserComposerAttachment[]): void;
|
||||
setProvider(provider: string): Promise<void>;
|
||||
setModel(modelId: string): Promise<void>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
setThinking(thinkingOptionId: string): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the agent's own value. */
|
||||
setOptions(patch: ProviderOptions): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the key. */
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
}
|
||||
|
||||
const ComposerModelContext = createContext<ComposerModel | null>(null);
|
||||
|
||||
export const ComposerModelProvider = ComposerModelContext.Provider;
|
||||
|
||||
/** The model of the composer this control sits in. */
|
||||
export function useComposerModel(): ComposerModel | null {
|
||||
return useContext(ComposerModelContext);
|
||||
}
|
||||
@@ -119,6 +119,36 @@ export async function fetchProvidersSnapshot(input: {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* The providers snapshot as a replica query: its key and its fetch together, so the model picker
|
||||
* and anything else that needs the catalog read the same cache entry. `client` is asked when the
|
||||
* fetch runs.
|
||||
*/
|
||||
export function providersSnapshotQuery(input: {
|
||||
serverId: string;
|
||||
cwd?: string | null;
|
||||
client: () => SnapshotClient;
|
||||
queryClient?: QueryClient;
|
||||
cache?: ProviderSnapshotCache;
|
||||
}) {
|
||||
const cwd = normalizeProvidersSnapshotCwd(input.cwd);
|
||||
return {
|
||||
queryKey: providersSnapshotQueryKey(input.serverId, cwd),
|
||||
// The content-hash cache already owns catalog and model identity.
|
||||
structuralSharing: false as const,
|
||||
pushEvent: "providers_snapshot_update",
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
fetchProvidersSnapshot({
|
||||
client: input.client(),
|
||||
serverId: input.serverId,
|
||||
cwd,
|
||||
queryClient: input.queryClient,
|
||||
cache: input.cache,
|
||||
signal,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAndApplyProvidersSnapshot(input: {
|
||||
client: SnapshotClient & Pick<DaemonClient, "refreshProvidersSnapshot">;
|
||||
queryClient: QueryClient;
|
||||
|
||||
@@ -63,7 +63,7 @@ export function useFetchQueries<TData>(
|
||||
return useQueries({ queries: inputs.map((input) => fetchQueryOptions(input)) });
|
||||
}
|
||||
|
||||
function replicaQueryOptions<
|
||||
export function replicaQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useReplicaQuery } from "@/data/query";
|
||||
import { queryClient as singletonQueryClient } from "@/data/query-client";
|
||||
import {
|
||||
normalizeProvidersSnapshotCwd,
|
||||
providersSnapshotQuery,
|
||||
providersSnapshotQueryKey,
|
||||
providersSnapshotQueryRoot,
|
||||
fetchProvidersSnapshot,
|
||||
@@ -78,17 +79,16 @@ export function useProvidersSnapshot(
|
||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
|
||||
|
||||
const snapshotQuery = useReplicaQuery({
|
||||
queryKey,
|
||||
// The content-hash cache already owns catalog and model identity.
|
||||
structuralSharing: false,
|
||||
...providersSnapshotQuery({
|
||||
serverId: serverId ?? "",
|
||||
cwd,
|
||||
client: () => {
|
||||
if (!client || !serverId) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
return client;
|
||||
},
|
||||
queryClient,
|
||||
}),
|
||||
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
|
||||
pushEvent: "providers_snapshot_update",
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!client || !serverId) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return fetchProvidersSnapshot({ client, serverId, cwd, queryClient, signal });
|
||||
},
|
||||
});
|
||||
|
||||
const refreshMutation = useMutation({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginComposersSource } from "./composer-items/source";
|
||||
import { callPluginRpc } from "@getpaseo/plugin/client/host";
|
||||
import type {
|
||||
PluginAgentCommandContext,
|
||||
@@ -40,6 +41,7 @@ export function createPluginCapabilities(
|
||||
navigation.openSettings(plugin.id, screenId);
|
||||
},
|
||||
openScreen,
|
||||
composers: (selector) => pluginComposersSource(plugin).resolve(selector),
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
openSurface: (screenId) => openScreen({ screenId }),
|
||||
};
|
||||
|
||||
@@ -149,6 +149,7 @@ function harness() {
|
||||
},
|
||||
openScreen() {},
|
||||
openSurface() {},
|
||||
composers: () => [],
|
||||
async playAudio() {},
|
||||
openPanel() {},
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ComposerControlSurfaceProvider,
|
||||
type ComposerControlSurface,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import { useComposerAgent, useComposerInput } from "@/composer/session";
|
||||
import { useComposerModel } from "@/composer/model";
|
||||
import { PluginItemList, pluginThemeMapping, usePluginItemHost } from "../item-host";
|
||||
import { PluginEnvironmentProvider } from "../popover";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
@@ -61,19 +61,16 @@ function PluginComposerItemsHost({
|
||||
plugin: InstalledPlugin;
|
||||
theme: PluginHostProps["theme"];
|
||||
}) {
|
||||
const agent = useComposerAgent();
|
||||
const composerInput = useComposerInput();
|
||||
const model = useComposerModel();
|
||||
const { environment, props } = usePluginItemHost(plugin, theme);
|
||||
const textSource = composerInput?.textSource;
|
||||
const prompt = useSyncExternalStore(
|
||||
textSource?.subscribe ?? subscribeNothing,
|
||||
textSource?.getSnapshot ?? emptyText,
|
||||
textSource?.getSnapshot ?? emptyText,
|
||||
const state = useSyncExternalStore(
|
||||
model?.subscribe ?? subscribeNothing,
|
||||
model ? model.getState : noState,
|
||||
model ? model.getState : noState,
|
||||
);
|
||||
const composer = useMemo(
|
||||
() =>
|
||||
agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null,
|
||||
[agent, composerInput, plugin, prompt],
|
||||
() => (model && state ? toPluginComposer({ plugin, model, state }) : null),
|
||||
[model, plugin, state],
|
||||
);
|
||||
if (!composer) return null;
|
||||
return (
|
||||
@@ -91,6 +88,6 @@ function subscribeNothing() {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
function emptyText() {
|
||||
return "";
|
||||
function noState(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { z } from "zod";
|
||||
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 type { InstalledPlugin } from "../types";
|
||||
import { toPluginComposer } from "./model";
|
||||
@@ -112,16 +113,17 @@ function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
|
||||
return { input, state };
|
||||
}
|
||||
|
||||
function compose(agent: ComposerAgent, input: ComposerInput) {
|
||||
const model = composerModelFromHalves({ agent, input });
|
||||
return toPluginComposer({ plugin: installed(), model, state: model.getState() });
|
||||
}
|
||||
|
||||
describe("toPluginComposer", () => {
|
||||
it("reads the agent the composer configures and the context it sits in", () => {
|
||||
const { agent } = fakeAgent("new-workspace");
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "hello",
|
||||
});
|
||||
const { input, state } = fakeInput();
|
||||
state.text = "hello";
|
||||
const composer = compose(agent, input);
|
||||
expect(composer.context).toBe("new-workspace");
|
||||
expect(composer.prompt).toBe("hello");
|
||||
expect(composer.agent).toEqual({
|
||||
@@ -139,12 +141,7 @@ describe("toPluginComposer", () => {
|
||||
async (context) => {
|
||||
const { agent, calls } = fakeAgent(context);
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
const composer = compose(agent, input);
|
||||
await composer.setModel("gpt");
|
||||
await composer.setMode("plan");
|
||||
await composer.setThinking("high");
|
||||
@@ -163,35 +160,20 @@ describe("toPluginComposer", () => {
|
||||
it("lets a draft change provider and tells a running agent it cannot", async () => {
|
||||
const { input } = fakeInput();
|
||||
const draft = fakeAgent("draft");
|
||||
await toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent: draft.agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
}).setProvider("codex");
|
||||
await compose(draft.agent, input).setProvider("codex");
|
||||
expect(draft.calls).toEqual(["provider:codex"]);
|
||||
|
||||
const running = fakeAgent("agent");
|
||||
await expect(
|
||||
toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent: running.agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
}).setProvider("codex"),
|
||||
).rejects.toThrow("A running agent cannot change provider.");
|
||||
await expect(compose(running.agent, input).setProvider("codex")).rejects.toThrow(
|
||||
"A running agent cannot change provider.",
|
||||
);
|
||||
expect(running.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("changes the prompt and inserts at the cursor", () => {
|
||||
const { agent } = fakeAgent("draft");
|
||||
const { input, state } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
const composer = compose(agent, input);
|
||||
composer.setPrompt("a");
|
||||
composer.insertText("b");
|
||||
expect(state.text).toBe("ab");
|
||||
@@ -200,12 +182,7 @@ describe("toPluginComposer", () => {
|
||||
it("refuses every prompt and agent command while the composer is locked", async () => {
|
||||
const { agent, calls } = fakeAgent("new-workspace", true);
|
||||
const { input, state } = fakeInput([], true);
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
const composer = compose(agent, input);
|
||||
|
||||
expect(() => composer.setPrompt("a")).toThrow("The composer is locked");
|
||||
expect(() => composer.insertText("b")).toThrow("The composer is locked");
|
||||
@@ -225,12 +202,7 @@ describe("toPluginComposer", () => {
|
||||
it("refuses a command a plugin kept from before the composer locked", async () => {
|
||||
const { agent } = fakeAgent("new-workspace");
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
const composer = compose(agent, input);
|
||||
|
||||
// The plugin's action was awaiting an RPC when the user submitted.
|
||||
const lock = { locked: true };
|
||||
@@ -251,12 +223,7 @@ describe("toPluginComposer", () => {
|
||||
const { agent } = fakeAgent("draft");
|
||||
agent.metadata = { nested: { list: [1] } };
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
const composer = compose(agent, input);
|
||||
|
||||
expect(Object.isFrozen(composer.agent.options)).toBe(true);
|
||||
expect(Object.isFrozen(composer.agent.metadata)).toBe(true);
|
||||
@@ -276,23 +243,13 @@ describe("toPluginComposer", () => {
|
||||
item: issue,
|
||||
};
|
||||
const { input, state } = fakeInput([other]);
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
const composer = compose(agent, input);
|
||||
|
||||
composer.addAttachment({ sourceId: "issues", item: issue });
|
||||
composer.addAttachment({ sourceId: "issues", item: issue });
|
||||
expect(state.attachments).toHaveLength(2);
|
||||
|
||||
const next = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
const next = compose(agent, input);
|
||||
expect(next.attachments).toEqual([{ sourceId: "issues", item: issue }]);
|
||||
|
||||
composer.removeAttachment({ sourceId: "issues", itemId: "ISS-1" });
|
||||
@@ -302,12 +259,7 @@ describe("toPluginComposer", () => {
|
||||
it("rejects an attachment from a source the plugin did not register", () => {
|
||||
const { agent } = fakeAgent("agent");
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
const composer = compose(agent, input);
|
||||
expect(() => composer.addAttachment({ sourceId: "nope", item: issue })).toThrow(
|
||||
"Unknown attachment source: nope",
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginComposer } from "@getpaseo/plugin/client";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ComposerAgent, ComposerInput } from "@/composer/session";
|
||||
import type { ComposerModel, ComposerState } from "@/composer/model";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginResourceAttachment } from "../attachments/model";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
@@ -20,55 +20,36 @@ function isPluginAttachment(
|
||||
);
|
||||
}
|
||||
|
||||
const LOCKED_MESSAGE = "The composer is locked while the agent is being created.";
|
||||
|
||||
function assertUnlocked(isLocked: () => boolean): void {
|
||||
if (isLocked()) throw new Error(LOCKED_MESSAGE);
|
||||
}
|
||||
|
||||
async function whenUnlocked(agent: ComposerAgent, command: () => Promise<void>): Promise<void> {
|
||||
assertUnlocked(agent.isLocked);
|
||||
await command();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a plugin sees of the composer it sits in. `agent` and `input` are the composer's own
|
||||
* halves, so the plugin's commands reach the same models the built-in controls use.
|
||||
* What a plugin sees of a composer. Reads are `state`; every command goes to the model, so the
|
||||
* mounted composer, a panel's handle and a Command Center item all reach the same one.
|
||||
*/
|
||||
export function toPluginComposer(input: {
|
||||
plugin: InstalledPlugin;
|
||||
agent: ComposerAgent;
|
||||
composerInput: ComposerInput;
|
||||
prompt: string;
|
||||
model: ComposerModel;
|
||||
state: ComposerState;
|
||||
}): PluginComposer {
|
||||
const { plugin, agent, composerInput, prompt } = input;
|
||||
const { plugin, model, state } = input;
|
||||
return {
|
||||
context: agent.context,
|
||||
prompt,
|
||||
attachments: composerInput.attachments.flatMap((attachment) =>
|
||||
context: state.context,
|
||||
prompt: state.prompt,
|
||||
attachments: state.attachments.flatMap((attachment) =>
|
||||
attachment.kind === "plugin_resource" && attachment.pluginId === plugin.id
|
||||
? [{ sourceId: attachment.sourceId, item: attachment.item }]
|
||||
: [],
|
||||
),
|
||||
// Provider options are JSON on the wire.
|
||||
agent: {
|
||||
provider: agent.provider,
|
||||
model: agent.model,
|
||||
mode: agent.mode,
|
||||
thinking: agent.thinking,
|
||||
options: cloneAndFreeze(agent.options as Record<string, JsonValue>),
|
||||
metadata: cloneAndFreeze(agent.metadata),
|
||||
},
|
||||
setPrompt: (text) => {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.setText(text);
|
||||
},
|
||||
insertText: (text) => {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.insertText(text);
|
||||
provider: state.provider,
|
||||
model: state.model,
|
||||
mode: state.mode,
|
||||
thinking: state.thinking,
|
||||
options: cloneAndFreeze(state.options as Record<string, JsonValue>),
|
||||
metadata: cloneAndFreeze(state.metadata),
|
||||
},
|
||||
setPrompt: (text) => model.setPrompt(text),
|
||||
insertText: (text) => model.insertText(text),
|
||||
addAttachment({ sourceId, item }) {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
const source = plugin.attachmentSources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) throw new Error(`Unknown attachment source: ${sourceId}`);
|
||||
const attachment = createPluginResourceAttachment(
|
||||
@@ -80,24 +61,22 @@ export function toPluginComposer(input: {
|
||||
},
|
||||
item,
|
||||
);
|
||||
composerInput.changeAttachments((current) =>
|
||||
model.changeAttachments((current) =>
|
||||
current.some((candidate) => isPluginAttachment(candidate, plugin.id, sourceId, item.id))
|
||||
? current
|
||||
: [...current, attachment],
|
||||
);
|
||||
},
|
||||
removeAttachment({ sourceId, itemId }) {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.changeAttachments((current) =>
|
||||
model.changeAttachments((current) =>
|
||||
current.filter((candidate) => !isPluginAttachment(candidate, plugin.id, sourceId, itemId)),
|
||||
);
|
||||
},
|
||||
setProvider: (provider) => whenUnlocked(agent, () => agent.setProvider(provider)),
|
||||
setModel: (modelId) => whenUnlocked(agent, () => agent.setModel(modelId)),
|
||||
setMode: (modeId) => whenUnlocked(agent, () => agent.setMode(modeId)),
|
||||
setThinking: (thinkingOptionId) =>
|
||||
whenUnlocked(agent, () => agent.setThinking(thinkingOptionId)),
|
||||
setOptions: (patch) => whenUnlocked(agent, () => agent.setOptions(patch)),
|
||||
setMetadata: (patch) => whenUnlocked(agent, () => agent.setMetadata(patch)),
|
||||
setProvider: (provider) => model.setProvider(provider),
|
||||
setModel: (modelId) => model.setModel(modelId),
|
||||
setMode: (modeId) => model.setMode(modeId),
|
||||
setThinking: (thinkingOptionId) => model.setThinking(thinkingOptionId),
|
||||
setOptions: (patch) => model.setOptions(patch),
|
||||
setMetadata: (patch) => model.setMetadata(patch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PluginComposer, PluginComposerSelector } from "@getpaseo/plugin/client";
|
||||
import type { PluginComposersSource } from "@getpaseo/plugin/client/host";
|
||||
import { agentComposer } from "@/composer/agent-composer";
|
||||
import type { ComposerState } from "@/composer/model";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import {
|
||||
getLastWorkspaceSelection,
|
||||
subscribeLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { getFocusedAgentId } from "../command-center/context";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { toPluginComposer } from "./model";
|
||||
|
||||
const NO_COMPOSERS: readonly PluginComposer[] = Object.freeze([]);
|
||||
const sources = new WeakMap<InstalledPlugin, PluginComposersSource>();
|
||||
|
||||
/**
|
||||
* The composers a plugin reaches by reference: one per running agent, open in a tab or not. The
|
||||
* same source serves `useComposers` in every surface and `composers()` in every command.
|
||||
*/
|
||||
export function pluginComposersSource(plugin: InstalledPlugin): PluginComposersSource {
|
||||
let source = sources.get(plugin);
|
||||
if (!source) {
|
||||
source = createSource(plugin);
|
||||
sources.set(plugin, source);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function createSource(plugin: InstalledPlugin): PluginComposersSource {
|
||||
const { serverId } = plugin;
|
||||
// Handles and result arrays are reused while nothing they read has changed, so a subscriber
|
||||
// that re-reads on every notification renders only when its composers did change.
|
||||
const handles = new WeakMap<ComposerState, PluginComposer>();
|
||||
const results = new WeakMap<PluginComposer, readonly PluginComposer[]>();
|
||||
|
||||
function runningAgentId(agentId: string): string | null {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
|
||||
return agent && !agent.archivedAt ? agent.id : null;
|
||||
}
|
||||
|
||||
function focusedAgentId(): string | null {
|
||||
const selection = getLastWorkspaceSelection();
|
||||
if (selection?.serverId !== serverId) return null;
|
||||
const layout =
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[
|
||||
`${selection.serverId}:${selection.workspaceId}`
|
||||
] ?? null;
|
||||
return getFocusedAgentId(layout);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(listener) {
|
||||
const stops = [
|
||||
useSessionStore.subscribe(listener),
|
||||
useDraftStore.subscribe(listener),
|
||||
useWorkspaceLayoutStore.subscribe(listener),
|
||||
subscribeLastWorkspaceSelection(listener),
|
||||
queryClient.getQueryCache().subscribe(listener),
|
||||
];
|
||||
return () => {
|
||||
for (const stop of stops) stop();
|
||||
};
|
||||
},
|
||||
resolve(selector: PluginComposerSelector) {
|
||||
const requested = "agentId" in selector ? selector.agentId : focusedAgentId();
|
||||
const agentId = requested === null ? null : runningAgentId(requested);
|
||||
if (agentId === null) return NO_COMPOSERS;
|
||||
const model = agentComposer(serverId, agentId);
|
||||
const state = model.getState();
|
||||
let composer = handles.get(state);
|
||||
if (!composer) {
|
||||
composer = toPluginComposer({ plugin, model, state });
|
||||
handles.set(state, composer);
|
||||
}
|
||||
let result = results.get(composer);
|
||||
if (!result) {
|
||||
result = Object.freeze([composer]);
|
||||
results.set(composer, result);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export type PluginClientRuntime = Pick<
|
||||
| "openSettings"
|
||||
| "openScreen"
|
||||
| "openSurface"
|
||||
| "composers"
|
||||
| "openPanel"
|
||||
| "subscribe"
|
||||
| "addComposerPill"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
PaseoApiProvider,
|
||||
PluginComposersProvider,
|
||||
PluginRpcProvider,
|
||||
PluginTranslationProvider,
|
||||
} from "@getpaseo/plugin/client/host";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { pluginComposersSource } from "./composer-items/source";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
/** Every plugin surface renders under its installation: one query cache, one Paseo client, RPCs. */
|
||||
@@ -19,7 +21,11 @@ export function PluginInstallationProvider({
|
||||
<QueryClientProvider client={plugin.queryClient}>
|
||||
<PaseoApiProvider paseo={plugin.paseo}>
|
||||
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
|
||||
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
|
||||
<PluginTranslationProvider runtime={plugin.i18n}>
|
||||
<PluginComposersProvider source={pluginComposersSource(plugin)}>
|
||||
{children}
|
||||
</PluginComposersProvider>
|
||||
</PluginTranslationProvider>
|
||||
</PluginRpcProvider>
|
||||
</PaseoApiProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -57,6 +57,7 @@ function registry(
|
||||
},
|
||||
openScreen: () => {},
|
||||
openSurface: () => {},
|
||||
composers: () => [],
|
||||
openSettings: () => {},
|
||||
playAudio: async () => {},
|
||||
openPanel: () => {},
|
||||
|
||||
@@ -35,6 +35,7 @@ function registry(version: string) {
|
||||
},
|
||||
openScreen() {},
|
||||
openSurface() {},
|
||||
composers: () => [],
|
||||
async playAudio() {},
|
||||
openPanel() {},
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createDraftPersistStorage } from "./persistence";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { applyMetadataPatch } from "@/composer/draft/metadata";
|
||||
import { hasDraftContent } from "@/composer/draft/input-draft-core";
|
||||
|
||||
export type { DraftInput, DraftLifecycleState } from "./state";
|
||||
|
||||
@@ -45,6 +46,11 @@ interface DraftStoreActions {
|
||||
getDraftInput: (draftKey: string) => DraftInput | undefined;
|
||||
hydrateDraftInput: (input: { draftKey: string }) => Promise<DraftInput | undefined>;
|
||||
saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void;
|
||||
/** Saves what `update` makes of the current input; an emptied input abandons the draft. */
|
||||
updateDraftInput: (input: {
|
||||
draftKey: string;
|
||||
update: (current: DraftInput) => DraftInput;
|
||||
}) => void;
|
||||
editDraftText: (input: { draftKey: string; text: string }) => void;
|
||||
markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void;
|
||||
clearDraftInput: (input: {
|
||||
@@ -329,6 +335,12 @@ export const useDraftStore = create<DraftStore>()(
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
updateDraftInput: ({ draftKey, update }) => {
|
||||
const next = update(get().getDraftInput(draftKey) ?? { text: "", attachments: [] });
|
||||
if (hasDraftContent(next)) get().saveDraftInput({ draftKey, draft: next });
|
||||
else get().clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
},
|
||||
|
||||
editDraftText: ({ draftKey, text }) => {
|
||||
set((state) => {
|
||||
const previous = state.drafts[draftKey];
|
||||
|
||||
@@ -96,6 +96,10 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function subscribeLastWorkspaceSelection(listener: () => void): () => void {
|
||||
return lastWorkspaceSelectionStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function useLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return useSyncExternalStore(
|
||||
lastWorkspaceSelectionStore.subscribe,
|
||||
|
||||
@@ -250,6 +250,51 @@ describe("plugin composer SDK boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin composers by reference SDK boundary", () => {
|
||||
it("reaches a running agent's composer from any component and from every command context", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
|
||||
function Button() {
|
||||
const [focused] = useComposers({ focused: true });
|
||||
const byId = useComposers({ agentId: "agent-1" });
|
||||
if (!focused) return null;
|
||||
const first: string = byId[0]?.prompt ?? "";
|
||||
focused.insertText("text");
|
||||
focused.setMetadata({ flag: true });
|
||||
// @ts-expect-error a selector names an agent or the focused agent
|
||||
useComposers({ active: true });
|
||||
// @ts-expect-error focused is true or absent
|
||||
useComposers({ focused: false });
|
||||
return null;
|
||||
}
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addWorkspacePanel({ id: "panel", title: "P", icon: "File", context: "workspace", Component: Button });
|
||||
client.addCommandCenterItem({
|
||||
id: "mark",
|
||||
title: "Mark",
|
||||
icon: "Flag",
|
||||
context: "agent",
|
||||
async onSelect(context) {
|
||||
for (const composer of context.composers({ agentId: context.agent.id })) {
|
||||
await composer.setMetadata({ review: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
client.addCommandCenterItem({
|
||||
id: "global",
|
||||
title: "Global",
|
||||
icon: "Flag",
|
||||
context: "global",
|
||||
onSelect: ({ composers }) => composers({ focused: true })[0]?.insertText("x"),
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin New workspace SDK boundary", () => {
|
||||
it("lets a New workspace item read the setup and change it through typed commands and a Combobox", () => {
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useSyncExternalStore,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginAttachmentItem } from "../attachments.js";
|
||||
import type { PluginHostProps } from "./contracts.js";
|
||||
@@ -76,3 +82,48 @@ export function useComposer(): PluginComposer {
|
||||
if (!composer) throw new Error("useComposer must run inside a composer item");
|
||||
return composer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the running agent whose composer a plugin wants. Both forms resolve to the same
|
||||
* `PluginComposer` an item of that agent's composer gets from `useComposer()`.
|
||||
*/
|
||||
export type PluginComposerSelector = { agentId: string } | { focused: true };
|
||||
|
||||
/** What `useComposers` and `composers(selector)` read. Paseo implements it per installed plugin. */
|
||||
export interface PluginComposersSource {
|
||||
subscribe(listener: () => void): () => void;
|
||||
/** The composers `selector` names now: one for a known running agent, none otherwise. */
|
||||
resolve(selector: PluginComposerSelector): readonly PluginComposer[];
|
||||
}
|
||||
|
||||
const PluginComposersContext = createContext<PluginComposersSource | null>(null);
|
||||
|
||||
export function PluginComposersProvider({
|
||||
source,
|
||||
children,
|
||||
}: {
|
||||
source: PluginComposersSource;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PluginComposersContext.Provider value={source}>{children}</PluginComposersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePluginComposersSource(): PluginComposersSource | null {
|
||||
return useContext(PluginComposersContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composers `selector` names, whether or not their tabs are open. Runs in any plugin
|
||||
* component. Empty for an unknown agent, or for `{ focused: true }` when no agent is focused.
|
||||
*/
|
||||
export function useComposers(selector: PluginComposerSelector): readonly PluginComposer[] {
|
||||
const source = useContext(PluginComposersContext);
|
||||
if (!source) throw new Error("useComposers must run inside a plugin component");
|
||||
return useSyncExternalStore(
|
||||
source.subscribe,
|
||||
() => source.resolve(selector),
|
||||
() => source.resolve(selector),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import type { PluginFile } from "./file.js";
|
||||
import type { PluginComposerItemContribution } from "./composer.js";
|
||||
import type {
|
||||
PluginComposer,
|
||||
PluginComposerItemContribution,
|
||||
PluginComposerSelector,
|
||||
} from "./composer.js";
|
||||
import type { PluginNewWorkspaceItemContribution } from "./new-workspace.js";
|
||||
import type {
|
||||
PluginRpcContract,
|
||||
@@ -318,6 +322,8 @@ export interface PluginCommandCapabilities {
|
||||
input: PluginWireInput<ZodInput<InputSchema>>,
|
||||
): Promise<ZodOutput<OutputSchema>>;
|
||||
openScreen(input: PluginOpenScreenInput): void;
|
||||
/** The composers `selector` names, whether or not their tabs are open; see `useComposers`. */
|
||||
composers(selector: PluginComposerSelector): readonly PluginComposer[];
|
||||
/** @deprecated Use `openScreen`. */
|
||||
openSurface(id: string): void;
|
||||
openSettings(id: string): void;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { PluginText } from "../text.js";
|
||||
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
|
||||
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
|
||||
export { PluginComposerProvider } from "./composer.js";
|
||||
export {
|
||||
PluginComposerProvider,
|
||||
PluginComposersProvider,
|
||||
type PluginComposersSource,
|
||||
} from "./composer.js";
|
||||
export { PluginNewWorkspaceProvider } from "./new-workspace.js";
|
||||
export {
|
||||
usePluginRuntimeContextBridge,
|
||||
|
||||
@@ -71,12 +71,13 @@ export {
|
||||
} from "./file.js";
|
||||
export { usePaseo } from "./paseo-context.js";
|
||||
export { useAgent, useWorkspace } from "./client-state.js";
|
||||
export { useComposer } from "./composer.js";
|
||||
export { useComposer, useComposers } from "./composer.js";
|
||||
export type {
|
||||
PluginComposer,
|
||||
PluginComposerAttachment,
|
||||
PluginComposerItemContribution,
|
||||
PluginComposerItemProps,
|
||||
PluginComposerSelector,
|
||||
} from "./composer.js";
|
||||
export { useNewWorkspace } from "./new-workspace.js";
|
||||
export type {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { PluginComposerProvider, usePluginComposerValue } from "./composer.js";
|
||||
import {
|
||||
PluginComposerProvider,
|
||||
PluginComposersProvider,
|
||||
usePluginComposerValue,
|
||||
usePluginComposersSource,
|
||||
} from "./composer.js";
|
||||
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
|
||||
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
|
||||
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
|
||||
@@ -14,6 +19,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
const rpc = usePluginRpcContextValue();
|
||||
const state = usePluginClientStateSource();
|
||||
const composer = usePluginComposerValue();
|
||||
const composers = usePluginComposersSource();
|
||||
|
||||
if (!paseo || !rpc) {
|
||||
throw new Error("Plugin UI must run inside a contributed plugin surface");
|
||||
@@ -26,11 +32,16 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
) : (
|
||||
children
|
||||
);
|
||||
const content = composer ? (
|
||||
<PluginComposerProvider composer={composer}>{withState}</PluginComposerProvider>
|
||||
const withComposers = composers ? (
|
||||
<PluginComposersProvider source={composers}>{withState}</PluginComposersProvider>
|
||||
) : (
|
||||
withState
|
||||
);
|
||||
const content = composer ? (
|
||||
<PluginComposerProvider composer={composer}>{withComposers}</PluginComposerProvider>
|
||||
) : (
|
||||
withComposers
|
||||
);
|
||||
return (
|
||||
<PaseoApiProvider paseo={paseo}>
|
||||
<PluginRpcProvider invoke={rpc.invoke} subscribe={rpc.subscribe}>
|
||||
@@ -39,6 +50,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
</PaseoApiProvider>
|
||||
);
|
||||
},
|
||||
[composer, paseo, rpc, state, translations],
|
||||
[composer, composers, paseo, rpc, state, translations],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ManagedAgent,
|
||||
} from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { validateAgentConfigurationAgainstProvider } from "./agent-configuration-validator.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
@@ -54,6 +55,7 @@ import type {
|
||||
AgentTimelineItem,
|
||||
ImportProviderSessionInput,
|
||||
ImportProviderSessionContext,
|
||||
ProviderSnapshotEntry,
|
||||
ResolveAgentDefaultModeInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
@@ -5389,6 +5391,51 @@ test("later explicit config mutations win over events emitted by earlier mutatio
|
||||
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
|
||||
});
|
||||
|
||||
test("a running agent rejects a model or thinking option its provider does not list", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-selection-validation-"));
|
||||
const provider: ProviderSnapshotEntry = {
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
isDefault: true,
|
||||
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
};
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
validateAgentSelection: async (input) =>
|
||||
validateAgentConfigurationAgainstProvider({ input, provider }),
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
||||
});
|
||||
const snapshot = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, model: "gpt-5.4" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
await expect(manager.setAgentModel(snapshot.id, "nope")).rejects.toThrow(
|
||||
"Model 'nope' is not available for provider 'codex'",
|
||||
);
|
||||
await expect(manager.setAgentThinkingOption(snapshot.id, "extreme")).rejects.toThrow(
|
||||
"Thinking option 'extreme' is not available for provider 'codex'",
|
||||
);
|
||||
expect(manager.getAgent(snapshot.id)?.config.model).toBe("gpt-5.4");
|
||||
|
||||
await manager.setAgentThinkingOption(snapshot.id, "low");
|
||||
await manager.setAgentModel(snapshot.id, null);
|
||||
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("low");
|
||||
expect(manager.getAgent(snapshot.id)?.config.model).toBeUndefined();
|
||||
});
|
||||
|
||||
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
|
||||
class ClampingSession extends TestAgentSession {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ProviderOptionsValidationError } from "./provider-options.js";
|
||||
import type { AgentToolResult } from "@getpaseo/plugin/server";
|
||||
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import type { AgentConfigurationValidationInput } from "./agent-configuration-validator.js";
|
||||
import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messages";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||
@@ -341,6 +343,13 @@ export interface AgentManagerOptions {
|
||||
pluginLifecycle?: PluginLifecycle;
|
||||
/** An internal workspace makes every agent created inside it internal. */
|
||||
clients?: ProviderClientMap;
|
||||
/**
|
||||
* Asks whether the provider lists a model or thinking option. A running agent rejects a
|
||||
* selection it does not; omitted, any value is accepted.
|
||||
*/
|
||||
validateAgentSelection?: (
|
||||
input: AgentConfigurationValidationInput,
|
||||
) => Promise<HubExecutionAgentValidationIssue[]>;
|
||||
providerDefinitions?: ProviderEnabledMap;
|
||||
idFactory?: () => string;
|
||||
registry?: AgentStorage;
|
||||
@@ -812,6 +821,7 @@ export class AgentManager {
|
||||
private logger: Logger;
|
||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||
private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"];
|
||||
private readonly validateAgentSelection?: AgentManagerOptions["validateAgentSelection"];
|
||||
private acceptingAgentRegistrations = true;
|
||||
|
||||
constructor(options: AgentManagerOptions) {
|
||||
@@ -836,6 +846,7 @@ export class AgentManager {
|
||||
options.rescueTimeouts?.interruptSessionMs ?? INTERRUPT_SESSION_TIMEOUT_MS,
|
||||
};
|
||||
this.beforeSteerUnavailableFallback = options.beforeSteerUnavailableFallback;
|
||||
this.validateAgentSelection = options.validateAgentSelection;
|
||||
this.agentStreamCoalescer = new AgentStreamCoalescer({
|
||||
windowMs: options.agentStreamCoalesceWindowMs ?? AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
|
||||
timers: { setTimeout, clearTimeout },
|
||||
@@ -2266,10 +2277,21 @@ export class AgentManager {
|
||||
return notice;
|
||||
}
|
||||
|
||||
private async assertSelectionListed(input: AgentConfigurationValidationInput): Promise<void> {
|
||||
const issues = (await this.validateAgentSelection?.(input)) ?? [];
|
||||
if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; "));
|
||||
}
|
||||
|
||||
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
if (normalizedModelId) {
|
||||
await this.assertSelectionListed({
|
||||
provider: agent.provider,
|
||||
model: normalizedModelId,
|
||||
});
|
||||
}
|
||||
|
||||
if (agent.session.setModel) {
|
||||
await agent.session.setModel(normalizedModelId);
|
||||
@@ -2294,6 +2316,13 @@ export class AgentManager {
|
||||
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
||||
? thinkingOptionId
|
||||
: null;
|
||||
if (normalizedThinkingOptionId) {
|
||||
await this.assertSelectionListed({
|
||||
provider: agent.provider,
|
||||
model: agent.config.model,
|
||||
thinkingOptionId: normalizedThinkingOptionId,
|
||||
});
|
||||
}
|
||||
|
||||
let notice: AgentProviderNotice | null = null;
|
||||
if (agent.session.setThinkingOption) {
|
||||
|
||||
@@ -1017,6 +1017,7 @@ export async function createPaseoDaemon(
|
||||
clients: initialAgentManagerState.clients,
|
||||
providerDefinitions: initialAgentManagerState.providerDefinitions,
|
||||
registry: agentStorage,
|
||||
validateAgentSelection: (input) => providerSnapshotManager.validateAgentConfiguration(input),
|
||||
appendSystemPrompt: config.appendSystemPrompt,
|
||||
onWorkspaceStateMayHaveChanged: ({ cwd }) => {
|
||||
workspaceGitService.onWorkspaceStateMayHaveChanged(cwd);
|
||||
|
||||
@@ -49,13 +49,13 @@ describe("snapshot mutation ownership boundary", () => {
|
||||
|
||||
const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot");
|
||||
|
||||
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4");
|
||||
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4-mini");
|
||||
await daemonHandle.daemon.agentManager.flush();
|
||||
|
||||
expect(applySnapshotSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const persisted = await daemonHandle.daemon.agentStorage.get(snapshot.id);
|
||||
expect(persisted?.config?.model).toBe("gpt-5.4");
|
||||
expect(persisted?.config?.model).toBe("gpt-5.4-mini");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
await daemonHandle.close();
|
||||
|
||||
@@ -14,5 +14,16 @@ Each component calls `useComposer()` and uses the same commands in every compose
|
||||
controls inline when the composer has room and as rows in the composer's overflow sheet when it
|
||||
does not, including on a phone.
|
||||
|
||||
[client/controls.tsx](client/controls.tsx) holds the four components; [index.client.tsx](index.client.tsx)
|
||||
registers them with `addComposerItem`.
|
||||
## Reaching a composer from outside it
|
||||
|
||||
Two more contributions use a composer without sitting in one. Both name the agent instead of
|
||||
reading the composer they are mounted in, and neither needs the agent's tab to be open.
|
||||
|
||||
| Contribution | What it does |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| **Composer tools** (Explorer panel) | `useComposers({ focused: true })` inserts the checklist into the focused agent's prompt. |
|
||||
| **Mark for review** (Command Center) | `composers({ agentId })` sets the same `needsReview` metadata as the toggle, for the agent you selected. |
|
||||
|
||||
[client/controls.tsx](client/controls.tsx) holds the four composer components,
|
||||
[client/panel.tsx](client/panel.tsx) the panel, and [index.client.tsx](index.client.tsx) registers
|
||||
everything.
|
||||
|
||||
@@ -10,7 +10,7 @@ const CONTEXT_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
const CONTEXT_KEY = "composer-controls.contextLength";
|
||||
const REVIEW_KEY = "composer-controls.needsReview";
|
||||
export const REVIEW_KEY = "composer-controls.needsReview";
|
||||
|
||||
/** Reads and writes agent metadata, so the choice is saved with the agent. */
|
||||
export function ContextLength() {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useComposers, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
||||
import { REVIEW_KEY } from "./controls";
|
||||
|
||||
/**
|
||||
* Lives in the Explorer, away from any composer, and reaches the composer of the agent whose tab
|
||||
* is focused. The agent's tab does not have to be open for the composer to exist: the text lands in
|
||||
* its prompt either way.
|
||||
*/
|
||||
export function ComposerTools({ theme, layout }: PluginWorkspacePanelProps) {
|
||||
const [focused] = useComposers({ focused: true });
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
screen: {
|
||||
flex: 1,
|
||||
padding: layout.compact ? 16 : 24,
|
||||
gap: 12,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
label: { color: theme.colors.foregroundMuted },
|
||||
button: {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: focused ? theme.colors.accent : theme.colors.surface2,
|
||||
},
|
||||
buttonText: {
|
||||
color: focused ? theme.colors.accentForeground : theme.colors.foregroundMuted,
|
||||
textAlign: "center" as const,
|
||||
},
|
||||
}),
|
||||
[focused, layout.compact, theme],
|
||||
);
|
||||
const insert = useCallback(() => focused?.insertText("- [ ] Tests\n- [ ] Docs\n"), [focused]);
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<Text style={styles.label}>
|
||||
{focused ? `Focused agent: ${focused.agent.provider}` : "No agent is focused"}
|
||||
</Text>
|
||||
{focused ? (
|
||||
<Text style={styles.label}>
|
||||
{focused.agent.metadata[REVIEW_KEY] === true ? "Needs review: yes" : "Needs review: no"}
|
||||
</Text>
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert checklist into the focused agent"
|
||||
disabled={!focused}
|
||||
onPress={insert}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.buttonText}>Insert checklist into the focused agent</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,30 @@
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { Checklist, ContextLength, PlanFirst, ReviewFlag } from "./client/controls";
|
||||
import { Checklist, ContextLength, PlanFirst, REVIEW_KEY, ReviewFlag } from "./client/controls";
|
||||
import { ComposerTools } from "./client/panel";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addComposerItem({ id: "context-length", Component: ContextLength });
|
||||
client.addComposerItem({ id: "review-flag", Component: ReviewFlag });
|
||||
client.addComposerItem({ id: "plan-first", Component: PlanFirst });
|
||||
client.addComposerItem({ id: "checklist", Component: Checklist });
|
||||
client.addWorkspacePanel({
|
||||
id: "composer-tools",
|
||||
title: "Composer tools",
|
||||
icon: "ListChecks",
|
||||
locations: ["explorer"],
|
||||
Component: ComposerTools,
|
||||
});
|
||||
client.addCommandCenterItem({
|
||||
id: "mark-for-review",
|
||||
title: "Composer controls: mark for review",
|
||||
icon: "Flag",
|
||||
context: "agent",
|
||||
async onSelect({ agent, composers }) {
|
||||
// The agent's tab does not have to be open.
|
||||
for (const composer of composers({ agentId: agent.id })) {
|
||||
await composer.setMetadata({ [REVIEW_KEY]: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1982,7 +1982,71 @@ renders nothing can still leave an empty overflow sheet on a narrow composer, so
|
||||
control.
|
||||
|
||||
The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)
|
||||
registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`.
|
||||
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.
|
||||
|
||||
### 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:
|
||||
|
||||
```tsx
|
||||
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
|
||||
|
||||
function InsertChecklist() {
|
||||
const [focused] = useComposers({ focused: true });
|
||||
return (
|
||||
<Pressable disabled={!focused} onPress={() => focused?.insertText("- [ ] Tests\n")}>
|
||||
<Text>Insert checklist</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addWorkspacePanel({
|
||||
id: "tools",
|
||||
title: "Tools",
|
||||
icon: "ListChecks",
|
||||
locations: ["explorer"],
|
||||
Component: InsertChecklist,
|
||||
});
|
||||
client.addCommandCenterItem({
|
||||
id: "mark-for-review",
|
||||
title: "Mark for review",
|
||||
icon: "Flag",
|
||||
context: "agent",
|
||||
async onSelect({ agent, composers }) {
|
||||
for (const composer of composers({ agentId: agent.id })) {
|
||||
await composer.setMetadata({ "review.needsReview": true });
|
||||
}
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
| Selector | Result |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{ agentId }` | The composer of that agent on this host. Empty for an agent that is unknown or archived. |
|
||||
| `{ focused: true }` | The composer of the agent whose tab is focused in the workspace you last viewed on this host. Empty when the focused tab is not an agent, and it follows focus as the user changes tabs. |
|
||||
|
||||
The result is always an array, empty or with one composer, so the same code reads either selector.
|
||||
`useComposers` re-renders when a composer changes. A composer from `composers()` is the state at the
|
||||
call; call `composers()` again for the next state.
|
||||
|
||||
The commands are those of [`useComposer()`](#composer-items), with these differences for a composer
|
||||
you reach from outside:
|
||||
|
||||
- `insertText` inserts at the cursor while the agent's tab is open. With the tab closed it appends to
|
||||
the agent's saved prompt, and the text is there when the tab opens.
|
||||
- A running agent's composer is never locked.
|
||||
- `setProvider` rejects, as it does inside the agent's own composer.
|
||||
- `setModel` and `setThinking` reject, with the daemon's message, for a value the provider does not
|
||||
list. `model` and `thinking` read `null` until the provider's catalog has loaded, then the value
|
||||
the model picker shows. Pass an id the catalog lists.
|
||||
- Drafts and the New workspace screen are not addressable by reference. Only an item inside one
|
||||
reaches its composer, through `useComposer()`.
|
||||
|
||||
## New workspace items
|
||||
|
||||
|
||||
@@ -1663,7 +1663,61 @@ export default function contribute(client: PluginClientContext) {
|
||||
|
||||
在 Paseo 知道渲染结果之前,composer 项就已计入可容纳的数量。不渲染任何内容的项仍可能在窄 composer 上留下空的溢出面板,所以请始终渲染一个控件。
|
||||
|
||||
[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`。
|
||||
[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`,还有一个资源管理器面板和一个 Command Center 条目,它们按引用访问 composer。
|
||||
|
||||
### 按引用访问 composer {#reach-a-composer-by-reference}
|
||||
|
||||
运行中的 Agent 无论标签页是否打开,都有自己的 composer。任意插件组件中的 `useComposers(selector)`,以及每个命令上下文中的 `composers(selector)`,都会返回与 `useComposer()` 给 composer 项的相同的 `PluginComposer`,对象可以是你指定的 Agent,也可以是当前聚焦的 Agent:
|
||||
|
||||
```tsx
|
||||
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
|
||||
|
||||
function InsertChecklist() {
|
||||
const [focused] = useComposers({ focused: true });
|
||||
return (
|
||||
<Pressable disabled={!focused} onPress={() => focused?.insertText("- [ ] Tests\n")}>
|
||||
<Text>Insert checklist</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addWorkspacePanel({
|
||||
id: "tools",
|
||||
title: "Tools",
|
||||
icon: "ListChecks",
|
||||
locations: ["explorer"],
|
||||
Component: InsertChecklist,
|
||||
});
|
||||
client.addCommandCenterItem({
|
||||
id: "mark-for-review",
|
||||
title: "Mark for review",
|
||||
icon: "Flag",
|
||||
context: "agent",
|
||||
async onSelect({ agent, composers }) {
|
||||
for (const composer of composers({ agentId: agent.id })) {
|
||||
await composer.setMetadata({ "review.needsReview": true });
|
||||
}
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
| 选择器 | 结果 |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{ agentId }` | 此主机上该 Agent 的 composer。Agent 未知或已归档时为空。 |
|
||||
| `{ focused: true }` | 你在此主机上最近查看的工作区中,标签页处于聚焦状态的 Agent 的 composer。聚焦的标签页不是 Agent 时为空,并随用户切换标签页而变化。 |
|
||||
|
||||
结果始终是数组,为空或只含一个 composer,所以同一段代码可以读取任一选择器。composer 变化时 `useComposers` 会重新渲染。`composers()` 返回的 composer 是调用时的状态,要获取下一个状态请再次调用 `composers()`。
|
||||
|
||||
命令与 [`useComposer()`](#composer-items) 相同,从外部访问时有以下差异:
|
||||
|
||||
- Agent 的标签页打开时,`insertText` 在光标处插入。标签页关闭时,它会追加到该 Agent 已保存的提示词,标签页打开时文本就在那里。
|
||||
- 运行中 Agent 的 composer 从不锁定。
|
||||
- `setProvider` 会被拒绝,与在 Agent 自己的 composer 内一样。
|
||||
- 对于 Provider 未列出的值,`setModel` 和 `setThinking` 会以 daemon 的消息拒绝。在 Provider 的模型目录加载完成之前,`model` 和 `thinking` 读取为 `null`,之后是模型选择器显示的值。请传入目录中列出的 id。
|
||||
- 草稿和新建工作区界面不能按引用访问,只有其中的项能通过 `useComposer()` 访问其 composer。
|
||||
|
||||
## 新建工作区项 {#new-workspace-items}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user