mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
fix(plugins): composer commands run on current state, report failures, and reach the overflow sheet
Draft commands read the state captured at render, so a plugin's
`await setProvider(); await setModel()` acted on a stale provider. The draft
form and options are now plain stores that commands read live, and
`setProvider`/`setModel` reject an unavailable provider or a model the loaded
catalog does not list. Draft `options` include the plugin's pending edits.
Shared composer controls own the pending state and show a rejected command as
an error toast, like the built-in controls. Callback payloads are objects
(`{ value }`, `onPress(event)`) so they can grow without breaking plugins.
The overflow sheet's content portals to the app root and lost the composer's
agent, input, toast and query contexts, so plugin items drew nothing in it.
`useComposerSheetBridge` carries them across.
This commit is contained in:
@@ -663,6 +663,16 @@ density and overflow. Plugin items count in the density presence in `layout.ts`,
|
||||
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
|
||||
|
||||
@@ -1,101 +1,178 @@
|
||||
import type { TFunction } from "i18next";
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Scan } from "lucide-react-native";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeControl, type ComposerControlItem } from "./composer-control";
|
||||
import { toFeatureItem } from "./control-items";
|
||||
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-context";
|
||||
import { ComposerControls, type ComposerControlItem } from "./composer-control";
|
||||
import { useAgentControlItems } from "./control-items";
|
||||
import { resolveComposerControlPresentation } from "./layout";
|
||||
|
||||
const t = ((key: string) => key) as unknown as TFunction;
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
afterEach(cleanup);
|
||||
|
||||
const base = { id: "x", title: "Context", icon: Scan };
|
||||
|
||||
const select: ComposerControlItem = {
|
||||
...base,
|
||||
type: "select",
|
||||
value: "large",
|
||||
choices: [
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "large", label: "Large", description: "More room" },
|
||||
],
|
||||
onChange: () => {},
|
||||
};
|
||||
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
|
||||
|
||||
describe("describeControl", () => {
|
||||
it("shows the chosen label and opens a list for a select", () => {
|
||||
expect(describeControl(select, "toolbar", t)).toMatchObject({
|
||||
value: "Large",
|
||||
selectedId: "large",
|
||||
iconOnly: false,
|
||||
tooltip: "Context",
|
||||
opensList: true,
|
||||
options: [
|
||||
{ id: "small", label: "Small" },
|
||||
{ id: "large", label: "Large", description: "More room" },
|
||||
],
|
||||
});
|
||||
});
|
||||
function createToast(): RecordingToast {
|
||||
return { error: vi.fn() } as unknown as RecordingToast;
|
||||
}
|
||||
|
||||
it("names the choice in the tooltip of an icon-only select", () => {
|
||||
const item: ComposerControlItem = { ...select, iconOnly: true } as ComposerControlItem;
|
||||
expect(describeControl(item, "toolbar", t)).toMatchObject({
|
||||
iconOnly: true,
|
||||
tooltip: "Context: Large",
|
||||
});
|
||||
});
|
||||
function ignoreOpenChange() {}
|
||||
|
||||
it("toggles on press in the toolbar and lists on/off in the sheet", () => {
|
||||
const toggle: ComposerControlItem = {
|
||||
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: "toggle",
|
||||
value: true,
|
||||
onChange: () => {},
|
||||
type: "select",
|
||||
value: "large",
|
||||
choices: [
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "large", label: "Large" },
|
||||
],
|
||||
onChange: () => undefined,
|
||||
};
|
||||
expect(describeControl(toggle, "toolbar", t).opensList).toBe(false);
|
||||
const inSheet = describeControl(toggle, "sheet", t);
|
||||
expect(inSheet).toMatchObject({ opensList: true, selectedId: "true" });
|
||||
expect(inSheet.value).toBe("agentControls.features.on");
|
||||
renderControls([select]);
|
||||
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("Large");
|
||||
});
|
||||
|
||||
it("acts on press for an action in both places", () => {
|
||||
const action: ComposerControlItem = { ...base, type: "action", onPress: () => {} };
|
||||
expect(describeControl(action, "toolbar", t).opensList).toBe(false);
|
||||
expect(describeControl(action, "sheet", t).opensList).toBe(false);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toFeatureItem", () => {
|
||||
it("reads a toggle from the agent's options and writes its key", () => {
|
||||
const writes: Array<[string, unknown]> = [];
|
||||
const item = toFeatureItem(
|
||||
{ type: "toggle", key: "fast_mode", label: "Fast", defaultValue: false },
|
||||
{ controls: [], options: { fast_mode: true }, onSetOption: (k, v) => writes.push([k, v]) },
|
||||
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],
|
||||
);
|
||||
expect(item).toMatchObject({
|
||||
id: "feature-fast_mode",
|
||||
type: "toggle",
|
||||
value: true,
|
||||
highlight: "yellow",
|
||||
testID: "agent-feature-fast_mode",
|
||||
const items = useAgentControlItems({
|
||||
surface: "toolbar",
|
||||
presentation: resolveComposerControlPresentation("full"),
|
||||
thinking: null,
|
||||
optionControls,
|
||||
});
|
||||
if (item.type === "toggle") item.onChange(false);
|
||||
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]]);
|
||||
});
|
||||
|
||||
it("falls back to the default choice and shows only the icon when asked", () => {
|
||||
const item = toFeatureItem(
|
||||
{
|
||||
type: "select",
|
||||
key: "speed",
|
||||
label: "Speed",
|
||||
desktopTrigger: "icon",
|
||||
defaultValue: "standard",
|
||||
choices: [
|
||||
{ value: "standard", label: "Standard" },
|
||||
{ value: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
{ controls: [], options: {}, onSetOption: () => {} },
|
||||
);
|
||||
expect(item).toMatchObject({ type: "select", value: "standard", iconOnly: true });
|
||||
expect(item.highlight).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, type ReactElement } from "react";
|
||||
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";
|
||||
@@ -8,6 +16,8 @@ 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-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
interface ComposerControlBase {
|
||||
id: string;
|
||||
@@ -36,13 +46,13 @@ export type ComposerControlItem =
|
||||
| (ComposerControlBase & {
|
||||
type: "toggle";
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
onChange: (value: boolean) => void | Promise<void>;
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "select";
|
||||
value: string | null;
|
||||
choices: readonly ComposerControlChoice[];
|
||||
onChange: (value: string) => void;
|
||||
onChange: (value: string) => void | Promise<void>;
|
||||
/** The toolbar trigger shows only the icon. */
|
||||
iconOnly?: boolean;
|
||||
showCaret?: boolean;
|
||||
@@ -57,7 +67,7 @@ export type ComposerControlItem =
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "action";
|
||||
onPress: () => void;
|
||||
onPress: () => void | Promise<void>;
|
||||
});
|
||||
|
||||
export type ComposerControlSurface = "toolbar" | "sheet";
|
||||
@@ -92,7 +102,7 @@ interface ControlView {
|
||||
opensList: boolean;
|
||||
}
|
||||
|
||||
export function describeControl(
|
||||
function describeControl(
|
||||
item: ComposerControlItem,
|
||||
surface: ComposerControlSurface,
|
||||
t: TFunction,
|
||||
@@ -153,18 +163,37 @@ export function ComposerControl({
|
||||
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") item.onChange(!item.value);
|
||||
else if (item.type === "action") item.onPress();
|
||||
if (item.type === "toggle") run(() => item.onChange(!item.value));
|
||||
else if (item.type === "action") run(item.onPress);
|
||||
onActionComplete?.();
|
||||
}, [item, onActionComplete]);
|
||||
}, [item, onActionComplete, run]);
|
||||
const handleOpenPress = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (item.type === "select") item.onChange(value);
|
||||
else if (item.type === "toggle") item.onChange(value === "true");
|
||||
if (item.type === "select") run(() => item.onChange(value));
|
||||
else if (item.type === "toggle") run(() => item.onChange(value === "true"));
|
||||
},
|
||||
[item],
|
||||
[item, run],
|
||||
);
|
||||
|
||||
const select = item.type === "select" ? item : null;
|
||||
@@ -179,7 +208,7 @@ export function ComposerControl({
|
||||
showToolbarLabel={!view.iconOnly}
|
||||
showCaret={select?.showCaret === true && !isSheet}
|
||||
open={view.opensList && open}
|
||||
disabled={disabled}
|
||||
disabled={disabled || pending}
|
||||
onPress={view.opensList ? handleOpenPress : handlePress}
|
||||
accessibilityLabel={item.accessibilityLabel ?? view.tooltip}
|
||||
testID={item.testID}
|
||||
|
||||
@@ -30,7 +30,7 @@ function getFeatureHighlight(
|
||||
return color === "default" ? undefined : color;
|
||||
}
|
||||
|
||||
export function toFeatureItem(
|
||||
function toFeatureItem(
|
||||
control: AgentOptionControl,
|
||||
optionControls: ComposerOptionControls,
|
||||
): ComposerControlItem {
|
||||
|
||||
@@ -71,6 +71,8 @@ import {
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import { useAgentControlItems } from "@/composer/agent-controls/control-items";
|
||||
import { CompactModelSheet } from "@/composer/agent-controls/model-sheet";
|
||||
import { useComposerSheetBridge } from "@/composer/agent-controls/sheet-bridge";
|
||||
import { draftComposerAgent } from "@/composer/draft/composer-agent";
|
||||
import { ComposerAgentProvider, type ComposerAgent } from "@/composer/session";
|
||||
import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items";
|
||||
import {
|
||||
@@ -128,16 +130,22 @@ export interface DraftAgentControlsProps {
|
||||
onSelectMode: (modeId: string) => void;
|
||||
models: AgentModelDefinition[];
|
||||
selectedModel: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
/** `false` when the provider's loaded catalog does not list the model. */
|
||||
onSelectModel: (modelId: string) => boolean;
|
||||
isModelLoading: boolean;
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
isAllModelsLoading: boolean;
|
||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||
/** `false` when the provider is unknown or not ready. */
|
||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => boolean;
|
||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
selectedThinkingOptionId: string;
|
||||
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
|
||||
/** The controls the daemon described for the draft; absent until it answers. */
|
||||
optionControls?: ComposerOptionControls;
|
||||
/** What the draft will create with, edits included, whether or not the daemon has answered. */
|
||||
options: ProviderOptions;
|
||||
onSetOption: (key: string, value: unknown) => void;
|
||||
onDropdownClose?: () => void;
|
||||
onModelSelectorOpen?: () => void;
|
||||
onRetryModelProvider?: (provider: AgentProvider) => void;
|
||||
@@ -711,6 +719,7 @@ interface DesktopAgentControlsContentProps extends AgentControlsContentProps {
|
||||
}
|
||||
|
||||
function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
const sheetBridge = useComposerSheetBridge();
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -817,6 +826,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
header={featuresSheetHeader}
|
||||
visible={activeSheet === "features"}
|
||||
onClose={handleCloseSheet}
|
||||
contextBridge={sheetBridge}
|
||||
testID="agent-features-sheet"
|
||||
>
|
||||
<ComposerControls
|
||||
@@ -853,6 +863,7 @@ interface SheetAgentControlsContentProps extends AgentControlsContentProps {
|
||||
}
|
||||
|
||||
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
const sheetBridge = useComposerSheetBridge();
|
||||
const {
|
||||
provider,
|
||||
selectedModelId,
|
||||
@@ -916,6 +927,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
onEditProfiles={onEditAgentProfiles}
|
||||
onCreateProfile={onCreateAgentProfile}
|
||||
onEditProfile={onEditAgentProfile}
|
||||
contextBridge={sheetBridge}
|
||||
isLoading={isModelLoading}
|
||||
disabled={disabled}
|
||||
onOpen={onModelSelectorOpen}
|
||||
@@ -1269,6 +1281,8 @@ export function DraftAgentControls({
|
||||
onSelectThinkingOption,
|
||||
onApplyAgentProfile,
|
||||
optionControls,
|
||||
options,
|
||||
onSetOption,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen,
|
||||
onRetryModelProvider,
|
||||
@@ -1338,26 +1352,23 @@ export function DraftAgentControls({
|
||||
[selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled],
|
||||
);
|
||||
|
||||
const composerAgent = useMemo<ComposerAgent>(
|
||||
() => ({
|
||||
context,
|
||||
provider: selectedProvider,
|
||||
model: selectedModel || null,
|
||||
mode: selectedMode || null,
|
||||
thinking: effectiveSelectedThinkingOption ?? null,
|
||||
options: optionControls?.options ?? EMPTY_OPTIONS,
|
||||
metadata,
|
||||
// An empty model id picks the provider's default model.
|
||||
setProvider: async (nextProvider) => onSelectProviderAndModel(nextProvider, ""),
|
||||
setModel: async (modelId) => onSelectModel(modelId),
|
||||
setMode: async (modeId) => onSelectMode(modeId),
|
||||
setThinking: async (thinkingOptionId) => onSelectThinkingOption(thinkingOptionId),
|
||||
setOptions: async (patch) => {
|
||||
if (!optionControls) throw new Error("This provider has no options.");
|
||||
for (const [key, value] of Object.entries(patch)) optionControls.onSetOption(key, value);
|
||||
},
|
||||
setMetadata: async (patch) => onSetMetadata(patch),
|
||||
}),
|
||||
const composerAgent = useMemo(
|
||||
() =>
|
||||
draftComposerAgent({
|
||||
context,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
selectedMode,
|
||||
thinking: effectiveSelectedThinkingOption ?? null,
|
||||
options,
|
||||
metadata,
|
||||
onSelectProviderAndModel,
|
||||
onSelectModel,
|
||||
onSelectMode,
|
||||
onSelectThinkingOption,
|
||||
onSetOption,
|
||||
onSetMetadata,
|
||||
}),
|
||||
[
|
||||
context,
|
||||
effectiveSelectedThinkingOption,
|
||||
@@ -1367,7 +1378,8 @@ export function DraftAgentControls({
|
||||
onSelectProviderAndModel,
|
||||
onSelectThinkingOption,
|
||||
onSetMetadata,
|
||||
optionControls,
|
||||
onSetOption,
|
||||
options,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
|
||||
@@ -25,11 +25,13 @@ 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,
|
||||
|
||||
@@ -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,197 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act, useSyncExternalStore } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
|
||||
/**
|
||||
* A faithful stand-in for `@gorhom/portal`: it renders nothing where it sits and hands its
|
||||
* element to a host elsewhere in the tree, so the sheet's content loses the composer's context
|
||||
* exactly as it does on a phone and on a narrow window.
|
||||
*/
|
||||
const portalStore = vi.hoisted(() => {
|
||||
const listeners = new Set<() => void>();
|
||||
let node: unknown = null;
|
||||
return {
|
||||
put(next: unknown) {
|
||||
node = next;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
read: () => node,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
reset() {
|
||||
node = null;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@gorhom/portal", () => ({
|
||||
Portal: ({ children }: { children?: React.ReactNode }) => {
|
||||
React.useEffect(() => {
|
||||
portalStore.put(children);
|
||||
}, [children]);
|
||||
return null;
|
||||
},
|
||||
PortalHost: () => React.createElement("div", { "data-host": true }),
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
const { Portal } = await import("@gorhom/portal");
|
||||
return {
|
||||
BottomSheetHandle: () => null,
|
||||
useBottomSheetGestureHandlers: () => ({}),
|
||||
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
BottomSheetModal: React.forwardRef(({ children }: { children?: React.ReactNode }, _ref) =>
|
||||
React.createElement(Portal, null, children),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/plugins/client-runtime", () => ({
|
||||
createPluginClientRuntime: () => ({
|
||||
paseo: { dispose: async () => {} },
|
||||
rpc: async () => undefined,
|
||||
openSurface: () => undefined,
|
||||
openPanel: () => undefined,
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
}),
|
||||
}));
|
||||
|
||||
import appPackage from "../../../package.json";
|
||||
import { IsolatedBottomSheetModal } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { ToastApiProvider } from "@/contexts/toast-context";
|
||||
import { PluginComposerItems } from "@/plugins/composer-items";
|
||||
import { pluginRegistry } from "@/plugins/registry";
|
||||
import {
|
||||
ComposerAgentProvider,
|
||||
ComposerInputProvider,
|
||||
type ComposerAgent,
|
||||
type ComposerInput,
|
||||
} from "@/composer/session";
|
||||
import { useComposerSheetBridge } from "./sheet-bridge";
|
||||
|
||||
// One toggle that reads the composer's metadata, as a plugin author writes it.
|
||||
const PLUGIN_BUNDLE = `(function(require) {
|
||||
const React = require("react");
|
||||
const { useComposer } = require("@getpaseo/plugin/client");
|
||||
const { Toggle } = require("@getpaseo/plugin/client/ui");
|
||||
const module = { exports: {} };
|
||||
function NeedsReview() {
|
||||
const { agent, setMetadata } = useComposer();
|
||||
return React.createElement(Toggle, {
|
||||
title: "Needs review",
|
||||
icon: "Scan",
|
||||
value: agent.metadata.needsReview === true,
|
||||
onValueChange: ({ value }) => setMetadata({ needsReview: value }),
|
||||
});
|
||||
}
|
||||
module.exports.default = function(plugin) {
|
||||
plugin.addComposerItem({ id: "needs-review", Component: NeedsReview });
|
||||
return function() {};
|
||||
};
|
||||
return module.exports;
|
||||
})`;
|
||||
|
||||
const agent: ComposerAgent = {
|
||||
context: "draft",
|
||||
provider: "claude",
|
||||
model: "sonnet",
|
||||
mode: null,
|
||||
thinking: null,
|
||||
options: {},
|
||||
metadata: { needsReview: true },
|
||||
setProvider: async () => {},
|
||||
setModel: async () => {},
|
||||
setMode: async () => {},
|
||||
setThinking: async () => {},
|
||||
setOptions: async () => {},
|
||||
setMetadata: async () => {},
|
||||
};
|
||||
|
||||
const input: ComposerInput = {
|
||||
textSource: { getSnapshot: () => "", subscribe: () => () => {} },
|
||||
setText: () => {},
|
||||
insertText: () => {},
|
||||
attachments: [],
|
||||
changeAttachments: () => {},
|
||||
};
|
||||
|
||||
const toast = { error: () => {} } as unknown as ToastApi;
|
||||
|
||||
/** The overflow sheet: the plugin's items inside a sheet, opened from the composer. */
|
||||
function OverflowSheet() {
|
||||
const sheetBridge = useComposerSheetBridge();
|
||||
return (
|
||||
<IsolatedBottomSheetModal contextBridge={sheetBridge}>
|
||||
<PluginComposerItems serverId="host-a" surface="sheet" />
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders whatever the portal holds from the app root, outside every composer provider. */
|
||||
function PortalHostProbe() {
|
||||
const node = useSyncExternalStore(portalStore.subscribe, portalStore.read, portalStore.read);
|
||||
return <div data-portal-host>{node as React.ReactNode}</div>;
|
||||
}
|
||||
|
||||
describe("the composer's overflow sheet", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
pluginRegistry.installCatalog(
|
||||
"host-a",
|
||||
[
|
||||
{
|
||||
id: "review",
|
||||
clientBundle: PLUGIN_BUNDLE,
|
||||
requirements: { paseo: `>=${appPackage.version}` },
|
||||
},
|
||||
],
|
||||
{ client: {} as DaemonClient, audio: { play: async () => 0 } },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
portalStore.reset();
|
||||
pluginRegistry.removeHost("host-a");
|
||||
});
|
||||
|
||||
it("draws a plugin's composer item as a row that reads the composer", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerAgentProvider value={agent}>
|
||||
<ComposerInputProvider value={input}>
|
||||
<OverflowSheet />
|
||||
</ComposerInputProvider>
|
||||
</ComposerAgentProvider>
|
||||
</ToastApiProvider>
|
||||
<PortalHostProbe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const sheet = container.querySelector("[data-portal-host]");
|
||||
expect(sheet?.textContent).toContain("Needs review");
|
||||
// The toggle reads `agent.metadata.needsReview`, so the row shows the composer's value.
|
||||
expect(sheet?.textContent).toContain("On");
|
||||
});
|
||||
});
|
||||
@@ -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-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,198 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import React, { type ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
const { asyncStorage, runtime } = vi.hoisted(() => ({
|
||||
asyncStorage: new Map<string, string>(),
|
||||
runtime: {
|
||||
controlRequests: [] as unknown[],
|
||||
client: null as unknown,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => void asyncStorage.set(key, value),
|
||||
removeItem: async (key: string) => void asyncStorage.delete(key),
|
||||
},
|
||||
}));
|
||||
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
modes: [{ id: "default", label: "Default" }],
|
||||
models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }],
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
modes: [{ id: "auto", label: "Auto" }],
|
||||
models: [
|
||||
{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true },
|
||||
{ provider: "codex", id: "gpt-5.5", label: "GPT-5.5" },
|
||||
],
|
||||
},
|
||||
// Ready, but it lists no models: the host cannot say which ids exist.
|
||||
{
|
||||
provider: "opencode",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@/hooks/use-providers-snapshot", () => ({
|
||||
useProvidersSnapshot: () => ({
|
||||
entries,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
supportsSnapshot: true,
|
||||
refresh: async () => undefined,
|
||||
refetchIfStale: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => runtime.client,
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
}));
|
||||
|
||||
const { useAgentFormState } = await import("@/hooks/use-agent-form-state");
|
||||
const { useDraftProviderOptions } = await import("@/hooks/use-draft-provider-options");
|
||||
const { draftComposerAgent } = await import("./composer-agent");
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
/** The draft composer's hooks, joined the way the screens join them. */
|
||||
const initialValues = { provider: "claude" } as const;
|
||||
|
||||
function useDraftComposer(context: "draft" | "new-workspace") {
|
||||
const form = useAgentFormState({
|
||||
serverId: "host-1",
|
||||
workingDir: "/repo",
|
||||
isVisible: true,
|
||||
initialValues,
|
||||
});
|
||||
const options = useDraftProviderOptions({
|
||||
serverId: "host-1",
|
||||
provider: form.selectedProvider,
|
||||
getProvider: form.getSelectedProvider,
|
||||
cwd: "/repo",
|
||||
modeId: form.selectedMode,
|
||||
modelId: form.selectedModel,
|
||||
thinkingOptionId: form.selectedThinkingOptionId,
|
||||
});
|
||||
const agent = draftComposerAgent({
|
||||
context,
|
||||
selectedProvider: form.selectedProvider,
|
||||
selectedModel: form.selectedModel,
|
||||
selectedMode: form.selectedMode,
|
||||
thinking: form.selectedThinkingOptionId || null,
|
||||
options: options.shownOptions,
|
||||
metadata: {},
|
||||
onSelectProviderAndModel: form.setProviderAndModelFromUser,
|
||||
onSelectModel: form.setModelFromUser,
|
||||
onSelectMode: form.setModeFromUser,
|
||||
onSelectThinkingOption: form.setThinkingOptionFromUser,
|
||||
onSetOption: options.setOption,
|
||||
onSetMetadata: () => undefined,
|
||||
});
|
||||
return { form, agent };
|
||||
}
|
||||
|
||||
async function mount(context: "draft" | "new-workspace") {
|
||||
const view = renderHook(() => useDraftComposer(context), { wrapper });
|
||||
await waitFor(() => expect(view.result.current.form.selectedProvider).not.toBeNull());
|
||||
return view;
|
||||
}
|
||||
|
||||
describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (context) => {
|
||||
beforeEach(() => {
|
||||
asyncStorage.clear();
|
||||
runtime.controlRequests = [];
|
||||
runtime.client = {
|
||||
listProviderControls: async (request: unknown) => {
|
||||
runtime.controlRequests.push(request);
|
||||
return { controls: [], options: {} };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("applies each awaited command before the next one runs", async () => {
|
||||
const view = await mount(context);
|
||||
// A plugin keeps the object it was handed across its awaits.
|
||||
const retained = view.result.current.agent;
|
||||
const other = retained.provider === "codex" ? "claude" : "codex";
|
||||
const model = other === "codex" ? "gpt-5.5" : "sonnet";
|
||||
|
||||
await act(async () => {
|
||||
await retained.setProvider(other);
|
||||
await retained.setModel(model);
|
||||
await retained.setOptions({ context: "1m" });
|
||||
});
|
||||
|
||||
const { form, agent } = view.result.current;
|
||||
expect(agent.context).toBe(context);
|
||||
expect(form.selectedProvider).toBe(other);
|
||||
expect(form.selectedModel).toBe(model);
|
||||
expect(agent.options).toEqual({ context: "1m" });
|
||||
await waitFor(() =>
|
||||
expect(runtime.controlRequests.at(-1)).toMatchObject({
|
||||
provider: other,
|
||||
model,
|
||||
providerOptions: { context: "1m" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the options of the provider they were set for", async () => {
|
||||
const view = await mount(context);
|
||||
const retained = view.result.current.agent;
|
||||
|
||||
await act(async () => {
|
||||
await retained.setOptions({ context: "1m" });
|
||||
await retained.setProvider(retained.provider === "codex" ? "claude" : "codex");
|
||||
});
|
||||
|
||||
// Choosing a provider by hand starts the options over.
|
||||
expect(view.result.current.agent.options).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects a provider the host cannot run and a model the catalog does not list", async () => {
|
||||
const view = await mount(context);
|
||||
const retained = view.result.current.agent;
|
||||
|
||||
await act(async () => {
|
||||
await expect(retained.setProvider("nope")).rejects.toThrow(
|
||||
"Unknown or unavailable provider: nope",
|
||||
);
|
||||
await retained.setProvider("codex");
|
||||
await expect(retained.setModel("missing")).rejects.toThrow("Unknown model: missing");
|
||||
});
|
||||
expect(view.result.current.form.selectedProvider).toBe("codex");
|
||||
expect(view.result.current.form.selectedModel).not.toBe("missing");
|
||||
});
|
||||
|
||||
it("keeps a model id as given when the provider lists no catalog", async () => {
|
||||
const view = await mount(context);
|
||||
const retained = view.result.current.agent;
|
||||
|
||||
await act(async () => {
|
||||
await retained.setProvider("opencode");
|
||||
await retained.setModel("anthropic/claude-x");
|
||||
});
|
||||
expect(view.result.current.form.selectedModel).toBe("anthropic/claude-x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
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"
|
||||
| "selectedProvider"
|
||||
| "selectedModel"
|
||||
| "selectedMode"
|
||||
| "options"
|
||||
| "metadata"
|
||||
| "onSelectProviderAndModel"
|
||||
| "onSelectModel"
|
||||
| "onSelectMode"
|
||||
| "onSelectThinkingOption"
|
||||
| "onSetOption"
|
||||
| "onSetMetadata"
|
||||
> & { thinking: string | null },
|
||||
): ComposerAgent {
|
||||
return {
|
||||
context: props.context ?? "draft",
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,8 @@ 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"]>;
|
||||
@@ -31,6 +33,8 @@ export function buildDraftAgentControls(input: {
|
||||
const {
|
||||
formState,
|
||||
optionControls,
|
||||
options,
|
||||
onSetOption,
|
||||
onApplyAgentProfile,
|
||||
onDropdownClose,
|
||||
context,
|
||||
@@ -55,6 +59,8 @@ export function buildDraftAgentControls(input: {
|
||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||
onApplyAgentProfile,
|
||||
optionControls,
|
||||
options,
|
||||
onSetOption,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||
onRetryModelProvider: formState.refreshProviderModels,
|
||||
|
||||
@@ -290,10 +290,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,
|
||||
@@ -337,6 +340,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
agentControls: buildDraftAgentControls({
|
||||
formState,
|
||||
optionControls: draftOptionControls,
|
||||
options: draftShownOptions,
|
||||
onSetOption: setDraftOption,
|
||||
onApplyAgentProfile: applyDraftAgentProfile,
|
||||
context: composerOptions.context ?? "draft",
|
||||
metadata,
|
||||
@@ -352,6 +357,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveThinkingOptionId,
|
||||
draftOptionControls,
|
||||
draftOptions,
|
||||
draftShownOptions,
|
||||
setDraftOption,
|
||||
applyDraftAgentProfile,
|
||||
formState,
|
||||
metadata,
|
||||
|
||||
@@ -513,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,
|
||||
|
||||
@@ -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 } 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,7 +76,8 @@ 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;
|
||||
@@ -172,11 +175,13 @@ 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,
|
||||
});
|
||||
const [formStore] = useState(openAgentForm);
|
||||
const { form: formState, resolution } = useSyncExternalStore(
|
||||
formStore.subscribe,
|
||||
formStore.getState,
|
||||
formStore.getState,
|
||||
);
|
||||
const dispatch = formStore.dispatch;
|
||||
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
@@ -277,12 +282,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
preferences,
|
||||
snapshotProviderModelsByProvider,
|
||||
snapshotResolvableProviderDefinitionMap,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
(provider: AgentProvider, modelId: string) => {
|
||||
(provider: AgentProvider, modelId: string): boolean => {
|
||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
@@ -307,13 +313,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
},
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||
[allProviderModels, dispatch, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const clearProviderSelectionFromUser = useCallback(() => {
|
||||
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
const applyProfileFromUser = useCallback(
|
||||
(profile: MaterializedAgentProfile) => {
|
||||
@@ -322,7 +329,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 +344,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 +368,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 +392,36 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
);
|
||||
}
|
||||
},
|
||||
[formState.provider, updateCurrentPreferences],
|
||||
[dispatch, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
// Reads the form as it is now: a command that follows `setProviderAndModelFromUser` sees the new
|
||||
// provider and its catalog. A catalog that has not arrived keeps the id as given; one that has
|
||||
// arrived refuses an id it does not list.
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
const provider = formState.provider;
|
||||
(modelId: string): boolean => {
|
||||
const { provider } = formStore.getState().form;
|
||||
const listed = provider
|
||||
? filterSelectableModels(
|
||||
(snapshotEntries ?? []).find((entry) => entry.provider === provider)?.models ?? null,
|
||||
)
|
||||
: null;
|
||||
const models = listed?.length ? listed : null;
|
||||
if (models && normalizeSelectedModelId(modelId) && !resolveEffectiveModel(models, modelId)) {
|
||||
return false;
|
||||
}
|
||||
const providerPrefs = provider
|
||||
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
|
||||
: undefined;
|
||||
dispatch({
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId,
|
||||
availableModels,
|
||||
availableModels: models,
|
||||
providerPrefs,
|
||||
});
|
||||
if (provider) {
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(models);
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
@@ -413,14 +432,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[availableModels, formState.provider, updateCurrentPreferences],
|
||||
[dispatch, formStore, snapshotEntries, 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 +455,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 +501,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
() => ({
|
||||
selectedServerId: serverId,
|
||||
selectedProvider: formState.provider,
|
||||
getSelectedProvider,
|
||||
selectedMode: formState.modeId,
|
||||
setModeFromUser,
|
||||
selectedModel: formState.model,
|
||||
@@ -509,6 +532,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
}),
|
||||
[
|
||||
serverId,
|
||||
getSelectedProvider,
|
||||
formState.provider,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useFetchQuery } from "@/data/query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
|
||||
|
||||
const EMPTY_OPTIONS: ProviderOptions = {};
|
||||
|
||||
/**
|
||||
* Adapter between a draft's options model and the screen: it sends the model's complete options
|
||||
* to the daemon, hands the answer back, and persists the user's toggles as last-used values.
|
||||
@@ -19,6 +21,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,7 +30,16 @@ export function useDraftProviderOptions(input: {
|
||||
initialOptions?: ProviderOptions;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
|
||||
const {
|
||||
serverId,
|
||||
provider,
|
||||
getProvider,
|
||||
cwd,
|
||||
modeId,
|
||||
modelId,
|
||||
thinkingOptionId,
|
||||
initialOptions,
|
||||
} = input;
|
||||
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
|
||||
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
@@ -77,21 +90,24 @@ 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) => {
|
||||
const current = getProvider();
|
||||
model.observeProvider(current);
|
||||
model.setOption(key, value);
|
||||
if (!provider) return;
|
||||
void updatePreferences((current) =>
|
||||
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 = viewOptions ?? ownOptions ?? EMPTY_OPTIONS;
|
||||
|
||||
return {
|
||||
optionControls,
|
||||
options: ownOptions,
|
||||
shownOptions,
|
||||
setOption,
|
||||
applyProfile: model.applyProfile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ function fakeAgent(context: ComposerAgent["context"]) {
|
||||
thinking: null,
|
||||
options: { effort: "high" },
|
||||
metadata: { flag: true },
|
||||
setProvider: async (provider) => void calls.push(`provider:${provider}`),
|
||||
// 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}`),
|
||||
@@ -133,14 +137,12 @@ describe("toPluginComposer", () => {
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
await composer.setProvider("codex");
|
||||
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([
|
||||
"provider:codex",
|
||||
"model:gpt",
|
||||
"mode:plan",
|
||||
"thinking:high",
|
||||
@@ -150,6 +152,29 @@ describe("toPluginComposer", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a draft change provider and tells a running agent it cannot", async () => {
|
||||
const { input } = fakeInput();
|
||||
const draft = fakeAgent("draft");
|
||||
await toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent: draft.agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
}).setProvider("codex");
|
||||
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();
|
||||
|
||||
@@ -43,7 +43,7 @@ export function Toggle({ title, icon, disabled, value, onValueChange }: TogglePr
|
||||
icon: Icon,
|
||||
highlight: value ? "blue" : undefined,
|
||||
value,
|
||||
onChange: onValueChange,
|
||||
onChange: (next) => onValueChange({ value: next }),
|
||||
}),
|
||||
[Icon, onValueChange, title, value],
|
||||
);
|
||||
@@ -67,7 +67,7 @@ export function Select<Value extends string>({
|
||||
icon: Icon,
|
||||
value,
|
||||
choices: options,
|
||||
onChange: (next) => onValueChange(next as Value),
|
||||
onChange: (next) => onValueChange({ value: next as Value }),
|
||||
showCaret: true,
|
||||
}),
|
||||
[Icon, onValueChange, options, title, value],
|
||||
@@ -78,7 +78,7 @@ export function Select<Value extends string>({
|
||||
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 }),
|
||||
() => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress: () => onPress({}) }),
|
||||
[Icon, onPress, title],
|
||||
);
|
||||
return <PluginComposerControl item={item} disabled={disabled} />;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
INITIAL_AGENT_FORM_RESOLUTION,
|
||||
INITIAL_USER_MODIFIED,
|
||||
resolveAgentForm,
|
||||
type AgentFormAction,
|
||||
type AgentFormReducerState,
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
/**
|
||||
* The draft form as a plain model: `resolveAgentForm` applied synchronously. A command reads
|
||||
* `getState()` 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;
|
||||
}
|
||||
|
||||
export function openAgentForm(): AgentFormStore {
|
||||
let state: AgentFormReducerState = {
|
||||
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
|
||||
userModified: INITIAL_USER_MODIFIED,
|
||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
getState: () => state,
|
||||
dispatch: (action) => {
|
||||
const next = resolveAgentForm(state, action);
|
||||
if (next === state) return;
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const testTheme = {
|
||||
blue: { 300: "#93c5fd", 400: "#60a5fa" },
|
||||
green: { 400: "#4ade80", 500: "#22c55e" },
|
||||
yellow: { 400: "#facc15" },
|
||||
zinc: { 600: "#52525b" },
|
||||
red: { 300: "#fca5a5" },
|
||||
white: "#ffffff",
|
||||
},
|
||||
|
||||
@@ -235,8 +235,8 @@ describe("plugin composer SDK boundary", () => {
|
||||
composer.setMetadata({ fn: () => {} });
|
||||
return (
|
||||
<>
|
||||
<Toggle title="Flag" icon="Flag" value={flag} onValueChange={(next: boolean) => composer.setMetadata({ flag: next })} />
|
||||
<Select title="Size" icon="Gauge" value="a" options={[{ value: "a", label: "A" }, { value: "b", label: "B" }]} onValueChange={(next: "a" | "b") => {}} />
|
||||
<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("")} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -193,17 +193,22 @@ export interface ComposerControlProps {
|
||||
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(value: boolean): void;
|
||||
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(value: Value): void;
|
||||
onValueChange(event: { value: Value }): void | Promise<void>;
|
||||
}
|
||||
export interface ActionPressEvent {}
|
||||
export interface ActionProps extends ComposerControlProps {
|
||||
onPress(): void;
|
||||
onPress(event: ActionPressEvent): void | Promise<void>;
|
||||
}
|
||||
/**
|
||||
* Composer controls. Render them from a composer item's `Component`. Paseo draws them inline when
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
# Composer controls
|
||||
|
||||
Install this directory as a plugin and open the agent, draft, or New workspace composer. Three
|
||||
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. |
|
||||
| **Insert checklist** | `Action` | Inserts a task list at the cursor through `composer.insertText`. |
|
||||
| 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 three components; [index.client.tsx](index.client.tsx)
|
||||
[client/controls.tsx](client/controls.tsx) holds the four components; [index.client.tsx](index.client.tsx)
|
||||
registers them with `addComposerItem`.
|
||||
|
||||
@@ -15,16 +15,16 @@ 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 value: ContextLength = composer.agent.metadata[CONTEXT_KEY] === "long" ? "long" : "short";
|
||||
const current: ContextLength = composer.agent.metadata[CONTEXT_KEY] === "long" ? "long" : "short";
|
||||
const change = useCallback(
|
||||
(next: ContextLength) => composer.setMetadata({ [CONTEXT_KEY]: next }),
|
||||
({ value }: { value: ContextLength }) => composer.setMetadata({ [CONTEXT_KEY]: value }),
|
||||
[composer],
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
title="Context length"
|
||||
icon="Gauge"
|
||||
value={value}
|
||||
value={current}
|
||||
options={CONTEXT_OPTIONS}
|
||||
onValueChange={change}
|
||||
/>
|
||||
@@ -34,7 +34,7 @@ export function ContextLength() {
|
||||
export function ReviewFlag() {
|
||||
const composer = useComposer();
|
||||
const change = useCallback(
|
||||
(next: boolean) => composer.setMetadata({ [REVIEW_KEY]: next ? true : null }),
|
||||
({ value }: { value: boolean }) => composer.setMetadata({ [REVIEW_KEY]: value ? true : null }),
|
||||
[composer],
|
||||
);
|
||||
return (
|
||||
@@ -47,6 +47,28 @@ export function ReviewFlag() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { Checklist, ContextLength, ReviewFlag } from "./client/controls";
|
||||
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 () => {};
|
||||
}
|
||||
|
||||
@@ -1912,7 +1912,9 @@ function ReviewFlag() {
|
||||
title="Needs review"
|
||||
icon="Flag"
|
||||
value={composer.agent.metadata["review.needsReview"] === true}
|
||||
onValueChange={(next) => composer.setMetadata({ "review.needsReview": next ? true : null })}
|
||||
onValueChange={({ value }) =>
|
||||
composer.setMetadata({ "review.needsReview": value ? true : null })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1937,35 +1939,42 @@ export default function contribute(client: PluginClientContext) {
|
||||
|
||||
`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. |
|
||||
| `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, and rejects on `context: "agent"`: a running agent keeps its provider. |
|
||||
| `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. |
|
||||
| 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`. `Select` takes `value`, `options` (`{ value, label,
|
||||
description? }`) and `onValueChange`. `Action` takes `onPress`. All three take `title`, `icon` (a
|
||||
[Lucide name, SVG or component](#icons)) and `disabled`. 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
|
||||
`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.
|
||||
|
||||
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`, a `Toggle` and an `Action`.
|
||||
registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`.
|
||||
|
||||
## Composer pills
|
||||
|
||||
|
||||
@@ -1615,7 +1615,9 @@ function ReviewFlag() {
|
||||
title="Needs review"
|
||||
icon="Flag"
|
||||
value={composer.agent.metadata["review.needsReview"] === true}
|
||||
onValueChange={(next) => composer.setMetadata({ "review.needsReview": next ? true : null })}
|
||||
onValueChange={({ value }) =>
|
||||
composer.setMetadata({ "review.needsReview": value ? true : null })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1640,24 +1642,26 @@ export default function contribute(client: PluginClientContext) {
|
||||
|
||||
`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.metadata` | Agent 的[元数据](/docs/plugins/hooks#metadata)。在新建工作区中是第一个 Agent 的元数据。 |
|
||||
| `setProvider`、`setModel`、`setMode`、`setThinking` | 返回 Promise。`setProvider` 会像 Provider 选择器一样重置模型,在 `context: "agent"` 下会被拒绝:运行中的 Agent 保持其 Provider。 |
|
||||
| `setOptions(patch)`、`setMetadata(patch)` | 顶层键替换,`null` 删除该键。草稿中设置的元数据在创建 Agent 时发送,发送提示词后清空。 |
|
||||
| 字段或命令 | 含义 |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `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`。`Select` 接收 `value`、`options`(`{ value, label, description? }`)和 `onValueChange`。`Action` 接收 `onPress`。三者都接收 `title`、`icon`([Lucide 名称、SVG 或组件](#icons))和 `disabled`。控件的位置由 Paseo 决定:composer 空间足够时显示在工具栏中,不够时(手机上始终如此)成为 composer 溢出面板中的一行。`Select` 会就地打开列表,而不是替换面板中的行。`Component` 如果渲染这些控件以外的内容,会在其所在位置绘制;在溢出面板中,该位置就是面板。
|
||||
`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` 在下一次渲染时反映它。
|
||||
|
||||
在 Paseo 知道渲染结果之前,composer 项就已计入可容纳的数量。不渲染任何内容的项仍可能在窄 composer 上留下空的溢出面板,所以请始终渲染一个控件。
|
||||
|
||||
[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、一个 `Toggle` 和一个 `Action`。
|
||||
[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`。
|
||||
|
||||
## Composer 胶囊 {#composer-pills}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user