mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +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:
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user