mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +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
|
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).
|
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
|
## Contribute timeline items
|
||||||
|
|
||||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
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.
|
* - `remembered`: the provider's last-used control values.
|
||||||
* - `edits`: toggles made in this draft.
|
* - `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
|
* 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.
|
* 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: {
|
export function openDraftOptions(input: {
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
seed?: ProviderOptions;
|
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 {
|
}): DraftOptionsModel {
|
||||||
let state: DraftOptionsState = {
|
let state: DraftOptionsState = {
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
@@ -64,6 +72,15 @@ export function openDraftOptions(input: {
|
|||||||
for (const listener of listeners) listener();
|
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 {
|
return {
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
subscribe: (listener) => {
|
subscribe: (listener) => {
|
||||||
@@ -72,13 +89,7 @@ export function openDraftOptions(input: {
|
|||||||
listeners.delete(listener);
|
listeners.delete(listener);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
observeProvider: (provider) =>
|
observeProvider,
|
||||||
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 };
|
|
||||||
}),
|
|
||||||
applyProfile: (profile) =>
|
applyProfile: (profile) =>
|
||||||
publish((current) => ({
|
publish((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -88,12 +99,14 @@ export function openDraftOptions(input: {
|
|||||||
resolved: current.provider === profile.provider ? current.resolved : null,
|
resolved: current.provider === profile.provider ? current.resolved : null,
|
||||||
})),
|
})),
|
||||||
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
|
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
|
||||||
setOption: (key, value) =>
|
setOption: (key, value) => {
|
||||||
|
if (input.currentProvider) observeProvider(input.currentProvider());
|
||||||
publish((current) =>
|
publish((current) =>
|
||||||
Object.is(current.edits[key], value)
|
Object.is(current.edits[key], value)
|
||||||
? current
|
? current
|
||||||
: { ...current, edits: { ...current.edits, [key]: value } },
|
: { ...current, edits: { ...current.edits, [key]: value } },
|
||||||
),
|
);
|
||||||
|
},
|
||||||
applyResolved: (resolved) => publish((current) => ({ ...current, resolved })),
|
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),
|
([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;
|
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<
|
export type DraftControlsRequest = Pick<
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
|
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
|
||||||
@@ -163,6 +180,20 @@ export function viewDraftOptions(
|
|||||||
options:
|
options:
|
||||||
resolved.requestKey === current.requestKey
|
resolved.requestKey === current.requestKey
|
||||||
? resolved.options
|
? 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;
|
}) => ReactElement;
|
||||||
onSearchQueryChange?: (query: string) => void;
|
onSearchQueryChange?: (query: string) => void;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
|
/** The caller answers the search query itself, so the options are shown as given. */
|
||||||
|
remoteSearch?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
emptyText?: 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({
|
export function Combobox({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -1299,6 +1305,7 @@ export function Combobox({
|
|||||||
renderOption,
|
renderOption,
|
||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
searchable = true,
|
searchable = true,
|
||||||
|
remoteSearch,
|
||||||
placeholder,
|
placeholder,
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
emptyText,
|
emptyText,
|
||||||
@@ -1458,7 +1465,7 @@ export function Combobox({
|
|||||||
buildVisibleComboboxOptions({
|
buildVisibleComboboxOptions({
|
||||||
options,
|
options,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
searchable,
|
searchable: filtersLocally({ searchable, remoteSearch }),
|
||||||
allowCustomValue,
|
allowCustomValue,
|
||||||
customValuePrefix,
|
customValuePrefix,
|
||||||
customValueDescription,
|
customValueDescription,
|
||||||
@@ -1470,6 +1477,7 @@ export function Combobox({
|
|||||||
customValueKind,
|
customValueKind,
|
||||||
customValuePrefix,
|
customValuePrefix,
|
||||||
options,
|
options,
|
||||||
|
remoteSearch,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
searchable,
|
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,
|
hasThinking: true,
|
||||||
hasMode: true,
|
hasMode: true,
|
||||||
features: [{ type: "toggle" as const }],
|
features: [{ type: "toggle" as const }],
|
||||||
|
pluginItems: 0,
|
||||||
fontScale: 1,
|
fontScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ describe("composer control layout", () => {
|
|||||||
hasThinking: true,
|
hasThinking: true,
|
||||||
hasMode: true,
|
hasMode: true,
|
||||||
features: [{ type: "toggle" }],
|
features: [{ type: "toggle" }],
|
||||||
|
pluginItems: 0,
|
||||||
fontScale: 1,
|
fontScale: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -107,6 +109,7 @@ describe("composer control layout", () => {
|
|||||||
hasThinking: true,
|
hasThinking: true,
|
||||||
hasMode: true,
|
hasMode: true,
|
||||||
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
|
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
|
||||||
|
pluginItems: 0,
|
||||||
fontScale: 1,
|
fontScale: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -119,6 +122,7 @@ describe("composer control layout", () => {
|
|||||||
hasThinking: true,
|
hasThinking: true,
|
||||||
hasMode: true,
|
hasMode: true,
|
||||||
features: [{ type: "toggle" }],
|
features: [{ type: "toggle" }],
|
||||||
|
pluginItems: 0,
|
||||||
fontScale: 1.25,
|
fontScale: 1.25,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -133,6 +137,7 @@ describe("composer control layout", () => {
|
|||||||
hasModel: true,
|
hasModel: true,
|
||||||
hasThinking: true,
|
hasThinking: true,
|
||||||
hasMode: true,
|
hasMode: true,
|
||||||
|
pluginItems: 0,
|
||||||
fontScale: 1,
|
fontScale: 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -154,6 +159,34 @@ describe("composer control layout", () => {
|
|||||||
).toBe("condensed");
|
).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", () => {
|
it("gives every toolbar control one shell and one platform glyph envelope", () => {
|
||||||
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
|
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
|
||||||
controlSize: 28,
|
controlSize: 28,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface ComposerControlPresence {
|
|||||||
hasThinking: boolean;
|
hasThinking: boolean;
|
||||||
hasMode: boolean;
|
hasMode: boolean;
|
||||||
features: readonly ComposerFeatureControlPresence[];
|
features: readonly ComposerFeatureControlPresence[];
|
||||||
|
/** Plugin composer items. Each is budgeted like a select with a short label. */
|
||||||
|
pluginItems: number;
|
||||||
fontScale: number;
|
fontScale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export const COMPOSER_TOOLBAR_GEOMETRY = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const DENSITY_HYSTERESIS = 12;
|
const DENSITY_HYSTERESIS = 12;
|
||||||
|
const PLUGIN_ITEM_LABEL = "Plugin item";
|
||||||
|
|
||||||
function normalizedFontScale(fontScale: number): number {
|
function normalizedFontScale(fontScale: number): number {
|
||||||
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
|
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.hasModel) widths.push(36 + 60 * fontScale);
|
||||||
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||||
if (controls.hasMode) widths.push(36 + 96 * fontScale);
|
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);
|
return sumControlWidths(widths);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +80,11 @@ function resolveFullFloor(controls: ComposerControlPresence): number {
|
|||||||
for (const feature of controls.features) {
|
for (const feature of controls.features) {
|
||||||
widths.push(resolveFeatureControlWidth(feature, fontScale));
|
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);
|
return sumControlWidths(widths);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,19 @@ describe("resolveAgentControlsMode", () => {
|
|||||||
onSelectMode: () => undefined,
|
onSelectMode: () => undefined,
|
||||||
models: [],
|
models: [],
|
||||||
selectedModel: "",
|
selectedModel: "",
|
||||||
onSelectModel: () => undefined,
|
onSelectModel: () => true,
|
||||||
isModelLoading: false,
|
isModelLoading: false,
|
||||||
modelSelectorProviders: [],
|
modelSelectorProviders: [],
|
||||||
isAllModelsLoading: false,
|
isAllModelsLoading: false,
|
||||||
onSelectProviderAndModel: () => undefined,
|
onSelectProviderAndModel: () => true,
|
||||||
|
options: {},
|
||||||
|
onSetOption: () => undefined,
|
||||||
thinkingOptions: [],
|
thinkingOptions: [],
|
||||||
selectedThinkingOptionId: "",
|
selectedThinkingOptionId: "",
|
||||||
onSelectThinkingOption: () => undefined,
|
onSelectThinkingOption: () => undefined,
|
||||||
onApplyAgentProfile: () => undefined,
|
onApplyAgentProfile: () => undefined,
|
||||||
|
metadata: {},
|
||||||
|
onSetMetadata: () => undefined,
|
||||||
}),
|
}),
|
||||||
).toBe("draft");
|
).toBe("draft");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
|
|||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
|
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
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 { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||||
import { useProviderIcon } from "@/components/provider-icons";
|
import { useProviderIcon } from "@/components/provider-icons";
|
||||||
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
|
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
|
||||||
@@ -43,6 +44,8 @@ interface CompactModelSheetProps {
|
|||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
glyphSize: number;
|
glyphSize: number;
|
||||||
canSwitchProvider: boolean;
|
canSwitchProvider: boolean;
|
||||||
|
/** Carries the composer's contexts into the sheets, which draw away from it. */
|
||||||
|
contextBridge?: ContextBridge | null;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +98,7 @@ export function CompactModelSheet({
|
|||||||
serverId = null,
|
serverId = null,
|
||||||
glyphSize,
|
glyphSize,
|
||||||
canSwitchProvider,
|
canSwitchProvider,
|
||||||
|
contextBridge = null,
|
||||||
children,
|
children,
|
||||||
}: CompactModelSheetProps) {
|
}: CompactModelSheetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -299,6 +303,7 @@ export function CompactModelSheet({
|
|||||||
snapPoints={SNAP_POINTS}
|
snapPoints={SNAP_POINTS}
|
||||||
scrollable={false}
|
scrollable={false}
|
||||||
contentStyle={styles.sheetBody}
|
contentStyle={styles.sheetBody}
|
||||||
|
contextBridge={contextBridge}
|
||||||
testID="agent-controls-model-sheet"
|
testID="agent-controls-model-sheet"
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -347,6 +352,7 @@ export function CompactModelSheet({
|
|||||||
scrollable={false}
|
scrollable={false}
|
||||||
presentation="push"
|
presentation="push"
|
||||||
contentStyle={styles.sheetBody}
|
contentStyle={styles.sheetBody}
|
||||||
|
contextBridge={contextBridge}
|
||||||
testID="agent-controls-model-browser-sheet"
|
testID="agent-controls-model-browser-sheet"
|
||||||
>
|
>
|
||||||
<View
|
<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 { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||||
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||||
@@ -21,10 +22,25 @@ export function resolveDraftKey(input: {
|
|||||||
export function buildDraftAgentControls(input: {
|
export function buildDraftAgentControls(input: {
|
||||||
formState: UseAgentFormStateResult;
|
formState: UseAgentFormStateResult;
|
||||||
optionControls?: DraftAgentControlsProps["optionControls"];
|
optionControls?: DraftAgentControlsProps["optionControls"];
|
||||||
|
options: DraftAgentControlsProps["options"];
|
||||||
|
onSetOption: DraftAgentControlsProps["onSetOption"];
|
||||||
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
|
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
|
||||||
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
|
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
|
||||||
|
context: NonNullable<DraftAgentControlsProps["context"]>;
|
||||||
|
metadata: Record<string, JsonValue>;
|
||||||
|
onSetMetadata: (patch: Record<string, JsonValue>) => void;
|
||||||
}): DraftAgentControlsProps {
|
}): DraftAgentControlsProps {
|
||||||
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input;
|
const {
|
||||||
|
formState,
|
||||||
|
optionControls,
|
||||||
|
options,
|
||||||
|
onSetOption,
|
||||||
|
onApplyAgentProfile,
|
||||||
|
onDropdownClose,
|
||||||
|
context,
|
||||||
|
metadata,
|
||||||
|
onSetMetadata,
|
||||||
|
} = input;
|
||||||
return {
|
return {
|
||||||
providerDefinitions: formState.providerDefinitions,
|
providerDefinitions: formState.providerDefinitions,
|
||||||
selectedProvider: formState.selectedProvider,
|
selectedProvider: formState.selectedProvider,
|
||||||
@@ -43,11 +59,16 @@ export function buildDraftAgentControls(input: {
|
|||||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||||
onApplyAgentProfile,
|
onApplyAgentProfile,
|
||||||
optionControls,
|
optionControls,
|
||||||
|
options,
|
||||||
|
onSetOption,
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||||
onRetryModelProvider: formState.refreshProviderModels,
|
onRetryModelProvider: formState.refreshProviderModels,
|
||||||
isRetryingModelProvider: formState.isProviderModelsRefreshing,
|
isRetryingModelProvider: formState.isProviderModelsRefreshing,
|
||||||
modelSelectorServerId: formState.selectedServerId,
|
modelSelectorServerId: formState.selectedServerId,
|
||||||
|
context,
|
||||||
|
metadata,
|
||||||
|
onSetMetadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { TextReplacement } from "@/composer/types";
|
import type { TextReplacement } from "@/composer/types";
|
||||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||||
@@ -28,6 +29,8 @@ import { useShallow } from "zustand/shallow";
|
|||||||
import type { ComposerTextSource } from "@/composer/text-source";
|
import type { ComposerTextSource } from "@/composer/text-source";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
|
const NO_METADATA: Record<string, JsonValue> = {};
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
| UserComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||||
@@ -36,6 +39,8 @@ interface AgentInputDraftComposerOptions {
|
|||||||
initialServerId: string | null;
|
initialServerId: string | null;
|
||||||
initialValues?: CreateAgentInitialValues;
|
initialValues?: CreateAgentInitialValues;
|
||||||
initialProviderOptions?: Record<string, unknown>;
|
initialProviderOptions?: Record<string, unknown>;
|
||||||
|
/** Which composer a plugin's `useComposer()` reports. */
|
||||||
|
context?: "draft" | "new-workspace";
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
lockedWorkingDir?: string;
|
lockedWorkingDir?: string;
|
||||||
}
|
}
|
||||||
@@ -51,6 +56,8 @@ type DraftComposerState = UseAgentFormStateResult & {
|
|||||||
effectiveModelId: string;
|
effectiveModelId: string;
|
||||||
effectiveThinkingOptionId: string;
|
effectiveThinkingOptionId: string;
|
||||||
providerOptions: Record<string, unknown> | undefined;
|
providerOptions: Record<string, unknown> | undefined;
|
||||||
|
/** What plugins set on the agent this draft creates. */
|
||||||
|
metadata: Record<string, JsonValue>;
|
||||||
agentControls: DraftAgentControlsProps;
|
agentControls: DraftAgentControlsProps;
|
||||||
commandDraft: DraftCommandTarget;
|
commandDraft: DraftCommandTarget;
|
||||||
};
|
};
|
||||||
@@ -191,10 +198,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
[saveDraft],
|
[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(
|
const clear = useCallback(
|
||||||
(lifecycle: "sent" | "abandoned") => {
|
(lifecycle: "sent" | "abandoned") => {
|
||||||
textPublication.cancel();
|
textPublication.cancel();
|
||||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
||||||
|
if (lifecycle === "sent") useDraftStore.getState().clearDraftMetadata({ draftKey });
|
||||||
},
|
},
|
||||||
[draftKey, textPublication],
|
[draftKey, textPublication],
|
||||||
);
|
);
|
||||||
@@ -276,10 +291,13 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
const {
|
const {
|
||||||
optionControls: draftOptionControls,
|
optionControls: draftOptionControls,
|
||||||
options: draftOptions,
|
options: draftOptions,
|
||||||
|
shownOptions: draftShownOptions,
|
||||||
|
setOption: setDraftOption,
|
||||||
applyProfile: applyProfileOptions,
|
applyProfile: applyProfileOptions,
|
||||||
} = useDraftProviderOptions({
|
} = useDraftProviderOptions({
|
||||||
serverId: formState.selectedServerId,
|
serverId: formState.selectedServerId,
|
||||||
provider: formState.selectedProvider,
|
provider: formState.selectedProvider,
|
||||||
|
getProvider: formState.getSelectedProvider,
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
modeId: formState.selectedMode,
|
modeId: formState.selectedMode,
|
||||||
modelId: effectiveModelId,
|
modelId: effectiveModelId,
|
||||||
@@ -319,10 +337,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
providerOptions: draftOptions,
|
providerOptions: draftOptions,
|
||||||
|
metadata,
|
||||||
agentControls: buildDraftAgentControls({
|
agentControls: buildDraftAgentControls({
|
||||||
formState,
|
formState,
|
||||||
optionControls: draftOptionControls,
|
optionControls: draftOptionControls,
|
||||||
|
options: draftShownOptions,
|
||||||
|
onSetOption: setDraftOption,
|
||||||
onApplyAgentProfile: applyDraftAgentProfile,
|
onApplyAgentProfile: applyDraftAgentProfile,
|
||||||
|
context: composerOptions.context ?? "draft",
|
||||||
|
metadata,
|
||||||
|
onSetMetadata: setMetadata,
|
||||||
}),
|
}),
|
||||||
commandDraft,
|
commandDraft,
|
||||||
};
|
};
|
||||||
@@ -334,8 +358,12 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
draftOptionControls,
|
draftOptionControls,
|
||||||
draftOptions,
|
draftOptions,
|
||||||
|
draftShownOptions,
|
||||||
|
setDraftOption,
|
||||||
applyDraftAgentProfile,
|
applyDraftAgentProfile,
|
||||||
formState,
|
formState,
|
||||||
|
metadata,
|
||||||
|
setMetadata,
|
||||||
workingDir,
|
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 type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
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 { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
@@ -133,6 +134,7 @@ async function submitDraftCreateRequest(input: {
|
|||||||
effectiveModelId: string | null;
|
effectiveModelId: string | null;
|
||||||
effectiveThinkingOptionId: string | null;
|
effectiveThinkingOptionId: string | null;
|
||||||
providerOptions: Record<string, unknown> | undefined;
|
providerOptions: Record<string, unknown> | undefined;
|
||||||
|
metadata: Record<string, JsonValue>;
|
||||||
};
|
};
|
||||||
hostDisconnectedMessage: string;
|
hostDisconnectedMessage: string;
|
||||||
selectModelMessage: string;
|
selectModelMessage: string;
|
||||||
@@ -182,6 +184,7 @@ async function submitDraftCreateRequest(input: {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
initialPrompt: text,
|
initialPrompt: text,
|
||||||
clientMessageId: attempt.clientMessageId,
|
clientMessageId: attempt.clientMessageId,
|
||||||
|
...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}),
|
||||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||||
};
|
};
|
||||||
@@ -510,7 +513,9 @@ export function WorkspaceDraftAgentTab({
|
|||||||
providers: composerState.modelSelectorProviders,
|
providers: composerState.modelSelectorProviders,
|
||||||
selectedProvider: draftProvider,
|
selectedProvider: draftProvider,
|
||||||
selectedModelId: composerState.effectiveModelId,
|
selectedModelId: composerState.effectiveModelId,
|
||||||
select: composerState.setProviderAndModelFromUser,
|
select: (provider, modelId) => {
|
||||||
|
composerState.setProviderAndModelFromUser(provider, modelId);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
thinking: {
|
thinking: {
|
||||||
options: draftThinkingOptions,
|
options: draftThinkingOptions,
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ import type {
|
|||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import type { SelectedFile } from "@/attachments/selected-file";
|
import type { SelectedFile } from "@/attachments/selected-file";
|
||||||
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
|
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 { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||||
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
|
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
|
||||||
import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop";
|
import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop";
|
||||||
@@ -1419,6 +1421,40 @@ function ComposerContentImpl({
|
|||||||
[onChangeText],
|
[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(
|
const runClientSlashCommand = useCallback(
|
||||||
(command: ClientSlashCommand): boolean => {
|
(command: ClientSlashCommand): boolean => {
|
||||||
if (command.execution !== "immediate" || !onClientSlashCommand) {
|
if (command.execution !== "immediate" || !onClientSlashCommand) {
|
||||||
@@ -2223,18 +2259,27 @@ function ComposerContentImpl({
|
|||||||
[attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
[attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const leftContent = useMemo(
|
const leftContent = useMemo(() => {
|
||||||
() =>
|
const controls = renderLeftContent({
|
||||||
renderLeftContent({
|
agentControls,
|
||||||
agentControls,
|
agentId,
|
||||||
agentId,
|
serverId,
|
||||||
serverId,
|
focusInput,
|
||||||
focusInput,
|
isCompactLayout,
|
||||||
isCompactLayout,
|
showAgentControls: mode.showAgentControls,
|
||||||
showAgentControls: mode.showAgentControls,
|
});
|
||||||
}),
|
return controls ? (
|
||||||
[agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId],
|
<ComposerInputProvider value={composerInput}>{controls}</ComposerInputProvider>
|
||||||
);
|
) : null;
|
||||||
|
}, [
|
||||||
|
agentControls,
|
||||||
|
agentId,
|
||||||
|
composerInput,
|
||||||
|
focusInput,
|
||||||
|
isCompactLayout,
|
||||||
|
mode.showAgentControls,
|
||||||
|
serverId,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||||
attachButtonRef.current = node;
|
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 { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
@@ -28,14 +28,13 @@ import {
|
|||||||
mergeSelectedComposerPreferences,
|
mergeSelectedComposerPreferences,
|
||||||
buildProviderDefinitionMap,
|
buildProviderDefinitionMap,
|
||||||
buildProviderDefinitionMapForStatuses,
|
buildProviderDefinitionMapForStatuses,
|
||||||
INITIAL_AGENT_FORM_RESOLUTION,
|
|
||||||
INITIAL_USER_MODIFIED,
|
|
||||||
RESOLVABLE_PROVIDER_STATUSES,
|
RESOLVABLE_PROVIDER_STATUSES,
|
||||||
SELECTABLE_PROVIDER_STATUSES,
|
SELECTABLE_PROVIDER_STATUSES,
|
||||||
type FormInitialValues,
|
type FormInitialValues,
|
||||||
type FormState,
|
type FormState,
|
||||||
type ProviderModelsByProvider,
|
type ProviderModelsByProvider,
|
||||||
} from "@/provider-selection/resolve-agent-form";
|
} from "@/provider-selection/resolve-agent-form";
|
||||||
|
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
|
||||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import type { MaterializedAgentProfile } from "@/agent-profiles";
|
import type { MaterializedAgentProfile } from "@/agent-profiles";
|
||||||
|
|
||||||
@@ -52,10 +51,13 @@ export interface UseAgentFormStateOptions {
|
|||||||
export interface UseAgentFormStateResult {
|
export interface UseAgentFormStateResult {
|
||||||
selectedServerId: string | null;
|
selectedServerId: string | null;
|
||||||
selectedProvider: AgentProvider | null;
|
selectedProvider: AgentProvider | null;
|
||||||
|
/** The provider as it is now, which a render may not have reached yet. */
|
||||||
|
getSelectedProvider: () => AgentProvider | null;
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
setModeFromUser: (modeId: string) => void;
|
setModeFromUser: (modeId: string) => void;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
setModelFromUser: (modelId: string) => void;
|
/** `false` when the provider's loaded catalog does not list the model. */
|
||||||
|
setModelFromUser: (modelId: string) => boolean;
|
||||||
selectedThinkingOptionId: string;
|
selectedThinkingOptionId: string;
|
||||||
setThinkingOptionFromUser: (thinkingOptionId: string) => void;
|
setThinkingOptionFromUser: (thinkingOptionId: string) => void;
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
@@ -74,13 +76,20 @@ export interface UseAgentFormStateResult {
|
|||||||
modelError: string | null;
|
modelError: string | null;
|
||||||
refreshProviderModels: (provider?: AgentProvider) => void;
|
refreshProviderModels: (provider?: AgentProvider) => void;
|
||||||
refetchProviderModelsIfStale: () => 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;
|
applyProfileFromUser: (profile: MaterializedAgentProfile) => void;
|
||||||
clearProviderSelectionFromUser: () => void;
|
clearProviderSelectionFromUser: () => void;
|
||||||
workingDirIsEmpty: boolean;
|
workingDirIsEmpty: boolean;
|
||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_CATALOG: AgentFormCatalog = {
|
||||||
|
selectableProviders: new Map(),
|
||||||
|
models: new Map(),
|
||||||
|
providerPreferences: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
function resolveSelectedProviderModes(input: {
|
function resolveSelectedProviderModes(input: {
|
||||||
selectedEntry: ProviderSnapshotEntry | null;
|
selectedEntry: ProviderSnapshotEntry | null;
|
||||||
provider: AgentProvider | null;
|
provider: AgentProvider | null;
|
||||||
@@ -172,11 +181,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
[updatePreferences],
|
[updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [{ form: formState, userModified, resolution }, dispatch] = useReducer(resolveAgentForm, {
|
// The store reads what the host offers when a command runs; each render publishes the latest.
|
||||||
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
|
const catalogRef = useRef<AgentFormCatalog>(EMPTY_CATALOG);
|
||||||
userModified: INITIAL_USER_MODIFIED,
|
const [formStore] = useState(() => openAgentForm(() => catalogRef.current));
|
||||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
const { form: formState, resolution } = useSyncExternalStore(
|
||||||
});
|
formStore.subscribe,
|
||||||
|
formStore.getState,
|
||||||
|
formStore.getState,
|
||||||
|
);
|
||||||
|
const dispatch = formStore.dispatch;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
entries: snapshotEntries,
|
entries: snapshotEntries,
|
||||||
@@ -246,6 +259,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||||
const allProviderModels = snapshotAllProviderModels;
|
const allProviderModels = snapshotAllProviderModels;
|
||||||
|
catalogRef.current = {
|
||||||
|
selectableProviders: selectableProviderDefinitionMap,
|
||||||
|
models: allProviderModels,
|
||||||
|
providerPreferences: (provider) =>
|
||||||
|
preferenceOverlayRef.current.current().providerPreferences?.[provider],
|
||||||
|
};
|
||||||
const modelSelectorProviders = snapshotModelSelectorProviders;
|
const modelSelectorProviders = snapshotModelSelectorProviders;
|
||||||
const availableModels = snapshotSelectedProviderModels;
|
const availableModels = snapshotSelectedProviderModels;
|
||||||
const modeOptions = snapshotSelectedProviderModes;
|
const modeOptions = snapshotSelectedProviderModes;
|
||||||
@@ -277,27 +296,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
preferences,
|
preferences,
|
||||||
snapshotProviderModelsByProvider,
|
snapshotProviderModelsByProvider,
|
||||||
snapshotResolvableProviderDefinitionMap,
|
snapshotResolvableProviderDefinitionMap,
|
||||||
|
dispatch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const setProviderAndModelFromUser = useCallback(
|
const setProviderAndModelFromUser = useCallback(
|
||||||
(provider: AgentProvider, modelId: string) => {
|
(provider: AgentProvider, modelId: string): boolean => {
|
||||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
if (!formStore.setProviderAndModel(provider, modelId)) return false;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
|
||||||
const providerModels = allProviderModels.get(provider) ?? null;
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
const nextModelId =
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
normalizeSelectedModelId(modelId) || resolveDefaultModelId(providerModels);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
|
||||||
provider,
|
|
||||||
modelId,
|
|
||||||
providerDef,
|
|
||||||
providerModels,
|
|
||||||
providerPrefs,
|
|
||||||
});
|
|
||||||
void updateCurrentPreferences((current) =>
|
void updateCurrentPreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
@@ -307,13 +314,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
|
[allProviderModels, formStore, updateCurrentPreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearProviderSelectionFromUser = useCallback(() => {
|
const clearProviderSelectionFromUser = useCallback(() => {
|
||||||
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
|
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
|
||||||
}, []);
|
}, [dispatch]);
|
||||||
|
|
||||||
const applyProfileFromUser = useCallback(
|
const applyProfileFromUser = useCallback(
|
||||||
(profile: MaterializedAgentProfile) => {
|
(profile: MaterializedAgentProfile) => {
|
||||||
@@ -322,7 +330,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousProvider = formState.provider;
|
const formBefore = formStore.getState();
|
||||||
|
const previousProvider = formBefore.form.provider;
|
||||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||||
const providerModels = allProviderModels.get(provider) ?? null;
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||||
@@ -336,7 +345,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
providerModels,
|
providerModels,
|
||||||
providerPrefs,
|
providerPrefs,
|
||||||
};
|
};
|
||||||
const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action);
|
const nextState = resolveAgentForm(formBefore, action);
|
||||||
const previousProviderModeIds = previousProvider
|
const previousProviderModeIds = previousProvider
|
||||||
? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? [])
|
? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? [])
|
||||||
: [];
|
: [];
|
||||||
@@ -360,19 +369,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
formState,
|
dispatch,
|
||||||
|
formStore,
|
||||||
providerDefinitionMap,
|
providerDefinitionMap,
|
||||||
resolution,
|
|
||||||
selectableProviderDefinitionMap,
|
selectableProviderDefinitionMap,
|
||||||
updateCurrentPreferences,
|
updateCurrentPreferences,
|
||||||
userModified,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
(modeId: string) => {
|
(modeId: string) => {
|
||||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||||
const provider = formState.provider;
|
const { provider } = formStore.getState().form;
|
||||||
if (provider) {
|
if (provider) {
|
||||||
void updateCurrentPreferences((current) =>
|
void updateCurrentPreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
@@ -385,24 +393,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[formState.provider, updateCurrentPreferences],
|
[dispatch, formStore, updateCurrentPreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModelFromUser = useCallback(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string): boolean => {
|
||||||
const provider = formState.provider;
|
if (!formStore.setModel(modelId)) return false;
|
||||||
const providerPrefs = provider
|
const { provider } = formStore.getState().form;
|
||||||
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
|
|
||||||
: undefined;
|
|
||||||
dispatch({
|
|
||||||
type: "SET_MODEL_FROM_USER",
|
|
||||||
modelId,
|
|
||||||
availableModels,
|
|
||||||
providerPrefs,
|
|
||||||
});
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const listed = allProviderModels.get(provider);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
const nextModelId =
|
||||||
|
normalizeSelectedModelId(modelId) ||
|
||||||
|
resolveDefaultModelId(listed?.length ? listed : null);
|
||||||
void updateCurrentPreferences((current) =>
|
void updateCurrentPreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
@@ -413,14 +415,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
[availableModels, formState.provider, updateCurrentPreferences],
|
[allProviderModels, formStore, updateCurrentPreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThinkingOptionFromUser = useCallback(
|
const setThinkingOptionFromUser = useCallback(
|
||||||
(thinkingOptionId: string) => {
|
(thinkingOptionId: string) => {
|
||||||
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||||
const { provider, model: modelId } = formState;
|
const { provider, model: modelId } = formStore.getState().form;
|
||||||
if (provider && modelId) {
|
if (provider && modelId) {
|
||||||
void updateCurrentPreferences((current) =>
|
void updateCurrentPreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
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(
|
const refreshProviderModels = useCallback(
|
||||||
(provider?: AgentProvider) => {
|
(provider?: AgentProvider) => {
|
||||||
void refreshSnapshot(provider ? [provider] : undefined);
|
void refreshSnapshot(provider ? [provider] : undefined);
|
||||||
@@ -479,6 +484,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
() => ({
|
() => ({
|
||||||
selectedServerId: serverId,
|
selectedServerId: serverId,
|
||||||
selectedProvider: formState.provider,
|
selectedProvider: formState.provider,
|
||||||
|
getSelectedProvider,
|
||||||
selectedMode: formState.modeId,
|
selectedMode: formState.modeId,
|
||||||
setModeFromUser,
|
setModeFromUser,
|
||||||
selectedModel: formState.model,
|
selectedModel: formState.model,
|
||||||
@@ -509,6 +515,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
serverId,
|
serverId,
|
||||||
|
getSelectedProvider,
|
||||||
formState.provider,
|
formState.provider,
|
||||||
formState.modeId,
|
formState.modeId,
|
||||||
formState.model,
|
formState.model,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
draftControlsRequest,
|
draftControlsRequest,
|
||||||
openDraftOptions,
|
openDraftOptions,
|
||||||
ownDraftOptions,
|
ownDraftOptions,
|
||||||
|
shownDraftOptions,
|
||||||
viewDraftOptions,
|
viewDraftOptions,
|
||||||
} from "@/agent-controls/draft-options-model";
|
} from "@/agent-controls/draft-options-model";
|
||||||
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||||
@@ -19,6 +20,8 @@ import { mergeProviderPreferences, useFormPreferences } from "./use-form-prefere
|
|||||||
export function useDraftProviderOptions(input: {
|
export function useDraftProviderOptions(input: {
|
||||||
serverId: string | null | undefined;
|
serverId: string | null | undefined;
|
||||||
provider: AgentProvider | null;
|
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;
|
cwd: string | null | undefined;
|
||||||
modeId: string | null | undefined;
|
modeId: string | null | undefined;
|
||||||
modelId: string | null | undefined;
|
modelId: string | null | undefined;
|
||||||
@@ -26,8 +29,19 @@ export function useDraftProviderOptions(input: {
|
|||||||
initialOptions?: ProviderOptions;
|
initialOptions?: ProviderOptions;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
|
const {
|
||||||
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
|
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 state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
@@ -77,21 +91,23 @@ export function useDraftProviderOptions(input: {
|
|||||||
if (provider && data) model.applyResolved({ provider, requestKey, ...data });
|
if (provider && data) model.applyResolved({ provider, requestKey, ...data });
|
||||||
}, [data, model, provider, requestKey]);
|
}, [data, model, provider, requestKey]);
|
||||||
|
|
||||||
|
// An edit belongs to the provider the form has now, even before the render that shows it.
|
||||||
const setOption = useCallback(
|
const setOption = useCallback(
|
||||||
(key: string, value: unknown) => {
|
(key: string, value: unknown) => {
|
||||||
model.setOption(key, value);
|
model.setOption(key, value);
|
||||||
if (!provider) return;
|
const current = getProvider();
|
||||||
void updatePreferences((current) =>
|
if (!current) return;
|
||||||
|
void updatePreferences((stored) =>
|
||||||
mergeProviderPreferences({
|
mergeProviderPreferences({
|
||||||
preferences: current,
|
preferences: stored,
|
||||||
provider,
|
provider: current,
|
||||||
updates: { options: { [key]: value } },
|
updates: { options: { [key]: value } },
|
||||||
}),
|
}),
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
console.warn("[useDraftProviderOptions] persist option preference failed", error);
|
console.warn("[useDraftProviderOptions] persist option preference failed", error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[model, provider, updatePreferences],
|
[getProvider, model, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const view = viewDraftOptions(state, { provider, requestKey });
|
const view = viewDraftOptions(state, { provider, requestKey });
|
||||||
@@ -105,5 +121,14 @@ export function useDraftProviderOptions(input: {
|
|||||||
[setOption, viewControls, viewOptions],
|
[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";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ar: TranslationResources = {
|
export const ar: TranslationResources = {
|
||||||
|
pluginCombobox: {
|
||||||
|
searching: "جارٍ البحث…",
|
||||||
|
empty: "لا توجد نتائج",
|
||||||
|
failed: "فشل البحث: {{message}}",
|
||||||
|
},
|
||||||
paneFind: {
|
paneFind: {
|
||||||
connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
|
connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
|
||||||
historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.",
|
historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.",
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
pluginCombobox: {
|
||||||
|
searching: "Searching…",
|
||||||
|
empty: "No results",
|
||||||
|
failed: "Search failed: {{message}}",
|
||||||
|
},
|
||||||
paneFind: {
|
paneFind: {
|
||||||
connectionFailure: "Could not search this chat. Check the host connection and retry.",
|
connectionFailure: "Could not search this chat. Check the host connection and retry.",
|
||||||
historyChangedFailure: "The chat changed while searching. Search again.",
|
historyChangedFailure: "The chat changed while searching. Search again.",
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
|||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const es: TranslationResources = {
|
export const es: TranslationResources = {
|
||||||
|
pluginCombobox: {
|
||||||
|
searching: "Buscando…",
|
||||||
|
empty: "Sin resultados",
|
||||||
|
failed: "La búsqueda falló: {{message}}",
|
||||||
|
},
|
||||||
paneFind: {
|
paneFind: {
|
||||||
connectionFailure:
|
connectionFailure:
|
||||||
"No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.",
|
"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";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const fr: TranslationResources = {
|
export const fr: TranslationResources = {
|
||||||
|
pluginCombobox: {
|
||||||
|
searching: "Recherche…",
|
||||||
|
empty: "Aucun résultat",
|
||||||
|
failed: "Échec de la recherche : {{message}}",
|
||||||
|
},
|
||||||
paneFind: {
|
paneFind: {
|
||||||
connectionFailure:
|
connectionFailure:
|
||||||
"Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.",
|
"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";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ja: TranslationResources = {
|
export const ja: TranslationResources = {
|
||||||
|
pluginCombobox: {
|
||||||
|
searching: "検索中…",
|
||||||
|
empty: "結果がありません",
|
||||||
|
failed: "検索に失敗しました: {{message}}",
|
||||||
|
},
|
||||||
paneFind: {
|
paneFind: {
|
||||||
connectionFailure:
|
connectionFailure:
|
||||||
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
|
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
|||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ko: TranslationResources = {
|
export const ko: TranslationResources = {
|
||||||
|
pluginCombobox: {
|
||||||
|
searching: "검색 중…",
|
||||||
|
empty: "결과 없음",
|
||||||
|
failed: "검색 실패: {{message}}",
|
||||||
|
},
|
||||||
paneFind: {
|
paneFind: {
|
||||||
connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
|
connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
|
||||||
historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.",
|
historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.",
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
|||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ptBR: TranslationResources = {
|
export const ptBR: TranslationResources = {
|
||||||
|
pluginCombobox: {
|
||||||
|
searching: "Pesquisando…",
|
||||||
|
empty: "Nenhum resultado",
|
||||||
|
failed: "Falha na pesquisa: {{message}}",
|
||||||
|
},
|
||||||
paneFind: {
|
paneFind: {
|
||||||
connectionFailure:
|
connectionFailure:
|
||||||
"Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.",
|
"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";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ru: TranslationResources = {
|
export const ru: TranslationResources = {
|
||||||
|
pluginCombobox: {
|
||||||
|
searching: "Поиск…",
|
||||||
|
empty: "Нет результатов",
|
||||||
|
failed: "Не удалось выполнить поиск: {{message}}",
|
||||||
|
},
|
||||||
paneFind: {
|
paneFind: {
|
||||||
connectionFailure:
|
connectionFailure:
|
||||||
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
|
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
|
|||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const zhCN: TranslationResources = {
|
export const zhCN: TranslationResources = {
|
||||||
|
pluginCombobox: {
|
||||||
|
searching: "搜索中…",
|
||||||
|
empty: "无结果",
|
||||||
|
failed: "搜索失败:{{message}}",
|
||||||
|
},
|
||||||
paneFind: {
|
paneFind: {
|
||||||
connectionFailure: "无法搜索此聊天。请检查主机连接后重试。",
|
connectionFailure: "无法搜索此聊天。请检查主机连接后重试。",
|
||||||
historyChangedFailure: "搜索期间聊天已更改。请重新搜索。",
|
historyChangedFailure: "搜索期间聊天已更改。请重新搜索。",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function installation(): InstalledPlugin {
|
|||||||
sidebarItems: { header: [], footer: [] },
|
sidebarItems: { header: [], footer: [] },
|
||||||
legacySidebarItems: [],
|
legacySidebarItems: [],
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSlashCommands: [],
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const agent: Agent = {
|
|||||||
attentionReason: "permission",
|
attentionReason: "permission",
|
||||||
parentAgentId: null,
|
parentAgentId: null,
|
||||||
labels: { phase: "implementation" },
|
labels: { phase: "implementation" },
|
||||||
|
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("plugin context snapshots", () => {
|
describe("plugin context snapshots", () => {
|
||||||
@@ -107,12 +108,16 @@ describe("plugin context snapshots", () => {
|
|||||||
attentionReason: "permission",
|
attentionReason: "permission",
|
||||||
parentAgentId: null,
|
parentAgentId: null,
|
||||||
labels: { phase: "implementation" },
|
labels: { phase: "implementation" },
|
||||||
|
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||||
});
|
});
|
||||||
expect(snapshot.labels).not.toBe(agent.labels);
|
expect(snapshot.labels).not.toBe(agent.labels);
|
||||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||||
expect(Object.isFrozen(snapshot.labels)).toBe(true);
|
expect(Object.isFrozen(snapshot.labels)).toBe(true);
|
||||||
expect(Reflect.set(snapshot, "title", "Hostile rename")).toBe(false);
|
expect(Reflect.set(snapshot, "title", "Hostile rename")).toBe(false);
|
||||||
expect(Reflect.set(snapshot.labels, "phase", "hostile")).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.title).toBe("Implement plugin panels");
|
||||||
expect(snapshot.labels).toEqual({ phase: "implementation" });
|
expect(snapshot.labels).toEqual({ phase: "implementation" });
|
||||||
expect(agent.title).toBe("Implement plugin panels");
|
expect(agent.title).toBe("Implement plugin panels");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
|
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
|
||||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { cloneAndFreeze } from "../freeze";
|
||||||
|
|
||||||
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
|
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
|
||||||
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
|
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
|
||||||
@@ -49,6 +50,7 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
|
|||||||
attentionReason: agent.attentionReason ?? null,
|
attentionReason: agent.attentionReason ?? null,
|
||||||
parentAgentId: agent.parentAgentId,
|
parentAgentId: agent.parentAgentId,
|
||||||
labels: Object.freeze({ ...agent.labels }),
|
labels: Object.freeze({ ...agent.labels }),
|
||||||
|
metadata: cloneAndFreeze(agent.metadata ?? {}),
|
||||||
});
|
});
|
||||||
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
|
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
|
||||||
byWorkspace.set(workspaceId, snapshot);
|
byWorkspace.set(workspaceId, snapshot);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const agent: PluginAgentSnapshot = {
|
|||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
parentAgentId: null,
|
parentAgentId: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
|
metadata: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const inspect = defineRpc({
|
const inspect = defineRpc({
|
||||||
@@ -82,6 +83,8 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
|||||||
sidebarItems: { header: [], footer: [] },
|
sidebarItems: { header: [], footer: [] },
|
||||||
legacySidebarItems: [],
|
legacySidebarItems: [],
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [
|
workspacePanels: [
|
||||||
{
|
{
|
||||||
id: "details",
|
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");
|
).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", () => {
|
it("runs the client entry with the full runtime context", () => {
|
||||||
const plugin = evaluatePluginClientBundle(
|
const plugin = evaluatePluginClientBundle(
|
||||||
"review",
|
"review",
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export function runPluginClientBundle(
|
|||||||
sidebarItems: { header: [], footer: [] },
|
sidebarItems: { header: [], footer: [] },
|
||||||
legacySidebarItems: [],
|
legacySidebarItems: [],
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSlashCommands: [],
|
||||||
@@ -146,6 +148,8 @@ export function runPluginClientBundle(
|
|||||||
footer: new Set(),
|
footer: new Set(),
|
||||||
};
|
};
|
||||||
const filePanelIds = new Set<string>();
|
const filePanelIds = new Set<string>();
|
||||||
|
const composerItemIds = new Set<string>();
|
||||||
|
const newWorkspaceItemIds = new Set<string>();
|
||||||
const workspacePanelIds = new Set<string>();
|
const workspacePanelIds = new Set<string>();
|
||||||
const commandCenterItemIds = new Set<string>();
|
const commandCenterItemIds = new Set<string>();
|
||||||
const clientSlashCommandNames = new Set<string>();
|
const clientSlashCommandNames = new Set<string>();
|
||||||
@@ -269,6 +273,34 @@ export function runPluginClientBundle(
|
|||||||
() => surfaceIds.delete(normalizedId),
|
() => 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) {
|
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
|
||||||
return addSidebarItem("header", contribution);
|
return addSidebarItem("header", contribution);
|
||||||
},
|
},
|
||||||
@@ -585,6 +617,8 @@ export function runPluginClientBundle(
|
|||||||
sidebarItems: collector.sidebarItems,
|
sidebarItems: collector.sidebarItems,
|
||||||
legacySidebarItems: collector.legacySidebarItems,
|
legacySidebarItems: collector.legacySidebarItems,
|
||||||
filePanels: collector.filePanels,
|
filePanels: collector.filePanels,
|
||||||
|
composerItems: collector.composerItems,
|
||||||
|
newWorkspaceItems: collector.newWorkspaceItems,
|
||||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||||
commandCenterItems: collector.commandCenterItems,
|
commandCenterItems: collector.commandCenterItems,
|
||||||
clientSlashCommands: collector.clientSlashCommands,
|
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: [] },
|
sidebarItems: { header: [], footer: [] },
|
||||||
legacySidebarItems: [],
|
legacySidebarItems: [],
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
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 { SettingsGroup } from "@/components/settings";
|
||||||
|
|
||||||
export { Icon } from "../icons";
|
export { Icon } from "../icons";
|
||||||
|
export { Action, Combobox, Select, Toggle } from "./composer-controls";
|
||||||
export { Modal } from "./modal";
|
export { Modal } from "./modal";
|
||||||
export { ScrollView, FlatList } from "./scroll-view";
|
export { ScrollView, FlatList } from "./scroll-view";
|
||||||
export { TextInput } from "./text-input";
|
export { TextInput } from "./text-input";
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export class PluginRegistry {
|
|||||||
sidebarItems: { header: [], footer: [] },
|
sidebarItems: { header: [], footer: [] },
|
||||||
legacySidebarItems: [],
|
legacySidebarItems: [],
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSlashCommands: [],
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ function installed(
|
|||||||
},
|
},
|
||||||
legacySidebarItems,
|
legacySidebarItems,
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSlashCommands: [],
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ function installation(
|
|||||||
icon: "Blocks",
|
icon: "Blocks",
|
||||||
})),
|
})),
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSlashCommands: [],
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
|||||||
sidebarItems: { header: [], footer: [] },
|
sidebarItems: { header: [], footer: [] },
|
||||||
legacySidebarItems: [],
|
legacySidebarItems: [],
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSlashCommands: [],
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function plugin(input: {
|
|||||||
sidebarItems: { header: [], footer: [] },
|
sidebarItems: { header: [], footer: [] },
|
||||||
legacySidebarItems: [],
|
legacySidebarItems: [],
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSlashCommands: [],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types";
|
import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||||
import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream";
|
import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream";
|
||||||
|
import { cloneAndFreeze } from "../freeze";
|
||||||
import type { TimelineItemTransform } from "./model";
|
import type { TimelineItemTransform } from "./model";
|
||||||
|
|
||||||
type AssistantPhase = "streaming" | "complete";
|
type AssistantPhase = "streaming" | "complete";
|
||||||
@@ -9,18 +10,6 @@ const projectionCache = new WeakMap<
|
|||||||
WeakMap<StreamItem, Partial<Record<AssistantPhase, StreamItem[]>>>
|
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 {
|
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||||
const { callId, name, status, error, detail, metadata } = data;
|
const { callId, name, status, error, detail, metadata } = data;
|
||||||
const base = {
|
const base = {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
PluginFilePanelContribution as SDKPluginFilePanelContribution,
|
PluginFilePanelContribution as SDKPluginFilePanelContribution,
|
||||||
PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution,
|
PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution,
|
||||||
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
|
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
|
||||||
|
PluginComposerItemContribution,
|
||||||
|
PluginNewWorkspaceItemContribution,
|
||||||
PluginComposerPillContribution as SDKPluginComposerPillContribution,
|
PluginComposerPillContribution as SDKPluginComposerPillContribution,
|
||||||
PluginSidebarContribution as SDKPluginSidebarContribution,
|
PluginSidebarContribution as SDKPluginSidebarContribution,
|
||||||
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
||||||
@@ -53,6 +55,8 @@ export interface EvaluatedPlugin {
|
|||||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||||
legacySidebarItems: PluginSidebarContribution[];
|
legacySidebarItems: PluginSidebarContribution[];
|
||||||
filePanels: PluginFilePanelContribution[];
|
filePanels: PluginFilePanelContribution[];
|
||||||
|
composerItems: PluginComposerItemContribution[];
|
||||||
|
newWorkspaceItems: PluginNewWorkspaceItemContribution[];
|
||||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||||
clientSlashCommands: PluginClientSlashCommandContribution[];
|
clientSlashCommands: PluginClientSlashCommandContribution[];
|
||||||
@@ -82,6 +86,8 @@ export type {
|
|||||||
PluginAttachmentSourceContribution,
|
PluginAttachmentSourceContribution,
|
||||||
PluginCommandCenterItemContribution,
|
PluginCommandCenterItemContribution,
|
||||||
PluginClientSlashCommandContribution,
|
PluginClientSlashCommandContribution,
|
||||||
|
PluginComposerItemContribution,
|
||||||
|
PluginNewWorkspaceItemContribution,
|
||||||
PluginComposerPillContribution,
|
PluginComposerPillContribution,
|
||||||
PluginSidebarContribution,
|
PluginSidebarContribution,
|
||||||
PluginSidebarItemContribution,
|
PluginSidebarItemContribution,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ function installed(): InstalledPlugin {
|
|||||||
sidebarItems: { header: [], footer: [] },
|
sidebarItems: { header: [], footer: [] },
|
||||||
legacySidebarItems: [],
|
legacySidebarItems: [],
|
||||||
filePanels: [],
|
filePanels: [],
|
||||||
|
composerItems: [],
|
||||||
|
newWorkspaceItems: [],
|
||||||
workspacePanels: [
|
workspacePanels: [
|
||||||
{
|
{
|
||||||
id: "details",
|
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,
|
resolveAgentForm,
|
||||||
resolveFormState,
|
resolveFormState,
|
||||||
resolveEffectiveModel,
|
resolveEffectiveModel,
|
||||||
|
isUnlistedModel,
|
||||||
resolveThinkingOptionId,
|
resolveThinkingOptionId,
|
||||||
mergeSelectedComposerPreferences,
|
mergeSelectedComposerPreferences,
|
||||||
buildProviderDefinitionMap,
|
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", () => {
|
describe("model aliases", () => {
|
||||||
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
|
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
|
||||||
const resolved = resolveFormState(
|
const resolved = resolveFormState(
|
||||||
|
|||||||
@@ -153,6 +153,19 @@ export function resolveEffectiveModel(
|
|||||||
return findModelByReference(availableModels, modelId) ?? null;
|
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: {
|
function resolvePreferredThinkingOptionId(input: {
|
||||||
availableModels: AgentModelDefinition[] | null;
|
availableModels: AgentModelDefinition[] | null;
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
buildPickerOptionData,
|
buildPickerOptionData,
|
||||||
defaultBasePickerItem,
|
defaultBasePickerItem,
|
||||||
pickerItemToCheckoutRequest,
|
pickerItemToCheckoutRequest,
|
||||||
|
startsNewBranch,
|
||||||
type PickerItem,
|
type PickerItem,
|
||||||
} from "./new-workspace-picker-item";
|
} 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
|
// 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".
|
// 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("refs/heads/")) return refName.slice("refs/heads/".length);
|
||||||
if (refName.startsWith(REMOTE_TRACKING_PREFIX)) {
|
if (refName.startsWith(REMOTE_TRACKING_PREFIX)) {
|
||||||
const remainder = refName.slice(REMOTE_TRACKING_PREFIX.length);
|
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 {
|
export function prPickerOptionId(number: number): string {
|
||||||
return `${PR_OPTION_PREFIX}${number}`;
|
return `${PR_OPTION_PREFIX}${number}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
|
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
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 type { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
@@ -79,7 +79,7 @@ import type { MessagePayload } from "@/composer/types";
|
|||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
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 type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||||
import {
|
import {
|
||||||
@@ -91,6 +91,7 @@ import {
|
|||||||
defaultBasePickerItem,
|
defaultBasePickerItem,
|
||||||
pickerItemLabel,
|
pickerItemLabel,
|
||||||
pickerItemToCheckoutRequest,
|
pickerItemToCheckoutRequest,
|
||||||
|
startsNewBranch,
|
||||||
type BranchPickerDetail,
|
type BranchPickerDetail,
|
||||||
type PickerCheckoutRequest,
|
type PickerCheckoutRequest,
|
||||||
type PickerItem,
|
type PickerItem,
|
||||||
@@ -98,10 +99,17 @@ import {
|
|||||||
} from "./new-workspace-picker-item";
|
} from "./new-workspace-picker-item";
|
||||||
import {
|
import {
|
||||||
clearPickerPrAttachmentForTargetChange,
|
clearPickerPrAttachmentForTargetChange,
|
||||||
initialPickerSelectionState,
|
|
||||||
reducePickerSelection,
|
|
||||||
syncPickerPrAttachment,
|
syncPickerPrAttachment,
|
||||||
} from "./new-workspace-picker-state";
|
} 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 {
|
import {
|
||||||
resolveNewWorkspaceAutomaticServerId,
|
resolveNewWorkspaceAutomaticServerId,
|
||||||
resolveNewWorkspaceInitialServerId,
|
resolveNewWorkspaceInitialServerId,
|
||||||
@@ -193,30 +201,22 @@ interface WorkspaceIsolationState {
|
|||||||
// Preserve the user's worktree choice while route metadata is provisional. Once
|
// Preserve the user's worktree choice while route metadata is provisional. Once
|
||||||
// the authoritative placement arrives, unsupported projects fall back to local.
|
// the authoritative placement arrives, unsupported projects fall back to local.
|
||||||
function useWorkspaceIsolation(input: {
|
function useWorkspaceIsolation(input: {
|
||||||
|
setup: NewWorkspaceSetup;
|
||||||
supportsMultiplicity: boolean;
|
supportsMultiplicity: boolean;
|
||||||
worktreeSupport: "supported" | "unsupported" | "unknown";
|
canCreateWorktree: boolean;
|
||||||
}): WorkspaceIsolationState {
|
}): WorkspaceIsolationState {
|
||||||
const { supportsMultiplicity, worktreeSupport } = input;
|
const { setup, supportsMultiplicity, canCreateWorktree } = input;
|
||||||
// The last isolation choice is remembered alongside the other New Workspace
|
// The last isolation choice is remembered alongside the other New Workspace
|
||||||
// form preferences (provider, model, mode). A manual in-screen pick overrides
|
// form preferences (provider, model, mode). A manual in-screen pick overrides
|
||||||
// the remembered default until the screen remounts.
|
// the remembered default until the screen remounts.
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
const { preferences } = useFormPreferences();
|
||||||
const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null);
|
const { manualIsolation } = useNewWorkspaceSetupState(setup);
|
||||||
const isolation = manualIsolation ?? preferences.isolation ?? "local";
|
const isolation = manualIsolation ?? preferences.isolation ?? "local";
|
||||||
const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported";
|
|
||||||
const isWorktree = isolation === "worktree" && canCreateWorktree;
|
const isWorktree = isolation === "worktree" && canCreateWorktree;
|
||||||
|
|
||||||
const setIsolation = useCallback(
|
|
||||||
(value: "local" | "worktree") => {
|
|
||||||
setManualIsolation(value);
|
|
||||||
void updatePreferences({ isolation: value });
|
|
||||||
},
|
|
||||||
[updatePreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isolation,
|
isolation,
|
||||||
setIsolation,
|
setIsolation: setup.chooseIsolation,
|
||||||
effectiveIsolation: isWorktree ? "worktree" : "local",
|
effectiveIsolation: isWorktree ? "worktree" : "local",
|
||||||
canCreateWorktree,
|
canCreateWorktree,
|
||||||
showRefPicker: !supportsMultiplicity || isWorktree,
|
showRefPicker: !supportsMultiplicity || isWorktree,
|
||||||
@@ -288,6 +288,10 @@ async function createMultiplicityWorkspace(input: {
|
|||||||
project: HostProjectListItem;
|
project: HostProjectListItem;
|
||||||
sourceDirectory: string;
|
sourceDirectory: string;
|
||||||
checkoutRequest: PickerCheckoutRequest | undefined;
|
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;
|
withInitialAgent: boolean;
|
||||||
agent?: CreateWorkspaceRequestOptions["agent"];
|
agent?: CreateWorkspaceRequestOptions["agent"];
|
||||||
onEvent?: (snapshot: CreationSnapshot) => void;
|
onEvent?: (snapshot: CreationSnapshot) => void;
|
||||||
@@ -318,6 +322,9 @@ async function createMultiplicityWorkspace(input: {
|
|||||||
projectId,
|
projectId,
|
||||||
worktreeSlug: input.worktreeSlug,
|
worktreeSlug: input.worktreeSlug,
|
||||||
...input.checkoutRequest,
|
...input.checkoutRequest,
|
||||||
|
...(input.branchName && startsNewBranch(input.checkoutRequest)
|
||||||
|
? { branchName: input.branchName }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
@@ -325,6 +332,7 @@ async function createMultiplicityWorkspace(input: {
|
|||||||
projectId,
|
projectId,
|
||||||
},
|
},
|
||||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||||
|
...(Object.keys(input.metadata).length > 0 ? { metadata: { ...input.metadata } } : {}),
|
||||||
});
|
});
|
||||||
if (payload.error || !payload.workspace) {
|
if (payload.error || !payload.workspace) {
|
||||||
throw new Error(payload.error ?? input.createFailedMessage);
|
throw new Error(payload.error ?? input.createFailedMessage);
|
||||||
@@ -456,6 +464,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
|||||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||||
providerOptions: composerState.providerOptions,
|
providerOptions: composerState.providerOptions,
|
||||||
},
|
},
|
||||||
|
...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}),
|
||||||
initialPrompt: text,
|
initialPrompt: text,
|
||||||
clientMessageId: `${input.draftId}:initial-message`,
|
clientMessageId: `${input.draftId}:initial-message`,
|
||||||
images: images?.length ? images : undefined,
|
images: images?.length ? images : undefined,
|
||||||
@@ -535,6 +544,7 @@ function buildComposerConfig(input: {
|
|||||||
initialServerId: serverId || null,
|
initialServerId: serverId || null,
|
||||||
initialValues: buildComposerInitialValues({ initialSetup }),
|
initialValues: buildComposerInitialValues({ initialSetup }),
|
||||||
initialProviderOptions: initialSetup?.providerOptions,
|
initialProviderOptions: initialSetup?.providerOptions,
|
||||||
|
context: "new-workspace" as const,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
lockedWorkingDir: workingDir,
|
lockedWorkingDir: workingDir,
|
||||||
};
|
};
|
||||||
@@ -1000,22 +1010,31 @@ export function NewWorkspaceScreen({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const composerState = chatDraft.composerState;
|
const composerState = chatDraft.composerState;
|
||||||
const [pickerSelection, dispatchPickerSelection] = useReducer(
|
const { setup, pluginProject, canCreateWorktree, isPending } = usePluginSetup({
|
||||||
reducePickerSelection,
|
serverId: selectedServerId,
|
||||||
initialPickerSelectionState,
|
project: selectedProject,
|
||||||
);
|
supportsMultiplicity: supportsWorkspaceMultiplicity,
|
||||||
const selectedItem = pickerSelection.selectedItem;
|
pendingAction,
|
||||||
|
isDraftHandoffActive,
|
||||||
|
updatePreferences: updateFormPreferences,
|
||||||
|
setAttachments: chatDraft.setAttachments,
|
||||||
|
});
|
||||||
|
const dispatchPickerSelection = setup.dispatchPicker;
|
||||||
|
const selectedItem = useNewWorkspaceSetupState(setup).pickerSelection.selectedItem;
|
||||||
|
|
||||||
const handleForgeChangeRequestDetected = useCallback(() => {
|
const handleForgeChangeRequestDetected = useCallback(() => {
|
||||||
dispatchPickerSelection({ type: "pr-detected" });
|
dispatchPickerSelection({ type: "pr-detected" });
|
||||||
}, []);
|
}, [dispatchPickerSelection]);
|
||||||
|
|
||||||
const handleForgeChangeRequestAutoAttach = useCallback((item: ForgeSearchItem) => {
|
const handleForgeChangeRequestAutoAttach = useCallback(
|
||||||
dispatchPickerSelection({
|
(item: ForgeSearchItem) => {
|
||||||
type: "pr-added",
|
dispatchPickerSelection({
|
||||||
item: { kind: "github-pr", item },
|
type: "pr-added",
|
||||||
});
|
item: { kind: "github-pr", item },
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[dispatchPickerSelection],
|
||||||
|
);
|
||||||
|
|
||||||
const withConnectedClient = useCallback(() => {
|
const withConnectedClient = useCallback(() => {
|
||||||
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
|
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
|
||||||
@@ -1034,15 +1053,11 @@ export function NewWorkspaceScreen({
|
|||||||
cwd: selectedSourceDirectory ?? "",
|
cwd: selectedSourceDirectory ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktreeSupport = selectedProject
|
const { effectiveIsolation, setIsolation, showRefPicker } = useWorkspaceIsolation({
|
||||||
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
|
setup,
|
||||||
: "unsupported";
|
supportsMultiplicity: supportsWorkspaceMultiplicity,
|
||||||
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
|
canCreateWorktree,
|
||||||
const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
|
});
|
||||||
useWorkspaceIsolation({
|
|
||||||
supportsMultiplicity: supportsWorkspaceMultiplicity,
|
|
||||||
worktreeSupport,
|
|
||||||
});
|
|
||||||
|
|
||||||
const branchSuggestionsQuery = useQuery({
|
const branchSuggestionsQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
@@ -1106,16 +1121,15 @@ export function NewWorkspaceScreen({
|
|||||||
}, [itemById, selectedOptionId]);
|
}, [itemById, selectedOptionId]);
|
||||||
const selectPickerItem = useCallback(
|
const selectPickerItem = useCallback(
|
||||||
(item: PickerItem) => {
|
(item: PickerItem) => {
|
||||||
const nextAttachments = syncPickerPrAttachment({
|
setup.chooseBase(item);
|
||||||
attachments: chatDraft.attachments,
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatchPickerSelection({ type: "picker-selected", item });
|
|
||||||
chatDraft.setAttachments(nextAttachments);
|
|
||||||
setPickerOpen(false);
|
setPickerOpen(false);
|
||||||
},
|
},
|
||||||
[chatDraft],
|
[setup],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pluginScreen = useMemo<NewWorkspaceItemsScreen>(
|
||||||
|
() => ({ project: pluginProject, isolation: effectiveIsolation, baseItem, locked: isPending }),
|
||||||
|
[baseItem, effectiveIsolation, isPending, pluginProject],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectOption = useCallback(
|
const handleSelectOption = useCallback(
|
||||||
@@ -1138,7 +1152,7 @@ export function NewWorkspaceScreen({
|
|||||||
chatDraft.setAttachments(nextAttachments);
|
chatDraft.setAttachments(nextAttachments);
|
||||||
dispatchPickerSelection({ type: "target-changed" });
|
dispatchPickerSelection({ type: "target-changed" });
|
||||||
},
|
},
|
||||||
[chatDraft],
|
[chatDraft, dispatchPickerSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectProjectOption = useCallback(
|
const handleSelectProjectOption = useCallback(
|
||||||
@@ -1258,6 +1272,8 @@ export function NewWorkspaceScreen({
|
|||||||
project: selectedProject,
|
project: selectedProject,
|
||||||
sourceDirectory: selectedSourceDirectory,
|
sourceDirectory: selectedSourceDirectory,
|
||||||
checkoutRequest,
|
checkoutRequest,
|
||||||
|
branchName: setup.getSnapshot().branch,
|
||||||
|
metadata: setup.getSnapshot().metadata,
|
||||||
withInitialAgent: input.withInitialAgent,
|
withInitialAgent: input.withInitialAgent,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
attachments: input.attachments,
|
attachments: input.attachments,
|
||||||
@@ -1280,6 +1296,7 @@ export function NewWorkspaceScreen({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
selectedSourceDirectory,
|
selectedSourceDirectory,
|
||||||
|
setup,
|
||||||
supportsWorkspaceMultiplicity,
|
supportsWorkspaceMultiplicity,
|
||||||
t,
|
t,
|
||||||
withConnectedClient,
|
withConnectedClient,
|
||||||
@@ -1460,6 +1477,9 @@ export function NewWorkspaceScreen({
|
|||||||
const formStack = useNewWorkspaceForm({
|
const formStack = useNewWorkspaceForm({
|
||||||
isCompact,
|
isCompact,
|
||||||
isPending,
|
isPending,
|
||||||
|
pluginItems: (
|
||||||
|
<PluginNewWorkspaceItems serverId={selectedServerId} screen={pluginScreen} setup={setup} />
|
||||||
|
),
|
||||||
project: {
|
project: {
|
||||||
anchorRef: projectPickerAnchorRef,
|
anchorRef: projectPickerAnchorRef,
|
||||||
open: openProjectPicker,
|
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. */
|
/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */
|
||||||
function useNewWorkspaceComposerEditing() {
|
function useNewWorkspaceComposerEditing() {
|
||||||
const terminal = useComposerEditingSession("terminal");
|
const terminal = useComposerEditingSession("terminal");
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ interface PickerControl {
|
|||||||
export interface NewWorkspaceFormProps {
|
export interface NewWorkspaceFormProps {
|
||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
/** Plugin fields, drawn after the base ref. */
|
||||||
|
pluginItems: ReactNode;
|
||||||
project: PickerControl & {
|
project: PickerControl & {
|
||||||
options: ComboboxOption[];
|
options: ComboboxOption[];
|
||||||
projectByOptionId: Map<string, HostProjectListItem>;
|
projectByOptionId: Map<string, HostProjectListItem>;
|
||||||
@@ -165,7 +167,7 @@ function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolea
|
|||||||
*/
|
*/
|
||||||
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
|
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
|
||||||
const { t } = useTranslation();
|
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 variant: FieldTriggerVariant = isCompact ? "field" : "badge";
|
||||||
const projectPicker = useProjectPicker(project, isPending);
|
const projectPicker = useProjectPicker(project, isPending);
|
||||||
const isolationPicker = useIsolationPicker(isolation, isPending);
|
const isolationPicker = useIsolationPicker(isolation, isPending);
|
||||||
@@ -337,6 +339,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
|
|||||||
{hostField}
|
{hostField}
|
||||||
{isolationField}
|
{isolationField}
|
||||||
{baseField}
|
{baseField}
|
||||||
|
{pluginItems}
|
||||||
{launchField}
|
{launchField}
|
||||||
{/* Isolation and Base come and go with the project; their rows stay reserved so the
|
{/* 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. */}
|
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>
|
<View style={styles.desktopControl}>{hostField}</View>
|
||||||
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
|
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
|
||||||
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
|
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
|
||||||
|
{pluginItems}
|
||||||
<View style={styles.launchSpacer} pointerEvents="none" />
|
<View style={styles.launchSpacer} pointerEvents="none" />
|
||||||
{launchField}
|
{launchField}
|
||||||
{pickers}
|
{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";
|
} from "./migration";
|
||||||
import { createDraftPersistStorage } from "./persistence";
|
import { createDraftPersistStorage } from "./persistence";
|
||||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
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";
|
export type { DraftInput, DraftLifecycleState } from "./state";
|
||||||
|
|
||||||
@@ -49,6 +51,9 @@ interface DraftStoreActions {
|
|||||||
draftKey: string;
|
draftKey: string;
|
||||||
lifecycle?: Exclude<DraftLifecycleState, "active">;
|
lifecycle?: Exclude<DraftLifecycleState, "active">;
|
||||||
}) => void;
|
}) => 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: {
|
attachWorkspaceFile: (input: {
|
||||||
draftKey: string;
|
draftKey: string;
|
||||||
attachment: WorkspaceFileComposerAttachment;
|
attachment: WorkspaceFileComposerAttachment;
|
||||||
@@ -60,6 +65,11 @@ interface DraftStoreActions {
|
|||||||
|
|
||||||
interface DraftStoreRuntimeState {
|
interface DraftStoreRuntimeState {
|
||||||
attachmentFocusRequestByDraftKey: Record<string, number>;
|
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;
|
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
|
||||||
@@ -256,6 +266,7 @@ export const useDraftStore = create<DraftStore>()(
|
|||||||
drafts: {},
|
drafts: {},
|
||||||
createModalDraft: null,
|
createModalDraft: null,
|
||||||
attachmentFocusRequestByDraftKey: {},
|
attachmentFocusRequestByDraftKey: {},
|
||||||
|
metadataByDraftKey: {},
|
||||||
|
|
||||||
getDraftInput: (draftKey) => {
|
getDraftInput: (draftKey) => {
|
||||||
const record = get().drafts[draftKey];
|
const record = get().drafts[draftKey];
|
||||||
@@ -374,6 +385,25 @@ export const useDraftStore = create<DraftStore>()(
|
|||||||
scheduleAttachmentGc();
|
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 }) => {
|
attachWorkspaceFile: async ({ draftKey, attachment }) => {
|
||||||
await get().hydrateDraftInput({ draftKey });
|
await get().hydrateDraftInput({ draftKey });
|
||||||
set((state) => {
|
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 {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentOptionControl,
|
AgentOptionControl,
|
||||||
|
JsonValue,
|
||||||
ProviderOptions,
|
ProviderOptions,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
@@ -101,6 +102,7 @@ export interface Agent {
|
|||||||
archivedAt?: Date | null;
|
archivedAt?: Date | null;
|
||||||
parentAgentId: string | null;
|
parentAgentId: string | null;
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
|
metadata?: Record<string, JsonValue>;
|
||||||
projectPlacement?: ProjectPlacementPayload | null;
|
projectPlacement?: ProjectPlacementPayload | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
|
|||||||
...(agent.lastError ? { lastError: agent.lastError } : {}),
|
...(agent.lastError ? { lastError: agent.lastError } : {}),
|
||||||
title: agent.title,
|
title: agent.title,
|
||||||
labels: agent.labels,
|
labels: agent.labels,
|
||||||
|
...(agent.metadata ? { metadata: agent.metadata } : {}),
|
||||||
requiresAttention: agent.requiresAttention ?? false,
|
requiresAttention: agent.requiresAttention ?? false,
|
||||||
attentionReason: agent.attentionReason ?? null,
|
attentionReason: agent.attentionReason ?? null,
|
||||||
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
||||||
@@ -137,5 +138,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
|||||||
archivedAt,
|
archivedAt,
|
||||||
parentAgentId,
|
parentAgentId,
|
||||||
labels: snapshot.labels,
|
labels: snapshot.labels,
|
||||||
|
metadata: snapshot.metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,3 +116,8 @@ export const Maximize2 = StubIcon;
|
|||||||
export const Workflow = StubIcon;
|
export const Workflow = StubIcon;
|
||||||
export const ZoomIn = StubIcon;
|
export const ZoomIn = StubIcon;
|
||||||
export const ZoomOut = 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",
|
borderAccent: "#a1a1aa",
|
||||||
palette: {
|
palette: {
|
||||||
amber: { 500: "#f59e0b" },
|
amber: { 500: "#f59e0b" },
|
||||||
blue: { 300: "#93c5fd" },
|
blue: { 300: "#93c5fd", 400: "#60a5fa" },
|
||||||
green: { 500: "#22c55e" },
|
green: { 400: "#4ade80", 500: "#22c55e" },
|
||||||
|
yellow: { 400: "#facc15" },
|
||||||
|
zinc: { 600: "#52525b" },
|
||||||
red: { 300: "#fca5a5" },
|
red: { 300: "#fca5a5" },
|
||||||
white: "#ffffff",
|
white: "#ffffff",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -236,6 +236,14 @@ export default defineConfig({
|
|||||||
find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/,
|
find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/,
|
||||||
replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"),
|
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
|
// 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
|
// react alias below (the CJS build uses require('react') which bypasses
|
||||||
// Vite alias resolution).
|
// 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", () => {
|
describe("plugin agent tools SDK boundary", () => {
|
||||||
it("lets a session hook attach typed tools without exposing their transport", () => {
|
it("lets a session hook attach typed tools without exposing their transport", () => {
|
||||||
expect(
|
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 { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||||
import type { PluginFile } from "./file.js";
|
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 { PluginRpcContract } from "../rpc.js";
|
||||||
import type {
|
import type {
|
||||||
PluginButtonRegistration,
|
PluginButtonRegistration,
|
||||||
@@ -194,6 +196,8 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
|
|||||||
contribution: PluginComposerPillContribution<PluginText>,
|
contribution: PluginComposerPillContribution<PluginText>,
|
||||||
): PluginButtonRegistration<PluginText>;
|
): PluginButtonRegistration<PluginText>;
|
||||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
|
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
|
||||||
|
addComposerItem(contribution: PluginComposerItemContribution): PluginCleanup;
|
||||||
|
addNewWorkspaceItem(contribution: PluginNewWorkspaceItemContribution): PluginCleanup;
|
||||||
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
|
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
|
||||||
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
|
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
|
||||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { PluginText } from "../text.js";
|
import type { PluginText } from "../text.js";
|
||||||
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
|
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
|
||||||
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
|
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
|
||||||
|
export { PluginComposerProvider } from "./composer.js";
|
||||||
|
export { PluginNewWorkspaceProvider } from "./new-workspace.js";
|
||||||
export {
|
export {
|
||||||
usePluginRuntimeContextBridge,
|
usePluginRuntimeContextBridge,
|
||||||
type PluginRuntimeContextBridge,
|
type PluginRuntimeContextBridge,
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ export {
|
|||||||
} from "./file.js";
|
} from "./file.js";
|
||||||
export { usePaseo } from "./paseo-context.js";
|
export { usePaseo } from "./paseo-context.js";
|
||||||
export { useAgent, useWorkspace } from "./client-state.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";
|
export { useRpc } from "./rpc-context.js";
|
||||||
import type { SettingsDefinition } from "../settings.js";
|
import type { SettingsDefinition } from "../settings.js";
|
||||||
import type { SettingsState } from "./contracts.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 { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
|
||||||
import { useCallback, type ReactNode } from "react";
|
import { useCallback, type ReactNode } from "react";
|
||||||
|
import { PluginComposerProvider, usePluginComposerValue } from "./composer.js";
|
||||||
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
|
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
|
||||||
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
|
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
|
||||||
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
|
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
|
||||||
@@ -12,6 +13,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
|||||||
const paseo = usePaseoContextValue();
|
const paseo = usePaseoContextValue();
|
||||||
const rpc = usePluginRpcContextValue();
|
const rpc = usePluginRpcContextValue();
|
||||||
const state = usePluginClientStateSource();
|
const state = usePluginClientStateSource();
|
||||||
|
const composer = usePluginComposerValue();
|
||||||
|
|
||||||
if (!paseo || !rpc) {
|
if (!paseo || !rpc) {
|
||||||
throw new Error("Plugin UI must run inside a contributed plugin surface");
|
throw new Error("Plugin UI must run inside a contributed plugin surface");
|
||||||
@@ -19,11 +21,16 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
|||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(children: ReactNode) => {
|
(children: ReactNode) => {
|
||||||
const content = state ? (
|
const withState = state ? (
|
||||||
<PluginClientStateProvider source={state}>{children}</PluginClientStateProvider>
|
<PluginClientStateProvider source={state}>{children}</PluginClientStateProvider>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
);
|
);
|
||||||
|
const content = composer ? (
|
||||||
|
<PluginComposerProvider composer={composer}>{withState}</PluginComposerProvider>
|
||||||
|
) : (
|
||||||
|
withState
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<PaseoApiProvider paseo={paseo}>
|
<PaseoApiProvider paseo={paseo}>
|
||||||
<PluginRpcProvider invoke={rpc.invoke}>
|
<PluginRpcProvider invoke={rpc.invoke}>
|
||||||
@@ -32,6 +39,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
|||||||
</PaseoApiProvider>
|
</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. */
|
/** Renders Markdown using Paseo’s theme, selection, and link behavior. */
|
||||||
export declare const Markdown: ComponentType<MarkdownProps>;
|
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 attentionReason: "finished" | "error" | "permission" | null;
|
||||||
readonly parentAgentId: string | null;
|
readonly parentAgentId: string | null;
|
||||||
readonly labels: Readonly<Record<string, string>>;
|
readonly labels: Readonly<Record<string, string>>;
|
||||||
|
/** Structured data plugins store on the agent. */
|
||||||
|
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginThemeColors {
|
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