mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 14:43:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baaaaa363d | ||
|
|
16b843949e | ||
|
|
4b0d5f5e4c | ||
|
|
4706165690 | ||
|
|
67a358128b | ||
|
|
9674fc4d4c | ||
|
|
0d89c51c36 | ||
|
|
a5fa8a5d83 | ||
|
|
ca178b6578 | ||
|
|
b965a3db7c | ||
|
|
c1f64d45e1 | ||
|
|
aa558ab845 | ||
|
|
a2b76f77f3 | ||
|
|
ad88797f8d | ||
|
|
d92b16afbf | ||
|
|
abd4d2c1ea |
@@ -644,6 +644,72 @@ 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 New workspace items
|
||||
|
||||
`addNewWorkspaceItem` mounts a plugin component on the New workspace screen. The public contract
|
||||
is the [New workspace items reference](../public-docs/plugins/reference.md#new-workspace-items).
|
||||
|
||||
The screen's choices that a plugin may change live in one plain store,
|
||||
`createNewWorkspaceSetup` (`packages/app/src/screens/new-workspace/setup-model.ts`): the base
|
||||
picker selection, the manual isolation, the branch name and the workspace metadata. The pickers and
|
||||
the plugin commands write to it, so a command sees what an earlier command left. Project and host
|
||||
stay in the screen's hooks (`useNewWorkspaceHostSelector` reconciles them against each other), and
|
||||
they are read-only to plugins; a command that switched them would unmount the item that called it.
|
||||
|
||||
The store is built in the render that creates the screen, with what the screen derives from its
|
||||
hooks (the lock, the project, whether the project can make worktrees, whether the host supports
|
||||
metadata), so a command is valid from a child's first effect, which runs before the screen's own
|
||||
effects. `syncInputs` refreshes those inputs in a layout effect on every commit, and commands read
|
||||
them when they run, never from the render that built them. The pickers and the commands pick
|
||||
through the same `chooseBase` and `chooseIsolation`, so a plugin command has the effects of a
|
||||
pick; the screen injects those effects when it builds the store (syncing the picker's PR attachment,
|
||||
remembering the isolation), so a command from a mount effect does the same as a click. Closing the
|
||||
picker stays in the picker's own handler. Add a capability by extending the store;
|
||||
do not read screen state from a plugin component.
|
||||
|
||||
`ensureWorkspace` sends the store's `branch` as `source.branchName` whenever the worktree cuts a
|
||||
new branch (`startsNewBranch`, which mirrors the daemon: anything but a checkout, including no base
|
||||
at all on a detached HEAD), and its `metadata` on `workspace.create` when it is not empty. The
|
||||
server uses `branchName ?? worktreeSlug` as the new branch. `baseRef.name` is the exact ref
|
||||
creation uses; `branchNameFromRef` is for labels only.
|
||||
|
||||
Items render through `ComposerControl` with `surface="field"`: a `FieldTrigger` (a badge on
|
||||
desktop, a full-width field when compact) and the same pending state, error toast and list as the
|
||||
composer's controls. `ComposerControlLockProvider` carries the screen's lock to them. Plugin
|
||||
`Combobox` is a `select` item with `remoteSearch`: the app `Combobox` does not filter locally, and
|
||||
`PluginCombobox` (`plugins/react-native/composer-controls.tsx`) runs `search` through
|
||||
`useFetchQuery` with `dataShape: "list"`, which keeps the previous results while a newer query runs.
|
||||
`plugins/item-host.tsx` is the host shared by composer and New workspace items.
|
||||
|
||||
## 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,92 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { withNewWorkspaceIssuesExample } from "../support/helpers/plugin-new-workspace-items";
|
||||
|
||||
for (const layout of [
|
||||
{ name: "desktop", compact: false },
|
||||
{ name: "compact", compact: true },
|
||||
]) {
|
||||
test(`a plugin's New workspace item sets the branch and metadata on ${layout.name}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(page, { compact: layout.compact }, async (screen) => {
|
||||
await screen.chooseWorktree();
|
||||
await screen.chooseIssue("PAS-102 Crash on resume");
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).toBe("pas-102-crash-on-resume");
|
||||
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-102" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("a branch chosen while Local applies once the user switches to a new worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(page, { compact: false }, async (screen) => {
|
||||
await screen.chooseIssue("PAS-101 Slow sidebar");
|
||||
await screen.chooseWorktree();
|
||||
const created = await screen.createWorkspace({ prompt: "" });
|
||||
expect(created.branch).toBe("pas-101-slow-sidebar");
|
||||
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-101" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin's branch applies when the project has no default base to branch off", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, detachedHead: true },
|
||||
async (screen) => {
|
||||
await screen.chooseWorktree();
|
||||
await screen.chooseIssue("PAS-101 Slow sidebar");
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).toBe("pas-101-slow-sidebar");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("a branch and metadata chosen for one project are not sent for another", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, otherProject: true },
|
||||
async (screen) => {
|
||||
await screen.chooseWorktree();
|
||||
await screen.chooseIssue("PAS-101 Slow sidebar");
|
||||
await screen.switchToOtherProject();
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).not.toBe("pas-101-slow-sidebar");
|
||||
expect(created.metadata ?? {}).toEqual({});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("an isolation set from a mount effect is remembered like a pick", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, setsOnMount: true },
|
||||
async (screen) => {
|
||||
await screen.expectIsolation("worktree");
|
||||
await screen.reopenWithoutPlugin();
|
||||
await screen.expectIsolation("worktree");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("an item can set the branch and metadata from its mount effect", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, setsOnMount: true },
|
||||
async (screen) => {
|
||||
await screen.expectIsolation("worktree");
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).toBe("set-on-mount");
|
||||
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "ON-MOUNT" });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectWorkspaceIsolationSelected,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { waitForSidebarHydration } from "./workspace-ui";
|
||||
|
||||
/** Presses Create, typing the prompt first unless the test wants an empty one. */
|
||||
async function submitCreate(page: Page, prompt: string | undefined) {
|
||||
if (prompt !== "") return submitNewWorkspacePrompt(page, prompt);
|
||||
await page.getByTestId("message-input-root").getByRole("button", { name: "Create" }).click();
|
||||
}
|
||||
|
||||
/** An item that sets the branch and metadata from its mount effect, before the user touches anything. */
|
||||
async function replaceWithMountEffectItem(directory: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { useEffect } from "react";
|
||||
import { useNewWorkspace } from "@getpaseo/plugin/client";
|
||||
import { Action } from "@getpaseo/plugin/client/ui";
|
||||
function OnMount() {
|
||||
const newWorkspace = useNewWorkspace();
|
||||
useEffect(() => {
|
||||
void newWorkspace.setIsolation("worktree");
|
||||
void newWorkspace.setBranch("set-on-mount");
|
||||
void newWorkspace.setMetadata({ "new-workspace-issues.issue": "ON-MOUNT" });
|
||||
}, []);
|
||||
return <Action title="Mount" icon="Flag" onPress={() => {}} />;
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addNewWorkspaceItem({ id: "on-mount", Component: OnMount });
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
type Client = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
/** The daemon fills in the git runtime after it creates the workspace, so wait for the branch. */
|
||||
async function readCreatedWorkspace(client: Client, workspaceId: string) {
|
||||
const deadline = Date.now() + 30_000;
|
||||
for (;;) {
|
||||
const { entries } = await client.fetchWorkspaces();
|
||||
const workspace = entries.find((entry) => entry.id === workspaceId);
|
||||
const branch = workspace?.gitRuntime?.currentBranch ?? null;
|
||||
if (workspace && branch) return { branch, metadata: workspace.metadata };
|
||||
if (Date.now() > deadline) throw new Error(`Workspace ${workspaceId} never reported a branch`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
const PLUGIN_ID = "new-workspace-issues-example";
|
||||
const COMPACT = { width: 500, height: 800 };
|
||||
|
||||
/** Runs `run` on the New workspace screen with the new-workspace-issues example installed. */
|
||||
export async function withNewWorkspaceIssuesExample(
|
||||
page: Page,
|
||||
options: {
|
||||
compact: boolean;
|
||||
detachedHead?: boolean;
|
||||
setsOnMount?: boolean;
|
||||
/** A second project the screen can switch to. */
|
||||
otherProject?: boolean;
|
||||
},
|
||||
run: (screen: {
|
||||
chooseWorktree(): Promise<void>;
|
||||
chooseIssue(label: string): Promise<void>;
|
||||
createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>;
|
||||
switchToOtherProject(): Promise<void>;
|
||||
expectIsolation(isolation: "local" | "worktree"): Promise<void>;
|
||||
/** Removes the plugin, leaves the screen, and opens New workspace for the project again. */
|
||||
reopenWithoutPlugin(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("new-workspace-issues");
|
||||
if (options.setsOnMount) await replaceWithMountEffectItem(example.directory);
|
||||
const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] });
|
||||
const otherRepo = options.otherProject
|
||||
? await createTempGitRepo("new-workspace-other-", { branches: ["main"] })
|
||||
: null;
|
||||
let createdDirectory: string | null = null;
|
||||
try {
|
||||
// A detached HEAD leaves the screen with no default base to branch off.
|
||||
if (options.detachedHead) execFileSync("git", ["checkout", "--detach"], { cwd: repo.path });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
const other = otherRepo ? await openProjectViaDaemon(client, otherRepo.path) : null;
|
||||
let target = project;
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
// The sidebar rows used to reach the screen exist only at desktop width.
|
||||
if (options.compact) await page.setViewportSize(COMPACT);
|
||||
await run({
|
||||
chooseWorktree: () =>
|
||||
test.step("choose a new worktree", async () => {
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
}),
|
||||
chooseIssue: (label) =>
|
||||
test.step(`choose the issue "${label}"`, async () => {
|
||||
await page.getByRole("button", { name: "Issue", exact: true }).click();
|
||||
await page.getByText(label, { exact: true }).click();
|
||||
}),
|
||||
switchToOtherProject: () =>
|
||||
test.step("switch to the other project", async () => {
|
||||
if (!other) throw new Error("Open the example with otherProject to switch projects");
|
||||
await page.getByRole("button", { name: "Workspace project" }).click();
|
||||
await page
|
||||
.getByTestId("combobox-desktop-container")
|
||||
.last()
|
||||
.getByText(other.projectDisplayName, { exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Workspace project" })).toContainText(
|
||||
other.projectDisplayName,
|
||||
);
|
||||
target = other;
|
||||
}),
|
||||
expectIsolation: (isolation) =>
|
||||
test.step(`the screen is on ${isolation}`, () =>
|
||||
expectWorkspaceIsolationSelected(page, isolation)),
|
||||
reopenWithoutPlugin: () =>
|
||||
test.step("reopen the screen without the plugin", async () => {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
// Leaving the screen drops its in-memory choice, so only the saved preference is left.
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await expect(page.getByRole("button", { name: "Mount", exact: true })).toHaveCount(0);
|
||||
}),
|
||||
createWorkspace: (createOptions) =>
|
||||
test.step("create the workspace and read what it carries", async () => {
|
||||
await submitCreate(page, createOptions?.prompt);
|
||||
const created = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: target.workspaceId,
|
||||
projectDisplayName: target.projectDisplayName,
|
||||
// The sidebar is closed at compact width.
|
||||
assertSidebarRow: !options.compact,
|
||||
});
|
||||
createdDirectory = created.workspaceDirectory;
|
||||
return readCreatedWorkspace(client, created.workspaceId);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (createdDirectory) {
|
||||
await archiveWorkspaceFromDaemon(client, createdDirectory).catch(() => undefined);
|
||||
}
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await repo.cleanup();
|
||||
await otherRepo?.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;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface ComboboxProps {
|
||||
}) => ReactElement;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
searchable?: boolean;
|
||||
/** The caller answers the search query itself, so the options are shown as given. */
|
||||
remoteSearch?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
@@ -1292,6 +1294,10 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
function filtersLocally(input: { searchable: boolean; remoteSearch?: boolean }): boolean {
|
||||
return input.searchable && !input.remoteSearch;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
@@ -1299,6 +1305,7 @@ export function Combobox({
|
||||
renderOption,
|
||||
onSearchQueryChange,
|
||||
searchable = true,
|
||||
remoteSearch,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
@@ -1458,7 +1465,7 @@ export function Combobox({
|
||||
buildVisibleComboboxOptions({
|
||||
options,
|
||||
searchQuery,
|
||||
searchable,
|
||||
searchable: filtersLocally({ searchable, remoteSearch }),
|
||||
allowCustomValue,
|
||||
customValuePrefix,
|
||||
customValueDescription,
|
||||
@@ -1470,6 +1477,7 @@ export function Combobox({
|
||||
customValueKind,
|
||||
customValuePrefix,
|
||||
options,
|
||||
remoteSearch,
|
||||
searchQuery,
|
||||
searchable,
|
||||
],
|
||||
|
||||
@@ -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,403 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
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 { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FieldTrigger } from "@/screens/new-workspace/form/field-trigger";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
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, drawn from data. Provider option controls, thinking and plugin controls all come
|
||||
* through here, so each is an icon in the composer toolbar, a row in its sheet, or, in a New
|
||||
* workspace item, a field. The name stays from when the composer was the only place it drew.
|
||||
*/
|
||||
export type ComposerControlItem =
|
||||
| (ComposerControlBase & {
|
||||
type: "toggle";
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void | Promise<void>;
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "select";
|
||||
value: string | null;
|
||||
choices: readonly ComposerControlChoice[];
|
||||
/** Names a value the choices do not list, such as a pick that a search no longer returns. */
|
||||
valueLabel?: string;
|
||||
onChange: (value: string) => void | Promise<void>;
|
||||
/** The toolbar trigger shows only the icon. */
|
||||
iconOnly?: boolean;
|
||||
showCaret?: boolean;
|
||||
searchable?: boolean;
|
||||
/** The list answers `onSearchQueryChange` itself, so it is not filtered again. */
|
||||
remoteSearch?: boolean;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
emptyText?: string;
|
||||
desktopMinWidth?: number;
|
||||
renderOption?: (args: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "action";
|
||||
onPress: () => void | Promise<void>;
|
||||
});
|
||||
|
||||
/** `field`: a New workspace setup field, a badge on desktop and a full-width field when compact. */
|
||||
export type ComposerControlSurface = "toolbar" | "sheet" | "field";
|
||||
|
||||
const ComposerControlSurfaceContext = createContext<ComposerControlSurface>("toolbar");
|
||||
export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider;
|
||||
|
||||
const ComposerControlLockContext = createContext(false);
|
||||
/** Disables every control below it, for a surface that is not a composer and has its own lock. */
|
||||
export const ComposerControlLockProvider = ComposerControlLockContext.Provider;
|
||||
|
||||
export function useComposerControlLocked(): boolean {
|
||||
return useContext(ComposerControlLockContext);
|
||||
}
|
||||
|
||||
/** Where the control being drawn sits: the toolbar, a row in the overflow sheet, or a field. */
|
||||
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.valueLabel ??
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
type SelectItem = Extract<ComposerControlItem, { type: "select" }>;
|
||||
|
||||
/** The list options only a select carries; a toggle's two-choice list has none of them. */
|
||||
function listProps(select: SelectItem | null) {
|
||||
if (!select) return { searchable: false };
|
||||
return {
|
||||
desktopMinWidth: select.desktopMinWidth,
|
||||
searchable: select.searchable,
|
||||
remoteSearch: select.remoteSearch,
|
||||
onSearchQueryChange: select.onSearchQueryChange,
|
||||
emptyText: select.emptyText,
|
||||
renderOption: select.renderOption,
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
function usePendingRun() {
|
||||
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],
|
||||
);
|
||||
return { pending, run };
|
||||
}
|
||||
|
||||
export function ComposerControl({
|
||||
item,
|
||||
surface,
|
||||
disabled = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
onActionComplete,
|
||||
}: ComposerControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const isSheet = surface === "sheet";
|
||||
const isField = surface === "field";
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title: item.title }), [item.title]);
|
||||
const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]);
|
||||
|
||||
const { pending, run } = usePendingRun();
|
||||
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 accessibilityLabel = item.accessibilityLabel ?? view.tooltip;
|
||||
const trigger = isField ? (
|
||||
<FieldControlTrigger
|
||||
ref={anchorRef}
|
||||
item={item}
|
||||
view={view}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
disabled={disabled || pending}
|
||||
onPress={view.opensList ? handleOpenPress : handlePress}
|
||||
/>
|
||||
) : (
|
||||
<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={accessibilityLabel}
|
||||
testID={item.testID}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSheet || isField ? (
|
||||
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={DESKTOP_PLACEMENT[surface]}
|
||||
{...listProps(select)}
|
||||
header={isSheet ? sheetHeader : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a list opens on desktop; the sheet has its own presentation. */
|
||||
const DESKTOP_PLACEMENT = {
|
||||
toolbar: "top-start",
|
||||
sheet: undefined,
|
||||
field: "bottom-start",
|
||||
} as const;
|
||||
|
||||
/** What a field shows: a badge has no label, so a toggle names itself beside its state. */
|
||||
function fieldValue(item: ComposerControlItem, view: ControlView, compact: boolean): string {
|
||||
if (item.type === "action") return item.title;
|
||||
if (item.type === "toggle" && !compact) return `${item.title}: ${view.value}`;
|
||||
return view.value;
|
||||
}
|
||||
|
||||
const FieldControlTrigger = forwardRef<
|
||||
View,
|
||||
{
|
||||
item: ComposerControlItem;
|
||||
view: ControlView;
|
||||
accessibilityLabel: string;
|
||||
disabled: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
>(function FieldControlTrigger({ item, view, accessibilityLabel, disabled, onPress }, ref) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const Icon = item.icon;
|
||||
const iconColor = item.highlight ? styles[item.highlight].color : styles.fieldIcon.color;
|
||||
const icon = useMemo(() => <Icon size={ICON_SIZE.sm} color={iconColor} />, [Icon, iconColor]);
|
||||
return (
|
||||
<FieldTrigger
|
||||
ref={ref}
|
||||
variant={compact ? "field" : "badge"}
|
||||
label={item.title}
|
||||
icon={icon}
|
||||
value={fieldValue(item, view, compact)}
|
||||
tooltip={view.tooltip}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
testID={item.testID}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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) => ({
|
||||
fieldIcon: { color: theme.colors.foregroundMuted },
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ar: TranslationResources = {
|
||||
pluginCombobox: {
|
||||
searching: "جارٍ البحث…",
|
||||
empty: "لا توجد نتائج",
|
||||
failed: "فشل البحث: {{message}}",
|
||||
},
|
||||
paneFind: {
|
||||
connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
|
||||
historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const en = {
|
||||
pluginCombobox: {
|
||||
searching: "Searching…",
|
||||
empty: "No results",
|
||||
failed: "Search failed: {{message}}",
|
||||
},
|
||||
paneFind: {
|
||||
connectionFailure: "Could not search this chat. Check the host connection and retry.",
|
||||
historyChangedFailure: "The chat changed while searching. Search again.",
|
||||
|
||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const es: TranslationResources = {
|
||||
pluginCombobox: {
|
||||
searching: "Buscando…",
|
||||
empty: "Sin resultados",
|
||||
failed: "La búsqueda falló: {{message}}",
|
||||
},
|
||||
paneFind: {
|
||||
connectionFailure:
|
||||
"No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.",
|
||||
|
||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const fr: TranslationResources = {
|
||||
pluginCombobox: {
|
||||
searching: "Recherche…",
|
||||
empty: "Aucun résultat",
|
||||
failed: "Échec de la recherche : {{message}}",
|
||||
},
|
||||
paneFind: {
|
||||
connectionFailure:
|
||||
"Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.",
|
||||
|
||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ja: TranslationResources = {
|
||||
pluginCombobox: {
|
||||
searching: "検索中…",
|
||||
empty: "結果がありません",
|
||||
failed: "検索に失敗しました: {{message}}",
|
||||
},
|
||||
paneFind: {
|
||||
connectionFailure:
|
||||
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
|
||||
|
||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ko: TranslationResources = {
|
||||
pluginCombobox: {
|
||||
searching: "검색 중…",
|
||||
empty: "결과 없음",
|
||||
failed: "검색 실패: {{message}}",
|
||||
},
|
||||
paneFind: {
|
||||
connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
|
||||
historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.",
|
||||
|
||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ptBR: TranslationResources = {
|
||||
pluginCombobox: {
|
||||
searching: "Pesquisando…",
|
||||
empty: "Nenhum resultado",
|
||||
failed: "Falha na pesquisa: {{message}}",
|
||||
},
|
||||
paneFind: {
|
||||
connectionFailure:
|
||||
"Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.",
|
||||
|
||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ru: TranslationResources = {
|
||||
pluginCombobox: {
|
||||
searching: "Поиск…",
|
||||
empty: "Нет результатов",
|
||||
failed: "Не удалось выполнить поиск: {{message}}",
|
||||
},
|
||||
paneFind: {
|
||||
connectionFailure:
|
||||
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
|
||||
|
||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const zhCN: TranslationResources = {
|
||||
pluginCombobox: {
|
||||
searching: "搜索中…",
|
||||
empty: "无结果",
|
||||
failed: "搜索失败:{{message}}",
|
||||
},
|
||||
paneFind: {
|
||||
connectionFailure: "无法搜索此聊天。请检查主机连接后重试。",
|
||||
historyChangedFailure: "搜索期间聊天已更改。请重新搜索。",
|
||||
|
||||
@@ -23,6 +23,8 @@ function installation(): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
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,8 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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 { PluginItemList, pluginThemeMapping, usePluginItemHost } from "../item-host";
|
||||
import { PluginEnvironmentProvider } from "../popover";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { toPluginComposer } from "./model";
|
||||
|
||||
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 { environment, props } = usePluginItemHost(plugin, theme);
|
||||
const textSource = composerInput?.textSource;
|
||||
const prompt = useSyncExternalStore(
|
||||
textSource?.subscribe ?? subscribeNothing,
|
||||
textSource?.getSnapshot ?? emptyText,
|
||||
textSource?.getSnapshot ?? emptyText,
|
||||
);
|
||||
const composer = useMemo(
|
||||
() =>
|
||||
agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null,
|
||||
[agent, composerInput, plugin, prompt],
|
||||
);
|
||||
if (!composer) return null;
|
||||
return (
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<PluginComposerProvider composer={composer}>
|
||||
<PluginItemList plugin={plugin} items={plugin.composerItems} props={props} />
|
||||
</PluginComposerProvider>
|
||||
</PluginEnvironmentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginComposerItems = withUnistyles(PluginComposerItemsHost);
|
||||
|
||||
function subscribeNothing() {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
function emptyText() {
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
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: [],
|
||||
newWorkspaceItems: [],
|
||||
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,8 @@ export function runPluginClientBundle(
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
@@ -146,6 +148,8 @@ export function runPluginClientBundle(
|
||||
footer: new Set(),
|
||||
};
|
||||
const filePanelIds = new Set<string>();
|
||||
const composerItemIds = new Set<string>();
|
||||
const newWorkspaceItemIds = new Set<string>();
|
||||
const workspacePanelIds = new Set<string>();
|
||||
const commandCenterItemIds = new Set<string>();
|
||||
const clientSlashCommandNames = new Set<string>();
|
||||
@@ -269,6 +273,34 @@ 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),
|
||||
);
|
||||
},
|
||||
addNewWorkspaceItem(contribution) {
|
||||
const normalizedId = requireId(contribution.id, "New workspace item id");
|
||||
if (newWorkspaceItemIds.has(normalizedId))
|
||||
throw new Error(`Duplicate New workspace item: ${normalizedId}`);
|
||||
if (!isComponentType(contribution.Component)) {
|
||||
throw new Error(`New workspace item ${normalizedId} is not a component`);
|
||||
}
|
||||
newWorkspaceItemIds.add(normalizedId);
|
||||
return register(
|
||||
collector.newWorkspaceItems,
|
||||
{ id: normalizedId, Component: contribution.Component },
|
||||
() => newWorkspaceItemIds.delete(normalizedId),
|
||||
);
|
||||
},
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
|
||||
return addSidebarItem("header", contribution);
|
||||
},
|
||||
@@ -585,6 +617,8 @@ export function runPluginClientBundle(
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
filePanels: collector.filePanels,
|
||||
composerItems: collector.composerItems,
|
||||
newWorkspaceItems: collector.newWorkspaceItems,
|
||||
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,8 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo, type ComponentType } from "react";
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
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 type { PluginEnvironment } from "./popover";
|
||||
import { SurfaceErrorBoundary } from "./surface-error-boundary";
|
||||
import { toPluginTheme } from "./theme";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
/** `withUnistyles` mapping that hands a host the plugin theme. */
|
||||
export const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
function renderNothing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What a plugin's items on a host surface (composer, New workspace) share: environment and props. */
|
||||
export function usePluginItemHost(
|
||||
plugin: InstalledPlugin,
|
||||
theme: PluginHostProps["theme"],
|
||||
): { environment: PluginEnvironment; props: PluginHostProps } {
|
||||
const toast = useToast();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const hosts = useHosts();
|
||||
const hostLabel =
|
||||
hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId;
|
||||
const environment = useMemo<PluginEnvironment>(
|
||||
() => ({
|
||||
installation: plugin,
|
||||
toast,
|
||||
state: createPluginClientStateSource(plugin.serverId),
|
||||
}),
|
||||
[plugin, toast],
|
||||
);
|
||||
const props = useMemo<PluginHostProps>(
|
||||
() => ({
|
||||
theme,
|
||||
host: { id: plugin.serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePluginPlatform() },
|
||||
}),
|
||||
[compact, hostLabel, plugin.serverId, theme],
|
||||
);
|
||||
return { environment, props };
|
||||
}
|
||||
|
||||
/** Mounts each item behind its own boundary; a throwing item draws nothing. */
|
||||
export function PluginItemList({
|
||||
plugin,
|
||||
items,
|
||||
props,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
items: readonly { id: string; Component: ComponentType<PluginHostProps> }[];
|
||||
props: PluginHostProps;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{items.map(({ id, Component }) => (
|
||||
<SurfaceErrorBoundary
|
||||
key={id}
|
||||
installation={plugin}
|
||||
Surface={Component}
|
||||
renderError={renderNothing}
|
||||
>
|
||||
<Component {...props} />
|
||||
</SurfaceErrorBoundary>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMemo } from "react";
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import { PluginNewWorkspaceProvider } from "@getpaseo/plugin/client/host";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ComposerControlLockProvider,
|
||||
ComposerControlSurfaceProvider,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import type { PickerItem } from "@/screens/new-workspace-picker-item";
|
||||
import {
|
||||
useNewWorkspaceSetupState,
|
||||
type NewWorkspaceSetup,
|
||||
} from "@/screens/new-workspace/setup-model";
|
||||
import { PluginItemList, pluginThemeMapping, usePluginItemHost } from "../item-host";
|
||||
import { PluginEnvironmentProvider } from "../popover";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { toPluginNewWorkspace } from "./model";
|
||||
|
||||
/** What the screen derives and the items read; the rest is in the setup model. */
|
||||
export interface NewWorkspaceItemsScreen {
|
||||
project: { id: string; name: string } | null;
|
||||
isolation: "local" | "worktree";
|
||||
baseItem: PickerItem | null;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
function useServerPlugins(serverId: string): InstalledPlugin[] {
|
||||
const installed = useInstalledPlugins();
|
||||
return useMemo(
|
||||
() =>
|
||||
installed.filter(
|
||||
(plugin) => plugin.serverId === serverId && plugin.newWorkspaceItems.length > 0,
|
||||
),
|
||||
[installed, serverId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected host's plugin items on the New workspace screen, drawn as setup fields. They read
|
||||
* the screen's choices and command them through the setup model.
|
||||
*/
|
||||
export function PluginNewWorkspaceItems({
|
||||
serverId,
|
||||
screen,
|
||||
setup,
|
||||
}: {
|
||||
serverId: string;
|
||||
screen: NewWorkspaceItemsScreen;
|
||||
setup: NewWorkspaceSetup;
|
||||
}) {
|
||||
const plugins = useServerPlugins(serverId);
|
||||
return (
|
||||
<ComposerControlSurfaceProvider value="field">
|
||||
<ComposerControlLockProvider value={screen.locked}>
|
||||
{plugins.map((plugin) => (
|
||||
<ThemedPluginNewWorkspaceItems
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
screen={screen}
|
||||
setup={setup}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
))}
|
||||
</ComposerControlLockProvider>
|
||||
</ComposerControlSurfaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginNewWorkspaceItemsHost({
|
||||
plugin,
|
||||
theme,
|
||||
screen,
|
||||
setup,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
theme: PluginHostProps["theme"];
|
||||
screen: NewWorkspaceItemsScreen;
|
||||
setup: NewWorkspaceSetup;
|
||||
}) {
|
||||
const { environment, props } = usePluginItemHost(plugin, theme);
|
||||
const { branch, metadata } = useNewWorkspaceSetupState(setup);
|
||||
const newWorkspace = useMemo(
|
||||
() =>
|
||||
toPluginNewWorkspace({
|
||||
host: props.host,
|
||||
view: { ...screen, branch, metadata },
|
||||
commands: setup.commands,
|
||||
}),
|
||||
[branch, metadata, props.host, screen, setup.commands],
|
||||
);
|
||||
return (
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<PluginNewWorkspaceProvider newWorkspace={newWorkspace}>
|
||||
<PluginItemList plugin={plugin} items={plugin.newWorkspaceItems} props={props} />
|
||||
</PluginNewWorkspaceProvider>
|
||||
</PluginEnvironmentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginNewWorkspaceItems = withUnistyles(PluginNewWorkspaceItemsHost);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBranchPickerItems,
|
||||
pickerItemToCheckoutRequest,
|
||||
type PickerItem,
|
||||
} from "@/screens/new-workspace-picker-item";
|
||||
import { createNewWorkspaceSetup } from "@/screens/new-workspace/setup-model";
|
||||
import { toPluginNewWorkspace } from "./model";
|
||||
|
||||
const HOST = { id: "host", label: "Host" } as Parameters<typeof toPluginNewWorkspace>[0]["host"];
|
||||
|
||||
function viewOf(baseItem: PickerItem) {
|
||||
return {
|
||||
project: { id: "p", name: "repo" },
|
||||
isolation: "worktree" as const,
|
||||
baseItem,
|
||||
branch: null,
|
||||
metadata: {},
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("the base ref a plugin reads", () => {
|
||||
it("names the exact ref creation uses, so setting it back picks the same base", async () => {
|
||||
// `release` exists locally and on origin, and the two have diverged.
|
||||
const rows = buildBranchPickerItems([
|
||||
{
|
||||
name: "release",
|
||||
committerDate: 0,
|
||||
hasLocal: true,
|
||||
hasRemote: true,
|
||||
localAhead: 2,
|
||||
localBehind: 1,
|
||||
},
|
||||
]);
|
||||
const origin = rows.find((row) => row.kind === "branch" && row.refName.includes("remotes"));
|
||||
if (!origin) throw new Error("expected an origin row");
|
||||
|
||||
const chosen: PickerItem[] = [];
|
||||
const setup = createNewWorkspaceSetup(
|
||||
{
|
||||
serverId: "host-a",
|
||||
locked: false,
|
||||
project: { id: "p", name: "repo" },
|
||||
canCreateWorktree: true,
|
||||
supportsMetadata: true,
|
||||
},
|
||||
{ persistIsolation: () => {}, syncBaseAttachment: (item) => chosen.push(item) },
|
||||
);
|
||||
const read = toPluginNewWorkspace({
|
||||
host: HOST,
|
||||
view: viewOf(origin),
|
||||
commands: setup.commands,
|
||||
});
|
||||
|
||||
expect(read.baseRef).toEqual({ type: "branch", name: "refs/remotes/origin/release" });
|
||||
if (read.baseRef?.type !== "branch") throw new Error("expected a branch");
|
||||
await read.setBaseRef(read.baseRef);
|
||||
|
||||
expect(pickerItemToCheckoutRequest(chosen[0] ?? null)).toEqual(
|
||||
pickerItemToCheckoutRequest(origin),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginHostProps, PluginNewWorkspace } from "@getpaseo/plugin/client";
|
||||
import type { PickerItem } from "@/screens/new-workspace-picker-item";
|
||||
import type { SetupCommands } from "@/screens/new-workspace/setup-model";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
|
||||
/** What the New workspace screen shows at render; the plugin reads it, never writes it. */
|
||||
export interface NewWorkspaceView {
|
||||
project: { id: string; name: string } | null;
|
||||
/** What the screen will create. */
|
||||
isolation: "local" | "worktree";
|
||||
baseItem: PickerItem | null;
|
||||
branch: string | null;
|
||||
metadata: Readonly<Record<string, JsonValue>>;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/** `name` is the exact ref creation uses, so a plugin can set back what it read. */
|
||||
function toBaseRef(item: PickerItem | null): PluginNewWorkspace["baseRef"] {
|
||||
if (!item) return null;
|
||||
return item.kind === "branch"
|
||||
? { type: "branch", name: item.refName }
|
||||
: { type: "change-request", number: item.item.number };
|
||||
}
|
||||
|
||||
export function toPluginNewWorkspace(input: {
|
||||
host: PluginHostProps["host"];
|
||||
view: NewWorkspaceView;
|
||||
commands: SetupCommands;
|
||||
}): PluginNewWorkspace {
|
||||
const { host, view, commands } = input;
|
||||
return {
|
||||
host: cloneAndFreeze({ id: host.id, label: host.label }),
|
||||
project: cloneAndFreeze(view.project),
|
||||
isolation: view.isolation,
|
||||
baseRef: cloneAndFreeze(toBaseRef(view.baseItem)),
|
||||
branch: view.branch,
|
||||
metadata: cloneAndFreeze(view.metadata),
|
||||
...commands,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ComboboxItem } from "@getpaseo/plugin/client/ui";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { ComposerControlSurfaceProvider } from "@/composer/agent-controls/composer-control";
|
||||
import { ToastApiProvider } from "@/contexts/toast-api-context";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { Combobox } from "./composer-controls";
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("React", React);
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
interface Issue extends ComboboxItem {
|
||||
branch: string;
|
||||
}
|
||||
|
||||
const ONE: Issue = { id: "1", label: "Slow sidebar", branch: "one" };
|
||||
const TWO: Issue = { id: "2", label: "Crash on resume", branch: "two" };
|
||||
const THREE: Issue = { id: "3", label: "Crash on launch", branch: "three" };
|
||||
|
||||
interface Search {
|
||||
query: string;
|
||||
resolve(items: Issue[]): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
/** A search the test settles by hand, so it decides which one finishes first. */
|
||||
function controlledSearch() {
|
||||
const searches: Search[] = [];
|
||||
const search = ({ query }: { query: string }) =>
|
||||
new Promise<Issue[]>((resolve, reject) => {
|
||||
searches.push({ query, resolve, reject });
|
||||
});
|
||||
const forQuery = (query: string) => {
|
||||
const found = searches.find((candidate) => candidate.query === query);
|
||||
if (!found) throw new Error(`No search for "${query}" yet`);
|
||||
return found;
|
||||
};
|
||||
const reject = (index: number, error: Error) => searches[index]?.reject(error);
|
||||
return { search, forQuery, reject, queries: () => searches.map((candidate) => candidate.query) };
|
||||
}
|
||||
|
||||
const toast = { error: vi.fn() } as unknown as ToastApi;
|
||||
const ignoreSelect = () => undefined;
|
||||
|
||||
function Harness({
|
||||
search,
|
||||
value,
|
||||
onSelect = ignoreSelect,
|
||||
}: {
|
||||
search: (event: { query: string }) => Promise<Issue[]>;
|
||||
value: ComboboxItem | null;
|
||||
onSelect?: (event: { item: Issue }) => void;
|
||||
}) {
|
||||
const [queryClient] = React.useState(() => new QueryClient());
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerControlSurfaceProvider value="field">
|
||||
<Combobox
|
||||
title="Issue"
|
||||
icon="CircleDot"
|
||||
value={value}
|
||||
search={search}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</ComposerControlSurfaceProvider>
|
||||
</ToastApiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCombobox(input: {
|
||||
search: (event: { query: string }) => Promise<Issue[]>;
|
||||
value?: ComboboxItem | null;
|
||||
onSelect?: (event: { item: Issue }) => void;
|
||||
}) {
|
||||
render(<Harness search={input.search} value={input.value ?? null} onSelect={input.onSelect} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Issue" }));
|
||||
}
|
||||
|
||||
describe("plugin Combobox", () => {
|
||||
it("searches when the list opens, shows the results, and hands the picked item back", async () => {
|
||||
const controlled = controlledSearch();
|
||||
const onSelect = vi.fn();
|
||||
renderCombobox({ search: controlled.search, onSelect });
|
||||
|
||||
await waitFor(() => expect(controlled.queries()).toEqual([""]));
|
||||
expect(screen.getByText("Searching…")).toBeTruthy();
|
||||
controlled.forQuery("").resolve([ONE, TWO]);
|
||||
|
||||
fireEvent.click(await screen.findByText("Crash on resume"));
|
||||
expect(onSelect).toHaveBeenCalledWith({ item: TWO });
|
||||
});
|
||||
|
||||
it("shows the error and not stale results when the search fails", async () => {
|
||||
const controlled = controlledSearch();
|
||||
renderCombobox({ search: controlled.search });
|
||||
|
||||
await waitFor(() => expect(controlled.queries()).toEqual([""]));
|
||||
controlled.forQuery("").reject(new Error("tracker is down"));
|
||||
|
||||
expect(await screen.findByText("Search failed: tracker is down")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the previous results while a newer search runs and ignores a superseded one", async () => {
|
||||
const controlled = controlledSearch();
|
||||
renderCombobox({ search: controlled.search });
|
||||
await waitFor(() => expect(controlled.queries()).toEqual([""]));
|
||||
controlled.forQuery("").resolve([ONE, TWO]);
|
||||
await screen.findByText("Slow sidebar");
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "c" } });
|
||||
await waitFor(() => expect(controlled.queries()).toEqual(["", "c"]));
|
||||
expect(screen.getByText("Slow sidebar")).toBeTruthy();
|
||||
|
||||
fireEvent.change(input, { target: { value: "cr" } });
|
||||
await waitFor(() => expect(controlled.queries()).toEqual(["", "c", "cr"]));
|
||||
controlled.forQuery("cr").resolve([TWO]);
|
||||
await waitFor(() => expect(screen.queryByText("Slow sidebar")).toBeNull());
|
||||
controlled.forQuery("c").resolve([ONE]);
|
||||
|
||||
// Searches settle in order, so once a later one has landed the stale one has been handled.
|
||||
fireEvent.change(input, { target: { value: "cra" } });
|
||||
await waitFor(() => expect(controlled.queries()).toEqual(["", "c", "cr", "cra"]));
|
||||
controlled.forQuery("cra").resolve([THREE]);
|
||||
await screen.findByText("Crash on launch");
|
||||
expect(screen.queryByText("Slow sidebar")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the error and not the previous results when a reopened list fails to refresh", async () => {
|
||||
const controlled = controlledSearch();
|
||||
renderCombobox({ search: controlled.search });
|
||||
await waitFor(() => expect(controlled.queries()).toEqual([""]));
|
||||
controlled.forQuery("").resolve([ONE]);
|
||||
await screen.findByText("Slow sidebar");
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Issue" }));
|
||||
await waitFor(() => expect(controlled.queries()).toEqual(["", ""]));
|
||||
controlled.reject(1, new Error("tracker is down"));
|
||||
|
||||
expect(await screen.findByText("Search failed: tracker is down")).toBeTruthy();
|
||||
expect(screen.queryByText("Slow sidebar")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a chosen value that the search no longer returns", async () => {
|
||||
const controlled = controlledSearch();
|
||||
renderCombobox({ search: controlled.search, value: { id: "9", label: "PAS-9 Old issue" } });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Issue" }).textContent).toContain("PAS-9 Old issue");
|
||||
await waitFor(() => expect(controlled.queries()).toEqual([""]));
|
||||
controlled.forQuery("").resolve([ONE]);
|
||||
await screen.findByText("Slow sidebar");
|
||||
expect(screen.getByRole("button", { name: "Issue" }).textContent).toContain("PAS-9 Old issue");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
ActionProps,
|
||||
ComboboxItem,
|
||||
ComboboxProps,
|
||||
SelectProps,
|
||||
ToggleProps,
|
||||
} from "@getpaseo/plugin/client/ui";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ComposerControl,
|
||||
useComposerControlLocked,
|
||||
useComposerControlSurface,
|
||||
type ComposerControlItem,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import { useComposerLocked } from "@/composer/session";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
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 item sits: inline, a row in the sheet, or a field. */
|
||||
function PluginComposerControl({
|
||||
item,
|
||||
disabled,
|
||||
onOpenChange,
|
||||
}: {
|
||||
item: ComposerControlItem;
|
||||
disabled: boolean | undefined;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const surface = useComposerControlSurface();
|
||||
const composerLocked = useComposerLocked();
|
||||
const fieldLocked = useComposerControlLocked();
|
||||
const locked = composerLocked || fieldLocked;
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleOpenChange = useMemo(
|
||||
() => (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
return (
|
||||
<ComposerControl
|
||||
item={item}
|
||||
surface={surface}
|
||||
disabled={disabled || locked}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
const NO_RESULTS: readonly never[] = [];
|
||||
|
||||
/** The query settles after the user stops typing; an empty query applies at once. */
|
||||
function useDebounced(query: string): string {
|
||||
const [settled, setSettled] = useState(query);
|
||||
useEffect(() => {
|
||||
if (query === "") return setSettled("");
|
||||
const timer = setTimeout(() => setSettled(query), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches while the list is open. The search runs once per settled query, keeps the previous
|
||||
* results on screen while the next one loads, and drops a search a newer one replaced.
|
||||
*/
|
||||
export function Combobox<Item extends ComboboxItem>({
|
||||
title,
|
||||
icon,
|
||||
disabled,
|
||||
value,
|
||||
search,
|
||||
onSelect,
|
||||
}: ComboboxProps<Item>) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const instanceId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const settled = useDebounced(query);
|
||||
const results = useFetchQuery({
|
||||
queryKey: ["plugin-combobox", instanceId, settled],
|
||||
queryFn: () => search({ query: settled }),
|
||||
dataShape: "list",
|
||||
staleTimeMs: 0,
|
||||
enabled: open,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
// A failed search shows its error, not the results of an earlier one.
|
||||
const items: readonly Item[] = results.isError ? NO_RESULTS : (results.data ?? NO_RESULTS);
|
||||
const item = useMemo<ComposerControlItem>(() => {
|
||||
const choices = items.map(({ id, label, description }) => ({ value: id, label, description }));
|
||||
return {
|
||||
id: "plugin-combobox",
|
||||
type: "select",
|
||||
title,
|
||||
icon: Icon,
|
||||
value: value?.id ?? null,
|
||||
valueLabel: value?.label,
|
||||
choices,
|
||||
onChange: (id) => {
|
||||
const picked = items.find((candidate) => candidate.id === id);
|
||||
return picked ? onSelect({ item: picked }) : undefined;
|
||||
},
|
||||
searchable: true,
|
||||
remoteSearch: true,
|
||||
onSearchQueryChange: setQuery,
|
||||
emptyText: comboboxEmptyText({ t, results, query, settled }),
|
||||
showCaret: true,
|
||||
};
|
||||
}, [Icon, items, onSelect, query, results, settled, t, title, value]);
|
||||
return <PluginComposerControl item={item} disabled={disabled} onOpenChange={setOpen} />;
|
||||
}
|
||||
|
||||
function comboboxEmptyText(input: {
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
results: { isError: boolean; isFetching: boolean; error: unknown };
|
||||
query: string;
|
||||
settled: string;
|
||||
}): string {
|
||||
const { t, results, query, settled } = input;
|
||||
if (results.isError)
|
||||
return t("pluginCombobox.failed", { message: toErrorMessage(results.error) });
|
||||
if (results.isFetching || query !== settled) return t("pluginCombobox.searching");
|
||||
return t("pluginCombobox.empty");
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export { FilePanelLoading as Loading } from "@/file-panels/loading";
|
||||
export { SettingsGroup } from "@/components/settings";
|
||||
|
||||
export { Icon } from "../icons";
|
||||
export { Action, Combobox, Select, Toggle } from "./composer-controls";
|
||||
export { Modal } from "./modal";
|
||||
export { ScrollView, FlatList } from "./scroll-view";
|
||||
export { TextInput } from "./text-input";
|
||||
|
||||
@@ -106,6 +106,8 @@ export class PluginRegistry {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -34,6 +34,8 @@ function installed(
|
||||
},
|
||||
legacySidebarItems,
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -36,6 +36,8 @@ function installation(
|
||||
icon: "Blocks",
|
||||
})),
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -61,6 +61,8 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -24,6 +24,8 @@ function plugin(input: {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
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,8 @@ import type {
|
||||
PluginFilePanelContribution as SDKPluginFilePanelContribution,
|
||||
PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
|
||||
PluginComposerItemContribution,
|
||||
PluginNewWorkspaceItemContribution,
|
||||
PluginComposerPillContribution as SDKPluginComposerPillContribution,
|
||||
PluginSidebarContribution as SDKPluginSidebarContribution,
|
||||
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
||||
@@ -53,6 +55,8 @@ export interface EvaluatedPlugin {
|
||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||
legacySidebarItems: PluginSidebarContribution[];
|
||||
filePanels: PluginFilePanelContribution[];
|
||||
composerItems: PluginComposerItemContribution[];
|
||||
newWorkspaceItems: PluginNewWorkspaceItemContribution[];
|
||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSlashCommands: PluginClientSlashCommandContribution[];
|
||||
@@ -82,6 +86,8 @@ export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginComposerItemContribution,
|
||||
PluginNewWorkspaceItemContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSidebarItemContribution,
|
||||
|
||||
@@ -21,6 +21,8 @@ function installed(): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
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;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildPickerOptionData,
|
||||
defaultBasePickerItem,
|
||||
pickerItemToCheckoutRequest,
|
||||
startsNewBranch,
|
||||
type PickerItem,
|
||||
} from "./new-workspace-picker-item";
|
||||
|
||||
@@ -386,3 +387,20 @@ describe("starting-ref search", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("startsNewBranch", () => {
|
||||
it("is true for a branch-off and for no request, which is the default base on a detached HEAD", () => {
|
||||
expect(startsNewBranch(undefined)).toBe(true);
|
||||
expect(startsNewBranch({ action: "branch-off", refName: "main" })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a checkout of a branch or a change request", () => {
|
||||
expect(startsNewBranch({ action: "checkout", refName: "feature" })).toBe(false);
|
||||
expect(
|
||||
startsNewBranch({
|
||||
checkoutSource: { kind: "change_request", forge: "github", number: 7 },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(startsNewBranch({ githubPrNumber: 7 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ export interface BaseRefCheckoutStatus {
|
||||
|
||||
// Display only. The exact ref is what every request carries; this is just how a ref reads in
|
||||
// a row label, so "refs/remotes/origin/other-name" shows as "other-name".
|
||||
function branchNameFromRef(refName: string): string {
|
||||
export function branchNameFromRef(refName: string): string {
|
||||
if (refName.startsWith("refs/heads/")) return refName.slice("refs/heads/".length);
|
||||
if (refName.startsWith(REMOTE_TRACKING_PREFIX)) {
|
||||
const remainder = refName.slice(REMOTE_TRACKING_PREFIX.length);
|
||||
@@ -210,6 +210,17 @@ export function pickerItemToCheckoutRequest(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether creating a worktree from this request cuts a new branch, which is what the daemon does
|
||||
* for anything but a checkout. No request at all (a detached HEAD gives the screen no default
|
||||
* base) cuts one from the repository's default branch.
|
||||
*/
|
||||
export function startsNewBranch(request: PickerCheckoutRequest | undefined): boolean {
|
||||
if (!request) return true;
|
||||
if (request.action === "checkout") return false;
|
||||
return !request.checkoutSource && request.githubPrNumber === undefined;
|
||||
}
|
||||
|
||||
export function prPickerOptionId(number: number): string {
|
||||
return `${PR_OPTION_PREFIX}${number}`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { router } from "expo-router";
|
||||
@@ -79,7 +79,7 @@ import type { MessagePayload } from "@/composer/types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProvider, JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||
import {
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
defaultBasePickerItem,
|
||||
pickerItemLabel,
|
||||
pickerItemToCheckoutRequest,
|
||||
startsNewBranch,
|
||||
type BranchPickerDetail,
|
||||
type PickerCheckoutRequest,
|
||||
type PickerItem,
|
||||
@@ -98,10 +99,17 @@ import {
|
||||
} from "./new-workspace-picker-item";
|
||||
import {
|
||||
clearPickerPrAttachmentForTargetChange,
|
||||
initialPickerSelectionState,
|
||||
reducePickerSelection,
|
||||
syncPickerPrAttachment,
|
||||
} from "./new-workspace-picker-state";
|
||||
import {
|
||||
PluginNewWorkspaceItems,
|
||||
type NewWorkspaceItemsScreen,
|
||||
} from "@/plugins/new-workspace-items";
|
||||
import {
|
||||
createNewWorkspaceSetup,
|
||||
useNewWorkspaceSetupState,
|
||||
type NewWorkspaceSetup,
|
||||
} from "./new-workspace/setup-model";
|
||||
import {
|
||||
resolveNewWorkspaceAutomaticServerId,
|
||||
resolveNewWorkspaceInitialServerId,
|
||||
@@ -193,30 +201,22 @@ interface WorkspaceIsolationState {
|
||||
// Preserve the user's worktree choice while route metadata is provisional. Once
|
||||
// the authoritative placement arrives, unsupported projects fall back to local.
|
||||
function useWorkspaceIsolation(input: {
|
||||
setup: NewWorkspaceSetup;
|
||||
supportsMultiplicity: boolean;
|
||||
worktreeSupport: "supported" | "unsupported" | "unknown";
|
||||
canCreateWorktree: boolean;
|
||||
}): WorkspaceIsolationState {
|
||||
const { supportsMultiplicity, worktreeSupport } = input;
|
||||
const { setup, supportsMultiplicity, canCreateWorktree } = input;
|
||||
// The last isolation choice is remembered alongside the other New Workspace
|
||||
// form preferences (provider, model, mode). A manual in-screen pick overrides
|
||||
// the remembered default until the screen remounts.
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null);
|
||||
const { preferences } = useFormPreferences();
|
||||
const { manualIsolation } = useNewWorkspaceSetupState(setup);
|
||||
const isolation = manualIsolation ?? preferences.isolation ?? "local";
|
||||
const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported";
|
||||
const isWorktree = isolation === "worktree" && canCreateWorktree;
|
||||
|
||||
const setIsolation = useCallback(
|
||||
(value: "local" | "worktree") => {
|
||||
setManualIsolation(value);
|
||||
void updatePreferences({ isolation: value });
|
||||
},
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
return {
|
||||
isolation,
|
||||
setIsolation,
|
||||
setIsolation: setup.chooseIsolation,
|
||||
effectiveIsolation: isWorktree ? "worktree" : "local",
|
||||
canCreateWorktree,
|
||||
showRefPicker: !supportsMultiplicity || isWorktree,
|
||||
@@ -288,6 +288,10 @@ async function createMultiplicityWorkspace(input: {
|
||||
project: HostProjectListItem;
|
||||
sourceDirectory: string;
|
||||
checkoutRequest: PickerCheckoutRequest | undefined;
|
||||
/** Names the new branch of a worktree cut from a branch. */
|
||||
branchName: string | null;
|
||||
/** Stored on the created workspace. */
|
||||
metadata: Readonly<Record<string, JsonValue>>;
|
||||
withInitialAgent: boolean;
|
||||
agent?: CreateWorkspaceRequestOptions["agent"];
|
||||
onEvent?: (snapshot: CreationSnapshot) => void;
|
||||
@@ -318,6 +322,9 @@ async function createMultiplicityWorkspace(input: {
|
||||
projectId,
|
||||
worktreeSlug: input.worktreeSlug,
|
||||
...input.checkoutRequest,
|
||||
...(input.branchName && startsNewBranch(input.checkoutRequest)
|
||||
? { branchName: input.branchName }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
kind: "directory",
|
||||
@@ -325,6 +332,7 @@ async function createMultiplicityWorkspace(input: {
|
||||
projectId,
|
||||
},
|
||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||
...(Object.keys(input.metadata).length > 0 ? { metadata: { ...input.metadata } } : {}),
|
||||
});
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error ?? input.createFailedMessage);
|
||||
@@ -456,6 +464,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 +544,7 @@ function buildComposerConfig(input: {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: buildComposerInitialValues({ initialSetup }),
|
||||
initialProviderOptions: initialSetup?.providerOptions,
|
||||
context: "new-workspace" as const,
|
||||
isVisible: true,
|
||||
lockedWorkingDir: workingDir,
|
||||
};
|
||||
@@ -1000,22 +1010,31 @@ export function NewWorkspaceScreen({
|
||||
}),
|
||||
});
|
||||
const composerState = chatDraft.composerState;
|
||||
const [pickerSelection, dispatchPickerSelection] = useReducer(
|
||||
reducePickerSelection,
|
||||
initialPickerSelectionState,
|
||||
);
|
||||
const selectedItem = pickerSelection.selectedItem;
|
||||
const { setup, pluginProject, canCreateWorktree, isPending } = usePluginSetup({
|
||||
serverId: selectedServerId,
|
||||
project: selectedProject,
|
||||
supportsMultiplicity: supportsWorkspaceMultiplicity,
|
||||
pendingAction,
|
||||
isDraftHandoffActive,
|
||||
updatePreferences: updateFormPreferences,
|
||||
setAttachments: chatDraft.setAttachments,
|
||||
});
|
||||
const dispatchPickerSelection = setup.dispatchPicker;
|
||||
const selectedItem = useNewWorkspaceSetupState(setup).pickerSelection.selectedItem;
|
||||
|
||||
const handleForgeChangeRequestDetected = useCallback(() => {
|
||||
dispatchPickerSelection({ type: "pr-detected" });
|
||||
}, []);
|
||||
}, [dispatchPickerSelection]);
|
||||
|
||||
const handleForgeChangeRequestAutoAttach = useCallback((item: ForgeSearchItem) => {
|
||||
dispatchPickerSelection({
|
||||
type: "pr-added",
|
||||
item: { kind: "github-pr", item },
|
||||
});
|
||||
}, []);
|
||||
const handleForgeChangeRequestAutoAttach = useCallback(
|
||||
(item: ForgeSearchItem) => {
|
||||
dispatchPickerSelection({
|
||||
type: "pr-added",
|
||||
item: { kind: "github-pr", item },
|
||||
});
|
||||
},
|
||||
[dispatchPickerSelection],
|
||||
);
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
|
||||
@@ -1034,15 +1053,11 @@ export function NewWorkspaceScreen({
|
||||
cwd: selectedSourceDirectory ?? "",
|
||||
});
|
||||
|
||||
const worktreeSupport = selectedProject
|
||||
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
|
||||
: "unsupported";
|
||||
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
|
||||
const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
|
||||
useWorkspaceIsolation({
|
||||
supportsMultiplicity: supportsWorkspaceMultiplicity,
|
||||
worktreeSupport,
|
||||
});
|
||||
const { effectiveIsolation, setIsolation, showRefPicker } = useWorkspaceIsolation({
|
||||
setup,
|
||||
supportsMultiplicity: supportsWorkspaceMultiplicity,
|
||||
canCreateWorktree,
|
||||
});
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
queryKey: [
|
||||
@@ -1106,16 +1121,15 @@ export function NewWorkspaceScreen({
|
||||
}, [itemById, selectedOptionId]);
|
||||
const selectPickerItem = useCallback(
|
||||
(item: PickerItem) => {
|
||||
const nextAttachments = syncPickerPrAttachment({
|
||||
attachments: chatDraft.attachments,
|
||||
item,
|
||||
});
|
||||
|
||||
dispatchPickerSelection({ type: "picker-selected", item });
|
||||
chatDraft.setAttachments(nextAttachments);
|
||||
setup.chooseBase(item);
|
||||
setPickerOpen(false);
|
||||
},
|
||||
[chatDraft],
|
||||
[setup],
|
||||
);
|
||||
|
||||
const pluginScreen = useMemo<NewWorkspaceItemsScreen>(
|
||||
() => ({ project: pluginProject, isolation: effectiveIsolation, baseItem, locked: isPending }),
|
||||
[baseItem, effectiveIsolation, isPending, pluginProject],
|
||||
);
|
||||
|
||||
const handleSelectOption = useCallback(
|
||||
@@ -1138,7 +1152,7 @@ export function NewWorkspaceScreen({
|
||||
chatDraft.setAttachments(nextAttachments);
|
||||
dispatchPickerSelection({ type: "target-changed" });
|
||||
},
|
||||
[chatDraft],
|
||||
[chatDraft, dispatchPickerSelection],
|
||||
);
|
||||
|
||||
const handleSelectProjectOption = useCallback(
|
||||
@@ -1258,6 +1272,8 @@ export function NewWorkspaceScreen({
|
||||
project: selectedProject,
|
||||
sourceDirectory: selectedSourceDirectory,
|
||||
checkoutRequest,
|
||||
branchName: setup.getSnapshot().branch,
|
||||
metadata: setup.getSnapshot().metadata,
|
||||
withInitialAgent: input.withInitialAgent,
|
||||
prompt: input.prompt,
|
||||
attachments: input.attachments,
|
||||
@@ -1280,6 +1296,7 @@ export function NewWorkspaceScreen({
|
||||
selectedProject,
|
||||
selectedServerId,
|
||||
selectedSourceDirectory,
|
||||
setup,
|
||||
supportsWorkspaceMultiplicity,
|
||||
t,
|
||||
withConnectedClient,
|
||||
@@ -1460,6 +1477,9 @@ export function NewWorkspaceScreen({
|
||||
const formStack = useNewWorkspaceForm({
|
||||
isCompact,
|
||||
isPending,
|
||||
pluginItems: (
|
||||
<PluginNewWorkspaceItems serverId={selectedServerId} screen={pluginScreen} setup={setup} />
|
||||
),
|
||||
project: {
|
||||
anchorRef: projectPickerAnchorRef,
|
||||
open: openProjectPicker,
|
||||
@@ -1600,6 +1620,54 @@ export function NewWorkspaceScreen({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The setup model, built with what the screen has this render so a plugin item's mount effect can
|
||||
* already run commands, and kept current on every commit.
|
||||
*/
|
||||
function usePluginSetup(input: {
|
||||
serverId: string;
|
||||
project: Parameters<typeof getHostProjectId>[0] | null;
|
||||
supportsMultiplicity: boolean;
|
||||
pendingAction: Parameters<typeof isNewWorkspacePending>[0]["pendingAction"];
|
||||
isDraftHandoffActive: boolean;
|
||||
updatePreferences: ReturnType<typeof useFormPreferences>["updatePreferences"];
|
||||
setAttachments: ReturnType<typeof useAgentInputDraft>["setAttachments"];
|
||||
}) {
|
||||
const {
|
||||
serverId,
|
||||
project,
|
||||
supportsMultiplicity,
|
||||
pendingAction,
|
||||
isDraftHandoffActive,
|
||||
updatePreferences,
|
||||
setAttachments,
|
||||
} = input;
|
||||
const worktreeSupport = project
|
||||
? getWorktreeSupportForHostProject({ project, serverId })
|
||||
: "unsupported";
|
||||
const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported";
|
||||
const locked = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
|
||||
const supportsMetadata = useHostFeature(serverId, "entityMetadata");
|
||||
const projectId = project ? getHostProjectId(project, serverId) : null;
|
||||
const projectName = project?.projectName ?? null;
|
||||
const pluginProject = useMemo(
|
||||
() => (projectId && projectName ? { id: projectId, name: projectName } : null),
|
||||
[projectId, projectName],
|
||||
);
|
||||
const inputs = { serverId, locked, project: pluginProject, canCreateWorktree, supportsMetadata };
|
||||
// Both are stable for the life of the screen: preferences write through the query client, and
|
||||
// the draft is keyed per mount. A command and a pick run the same effects.
|
||||
const [setup] = useState(() =>
|
||||
createNewWorkspaceSetup(inputs, {
|
||||
persistIsolation: (isolation) => void updatePreferences({ isolation }),
|
||||
syncBaseAttachment: (item) =>
|
||||
setAttachments((attachments) => syncPickerPrAttachment({ attachments, item })),
|
||||
}),
|
||||
);
|
||||
useLayoutEffect(() => setup.syncInputs(inputs));
|
||||
return { setup, pluginProject, canCreateWorktree, isPending: locked };
|
||||
}
|
||||
|
||||
/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */
|
||||
function useNewWorkspaceComposerEditing() {
|
||||
const terminal = useComposerEditingSession("terminal");
|
||||
|
||||
@@ -33,6 +33,8 @@ interface PickerControl {
|
||||
export interface NewWorkspaceFormProps {
|
||||
isCompact: boolean;
|
||||
isPending: boolean;
|
||||
/** Plugin fields, drawn after the base ref. */
|
||||
pluginItems: ReactNode;
|
||||
project: PickerControl & {
|
||||
options: ComboboxOption[];
|
||||
projectByOptionId: Map<string, HostProjectListItem>;
|
||||
@@ -165,7 +167,7 @@ function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolea
|
||||
*/
|
||||
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { isCompact, isPending, project, host, isolation, base, launch } = props;
|
||||
const { isCompact, isPending, pluginItems, project, host, isolation, base, launch } = props;
|
||||
const variant: FieldTriggerVariant = isCompact ? "field" : "badge";
|
||||
const projectPicker = useProjectPicker(project, isPending);
|
||||
const isolationPicker = useIsolationPicker(isolation, isPending);
|
||||
@@ -337,6 +339,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
|
||||
{hostField}
|
||||
{isolationField}
|
||||
{baseField}
|
||||
{pluginItems}
|
||||
{launchField}
|
||||
{/* Isolation and Base come and go with the project; their rows stay reserved so the
|
||||
stack keeps one height and nothing above the composer jumps. */}
|
||||
@@ -353,6 +356,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
|
||||
<View style={styles.desktopControl}>{hostField}</View>
|
||||
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
|
||||
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
|
||||
{pluginItems}
|
||||
<View style={styles.launchSpacer} pointerEvents="none" />
|
||||
{launchField}
|
||||
{pickers}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PickerItem } from "../new-workspace-picker-item";
|
||||
import { createNewWorkspaceSetup, type SetupEffects, type SetupInputs } from "./setup-model";
|
||||
|
||||
const READY: SetupInputs = {
|
||||
serverId: "host-a",
|
||||
locked: false,
|
||||
project: { id: "p1", name: "repo" },
|
||||
canCreateWorktree: true,
|
||||
supportsMetadata: true,
|
||||
};
|
||||
|
||||
function recordingEffects() {
|
||||
const chosen: PickerItem[] = [];
|
||||
const isolations: string[] = [];
|
||||
const effects: SetupEffects = {
|
||||
persistIsolation: (isolation) => isolations.push(isolation),
|
||||
syncBaseAttachment: (item) => chosen.push(item),
|
||||
};
|
||||
return { effects, chosen, isolations };
|
||||
}
|
||||
|
||||
describe("New workspace setup commands", () => {
|
||||
it("accepts commands from the first moment, before the screen has committed", async () => {
|
||||
const setup = createNewWorkspaceSetup(READY, recordingEffects().effects);
|
||||
|
||||
await setup.commands.setBranch("pas-101");
|
||||
|
||||
expect(setup.getSnapshot().branch).toBe("pas-101");
|
||||
});
|
||||
|
||||
it("applies commands that run back to back without waiting for a render", async () => {
|
||||
const setup = createNewWorkspaceSetup(READY, recordingEffects().effects);
|
||||
|
||||
void setup.commands.setBranch("pas-101");
|
||||
void setup.commands.setMetadata({ issue: "PAS-101", keep: true });
|
||||
await setup.commands.setMetadata({ keep: null });
|
||||
|
||||
expect(setup.getSnapshot().branch).toBe("pas-101");
|
||||
expect(setup.getSnapshot().metadata).toEqual({ issue: "PAS-101" });
|
||||
});
|
||||
|
||||
it("changes the model and runs the effects of a pick, for a base branch and an isolation choice", async () => {
|
||||
const { effects, chosen, isolations } = recordingEffects();
|
||||
const setup = createNewWorkspaceSetup(READY, effects);
|
||||
|
||||
await setup.commands.setBaseRef({ type: "branch", name: " release " });
|
||||
await setup.commands.setIsolation("worktree");
|
||||
|
||||
expect(chosen).toMatchObject([{ kind: "branch", name: "release", refName: "release" }]);
|
||||
expect(setup.getSnapshot().pickerSelection.selectedItem).toEqual(chosen[0]);
|
||||
expect(isolations).toEqual(["worktree"]);
|
||||
expect(setup.getSnapshot().manualIsolation).toBe("worktree");
|
||||
});
|
||||
|
||||
it("shows a base ref's short name and keeps the exact ref for creation", async () => {
|
||||
const setup = createNewWorkspaceSetup(READY, recordingEffects().effects);
|
||||
|
||||
await setup.commands.setBaseRef({ type: "branch", name: "refs/remotes/origin/release" });
|
||||
|
||||
expect(setup.getSnapshot().pickerSelection.selectedItem).toMatchObject({
|
||||
name: "release",
|
||||
refName: "refs/remotes/origin/release",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the branch and metadata chosen for another project, but not while the project resolves", async () => {
|
||||
const setup = createNewWorkspaceSetup({ ...READY, project: null }, recordingEffects().effects);
|
||||
await setup.commands.setBranch("pas-101");
|
||||
setup.syncInputs(READY);
|
||||
expect(setup.getSnapshot().branch).toBe("pas-101");
|
||||
|
||||
await setup.commands.setMetadata({ issue: "PAS-101" });
|
||||
setup.syncInputs({ ...READY, project: { id: "p2", name: "other" } });
|
||||
|
||||
expect(setup.getSnapshot().branch).toBeNull();
|
||||
expect(setup.getSnapshot().metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("drops the branch and metadata when the host changes under the same project id", async () => {
|
||||
const setup = createNewWorkspaceSetup(READY, recordingEffects().effects);
|
||||
await setup.commands.setBranch("pas-101");
|
||||
await setup.commands.setMetadata({ issue: "PAS-101" });
|
||||
|
||||
setup.syncInputs({ ...READY, serverId: "host-b" });
|
||||
|
||||
expect(setup.getSnapshot().branch).toBeNull();
|
||||
expect(setup.getSnapshot().metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects every command while the screen is locked, as the pickers do", async () => {
|
||||
const setup = createNewWorkspaceSetup({ ...READY, locked: true }, recordingEffects().effects);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
setup.commands.setBranch("x"),
|
||||
setup.commands.setMetadata({ a: 1 }),
|
||||
setup.commands.setIsolation("local"),
|
||||
setup.commands.setBaseRef({ type: "branch", name: "main" }),
|
||||
]);
|
||||
|
||||
expect(results.map((result) => result.status)).toEqual(Array(4).fill("rejected"));
|
||||
expect(setup.getSnapshot().branch).toBeNull();
|
||||
expect(setup.getSnapshot().metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("reads the inputs when a command runs, so a project change after render is seen", async () => {
|
||||
const setup = createNewWorkspaceSetup(READY, recordingEffects().effects);
|
||||
setup.syncInputs({ ...READY, project: null });
|
||||
|
||||
await expect(setup.commands.setBaseRef({ type: "branch", name: "main" })).rejects.toThrow(
|
||||
"Choose a project first.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects what the project or host cannot do", async () => {
|
||||
const setup = createNewWorkspaceSetup(
|
||||
{ ...READY, canCreateWorktree: false, supportsMetadata: false },
|
||||
recordingEffects().effects,
|
||||
);
|
||||
|
||||
await expect(setup.commands.setIsolation("worktree")).rejects.toThrow(
|
||||
"cannot create worktrees",
|
||||
);
|
||||
await expect(setup.commands.setMetadata({ a: 1 })).rejects.toThrow("Update the host");
|
||||
await expect(setup.commands.setBranch(" ")).rejects.toThrow("needs a name");
|
||||
await setup.commands.setBranch(null);
|
||||
expect(setup.getSnapshot().branch).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { applyMetadataPatch } from "@/composer/draft/metadata";
|
||||
import { branchNameFromRef, type PickerItem } from "../new-workspace-picker-item";
|
||||
import {
|
||||
initialPickerSelectionState,
|
||||
reducePickerSelection,
|
||||
type PickerSelectionEvent,
|
||||
type PickerSelectionState,
|
||||
} from "../new-workspace-picker-state";
|
||||
|
||||
type Isolation = "local" | "worktree";
|
||||
|
||||
/** What the user has chosen on the New workspace screen, apart from project and host. */
|
||||
interface SetupState {
|
||||
pickerSelection: PickerSelectionState;
|
||||
manualIsolation: Isolation | null;
|
||||
/** The name for a new worktree branch; null lets Paseo pick one. */
|
||||
branch: string | null;
|
||||
/** The created workspace's metadata. */
|
||||
metadata: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the screen derives from its hooks, given at construction and kept current on every
|
||||
* commit, so a command reads it at the moment it runs rather than from the render that built it.
|
||||
*/
|
||||
export interface SetupInputs {
|
||||
/** The host the screen creates on; a project id alone can repeat across hosts. */
|
||||
serverId: string;
|
||||
locked: boolean;
|
||||
project: { id: string; name: string } | null;
|
||||
canCreateWorktree: boolean;
|
||||
supportsMetadata: boolean;
|
||||
}
|
||||
|
||||
export interface SetupCommands {
|
||||
setIsolation(isolation: Isolation): Promise<void>;
|
||||
setBaseRef(ref: { type: "branch"; name: string }): Promise<void>;
|
||||
setBranch(name: string | null): Promise<void>;
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface NewWorkspaceSetup {
|
||||
subscribe(listener: () => void): () => void;
|
||||
getSnapshot(): SetupState;
|
||||
/** Events other than a pick: a detected change request, a target change. */
|
||||
dispatchPicker(event: PickerSelectionEvent): void;
|
||||
/** The base picker's entry, and the one `setBaseRef` takes. */
|
||||
chooseBase(item: PickerItem): void;
|
||||
/** The isolation picker's entry, and the one `setIsolation` takes. */
|
||||
chooseIsolation(isolation: Isolation): void;
|
||||
syncInputs(inputs: SetupInputs): void;
|
||||
commands: SetupCommands;
|
||||
}
|
||||
|
||||
/** What a pick does beyond changing the model, the same for the picker and for a command. */
|
||||
export interface SetupEffects {
|
||||
/** Remembers the isolation as the default for the next New workspace screen. */
|
||||
persistIsolation(isolation: Isolation): void;
|
||||
/** Keeps the picker's change-request attachment in step with the chosen base. */
|
||||
syncBaseAttachment(item: PickerItem): void;
|
||||
}
|
||||
|
||||
const LOCKED_MESSAGE = "The New workspace screen is locked while the workspace is being created.";
|
||||
|
||||
/**
|
||||
* The New workspace screen's choices as one plain model. The pickers and the plugin commands
|
||||
* write to it through the same entries, so a command sees what an earlier command or pick left,
|
||||
* has the effects of a pick, and applies the same rules as the picker it stands in for. It is
|
||||
* built with its inputs and effects, so a command does the same thing from a child's mount effect
|
||||
* as from a button later.
|
||||
*/
|
||||
export function createNewWorkspaceSetup(
|
||||
initial: SetupInputs,
|
||||
effects: SetupEffects,
|
||||
): NewWorkspaceSetup {
|
||||
let state: SetupState = {
|
||||
pickerSelection: initialPickerSelectionState,
|
||||
manualIsolation: null,
|
||||
branch: null,
|
||||
metadata: {},
|
||||
};
|
||||
let inputs = initial;
|
||||
let target = initial.project ? `${initial.serverId}\n${initial.project.id}` : null;
|
||||
const listeners = new Set<() => void>();
|
||||
const update = (patch: Partial<SetupState>) => {
|
||||
state = { ...state, ...patch };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
const unlocked = (): SetupInputs => {
|
||||
if (inputs.locked) throw new Error(LOCKED_MESSAGE);
|
||||
return inputs;
|
||||
};
|
||||
const dispatchPicker = (event: PickerSelectionEvent) =>
|
||||
update({ pickerSelection: reducePickerSelection(state.pickerSelection, event) });
|
||||
const chooseBase = (item: PickerItem) => {
|
||||
dispatchPicker({ type: "picker-selected", item });
|
||||
effects.syncBaseAttachment(item);
|
||||
};
|
||||
const chooseIsolation = (isolation: Isolation) => {
|
||||
update({ manualIsolation: isolation });
|
||||
effects.persistIsolation(isolation);
|
||||
};
|
||||
return {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
getSnapshot: () => state,
|
||||
dispatchPicker,
|
||||
chooseBase,
|
||||
chooseIsolation,
|
||||
syncInputs: (next) => {
|
||||
inputs = next;
|
||||
// A branch and metadata are chosen for one project on one host. A project id can repeat
|
||||
// across hosts (a remote repository keeps its key everywhere), so the host is part of the
|
||||
// target. An absent project is a screen that is still resolving, not a switch.
|
||||
if (!next.project) return;
|
||||
const nextTarget = `${next.serverId}\n${next.project.id}`;
|
||||
if (target && target !== nextTarget) update({ branch: null, metadata: {} });
|
||||
target = nextTarget;
|
||||
},
|
||||
commands: {
|
||||
async setIsolation(isolation) {
|
||||
const current = unlocked();
|
||||
if (isolation === "worktree" && !current.canCreateWorktree) {
|
||||
throw new Error("This project cannot create worktrees.");
|
||||
}
|
||||
chooseIsolation(isolation);
|
||||
},
|
||||
async setBaseRef({ name }) {
|
||||
const current = unlocked();
|
||||
const refName = name.trim();
|
||||
if (!current.project) throw new Error("Choose a project first.");
|
||||
if (!refName) throw new Error("A base branch needs a name.");
|
||||
chooseBase({
|
||||
kind: "branch",
|
||||
name: branchNameFromRef(refName),
|
||||
refName,
|
||||
accessibilityLabel: `${branchNameFromRef(refName)}, branch`,
|
||||
});
|
||||
},
|
||||
async setBranch(name) {
|
||||
unlocked();
|
||||
const branch = name === null ? null : name.trim();
|
||||
if (branch === "") throw new Error("A branch needs a name.");
|
||||
update({ branch });
|
||||
},
|
||||
async setMetadata(patch) {
|
||||
const current = unlocked();
|
||||
if (!current.supportsMetadata) throw new Error("Update the host to use metadata.");
|
||||
update({ metadata: applyMetadataPatch(state.metadata, patch) });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useNewWorkspaceSetupState(setup: NewWorkspaceSetup): SetupState {
|
||||
return useSyncExternalStore(setup.subscribe, setup.getSnapshot, setup.getSnapshot);
|
||||
}
|
||||
@@ -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,88 @@ 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 New workspace SDK boundary", () => {
|
||||
it("lets a New workspace item read the setup and change it through typed commands and a Combobox", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import { useNewWorkspace, type PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { Combobox } from "@getpaseo/plugin/client/ui";
|
||||
interface Issue { id: string; label: string; branch: string }
|
||||
function Item() {
|
||||
const newWorkspace = useNewWorkspace();
|
||||
const isolation: "local" | "worktree" = newWorkspace.isolation;
|
||||
const branch: string | null = newWorkspace.branch;
|
||||
const project: string | undefined = newWorkspace.project?.name;
|
||||
const host: string = newWorkspace.host.label;
|
||||
const base = newWorkspace.baseRef;
|
||||
if (base?.type === "branch") { const name: string = base.name; }
|
||||
// @ts-expect-error project is read-only
|
||||
newWorkspace.project = null;
|
||||
// @ts-expect-error host is read-only
|
||||
newWorkspace.setHost("other");
|
||||
// @ts-expect-error metadata reads are not writable
|
||||
newWorkspace.metadata.key = "x";
|
||||
newWorkspace.setIsolation("worktree").then(() => newWorkspace.setBaseRef({ type: "branch", name: "main" }));
|
||||
newWorkspace.setBranch(null);
|
||||
newWorkspace.setMetadata({ issue: "1", stale: null });
|
||||
// @ts-expect-error commands take JSON values
|
||||
newWorkspace.setMetadata({ fn: () => {} });
|
||||
return (
|
||||
<Combobox<Issue>
|
||||
title="Issue"
|
||||
icon="CircleDot"
|
||||
value={{ id: "1", label: "One" }}
|
||||
search={async ({ query }: { query: string }) => [{ id: query, label: query, branch: "b" }]}
|
||||
onSelect={({ item }) => newWorkspace.setBranch(item.branch)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addNewWorkspaceItem({ 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,8 @@ 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 { PluginNewWorkspaceItemContribution } from "./new-workspace.js";
|
||||
import type { PluginRpcContract } from "../rpc.js";
|
||||
import type {
|
||||
PluginButtonRegistration,
|
||||
@@ -194,6 +196,8 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
|
||||
contribution: PluginComposerPillContribution<PluginText>,
|
||||
): PluginButtonRegistration<PluginText>;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
|
||||
addComposerItem(contribution: PluginComposerItemContribution): PluginCleanup;
|
||||
addNewWorkspaceItem(contribution: PluginNewWorkspaceItemContribution): PluginCleanup;
|
||||
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
|
||||
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
|
||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 { PluginNewWorkspaceProvider } from "./new-workspace.js";
|
||||
export {
|
||||
usePluginRuntimeContextBridge,
|
||||
type PluginRuntimeContextBridge,
|
||||
|
||||
@@ -71,6 +71,19 @@ 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 { useNewWorkspace } from "./new-workspace.js";
|
||||
export type {
|
||||
PluginNewWorkspace,
|
||||
PluginNewWorkspaceItemContribution,
|
||||
PluginNewWorkspaceItemProps,
|
||||
} from "./new-workspace.js";
|
||||
export { useRpc } from "./rpc-context.js";
|
||||
import type { SettingsDefinition } from "../settings.js";
|
||||
import type { SettingsState } from "./contracts.js";
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginHostProps } from "./contracts.js";
|
||||
|
||||
/**
|
||||
* The New workspace setup the item sits in. `host` and `project` are the user's choices and are
|
||||
* read-only. The commands change what the created workspace is built from, under the same rules as
|
||||
* the screen's own pickers.
|
||||
*/
|
||||
export interface PluginNewWorkspace {
|
||||
readonly host: { readonly id: string; readonly label: string };
|
||||
readonly project: { readonly id: string; readonly name: string } | null;
|
||||
/** What the screen will create. A project that cannot make worktrees reads `"local"`. */
|
||||
readonly isolation: "local" | "worktree";
|
||||
/**
|
||||
* What a new worktree starts from; `null` until the project's default is known. A branch's
|
||||
* `name` is the exact ref creation uses, such as `refs/remotes/origin/main`, so
|
||||
* `setBaseRef(newWorkspace.baseRef)` changes nothing.
|
||||
*/
|
||||
readonly baseRef:
|
||||
| { readonly type: "branch"; readonly name: string }
|
||||
| { readonly type: "change-request"; readonly number: number }
|
||||
| null;
|
||||
/**
|
||||
* The name of the branch a new worktree is created on; `null` lets Paseo pick one. It is kept
|
||||
* while isolation is local or the base is a change request. It names the new branch of any
|
||||
* worktree that cuts one, from the default base or a chosen branch, and does not apply to a
|
||||
* worktree that checks out an existing branch or change request.
|
||||
*/
|
||||
readonly branch: string | null;
|
||||
/** The workspace's metadata, not the first agent's. */
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
/** Rejects `"worktree"` for a project that cannot make worktrees. */
|
||||
setIsolation(isolation: "local" | "worktree"): Promise<void>;
|
||||
/**
|
||||
* Starts the workspace from a branch. `name` is any ref the Base picker offers, such as
|
||||
* `refs/remotes/origin/main`, or a bare branch name, which Paseo resolves the way it does for
|
||||
* a branch name it is given anywhere else. Rejects while no project is chosen.
|
||||
*/
|
||||
setBaseRef(ref: { type: "branch"; name: string }): Promise<void>;
|
||||
setBranch(name: string | null): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the key. Rejects on a host without metadata support. */
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginNewWorkspaceItemProps extends PluginHostProps {}
|
||||
|
||||
export interface PluginNewWorkspaceItemContribution {
|
||||
id: string;
|
||||
Component: ComponentType<PluginNewWorkspaceItemProps>;
|
||||
}
|
||||
|
||||
const PluginNewWorkspaceContext = createContext<PluginNewWorkspace | null>(null);
|
||||
|
||||
export function PluginNewWorkspaceProvider({
|
||||
newWorkspace,
|
||||
children,
|
||||
}: {
|
||||
newWorkspace: PluginNewWorkspace;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PluginNewWorkspaceContext.Provider value={newWorkspace}>
|
||||
{children}
|
||||
</PluginNewWorkspaceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The New workspace setup this item sits in. Call it from a component registered with `addNewWorkspaceItem`. */
|
||||
export function useNewWorkspace(): PluginNewWorkspace {
|
||||
const newWorkspace = useContext(PluginNewWorkspaceContext);
|
||||
if (!newWorkspace) throw new Error("useNewWorkspace must run inside a New workspace item");
|
||||
return newWorkspace;
|
||||
}
|
||||
@@ -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,60 @@ 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>;
|
||||
}
|
||||
/** One result of a `Combobox` search. Extra fields ride along to `onSelect`. */
|
||||
export interface ComboboxItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
export interface ComboboxSearchEvent {
|
||||
/** Empty when the list opens. */
|
||||
query: string;
|
||||
}
|
||||
export interface ComboboxProps<
|
||||
Item extends ComboboxItem = ComboboxItem,
|
||||
> extends ComposerControlProps {
|
||||
/** What the control shows. It need not be among the search results. */
|
||||
value: ComboboxItem | null;
|
||||
/**
|
||||
* Called when the list opens and again, debounced, as the user types. Paseo shows the loading
|
||||
* and error states, keeps the previous results while a search runs, and ignores a search that a
|
||||
* newer one replaced.
|
||||
*/
|
||||
search(event: ComboboxSearchEvent): Promise<readonly Item[]>;
|
||||
onSelect(event: { item: Item }): void | Promise<void>;
|
||||
}
|
||||
/**
|
||||
* Paseo's controls. Render them from a composer item's or a New workspace item's `Component`.
|
||||
* Paseo draws them where the item sits: inline when the composer has room and as rows in its
|
||||
* overflow sheet when it does not, and as a field in the New workspace setup.
|
||||
*/
|
||||
export declare const Toggle: ComponentType<ToggleProps>;
|
||||
export declare function Select<Value extends string>(props: SelectProps<Value>): ReactNode;
|
||||
export declare const Action: ComponentType<ActionProps>;
|
||||
export declare function Combobox<Item extends ComboboxItem>(props: ComboboxProps<Item>): ReactNode;
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# New workspace issues
|
||||
|
||||
Install this directory as a plugin and open New workspace. An **Issue** field appears beside the
|
||||
project, host, and branch pickers: a field on a phone, a badge on desktop.
|
||||
|
||||
Choosing an issue:
|
||||
|
||||
- Calls `newWorkspace.setBranch(issue.branch)`, so a worktree is created on that branch name.
|
||||
- Calls `newWorkspace.setMetadata({ "new-workspace-issues.issue": issue.id })`, so the created
|
||||
workspace carries the issue id.
|
||||
|
||||
`Combobox` runs `search` when the list opens and again as the user types. Paseo shows the loading
|
||||
and error states and drops results that a newer search replaced. The server handler in
|
||||
[server/issues.ts](server/issues.ts) filters a fixed list; a real plugin would call its tracker.
|
||||
|
||||
[client/issue-field.tsx](client/issue-field.tsx) is the field; [index.client.tsx](index.client.tsx)
|
||||
registers it with `addNewWorkspaceItem`.
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useNewWorkspace, useRpc } from "@getpaseo/plugin/client";
|
||||
import { Combobox } from "@getpaseo/plugin/client/ui";
|
||||
import { searchIssuesRpc, type Issue } from "../shared/issues";
|
||||
|
||||
const ISSUE_KEY = "new-workspace-issues.issue";
|
||||
|
||||
/** Starts the workspace on the issue's branch and records which issue it is for. */
|
||||
export function IssueField() {
|
||||
const newWorkspace = useNewWorkspace();
|
||||
const searchIssues = useRpc(searchIssuesRpc);
|
||||
const issueId = newWorkspace.metadata[ISSUE_KEY];
|
||||
const value = useMemo(
|
||||
() => (typeof issueId === "string" ? { id: issueId, label: issueId } : null),
|
||||
[issueId],
|
||||
);
|
||||
const search = useCallback(
|
||||
async ({ query }: { query: string }) => (await searchIssues({ query })).issues,
|
||||
[searchIssues],
|
||||
);
|
||||
const select = useCallback(
|
||||
async ({ item }: { item: Issue }) => {
|
||||
await newWorkspace.setBranch(item.branch);
|
||||
await newWorkspace.setMetadata({ [ISSUE_KEY]: item.id });
|
||||
},
|
||||
[newWorkspace],
|
||||
);
|
||||
return (
|
||||
<Combobox title="Issue" icon="CircleDot" value={value} search={search} onSelect={select} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { IssueField } from "./client/issue-field";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addNewWorkspaceItem({ id: "issue", Component: IssueField });
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { searchIssues } from "./server/issues";
|
||||
import { searchIssuesRpc } from "./shared/issues";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.handle(searchIssuesRpc, searchIssues);
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "new-workspace-issues-example",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { RpcInput } from "@getpaseo/plugin";
|
||||
import { searchIssuesRpc, type Issue } from "../shared/issues";
|
||||
|
||||
// A real plugin would ask its tracker. A fixed list keeps the example self-contained.
|
||||
const ISSUES: Issue[] = [
|
||||
{
|
||||
id: "PAS-101",
|
||||
label: "PAS-101 Slow sidebar",
|
||||
description: "Lists lag at 200 rows",
|
||||
branch: "pas-101-slow-sidebar",
|
||||
},
|
||||
{
|
||||
id: "PAS-102",
|
||||
label: "PAS-102 Crash on resume",
|
||||
description: "Agent resume throws",
|
||||
branch: "pas-102-crash-on-resume",
|
||||
},
|
||||
{ id: "PAS-103", label: "PAS-103 Dark theme contrast", branch: "pas-103-dark-theme-contrast" },
|
||||
];
|
||||
|
||||
export async function searchIssues({ query }: RpcInput<typeof searchIssuesRpc>) {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return {
|
||||
issues: ISSUES.filter((issue) => issue.label.toLowerCase().includes(needle)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
export const IssueSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
/** The branch the issue's work goes on. */
|
||||
branch: z.string(),
|
||||
});
|
||||
|
||||
export type Issue = z.infer<typeof IssueSchema>;
|
||||
|
||||
export const searchIssuesRpc = defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({ query: z.string() }),
|
||||
output: z.object({ issues: z.array(IssueSchema) }),
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user