mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5fa8a5d83 | ||
|
|
ca178b6578 | ||
|
|
b965a3db7c | ||
|
|
c1f64d45e1 | ||
|
|
aa558ab845 | ||
|
|
a2b76f77f3 | ||
|
|
ad88797f8d | ||
|
|
d92b16afbf | ||
|
|
abd4d2c1ea |
@@ -644,6 +644,35 @@ Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
|
||||
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
|
||||
|
||||
## Contribute composer items
|
||||
|
||||
`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.
|
||||
|
||||
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`),
|
||||
a toolbar icon or a row in the overflow sheet. Model and mode stay specialized but share its trigger,
|
||||
density and overflow. Plugin items count in the density presence in `layout.ts`, so a composer
|
||||
condenses to make room for them. A custom component renders wherever its item is placed, and an
|
||||
item that renders nothing still counts for density; there is no registration for either.
|
||||
|
||||
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
|
||||
return a promise.
|
||||
|
||||
The overflow sheet's content portals to the app root, away from the composer. Pass
|
||||
`useComposerSheetBridge()` as the sheet's `contextBridge`, or `useComposer()` returns nothing and
|
||||
the sheet draws no plugin rows.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
|
||||
|
||||
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerControlsExample(page, async (composer) => {
|
||||
await composer.narrowTheWindow();
|
||||
await composer.openAgentFeatures();
|
||||
await composer.expectFeatureRow("Needs review");
|
||||
await composer.expectFeatureRow("Insert checklist");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
|
||||
const PLUGIN_ID = "composer-controls-example";
|
||||
const NARROW = { width: 500, height: 800 };
|
||||
|
||||
/** Runs `run` in an agent's composer with the composer-controls example installed. */
|
||||
export async function withComposerControlsExample(
|
||||
page: Page,
|
||||
run: (composer: {
|
||||
narrowTheWindow(): Promise<void>;
|
||||
openAgentFeatures(): Promise<void>;
|
||||
expectFeatureRow(name: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "composer-items-",
|
||||
title: "Composer items",
|
||||
});
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Composer items agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
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 gotoAppShell(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await run({
|
||||
narrowTheWindow: () =>
|
||||
test.step("narrow the window until the composer folds its controls away", () =>
|
||||
page.setViewportSize(NARROW)),
|
||||
openAgentFeatures: () =>
|
||||
test.step("open the agent features sheet", async () => {
|
||||
await page.getByRole("button", { name: "Open agent features", exact: true }).click();
|
||||
}),
|
||||
expectFeatureRow: (name) =>
|
||||
test.step(`the sheet has a row for "${name}"`, () =>
|
||||
expect(page.getByRole("button", { name, exact: true })).toBeVisible()),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
* - `remembered`: the provider's last-used control values.
|
||||
* - `edits`: toggles made in this draft.
|
||||
*
|
||||
* An edit of `null` removes the key from every layer, the way a patch does on a running agent, so
|
||||
* the provider's default applies again.
|
||||
*
|
||||
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
|
||||
* and the options in one step, so the provider change that follows does not clear them.
|
||||
*/
|
||||
@@ -47,6 +50,11 @@ export interface DraftOptionsModel {
|
||||
export function openDraftOptions(input: {
|
||||
provider: string | null;
|
||||
seed?: ProviderOptions;
|
||||
/**
|
||||
* The provider the draft's form has now. An edit belongs to it even before the screen has
|
||||
* rendered the change, so `setOption` observes it first.
|
||||
*/
|
||||
currentProvider?: () => string | null;
|
||||
}): DraftOptionsModel {
|
||||
let state: DraftOptionsState = {
|
||||
provider: input.provider,
|
||||
@@ -64,6 +72,15 @@ export function openDraftOptions(input: {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function observeProvider(provider: string | null): void {
|
||||
publish((current) => {
|
||||
if (provider === current.provider) return current;
|
||||
// The first provider a draft picks does not discard what it was opened with.
|
||||
if (current.provider === null) return { ...current, provider };
|
||||
return { ...current, provider, seed: {}, edits: {}, resolved: null };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: (listener) => {
|
||||
@@ -72,13 +89,7 @@ export function openDraftOptions(input: {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
observeProvider: (provider) =>
|
||||
publish((current) => {
|
||||
if (provider === current.provider) return current;
|
||||
// The first provider a draft picks does not discard what it was opened with.
|
||||
if (current.provider === null) return { ...current, provider };
|
||||
return { ...current, provider, seed: {}, edits: {}, resolved: null };
|
||||
}),
|
||||
observeProvider,
|
||||
applyProfile: (profile) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
@@ -88,12 +99,14 @@ export function openDraftOptions(input: {
|
||||
resolved: current.provider === profile.provider ? current.resolved : null,
|
||||
})),
|
||||
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
|
||||
setOption: (key, value) =>
|
||||
setOption: (key, value) => {
|
||||
if (input.currentProvider) observeProvider(input.currentProvider());
|
||||
publish((current) =>
|
||||
Object.is(current.edits[key], value)
|
||||
? current
|
||||
: { ...current, edits: { ...current.edits, [key]: value } },
|
||||
),
|
||||
);
|
||||
},
|
||||
applyResolved: (resolved) => publish((current) => ({ ...current, resolved })),
|
||||
};
|
||||
}
|
||||
@@ -109,10 +122,14 @@ export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | und
|
||||
([key]) => controlKeys.has(key) && !(key in state.seed),
|
||||
),
|
||||
);
|
||||
const own = { ...state.seed, ...remembered, ...state.edits };
|
||||
const own = withoutRemovals({ ...state.seed, ...remembered, ...state.edits });
|
||||
return Object.keys(own).length > 0 ? own : undefined;
|
||||
}
|
||||
|
||||
function withoutRemovals(options: ProviderOptions): ProviderOptions {
|
||||
return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== null));
|
||||
}
|
||||
|
||||
export type DraftControlsRequest = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
|
||||
@@ -163,6 +180,20 @@ export function viewDraftOptions(
|
||||
options:
|
||||
resolved.requestKey === current.requestKey
|
||||
? resolved.options
|
||||
: { ...resolved.options, ...state.edits },
|
||||
: withoutRemovals({ ...resolved.options, ...state.edits }),
|
||||
};
|
||||
}
|
||||
|
||||
const NO_OPTIONS: ProviderOptions = {};
|
||||
|
||||
/**
|
||||
* The options the draft will create with, for a plugin or a control to read: the daemon's answer
|
||||
* for this provider when there is one, otherwise the draft's own layers, so an edit shows before
|
||||
* the daemon has answered.
|
||||
*/
|
||||
export function shownDraftOptions(
|
||||
state: DraftOptionsState,
|
||||
current: { provider: string | null; requestKey: string },
|
||||
): ProviderOptions {
|
||||
return viewDraftOptions(state, current)?.options ?? ownDraftOptions(state) ?? NO_OPTIONS;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import React from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Scan } from "lucide-react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { ToastApiProvider } from "@/contexts/toast-api-context";
|
||||
import { ComposerControls, type ComposerControlItem } from "./composer-control";
|
||||
import { useAgentControlItems } from "./control-items";
|
||||
import { resolveComposerControlPresentation } from "./layout";
|
||||
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
afterEach(cleanup);
|
||||
|
||||
const base = { id: "x", title: "Context", icon: Scan };
|
||||
|
||||
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
|
||||
|
||||
function createToast(): RecordingToast {
|
||||
return { error: vi.fn() } as unknown as RecordingToast;
|
||||
}
|
||||
|
||||
function ignoreOpenChange() {}
|
||||
|
||||
function Controls({
|
||||
items,
|
||||
surface,
|
||||
toast,
|
||||
}: {
|
||||
items: ComposerControlItem[];
|
||||
surface: "toolbar" | "sheet";
|
||||
toast: ToastApi;
|
||||
}) {
|
||||
return (
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerControls
|
||||
items={items}
|
||||
surface={surface}
|
||||
openId={null}
|
||||
onOpenIdChange={ignoreOpenChange}
|
||||
/>
|
||||
</ToastApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderControls(items: ComposerControlItem[], surface: "toolbar" | "sheet" = "toolbar") {
|
||||
const toast = createToast();
|
||||
render(<Controls items={items} surface={surface} toast={toast} />);
|
||||
return toast;
|
||||
}
|
||||
|
||||
describe("ComposerControls", () => {
|
||||
it("shows a select's chosen label, or only its icon when asked", () => {
|
||||
const select: ComposerControlItem = {
|
||||
...base,
|
||||
type: "select",
|
||||
value: "large",
|
||||
choices: [
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "large", label: "Large" },
|
||||
],
|
||||
onChange: () => undefined,
|
||||
};
|
||||
renderControls([select]);
|
||||
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("Large");
|
||||
});
|
||||
|
||||
it("names the choice on an icon-only select, without drawing it", () => {
|
||||
renderControls([
|
||||
{
|
||||
...base,
|
||||
type: "select",
|
||||
value: "large",
|
||||
iconOnly: true,
|
||||
choices: [{ value: "large", label: "Large" }],
|
||||
onChange: () => undefined,
|
||||
},
|
||||
]);
|
||||
const trigger = screen.getByRole("button", { name: "Context: Large" });
|
||||
expect(trigger.textContent).not.toContain("Large");
|
||||
});
|
||||
|
||||
it("flips a toggle on press in the toolbar", () => {
|
||||
const onChange = vi.fn();
|
||||
renderControls([{ ...base, type: "toggle", value: true, onChange }]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("lists a toggle as On or Off in the sheet", () => {
|
||||
renderControls([{ ...base, type: "toggle", value: true, onChange: () => undefined }], "sheet");
|
||||
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On");
|
||||
});
|
||||
|
||||
it("reports a failed command the way a built-in control does, and frees the control", async () => {
|
||||
let reject: (error: Error) => void = () => undefined;
|
||||
const onPress = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, rejectPromise) => {
|
||||
reject = rejectPromise;
|
||||
}),
|
||||
);
|
||||
const toast = renderControls([{ ...base, type: "action", onPress }]);
|
||||
const button = screen.getByRole("button", { name: "Context" });
|
||||
|
||||
fireEvent.click(button);
|
||||
// While the command is in flight the control cannot be pressed again.
|
||||
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
|
||||
fireEvent.click(button);
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
|
||||
reject(new Error("Unknown or unavailable provider: x"));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Unknown or unavailable provider: x"),
|
||||
);
|
||||
await waitFor(() => expect(button.getAttribute("aria-disabled")).not.toBe("true"));
|
||||
});
|
||||
|
||||
it("reports a command that throws before it returns", () => {
|
||||
const toast = renderControls([
|
||||
{
|
||||
...base,
|
||||
type: "action",
|
||||
onPress: () => {
|
||||
throw new Error("No such control");
|
||||
},
|
||||
},
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
||||
expect(toast.error).toHaveBeenCalledWith("No such control");
|
||||
});
|
||||
});
|
||||
|
||||
const FAST_ON = { fast_mode: true };
|
||||
|
||||
describe("provider option controls", () => {
|
||||
const fastMode: AgentOptionControl = {
|
||||
type: "toggle",
|
||||
key: "fast_mode",
|
||||
label: "Fast",
|
||||
defaultValue: false,
|
||||
};
|
||||
function OptionControls({
|
||||
options,
|
||||
onSetOption,
|
||||
}: {
|
||||
options: Record<string, unknown>;
|
||||
onSetOption: (key: string, value: unknown) => void;
|
||||
}) {
|
||||
const optionControls = React.useMemo(
|
||||
() => ({ controls: [fastMode], options, onSetOption }),
|
||||
[onSetOption, options],
|
||||
);
|
||||
const items = useAgentControlItems({
|
||||
surface: "toolbar",
|
||||
presentation: resolveComposerControlPresentation("full"),
|
||||
thinking: null,
|
||||
optionControls,
|
||||
});
|
||||
return <Controls items={items.features} surface="toolbar" toast={TOAST} />;
|
||||
}
|
||||
const TOAST = createToast();
|
||||
|
||||
function recordWrites(writes: Array<[string, unknown]>) {
|
||||
return (key: string, value: unknown) => void writes.push([key, value]);
|
||||
}
|
||||
|
||||
it("write the toggled option from the agent's current options", () => {
|
||||
const writes: Array<[string, unknown]> = [];
|
||||
const onSetOption = recordWrites(writes);
|
||||
render(<OptionControls options={FAST_ON} onSetOption={onSetOption} />);
|
||||
fireEvent.click(screen.getByTestId("agent-feature-fast_mode"));
|
||||
expect(writes).toEqual([["fast_mode", false]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentControlIcon } from "@/agent-controls/icons";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { AgentControlTrigger } from "@/composer/agent-controls/control";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
interface ComposerControlBase {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: AgentControlIcon;
|
||||
/** Tints the icon; muted when absent. */
|
||||
highlight?: "blue" | "green" | "yellow";
|
||||
/** Defaults to the title, or to "title: choice" on an icon-only select. */
|
||||
tooltip?: string;
|
||||
/** Defaults to the tooltip. */
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export interface ComposerControlChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One control in the composer's toolbar, drawn from data. Provider option controls, thinking and
|
||||
* plugin controls all come through here, so each is an icon in the toolbar and a row in the sheet.
|
||||
*/
|
||||
export type ComposerControlItem =
|
||||
| (ComposerControlBase & {
|
||||
type: "toggle";
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void | Promise<void>;
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "select";
|
||||
value: string | null;
|
||||
choices: readonly ComposerControlChoice[];
|
||||
onChange: (value: string) => void | Promise<void>;
|
||||
/** The toolbar trigger shows only the icon. */
|
||||
iconOnly?: boolean;
|
||||
showCaret?: boolean;
|
||||
searchable?: boolean;
|
||||
desktopMinWidth?: number;
|
||||
renderOption?: (args: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "action";
|
||||
onPress: () => void | Promise<void>;
|
||||
});
|
||||
|
||||
export type ComposerControlSurface = "toolbar" | "sheet";
|
||||
|
||||
const ComposerControlSurfaceContext = createContext<ComposerControlSurface>("toolbar");
|
||||
export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider;
|
||||
|
||||
/** Where the control being drawn sits: the toolbar, or a row in the overflow sheet. */
|
||||
export function useComposerControlSurface(): ComposerControlSurface {
|
||||
return useContext(ComposerControlSurfaceContext);
|
||||
}
|
||||
|
||||
interface ComposerControlProps {
|
||||
item: ComposerControlItem;
|
||||
surface: ComposerControlSurface;
|
||||
disabled?: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Runs after a toolbar toggle or action, so the composer can take focus back. */
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
interface ControlView {
|
||||
options: ComboboxOption[];
|
||||
/** The list's selected id. */
|
||||
selectedId: string;
|
||||
/** What the trigger shows beside the icon. */
|
||||
value: string;
|
||||
iconOnly: boolean;
|
||||
tooltip: string;
|
||||
/** Whether pressing the trigger opens a list instead of acting. */
|
||||
opensList: boolean;
|
||||
}
|
||||
|
||||
function describeControl(
|
||||
item: ComposerControlItem,
|
||||
surface: ComposerControlSurface,
|
||||
t: TFunction,
|
||||
): ControlView {
|
||||
const isSheet = surface === "sheet";
|
||||
if (item.type === "action") {
|
||||
return {
|
||||
options: [],
|
||||
selectedId: "",
|
||||
value: "",
|
||||
iconOnly: true,
|
||||
tooltip: item.tooltip ?? item.title,
|
||||
opensList: false,
|
||||
};
|
||||
}
|
||||
if (item.type === "toggle") {
|
||||
const label = item.value ? t("agentControls.features.on") : t("agentControls.features.off");
|
||||
return {
|
||||
options: [
|
||||
{ id: "true", label: t("agentControls.features.on") },
|
||||
{ id: "false", label: t("agentControls.features.off") },
|
||||
],
|
||||
selectedId: String(item.value),
|
||||
value: label,
|
||||
iconOnly: true,
|
||||
tooltip: item.tooltip ?? item.title,
|
||||
opensList: isSheet,
|
||||
};
|
||||
}
|
||||
const choiceLabel =
|
||||
item.choices.find((choice) => choice.value === item.value)?.label ?? item.title;
|
||||
const iconOnly = item.iconOnly === true;
|
||||
return {
|
||||
options: item.choices.map((choice) => ({
|
||||
id: choice.value,
|
||||
label: choice.label,
|
||||
description: choice.description,
|
||||
})),
|
||||
selectedId: String(item.value ?? ""),
|
||||
value: choiceLabel,
|
||||
iconOnly,
|
||||
// An icon-only trigger names its value; a sheet row shows the value, so it names the action.
|
||||
tooltip: iconOnly && !isSheet ? `${item.title}: ${choiceLabel}` : (item.tooltip ?? item.title),
|
||||
opensList: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function ComposerControl({
|
||||
item,
|
||||
surface,
|
||||
disabled = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
onActionComplete,
|
||||
}: ComposerControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const isSheet = surface === "sheet";
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title: item.title }), [item.title]);
|
||||
const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]);
|
||||
|
||||
// A command that returns a promise keeps the control pending until it settles, and a failure
|
||||
// reaches the user the way a built-in live control's does.
|
||||
const toast = useToast();
|
||||
const [pending, setPending] = useState(false);
|
||||
const run = useCallback(
|
||||
(invoke: () => void | Promise<void>) => {
|
||||
try {
|
||||
const result = invoke();
|
||||
if (!result) return;
|
||||
setPending(true);
|
||||
result
|
||||
.catch((error: unknown) => toast.error(toErrorMessage(error)))
|
||||
.finally(() => setPending(false));
|
||||
} catch (error) {
|
||||
toast.error(toErrorMessage(error));
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (item.type === "toggle") run(() => item.onChange(!item.value));
|
||||
else if (item.type === "action") run(item.onPress);
|
||||
onActionComplete?.();
|
||||
}, [item, onActionComplete, run]);
|
||||
const handleOpenPress = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (item.type === "select") run(() => item.onChange(value));
|
||||
else if (item.type === "toggle") run(() => item.onChange(value === "true"));
|
||||
},
|
||||
[item, run],
|
||||
);
|
||||
|
||||
const select = item.type === "select" ? item : null;
|
||||
const trigger = (
|
||||
<AgentControlTrigger
|
||||
ref={anchorRef}
|
||||
icon={item.icon}
|
||||
iconColor={item.highlight ? styles[item.highlight].color : undefined}
|
||||
surface={surface}
|
||||
label={item.title}
|
||||
value={view.value}
|
||||
showToolbarLabel={!view.iconOnly}
|
||||
showCaret={select?.showCaret === true && !isSheet}
|
||||
open={view.opensList && open}
|
||||
disabled={disabled || pending}
|
||||
onPress={view.opensList ? handleOpenPress : handlePress}
|
||||
accessibilityLabel={item.accessibilityLabel ?? view.tooltip}
|
||||
testID={item.testID}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSheet ? (
|
||||
trigger
|
||||
) : (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{view.tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{view.opensList ? (
|
||||
<Combobox
|
||||
options={view.options}
|
||||
value={view.selectedId}
|
||||
onSelect={handleSelect}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
presentation={isSheet ? "push" : undefined}
|
||||
desktopPlacement={isSheet ? undefined : "top-start"}
|
||||
desktopMinWidth={select?.desktopMinWidth}
|
||||
searchable={select ? select.searchable : false}
|
||||
renderOption={select?.renderOption}
|
||||
header={isSheet ? sheetHeader : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ComposerControlsProps {
|
||||
items: readonly ComposerControlItem[];
|
||||
surface: ComposerControlSurface;
|
||||
disabled?: boolean;
|
||||
/** The id of the item whose list is open. */
|
||||
openId: string | null;
|
||||
onOpenIdChange: (id: string | null) => void;
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
/** A row of Paseo-owned controls that share one open list at a time. */
|
||||
export function ComposerControls({
|
||||
items,
|
||||
onOpenIdChange,
|
||||
openId,
|
||||
...rest
|
||||
}: ComposerControlsProps) {
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<ComposerControlSlot
|
||||
key={item.id}
|
||||
item={item}
|
||||
open={openId === item.id}
|
||||
onOpenIdChange={onOpenIdChange}
|
||||
{...rest}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerControlSlot({
|
||||
item,
|
||||
open,
|
||||
onOpenIdChange,
|
||||
...rest
|
||||
}: Omit<ComposerControlsProps, "items" | "openId"> & { item: ComposerControlItem; open: boolean }) {
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => onOpenIdChange(next ? item.id : null),
|
||||
[item.id, onOpenIdChange],
|
||||
);
|
||||
return <ComposerControl item={item} open={open} onOpenChange={handleOpenChange} {...rest} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
blue: { color: theme.colors.palette.blue[400] },
|
||||
green: { color: theme.colors.palette.green[400] },
|
||||
yellow: { color: theme.colors.palette.yellow[400] },
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
|
||||
import {
|
||||
getControlTooltip,
|
||||
isControlActive,
|
||||
readSelectOption,
|
||||
readToggleOption,
|
||||
type ComposerOptionControls,
|
||||
} from "@/agent-controls/option-controls";
|
||||
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import {
|
||||
type ComposerControlItem,
|
||||
type ComposerControlSurface,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
|
||||
import { getAgentControlHintKey, getFeatureHighlightColor } from "@/composer/agent-controls/utils";
|
||||
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
function getFeatureHighlight(
|
||||
control: AgentOptionControl,
|
||||
options: ProviderOptions,
|
||||
): ComposerControlItem["highlight"] {
|
||||
if (!isControlActive(control, options)) return undefined;
|
||||
const color = getFeatureHighlightColor(control.key);
|
||||
return color === "default" ? undefined : color;
|
||||
}
|
||||
|
||||
function toFeatureItem(
|
||||
control: AgentOptionControl,
|
||||
optionControls: ComposerOptionControls,
|
||||
): ComposerControlItem {
|
||||
const { options, onSetOption } = optionControls;
|
||||
const base = {
|
||||
id: `feature-${control.key}`,
|
||||
title: control.label,
|
||||
icon: getAgentFeatureIcon(control.icon),
|
||||
highlight: getFeatureHighlight(control, options),
|
||||
testID: `agent-feature-${control.key}`,
|
||||
};
|
||||
if (control.type === "toggle") {
|
||||
return {
|
||||
...base,
|
||||
type: "toggle",
|
||||
tooltip: getControlTooltip(control),
|
||||
value: readToggleOption(control, options),
|
||||
onChange: (value) => onSetOption(control.key, value),
|
||||
};
|
||||
}
|
||||
const iconOnly = control.desktopTrigger === "icon";
|
||||
return {
|
||||
...base,
|
||||
type: "select",
|
||||
tooltip: getControlTooltip(control),
|
||||
value: readSelectOption(control, options),
|
||||
choices: control.choices,
|
||||
iconOnly,
|
||||
onChange: (value) => onSetOption(control.key, value),
|
||||
};
|
||||
}
|
||||
|
||||
interface ThinkingControl {
|
||||
options: readonly { id: string; label: string }[];
|
||||
selectedId: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
/** The select's displayed label, falling back like the compact model sheet. */
|
||||
displayLabel: string;
|
||||
}
|
||||
|
||||
/** The thinking select and the option controls, as the data `ComposerControl` draws. */
|
||||
export function useAgentControlItems({
|
||||
surface,
|
||||
presentation,
|
||||
thinking,
|
||||
optionControls,
|
||||
}: {
|
||||
surface: ComposerControlSurface;
|
||||
presentation: ComposerControlPresentation;
|
||||
thinking: ThinkingControl | null;
|
||||
optionControls: ComposerOptionControls | undefined;
|
||||
}): { thinking: ComposerControlItem[]; features: ComposerControlItem[] } {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const thinkingItem = useMemo(
|
||||
() => (thinking ? [toThinkingItem({ thinking, surface, presentation, t })] : []),
|
||||
[presentation, surface, t, thinking],
|
||||
);
|
||||
const features = useMemo(
|
||||
() =>
|
||||
optionControls
|
||||
? optionControls.controls.map((control) => toFeatureItem(control, optionControls))
|
||||
: [],
|
||||
[optionControls],
|
||||
);
|
||||
return { thinking: thinkingItem, features };
|
||||
}
|
||||
|
||||
function toThinkingItem({
|
||||
thinking,
|
||||
surface,
|
||||
presentation,
|
||||
t,
|
||||
}: {
|
||||
thinking: ThinkingControl;
|
||||
surface: ComposerControlSurface;
|
||||
presentation: ComposerControlPresentation;
|
||||
t: TFunction;
|
||||
}): ComposerControlItem {
|
||||
const isSheet = surface === "sheet";
|
||||
return {
|
||||
id: "thinking",
|
||||
type: "select",
|
||||
title: t("agentControls.thinking.title"),
|
||||
icon: ThinkingIcon,
|
||||
tooltip: t(getAgentControlHintKey("thinking")),
|
||||
accessibilityLabel: t("agentControls.thinking.selectWithValue", {
|
||||
value: thinking.displayLabel,
|
||||
}),
|
||||
testID: isSheet ? "agent-controls-thinking" : "agent-thinking-selector",
|
||||
value: thinking.options.some((option) => option.id === thinking.selectedId)
|
||||
? (thinking.selectedId ?? null)
|
||||
: (thinking.options[0]?.id ?? null),
|
||||
choices: thinking.options.map((option) => ({ value: option.id, label: option.label })),
|
||||
onChange: thinking.onSelect,
|
||||
iconOnly: !presentation.showThinkingLabel,
|
||||
showCaret: presentation.showCarets,
|
||||
searchable: !isSheet && thinking.options.length > SEARCH_THRESHOLD,
|
||||
desktopMinWidth: 200,
|
||||
renderOption: (args) => <ThinkingOption {...args} />,
|
||||
};
|
||||
}
|
||||
|
||||
function ThinkingOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const leadingSlot = useMemo(
|
||||
() => <ThinkingIcon size={16} color={styles.thinkingIcon.color} />,
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
thinkingIcon: { color: theme.colors.foreground },
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" as const }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
};
|
||||
|
||||
@@ -95,6 +96,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
@@ -107,6 +109,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
@@ -119,6 +122,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1.25,
|
||||
},
|
||||
}),
|
||||
@@ -133,6 +137,7 @@ describe("composer control layout", () => {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
},
|
||||
};
|
||||
@@ -154,6 +159,34 @@ describe("composer control layout", () => {
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("condenses to make room for plugin items and keeps them in the overflow", () => {
|
||||
const base = {
|
||||
availableWidth: 430,
|
||||
currentDensity: "full" as const,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [],
|
||||
fontScale: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }),
|
||||
).toBe("condensed");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
availableWidth: 250,
|
||||
controls: { ...base.controls, pluginItems: 2 },
|
||||
}),
|
||||
).toBe("tight");
|
||||
});
|
||||
|
||||
it("gives every toolbar control one shell and one platform glyph envelope", () => {
|
||||
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
|
||||
controlSize: 28,
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface ComposerControlPresence {
|
||||
hasThinking: boolean;
|
||||
hasMode: boolean;
|
||||
features: readonly ComposerFeatureControlPresence[];
|
||||
/** Plugin composer items. Each is budgeted like a select with a short label. */
|
||||
pluginItems: number;
|
||||
fontScale: number;
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ export const COMPOSER_TOOLBAR_GEOMETRY = {
|
||||
} as const;
|
||||
|
||||
const DENSITY_HYSTERESIS = 12;
|
||||
const PLUGIN_ITEM_LABEL = "Plugin item";
|
||||
|
||||
function normalizedFontScale(fontScale: number): number {
|
||||
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
|
||||
@@ -62,7 +65,9 @@ function resolveCondensedFloor(controls: ComposerControlPresence): number {
|
||||
if (controls.hasModel) widths.push(36 + 60 * fontScale);
|
||||
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
if (controls.hasMode) widths.push(36 + 96 * fontScale);
|
||||
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
if (controls.features.length > 0 || controls.pluginItems > 0) {
|
||||
widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
}
|
||||
return sumControlWidths(widths);
|
||||
}
|
||||
|
||||
@@ -75,6 +80,11 @@ function resolveFullFloor(controls: ComposerControlPresence): number {
|
||||
for (const feature of controls.features) {
|
||||
widths.push(resolveFeatureControlWidth(feature, fontScale));
|
||||
}
|
||||
for (let index = 0; index < controls.pluginItems; index++) {
|
||||
widths.push(
|
||||
resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale),
|
||||
);
|
||||
}
|
||||
return sumControlWidths(widths);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,19 @@ describe("resolveAgentControlsMode", () => {
|
||||
onSelectMode: () => undefined,
|
||||
models: [],
|
||||
selectedModel: "",
|
||||
onSelectModel: () => undefined,
|
||||
onSelectModel: () => true,
|
||||
isModelLoading: false,
|
||||
modelSelectorProviders: [],
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
onSelectProviderAndModel: () => true,
|
||||
options: {},
|
||||
onSetOption: () => undefined,
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: "",
|
||||
onSelectThinkingOption: () => undefined,
|
||||
onApplyAgentProfile: () => undefined,
|
||||
metadata: {},
|
||||
onSetMetadata: () => undefined,
|
||||
}),
|
||||
).toBe("draft");
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
|
||||
@@ -43,6 +44,8 @@ interface CompactModelSheetProps {
|
||||
serverId?: string | null;
|
||||
glyphSize: number;
|
||||
canSwitchProvider: boolean;
|
||||
/** Carries the composer's contexts into the sheets, which draw away from it. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -95,6 +98,7 @@ export function CompactModelSheet({
|
||||
serverId = null,
|
||||
glyphSize,
|
||||
canSwitchProvider,
|
||||
contextBridge = null,
|
||||
children,
|
||||
}: CompactModelSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -299,6 +303,7 @@ export function CompactModelSheet({
|
||||
snapPoints={SNAP_POINTS}
|
||||
scrollable={false}
|
||||
contentStyle={styles.sheetBody}
|
||||
contextBridge={contextBridge}
|
||||
testID="agent-controls-model-sheet"
|
||||
>
|
||||
<View
|
||||
@@ -347,6 +352,7 @@ export function CompactModelSheet({
|
||||
scrollable={false}
|
||||
presentation="push"
|
||||
contentStyle={styles.sheetBody}
|
||||
contextBridge={contextBridge}
|
||||
testID="agent-controls-model-browser-sheet"
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback } from "react";
|
||||
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { ComposerControlLayoutProvider, useComposerControlLayout } from "./layout-context";
|
||||
import {
|
||||
ComposerAgentProvider,
|
||||
ComposerInputProvider,
|
||||
useComposerAgent,
|
||||
useComposerInput,
|
||||
} from "@/composer/session";
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
export function useComposerSheetBridge(): ContextBridge {
|
||||
const layout = useComposerControlLayout();
|
||||
const agent = useComposerAgent();
|
||||
const input = useComposerInput();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback<ContextBridge>(
|
||||
(content) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerControlLayoutProvider value={layout}>
|
||||
<ComposerAgentProvider value={agent}>
|
||||
{input ? (
|
||||
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</ComposerAgentProvider>
|
||||
</ComposerControlLayoutProvider>
|
||||
</ToastApiProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
[agent, input, layout, queryClient, toast],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import {
|
||||
draftControlsRequest,
|
||||
openDraftOptions,
|
||||
shownDraftOptions,
|
||||
} from "@/agent-controls/draft-options-model";
|
||||
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
|
||||
import { draftComposerAgent } from "./composer-agent";
|
||||
|
||||
function definition(id: string, modeId: string): AgentProviderDefinition {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
description: id,
|
||||
defaultModeId: modeId,
|
||||
modes: [{ id: modeId, label: modeId, icon: "ShieldCheck", colorTier: "safe" }],
|
||||
};
|
||||
}
|
||||
|
||||
function model(provider: string, id: string, isDefault = false): AgentModelDefinition {
|
||||
return { provider, id, label: id, isDefault };
|
||||
}
|
||||
|
||||
/** The host: two providers with catalogs, and one that is ready but lists no models. */
|
||||
const catalog: AgentFormCatalog = {
|
||||
selectableProviders: new Map([
|
||||
["claude", definition("claude", "default")],
|
||||
["codex", definition("codex", "auto")],
|
||||
["opencode", definition("opencode", "build")],
|
||||
]),
|
||||
models: new Map([
|
||||
["claude", [model("claude", "sonnet", true)]],
|
||||
["codex", [model("codex", "gpt-5.4", true), model("codex", "gpt-5.5")]],
|
||||
["opencode", []],
|
||||
]),
|
||||
providerPreferences: () => undefined,
|
||||
};
|
||||
|
||||
/** A draft composer on the real form and options models. */
|
||||
function openDraft(context: "draft" | "new-workspace") {
|
||||
const form = openAgentForm(() => catalog);
|
||||
const options = openDraftOptions({
|
||||
provider: null,
|
||||
currentProvider: () => form.getState().form.provider,
|
||||
});
|
||||
// The screen hands the options the provider the form reaches.
|
||||
form.subscribe(() => options.observeProvider(form.getState().form.provider));
|
||||
form.setProviderAndModel("claude", "");
|
||||
|
||||
function agent() {
|
||||
const { form: current } = form.getState();
|
||||
return draftComposerAgent({
|
||||
context,
|
||||
selectedProvider: current.provider,
|
||||
selectedModel: current.model,
|
||||
selectedMode: current.modeId,
|
||||
thinking: current.thinkingOptionId || null,
|
||||
isLocked: () => false,
|
||||
options: shownDraftOptions(options.getState(), {
|
||||
provider: current.provider,
|
||||
requestKey: "",
|
||||
}),
|
||||
metadata: {},
|
||||
onSelectProviderAndModel: form.setProviderAndModel,
|
||||
onSelectModel: form.setModel,
|
||||
onSelectMode: () => undefined,
|
||||
onSelectThinkingOption: () => undefined,
|
||||
onSetOption: options.setOption,
|
||||
onSetMetadata: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** What the daemon is asked to resolve for the agent this draft would create. */
|
||||
function request() {
|
||||
const { form: current } = form.getState();
|
||||
return draftControlsRequest(options.getState(), {
|
||||
provider: current.provider,
|
||||
cwd: "/repo",
|
||||
modeId: current.modeId,
|
||||
modelId: current.model,
|
||||
});
|
||||
}
|
||||
|
||||
return { form, agent, request };
|
||||
}
|
||||
|
||||
describe("a draft's options", () => {
|
||||
it("belong to the provider the form has now, before the screen has rendered the change", () => {
|
||||
const form = openAgentForm(() => catalog);
|
||||
const options = openDraftOptions({
|
||||
provider: null,
|
||||
currentProvider: () => form.getState().form.provider,
|
||||
});
|
||||
// No subscription: the screen observes the provider in an effect, after the command ran.
|
||||
form.setProviderAndModel("claude", "");
|
||||
options.observeProvider("claude");
|
||||
options.setOption("context", "1m");
|
||||
|
||||
form.setProviderAndModel("codex", "");
|
||||
options.setOption("effort", "high");
|
||||
|
||||
expect(options.getState()).toMatchObject({ provider: "codex", edits: { effort: "high" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (context) => {
|
||||
it("applies each awaited command before the next one runs", async () => {
|
||||
const draft = openDraft(context);
|
||||
// A plugin keeps the object it was handed across its awaits.
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setProvider("codex");
|
||||
await retained.setModel("gpt-5.5");
|
||||
await retained.setOptions({ context: "1m" });
|
||||
|
||||
const agent = draft.agent();
|
||||
expect(agent).toMatchObject({
|
||||
context,
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
options: { context: "1m" },
|
||||
});
|
||||
expect(draft.request()).toMatchObject({
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
providerOptions: { context: "1m" },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts the options over when the provider is chosen by hand", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setOptions({ context: "1m" });
|
||||
expect(draft.agent().options).toEqual({ context: "1m" });
|
||||
await retained.setProvider("codex");
|
||||
|
||||
expect(draft.agent().options).toEqual({});
|
||||
});
|
||||
|
||||
it("creates with the provider's default again once a plugin resets an option", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setOptions({ context: "1m", effort: "high" });
|
||||
await retained.setOptions({ context: null });
|
||||
|
||||
expect(draft.agent().options).toEqual({ effort: "high" });
|
||||
expect(draft.request()?.providerOptions).toEqual({ effort: "high" });
|
||||
|
||||
await retained.setOptions({ effort: null });
|
||||
|
||||
expect(draft.agent().options).toEqual({});
|
||||
expect(draft.request()?.providerOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a provider the host cannot run and a model the catalog does not list", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await expect(retained.setProvider("nope")).rejects.toThrow(
|
||||
"Unknown or unavailable provider: nope",
|
||||
);
|
||||
expect(draft.agent().provider).toBe("claude");
|
||||
|
||||
await retained.setProvider("codex");
|
||||
await expect(retained.setModel("missing")).rejects.toThrow("Unknown model: missing");
|
||||
expect(draft.agent()).toMatchObject({ provider: "codex", model: "gpt-5.4" });
|
||||
});
|
||||
|
||||
it("keeps a model id as given when the provider lists no catalog", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setProvider("opencode");
|
||||
await retained.setModel("anthropic/claude-x");
|
||||
|
||||
expect(draft.agent().model).toBe("anthropic/claude-x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ComposerAgent } from "@/composer/session";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
|
||||
/**
|
||||
* The agent a draft composer creates, as a plugin sees it. Its commands go to the form and the
|
||||
* options model, which apply them as they run, so a plugin can `await` one and issue the next.
|
||||
*/
|
||||
export function draftComposerAgent(
|
||||
props: Pick<
|
||||
DraftAgentControlsProps,
|
||||
| "context"
|
||||
| "disabled"
|
||||
| "selectedProvider"
|
||||
| "selectedModel"
|
||||
| "selectedMode"
|
||||
| "options"
|
||||
| "metadata"
|
||||
| "onSelectProviderAndModel"
|
||||
| "onSelectModel"
|
||||
| "onSelectMode"
|
||||
| "onSelectThinkingOption"
|
||||
| "onSetOption"
|
||||
| "onSetMetadata"
|
||||
> & { thinking: string | null; isLocked: () => boolean },
|
||||
): ComposerAgent {
|
||||
return {
|
||||
context: props.context ?? "draft",
|
||||
locked: props.disabled ?? false,
|
||||
isLocked: props.isLocked,
|
||||
provider: props.selectedProvider,
|
||||
model: props.selectedModel || null,
|
||||
mode: props.selectedMode || null,
|
||||
thinking: props.thinking,
|
||||
options: props.options,
|
||||
metadata: props.metadata,
|
||||
// An empty model id picks the provider's default model.
|
||||
setProvider: async (provider) => {
|
||||
if (!props.onSelectProviderAndModel(provider, "")) {
|
||||
throw new Error(`Unknown or unavailable provider: ${provider}`);
|
||||
}
|
||||
},
|
||||
setModel: async (modelId) => {
|
||||
if (!props.onSelectModel(modelId)) throw new Error(`Unknown model: ${modelId}`);
|
||||
},
|
||||
setMode: async (modeId) => props.onSelectMode(modeId),
|
||||
setThinking: async (thinkingOptionId) => props.onSelectThinkingOption(thinkingOptionId),
|
||||
setOptions: async (patch) => {
|
||||
for (const [key, value] of Object.entries(patch)) props.onSetOption(key, value);
|
||||
},
|
||||
setMetadata: async (patch) => props.onSetMetadata(patch),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||
@@ -21,10 +22,25 @@ export function resolveDraftKey(input: {
|
||||
export function buildDraftAgentControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
optionControls?: DraftAgentControlsProps["optionControls"];
|
||||
options: DraftAgentControlsProps["options"];
|
||||
onSetOption: DraftAgentControlsProps["onSetOption"];
|
||||
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
|
||||
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
|
||||
context: NonNullable<DraftAgentControlsProps["context"]>;
|
||||
metadata: Record<string, JsonValue>;
|
||||
onSetMetadata: (patch: Record<string, JsonValue>) => void;
|
||||
}): DraftAgentControlsProps {
|
||||
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input;
|
||||
const {
|
||||
formState,
|
||||
optionControls,
|
||||
options,
|
||||
onSetOption,
|
||||
onApplyAgentProfile,
|
||||
onDropdownClose,
|
||||
context,
|
||||
metadata,
|
||||
onSetMetadata,
|
||||
} = input;
|
||||
return {
|
||||
providerDefinitions: formState.providerDefinitions,
|
||||
selectedProvider: formState.selectedProvider,
|
||||
@@ -43,11 +59,16 @@ export function buildDraftAgentControls(input: {
|
||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||
onApplyAgentProfile,
|
||||
optionControls,
|
||||
options,
|
||||
onSetOption,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||
onRetryModelProvider: formState.refreshProviderModels,
|
||||
isRetryingModelProvider: formState.isProviderModelsRefreshing,
|
||||
modelSelectorServerId: formState.selectedServerId,
|
||||
context,
|
||||
metadata,
|
||||
onSetMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
@@ -28,6 +29,8 @@ import { useShallow } from "zustand/shallow";
|
||||
import type { ComposerTextSource } from "@/composer/text-source";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const NO_METADATA: Record<string, JsonValue> = {};
|
||||
|
||||
type AttachmentUpdater =
|
||||
| UserComposerAttachment[]
|
||||
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||
@@ -36,6 +39,8 @@ interface AgentInputDraftComposerOptions {
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialProviderOptions?: Record<string, unknown>;
|
||||
/** Which composer a plugin's `useComposer()` reports. */
|
||||
context?: "draft" | "new-workspace";
|
||||
isVisible?: boolean;
|
||||
lockedWorkingDir?: string;
|
||||
}
|
||||
@@ -51,6 +56,8 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
providerOptions: Record<string, unknown> | undefined;
|
||||
/** What plugins set on the agent this draft creates. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
agentControls: DraftAgentControlsProps;
|
||||
commandDraft: DraftCommandTarget;
|
||||
};
|
||||
@@ -191,10 +198,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
const metadata = useDraftStore((state) => state.metadataByDraftKey[draftKey] ?? NO_METADATA);
|
||||
const setMetadata = useCallback(
|
||||
(patch: Record<string, JsonValue>) =>
|
||||
useDraftStore.getState().patchDraftMetadata({ draftKey, patch }),
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
const clear = useCallback(
|
||||
(lifecycle: "sent" | "abandoned") => {
|
||||
textPublication.cancel();
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
||||
if (lifecycle === "sent") useDraftStore.getState().clearDraftMetadata({ draftKey });
|
||||
},
|
||||
[draftKey, textPublication],
|
||||
);
|
||||
@@ -276,10 +291,13 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
const {
|
||||
optionControls: draftOptionControls,
|
||||
options: draftOptions,
|
||||
shownOptions: draftShownOptions,
|
||||
setOption: setDraftOption,
|
||||
applyProfile: applyProfileOptions,
|
||||
} = useDraftProviderOptions({
|
||||
serverId: formState.selectedServerId,
|
||||
provider: formState.selectedProvider,
|
||||
getProvider: formState.getSelectedProvider,
|
||||
cwd: workingDir,
|
||||
modeId: formState.selectedMode,
|
||||
modelId: effectiveModelId,
|
||||
@@ -319,10 +337,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
providerOptions: draftOptions,
|
||||
metadata,
|
||||
agentControls: buildDraftAgentControls({
|
||||
formState,
|
||||
optionControls: draftOptionControls,
|
||||
options: draftShownOptions,
|
||||
onSetOption: setDraftOption,
|
||||
onApplyAgentProfile: applyDraftAgentProfile,
|
||||
context: composerOptions.context ?? "draft",
|
||||
metadata,
|
||||
onSetMetadata: setMetadata,
|
||||
}),
|
||||
commandDraft,
|
||||
};
|
||||
@@ -334,8 +358,12 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveThinkingOptionId,
|
||||
draftOptionControls,
|
||||
draftOptions,
|
||||
draftShownOptions,
|
||||
setDraftOption,
|
||||
applyDraftAgentProfile,
|
||||
formState,
|
||||
metadata,
|
||||
setMetadata,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyMetadataPatch } from "./metadata";
|
||||
|
||||
describe("applyMetadataPatch", () => {
|
||||
it("replaces top-level keys and removes keys patched to null", () => {
|
||||
expect(
|
||||
applyMetadataPatch(
|
||||
{ "linear.issue": { id: "ENG-1" }, review: true },
|
||||
{ "linear.issue": { id: "ENG-2" }, review: null, extra: [1] },
|
||||
),
|
||||
).toEqual({ "linear.issue": { id: "ENG-2" }, extra: [1] });
|
||||
});
|
||||
|
||||
it("leaves the current record untouched", () => {
|
||||
const current = { review: true };
|
||||
applyMetadataPatch(current, { review: null });
|
||||
expect(current).toEqual({ review: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
/** Top-level keys replace; `null` removes the key. The daemon applies the same rule to an agent. */
|
||||
export function applyMetadataPatch(
|
||||
current: Readonly<Record<string, JsonValue>>,
|
||||
patch: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> {
|
||||
const next = { ...current };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) delete next[key];
|
||||
else next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -133,6 +134,7 @@ async function submitDraftCreateRequest(input: {
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
providerOptions: Record<string, unknown> | undefined;
|
||||
metadata: Record<string, JsonValue>;
|
||||
};
|
||||
hostDisconnectedMessage: string;
|
||||
selectModelMessage: string;
|
||||
@@ -182,6 +184,7 @@ async function submitDraftCreateRequest(input: {
|
||||
workspaceId,
|
||||
initialPrompt: text,
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}),
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||
};
|
||||
@@ -510,7 +513,9 @@ export function WorkspaceDraftAgentTab({
|
||||
providers: composerState.modelSelectorProviders,
|
||||
selectedProvider: draftProvider,
|
||||
selectedModelId: composerState.effectiveModelId,
|
||||
select: composerState.setProviderAndModelFromUser,
|
||||
select: (provider, modelId) => {
|
||||
composerState.setProviderAndModelFromUser(provider, modelId);
|
||||
},
|
||||
},
|
||||
thinking: {
|
||||
options: draftThinkingOptions,
|
||||
|
||||
@@ -139,6 +139,8 @@ import type {
|
||||
} from "@/attachments/types";
|
||||
import type { SelectedFile } from "@/attachments/selected-file";
|
||||
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
|
||||
import { ComposerInputProvider, type ComposerInput } from "@/composer/session";
|
||||
import { insertAtSelection } from "@/composer/insert-text";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
|
||||
import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop";
|
||||
@@ -1419,6 +1421,40 @@ function ComposerContentImpl({
|
||||
[onChangeText],
|
||||
);
|
||||
|
||||
const insertUserInput = useCallback(
|
||||
(text: string) => {
|
||||
const input = messageInputRef.current;
|
||||
if (!input) {
|
||||
onChangeText(textSource.getSnapshot() + text);
|
||||
return;
|
||||
}
|
||||
const inserted = insertAtSelection(input.getInputSnapshot(), text);
|
||||
input.replaceText(inserted.text, inserted.selection);
|
||||
},
|
||||
[onChangeText, textSource],
|
||||
);
|
||||
const isComposerLockedRef = useRef(isComposerLocked);
|
||||
isComposerLockedRef.current = isComposerLocked;
|
||||
const composerInput = useMemo<ComposerInput>(
|
||||
() => ({
|
||||
locked: isComposerLocked,
|
||||
isLocked: () => isComposerLockedRef.current,
|
||||
textSource,
|
||||
setText: replaceUserInput,
|
||||
insertText: insertUserInput,
|
||||
attachments,
|
||||
changeAttachments: setSelectedAttachments,
|
||||
}),
|
||||
[
|
||||
attachments,
|
||||
insertUserInput,
|
||||
isComposerLocked,
|
||||
replaceUserInput,
|
||||
setSelectedAttachments,
|
||||
textSource,
|
||||
],
|
||||
);
|
||||
|
||||
const runClientSlashCommand = useCallback(
|
||||
(command: ClientSlashCommand): boolean => {
|
||||
if (command.execution !== "immediate" || !onClientSlashCommand) {
|
||||
@@ -2223,18 +2259,27 @@ function ComposerContentImpl({
|
||||
[attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
() =>
|
||||
renderLeftContent({
|
||||
agentControls,
|
||||
agentId,
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
showAgentControls: mode.showAgentControls,
|
||||
}),
|
||||
[agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId],
|
||||
);
|
||||
const leftContent = useMemo(() => {
|
||||
const controls = renderLeftContent({
|
||||
agentControls,
|
||||
agentId,
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
showAgentControls: mode.showAgentControls,
|
||||
});
|
||||
return controls ? (
|
||||
<ComposerInputProvider value={composerInput}>{controls}</ComposerInputProvider>
|
||||
) : null;
|
||||
}, [
|
||||
agentControls,
|
||||
agentId,
|
||||
composerInput,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
mode.showAgentControls,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
attachButtonRef.current = node;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { insertAtSelection } from "./insert-text";
|
||||
|
||||
describe("insertAtSelection", () => {
|
||||
it("inserts at the caret and moves the caret after the insertion", () => {
|
||||
expect(insertAtSelection({ text: "ab", selection: { start: 1, end: 1 } }, "XY")).toEqual({
|
||||
text: "aXYb",
|
||||
selection: { start: 3, end: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces the selection, however it was dragged", () => {
|
||||
expect(insertAtSelection({ text: "abcd", selection: { start: 3, end: 1 } }, "-")).toEqual({
|
||||
text: "a-d",
|
||||
selection: { start: 2, end: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ComposerInputSnapshot } from "@/composer/input/input";
|
||||
|
||||
/** The text with `inserted` in place of the selection, and the caret after it. */
|
||||
export function insertAtSelection(
|
||||
{ text, selection }: ComposerInputSnapshot,
|
||||
inserted: string,
|
||||
): ComposerInputSnapshot {
|
||||
const start = Math.min(selection.start, selection.end);
|
||||
const end = Math.max(selection.start, selection.end);
|
||||
const caret = start + inserted.length;
|
||||
return {
|
||||
text: text.slice(0, start) + inserted + text.slice(end),
|
||||
selection: { start: caret, end: caret },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ComposerTextSource } from "@/composer/text-source";
|
||||
|
||||
/**
|
||||
* The text and attachments of the composer a control sits in. `Composer` provides it, so a
|
||||
* control reaches the prompt without knowing which screen mounted the composer.
|
||||
*/
|
||||
export interface ComposerInput {
|
||||
/** The prompt is held while the composer submits; the built-in input is read-only meanwhile. */
|
||||
locked: boolean;
|
||||
/** `locked` as it is now. A command a plugin kept from an earlier render asks this. */
|
||||
isLocked: () => boolean;
|
||||
textSource: ComposerTextSource;
|
||||
setText: (text: string) => void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
insertText: (text: string) => void;
|
||||
attachments: readonly UserComposerAttachment[];
|
||||
changeAttachments: (
|
||||
updater: (current: UserComposerAttachment[]) => UserComposerAttachment[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent a composer configures, as one set of reads and commands. A running agent sends each
|
||||
* command to the daemon; a draft keeps it locally until the agent is created. Controls call the
|
||||
* same command either way.
|
||||
*/
|
||||
export interface ComposerAgent {
|
||||
context: "agent" | "draft" | "new-workspace";
|
||||
/** The agent is being created; the built-in controls are disabled meanwhile. */
|
||||
locked: boolean;
|
||||
/** `locked` as it is now. A command a plugin kept from an earlier render asks this. */
|
||||
isLocked: () => boolean;
|
||||
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>;
|
||||
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 ComposerInputContext = createContext<ComposerInput | null>(null);
|
||||
const ComposerAgentContext = createContext<ComposerAgent | null>(null);
|
||||
|
||||
export function ComposerInputProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ComposerInput;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ComposerInputContext.Provider value={value}>{children}</ComposerInputContext.Provider>;
|
||||
}
|
||||
|
||||
export function ComposerAgentProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ComposerAgent | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ComposerAgentContext.Provider value={value}>{children}</ComposerAgentContext.Provider>;
|
||||
}
|
||||
|
||||
export function useComposerInput(): ComposerInput | null {
|
||||
return useContext(ComposerInputContext);
|
||||
}
|
||||
|
||||
export function useComposerAgent(): ComposerAgent | null {
|
||||
return useContext(ComposerAgentContext);
|
||||
}
|
||||
|
||||
/** True while the composer holds its prompt or its agent's settings for a submit in flight. */
|
||||
export function useComposerLocked(): boolean {
|
||||
const input = useComposerInput();
|
||||
const agent = useComposerAgent();
|
||||
return Boolean(input?.locked || agent?.locked);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -28,14 +28,13 @@ import {
|
||||
mergeSelectedComposerPreferences,
|
||||
buildProviderDefinitionMap,
|
||||
buildProviderDefinitionMapForStatuses,
|
||||
INITIAL_AGENT_FORM_RESOLUTION,
|
||||
INITIAL_USER_MODIFIED,
|
||||
RESOLVABLE_PROVIDER_STATUSES,
|
||||
SELECTABLE_PROVIDER_STATUSES,
|
||||
type FormInitialValues,
|
||||
type FormState,
|
||||
type ProviderModelsByProvider,
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { MaterializedAgentProfile } from "@/agent-profiles";
|
||||
|
||||
@@ -52,10 +51,13 @@ export interface UseAgentFormStateOptions {
|
||||
export interface UseAgentFormStateResult {
|
||||
selectedServerId: string | null;
|
||||
selectedProvider: AgentProvider | null;
|
||||
/** The provider as it is now, which a render may not have reached yet. */
|
||||
getSelectedProvider: () => AgentProvider | null;
|
||||
selectedMode: string;
|
||||
setModeFromUser: (modeId: string) => void;
|
||||
selectedModel: string;
|
||||
setModelFromUser: (modelId: string) => void;
|
||||
/** `false` when the provider's loaded catalog does not list the model. */
|
||||
setModelFromUser: (modelId: string) => boolean;
|
||||
selectedThinkingOptionId: string;
|
||||
setThinkingOptionFromUser: (thinkingOptionId: string) => void;
|
||||
workingDir: string;
|
||||
@@ -74,13 +76,20 @@ export interface UseAgentFormStateResult {
|
||||
modelError: string | null;
|
||||
refreshProviderModels: (provider?: AgentProvider) => void;
|
||||
refetchProviderModelsIfStale: () => void;
|
||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||
/** `false` when the provider is unknown or not ready. */
|
||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => boolean;
|
||||
applyProfileFromUser: (profile: MaterializedAgentProfile) => void;
|
||||
clearProviderSelectionFromUser: () => void;
|
||||
workingDirIsEmpty: boolean;
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
}
|
||||
|
||||
const EMPTY_CATALOG: AgentFormCatalog = {
|
||||
selectableProviders: new Map(),
|
||||
models: new Map(),
|
||||
providerPreferences: () => undefined,
|
||||
};
|
||||
|
||||
function resolveSelectedProviderModes(input: {
|
||||
selectedEntry: ProviderSnapshotEntry | null;
|
||||
provider: AgentProvider | null;
|
||||
@@ -172,11 +181,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
const [{ form: formState, userModified, resolution }, dispatch] = useReducer(resolveAgentForm, {
|
||||
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
|
||||
userModified: INITIAL_USER_MODIFIED,
|
||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
||||
});
|
||||
// The store reads what the host offers when a command runs; each render publishes the latest.
|
||||
const catalogRef = useRef<AgentFormCatalog>(EMPTY_CATALOG);
|
||||
const [formStore] = useState(() => openAgentForm(() => catalogRef.current));
|
||||
const { form: formState, resolution } = useSyncExternalStore(
|
||||
formStore.subscribe,
|
||||
formStore.getState,
|
||||
formStore.getState,
|
||||
);
|
||||
const dispatch = formStore.dispatch;
|
||||
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
@@ -246,6 +259,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||
const allProviderModels = snapshotAllProviderModels;
|
||||
catalogRef.current = {
|
||||
selectableProviders: selectableProviderDefinitionMap,
|
||||
models: allProviderModels,
|
||||
providerPreferences: (provider) =>
|
||||
preferenceOverlayRef.current.current().providerPreferences?.[provider],
|
||||
};
|
||||
const modelSelectorProviders = snapshotModelSelectorProviders;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
@@ -277,27 +296,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
preferences,
|
||||
snapshotProviderModelsByProvider,
|
||||
snapshotResolvableProviderDefinitionMap,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
(provider: AgentProvider, modelId: string) => {
|
||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||
return;
|
||||
}
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
(provider: AgentProvider, modelId: string): boolean => {
|
||||
if (!formStore.setProviderAndModel(provider, modelId)) return false;
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider,
|
||||
modelId,
|
||||
providerDef,
|
||||
providerModels,
|
||||
providerPrefs,
|
||||
});
|
||||
const nextModelId =
|
||||
normalizeSelectedModelId(modelId) || resolveDefaultModelId(providerModels);
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
@@ -307,13 +314,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
},
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||
[allProviderModels, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const clearProviderSelectionFromUser = useCallback(() => {
|
||||
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
const applyProfileFromUser = useCallback(
|
||||
(profile: MaterializedAgentProfile) => {
|
||||
@@ -322,7 +330,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
return;
|
||||
}
|
||||
|
||||
const previousProvider = formState.provider;
|
||||
const formBefore = formStore.getState();
|
||||
const previousProvider = formBefore.form.provider;
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||
@@ -336,7 +345,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
providerModels,
|
||||
providerPrefs,
|
||||
};
|
||||
const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action);
|
||||
const nextState = resolveAgentForm(formBefore, action);
|
||||
const previousProviderModeIds = previousProvider
|
||||
? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? [])
|
||||
: [];
|
||||
@@ -360,19 +369,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
},
|
||||
[
|
||||
allProviderModels,
|
||||
formState,
|
||||
dispatch,
|
||||
formStore,
|
||||
providerDefinitionMap,
|
||||
resolution,
|
||||
selectableProviderDefinitionMap,
|
||||
updateCurrentPreferences,
|
||||
userModified,
|
||||
],
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||
const provider = formState.provider;
|
||||
const { provider } = formStore.getState().form;
|
||||
if (provider) {
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
@@ -385,24 +393,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
);
|
||||
}
|
||||
},
|
||||
[formState.provider, updateCurrentPreferences],
|
||||
[dispatch, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
const provider = formState.provider;
|
||||
const providerPrefs = provider
|
||||
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
|
||||
: undefined;
|
||||
dispatch({
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId,
|
||||
availableModels,
|
||||
providerPrefs,
|
||||
});
|
||||
(modelId: string): boolean => {
|
||||
if (!formStore.setModel(modelId)) return false;
|
||||
const { provider } = formStore.getState().form;
|
||||
if (provider) {
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||
const listed = allProviderModels.get(provider);
|
||||
const nextModelId =
|
||||
normalizeSelectedModelId(modelId) ||
|
||||
resolveDefaultModelId(listed?.length ? listed : null);
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
@@ -413,14 +415,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[availableModels, formState.provider, updateCurrentPreferences],
|
||||
[allProviderModels, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||
const { provider, model: modelId } = formState;
|
||||
const { provider, model: modelId } = formStore.getState().form;
|
||||
if (provider && modelId) {
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
@@ -435,9 +438,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
);
|
||||
}
|
||||
},
|
||||
[formState, updateCurrentPreferences],
|
||||
[dispatch, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const getSelectedProvider = useCallback(() => formStore.getState().form.provider, [formStore]);
|
||||
|
||||
const refreshProviderModels = useCallback(
|
||||
(provider?: AgentProvider) => {
|
||||
void refreshSnapshot(provider ? [provider] : undefined);
|
||||
@@ -479,6 +484,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
() => ({
|
||||
selectedServerId: serverId,
|
||||
selectedProvider: formState.provider,
|
||||
getSelectedProvider,
|
||||
selectedMode: formState.modeId,
|
||||
setModeFromUser,
|
||||
selectedModel: formState.model,
|
||||
@@ -509,6 +515,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
}),
|
||||
[
|
||||
serverId,
|
||||
getSelectedProvider,
|
||||
formState.provider,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
draftControlsRequest,
|
||||
openDraftOptions,
|
||||
ownDraftOptions,
|
||||
shownDraftOptions,
|
||||
viewDraftOptions,
|
||||
} from "@/agent-controls/draft-options-model";
|
||||
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||
@@ -19,6 +20,8 @@ import { mergeProviderPreferences, useFormPreferences } from "./use-form-prefere
|
||||
export function useDraftProviderOptions(input: {
|
||||
serverId: string | null | undefined;
|
||||
provider: AgentProvider | null;
|
||||
/** The form's provider as it is now, which a render may not have reached yet. */
|
||||
getProvider: () => AgentProvider | null;
|
||||
cwd: string | null | undefined;
|
||||
modeId: string | null | undefined;
|
||||
modelId: string | null | undefined;
|
||||
@@ -26,8 +29,19 @@ export function useDraftProviderOptions(input: {
|
||||
initialOptions?: ProviderOptions;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
|
||||
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
|
||||
const {
|
||||
serverId,
|
||||
provider,
|
||||
getProvider,
|
||||
cwd,
|
||||
modeId,
|
||||
modelId,
|
||||
thinkingOptionId,
|
||||
initialOptions,
|
||||
} = input;
|
||||
const [model] = useState(() =>
|
||||
openDraftOptions({ provider, seed: initialOptions, currentProvider: getProvider }),
|
||||
);
|
||||
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
@@ -77,21 +91,23 @@ export function useDraftProviderOptions(input: {
|
||||
if (provider && data) model.applyResolved({ provider, requestKey, ...data });
|
||||
}, [data, model, provider, requestKey]);
|
||||
|
||||
// An edit belongs to the provider the form has now, even before the render that shows it.
|
||||
const setOption = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
model.setOption(key, value);
|
||||
if (!provider) return;
|
||||
void updatePreferences((current) =>
|
||||
const current = getProvider();
|
||||
if (!current) return;
|
||||
void updatePreferences((stored) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider,
|
||||
preferences: stored,
|
||||
provider: current,
|
||||
updates: { options: { [key]: value } },
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[useDraftProviderOptions] persist option preference failed", error);
|
||||
});
|
||||
},
|
||||
[model, provider, updatePreferences],
|
||||
[getProvider, model, updatePreferences],
|
||||
);
|
||||
|
||||
const view = viewDraftOptions(state, { provider, requestKey });
|
||||
@@ -105,5 +121,14 @@ export function useDraftProviderOptions(input: {
|
||||
[setOption, viewControls, viewOptions],
|
||||
);
|
||||
|
||||
return { optionControls, options: ownOptions, applyProfile: model.applyProfile };
|
||||
// What the draft will create with, the plugin's own edits included, before the daemon answers.
|
||||
const shownOptions = shownDraftOptions(state, { provider, requestKey });
|
||||
|
||||
return {
|
||||
optionControls,
|
||||
options: ownOptions,
|
||||
shownOptions,
|
||||
setOption,
|
||||
applyProfile: model.applyProfile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -53,6 +53,7 @@ const agent: Agent = {
|
||||
attentionReason: "permission",
|
||||
parentAgentId: null,
|
||||
labels: { phase: "implementation" },
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
};
|
||||
|
||||
describe("plugin context snapshots", () => {
|
||||
@@ -107,12 +108,16 @@ describe("plugin context snapshots", () => {
|
||||
attentionReason: "permission",
|
||||
parentAgentId: null,
|
||||
labels: { phase: "implementation" },
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
});
|
||||
expect(snapshot.labels).not.toBe(agent.labels);
|
||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||
expect(Object.isFrozen(snapshot.labels)).toBe(true);
|
||||
expect(Reflect.set(snapshot, "title", "Hostile rename")).toBe(false);
|
||||
expect(Reflect.set(snapshot.labels, "phase", "hostile")).toBe(false);
|
||||
const issue = snapshot.metadata["linear.issue"] as { id: string };
|
||||
expect(Reflect.set(issue, "id", "hostile")).toBe(false);
|
||||
expect(agent.metadata?.["linear.issue"]).toEqual({ id: "ENG-1" });
|
||||
expect(snapshot.title).toBe("Implement plugin panels");
|
||||
expect(snapshot.labels).toEqual({ phase: "implementation" });
|
||||
expect(agent.title).toBe("Implement plugin panels");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
|
||||
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
|
||||
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
|
||||
@@ -49,6 +50,7 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
parentAgentId: agent.parentAgentId,
|
||||
labels: Object.freeze({ ...agent.labels }),
|
||||
metadata: cloneAndFreeze(agent.metadata ?? {}),
|
||||
});
|
||||
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
|
||||
byWorkspace.set(workspaceId, snapshot);
|
||||
|
||||
@@ -46,6 +46,7 @@ const agent: PluginAgentSnapshot = {
|
||||
attentionReason: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const inspect = defineRpc({
|
||||
@@ -82,6 +83,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import { PluginComposerProvider } from "@getpaseo/plugin/client/host";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ComposerControlSurfaceProvider,
|
||||
type ComposerControlSurface,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import { useComposerAgent, useComposerInput } from "@/composer/session";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { resolvePluginPlatform } from "../platform";
|
||||
import { PluginEnvironmentProvider, type PluginEnvironment } from "../popover";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { toPluginComposer } from "./model";
|
||||
|
||||
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
function renderNothing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function useServerPlugins(serverId: string | null): InstalledPlugin[] {
|
||||
const installed = useInstalledPlugins();
|
||||
return useMemo(
|
||||
() =>
|
||||
serverId === null
|
||||
? []
|
||||
: installed.filter(
|
||||
(plugin) => plugin.serverId === serverId && plugin.composerItems.length > 0,
|
||||
),
|
||||
[installed, serverId],
|
||||
);
|
||||
}
|
||||
|
||||
/** How many composer items the host's plugins registered; the toolbar budgets room for them. */
|
||||
export function usePluginComposerItemCount(serverId: string | null): number {
|
||||
const plugins = useServerPlugins(serverId);
|
||||
return plugins.reduce((count, plugin) => count + plugin.composerItems.length, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's plugin composer items. They read and command the composer they are mounted in, so
|
||||
* they render only where a composer provides its input and agent.
|
||||
*/
|
||||
export function PluginComposerItems({
|
||||
serverId,
|
||||
surface,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
surface: ComposerControlSurface;
|
||||
}) {
|
||||
const plugins = useServerPlugins(serverId);
|
||||
if (serverId === null || plugins.length === 0) return null;
|
||||
return (
|
||||
<ComposerControlSurfaceProvider value={surface}>
|
||||
{plugins.map((plugin) => (
|
||||
<ThemedPluginComposerItems key={plugin.id} plugin={plugin} uniProps={pluginThemeMapping} />
|
||||
))}
|
||||
</ComposerControlSurfaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginComposerItemsHost({
|
||||
plugin,
|
||||
theme,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
theme: PluginHostProps["theme"];
|
||||
}) {
|
||||
const agent = useComposerAgent();
|
||||
const composerInput = useComposerInput();
|
||||
const toast = useToast();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const hosts = useHosts();
|
||||
const hostLabel =
|
||||
hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId;
|
||||
const textSource = composerInput?.textSource;
|
||||
const prompt = useSyncExternalStore(
|
||||
textSource?.subscribe ?? subscribeNothing,
|
||||
textSource?.getSnapshot ?? emptyText,
|
||||
textSource?.getSnapshot ?? emptyText,
|
||||
);
|
||||
const environment = useMemo<PluginEnvironment>(
|
||||
() => ({
|
||||
installation: plugin,
|
||||
toast,
|
||||
state: createPluginClientStateSource(plugin.serverId),
|
||||
}),
|
||||
[plugin, toast],
|
||||
);
|
||||
const composer = useMemo(
|
||||
() =>
|
||||
agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null,
|
||||
[agent, composerInput, plugin, prompt],
|
||||
);
|
||||
const props = useMemo<PluginHostProps>(
|
||||
() => ({
|
||||
theme,
|
||||
host: { id: plugin.serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePluginPlatform() },
|
||||
}),
|
||||
[compact, hostLabel, plugin.serverId, theme],
|
||||
);
|
||||
if (!composer) return null;
|
||||
return (
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<PluginComposerProvider composer={composer}>
|
||||
{plugin.composerItems.map(({ id, Component }) => (
|
||||
<SurfaceErrorBoundary
|
||||
key={id}
|
||||
installation={plugin}
|
||||
Surface={Component}
|
||||
renderError={renderNothing}
|
||||
>
|
||||
<Component {...props} />
|
||||
</SurfaceErrorBoundary>
|
||||
))}
|
||||
</PluginComposerProvider>
|
||||
</PluginEnvironmentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginComposerItems = withUnistyles(PluginComposerItemsHost);
|
||||
|
||||
function subscribeNothing() {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
function emptyText() {
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
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 type { ComposerAgent, ComposerInput } from "@/composer/session";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { toPluginComposer } from "./model";
|
||||
|
||||
function installed(): InstalledPlugin {
|
||||
return {
|
||||
id: "review",
|
||||
serverId: "host-1",
|
||||
clientBundle: "bundle",
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [
|
||||
{
|
||||
id: "issues",
|
||||
title: "Issues",
|
||||
pickerTitle: "Issues",
|
||||
searchPlaceholder: "Search issues",
|
||||
icon: "Bug",
|
||||
search: defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({}),
|
||||
output: z.array(z.object({})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
}
|
||||
|
||||
const issue = {
|
||||
id: "ISS-1",
|
||||
identifier: "ISS-1",
|
||||
title: "Fix it",
|
||||
url: "https://example.com/ISS-1",
|
||||
text: "Fix it",
|
||||
resourceType: "issue",
|
||||
};
|
||||
|
||||
function fakeAgent(context: ComposerAgent["context"], locked = false) {
|
||||
const calls: string[] = [];
|
||||
const agent: ComposerAgent = {
|
||||
context,
|
||||
locked,
|
||||
isLocked: () => locked,
|
||||
provider: "claude",
|
||||
model: "sonnet",
|
||||
mode: "default",
|
||||
thinking: null,
|
||||
options: { effort: "high" },
|
||||
metadata: { flag: true },
|
||||
// A running agent keeps its provider, as the real adapter does.
|
||||
setProvider: async (provider) => {
|
||||
if (context === "agent") throw new Error("A running agent cannot change provider.");
|
||||
calls.push(`provider:${provider}`);
|
||||
},
|
||||
setModel: async (model) => void calls.push(`model:${model}`),
|
||||
setMode: async (mode) => void calls.push(`mode:${mode}`),
|
||||
setThinking: async (thinking) => void calls.push(`thinking:${thinking}`),
|
||||
setOptions: async (patch) => void calls.push(`options:${JSON.stringify(patch)}`),
|
||||
setMetadata: async (patch: Record<string, JsonValue>) =>
|
||||
void calls.push(`metadata:${JSON.stringify(patch)}`),
|
||||
};
|
||||
return { agent, calls };
|
||||
}
|
||||
|
||||
function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
|
||||
const state = { text: "", attachments: initial };
|
||||
const input: ComposerInput = {
|
||||
locked,
|
||||
isLocked: () => locked,
|
||||
textSource: { getSnapshot: () => state.text, subscribe: () => () => {} },
|
||||
setText: (text) => {
|
||||
state.text = text;
|
||||
},
|
||||
insertText: (text) => {
|
||||
state.text += text;
|
||||
},
|
||||
get attachments() {
|
||||
return state.attachments;
|
||||
},
|
||||
changeAttachments: (updater) => {
|
||||
state.attachments = updater(state.attachments);
|
||||
},
|
||||
};
|
||||
return { input, state };
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
expect(composer.context).toBe("new-workspace");
|
||||
expect(composer.prompt).toBe("hello");
|
||||
expect(composer.agent).toEqual({
|
||||
provider: "claude",
|
||||
model: "sonnet",
|
||||
mode: "default",
|
||||
thinking: null,
|
||||
options: { effort: "high" },
|
||||
metadata: { flag: true },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["agent", "draft"] as const)(
|
||||
"sends every command to the %s composer's agent",
|
||||
async (context) => {
|
||||
const { agent, calls } = fakeAgent(context);
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
await composer.setModel("gpt");
|
||||
await composer.setMode("plan");
|
||||
await composer.setThinking("high");
|
||||
await composer.setOptions({ effort: "low" });
|
||||
await composer.setMetadata({ flag: null });
|
||||
expect(calls).toEqual([
|
||||
"model:gpt",
|
||||
"mode:plan",
|
||||
"thinking:high",
|
||||
'options:{"effort":"low"}',
|
||||
'metadata:{"flag":null}',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
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");
|
||||
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.");
|
||||
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: "",
|
||||
});
|
||||
composer.setPrompt("a");
|
||||
composer.insertText("b");
|
||||
expect(state.text).toBe("ab");
|
||||
});
|
||||
|
||||
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: "",
|
||||
});
|
||||
|
||||
expect(() => composer.setPrompt("a")).toThrow("The composer is locked");
|
||||
expect(() => composer.insertText("b")).toThrow("The composer is locked");
|
||||
expect(() => composer.addAttachment({ sourceId: "issues", item: issue })).toThrow(
|
||||
"The composer is locked",
|
||||
);
|
||||
await expect(composer.setProvider("codex")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setModel("gpt")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setMode("plan")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setThinking("high")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setMetadata({ flag: null })).rejects.toThrow("The composer is locked");
|
||||
expect(state.text).toBe("");
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
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: "",
|
||||
});
|
||||
|
||||
// The plugin's action was awaiting an RPC when the user submitted.
|
||||
const lock = { locked: true };
|
||||
agent.isLocked = () => lock.locked;
|
||||
input.isLocked = () => lock.locked;
|
||||
|
||||
expect(() => composer.insertText("late")).toThrow("The composer is locked");
|
||||
expect(() => composer.addAttachment({ sourceId: "issues", item: issue })).toThrow(
|
||||
"The composer is locked",
|
||||
);
|
||||
expect(() => composer.removeAttachment({ sourceId: "issues", itemId: issue.id })).toThrow(
|
||||
"The composer is locked",
|
||||
);
|
||||
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
|
||||
});
|
||||
|
||||
it("hands out options and metadata a plugin cannot change", () => {
|
||||
const { agent } = fakeAgent("draft");
|
||||
agent.metadata = { nested: { list: [1] } };
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
|
||||
expect(Object.isFrozen(composer.agent.options)).toBe(true);
|
||||
expect(Object.isFrozen(composer.agent.metadata)).toBe(true);
|
||||
expect(Object.isFrozen((composer.agent.metadata as { nested: object }).nested)).toBe(true);
|
||||
expect(agent.metadata).toEqual({ nested: { list: [1] } });
|
||||
expect(Object.isFrozen(agent.metadata)).toBe(false);
|
||||
});
|
||||
|
||||
it("adds and removes the plugin's own attachments only", () => {
|
||||
const { agent } = fakeAgent("agent");
|
||||
const other: UserComposerAttachment = {
|
||||
kind: "plugin_resource",
|
||||
pluginId: "other",
|
||||
sourceId: "issues",
|
||||
sourceTitle: "Issues",
|
||||
sourceIcon: "Bug",
|
||||
item: issue,
|
||||
};
|
||||
const { input, state } = fakeInput([other]);
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
|
||||
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: "",
|
||||
});
|
||||
expect(next.attachments).toEqual([{ sourceId: "issues", item: issue }]);
|
||||
|
||||
composer.removeAttachment({ sourceId: "issues", itemId: "ISS-1" });
|
||||
expect(state.attachments).toEqual([other]);
|
||||
});
|
||||
|
||||
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: "",
|
||||
});
|
||||
expect(() => composer.addAttachment({ sourceId: "nope", item: issue })).toThrow(
|
||||
"Unknown attachment source: nope",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { InstalledPlugin } from "../types";
|
||||
import { createPluginResourceAttachment } from "../attachments/model";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
|
||||
function isPluginAttachment(
|
||||
attachment: UserComposerAttachment,
|
||||
pluginId: string,
|
||||
sourceId: string,
|
||||
itemId?: string,
|
||||
): boolean {
|
||||
return (
|
||||
attachment.kind === "plugin_resource" &&
|
||||
attachment.pluginId === pluginId &&
|
||||
attachment.sourceId === sourceId &&
|
||||
(itemId === undefined || attachment.item.id === itemId)
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export function toPluginComposer(input: {
|
||||
plugin: InstalledPlugin;
|
||||
agent: ComposerAgent;
|
||||
composerInput: ComposerInput;
|
||||
prompt: string;
|
||||
}): PluginComposer {
|
||||
const { plugin, agent, composerInput, prompt } = input;
|
||||
return {
|
||||
context: agent.context,
|
||||
prompt,
|
||||
attachments: composerInput.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);
|
||||
},
|
||||
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(
|
||||
{
|
||||
pluginId: plugin.id,
|
||||
sourceId,
|
||||
sourceTitle: plugin.i18n.resolve(source.title, plugin.locale),
|
||||
sourceIcon: source.icon,
|
||||
},
|
||||
item,
|
||||
);
|
||||
composerInput.changeAttachments((current) =>
|
||||
current.some((candidate) => isPluginAttachment(candidate, plugin.id, sourceId, item.id))
|
||||
? current
|
||||
: [...current, attachment],
|
||||
);
|
||||
},
|
||||
removeAttachment({ sourceId, itemId }) {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.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)),
|
||||
};
|
||||
}
|
||||
@@ -531,6 +531,38 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("Duplicate Command Center item: review");
|
||||
});
|
||||
|
||||
it("collects composer items and releases an id on removal", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
const remove = plugin.addComposerItem({ id: "flag", Component: Item });
|
||||
remove();
|
||||
plugin.addComposerItem({ id: "flag", Component: Item });
|
||||
`),
|
||||
);
|
||||
expect(plugin.composerItems.map((item) => item.id)).toEqual(["flag"]);
|
||||
});
|
||||
|
||||
it("rejects duplicate and non-component composer items", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
plugin.addComposerItem({ id: "flag", Component: Item });
|
||||
plugin.addComposerItem({ id: "flag", Component: Item });
|
||||
`),
|
||||
),
|
||||
).toThrow("Duplicate composer item: flag");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`plugin.addComposerItem({ id: "flag", Component: "nope" })`),
|
||||
),
|
||||
).toThrow("Composer item flag is not a component");
|
||||
});
|
||||
|
||||
it("runs the client entry with the full runtime context", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
|
||||
@@ -131,6 +131,7 @@ export function runPluginClientBundle(
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
@@ -146,6 +147,7 @@ export function runPluginClientBundle(
|
||||
footer: new Set(),
|
||||
};
|
||||
const filePanelIds = new Set<string>();
|
||||
const composerItemIds = new Set<string>();
|
||||
const workspacePanelIds = new Set<string>();
|
||||
const commandCenterItemIds = new Set<string>();
|
||||
const clientSlashCommandNames = new Set<string>();
|
||||
@@ -269,6 +271,20 @@ export function runPluginClientBundle(
|
||||
() => surfaceIds.delete(normalizedId),
|
||||
);
|
||||
},
|
||||
addComposerItem(contribution) {
|
||||
const normalizedId = requireId(contribution.id, "composer item id");
|
||||
if (composerItemIds.has(normalizedId))
|
||||
throw new Error(`Duplicate composer item: ${normalizedId}`);
|
||||
if (!isComponentType(contribution.Component)) {
|
||||
throw new Error(`Composer item ${normalizedId} is not a component`);
|
||||
}
|
||||
composerItemIds.add(normalizedId);
|
||||
return register(
|
||||
collector.composerItems,
|
||||
{ id: normalizedId, Component: contribution.Component },
|
||||
() => composerItemIds.delete(normalizedId),
|
||||
);
|
||||
},
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
|
||||
return addSidebarItem("header", contribution);
|
||||
},
|
||||
@@ -585,6 +601,7 @@ export function runPluginClientBundle(
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
filePanels: collector.filePanels,
|
||||
composerItems: collector.composerItems,
|
||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||
commandCenterItems: collector.commandCenterItems,
|
||||
clientSlashCommands: collector.clientSlashCommands,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** A deep copy no plugin can change. Plugins read data through it, never the host's own objects. */
|
||||
export function cloneAndFreeze<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ActionProps, SelectProps, ToggleProps } from "@getpaseo/plugin/client/ui";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ComposerControl,
|
||||
useComposerControlSurface,
|
||||
type ComposerControlItem,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import { useComposerLocked } from "@/composer/session";
|
||||
import { parsePluginIcon, pluginIconComponent } from "../icons";
|
||||
|
||||
/** Icons resolve at render, so an invalid one throws to the item's boundary. */
|
||||
function usePluginControlIcon(icon: ToggleProps["icon"]) {
|
||||
return useMemo(() => pluginIconComponent(parsePluginIcon("Composer control", icon)), [icon]);
|
||||
}
|
||||
|
||||
/** Draws one plugin control where its composer item sits: inline, or as a row in the sheet. */
|
||||
function PluginComposerControl({
|
||||
item,
|
||||
disabled,
|
||||
}: {
|
||||
item: ComposerControlItem;
|
||||
disabled: boolean | undefined;
|
||||
}) {
|
||||
const surface = useComposerControlSurface();
|
||||
const locked = useComposerLocked();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<ComposerControl
|
||||
item={item}
|
||||
surface={surface}
|
||||
disabled={disabled || locked}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({ title, icon, disabled, value, onValueChange }: ToggleProps) {
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const item = useMemo<ComposerControlItem>(
|
||||
() => ({
|
||||
id: "plugin-toggle",
|
||||
type: "toggle",
|
||||
title,
|
||||
icon: Icon,
|
||||
highlight: value ? "blue" : undefined,
|
||||
value,
|
||||
onChange: (next) => onValueChange({ value: next }),
|
||||
}),
|
||||
[Icon, onValueChange, title, value],
|
||||
);
|
||||
return <PluginComposerControl item={item} disabled={disabled} />;
|
||||
}
|
||||
|
||||
export function Select<Value extends string>({
|
||||
title,
|
||||
icon,
|
||||
disabled,
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
}: SelectProps<Value>) {
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const item = useMemo<ComposerControlItem>(
|
||||
() => ({
|
||||
id: "plugin-select",
|
||||
type: "select",
|
||||
title,
|
||||
icon: Icon,
|
||||
value,
|
||||
choices: options,
|
||||
onChange: (next) => onValueChange({ value: next as Value }),
|
||||
showCaret: true,
|
||||
}),
|
||||
[Icon, onValueChange, options, title, value],
|
||||
);
|
||||
return <PluginComposerControl item={item} disabled={disabled} />;
|
||||
}
|
||||
|
||||
export function Action({ title, icon, disabled, onPress }: ActionProps) {
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const item = useMemo<ComposerControlItem>(
|
||||
() => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress: () => onPress({}) }),
|
||||
[Icon, onPress, title],
|
||||
);
|
||||
return <PluginComposerControl item={item} disabled={disabled} />;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export { FilePanelLoading as Loading } from "@/file-panels/loading";
|
||||
export { SettingsGroup } from "@/components/settings";
|
||||
|
||||
export { Icon } from "../icons";
|
||||
export { Action, Select, Toggle } from "./composer-controls";
|
||||
export { Modal } from "./modal";
|
||||
export { ScrollView, FlatList } from "./scroll-view";
|
||||
export { TextInput } from "./text-input";
|
||||
|
||||
@@ -106,6 +106,7 @@ export class PluginRegistry {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -34,6 +34,7 @@ function installed(
|
||||
},
|
||||
legacySidebarItems,
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -36,6 +36,7 @@ function installation(
|
||||
icon: "Blocks",
|
||||
})),
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -24,6 +24,7 @@ function plugin(input: {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
import type { TimelineItemTransform } from "./model";
|
||||
|
||||
type AssistantPhase = "streaming" | "complete";
|
||||
@@ -9,18 +10,6 @@ const projectionCache = new WeakMap<
|
||||
WeakMap<StreamItem, Partial<Record<AssistantPhase, StreamItem[]>>>
|
||||
>();
|
||||
|
||||
function cloneAndFreeze<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||
const { callId, name, status, error, detail, metadata } = data;
|
||||
const base = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
PluginFilePanelContribution as SDKPluginFilePanelContribution,
|
||||
PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
|
||||
PluginComposerItemContribution,
|
||||
PluginComposerPillContribution as SDKPluginComposerPillContribution,
|
||||
PluginSidebarContribution as SDKPluginSidebarContribution,
|
||||
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
||||
@@ -53,6 +54,7 @@ export interface EvaluatedPlugin {
|
||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||
legacySidebarItems: PluginSidebarContribution[];
|
||||
filePanels: PluginFilePanelContribution[];
|
||||
composerItems: PluginComposerItemContribution[];
|
||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSlashCommands: PluginClientSlashCommandContribution[];
|
||||
@@ -82,6 +84,7 @@ export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginComposerItemContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSidebarItemContribution,
|
||||
|
||||
@@ -21,6 +21,7 @@ function installed(): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { FormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
INITIAL_AGENT_FORM_RESOLUTION,
|
||||
INITIAL_USER_MODIFIED,
|
||||
isUnlistedModel,
|
||||
resolveAgentForm,
|
||||
type AgentFormAction,
|
||||
type AgentFormReducerState,
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
/** What the host offers right now. The store reads it when a command runs, never earlier. */
|
||||
export interface AgentFormCatalog {
|
||||
/** The providers the host can run. */
|
||||
selectableProviders: ReadonlyMap<AgentProvider, AgentProviderDefinition>;
|
||||
/** Each provider's selectable models; empty when the host has listed none. */
|
||||
models: ReadonlyMap<string, AgentModelDefinition[]>;
|
||||
providerPreferences: (
|
||||
provider: AgentProvider,
|
||||
) => NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft form as a plain model: `resolveAgentForm` applied synchronously. A command reads
|
||||
* `getState()` and the catalog when it runs, so the one after `await setProvider(...)` sees the
|
||||
* new provider instead of the render that created it.
|
||||
*/
|
||||
export interface AgentFormStore {
|
||||
getState: () => AgentFormReducerState;
|
||||
dispatch: (action: AgentFormAction) => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** `false` when the provider is unknown or not ready; the form is left as it was. */
|
||||
setProviderAndModel: (provider: AgentProvider, modelId: string) => boolean;
|
||||
/**
|
||||
* `false` when the provider's catalog is loaded and does not list the model. A catalog that has
|
||||
* not arrived keeps the id as given.
|
||||
*/
|
||||
setModel: (modelId: string) => boolean;
|
||||
}
|
||||
|
||||
export function openAgentForm(catalog: () => AgentFormCatalog): AgentFormStore {
|
||||
let state: AgentFormReducerState = {
|
||||
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
|
||||
userModified: INITIAL_USER_MODIFIED,
|
||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
function dispatch(action: AgentFormAction): void {
|
||||
const next = resolveAgentForm(state, action);
|
||||
if (next === state) return;
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
return {
|
||||
getState: () => state,
|
||||
dispatch,
|
||||
setProviderAndModel: (provider, modelId) => {
|
||||
const host = catalog();
|
||||
const providerDef = host.selectableProviders.get(provider);
|
||||
if (!providerDef) return false;
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider,
|
||||
modelId,
|
||||
providerDef,
|
||||
providerModels: host.models.get(provider) ?? null,
|
||||
providerPrefs: host.providerPreferences(provider),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
setModel: (modelId) => {
|
||||
const { provider } = state.form;
|
||||
const host = catalog();
|
||||
const listed = provider ? host.models.get(provider) : undefined;
|
||||
const models = listed?.length ? listed : null;
|
||||
if (isUnlistedModel(models, modelId)) return false;
|
||||
dispatch({
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId,
|
||||
availableModels: models,
|
||||
providerPrefs: provider ? host.providerPreferences(provider) : undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
resolveAgentForm,
|
||||
resolveFormState,
|
||||
resolveEffectiveModel,
|
||||
isUnlistedModel,
|
||||
resolveThinkingOptionId,
|
||||
mergeSelectedComposerPreferences,
|
||||
buildProviderDefinitionMap,
|
||||
@@ -131,6 +132,19 @@ describe("resolveDefaultModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUnlistedModel", () => {
|
||||
it("rejects an id the provider's catalog lacks, and accepts a listed id or alias", () => {
|
||||
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "not-in-the-catalog")).toBe(true);
|
||||
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex")).toBe(false);
|
||||
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex-legacy")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts any id while the provider has listed no models", () => {
|
||||
expect(isUnlistedModel(null, "anything")).toBe(false);
|
||||
expect(isUnlistedModel([], "anything")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("model aliases", () => {
|
||||
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
|
||||
const resolved = resolveFormState(
|
||||
|
||||
@@ -153,6 +153,19 @@ export function resolveEffectiveModel(
|
||||
return findModelByReference(availableModels, modelId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the provider has listed its models and this id is not among them. A provider that
|
||||
* lists none accepts any id.
|
||||
*/
|
||||
export function isUnlistedModel(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
modelId: string,
|
||||
): boolean {
|
||||
if (!availableModels || availableModels.length === 0) return false;
|
||||
if (!normalizeSelectedModelId(modelId)) return false;
|
||||
return !resolveEffectiveModel(availableModels, modelId);
|
||||
}
|
||||
|
||||
function resolvePreferredThinkingOptionId(input: {
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
|
||||
@@ -456,6 +456,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||
providerOptions: composerState.providerOptions,
|
||||
},
|
||||
...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}),
|
||||
initialPrompt: text,
|
||||
clientMessageId: `${input.draftId}:initial-message`,
|
||||
images: images?.length ? images : undefined,
|
||||
@@ -535,6 +536,7 @@ function buildComposerConfig(input: {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: buildComposerInitialValues({ initialSetup }),
|
||||
initialProviderOptions: initialSetup?.providerOptions,
|
||||
context: "new-workspace" as const,
|
||||
isVisible: true,
|
||||
lockedWorkingDir: workingDir,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
} from "./migration";
|
||||
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";
|
||||
|
||||
export type { DraftInput, DraftLifecycleState } from "./state";
|
||||
|
||||
@@ -49,6 +51,9 @@ interface DraftStoreActions {
|
||||
draftKey: string;
|
||||
lifecycle?: Exclude<DraftLifecycleState, "active">;
|
||||
}) => void;
|
||||
/** Plugin metadata the draft will create its agent with; see `composer/draft/metadata`. */
|
||||
patchDraftMetadata: (input: { draftKey: string; patch: Record<string, JsonValue> }) => void;
|
||||
clearDraftMetadata: (input: { draftKey: string }) => void;
|
||||
attachWorkspaceFile: (input: {
|
||||
draftKey: string;
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
@@ -60,6 +65,11 @@ interface DraftStoreActions {
|
||||
|
||||
interface DraftStoreRuntimeState {
|
||||
attachmentFocusRequestByDraftKey: Record<string, number>;
|
||||
/**
|
||||
* Kept beside the prompt so a composer that remounts finds it. Not persisted, and not cleared
|
||||
* with an emptied prompt: a plugin's choice outlives the text until the draft is sent.
|
||||
*/
|
||||
metadataByDraftKey: Record<string, Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
|
||||
@@ -256,6 +266,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
metadataByDraftKey: {},
|
||||
|
||||
getDraftInput: (draftKey) => {
|
||||
const record = get().drafts[draftKey];
|
||||
@@ -374,6 +385,25 @@ export const useDraftStore = create<DraftStore>()(
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
patchDraftMetadata: ({ draftKey, patch }) => {
|
||||
set((state) => {
|
||||
const next = applyMetadataPatch(state.metadataByDraftKey[draftKey] ?? {}, patch);
|
||||
const { [draftKey]: _previous, ...others } = state.metadataByDraftKey;
|
||||
return {
|
||||
metadataByDraftKey:
|
||||
Object.keys(next).length > 0 ? { ...others, [draftKey]: next } : others,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearDraftMetadata: ({ draftKey }) => {
|
||||
set((state) => {
|
||||
if (!(draftKey in state.metadataByDraftKey)) return state;
|
||||
const { [draftKey]: _removed, ...others } = state.metadataByDraftKey;
|
||||
return { metadataByDraftKey: others };
|
||||
});
|
||||
},
|
||||
|
||||
attachWorkspaceFile: async ({ draftKey, attachment }) => {
|
||||
await get().hydrateDraftInput({ draftKey });
|
||||
set((state) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useDraftStore } from "./index";
|
||||
|
||||
const draftKey = "draft:metadata";
|
||||
|
||||
describe("a draft's plugin metadata", () => {
|
||||
beforeEach(() => {
|
||||
useDraftStore.setState({ drafts: {}, metadataByDraftKey: {} });
|
||||
});
|
||||
|
||||
it("is there when the draft's composer mounts again", () => {
|
||||
const store = useDraftStore.getState();
|
||||
store.editDraftText({ draftKey, text: "review this" });
|
||||
store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true, "plugin.tag": "a" } });
|
||||
store.patchDraftMetadata({ draftKey, patch: { "plugin.tag": null } });
|
||||
|
||||
// A remount reads the store again; nothing from the unmounted composer is kept.
|
||||
const remounted = useDraftStore.getState();
|
||||
expect(remounted.getDraftInput(draftKey)?.text).toBe("review this");
|
||||
expect(remounted.metadataByDraftKey[draftKey]).toEqual({ "plugin.review": true });
|
||||
});
|
||||
|
||||
it("is dropped with the prompt once the draft is sent, and not before", () => {
|
||||
const store = useDraftStore.getState();
|
||||
store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true } });
|
||||
|
||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toEqual({
|
||||
"plugin.review": true,
|
||||
});
|
||||
|
||||
store.clearDraftMetadata({ draftKey });
|
||||
expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentOptionControl,
|
||||
JsonValue,
|
||||
ProviderOptions,
|
||||
AgentProvider,
|
||||
AgentMode,
|
||||
@@ -101,6 +102,7 @@ export interface Agent {
|
||||
archivedAt?: Date | null;
|
||||
parentAgentId: string | null;
|
||||
labels: Record<string, string>;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
projectPlacement?: ProjectPlacementPayload | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
|
||||
...(agent.lastError ? { lastError: agent.lastError } : {}),
|
||||
title: agent.title,
|
||||
labels: agent.labels,
|
||||
...(agent.metadata ? { metadata: agent.metadata } : {}),
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
||||
@@ -137,5 +138,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
archivedAt,
|
||||
parentAgentId,
|
||||
labels: snapshot.labels,
|
||||
metadata: snapshot.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,3 +116,8 @@ export const Maximize2 = StubIcon;
|
||||
export const Workflow = StubIcon;
|
||||
export const ZoomIn = StubIcon;
|
||||
export const ZoomOut = StubIcon;
|
||||
export const ListTodo = StubIcon;
|
||||
export const Shield = StubIcon;
|
||||
export const ShieldEllipsis = StubIcon;
|
||||
export const ShieldPlus = StubIcon;
|
||||
export const Zap = StubIcon;
|
||||
|
||||
@@ -30,8 +30,10 @@ const testTheme = {
|
||||
borderAccent: "#a1a1aa",
|
||||
palette: {
|
||||
amber: { 500: "#f59e0b" },
|
||||
blue: { 300: "#93c5fd" },
|
||||
green: { 500: "#22c55e" },
|
||||
blue: { 300: "#93c5fd", 400: "#60a5fa" },
|
||||
green: { 400: "#4ade80", 500: "#22c55e" },
|
||||
yellow: { 400: "#facc15" },
|
||||
zinc: { 600: "#52525b" },
|
||||
red: { 300: "#fca5a5" },
|
||||
white: "#ffffff",
|
||||
},
|
||||
|
||||
@@ -236,6 +236,14 @@ export default defineConfig({
|
||||
find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"),
|
||||
},
|
||||
// The CJS entry requires the real react-native, whose Flow sources do not parse.
|
||||
{
|
||||
find: /^@floating-ui\/react-native$/,
|
||||
replacement: path.resolve(
|
||||
rootNodeModules,
|
||||
"@floating-ui/react-native/dist/floating-ui.react-native.esm.js",
|
||||
),
|
||||
},
|
||||
// Point to the ESM build so Vite can transform its imports and apply the
|
||||
// react alias below (the CJS build uses require('react') which bypasses
|
||||
// Vite alias resolution).
|
||||
|
||||
@@ -213,6 +213,43 @@ describe("plugin panel SDK boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin composer SDK boundary", () => {
|
||||
it("lets a composer item read and change the composer through typed commands and controls", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
|
||||
function Item() {
|
||||
const composer = useComposer();
|
||||
const context: "agent" | "draft" | "new-workspace" = composer.context;
|
||||
const provider: string | null = composer.agent.provider;
|
||||
const flag: boolean = composer.agent.metadata.flag === true;
|
||||
// @ts-expect-error reads are not writable
|
||||
composer.agent.model = "other";
|
||||
composer.setProvider("codex").then(() => composer.setModel("gpt"));
|
||||
composer.setOptions({ effort: null });
|
||||
composer.setMetadata({ flag: true, stale: null });
|
||||
composer.insertText("text");
|
||||
composer.addAttachment({ sourceId: "issues", item: { id: "1", identifier: "1", title: "t", url: "https://e.com", text: "t", resourceType: "issue" } });
|
||||
// @ts-expect-error commands take JSON values
|
||||
composer.setMetadata({ fn: () => {} });
|
||||
return (
|
||||
<>
|
||||
<Toggle title="Flag" icon="Flag" value={flag} onValueChange={({ value }: { value: boolean }) => composer.setMetadata({ flag: value })} />
|
||||
<Select title="Size" icon="Gauge" value="a" options={[{ value: "a", label: "A" }, { value: "b", label: "B" }]} onValueChange={({ value }: { value: "a" | "b" }) => {}} />
|
||||
<Action title="Insert" icon="Plus" onPress={() => composer.setPrompt("")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addComposerItem({ id: "item", Component: Item });
|
||||
return () => {};
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin agent tools SDK boundary", () => {
|
||||
it("lets a session hook attach typed tools without exposing their transport", () => {
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext, 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";
|
||||
|
||||
/** One item the user attached from a source this plugin registered with `addAttachmentSource`. */
|
||||
export interface PluginComposerAttachment {
|
||||
readonly sourceId: string;
|
||||
readonly item: PluginAttachmentItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer the item sits in: the prompt, the attachments and the agent being configured.
|
||||
* The commands behave the same in every composer. `context` says where the composer is; it does
|
||||
* not change what a command does, except that a running agent keeps its provider.
|
||||
*/
|
||||
export interface PluginComposer {
|
||||
/** `"agent"`: a running agent. `"draft"`: a new agent in a workspace. `"new-workspace"`: the New workspace screen. */
|
||||
readonly context: "agent" | "draft" | "new-workspace";
|
||||
readonly prompt: string;
|
||||
/** This plugin's attachments only. */
|
||||
readonly attachments: readonly PluginComposerAttachment[];
|
||||
readonly agent: {
|
||||
readonly provider: string | null;
|
||||
readonly model: string | null;
|
||||
readonly mode: string | null;
|
||||
readonly thinking: string | null;
|
||||
/** The options the agent runs with, including provider defaults. */
|
||||
readonly options: Readonly<Record<string, JsonValue>>;
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
setPrompt(text: string): void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
insertText(text: string): void;
|
||||
addAttachment(input: { sourceId: string; item: PluginAttachmentItem }): void;
|
||||
removeAttachment(input: { sourceId: string; itemId: string }): void;
|
||||
/** Resets the model like the built-in control. Rejects on a running agent, which keeps its provider. */
|
||||
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: Record<string, JsonValue>): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the key. */
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginComposerItemProps extends PluginHostProps {}
|
||||
|
||||
export interface PluginComposerItemContribution {
|
||||
id: string;
|
||||
Component: ComponentType<PluginComposerItemProps>;
|
||||
}
|
||||
|
||||
const PluginComposerContext = createContext<PluginComposer | null>(null);
|
||||
|
||||
export function PluginComposerProvider({
|
||||
composer,
|
||||
children,
|
||||
}: {
|
||||
composer: PluginComposer;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PluginComposerContext.Provider value={composer}>{children}</PluginComposerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePluginComposerValue(): PluginComposer | null {
|
||||
return useContext(PluginComposerContext);
|
||||
}
|
||||
|
||||
/** The composer this item sits in. Call it from a component registered with `addComposerItem`. */
|
||||
export function useComposer(): PluginComposer {
|
||||
const composer = usePluginComposerValue();
|
||||
if (!composer) throw new Error("useComposer must run inside a composer item");
|
||||
return composer;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 { PluginRpcContract } from "../rpc.js";
|
||||
import type {
|
||||
PluginButtonRegistration,
|
||||
@@ -194,6 +195,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
|
||||
contribution: PluginComposerPillContribution<PluginText>,
|
||||
): PluginButtonRegistration<PluginText>;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
|
||||
addComposerItem(contribution: PluginComposerItemContribution): PluginCleanup;
|
||||
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
|
||||
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
|
||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
usePluginRuntimeContextBridge,
|
||||
type PluginRuntimeContextBridge,
|
||||
|
||||
@@ -71,6 +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 type {
|
||||
PluginComposer,
|
||||
PluginComposerAttachment,
|
||||
PluginComposerItemContribution,
|
||||
PluginComposerItemProps,
|
||||
} from "./composer.js";
|
||||
export { useRpc } from "./rpc-context.js";
|
||||
import type { SettingsDefinition } from "../settings.js";
|
||||
import type { SettingsState } from "./contracts.js";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { PluginComposerProvider, usePluginComposerValue } from "./composer.js";
|
||||
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
|
||||
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
|
||||
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
|
||||
@@ -12,6 +13,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
const paseo = usePaseoContextValue();
|
||||
const rpc = usePluginRpcContextValue();
|
||||
const state = usePluginClientStateSource();
|
||||
const composer = usePluginComposerValue();
|
||||
|
||||
if (!paseo || !rpc) {
|
||||
throw new Error("Plugin UI must run inside a contributed plugin surface");
|
||||
@@ -19,11 +21,16 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
|
||||
return useCallback(
|
||||
(children: ReactNode) => {
|
||||
const content = state ? (
|
||||
const withState = state ? (
|
||||
<PluginClientStateProvider source={state}>{children}</PluginClientStateProvider>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
const content = composer ? (
|
||||
<PluginComposerProvider composer={composer}>{withState}</PluginComposerProvider>
|
||||
) : (
|
||||
withState
|
||||
);
|
||||
return (
|
||||
<PaseoApiProvider paseo={paseo}>
|
||||
<PluginRpcProvider invoke={rpc.invoke}>
|
||||
@@ -32,6 +39,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
</PaseoApiProvider>
|
||||
);
|
||||
},
|
||||
[paseo, rpc, state, translations],
|
||||
[composer, paseo, rpc, state, translations],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,3 +185,35 @@ export interface MarkdownProps {
|
||||
|
||||
/** Renders Markdown using Paseo’s theme, selection, and link behavior. */
|
||||
export declare const Markdown: ComponentType<MarkdownProps>;
|
||||
|
||||
export interface ComposerControlProps {
|
||||
/** The control's label and tooltip. */
|
||||
title: string;
|
||||
/** A Lucide icon name, inline SVG, or a component. */
|
||||
icon: PluginIcon;
|
||||
disabled?: boolean;
|
||||
}
|
||||
/**
|
||||
* What a control's callback receives. A returned promise marks the control pending until it
|
||||
* settles, and Paseo reports a rejection to the user.
|
||||
*/
|
||||
export interface ToggleProps extends ComposerControlProps {
|
||||
value: boolean;
|
||||
onValueChange(event: { value: boolean }): void | Promise<void>;
|
||||
}
|
||||
export interface SelectProps<Value extends string = string> extends ComposerControlProps {
|
||||
value: Value;
|
||||
options: readonly { value: Value; label: string; description?: string }[];
|
||||
onValueChange(event: { value: Value }): void | Promise<void>;
|
||||
}
|
||||
export interface ActionPressEvent {}
|
||||
export interface ActionProps extends ComposerControlProps {
|
||||
onPress(event: ActionPressEvent): void | Promise<void>;
|
||||
}
|
||||
/**
|
||||
* Composer controls. Render them from a composer item's `Component`. Paseo draws them inline when
|
||||
* the composer has room and as rows in its overflow sheet when it does not.
|
||||
*/
|
||||
export declare const Toggle: ComponentType<ToggleProps>;
|
||||
export declare function Select<Value extends string>(props: SelectProps<Value>): ReactNode;
|
||||
export declare const Action: ComponentType<ActionProps>;
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface PluginAgentSnapshot {
|
||||
readonly attentionReason: "finished" | "error" | "permission" | null;
|
||||
readonly parentAgentId: string | null;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
/** Structured data plugins store on the agent. */
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
export interface PluginThemeColors {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Composer controls
|
||||
|
||||
Install this directory as a plugin and open the agent, draft, or New workspace composer. Four
|
||||
controls appear beside the model and thinking controls.
|
||||
|
||||
| Control | Component | What it does |
|
||||
| -------------------- | --------- | ---------------------------------------------------------------------------------------- |
|
||||
| **Context length** | `Select` | Saves `composer-controls.contextLength` in the agent's metadata. |
|
||||
| **Needs review** | `Toggle` | Sets or clears `composer-controls.needsReview` in the agent's metadata. |
|
||||
| **Plan first** | `Toggle` | Sets the provider option `plan_mode` with `composer.setOptions`; enabled for Codex only. |
|
||||
| **Insert checklist** | `Action` | Inserts a task list at the cursor through `composer.insertText`. |
|
||||
|
||||
Each component calls `useComposer()` and uses the same commands in every composer. Paseo draws the
|
||||
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`.
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from "react";
|
||||
import { useComposer } from "@getpaseo/plugin/client";
|
||||
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
type ContextLength = "short" | "long";
|
||||
|
||||
const CONTEXT_OPTIONS = [
|
||||
{ value: "short", label: "Short", description: "Answer from the files you name." },
|
||||
{ value: "long", label: "Long", description: "Read the surrounding code first." },
|
||||
] as const;
|
||||
|
||||
const CONTEXT_KEY = "composer-controls.contextLength";
|
||||
const REVIEW_KEY = "composer-controls.needsReview";
|
||||
|
||||
/** Reads and writes agent metadata, so the choice is saved with the agent. */
|
||||
export function ContextLength() {
|
||||
const composer = useComposer();
|
||||
const current: ContextLength = composer.agent.metadata[CONTEXT_KEY] === "long" ? "long" : "short";
|
||||
const change = useCallback(
|
||||
({ value }: { value: ContextLength }) => composer.setMetadata({ [CONTEXT_KEY]: value }),
|
||||
[composer],
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
title="Context length"
|
||||
icon="Gauge"
|
||||
value={current}
|
||||
options={CONTEXT_OPTIONS}
|
||||
onValueChange={change}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewFlag() {
|
||||
const composer = useComposer();
|
||||
const change = useCallback(
|
||||
({ value }: { value: boolean }) => composer.setMetadata({ [REVIEW_KEY]: value ? true : null }),
|
||||
[composer],
|
||||
);
|
||||
return (
|
||||
<Toggle
|
||||
title="Needs review"
|
||||
icon="Flag"
|
||||
value={composer.agent.metadata[REVIEW_KEY] === true}
|
||||
onValueChange={change}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a provider option. Only Codex reports `plan_mode`, so the control is disabled for
|
||||
* other providers. The promise it returns keeps the control pending, and a rejection reaches the
|
||||
* user as a toast.
|
||||
*/
|
||||
export function PlanFirst() {
|
||||
const composer = useComposer();
|
||||
const change = useCallback(
|
||||
({ value }: { value: boolean }) => composer.setOptions({ plan_mode: value }),
|
||||
[composer],
|
||||
);
|
||||
return (
|
||||
<Toggle
|
||||
title="Plan first"
|
||||
icon="ListTodo"
|
||||
disabled={composer.agent.provider !== "codex"}
|
||||
value={composer.agent.options.plan_mode === true}
|
||||
onValueChange={change}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Works the same in the agent, draft and New workspace composers. */
|
||||
export function Checklist() {
|
||||
const composer = useComposer();
|
||||
const insert = useCallback(() => composer.insertText("- [ ] Tests\n- [ ] Docs\n"), [composer]);
|
||||
return <Action title="Insert checklist" icon="ListChecks" onPress={insert} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { Checklist, ContextLength, PlanFirst, ReviewFlag } from "./client/controls";
|
||||
|
||||
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 });
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "composer-controls-example",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add
|
||||
[file panels](/docs/plugins/reference#file-panels),
|
||||
[Command Center items](/docs/plugins/reference#command-center-items),
|
||||
[slash commands](/docs/plugins/reference#slash-commands),
|
||||
[composer items](/docs/plugins/reference#composer-items),
|
||||
[composer pills](/docs/plugins/reference#composer-pills),
|
||||
[timeline items](/docs/plugins/reference#timeline-items),
|
||||
[themes](/docs/plugins/reference#contribute-a-theme),
|
||||
|
||||
@@ -6,7 +6,7 @@ nav: 快速上手
|
||||
|
||||
# 插件快速上手 {#plugin-quickstart}
|
||||
|
||||
插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。
|
||||
插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 项](/docs/plugins/reference#composer-items)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。
|
||||
|
||||
浏览[已发布的插件](https://paseo.sh/plugins),然后在你的 daemon 上安装一个:
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ Use this table as the complete registration checklist.
|
||||
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
|
||||
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
|
||||
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
|
||||
| New composer item contribution | `client.addComposerItem(item)` in `index.client.tsx`; see [composer items](reference#composer-items) |
|
||||
| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` |
|
||||
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
|
||||
| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) |
|
||||
|
||||
@@ -73,6 +73,7 @@ my-plugin/
|
||||
| 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` |
|
||||
| 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 |
|
||||
| 旧客户端回调中的 `client.addComposerPill(pill)` | `index.client.tsx` 或被导入的 `client/` 函数中的 `client.addComposerPill(pill)` |
|
||||
| 新的编辑器项贡献 | `index.client.tsx` 中的 `client.addComposerItem(item)`;见[编辑器项](reference#composer-items) |
|
||||
| 新的标题栏贡献 | `client.addHeaderButton({ id, workspaceId, button })` |
|
||||
| 旧根入口中的 `plugin.addAttachmentSource(source)` | `index.client.tsx` 中的 `client.addAttachmentSource(source)` |
|
||||
| 新的设置界面贡献 | `index.client.tsx` 中的 `client.addSettingsScreen(screen)`;见[设置界面](reference#settings-screens) |
|
||||
|
||||
@@ -1894,6 +1894,93 @@ header has no room for plugin buttons, so each one is a row of the workspace men
|
||||
icon and title; its menus and popovers open as pages of that sheet. Placement and overflow are host
|
||||
decisions.
|
||||
|
||||
## Composer items
|
||||
|
||||
`client.addComposerItem({ id, Component })` puts a component in the composer: the agent composer,
|
||||
the draft composer for a new agent, and the composer on the New workspace screen. `useComposer()`
|
||||
reads and changes what the composer configures, and `Toggle`, `Select` and `Action` from
|
||||
`@getpaseo/plugin/client/ui` draw the controls.
|
||||
|
||||
```tsx
|
||||
import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function ReviewFlag() {
|
||||
const composer = useComposer();
|
||||
return (
|
||||
<Toggle
|
||||
title="Needs review"
|
||||
icon="Flag"
|
||||
value={composer.agent.metadata["review.needsReview"] === true}
|
||||
onValueChange={({ value }) =>
|
||||
composer.setMetadata({ "review.needsReview": value ? true : null })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Checklist() {
|
||||
const composer = useComposer();
|
||||
return (
|
||||
<Action
|
||||
title="Insert checklist"
|
||||
icon="ListChecks"
|
||||
onPress={() => composer.insertText("- [ ] Tests\n- [ ] Docs\n")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addComposerItem({ id: "review-flag", Component: ReviewFlag });
|
||||
client.addComposerItem({ id: "checklist", Component: Checklist });
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
`useComposer()` returns the composer the item sits in:
|
||||
|
||||
| Field or command | Meaning |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `context` | `"agent"` for a running agent, `"draft"` for a new agent in a workspace, `"new-workspace"` for the New workspace screen. |
|
||||
| `prompt`, `setPrompt(text)`, `insertText(text)` | The prompt. `insertText` replaces the selection or inserts at the cursor. |
|
||||
| `attachments`, `addAttachment`, `removeAttachment` | Items from [your own attachment sources](#add-a-composer-attachment-source). Attachments from other plugins and Paseo's own are not listed. |
|
||||
| `agent.provider`, `model`, `mode`, `thinking` | What the agent runs with, or what the draft will create. |
|
||||
| `agent.options` | The provider options in effect, defaults included. In a draft: the options the agent will be created with, including your changes. |
|
||||
| `agent.metadata` | The agent's [metadata](/docs/plugins/hooks#metadata). In New workspace, the first agent's. |
|
||||
| `setProvider`, `setModel`, `setMode`, `setThinking` | Return promises. `setProvider` resets the model like the provider picker. It rejects on `context: "agent"`, which keeps its provider, and for a provider the host does not offer. `setModel` rejects for a model the provider's loaded catalog does not list. |
|
||||
| `setOptions(patch)`, `setMetadata(patch)` | Top-level keys replace; `null` removes the key. Metadata set in a draft is sent when the agent is created and cleared once the prompt is sent. |
|
||||
|
||||
The same call does the same thing in every composer; a plugin does not choose a composer's
|
||||
implementation. `setMetadata` on a running agent needs a host that supports metadata. On an older
|
||||
host the promise rejects with "Update the host to use metadata."
|
||||
|
||||
`Toggle` takes `value` and `onValueChange({ value })`. `Select` takes `value`, `options` (`{ value, label,
|
||||
description? }`) and `onValueChange({ value })`. `Action` takes `onPress(event)`. All three take `title`,
|
||||
`icon` (a [Lucide name, SVG or component](#icons)) and `disabled`. A callback may return a promise:
|
||||
the control stays disabled until it settles, and Paseo shows a rejection to the user as an error
|
||||
toast, the way it does for its own controls. Paseo owns where each control sits: inline in the
|
||||
toolbar when the composer has room, and a row in the composer's overflow sheet when it does not,
|
||||
which is always the case on a phone. A `Select` opens its list in place of the sheet row. A
|
||||
`Component` that renders something other than these controls draws it where its item is placed; in
|
||||
the overflow sheet that is the sheet.
|
||||
|
||||
Each command acts on the composer's state when it runs, not on the render that created your
|
||||
callback, so `await composer.setProvider("codex")` followed by `await composer.setModel("gpt")`
|
||||
changes both. A command's promise resolves once the change has taken effect, and `composer.agent`
|
||||
shows it on the next render.
|
||||
|
||||
While the New workspace screen creates the workspace and its agent, the composer is locked. Your
|
||||
controls render disabled, `setPrompt` and `insertText` throw, and the agent commands reject with
|
||||
"The composer is locked while the agent is being created." `composer.agent.options` and
|
||||
`composer.agent.metadata` are frozen copies; change them through `setOptions` and `setMetadata`.
|
||||
|
||||
A composer item counts toward how much fits before Paseo knows what it renders. An item that
|
||||
renders nothing can still leave an empty overflow sheet on a narrow composer, so always render a
|
||||
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`.
|
||||
|
||||
## Composer pills
|
||||
|
||||
`client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor)
|
||||
|
||||
@@ -1600,6 +1600,71 @@ review.remove();
|
||||
贡献移入一个共享的溢出菜单。在手机和较窄的窗口中,标题栏没有空间放置插件按钮,因此每个按钮都会成为工作区
|
||||
菜单(`···`)中的一行,显示它的图标和标题;它的菜单和弹出框以该底部面板中的页面打开。位置和溢出由宿主决定。
|
||||
|
||||
## Composer 项 {#composer-items}
|
||||
|
||||
`client.addComposerItem({ id, Component })` 把一个组件放进 composer:Agent composer、为新 Agent 准备的草稿 composer,以及新建工作区界面上的 composer。`useComposer()` 读取并修改 composer 所配置的内容,`@getpaseo/plugin/client/ui` 中的 `Toggle`、`Select` 和 `Action` 负责绘制控件。
|
||||
|
||||
```tsx
|
||||
import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function ReviewFlag() {
|
||||
const composer = useComposer();
|
||||
return (
|
||||
<Toggle
|
||||
title="Needs review"
|
||||
icon="Flag"
|
||||
value={composer.agent.metadata["review.needsReview"] === true}
|
||||
onValueChange={({ value }) =>
|
||||
composer.setMetadata({ "review.needsReview": value ? true : null })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Checklist() {
|
||||
const composer = useComposer();
|
||||
return (
|
||||
<Action
|
||||
title="Insert checklist"
|
||||
icon="ListChecks"
|
||||
onPress={() => composer.insertText("- [ ] Tests\n- [ ] Docs\n")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addComposerItem({ id: "review-flag", Component: ReviewFlag });
|
||||
client.addComposerItem({ id: "checklist", Component: Checklist });
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
`useComposer()` 返回该项所在的 composer:
|
||||
|
||||
| 字段或命令 | 含义 |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `context` | `"agent"` 表示运行中的 Agent,`"draft"` 表示工作区中的新 Agent,`"new-workspace"` 表示新建工作区界面。 |
|
||||
| `prompt`、`setPrompt(text)`、`insertText(text)` | 提示词。`insertText` 会替换选区,或在光标处插入。 |
|
||||
| `attachments`、`addAttachment`、`removeAttachment` | 来自[你自己的附件来源](#add-a-composer-attachment-source)的项目。其他插件和 Paseo 自身的附件不会列出。 |
|
||||
| `agent.provider`、`model`、`mode`、`thinking` | Agent 当前使用的配置,或草稿将要创建的配置。 |
|
||||
| `agent.options` | 当前生效的 Provider 选项,包含默认值。在草稿中:创建 Agent 时将使用的选项,包含你所做的修改。 |
|
||||
| `agent.metadata` | Agent 的[元数据](/docs/plugins/hooks#metadata)。在新建工作区中是第一个 Agent 的元数据。 |
|
||||
| `setProvider`、`setModel`、`setMode`、`setThinking` | 返回 Promise。`setProvider` 会像 Provider 选择器一样重置模型。在 `context: "agent"` 下(运行中的 Agent 保持其 Provider)以及主机不提供该 Provider 时会被拒绝。Provider 的模型目录已加载但未列出该模型时,`setModel` 会被拒绝。 |
|
||||
| `setOptions(patch)`、`setMetadata(patch)` | 顶层键替换,`null` 删除该键。草稿中设置的元数据在创建 Agent 时发送,发送提示词后清空。 |
|
||||
|
||||
同一个调用在每个 composer 中行为一致,插件不需要选择 composer 的实现。在运行中的 Agent 上调用 `setMetadata` 需要支持元数据的主机;主机过旧时,Promise 会以 “Update the host to use metadata.” 拒绝。
|
||||
|
||||
`Toggle` 接收 `value` 和 `onValueChange({ value })`。`Select` 接收 `value`、`options`(`{ value, label, description? }`)和 `onValueChange({ value })`。`Action` 接收 `onPress(event)`。三者都接收 `title`、`icon`([Lucide 名称、SVG 或组件](#icons))和 `disabled`。回调可以返回 Promise:在它完成之前控件保持禁用,被拒绝时 Paseo 会像对待自身控件一样,以错误提示向用户显示。控件的位置由 Paseo 决定:composer 空间足够时显示在工具栏中,不够时(手机上始终如此)成为 composer 溢出面板中的一行。`Select` 会就地打开列表,而不是替换面板中的行。`Component` 如果渲染这些控件以外的内容,会在其所在位置绘制;在溢出面板中,该位置就是面板。
|
||||
|
||||
每个命令都按运行时 composer 的当前状态执行,而不是按创建回调的那次渲染,所以 `await composer.setProvider("codex")` 之后再 `await composer.setModel("gpt")` 会同时生效。命令的 Promise 在更改生效后完成,`composer.agent` 在下一次渲染时反映它。
|
||||
|
||||
新建工作区界面在创建工作区及其 Agent 期间会锁定 composer。你的控件显示为禁用,`setPrompt` 和 `insertText` 会抛出错误,Agent 命令会以 "The composer is locked while the agent is being created." 拒绝。`composer.agent.options` 和 `composer.agent.metadata` 是冻结的副本;请通过 `setOptions` 和 `setMetadata` 修改它们。
|
||||
|
||||
在 Paseo 知道渲染结果之前,composer 项就已计入可容纳的数量。不渲染任何内容的项仍可能在窄 composer 上留下空的溢出面板,所以请始终渲染一个控件。
|
||||
|
||||
[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`。
|
||||
|
||||
## Composer 胶囊 {#composer-pills}
|
||||
|
||||
`client.addComposerPill({ id, workspaceId, agentId, button })` 使用同样的[按钮描述符](#button-descriptor),
|
||||
|
||||
Reference in New Issue
Block a user