diff --git a/docs/data-model.md b/docs/data-model.md index 38c13b63df..518d3e03d1 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -96,6 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory. | `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | | `title` | `string?` | User-visible title | | `labels` | `Record` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) | +| `metadata` | `Record` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `agent.metadata.update.request`; `null` in a patch removes a key | | `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. | | `lastModeId` | `string?` | Last active mode ID | | `config` | `SerializableConfig?` | Agent session configuration (see below) | @@ -482,6 +483,7 @@ Array of workspace records. A workspace is a specific working directory within a | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | | `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. | | `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. | +| `metadata` | `Record` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `workspace.metadata.update.request`. | | `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | | `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. | diff --git a/docs/plugins.md b/docs/plugins.md index 2da369f4a5..3fa61d1457 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -474,6 +474,10 @@ path shape: a plugin can place a worktree several directories below the default Emit from the operation owner, not a client subscription. Provider history replay must not trigger live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt or answer a permission through its own daemon session. Awaiting it there deadlocks that command. +The same holds for a hook that runs inside an agent's lifecycle lane: an `agent.open.before` or +`agent.create.before` hook that awaits a write to the agent being opened (`setMetadata`, `setLabels`, +an option, model, thinking or mode change) waits on the open, which waits on the hook. The hooks +reference tells plugin authors to start such a write without awaiting it. ## Contribute a provider @@ -529,13 +533,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh `send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state, configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`. Route messages, structured commands, steering, and command side effects through `session.prompt`. -Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in -the opaque `ProviderSessionConfig.providerOptions` object on `session.open`. -It contains the provider defaults and per-agent overrides merged by the daemon. +A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on +`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon. Validate and apply it inside the provider; core does not know your option shape. See [provider options](custom-providers.md#provider-options) for configuration and merge semantics. +The provider draws some of those keys in the composer by reporting `controls` and the values it runs +(`providerOptions`) in `session.config`. A running-agent change arrives as +`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as +built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in +`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting +`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and +its value into an option and is sent both shapes in `session.configure`; delete it with +`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`. + Providers add an optional veto to daemon baseline cleanup through the per-session [runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup). The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins; @@ -661,6 +673,131 @@ Mounted surfaces and command invocations have shorter API lifetimes. Keep the client entry synchronous: return its cleanup function immediately and start asynchronous work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx). +## Contribute composer items + +`addComposerItem` mounts a plugin component in the agent, draft and New workspace composers. The +public contract is the [composer items reference](../public-docs/plugins/reference.md#composer-items). + +A plugin reads one `ComposerModel` (`packages/app/src/composer/model.ts`): state, subscribe and +commands. `toPluginComposer` (`packages/app/src/plugins/composer-items/`) turns its state into the +`PluginComposer` an item reads, and the host never learns which kind of composer it got. Two +implementations provide it: + +- A running agent's `agentComposer(serverId, agentId)` (`composer/agent-composer/`). It exists + without a mounted tab: the prompt and attachments are the agent's draft in the draft store, the + settings are the session store's agent, and `model` and `thinking` resolve through the provider + catalog with `resolveAgentModelSelection`, the picker's own function, so a read equals what the + picker shows. The model loads the catalog itself and resolves with no models until it lands, as the picker does, + so a read never differs from what the picker shows. While anything watches the model it holds a + `QueryObserver` on the agent's catalog: that is what lets reconnect repair, which refetches only + active queries, refresh a catalog nobody has a picker open for, and retry one that failed. + `PluginComposersSource.subscribe(selector, listener)` is what holds it for `useComposers`, so + the subscription must go through the model's own `subscribe`. Watching again after the catalog + was invalidated or failed refetches it, and a read with no watcher starts one deduplicated + refresh and shows the cached catalog until it lands. The model keeps one pair of store + subscriptions however many components watch it. It is shared for as long as its agent is in the + session and not archived, so a command a plugin saved keeps reaching the composer a later tab + mounts; it is dropped when the agent is gone or archived and nothing watches it or has one + mounted, checked on session changes and when the last holder lets go. The mounted + `AgentControls` plugs its input in with `attachInput` so `insertText` lands at the cursor. + Without a live input the model writes the stored prompt with `replaceDraftText`, which also asks + a mounted input to show it. A workspace the user left stays mounted, display none. Web + drops the input ref there; native keeps the input, with a stale cursor and selection. + `ComposerInput.isLive()` is how the model tells: it needs the ref and a retained panel that is + showing (`useRetainedPanelActive`). Closing a root agent's tab archives + it, so a running agent with no mounted composer at all only exists as a subagent whose tab was + closed. The daemon does not check `setModel` and `setThinking` against a catalog; the provider + decides and its refusal rejects the call. +- A draft or New workspace screen's `composerModelFromHalves`, an adapter over the two contexts + in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments), + `DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock. + +`ComposerAgent` stays for the built-in controls. Add a capability to the `ComposerModel`, both +implementations and `toPluginComposer`; never branch on `context` in the host. + +`useComposers` and `composers()` reach an agent's model by reference (`plugins/composer-items/source.ts`): +`PluginInstallationProvider` provides the plugin's source to every surface, and +`createPluginCapabilities` puts `composers` on every command context. `{ focused: true }` is the +agent Command Center registration hands to agent items, published through `plugins/focused-agent.ts` +(null outside a workspace route). The source reads that module, not the navigation or layout +stores, so the plugin runtime does not import them. The remembered workspace selection is +navigation restore state and is not the focused agent. The query-cache subscriptions react to +`updated` events only: a `useQuery` in another component's render announces `added`, and a +subscriber that updates then triggers React's "update a component while rendering a different one" +warning. + +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. It passes a model id to the form +as the picker does, with no catalog check; for a running agent the provider decides. `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. + +The item host (`PluginNewWorkspaceItems`) keys the items by `(serverId, project id)`, so a change of +project or host remounts them. That is what keeps an item's own state and a `Combobox`'s cached +results from outliving their project: the `Combobox` cannot tell that an author's inline `search` +now means another project, because its identity changes every render. The store clears `branch` +and `metadata` on the same change in `syncInputs`, and a remounted item's mount effect sets them +again. + +An item's commands come from `setup.commandsFor({ serverId, projectId })`, bound to the target the +item was mounted for. Once `syncInputs` reports another target they reject with a "moved" error, so +a lookup that finishes after a project switch cannot write into the new project's choices. The +reset only clears what was already written; this check covers what is written later. Rejecting, not +ignoring, matches the lock: a command's promise resolves only when the change took effect. + +Known limit: an item that issues a command from `useLayoutEffect` on mount after a project or host +switch runs before the screen's `syncInputs` layout effect, so its commands (bound to the new +target) still see the previous target and reject as moved. This applies to a project switch as well as a host switch, since items +remount per `(serverId, project id)`. Passive effects are not affected. + +`ensureWorkspace` sends the store's `branch` as `source.branchName` whenever the worktree cuts a +new branch (`startsNewBranch`, which mirrors the daemon: anything but a checkout, including no base +at all on a detached HEAD), and its `metadata` on `workspace.create` when it is not empty. The +server uses `branchName ?? worktreeSlug` as the new branch. `baseRef.name` is the exact ref +creation uses; `branchNameFromRef` is for labels only. + +Items render through `ComposerControl` with `surface="field"`: a `FieldTrigger` (a badge on +desktop, a full-width field when compact) and the same pending state, error toast and list as the +composer's controls. `ComposerControlLockProvider` carries the screen's lock to them. Plugin +`Combobox` is a `select` item with `remoteSearch`: the app `Combobox` does not filter locally, and +`PluginCombobox` (`plugins/react-native/composer-controls.tsx`) runs `search` through +`useFetchQuery` with `dataShape: "list"`, which keeps the previous results while a newer query runs. +`plugins/item-host.tsx` is the host shared by composer and New workspace items. + ## Contribute timeline items Timeline transformers and renderers are client contributions. The daemon's canonical rows and diff --git a/docs/providers.md b/docs/providers.md index 60fb75a785..fe3d58446a 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -30,6 +30,71 @@ at session startup. See [provider configuration](custom-providers.md#provider-op for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md) for native keys and examples. +### Options flow + +`options` on the agent snapshot is the only option state. Controls describe how to draw an +option and read `options`; they hold no value of their own. Any option can be read and set with +or without a control. + +- **Create and resume.** The provider opens with the options it is given and emits nothing about + them while opening. `openOptionsPatch` carries every option the session ended up with that + differs from what it was given, including a setting nobody asked for (a native default such as + Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the + daemon stores it before the agent is published. A reopened agent resumes that state, so a + native default the provider later changes does not follow. `options` on the snapshot is what + the session opened with, and afterwards it moves only through an apply or an `options_changed`. + Editing a provider default in `config.json` affects agents the next time they open. +- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the + `config.json` defaults. A removal arrives as the default the key falls back to, or `null` when + there is none; the provider returns the setting to its own default if it can, or throws to + reject. A key the running session cannot apply (one the provider reads only when it opens) is + rejected too, so a stored option always describes what runs. Claude reads most options when it + starts its query, and replacing a started query would end its background work, so after the + first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread + starts, so it applies only `service_tier` and `plan_mode` live. A session with no + `applyOptions` rejects every patch; the daemon enforces this. The provider returns the options + it ended up with when they differ from the request. The daemon stores the patch and that + result in one step after the call resolves. Throwing rejects the whole patch and none of it is + stored. Emit nothing from inside the call. If the provider's own state moved before it refused, + it reports the moved values once as `options_changed` after the call has ended, because the + stored options must match what runs. +- **One change at a time per agent.** `AgentManager` runs every option, model, thinking and mode + change inside the agent's lifecycle lane, the same queue that opens, reopens, closes and + archives it. `applyOptions`, `setModel`, `setThinkingOption` and `setMode` are never in flight + together on one session, and none of them overlaps an open or reopen. Providers do no + sequencing of their own: no queues, no "apply over the options as they are now" handling. A + change that arrives while another runs waits for it and then reads the agent's stored options + as that one left them. +- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes + `service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an + agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft + controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is + `"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter + behind the provider id, following `derivedFromProviderId`, so a custom provider with + `extends: "codex"` is converted too. Configured defaults convert in the provider registry. A + stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored + converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves + `"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the + `applyOptions` result like any other value. The `featureValues` to `options` fold is separate + and provider-agnostic; it stays at the protocol and legacy edges. +- **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the + agent snapshot for a running or closed agent. A draft and a profile form send everything they + would create with (seed, the provider's last-used control values once the provider has said it + still draws them, and the user's edits) in `provider.controls.list.request` and draw only what + comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once, + by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's + layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile + replaces provider and options in one step, while choosing a provider by hand starts over. A + profile saves its stored options plus the form's edits, so options no control draws survive an + edit. A daemon without `server_info.features.providerOptionControls` sends `features` and + `featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that + turns those into `controls` and `options` and back, so nothing in the app branches on it. A + create that carries options first lists the controls for its own provider, model, mode and + thinking level, since which options are controls differs by model, and sends those as + `featureValues`; callers do not prime anything. +- **Provider-initiated changes.** A setting the provider changes on its own is an + `options_changed` event. The daemon stores and forwards it and does not push the old value back. + Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail closed for Hub unattended execution until it can approve one exact injected MCP server and tool identity without approving native tools. @@ -115,7 +180,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading. -Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state. +Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state. It returns `{ controls, options }`: the options a session opened from the draft would run with, so a value the provider resolves natively (an ACP select nobody set) shows in the draft the same way it would on the agent. Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready. @@ -478,7 +543,7 @@ interface AgentSession { readonly provider: AgentProvider; readonly id: string | null; readonly capabilities: AgentCapabilityFlags; - readonly features?: AgentFeature[]; + readonly controls?: AgentOptionControl[]; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; @@ -500,7 +565,7 @@ interface AgentSession { listCommands?(): Promise; setModel?(modelId: string | null): Promise; setThinkingOption?(thinkingOptionId: string | null): Promise; - setFeature?(featureId: string, value: unknown): Promise; + applyOptions?(options: ProviderOptions): Promise; tryHandleOutOfBand?(prompt: AgentPromptInput): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise; } | null; diff --git a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts index a10afd2cca..97370bb7c8 100644 --- a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts +++ b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts @@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro const raw = localStorage.getItem(preferencesKey); if (!raw) return null; const preferences = JSON.parse(raw) as FormPreferences; - return preferences.providerPreferences?.[providerId]?.featureValues ?? null; + return preferences.providerPreferences?.[providerId]?.options ?? null; }, { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider }, ); @@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { provider: featureProviderId, model: featureModelId, modeId: "bypassPermissions", - featureValues: { fast_mode: true }, + options: { fast_mode: true }, }, ]); const provider = await seedModelProvider({ @@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { expect(createAgentRequest).toMatchObject({ config: { provider: featureProviderId, - featureValues: { fast_mode: true }, + providerOptions: { fast_mode: true }, }, }); } finally { diff --git a/packages/app/e2e/browser/plugin-composer-items.spec.ts b/packages/app/e2e/browser/plugin-composer-items.spec.ts new file mode 100644 index 0000000000..19218c55d4 --- /dev/null +++ b/packages/app/e2e/browser/plugin-composer-items.spec.ts @@ -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"); + }); +}); diff --git a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts new file mode 100644 index 0000000000..4878ab76e6 --- /dev/null +++ b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts @@ -0,0 +1,53 @@ +import { test } from "../support/fixtures"; +import { + CHECKLIST, + withComposerToolsExample, + withPluginWritingToAnotherWorkspace, +} from "../support/helpers/plugin-composers-by-reference"; + +test("a panel in the Explorer inserts at the cursor of the focused agent's composer", async ({ + page, +}) => { + test.setTimeout(120_000); + await withComposerToolsExample(page, async (author) => { + await author.openComposerToolsInExplorer(); + await author.typeInComposer("start end", 5); + await author.insertChecklistFromPanel(); + await author.expectComposerText(`start${CHECKLIST} end`); + }); +}); + +test("a Command Center item changes an agent's metadata through its composer", async ({ page }) => { + test.setTimeout(120_000); + await withComposerToolsExample(page, async (author) => { + await author.openComposerToolsInExplorer(); + await author.expectPanelShowsReview(false); + await author.markForReviewFromCommandCenter(); + await author.expectPanelShowsReview(true); + }); +}); + +test("the panel follows which agent is focused", async ({ page }) => { + test.setTimeout(120_000); + await withComposerToolsExample(page, async (author) => { + await author.openComposerToolsInExplorer(); + await author.expectPanelFocusedAgent(true); + await author.focusANewAgentTab(); + await author.expectPanelFocusedAgent(false); + await author.focusTheAgentTab(); + await author.expectPanelFocusedAgent(true); + }); +}); + +test("a plugin writes to an agent's composer while its workspace is not on screen", async ({ + page, +}) => { + test.setTimeout(120_000); + await withPluginWritingToAnotherWorkspace(page, async (author) => { + await author.openTargetAgent(); + await author.goToAnotherWorkspace(); + await author.insertFromCommandCenter(); + await author.returnToTargetWorkspace(); + await author.expectComposerText("A note from a plugin"); + }); +}); diff --git a/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts b/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts new file mode 100644 index 0000000000..485375b352 --- /dev/null +++ b/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts @@ -0,0 +1,110 @@ +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 item's Issue list does not keep the previous project's results after a project switch", async ({ + page, +}) => { + test.setTimeout(180_000); + await withNewWorkspaceIssuesExample( + page, + { compact: false, otherProject: true, searchesByProject: true }, + async (screen) => { + await screen.openIssueList(); + await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, true); + await screen.closeIssueList(); + await screen.switchToOtherProject(); + await screen.openIssueList(); + await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, false); + }, + ); +}); + +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" }); + }, + ); +}); diff --git a/packages/app/e2e/support/helpers/plugin-composer-items.ts b/packages/app/e2e/support/helpers/plugin-composer-items.ts new file mode 100644 index 0000000000..bac676ab38 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-composer-items.ts @@ -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; + openAgentFeatures(): Promise; + expectFeatureRow(name: string): Promise; + }) => Promise, +) { + 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(); + } +} diff --git a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts new file mode 100644 index 0000000000..d761ba966d --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts @@ -0,0 +1,208 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test, type Page } from "@playwright/test"; +import { gotoAppShell } from "./app"; +import { openCommandCenter } from "./command-center"; +import { openAgentRoute } from "./mock-agent"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { copyPluginExample, pluginRequirements } from "./plugin-fixture"; +import { seedWorkspace } from "./seed-client"; +import { getServerId } from "./server-id"; +import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs"; + +const WIDE = { width: 1440, height: 900 }; +const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n"; + +type Workspace = Awaited>; + +function prompt(page: Page) { + return page.getByRole("textbox", { name: "Message agent..." }); +} + +async function createMockAgent(workspace: Workspace, title: string) { + return workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title, + model: "ten-second-stream", + modeId: "load-test", + }); +} + +async function runCommand(page: Page, title: string) { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill(title); + await panel.getByRole("button", { name: title, exact: true }).click(); + await expect(panel).not.toBeVisible(); +} + +export async function withComposerToolsExample( + page: Page, + run: (author: { + openComposerToolsInExplorer(): Promise; + typeInComposer(text: string, cursorAfter: number): Promise; + insertChecklistFromPanel(): Promise; + expectComposerText(text: string): Promise; + markForReviewFromCommandCenter(): Promise; + focusANewAgentTab(): Promise; + focusTheAgentTab(): Promise; + expectPanelFocusedAgent(focused: boolean): Promise; + expectPanelShowsReview(needsReview: boolean): Promise; + }) => Promise, +) { + const workspace = await seedWorkspace({ repoPrefix: "composer-tools-", title: "Composer tools" }); + const agent = await createMockAgent(workspace, "Composer tools agent"); + 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 page.setViewportSize(WIDE); + await gotoAppShell(page); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id }); + await run({ + openComposerToolsInExplorer: () => + test.step("open the Composer tools panel in the Explorer", async () => { + const explorer = await ensureExplorerSidebar(page); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + await page + .getByTestId("workspace-new-tab-menu") + .filter({ visible: true }) + .getByRole("menuitem", { name: "Composer tools", exact: true }) + .click(); + await expect(page.getByText("Focused agent: mock", { exact: true })).toBeVisible(); + }), + typeInComposer: (text, cursorAfter) => + test.step(`type "${text}" and put the cursor after ${cursorAfter} characters`, async () => { + const input = prompt(page); + await input.fill(text); + await input.press("Home"); + for (let index = 0; index < cursorAfter; index += 1) await input.press("ArrowRight"); + }), + insertChecklistFromPanel: () => + test.step("press the panel's button", () => + page + .getByRole("button", { name: "Insert checklist into the focused agent", exact: true }) + .click()), + expectComposerText: (text) => + test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)), + markForReviewFromCommandCenter: () => + test.step("run the Command Center item", () => + runCommand(page, "Composer controls: mark for review")), + focusANewAgentTab: () => + test.step("focus a new agent tab, which is a draft", () => createAgentTabFromMenu(page)), + focusTheAgentTab: () => + test.step("focus the running agent's tab again", () => + page + .getByTestId(`workspace-tab-agent_${agent.id}`) + .filter({ visible: true }) + .first() + .click({ position: { x: 12, y: 13 } })), + expectPanelFocusedAgent: (focused) => + test.step(`the panel ${focused ? "names" : "has no"} focused agent`, async () => { + await expect(page.getByText("Focused agent: mock", { exact: true })).toHaveCount( + focused ? 1 : 0, + ); + await expect(page.getByText("No agent is focused", { exact: true })).toHaveCount( + focused ? 0 : 1, + ); + }), + expectPanelShowsReview: (needsReview) => + test.step(`the panel's snapshot says needs review: ${needsReview}`, () => + expect( + page.getByText(`Needs review: ${needsReview ? "yes" : "no"}`, { exact: true }), + ).toBeVisible()), + }); + } finally { + await client.removePlugin("composer-controls-example"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await client.close(); + await example.cleanup(); + await workspace.cleanup(); + } +} + +/** + * A plugin that names an agent by id from a Command Center item while the user is in another + * workspace: the target's workspace is not on screen, so its composer is not mounted. + */ +export async function withPluginWritingToAnotherWorkspace( + page: Page, + run: (author: { + openTargetAgent(): Promise; + goToAnotherWorkspace(): Promise; + insertFromCommandCenter(): Promise; + returnToTargetWorkspace(): Promise; + expectComposerText(text: string): Promise; + }) => Promise, +) { + const workspace = await seedWorkspace({ repoPrefix: "composer-ref-", title: "Composer ref" }); + const elsewhere = await seedWorkspace({ repoPrefix: "composer-away-", title: "Composer away" }); + const target = await createMockAgent(workspace, "Target agent"); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-ref-")); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "composer-ref", requirements: pluginRequirements }), + ); + await writeFile( + path.join(directory, "index.client.tsx"), + `export default function contribute(client) { + client.addCommandCenterItem({ + id: "note", + title: "Composer ref: add a note", + icon: "Flag", + context: "global", + onSelect({ composers }) { + for (const composer of composers({ agentId: ${JSON.stringify(target.id)} })) composer.insertText("A note from a plugin"); + }, + }); + return () => {}; +}`, + ); + const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`); + const workspaceRow = (id: string) => + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await run({ + openTargetAgent: () => + test.step("open the agent the plugin will write to", async () => { + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id }); + await expect(prompt(page)).toBeVisible({ timeout: 30_000 }); + }), + goToAnotherWorkspace: () => + test.step("go to another workspace, so the agent's composer is not on screen", async () => { + await workspaceRow(elsewhere.workspaceId).click(); + // The app keeps the workspace it left mounted, out of sight. + await expect(targetTab().first()).toBeHidden(); + }), + insertFromCommandCenter: () => + test.step("run the plugin's Command Center item", () => + runCommand(page, "Composer ref: add a note")), + returnToTargetWorkspace: () => + test.step("return to the agent's workspace without reloading the app", async () => { + await workspaceRow(workspace.workspaceId).click(); + await expect(targetTab().first()).toBeVisible({ timeout: 30_000 }); + }), + expectComposerText: (text) => + test.step("its composer holds the text", () => expect(prompt(page)).toHaveValue(text)), + }); + } finally { + await client.removePlugin("composer-ref"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await client.close(); + await rm(directory, { recursive: true, force: true }); + await elsewhere.cleanup(); + await workspace.cleanup(); + } +} + +export { CHECKLIST }; diff --git a/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts b/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts new file mode 100644 index 0000000000..ddcdd6d08b --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts @@ -0,0 +1,228 @@ +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 {}} />; +} +export default function contribute(client) { + client.addNewWorkspaceItem({ id: "on-mount", Component: OnMount }); + return () => {}; +} +`, + ); +} + +/** + * An item whose search depends on the project: it answers with the project's name, and never + * answers for the project named "other", so a list that still shows the first project's answer + * there is stale. + */ +async function replaceWithProjectSearchItem(directory: string) { + await writeFile( + path.join(directory, "index.client.tsx"), + `import { useNewWorkspace } from "@getpaseo/plugin/client"; +import { Combobox } from "@getpaseo/plugin/client/ui"; +function ProjectIssues() { + const { project } = useNewWorkspace(); + const name = project?.name ?? ""; + return ( + + name.includes("other") + ? new Promise(() => {}) + : Promise.resolve([{ id: name, label: "Issue of " + name }]) + } + onSelect={() => {}} + /> + ); +} +export default function contribute(client) { + client.addNewWorkspaceItem({ id: "project-issues", Component: ProjectIssues }); + return () => {}; +} +`, + ); +} + +type Client = Awaited>; + +/** 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; + /** Installs an item whose Issue list depends on the project instead of the example's. */ + searchesByProject?: boolean; + /** A second project the screen can switch to. */ + otherProject?: boolean; + }, + run: (screen: { + chooseWorktree(): Promise; + chooseIssue(label: string): Promise; + createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>; + switchToOtherProject(): Promise; + openIssueList(): Promise; + closeIssueList(): Promise; + /** The Issue list shows `text`, or does not show it when `shown` is false. */ + expectIssueListText(text: string, shown: boolean): Promise; + /** The name the first project's items read as `project.name`. */ + firstProjectName: string; + expectIsolation(isolation: "local" | "worktree"): Promise; + /** Removes the plugin, leaves the screen, and opens New workspace for the project again. */ + reopenWithoutPlugin(): Promise; + }) => Promise, +) { + const client = await connectNewWorkspaceDaemonClient(); + const previous = await client.getDaemonConfig(); + const example = await copyPluginExample("new-workspace-issues"); + if (options.searchesByProject) await replaceWithProjectSearchItem(example.directory); + 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(); + }), + firstProjectName: project.projectDisplayName, + openIssueList: () => + test.step("open the Issue list", async () => { + await page.getByRole("button", { name: "Issue", exact: true }).click(); + }), + closeIssueList: () => test.step("close the Issue list", () => page.keyboard.press("Escape")), + expectIssueListText: (text, shown) => + test.step(`the Issue list ${shown ? "shows" : "does not show"} "${text}"`, async () => { + const list = page.getByTestId("combobox-desktop-container").last(); + await expect(list.getByText(text, { exact: true })).toHaveCount(shown ? 1 : 0); + }), + 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(); + } +} diff --git a/packages/app/src/agent-commands/cache/index.ts b/packages/app/src/agent-commands/cache/index.ts index 474491f297..baee276a47 100644 --- a/packages/app/src/agent-commands/cache/index.ts +++ b/packages/app/src/agent-commands/cache/index.ts @@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig { modeId?: string; model?: string; thinkingOptionId?: string; - featureValues?: Record; + providerOptions?: Record; } export function normalizeAgentCommandsCwd(cwd: string): string { @@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: { "thinking", draftConfig.thinkingOptionId ?? null, "features", - draftConfig.featureValues ?? null, + draftConfig.providerOptions ?? null, ] as const; } diff --git a/packages/app/src/agent-controls/draft-options-model.test.ts b/packages/app/src/agent-controls/draft-options-model.test.ts new file mode 100644 index 0000000000..1efee32348 --- /dev/null +++ b/packages/app/src/agent-controls/draft-options-model.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; +import { isControlActive } from "./option-controls"; +import { + draftControlsRequest, + openDraftOptions, + ownDraftOptions, + viewDraftOptions, +} from "./draft-options-model"; + +const SPEED: AgentOptionControl = { + type: "select", + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Standard" }, + { value: "priority", label: "Fast" }, + ], +}; +const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" }; + +function answer( + provider: string, + requestKey: string, + options: Record, + controls = [SPEED, WEB_SEARCH], +) { + return { provider, requestKey, controls, options }; +} + +describe("draft options model", () => { + it("draws a legacy Fast profile as the Fast tier the daemon resolved", () => { + const model = openDraftOptions({ provider: "codex", seed: { service_tier: "fast" } }); + model.applyResolved(answer("codex", "k1", { service_tier: "priority" })); + + const view = viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" }); + + expect(view?.options).toEqual({ service_tier: "priority" }); + expect(view && isControlActive(SPEED, view.options)).toBe(true); + // The daemon still gets the stored value to resolve, and so does create. + expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" }); + }); + + it("applying a profile from another provider keeps its options through the provider change", () => { + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved(answer("claude", "k1", {})); + + model.applyProfile({ + provider: "codex", + options: { sandbox_mode: "read-only", service_tier: "fast" }, + }); + // The form follows the profile to its provider. + model.observeProvider("codex"); + + expect(ownDraftOptions(model.getState())).toEqual({ + sandbox_mode: "read-only", + service_tier: "fast", + }); + expect(model.getState().resolved).toBeNull(); + }); + + it("choosing another provider by hand starts the options over", () => { + const model = openDraftOptions({ provider: "codex", seed: { sandbox_mode: "read-only" } }); + model.setOption("web_search", true); + + model.observeProvider("claude"); + + expect(ownDraftOptions(model.getState())).toBeUndefined(); + expect(model.getState().resolved).toBeNull(); + }); + + it("never pairs a manually chosen provider with the previous provider's options", () => { + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved( + answer("claude", "k1", { fast_mode: true }, [ + { type: "toggle", key: "fast_mode", label: "Fast" }, + ]), + ); + model.setOption("fast_mode", true); + const form = { provider: "claude", cwd: "/repo" }; + expect(draftControlsRequest(model.getState(), form)).toMatchObject({ + provider: "claude", + providerOptions: { fast_mode: true }, + }); + + // The form shows OpenCode on its next render; the model has not observed it yet. + expect(draftControlsRequest(model.getState(), { ...form, provider: "opencode" })).toBeNull(); + + model.observeProvider("opencode"); + const request = draftControlsRequest(model.getState(), { ...form, provider: "opencode" }); + expect(request).toEqual({ provider: "opencode", cwd: "/repo" }); + }); + + it("stops sending a toggle once the model in use draws no control for it", () => { + const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" }; + const model = openDraftOptions({ provider: "claude", seed: { sandbox_mode: "read-only" } }); + model.applyResolved(answer("claude", "opus", { fast_mode: false }, [FAST_MODE])); + model.setOption("fast_mode", true); + expect(ownDraftOptions(model.getState())).toEqual({ + sandbox_mode: "read-only", + fast_mode: true, + }); + + // The form moves to a model without Fast; its answer lists no such control. + model.applyResolved(answer("claude", "haiku", {}, [])); + + expect(ownDraftOptions(model.getState())).toEqual({ sandbox_mode: "read-only" }); + + // Back on a model that draws the control, the toggle applies again. The daemon's answer + // for the request without it said Fast was off; the request that carries it follows. + model.applyResolved(answer("claude", "opus-again", { fast_mode: false }, [FAST_MODE])); + expect(ownDraftOptions(model.getState())).toEqual({ + sandbox_mode: "read-only", + fast_mode: true, + }); + }); + + it("always sends an option no control has drawn", () => { + const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" }; + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved(answer("claude", "k1", {}, [FAST_MODE])); + + model.setOption("token_budget", 5); + + expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 }); + model.applyResolved(answer("claude", "k2", {}, [])); + expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 }); + }); + + it("keeps a draft's seed when the first provider is chosen", () => { + const model = openDraftOptions({ provider: null, seed: { web_search: true } }); + + model.observeProvider("claude"); + + expect(ownDraftOptions(model.getState())).toEqual({ web_search: true }); + }); + + it("sends a remembered value to the daemon, and draws only what comes back", () => { + const model = openDraftOptions({ provider: "codex" }); + model.setRemembered({ service_tier: "fast", retired: true }); + // Nothing is known about the provider's controls yet. + expect(ownDraftOptions(model.getState())).toBeUndefined(); + + model.applyResolved(answer("codex", "k1", { service_tier: "default" })); + expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" }); + // The answer to the first request does not show the remembered value as if it applied. + expect( + viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options, + ).toEqual({ service_tier: "default" }); + + model.applyResolved(answer("codex", "k2", { service_tier: "priority" })); + expect( + viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options, + ).toEqual({ service_tier: "priority" }); + }); + + it("does not let a remembered value override the seed", () => { + const model = openDraftOptions({ provider: "codex", seed: { service_tier: "default" } }); + model.applyResolved(answer("codex", "k1", { service_tier: "default" })); + model.setRemembered({ service_tier: "fast" }); + + expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "default" }); + }); + + it("lays a toggle over the previous answer until the new one arrives", () => { + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved(answer("claude", "k1", { web_search: false })); + + model.setOption("web_search", true); + + expect( + viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options, + ).toEqual({ web_search: true }); + model.applyResolved(answer("claude", "k2", { web_search: true })); + expect( + viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options, + ).toEqual({ web_search: true }); + }); + + it("draws nothing for a provider the answer is not for", () => { + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved(answer("claude", "k1", {})); + + expect(viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" })).toBe( + undefined, + ); + }); +}); diff --git a/packages/app/src/agent-controls/draft-options-model.ts b/packages/app/src/agent-controls/draft-options-model.ts new file mode 100644 index 0000000000..27c73194f5 --- /dev/null +++ b/packages/app/src/agent-controls/draft-options-model.ts @@ -0,0 +1,217 @@ +import type { + AgentOptionControl, + AgentSessionConfig, + ProviderOptions, +} from "@getpaseo/protocol/agent-types"; +import { withoutLostControls } from "./lost-controls"; + +/** + * The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers + * make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon + * (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias, + * default or legacy key is resolved here. + * + * - `seed`: options the draft started from (a profile, a forked agent). + * - `remembered`: the provider's last-used control values. + * - `edits`: toggles made in this draft. + * + * An edit of `null` removes the key from every layer, the way a patch does on a running agent, so + * the provider's default applies again. + * + * Choosing a provider by hand starts the options over; applying a profile replaces the provider + * and the options in one step, so the provider change that follows does not clear them. + */ +export interface ResolvedDraftOptions { + provider: string; + /** Names the request that produced this answer; see `viewDraftOptions`. */ + requestKey: string; + controls: AgentOptionControl[]; + options: ProviderOptions; +} + +export interface DraftOptionsState { + provider: string | null; + seed: ProviderOptions; + remembered: ProviderOptions; + edits: ProviderOptions; + resolved: ResolvedDraftOptions | null; + /** Every key an answer for this provider has drawn as a control. */ + drawn: ReadonlySet; +} + +export interface DraftOptionsModel { + getState: () => DraftOptionsState; + subscribe: (listener: () => void) => () => void; + /** The provider the draft's form now has. A different one than the model's is a manual choice. */ + observeProvider: (provider: string | null) => void; + applyProfile: (profile: { provider: string; options: ProviderOptions }) => void; + setRemembered: (remembered: ProviderOptions) => void; + setOption: (key: string, value: unknown) => void; + applyResolved: (resolved: ResolvedDraftOptions) => void; +} + +export function openDraftOptions(input: { + provider: string | null; + seed?: ProviderOptions; + /** + * The provider the draft's form has now. An edit belongs to it even before the screen has + * rendered the change, so `setOption` observes it first. + */ + currentProvider?: () => string | null; +}): DraftOptionsModel { + let state: DraftOptionsState = { + provider: input.provider, + seed: input.seed ?? {}, + remembered: {}, + edits: {}, + resolved: null, + drawn: new Set(), + }; + const listeners = new Set<() => void>(); + + function publish(update: (current: DraftOptionsState) => DraftOptionsState): void { + const next = update(state); + if (next === state) return; + state = next; + 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, drawn: new Set() }; + }); + } + + return { + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + observeProvider, + applyProfile: (profile) => + publish((current) => ({ + ...current, + provider: profile.provider, + seed: profile.options, + edits: {}, + resolved: current.provider === profile.provider ? current.resolved : null, + drawn: current.provider === profile.provider ? current.drawn : new Set(), + })), + setRemembered: (remembered) => publish((current) => ({ ...current, remembered })), + setOption: (key, value) => { + if (input.currentProvider) observeProvider(input.currentProvider()); + publish((current) => + Object.is(current.edits[key], value) + ? current + : { ...current, edits: { ...current.edits, [key]: value } }, + ); + }, + applyResolved: (resolved) => + publish((current) => ({ + ...current, + resolved, + drawn: new Set([...current.drawn, ...resolved.controls.map((control) => control.key)]), + })), + }; +} + +/** + * Everything the draft would create with, for the daemon to resolve. A last-used value applies + * once the provider has told us it still draws a control for that key, and never over the seed. + * An edit of a key an earlier answer drew as a control stops applying when the latest answer + * draws none for it (the model changed to one without that control) and applies again when a + * control for it returns. An edit of a key no answer has drawn is an option without a control + * and always applies. + */ +export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined { + const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key)); + const remembered = Object.fromEntries( + Object.entries(state.remembered).filter( + ([key]) => controlKeys.has(key) && !(key in state.seed), + ), + ); + const edits = withoutLostControls(state.edits, state.drawn, controlKeys); + const own = withoutRemovals({ ...state.seed, ...remembered, ...edits }); + return Object.keys(own).length > 0 ? own : undefined; +} + +function withoutRemovals(options: ProviderOptions): ProviderOptions { + return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== null)); +} + +export type DraftControlsRequest = Pick< + AgentSessionConfig, + "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions" +>; + +/** What the draft's form has chosen; the options come from the model. */ +export interface DraftControlsForm { + provider: string | null; + cwd: string; + modeId?: string | null; + modelId?: string | null; + thinkingOptionId?: string | null; +} + +/** + * The listing request for the draft's form, built from one snapshot of the model. The form + * reaches a new provider before the model has observed it, and the model still holds the + * previous provider's options until it does; that render has no request, so a request never + * pairs one provider with another's options. + */ +export function draftControlsRequest( + state: DraftOptionsState, + form: DraftControlsForm, +): DraftControlsRequest | null { + if (!form.provider || !form.cwd || form.provider !== state.provider) return null; + const providerOptions = ownDraftOptions(state); + return { + provider: form.provider, + cwd: form.cwd, + ...(form.modeId ? { modeId: form.modeId } : {}), + ...(form.modelId ? { model: form.modelId } : {}), + ...(form.thinkingOptionId ? { thinkingOptionId: form.thinkingOptionId } : {}), + ...(providerOptions ? { providerOptions } : {}), + }; +} + +/** + * What the controls draw: the daemon's answer for this provider. The last answer stays up while + * a newer request is in flight, with the toggles made since laid over it, so a switch does not + * wait for the round trip. It never carries a seed or remembered value the daemon has not + * resolved. + */ +export function viewDraftOptions( + state: DraftOptionsState, + current: { provider: string | null; requestKey: string }, +): { controls: AgentOptionControl[]; options: ProviderOptions } | undefined { + const { resolved } = state; + if (!resolved || resolved.provider !== current.provider) return undefined; + return { + controls: resolved.controls, + options: + resolved.requestKey === current.requestKey + ? resolved.options + : 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; +} diff --git a/packages/app/src/agent-controls/icons.test.ts b/packages/app/src/agent-controls/icons.test.ts new file mode 100644 index 0000000000..b43e7f88e7 --- /dev/null +++ b/packages/app/src/agent-controls/icons.test.ts @@ -0,0 +1,16 @@ +import { Settings2 } from "lucide-react-native"; +import { describe, expect, it } from "vitest"; +import { getAgentFeatureIcon } from "./icons"; + +describe("getAgentFeatureIcon", () => { + it("draws the Lucide icon a plugin provider names", () => { + const icon = getAgentFeatureIcon("Mic"); + expect(icon).not.toBe(Settings2); + expect(getAgentFeatureIcon("Mic")).toBe(icon); + }); + + it("falls back to the generic icon for a missing or unknown name", () => { + expect(getAgentFeatureIcon()).toBe(Settings2); + expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2); + }); +}); diff --git a/packages/app/src/agent-controls/icons.ts b/packages/app/src/agent-controls/icons.ts index 8effc9831d..485eb3e915 100644 --- a/packages/app/src/agent-controls/icons.ts +++ b/packages/app/src/agent-controls/icons.ts @@ -13,6 +13,7 @@ import { ShieldQuestionMark, Zap, } from "lucide-react-native"; +import { isLucideIconName, pluginIconComponent } from "@/plugins/icons"; import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; export interface AgentControlIconProps { @@ -61,6 +62,9 @@ export function getAgentModeOptionIcon( return icon ? MODE_ICONS[icon] : undefined; } +/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */ export function getAgentFeatureIcon(icon?: string): AgentControlIcon { - return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2; + if (!icon) return Settings2; + if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon]; + return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2; } diff --git a/packages/app/src/agent-controls/lost-controls.ts b/packages/app/src/agent-controls/lost-controls.ts new file mode 100644 index 0000000000..0ea7d76940 --- /dev/null +++ b/packages/app/src/agent-controls/lost-controls.ts @@ -0,0 +1,17 @@ +import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; + +/** + * The options that still apply after an answer from the daemon. A key an earlier answer drew as a + * control and the latest answer does not (the model changed to one without that control) is + * dropped, and applies again if a control for it returns. A key no answer has drawn is an option + * without a control and always applies. + */ +export function withoutLostControls( + options: ProviderOptions, + drawn: ReadonlySet, + current: ReadonlySet, +): ProviderOptions { + return Object.fromEntries( + Object.entries(options).filter(([key]) => !drawn.has(key) || current.has(key)), + ); +} diff --git a/packages/app/src/agent-controls/option-controls.test.ts b/packages/app/src/agent-controls/option-controls.test.ts new file mode 100644 index 0000000000..b14f20d6ed --- /dev/null +++ b/packages/app/src/agent-controls/option-controls.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; +import { + getControlTooltip, + isControlActive, + readSelectOption, + readToggleOption, +} from "./option-controls"; + +const speed: AgentOptionControl = { + type: "select", + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Normal" }, + { value: "priority", label: "Fast" }, + ], +}; +const fast: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" }; + +describe("option controls", () => { + it("reads a select from options and falls back to the control's default", () => { + if (speed.type !== "select") throw new Error("select expected"); + expect(readSelectOption(speed, { service_tier: "priority" })).toBe("priority"); + expect(readSelectOption(speed, {})).toBe("default"); + expect(readSelectOption(speed, { service_tier: "unknown-tier" })).toBe("default"); + }); + + it("reads a toggle from options and is off when unset", () => { + if (fast.type !== "toggle") throw new Error("toggle expected"); + expect(readToggleOption(fast, { fast_mode: true })).toBe(true); + expect(readToggleOption(fast, {})).toBe(false); + }); + + it("is active when the option differs from the control's default", () => { + expect(isControlActive(speed, { service_tier: "priority" })).toBe(true); + expect(isControlActive(speed, { service_tier: "default" })).toBe(false); + expect(isControlActive(speed, {})).toBe(false); + expect(isControlActive(fast, { fast_mode: true })).toBe(true); + expect(isControlActive(fast, {})).toBe(false); + }); + + it("prefers a control's tooltip over its label", () => { + expect(getControlTooltip({ label: "Plan", tooltip: "Toggle plan mode" })).toBe( + "Toggle plan mode", + ); + expect(getControlTooltip({ label: "Custom" })).toBe("Custom"); + }); +}); diff --git a/packages/app/src/agent-controls/option-controls.ts b/packages/app/src/agent-controls/option-controls.ts new file mode 100644 index 0000000000..ae513de23d --- /dev/null +++ b/packages/app/src/agent-controls/option-controls.ts @@ -0,0 +1,47 @@ +import type { + AgentOptionControl, + AgentOptionSelectControl, + AgentOptionToggleControl, + ProviderOptions, +} from "@getpaseo/protocol/agent-types"; + +/** + * The composer's option controls. A control only describes how to draw one option key; the + * value is read from `options`, which the daemon resolved (defaults, aliases, what runs). + */ +export interface ComposerOptionControls { + controls: AgentOptionControl[]; + options: ProviderOptions; + onSetOption: (key: string, value: unknown) => void; +} + +export function readToggleOption( + control: AgentOptionToggleControl, + options: ProviderOptions, +): boolean { + const value = options[control.key]; + return typeof value === "boolean" ? value : (control.defaultValue ?? false); +} + +/** The selected choice's value, or the control's default when the option names no choice. */ +export function readSelectOption( + control: AgentOptionSelectControl, + options: ProviderOptions, +): string | null { + const value = options[control.key]; + if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) { + return value; + } + return control.defaultValue ?? null; +} + +/** Whether the option is set to something other than the control's default. */ +export function isControlActive(control: AgentOptionControl, options: ProviderOptions): boolean { + if (control.type === "toggle") return readToggleOption(control, options); + const value = readSelectOption(control, options); + return value !== null && value !== control.defaultValue; +} + +export function getControlTooltip(control: Pick): string { + return control.tooltip ?? control.label; +} diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts index dd60b6680f..507ea84e12 100644 --- a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts +++ b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts @@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => { model: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { web_search: true }, + options: { web_search: true }, }), ), ).toEqual({ @@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => { modelId: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { web_search: true }, + options: { web_search: true }, }); }); @@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => { modelId: "", modeId: "", thinkingOptionId: "", - featureValues: {}, + options: {}, }); }); @@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => { model: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { web_search: true }, + options: { web_search: true }, }), ), ), @@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => { modelId: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { web_search: true }, + options: { web_search: true }, }); }); @@ -81,8 +81,8 @@ describe("toAgentConfigApply", () => { it("omits an empty feature map so the agent keeps its own feature values", () => { expect( - toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))), - ).not.toHaveProperty("featureValues"); + toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))), + ).not.toHaveProperty("options"); }); it("never carries a provider, which a running agent cannot change", () => { diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.ts b/packages/app/src/agent-profiles/internal/materialize-profile.ts index fc393f5ec2..e0e3df628c 100644 --- a/packages/app/src/agent-profiles/internal/materialize-profile.ts +++ b/packages/app/src/agent-profiles/internal/materialize-profile.ts @@ -12,7 +12,7 @@ export interface MaterializedAgentProfile { modelId: string; modeId: string; thinkingOptionId: string; - featureValues: Record; + options: Record; } function trimmed(value: string | undefined): string { @@ -25,7 +25,7 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen modelId: trimmed(profile.model), modeId: trimmed(profile.modeId), thinkingOptionId: trimmed(profile.thinkingOptionId), - featureValues: profile.featureValues ?? {}, + options: profile.options ?? {}, }; } @@ -52,8 +52,6 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf ...(profile.modelId ? { modelId: profile.modelId } : {}), ...(profile.modeId ? { modeId: profile.modeId } : {}), ...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}), - ...(Object.keys(profile.featureValues).length > 0 - ? { featureValues: profile.featureValues } - : {}), + ...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}), }; } diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts index 7689621830..4756bc127a 100644 --- a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts +++ b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts @@ -1,7 +1,7 @@ import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { describe, expect, it } from "vitest"; import { - buildFeatureRequestKey, + buildControlsRequestKey, openAgentProfileForm, type AgentProfileFormModel, } from "./profile-form-model"; @@ -70,6 +70,21 @@ function optionValues(options: readonly { value: string }[]): string[] { return options.map((option) => option.value); } +const FAST = { + controls: [{ type: "toggle" as const, key: "fast_mode", label: "Fast" }], + options: { fast_mode: false }, +}; +const NO_CONTROLS = { controls: [], options: {} }; + +function toggledOnOpus(): AgentProfileFormModel { + const model = openWithCatalog({ mode: "create" }); + model.setName("Fast"); + selectClaude(model); + model.applyControls(model.getState().controlsRequestKey ?? "", FAST); + model.setOption("fast_mode", true); + return model; +} + describe("openAgentProfileForm", () => { it("starts a create form empty and cannot submit", () => { const model = openWithCatalog({ mode: "create" }); @@ -159,6 +174,7 @@ describe("openAgentProfileForm", () => { provider: "codex", model: "gpt-5.2-codex", modeId: "read-only", + options: {}, }); }); @@ -173,7 +189,7 @@ describe("openAgentProfileForm", () => { model: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { webSearch: true }, + options: { webSearch: true }, notes: "Visual work only.", }, }); @@ -229,10 +245,11 @@ describe("openAgentProfileForm", () => { selectClaude(model); model.setModel("claude-opus-5", { label: "Opus 5" }); model.setThinking("think-hard", { label: "Think hard" }); - model.applyFeatures(model.getState().featureRequestKey ?? "", [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); - model.setFeatureValue("webSearch", true); + model.applyControls(model.getState().controlsRequestKey ?? "", { + controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], + options: { webSearch: false }, + }); + model.setOption("webSearch", true); model.setProvider("codex", { label: "Codex" }); const state = model.getState(); @@ -242,8 +259,9 @@ describe("openAgentProfileForm", () => { expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" }); expect(state.thinkingOptionId).toBe(""); expect(state.thinkingDisplay).toBeNull(); - expect(state.featureValues).toEqual({}); - expect(state.features).toEqual([]); + expect(state.options).toEqual({}); + expect(state.optionEdits).toEqual({}); + expect(state.controls).toEqual([]); expect(state.name).toBe("Test writer"); }); @@ -334,23 +352,28 @@ describe("openAgentProfileForm", () => { }); }); - describe("features", () => { - it("asks for features per provider/model/mode/thinking combination", () => { + const WEB_SEARCH_CONTROLS = { + controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }], + options: { webSearch: false }, + }; + + describe("controls", () => { + it("asks for controls per provider/model/mode/thinking combination", () => { const model = openWithCatalog({ mode: "create" }); selectClaude(model); - expect(model.getState().featureRequest).toEqual({ + expect(model.getState().controlsRequest).toEqual({ provider: "claude", model: "claude-opus-5", modeId: "plan", thinkingOptionId: "think", }); - const firstKey = model.getState().featureRequestKey; + const firstKey = model.getState().controlsRequestKey; model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); - expect(model.getState().featureRequestKey).not.toBe(firstKey); - expect(model.getState().featureRequest).toEqual({ + expect(model.getState().controlsRequestKey).not.toBe(firstKey); + expect(model.getState().controlsRequest).toEqual({ provider: "claude", model: "claude-haiku-4-5", modeId: "plan", @@ -361,77 +384,235 @@ describe("openAgentProfileForm", () => { it("moves from pending to complete only for the current request", () => { const model = openWithCatalog({ mode: "create" }); selectClaude(model); - const key = model.getState().featureRequestKey ?? ""; - expect(model.getState().featureResolution).toBe("pending"); + const key = model.getState().controlsRequestKey ?? ""; + expect(model.getState().controlsResolution).toBe("pending"); - model.applyFeatures("stale-key", [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); - expect(model.getState().featureResolution).toBe("pending"); - expect(model.getState().features).toEqual([]); + model.applyControls("stale-key", { + controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], + options: { webSearch: false }, + }); + expect(model.getState().controlsResolution).toBe("pending"); + expect(model.getState().controls).toEqual([]); - model.applyFeatures(key, [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); - expect(model.getState().featureResolution).toBe("complete"); - expect(model.getState().disclosure.showFeaturesField).toBe(true); + model.applyControls(key, { + controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], + options: { webSearch: false }, + }); + expect(model.getState().controlsResolution).toBe("complete"); + expect(model.getState().disclosure.showControlsField).toBe(true); }); - it("reflects a set feature value back into the rendered feature", () => { + it("reflects a set option back into the rendered controls", () => { + const model = openWithCatalog({ mode: "create" }); + model.setName("Searcher"); + selectClaude(model); + model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS); + + model.setOption("webSearch", true); + + expect(model.getState().controls[0]).toMatchObject({ key: "webSearch" }); + expect(model.getState().controlOptions).toEqual({ webSearch: true }); + expect(model.getState().submitValue?.options).toEqual({ webSearch: true }); + }); + + it("keeps stored options while the controls are still pending", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { id: "p1", name: "UI work", provider: "claude", options: { webSearch: true } }, + }); + model.applyProviderCatalog(ENTRIES); + + expect(model.getState().controlsResolution).toBe("pending"); + expect(model.getState().submitValue?.options).toEqual({ webSearch: true }); + }); + + it("keeps options no control draws, so editing a profile does not drop them", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { + id: "p1", + name: "UI work", + provider: "claude", + options: { webSearch: true, sandbox_mode: "read-only" }, + }, + }); + model.applyProviderCatalog(ENTRIES); + model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS); + model.setOption("webSearch", false); + + expect(model.getState().submitValue?.options).toEqual({ + webSearch: false, + sandbox_mode: "read-only", + }); + }); + + it("draws the options the daemon resolved from the stored ones, then the edits", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { id: "p1", name: "Fast", provider: "codex", options: { service_tier: "fast" } }, + }); + model.applyProviderCatalog(ENTRIES); + + // The request carries the stored options so the daemon can resolve the legacy tier. + expect(model.getState().controlsRequest?.providerOptions).toEqual({ service_tier: "fast" }); + + model.applyControls(model.getState().controlsRequestKey ?? "", { + controls: [ + { + type: "select", + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Standard" }, + { value: "priority", label: "Fast" }, + ], + }, + ], + options: { service_tier: "priority" }, + }); + + expect(model.getState().controlOptions).toEqual({ service_tier: "priority" }); + // Nothing was edited, so the stored value is saved as it was. + expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" }); + }); + + it("sends an edit to the daemon with the stored options, and draws the answer", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { + id: "p1", + name: "Fast", + provider: "codex", + options: { service_tier: "fast", sandbox_mode: "read-only" }, + }, + }); + model.applyProviderCatalog(ENTRIES); + const speed = { + controls: [ + { + type: "select" as const, + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Standard" }, + { value: "priority", label: "Fast" }, + ], + }, + ], + options: { service_tier: "priority", sandbox_mode: "read-only" }, + }; + model.applyControls(model.getState().controlsRequestKey ?? "", speed); + + model.setOption("service_tier", "default"); + + // The controls stay up while the daemon answers, with the toggle laid over the last answer. + expect(model.getState().controls).toHaveLength(1); + expect(model.getState().controlOptions).toMatchObject({ service_tier: "default" }); + // The request carries everything the profile would save, so the daemon resolves it. + expect(model.getState().controlsRequest?.providerOptions).toEqual({ + service_tier: "default", + sandbox_mode: "read-only", + }); + expect(model.getState().controlsResolution).toBe("pending"); + + model.applyControls(model.getState().controlsRequestKey ?? "", { + ...speed, + options: { service_tier: "default", sandbox_mode: "read-only" }, + }); + expect(model.getState().controlsResolution).toBe("complete"); + expect(model.getState().controlOptions).toEqual({ + service_tier: "default", + sandbox_mode: "read-only", + }); + }); + + it("draws no controls for the previous model while another model's answer is pending", () => { const model = openWithCatalog({ mode: "create" }); selectClaude(model); - model.applyFeatures(model.getState().featureRequestKey ?? "", [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); + model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS); - model.setFeatureValue("webSearch", true); + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); - expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true }); - expect(model.getState().featureValues).toEqual({ webSearch: true }); + expect(model.getState().controls).toEqual([]); }); - it("keeps stored feature values while the feature list is still pending", () => { + it("does not save a toggle once the model that drew it is replaced by one without it", () => { + const model = toggledOnOpus(); + expect(model.getState().submitValue?.options).toEqual({ fast_mode: true }); + + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS); + + // Empty, not absent: the daemon reads a profile write without `options` as one from an + // app that predates them and keeps the stored options. + expect(model.getState().submitValue?.options).toEqual({}); + expect(model.getState().controlsRequest?.providerOptions).toBeUndefined(); + }); + + it("saves a toggle again when the model that draws it returns", () => { + const model = toggledOnOpus(); + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS); + + model.setModel("claude-opus-5", { label: "Opus 5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", FAST); + + expect(model.getState().submitValue?.options).toEqual({ fast_mode: true }); + }); + + it("does not save a stored toggle profile either", () => { const model = openAgentProfileForm({ mode: "edit", profile: { id: "p1", - name: "UI work", + name: "Fast", provider: "claude", - featureValues: { webSearch: true }, + model: "claude-opus-5", + options: { fast_mode: true }, }, }); model.applyProviderCatalog(ENTRIES); + model.applyControls(model.getState().controlsRequestKey ?? "", FAST); - expect(model.getState().featureResolution).toBe("pending"); - expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true }); + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS); + + expect(model.getState().submitValue?.options).toEqual({}); }); - it("prunes stored values the provider no longer reports", () => { - const model = openAgentProfileForm({ - mode: "edit", - profile: { - id: "p1", - name: "UI work", - provider: "claude", - featureValues: { webSearch: true, retiredFlag: true }, - }, - }); - model.applyProviderCatalog(ENTRIES); - model.applyFeatures(model.getState().featureRequestKey ?? "", [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); + it("never drops an option no control has drawn", () => { + const model = openWithCatalog({ mode: "create" }); + model.setName("Fast"); + selectClaude(model); + model.applyControls(model.getState().controlsRequestKey ?? "", FAST); + model.setOption("token_budget", 8000); + model.setOption("fast_mode", true); - expect(model.getState().featureValues).toEqual({ webSearch: true }); + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS); + + expect(model.getState().submitValue?.options).toEqual({ token_budget: 8000 }); + }); + + it("keeps a toggle when the daemon cannot list controls", () => { + const model = toggledOnOpus(); + + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + model.applyControlsUnavailable(model.getState().controlsRequestKey ?? ""); + + expect(model.getState().submitValue?.options).toEqual({ fast_mode: true }); }); it("resolves an unavailable feature listing without showing the field", () => { const model = openWithCatalog({ mode: "create" }); selectClaude(model); - model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? ""); + model.applyControlsUnavailable(model.getState().controlsRequestKey ?? ""); - expect(model.getState().featureResolution).toBe("complete"); - expect(model.getState().disclosure.showFeaturesField).toBe(false); + expect(model.getState().controlsResolution).toBe("complete"); + expect(model.getState().disclosure.showControlsField).toBe(false); }); }); @@ -464,15 +645,15 @@ describe("openAgentProfileForm", () => { }); }); -describe("buildFeatureRequestKey", () => { +describe("buildControlsRequestKey", () => { it("is null without a provider", () => { - expect(buildFeatureRequestKey(null)).toBeNull(); + expect(buildControlsRequestKey(null)).toBeNull(); }); it("separates the fields so a value cannot bleed across positions", () => { - expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||"); - expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe( - buildFeatureRequestKey({ provider: "claude", model: "a" }), + expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||"); + expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe( + buildControlsRequestKey({ provider: "claude", model: "a" }), ); }); }); diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.ts b/packages/app/src/agent-profiles/internal/profile-form-model.ts index 2ab6185054..37756376bd 100644 --- a/packages/app/src/agent-profiles/internal/profile-form-model.ts +++ b/packages/app/src/agent-profiles/internal/profile-form-model.ts @@ -1,13 +1,14 @@ import type { - AgentFeature, + AgentOptionControl, + ProviderOptions, AgentMode, AgentModelDefinition, AgentSelectOption, ProviderSnapshotEntry, } from "@getpaseo/protocol/agent-types"; import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { withoutLostControls } from "@/agent-controls/lost-controls"; import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; -import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; /** @@ -57,11 +58,15 @@ export interface AgentProfileFormSnapshot { } /** - * The inputs a feature listing needs. Features are provider-scoped and change - * with model/mode/thinking, so every one of those is part of the request. + * The inputs a controls listing needs. Controls are provider-scoped and change + * with model/mode/thinking, so every one of those is part of the request. The + * profile's own options ride along so the daemon can resolve them (a legacy + * `service_tier: "fast"` is drawn as the model's Fast tier); they are the same + * for the life of one provider, so they are not part of the key. */ -export interface AgentProfileFeatureRequest { +export interface AgentProfileControlsRequest { provider: string; + providerOptions?: ProviderOptions; model?: string; modeId?: string; thinkingOptionId?: string; @@ -73,7 +78,7 @@ export interface AgentProfileFormDisclosure { showModelField: boolean; showModeField: boolean; showThinkingField: boolean; - showFeaturesField: boolean; + showControlsField: boolean; } export interface AgentProfileFormState { @@ -86,13 +91,22 @@ export interface AgentProfileFormState { modelId: string; modeId: string; thinkingOptionId: string; - featureValues: Record; + /** The options the profile was opened with, including ones no control draws. */ + options: ProviderOptions; + /** Changes made in this form, over `options`. */ + optionEdits: ProviderOptions; providerOptions: AgentProfileFormOption[]; modelOptions: AgentProfileFormOption[]; modeOptions: AgentProfileFormOption[]; thinkingOptions: AgentProfileFormOption[]; - features: AgentFeature[]; + controls: AgentOptionControl[]; + /** What the controls show: the options the daemon resolved from `options`, then the edits. */ + controlOptions: ProviderOptions; + /** Every key an answer for this provider has drawn as a control. */ + drawnControlKeys: ReadonlySet; + /** The keys the latest answer draws as controls. */ + answeredControlKeys: ReadonlySet; providerDisplay: AgentProfileFormDisplay | null; modelDisplay: AgentProfileFormDisplay | null; @@ -100,9 +114,9 @@ export interface AgentProfileFormState { thinkingDisplay: AgentProfileFormDisplay | null; catalogResolution: AgentProfileResolutionStatus; - featureResolution: AgentProfileResolutionStatus; - featureRequest: AgentProfileFeatureRequest | null; - featureRequestKey: string | null; + controlsResolution: AgentProfileResolutionStatus; + controlsRequest: AgentProfileControlsRequest | null; + controlsRequestKey: string | null; disclosure: AgentProfileFormDisclosure; isSubmitting: boolean; @@ -118,9 +132,12 @@ export interface AgentProfileFormModel { /** Late input: the host-scoped provider catalog. Never touches selections. */ applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void; /** Late input: the feature list for one request. Stale keys are ignored. */ - applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void; + applyControls: ( + requestKey: string, + resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions }, + ) => void; /** Resolve a request that produced no usable features (provider error). */ - applyFeaturesUnavailable: (requestKey: string) => void; + applyControlsUnavailable: (requestKey: string) => void; setName: (value: string) => void; setAppearance: (value: { icon: string; color: string }) => void; setNotes: (value: string) => void; @@ -128,7 +145,7 @@ export interface AgentProfileFormModel { setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void; setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void; setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void; - setFeatureValue: (featureId: string, value: unknown) => void; + setOption: (key: string, value: unknown) => void; setSubmitting: (value: boolean) => void; setSubmitError: (value: string | null) => void; } @@ -312,19 +329,39 @@ function refineDisplay( return current ?? { label: selectedId }; } -function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null { +function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null { if (!state.provider) { return null; } + const ownOptions = ownOptionsOf(state); return { provider: state.provider, ...(state.modelId ? { model: state.modelId } : {}), ...(state.modeId ? { modeId: state.modeId } : {}), ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), + ...(Object.keys(ownOptions).length > 0 ? { providerOptions: ownOptions } : {}), }; } -export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null { +/** + * Everything the profile would save as its options; the daemon resolves them for the form. A + * toggle the model in use does not draw is not saved (see `withoutLostControls`). + */ +function ownOptionsOf( + state: Pick< + AgentProfileFormState, + "options" | "optionEdits" | "drawnControlKeys" | "answeredControlKeys" + >, +): ProviderOptions { + return withoutLostControls( + { ...state.options, ...state.optionEdits }, + state.drawnControlKeys, + state.answeredControlKeys, + ); +} + +/** The part of a request that decides which controls exist; options only change their values. */ +function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null { if (!request) { return null; } @@ -336,6 +373,18 @@ export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | nul ].join("|"); } +export function buildControlsRequestKey( + request: AgentProfileControlsRequest | null, +): string | null { + if (!request) { + return null; + } + const scope = buildControlsScopeKey(request); + return request.providerOptions + ? `${scope}|${JSON.stringify(Object.entries(request.providerOptions).sort(([a], [b]) => a.localeCompare(b)))}` + : scope; +} + function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null { const name = state.name.trim(); const notes = state.notes.trim(); @@ -350,16 +399,18 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul ...(state.modelId ? { model: state.modelId } : {}), ...(state.modeId ? { modeId: state.modeId } : {}), ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), - ...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}), + // Always sent, empty included: the daemon reads a write without `options` as one from an + // app that predates them and keeps the profile's stored options. + options: ownOptionsOf(state), ...(notes ? { notes } : {}), }; } -function resolveFeatureStatus( - featureRequestKey: string | null, +function resolveControlsStatus( + controlsRequestKey: string | null, featuresAreCurrent: boolean, ): AgentProfileResolutionStatus { - if (featureRequestKey === null) { + if (controlsRequestKey === null) { return "idle"; } return featuresAreCurrent ? "complete" : "pending"; @@ -392,25 +443,29 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm modelId, modeId: profile.modeId ?? "", thinkingOptionId: profile.thinkingOptionId ?? "", - featureValues: { ...profile.featureValues }, + options: { ...profile.options }, + optionEdits: {}, providerOptions: [], modelOptions: [], modeOptions: [], thinkingOptions: [], - features: [], + controls: [], + controlOptions: {}, + drawnControlKeys: new Set(), + answeredControlKeys: new Set(), providerDisplay: seedDisplay(provider), modelDisplay: seedDisplay(modelId), modeDisplay: seedDisplay(profile.modeId), thinkingDisplay: seedDisplay(profile.thinkingOptionId), catalogResolution: "idle", - featureResolution: "idle", - featureRequest: null, - featureRequestKey: null, + controlsResolution: "idle", + controlsRequest: null, + controlsRequestKey: null, disclosure: { showModelField: false, showModeField: false, showThinkingField: false, - showFeaturesField: false, + showControlsField: false, }, isSubmitting: false, submitError: null, @@ -422,28 +477,45 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel { let entries: readonly ProviderSnapshotEntry[] = []; let catalogResolution: AgentProfileResolutionStatus = "idle"; - let resolvedFeatureKey: string | null = null; - let resolvedFeatures: AgentFeature[] = []; + let resolvedControlsKey: string | null = null; + let resolvedScopeKey: string | null = null; + let resolvedControls: AgentOptionControl[] = []; + let resolvedOptions: ProviderOptions = {}; + let drawnKeys: ReadonlySet = new Set(); + let answeredKeys: ReadonlySet = new Set(); let listeners = new Set<() => void>(); let closed = false; function derive(incoming: AgentProfileFormState): AgentProfileFormState { const models = resolveModels(entries, incoming.provider); const modes = resolveModes(entries, incoming.provider); - const next = seedSelections(incoming, { - entry: findEntry(entries, incoming.provider), - models, - modes, - }); + const next: AgentProfileFormState = { + ...seedSelections(incoming, { + entry: findEntry(entries, incoming.provider), + models, + modes, + }), + drawnControlKeys: drawnKeys, + answeredControlKeys: answeredKeys, + }; const thinking = resolveThinkingOptions(entries, next.provider, next.modelId); - const featureRequest = buildFeatureRequest(next); - const featureRequestKey = buildFeatureRequestKey(featureRequest); + const controlsRequest = buildControlsRequest(next); + const controlsRequestKey = buildControlsRequestKey(controlsRequest); + const controlsScopeKey = buildControlsScopeKey(controlsRequest); const featuresAreCurrent = - featureRequestKey !== null && featureRequestKey === resolvedFeatureKey; - const features = featuresAreCurrent - ? applyFeatureValues(resolvedFeatures, next.featureValues) - : []; - const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent); + controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey; + // A toggle changes only the options, so the controls stay up while the daemon answers and the + // toggle shows over the last answer. A change of provider, model, mode or thinking draws none + // until its own answer arrives. + const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey; + const controls = controlsApply ? resolvedControls : []; + let controlOptions: ProviderOptions = next.options; + if (controlsApply) { + controlOptions = featuresAreCurrent + ? resolvedOptions + : { ...resolvedOptions, ...next.optionEdits }; + } + const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent); const withOptions: AgentProfileFormState = { ...next, @@ -451,7 +523,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP modelOptions: buildModelOptions(models), modeOptions: buildModeOptions(modes), thinkingOptions: buildThinkingOptions(thinking), - features, + controls, + controlOptions, providerDisplay: refineDisplay( next.providerDisplay, next.provider, @@ -479,9 +552,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP })(), ), catalogResolution, - featureResolution, - featureRequest, - featureRequestKey, + controlsResolution, + controlsRequest, + controlsRequestKey, }; // A field appears once the catalog can populate it, and stays visible while @@ -492,7 +565,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)), showThinkingField: hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)), - showFeaturesField: hasProvider && features.length > 0, + showControlsField: hasProvider && controls.length > 0, }; const canSubmit = withOptions.name.trim().length > 0 && @@ -531,23 +604,26 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP catalogResolution = "complete"; publish((current) => current); }, - applyFeatures: (requestKey, features) => { - if (requestKey !== state.featureRequestKey) { + applyControls: (requestKey, resolved) => { + if (requestKey !== state.controlsRequestKey) { return; } - resolvedFeatureKey = requestKey; - resolvedFeatures = [...features]; - publish((current) => ({ - ...current, - featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures), - })); + resolvedControlsKey = requestKey; + resolvedScopeKey = buildControlsScopeKey(state.controlsRequest); + resolvedControls = [...resolved.controls]; + resolvedOptions = resolved.options; + answeredKeys = new Set(resolved.controls.map((control) => control.key)); + drawnKeys = new Set([...drawnKeys, ...answeredKeys]); + publish((current) => current); }, - applyFeaturesUnavailable: (requestKey) => { - if (requestKey !== state.featureRequestKey) { + applyControlsUnavailable: (requestKey) => { + if (requestKey !== state.controlsRequestKey) { return; } - resolvedFeatureKey = requestKey; - resolvedFeatures = []; + resolvedControlsKey = requestKey; + resolvedScopeKey = buildControlsScopeKey(state.controlsRequest); + resolvedControls = []; + resolvedOptions = {}; publish((current) => current); }, setName: (value) => publish((current) => ({ ...current, name: value })), @@ -562,6 +638,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP // Everything below the provider is provider-scoped: a model id, mode id, // thinking id or feature id from the old provider means nothing here. // Clearing them is enough — `derive` seeds the new provider's defaults. + drawnKeys = new Set(); + answeredKeys = new Set(); return { ...current, provider: providerId, @@ -572,7 +650,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP modeDisplay: null, thinkingOptionId: "", thinkingDisplay: null, - featureValues: {}, + options: {}, + optionEdits: {}, }; }), setModel: (modelId, display) => @@ -598,10 +677,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP publish((current) => ({ ...current, modeId, modeDisplay: display })), setThinking: (thinkingOptionId, display) => publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })), - setFeatureValue: (featureId, value) => + setOption: (key, value) => publish((current) => ({ ...current, - featureValues: { ...current.featureValues, [featureId]: value }, + optionEdits: { ...current.optionEdits, [key]: value }, })), setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })), setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })), diff --git a/packages/app/src/agent-profiles/internal/profile-summary.ts b/packages/app/src/agent-profiles/internal/profile-summary.ts index a0507e6b94..28431b093c 100644 --- a/packages/app/src/agent-profiles/internal/profile-summary.ts +++ b/packages/app/src/agent-profiles/internal/profile-summary.ts @@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: { tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) }); } - const featureCount = Object.keys(input.profile.featureValues ?? {}).length; + const featureCount = Object.keys(input.profile.options ?? {}).length; if (featureCount > 0) { tags.push({ id: "features", label: input.formatFeatureCount(featureCount) }); } diff --git a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts index c4b97ec229..589d4d0b29 100644 --- a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts +++ b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts @@ -117,9 +117,7 @@ export function useAgentProfilePicker( modelId: resolved.modelId, modeId: resolved.modeId, thinkingOptionId: resolved.thinkingOptionId, - ...(Object.keys(resolved.featureValues).length > 0 - ? { featureValues: resolved.featureValues } - : {}), + ...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}), }), ).catch((error) => { console.warn("[useAgentProfilePicker] persist profile selection failed", error); diff --git a/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts b/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts index fb78320ca1..d7cfb2b6e6 100644 --- a/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts +++ b/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts @@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: { }, [entries, input.model]); } -export function useAgentProfileFormFeatures(input: { +export function useAgentProfileFormControls(input: { serverId: string; model: AgentProfileFormModel; state: AgentProfileFormState; @@ -35,14 +35,14 @@ export function useAgentProfileFormFeatures(input: { const { t } = useTranslation(); const client = useHostRuntimeClient(input.serverId); const isConnected = useHostRuntimeIsConnected(input.serverId); - const request = input.state.featureRequest; - const requestKey = input.state.featureRequestKey; + const request = input.state.controlsRequest; + const requestKey = input.state.controlsRequestKey; // `dataShape: "value"` and not `"list"`: a list shape keeps the previous // page's data while the next request is in flight, which would hand the // features of one provider/model combination to the key of another. - const featuresQuery = useFetchQuery({ - queryKey: ["agentProfileFeatures", input.serverId, requestKey], + const controlsQuery = useFetchQuery({ + queryKey: ["agentProfileControls", input.serverId, requestKey], dataShape: "value", staleTimeMs: 5 * 60 * 1000, enabled: Boolean(client && isConnected && request), @@ -51,28 +51,28 @@ export function useAgentProfileFormFeatures(input: { if (!client || !request) { throw new Error(t("workspace.terminal.hostDisconnected")); } - const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD }); + const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD }); if (payload.error) { throw new Error(payload.error); } - return payload.features ?? []; + return { controls: payload.controls ?? [], options: payload.options ?? {} }; }, }); - const { data, isError } = featuresQuery; + const { data, isError } = controlsQuery; const { model } = input; useEffect(() => { if (!requestKey || !data) { return; } - model.applyFeatures(requestKey, data); + model.applyControls(requestKey, data); }, [data, model, requestKey]); useEffect(() => { if (!requestKey || !isError) { return; } - model.applyFeaturesUnavailable(requestKey); + model.applyControlsUnavailable(requestKey); }, [isError, model, requestKey]); } diff --git a/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx index 53e1dede80..1fb3c0620a 100644 --- a/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx +++ b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import type { AgentFeature } from "@getpaseo/protocol/agent-types"; +import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls"; import type { AgentProfile } from "@getpaseo/protocol/messages"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -24,7 +25,7 @@ import type { } from "../internal/profile-form-model"; import { useAgentProfileFormCatalog, - useAgentProfileFormFeatures, + useAgentProfileFormControls, } from "../internal/use-profile-form-inputs"; import { useAgentProfileFormModel, @@ -140,7 +141,7 @@ function OpenAgentProfileEditModal({ const model = useAgentProfileFormModel(snapshot); const state = useAgentProfileFormState(model); useAgentProfileFormCatalog({ serverId, model }); - useAgentProfileFormFeatures({ serverId, model, state }); + useAgentProfileFormControls({ serverId, model, state }); const sheetHeader = useMemo( () => ({ @@ -327,16 +328,17 @@ function OpenAgentProfileEditModal({ /> ) : null} - {state.disclosure.showFeaturesField ? ( + {state.disclosure.showControlsField ? ( - {state.features.map((feature, index) => ( - ( + model.setFeatureValue(feature.id, value), - [feature.id, model], + (value: boolean) => model.setOption(control.key, value), + [control.key, model], ); const handleSelect = useCallback( - (value: string) => model.setFeatureValue(feature.id, value), - [feature.id, model], + (value: string) => model.setOption(control.key, value), + [control.key, model], ); const selectOptions = useMemo[]>( () => - feature.type === "select" - ? feature.options.map((option) => ({ - id: option.id, - value: option.id, - label: option.label, - ...(option.description ? { description: option.description } : {}), - testID: `agent-profile-feature-option-${feature.id}-${option.id}`, + control.type === "select" + ? control.choices.map((choice) => ({ + id: choice.value, + value: choice.value, + label: choice.label, + ...(choice.description ? { description: choice.description } : {}), + testID: `agent-profile-feature-option-${control.key}-${choice.value}`, })) : [], - [feature], + [control], ); + const selectedValue = control.type === "select" ? readSelectOption(control, options) : null; const selectedDisplay = useMemo(() => { - if (feature.type !== "select" || !feature.value) { + if (control.type !== "select" || !selectedValue) { return null; } - const option = feature.options.find((candidate) => candidate.id === feature.value); - return { label: option?.label ?? feature.value }; - }, [feature]); + const choice = control.choices.find((candidate) => candidate.value === selectedValue); + return { label: choice?.label ?? selectedValue }; + }, [control, selectedValue]); - if (feature.type === "select") { + if (control.type === "select") { return ( - + ); } return ( - + - {feature.label} + {control.label} - {feature.description ? ( + {control.description ? ( - {feature.description} + {control.description} ) : null} ); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index ef22abfa48..761e73bac6 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1259,7 +1259,8 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent) if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) { reasons.push("agent.runtimeInfo.thinkingOptionId"); } - if (left.features !== right.features) reasons.push("agent.features"); + if (left.options !== right.options) reasons.push("agent.options"); + if (left.controls !== right.controls) reasons.push("agent.controls"); return reasons; } diff --git a/packages/app/src/client-slash-commands/index.test.ts b/packages/app/src/client-slash-commands/index.test.ts index c6e86be582..dc2850615f 100644 --- a/packages/app/src/client-slash-commands/index.test.ts +++ b/packages/app/src/client-slash-commands/index.test.ts @@ -40,16 +40,7 @@ function createAgent(overrides: Partial = {}): Agent { cwd: "/repo", model: "agent-model", thinkingOptionId: "think-hard", - features: [ - { type: "toggle", id: "web-search", label: "Web search", value: true }, - { - type: "select", - id: "effort", - label: "Effort", - value: "high", - options: [{ id: "high", label: "High" }], - }, - ], + options: { "web-search": true, effort: "high" }, parentAgentId: null, labels: {}, ...overrides, @@ -114,7 +105,7 @@ describe("buildDraftAgentSetup", () => { modeId: "mode-current", model: "agent-model", thinkingOptionId: "think-hard", - featureValues: { + providerOptions: { "web-search": true, effort: "high", }, diff --git a/packages/app/src/client-slash-commands/index.ts b/packages/app/src/client-slash-commands/index.ts index 5d1a31f7d2..9a85d01591 100644 --- a/packages/app/src/client-slash-commands/index.ts +++ b/packages/app/src/client-slash-commands/index.ts @@ -65,17 +65,12 @@ export function resolveClientSlashCommand(input: { } export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup { - const featureValues: Record = {}; - for (const feature of agent.features ?? []) { - featureValues[feature.id] = feature.value; - } - return { provider: agent.provider, cwd: agent.cwd, modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, model: agent.model ?? agent.runtimeInfo?.model ?? null, thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, - featureValues, + providerOptions: { ...agent.options }, }; } diff --git a/packages/app/src/command-center/agent-control-contributions.test.ts b/packages/app/src/command-center/agent-control-contributions.test.ts index 6fe8f91bb6..929804d913 100644 --- a/packages/app/src/command-center/agent-control-contributions.test.ts +++ b/packages/app/src/command-center/agent-control-contributions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types"; +import type { + AgentMode, + AgentOptionControl, + AgentSelectOption, +} from "@getpaseo/protocol/agent-types"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import type { CommandCenterContribution } from "./contributions"; import { @@ -31,7 +35,7 @@ const ICONS: AgentControlContributionSource["icons"] = { thinking: TestIcon, planMode: TestIcon, mode: () => TestIcon, - feature: () => TestIcon, + control: () => TestIcon, }; function provider(input: { @@ -65,8 +69,8 @@ function provider(input: { }; } -function toggleFeature(id: string, value = false, icon?: string): AgentFeature { - return { type: "toggle", id, label: id, value, icon }; +function toggleControl(key: string, icon?: string): AgentOptionControl { + return { type: "toggle", key, label: key, icon }; } function makeSource( @@ -75,7 +79,7 @@ function makeSource( models?: Partial; thinking?: Partial; modes?: Partial>; - features?: Partial; + optionControls?: Partial; } = {}, ): AgentControlContributionSource { return { @@ -104,10 +108,11 @@ function makeSource( select: () => undefined, ...overrides.modes, }, - features: { - list: [], - set: () => undefined, - ...overrides.features, + optionControls: { + controls: [], + options: {}, + onSetOption: () => undefined, + ...overrides.optionControls, }, }; } @@ -133,7 +138,7 @@ describe("Command Center agent-control contributions", () => { const contributions = buildAgentControlContributions({ ...makeSource({ models: { providers: [provider({ id: "claude", label: "Claude" })] }, - features: { list: [toggleFeature("plan_mode", true)] }, + optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } }, }), modes: undefined, }); @@ -323,9 +328,10 @@ describe("Command Center agent-control contributions", () => { selectedId: "default", defaultModeId: "default", }, - features: { - list: [toggleFeature("plan_mode", true, "list-todo")], - set: (id, value) => { + optionControls: { + controls: [toggleControl("plan_mode", "list-todo")], + options: { plan_mode: true }, + onSetOption: (id, value) => { calls.push([id, value]); }, }, @@ -341,9 +347,10 @@ describe("Command Center agent-control contributions", () => { const calls: [string, unknown][] = []; const contributions = buildAgentControlContributions( makeSource({ - features: { - list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)], - set: (id, value) => { + optionControls: { + controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")], + options: { fast_mode: false, auto_accept: true }, + onSetOption: (id, value) => { calls.push([id, value]); }, }, @@ -376,7 +383,7 @@ describe("Command Center agent-control contributions", () => { selectedId: "default", defaultModeId: "default", }, - features: { list: [toggleFeature("fast_mode")] }, + optionControls: { controls: [toggleControl("fast_mode")], options: {} }, }), ); const ranks = new Map(); diff --git a/packages/app/src/command-center/agent-control-contributions.ts b/packages/app/src/command-center/agent-control-contributions.ts index d8f31523e9..b16ee90549 100644 --- a/packages/app/src/command-center/agent-control-contributions.ts +++ b/packages/app/src/command-center/agent-control-contributions.ts @@ -1,9 +1,10 @@ import type { - AgentFeature, AgentMode, + AgentOptionControl, AgentProvider, AgentSelectOption, } from "@getpaseo/protocol/agent-types"; +import { readToggleOption, type ComposerOptionControls } from "@/agent-controls/option-controls"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; import { @@ -61,7 +62,7 @@ export interface AgentControlContributionIcons { thinking: CommandCenterIcon; planMode: CommandCenterIcon; mode(modeId: string): CommandCenterIcon; - feature(feature: AgentFeature): CommandCenterIcon; + control(control: AgentOptionControl): CommandCenterIcon; } interface CommandCenterChoice { @@ -105,10 +106,7 @@ export interface AgentControlContributionSource { defaultModeId: string | null; select(modeId: string): void | Promise; }; - features: { - list: readonly AgentFeature[]; - set(featureId: string, value: unknown): void | Promise; - }; + optionControls: ComposerOptionControls; } function buildContributions( @@ -242,18 +240,19 @@ function toggleChoices(input: { } function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { - const planFeature = source.features.list.find( - (feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle", + const { controls, options, onSetOption } = source.optionControls; + const planControl = controls.find( + (control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle", ); let choices: CommandCenterChoice[] = []; - if (planFeature?.type === "toggle") { + if (planControl?.type === "toggle") { choices = toggleChoices({ source, key: "plan", - icon: source.icons.feature(planFeature), - isOn: planFeature.value, - turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true), - turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false), + icon: source.icons.control(planControl), + isOn: readToggleOption(planControl, options), + turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true), + turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false), }); } else if (source.modes) { const modes = source.modes; @@ -280,18 +279,19 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent } function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { - const fastFeature = source.features.list.find( - (feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle", + const { controls, options, onSetOption } = source.optionControls; + const fastControl = controls.find( + (control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle", ); const choices = - fastFeature?.type === "toggle" + fastControl?.type === "toggle" ? toggleChoices({ source, key: "fast", - icon: source.icons.feature(fastFeature), - isOn: fastFeature.value, - turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true), - turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false), + icon: source.icons.control(fastControl), + isOn: readToggleOption(fastControl, options), + turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true), + turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false), }) : []; return { diff --git a/packages/app/src/command-center/agent-control-registration.tsx b/packages/app/src/command-center/agent-control-registration.tsx index 21ce492373..81391dba10 100644 --- a/packages/app/src/command-center/agent-control-registration.tsx +++ b/packages/app/src/command-center/agent-control-registration.tsx @@ -1,11 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import type { - AgentFeature, - AgentMode, - AgentProvider, - AgentSelectOption, -} from "@getpaseo/protocol/agent-types"; +import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getAgentFeatureIcon, @@ -14,6 +9,7 @@ import { ThinkingIcon, } from "@/agent-controls/icons"; import { useProviderIcons } from "@/components/provider-icons"; +import type { ComposerOptionControls } from "@/agent-controls/option-controls"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { buildAgentControlContributions, @@ -44,15 +40,15 @@ export interface AgentControlCommandCenterSource { selectedId: string | null | undefined; select(modeId: string): void | Promise; }; - features: { - list: readonly AgentFeature[] | undefined; - set?: (featureId: string, value: unknown) => void | Promise; - }; + /** Absent when the surface cannot change options (the agent has no client). */ + optionControls: ComposerOptionControls | null; } -function ignoreAgentFeatureChange(): void { - return; -} +const NO_OPTION_CONTROLS: ComposerOptionControls = { + controls: [], + options: {}, + onSetOption: () => undefined, +}; function resolveDefaultModeId( provider: string | null | undefined, @@ -87,7 +83,7 @@ export function useAgentControlCommandCenterActions(input: { }): void { const { t } = useTranslation(); const { controls } = input; - const { features, models, modes, thinking } = controls; + const { optionControls, models, modes, thinking } = controls; const getProviderIcon = useProviderIcons(controls.serverId); const actions = useMemo( () => @@ -104,7 +100,7 @@ export function useAgentControlCommandCenterActions(input: { getCommandCenterIcon( getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions), ), - feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)), + control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)), }, models: { providers: models.providers, @@ -123,10 +119,7 @@ export function useAgentControlCommandCenterActions(input: { modes?.select, resolveDefaultModeId(controls.provider, controls.providerDefinitions), ), - features: { - list: features.set ? (features.list ?? []) : [], - set: features.set ?? ignoreAgentFeatureChange, - }, + optionControls: optionControls ?? NO_OPTION_CONTROLS, }), [ getProviderIcon, @@ -134,8 +127,7 @@ export function useAgentControlCommandCenterActions(input: { controls.provider, controls.providerDefinitions, controls.serverId, - features.list, - features.set, + optionControls, models.providers, models.select, models.selectedModelId, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index cee4fc2dea..4b5c08f425 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -87,6 +87,8 @@ export interface ComboboxProps { }) => ReactElement; onSearchQueryChange?: (query: string) => void; searchable?: boolean; + /** The caller answers the search query itself, so the options are shown as given. */ + remoteSearch?: boolean; placeholder?: string; searchPlaceholder?: string; emptyText?: string; @@ -1280,6 +1282,10 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null { ); } +function filtersLocally(input: { searchable: boolean; remoteSearch?: boolean }): boolean { + return input.searchable && !input.remoteSearch; +} + export function Combobox({ options, value, @@ -1287,6 +1293,7 @@ export function Combobox({ renderOption, onSearchQueryChange, searchable = true, + remoteSearch, placeholder, searchPlaceholder, emptyText, @@ -1446,7 +1453,7 @@ export function Combobox({ buildVisibleComboboxOptions({ options, searchQuery, - searchable, + searchable: filtersLocally({ searchable, remoteSearch }), allowCustomValue, customValuePrefix, customValueDescription, @@ -1458,6 +1465,7 @@ export function Combobox({ customValueKind, customValuePrefix, options, + remoteSearch, searchQuery, searchable, ], diff --git a/packages/app/src/composer/agent-composer/index.test.ts b/packages/app/src/composer/agent-composer/index.test.ts new file mode 100644 index 0000000000..3eb859d726 --- /dev/null +++ b/packages/app/src/composer/agent-composer/index.test.ts @@ -0,0 +1,434 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { resolveAgentModelSelection } from "@/composer/agent-controls/utils"; +import { filterSelectableModels } from "@/provider-selection/model-catalog"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { useDraftStore } from "@/stores/draft-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { providersSnapshotQueryRoot } from "@/data/providers-snapshot"; +import { agentComposer, createAgentComposer } from "./index"; + +const serverId = "host-1"; +const agentId = "agent-1"; +const draftKey = buildDraftStoreKey({ serverId, agentId }); + +const entries: ProviderSnapshotEntry[] = [ + { + provider: "claude", + status: "ready", + enabled: true, + models: [ + { + provider: "claude", + id: "opus", + label: "Opus", + isDefault: true, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + { + provider: "claude", + id: "claude-sonnet-4", + label: "Sonnet 4", + aliases: ["sonnet"], + thinkingOptions: [{ id: "low", label: "Low" }], + }, + ], + }, +]; + +function agent(overrides: Partial = {}): Agent { + return { + serverId, + id: agentId, + provider: "claude", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: new Date("2026-11-04T10:00:00.000Z"), + updatedAt: new Date("2026-11-04T10:00:00.000Z"), + lastUserMessageAt: null, + lastActivityAt: new Date("2026-11-04T10:00:00.000Z"), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: "default", + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + cwd: "/repo", + workspaceId: "workspace-1", + model: null, + parentAgentId: null, + labels: {}, + ...overrides, + }; +} + +function fakeClient() { + const calls: string[] = []; + const client = { + async getProvidersSnapshot() { + if (hostCatalog.failing) throw new Error("The host is busy."); + return { + entries: hostCatalog.entries, + generatedAt: "2026-11-04T10:00:00.000Z", + requestId: "snapshot", + }; + }, + async setAgentModel(id: string, model: string | null) { + calls.push(`model:${id}:${model}`); + if (model === "nope") throw new Error("The provider refused model 'nope'"); + }, + async setAgentMetadata(id: string, patch: unknown) { + calls.push(`metadata:${id}:${JSON.stringify(patch)}`); + }, + async setAgentOptions(id: string, patch: unknown) { + calls.push(`options:${id}:${JSON.stringify(patch)}`); + }, + async setAgentMode(id: string, mode: string) { + calls.push(`mode:${id}:${mode}`); + return null; + }, + async setAgentThinkingOption(id: string, thinking: string) { + calls.push(`thinking:${id}:${thinking}`); + return null; + }, + }; + return { client: client as unknown as DaemonClient, calls }; +} + +/** What the host answers the next catalog request with. */ +const hostCatalog: { entries: ProviderSnapshotEntry[]; failing: boolean } = { + entries, + failing: false, +}; + +function openSession(running: Agent) { + const { client, calls } = fakeClient(); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: null, + features: { providersSnapshot: true }, + } as never); + store.setAgents(serverId, new Map([[running.id, running]])); + return calls; +} + +async function until(read: () => unknown, expected: unknown) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (Object.is(read(), expected)) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(read()).toBe(expected); +} + +let queryClient: QueryClient; + +function runtimeModel(model: string): Agent["runtimeInfo"] { + return { provider: "claude", sessionId: null, model }; +} + +function composer() { + return createAgentComposer(serverId, agentId, { queryClient }); +} + +beforeEach(() => { + queryClient = new QueryClient(); + hostCatalog.entries = entries; + hostCatalog.failing = false; + useDraftStore.setState({ drafts: {}, textReplacementRequestByDraftKey: {} }); +}); + +afterEach(() => { + useSessionStore.getState().clearSession(serverId); +}); + +describe("agent composer", () => { + it("reads without notifying anyone, so a read during a render updates no other component", () => { + const running = agent(); + const other = agent({ id: "agent-2", cwd: "/other-repo" }); + openSession(running); + useSessionStore.getState().setAgents( + serverId, + new Map([ + [running.id, running], + [other.id, other], + ]), + ); + const watched = createAgentComposer(serverId, running.id, { queryClient }); + const reader = createAgentComposer(serverId, other.id, { queryClient }); + let notifications = 0; + const stop = watched.subscribe(() => { + notifications += 1; + }); + const before = notifications; + // The first read of another catalog creates its query in the shared cache. + reader.getState(); + expect(notifications).toBe(before); + stop(); + }); + + it("keeps the prompt in the stored draft while no composer is mounted", () => { + openSession(agent()); + const model = composer(); + model.insertText("Review "); + model.insertText("this diff.\n"); + expect(model.getState().prompt).toBe("Review this diff.\n"); + expect(useDraftStore.getState().getDraftInput(draftKey)?.text).toBe("Review this diff.\n"); + model.setPrompt("Start over"); + expect(model.getState().prompt).toBe("Start over"); + }); + + it("sends prompt commands through the mounted composer while it is mounted", () => { + openSession(agent()); + const model = composer(); + const mounted: string[] = []; + const detach = model.attachInput({ + isLive: () => true, + setText: (text) => mounted.push(`set:${text}`), + insertText: (text) => mounted.push(`insert:${text}`), + }); + model.insertText("at the cursor"); + model.setPrompt("replaced"); + expect(mounted).toEqual(["insert:at the cursor", "set:replaced"]); + expect(useDraftStore.getState().getDraftInput(draftKey)).toBeUndefined(); + detach(); + model.insertText("stored"); + expect(model.getState().prompt).toBe("stored"); + }); + + it("stores the prompt and asks the draft's input to show it while the composer is mounted off screen", () => { + openSession(agent()); + const model = composer(); + const mounted: string[] = []; + model.attachInput({ + isLive: () => false, + setText: (text) => mounted.push(`set:${text}`), + insertText: (text) => mounted.push(`insert:${text}`), + }); + model.insertText("while away"); + expect(mounted).toEqual([]); + expect(model.getState().prompt).toBe("while away"); + expect(useDraftStore.getState().textReplacementRequestByDraftKey[draftKey]).toBe(1); + model.setPrompt("replaced"); + expect(model.getState().prompt).toBe("replaced"); + expect(useDraftStore.getState().textReplacementRequestByDraftKey[draftKey]).toBe(2); + }); + + it("notifies subscribers of a stored prompt and returns the same state until something changes", () => { + openSession(agent()); + const model = composer(); + let notified = 0; + const stop = model.subscribe(() => { + notified += 1; + }); + const before = model.getState(); + expect(model.getState()).toBe(before); + model.insertText("x"); + expect(notified).toBeGreaterThan(0); + expect(model.getState()).not.toBe(before); + stop(); + }); + + it("adds and removes attachments in the stored draft", () => { + openSession(agent()); + const model = composer(); + const attachment = { + kind: "plugin_resource" as const, + pluginId: "review", + sourceId: "issues", + sourceTitle: "Issues", + sourceIcon: "CircleDot", + item: { + id: "1", + identifier: "ENG-1", + title: "Title", + subtitle: "Open", + url: "https://example.com/1", + text: "Issue ENG-1: Title", + resourceType: "issue", + }, + }; + model.changeAttachments((current) => [...current, attachment]); + expect(model.getState().attachments).toHaveLength(1); + model.changeAttachments(() => []); + expect(model.getState().attachments).toHaveLength(0); + }); + + it("reads mode, options and metadata from the agent, and sends commands to the daemon", async () => { + const calls = openSession( + agent({ currentModeId: "plan", options: { effort: "high" }, metadata: { flag: true } }), + ); + const model = composer(); + expect(model.getState()).toMatchObject({ + context: "agent", + provider: "claude", + mode: "plan", + options: { effort: "high" }, + metadata: { flag: true }, + }); + await model.setMetadata({ flag: null }); + await model.setOptions({ effort: "low" }); + await model.setMode("default"); + expect(calls).toEqual([ + 'metadata:agent-1:{"flag":null}', + 'options:agent-1:{"effort":"low"}', + "mode:agent-1:default", + ]); + await expect(model.setProvider("codex")).rejects.toThrow( + "A running agent cannot change provider.", + ); + }); + + it("rejects a model the provider refuses with the provider's message", async () => { + openSession(agent()); + await expect(composer().setModel("nope")).rejects.toThrow("The provider refused model 'nope'"); + }); + + it("takes the catalog the host changed while the picker was not open, once the host reconnects", async () => { + openSession(agent({ runtimeInfo: runtimeModel("opus") })); + const model = composer(); + const stop = model.subscribe(() => {}); + await until(() => model.getState().thinking, "high"); + + // The host changes the catalog while the client is away. Reconnecting invalidates the provider + // catalogs, which refetches the ones something is watching. + hostCatalog.entries = [ + { + ...entries[0], + models: [{ ...entries[0].models![0], defaultThinkingOptionId: "low" }], + }, + ] as ProviderSnapshotEntry[]; + await queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) }); + + await until(() => model.getState().thinking, "low"); + stop(); + }); + + it("asks for the catalog again after a failed load once the host reconnects", async () => { + hostCatalog.failing = true; + openSession(agent({ runtimeInfo: runtimeModel("opus") })); + const model = composer(); + const stop = model.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(model.getState().thinking).toBeNull(); + + hostCatalog.failing = false; + await queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) }); + + await until(() => model.getState().thinking, "high"); + stop(); + }); + + it("watches the stores once however many components watch the composer, and only for its own draft", () => { + openSession(agent()); + const model = composer(); + const calls = [0, 0, 0]; + const stops: Array<() => void> = []; + for (const index of calls.keys()) { + stops.push(model.subscribe(() => void (calls[index] += 1))); + } + const before = [...calls]; + model.insertText("typed"); + expect(calls.map((count, index) => count - before[index])).toEqual([1, 1, 1]); + + const unrelated = [...calls]; + useDraftStore.getState().replaceDraftText({ + draftKey: buildDraftStoreKey({ serverId, agentId: "agent-2" }), + text: "elsewhere", + }); + expect(calls).toEqual(unrelated); + for (const stop of stops) stop(); + }); + + function pickerShows(running: Agent, catalog: ProviderSnapshotEntry[] | null) { + const picker = resolveAgentModelSelection({ + models: filterSelectableModels(catalog?.[0]?.models ?? null), + runtimeModelId: running.runtimeInfo?.model ?? null, + configuredModelId: running.model, + runtimeThinkingOptionId: running.runtimeInfo?.thinkingOptionId ?? null, + explicitThinkingOptionId: running.thinkingOptionId, + }); + return { model: picker.activeModelId, thinking: picker.selectedThinkingId }; + } + + it.each([ + [ + "an alias of a listed model", + { runtimeInfo: { model: "sonnet" } }, + "sonnet", + "claude-sonnet-4", + "low", + ], + ["no reported model", { model: null }, null, "opus", "high"], + ["the configured model", { model: "opus", thinkingOptionId: "low" }, "opus", "opus", "low"], + ] as const)( + "shows what the picker shows for %s, before the catalog and after it loads", + async (_name, overrides, modelBefore, modelAfter, thinkingAfter) => { + const running = agent(overrides as unknown as Partial); + openSession(running); + const model = composer(); + // No catalog yet: the picker resolves with no models. + expect(model.getState()).toMatchObject(pickerShows(running, null)); + expect(model.getState().model).toBe(modelBefore); + const stop = model.subscribe(() => {}); + await until(() => model.getState().model, modelAfter); + expect(model.getState()).toMatchObject(pickerShows(running, entries)); + expect(model.getState().thinking).toBe(thinkingAfter); + stop(); + }, + ); + + it("is shared while its agent is in the session, across closing and reopening its tab", () => { + openSession(agent()); + const shared = agentComposer(serverId, agentId); + const detach = shared.attachInput({ isLive: () => true, setText() {}, insertText() {} }); + detach(); + expect(agentComposer(serverId, agentId)).toBe(shared); + const stop = shared.subscribe(() => {}); + stop(); + expect(agentComposer(serverId, agentId)).toBe(shared); + }); + + it("is released when the last holder lets go of a composer whose agent was removed meanwhile", () => { + openSession(agent()); + const mounted = agentComposer(serverId, agentId); + const detach = mounted.attachInput({ isLive: () => true, setText() {}, insertText() {} }); + useSessionStore.getState().setAgents(serverId, new Map()); + // Held, so the session change did not release it, and nothing changes in the session after. + expect(agentComposer(serverId, agentId)).toBe(mounted); + detach(); + expect(agentComposer(serverId, agentId)).not.toBe(mounted); + + useSessionStore.getState().setAgents(serverId, new Map([[agentId, agent()]])); + const watched = agentComposer(serverId, agentId); + const stop = watched.subscribe(() => {}); + useSessionStore.getState().clearSession(serverId); + expect(agentComposer(serverId, agentId)).toBe(watched); + stop(); + expect(agentComposer(serverId, agentId)).not.toBe(watched); + }); + + it("is released when its agent is archived and nothing holds it", () => { + openSession(agent()); + const idle = agentComposer(serverId, agentId); + useSessionStore + .getState() + .setAgents(serverId, new Map([[agentId, agent({ archivedAt: new Date() })]])); + expect(agentComposer(serverId, agentId)).not.toBe(idle); + }); +}); diff --git a/packages/app/src/composer/agent-composer/index.ts b/packages/app/src/composer/agent-composer/index.ts new file mode 100644 index 0000000000..e7aaf6e833 --- /dev/null +++ b/packages/app/src/composer/agent-composer/index.ts @@ -0,0 +1,317 @@ +import { QueryObserver, type QueryClient } from "@tanstack/react-query"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { providersSnapshotQuery } from "@/data/providers-snapshot"; +import { replicaQueryOptions } from "@/data/query"; +import { queryClient as singletonQueryClient } from "@/data/query-client"; +import type { ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; +import { + resolveAgentModelSelection, + resolveProviderSnapshotEntry, +} from "@/composer/agent-controls/utils"; +import type { ComposerModel, ComposerState } from "@/composer/model"; +import { filterSelectableModels } from "@/provider-selection/model-catalog"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { useDraftStore } from "@/stores/draft-store"; +import { toDraftInputIfReady, type DraftRecord } from "@/stores/draft-store/state"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import type { UserComposerAttachment } from "@/attachments/types"; + +/** The mounted composer's input. While attached, prompt commands go through it. */ +export interface MountedComposerInput { + /** False while the composer is retained but its input is not on screen. */ + isLive(): boolean; + setText(text: string): void; + /** Inserts at the cursor, replacing the selection. */ + insertText(text: string): void; +} + +/** + * The composer of a running agent. It exists whether or not the agent's tab is open: the prompt + * and attachments are the agent's draft in the draft store, its settings are the session store's + * agent, and the model and thinking level resolve through the provider catalog exactly as the + * model picker does. + */ +export interface AgentComposer extends ComposerModel { + /** The mounted composer plugs in cursor-aware prompt commands for as long as it is mounted. */ + attachInput(input: MountedComposerInput): () => void; +} + +interface AgentComposerDeps { + queryClient: QueryClient; + cache?: ProviderSnapshotCache; + /** The last watcher or mounted composer let go, so the composer may be releasable. */ + onUnheld?: () => void; +} + +type Catalog = { status: "loading" } | { status: "ready"; entries: ProviderSnapshotEntry[] | null }; + +const NO_VALUES = Object.freeze({}); +const NO_ATTACHMENTS: readonly UserComposerAttachment[] = Object.freeze([]); + +interface ManagedAgentComposer extends AgentComposer { + /** The agent is gone or archived, and nothing watches the composer or has one mounted. */ + isReleasable(): boolean; +} + +export function createAgentComposer( + serverId: string, + agentId: string, + deps: AgentComposerDeps, +): ManagedAgentComposer { + const draftKey = buildDraftStoreKey({ serverId, agentId }); + const listeners = new Set<() => void>(); + // While something watches, the catalog has an observer: it fetches the catalog, and it is what + // makes the catalog an active query that reconnect repair refetches, a failed one included. + let observing: { cwd: string; stop: () => void } | null = null; + // One pair of store subscriptions serves every listener, from the first to the last. + let stopWatchingStores: (() => void) | null = null; + let mounted: MountedComposerInput | null = null; + let last: { + agent: Agent | null; + draft: DraftRecord | undefined; + catalog: Catalog; + state: ComposerState; + } | null = null; + + function readAgent(): Agent | null { + const session = useSessionStore.getState().sessions[serverId]; + return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; + } + + function client() { + const connected = useSessionStore.getState().sessions[serverId]?.client; + if (!connected) throw new Error("The host is not connected."); + return connected; + } + + function catalogQuery(cwd: string) { + return replicaQueryOptions( + providersSnapshotQuery({ + serverId, + cwd, + client, + queryClient: deps.queryClient, + cache: deps.cache, + }), + ); + } + + function notify() { + for (const listener of listeners) listener(); + } + + function observeCatalog() { + const agent = listeners.size > 0 ? readAgent() : null; + const session = useSessionStore.getState().sessions[serverId]; + const cwd = + agent && session?.client && session.serverInfo?.features?.providersSnapshot === true + ? agent.cwd + : null; + if ((observing?.cwd ?? null) === cwd) return; + observing?.stop(); + observing = null; + if (cwd === null) return; + // Watching again after the catalog was invalidated or failed while nothing watched refreshes it. + const options = { ...catalogQuery(cwd), refetchOnMount: true }; + observing = { cwd, stop: new QueryObserver(deps.queryClient, options).subscribe(notify) }; + } + + function watchStores() { + const stops = [ + useSessionStore.subscribe(() => { + observeCatalog(); + notify(); + }), + useDraftStore.subscribe((state, previous) => { + if (state.drafts[draftKey] !== previous.drafts[draftKey]) notify(); + }), + ]; + return () => { + for (const stop of stops) stop(); + }; + } + + function readCatalog(agent: Agent): Catalog { + const session = useSessionStore.getState().sessions[serverId]; + // The picker never loads a catalog from a host without it and shows the configured model. + if (session?.serverInfo?.features?.providersSnapshot !== true) { + return { status: "ready", entries: null }; + } + const query = catalogQuery(agent.cwd); + const state = deps.queryClient.getQueryState<{ entries: ProviderSnapshotEntry[] }>( + query.queryKey, + ); + // Nothing observes while nothing watches, so a read starts the one deduplicated load or + // refresh it needs and shows the cached catalog meanwhile. A read can run in another + // component's render and creating the query tells every query-cache subscriber, so it starts + // after the render. + const loads = state?.isInvalidated === true || (!state?.data && state?.status !== "error"); + if (loads && !observing && session.client) { + queueMicrotask(() => void deps.queryClient.prefetchQuery(query)); + } + if (state?.data) return { status: "ready", entries: state.data.entries }; + if (state?.status === "error") return { status: "ready", entries: null }; + return { status: "loading" }; + } + + function selectModel(agent: Agent, catalog: Catalog) { + // Until the catalog lands there are no models, which is what the picker resolves with too. + const entries = catalog.status === "ready" ? catalog.entries : null; + return resolveAgentModelSelection({ + models: filterSelectableModels( + resolveProviderSnapshotEntry(entries ?? undefined, agent.provider)?.models ?? null, + ), + runtimeModelId: agent.runtimeInfo?.model ?? null, + configuredModelId: agent.model, + runtimeThinkingOptionId: agent.runtimeInfo?.thinkingOptionId ?? null, + explicitThinkingOptionId: agent.thinkingOptionId, + }); + } + + function buildState( + agent: Agent | null, + catalog: Catalog, + record: DraftRecord | undefined, + ): ComposerState { + const draft = toDraftInputIfReady(record); + const selection = agent ? selectModel(agent, catalog) : null; + return { + context: "agent", + prompt: draft?.text ?? "", + attachments: draft?.attachments ?? NO_ATTACHMENTS, + provider: agent?.provider ?? null, + model: selection?.activeModelId ?? null, + mode: agent?.currentModeId ?? null, + thinking: selection?.selectedThinkingId ?? null, + options: agent?.options ?? NO_VALUES, + metadata: agent?.metadata ?? NO_VALUES, + }; + } + + function readDraft(): DraftRecord | undefined { + const record = useDraftStore.getState().drafts[draftKey]; + return record?.lifecycle === "active" ? record : undefined; + } + + function getState(): ComposerState { + const agent = readAgent(); + const draft = readDraft(); + const catalog = agent ? readCatalog(agent) : ({ status: "loading" } as const); + if ( + last && + last.agent === agent && + last.draft === draft && + sameCatalog(last.catalog, catalog) + ) { + return last.state; + } + last = { agent, draft, catalog, state: buildState(agent, catalog, draft) }; + return last.state; + } + + function unheld() { + if (listeners.size === 0 && mounted === null) deps.onUnheld?.(); + } + + return { + isReleasable() { + if (listeners.size > 0 || mounted !== null) return false; + const agent = readAgent(); + return agent === null || agent.archivedAt != null; + }, + getState, + subscribe(listener) { + listeners.add(listener); + stopWatchingStores ??= watchStores(); + observeCatalog(); + getState(); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + stopWatchingStores?.(); + stopWatchingStores = null; + } + observeCatalog(); + unheld(); + }; + }, + attachInput(input) { + mounted = input; + return () => { + if (mounted === input) mounted = null; + unheld(); + }; + }, + setPrompt(text) { + if (mounted?.isLive()) mounted.setText(text); + else useDraftStore.getState().replaceDraftText({ draftKey, text }); + }, + insertText(text) { + if (mounted?.isLive()) mounted.insertText(text); + else { + const current = useDraftStore.getState().getDraftInput(draftKey)?.text ?? ""; + useDraftStore.getState().replaceDraftText({ draftKey, text: current + text }); + } + }, + changeAttachments(updater) { + useDraftStore.getState().updateDraftInput({ + draftKey, + update: (current) => ({ ...current, attachments: updater(current.attachments) }), + }); + }, + async setProvider() { + throw new Error("A running agent cannot change provider."); + }, + async setModel(modelId) { + await client().setAgentModel(agentId, modelId); + }, + async setMode(modeId) { + await client().setAgentMode(agentId, modeId); + }, + async setThinking(thinkingOptionId) { + await client().setAgentThinkingOption(agentId, thinkingOptionId); + }, + async setOptions(patch) { + await client().setAgentOptions(agentId, patch); + }, + async setMetadata(patch) { + await client().setAgentMetadata(agentId, patch); + }, + }; +} + +function sameCatalog(left: Catalog, right: Catalog): boolean { + return ( + left.status === right.status && + (left.status === "loading" || left.entries === (right as typeof left).entries) + ); +} + +const composers = new Map(); +let stopReleasing: (() => void) | null = null; + +/** + * The composer of a running agent; the same one for every caller for as long as the agent is in the + * session and not archived, so a command a plugin saved keeps reaching the composer a later tab + * mounts. It is released when the agent is gone or archived and nothing holds it, checked when the + * session changes and when the last holder lets go. + */ +export function agentComposer(serverId: string, agentId: string): AgentComposer { + stopReleasing ??= useSessionStore.subscribe(releaseReleasableComposers); + const key = `${serverId}:${agentId}`; + let composer = composers.get(key); + if (!composer) { + composer = createAgentComposer(serverId, agentId, { + queryClient: singletonQueryClient, + onUnheld: releaseReleasableComposers, + }); + composers.set(key, composer); + } + return composer; +} + +function releaseReleasableComposers() { + for (const [key, composer] of composers) { + if (composer.isReleasable()) composers.delete(key); + } +} diff --git a/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx b/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx new file mode 100644 index 0000000000..8a4905b235 --- /dev/null +++ b/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx @@ -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 }; + +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 ( + + + + ); +} + +function renderControls(items: ComposerControlItem[], surface: "toolbar" | "sheet" = "toolbar") { + const toast = createToast(); + render(); + 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((_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; + 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 ; + } + 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(); + fireEvent.click(screen.getByTestId("agent-feature-fast_mode")); + expect(writes).toEqual([["fast_mode", false]]); + }); +}); diff --git a/packages/app/src/composer/agent-controls/composer-control.tsx b/packages/app/src/composer/agent-controls/composer-control.tsx new file mode 100644 index 0000000000..f98be7261f --- /dev/null +++ b/packages/app/src/composer/agent-controls/composer-control.tsx @@ -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; + }) + | (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; + /** 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; + }); + +/** `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("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; + +/** 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) => { + 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(null); + const isSheet = surface === "sheet"; + const isField = surface === "field"; + const sheetHeader = useMemo(() => ({ 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 ? ( + + ) : ( + + ); + + return ( + <> + {isSheet || isField ? ( + trigger + ) : ( + + + {trigger} + + + {view.tooltip} + + + )} + {view.opensList ? ( + + ) : 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, iconColor]); + return ( + + ); +}); + +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) => ( + + ))} + + ); +} + +function ComposerControlSlot({ + item, + open, + onOpenIdChange, + ...rest +}: Omit & { item: ComposerControlItem; open: boolean }) { + const handleOpenChange = useCallback( + (next: boolean) => onOpenIdChange(next ? item.id : null), + [item.id, onOpenIdChange], + ); + return ; +} + +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, + }, +})); diff --git a/packages/app/src/composer/agent-controls/control-items.tsx b/packages/app/src/composer/agent-controls/control-items.tsx new file mode 100644 index 0000000000..590f60dfd3 --- /dev/null +++ b/packages/app/src/composer/agent-controls/control-items.tsx @@ -0,0 +1,161 @@ +import { useMemo } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { DensityIcon } from "@/components/ui/density-icon"; +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) => , + }; +} + +function ThinkingOption({ + option, + selected, + active, + onPress, +}: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; +}) { + const leadingSlot = useMemo( + () => , + [], + ); + return ( + + ); +} diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index f86721fb98..a3bb484b94 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -1,13 +1,4 @@ -import { - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactElement, - type RefObject, -} from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { router } from "expo-router"; import { @@ -17,16 +8,12 @@ import { Keyboard, useWindowDimensions, type LayoutChangeEvent, - type PressableStateCallbackType, - type StyleProp, - type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { Settings2 } from "lucide-react-native"; -import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons"; +import { readSelectOption, type ComposerOptionControls } from "@/agent-controls/option-controls"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; -import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { buildProviderSelectorProviders, @@ -38,7 +25,6 @@ import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; -import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { AgentModeControl, useLiveAgentModeControl, @@ -47,18 +33,18 @@ import { import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { - AgentFeature, + AgentOptionControl, + JsonValue, + ProviderOptions, AgentMode, AgentModelDefinition, AgentProvider, } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { - getFeatureHighlightColor, - isFeatureActive, - getFeatureTooltip, getAgentControlHintKey, resolveAgentModelSelection, + resolveProviderSnapshotEntry, } from "@/composer/agent-controls/utils"; import { useIsCompactFormFactor } from "@/constants/layout"; import { @@ -68,10 +54,8 @@ import { toolbarGlyphColor, type ToolbarGlyphs, TOUCH_ICON_SIZE, - toolbarContentSlot, toolbarSlot, } from "@/components/ui/control-geometry"; -import { DensityIcon } from "@/components/ui/density-icon"; import { readMeasuredWidth } from "@/hooks/use-container-width"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; @@ -88,13 +72,21 @@ import { type ComposerControlDensity, type ComposerControlPresentation, } from "@/composer/agent-controls/layout"; -import { - ComposerControlLayoutProvider, - useComposerControlLayout, -} from "@/composer/agent-controls/layout-context"; +import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; -import { AgentControlTrigger } from "@/composer/agent-controls/control"; +import { + ComposerControls, + type ComposerControlItem, +} from "@/composer/agent-controls/composer-control"; +import { useAgentControlItems } from "@/composer/agent-controls/control-items"; import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; +import { useComposerSheetBridge } from "@/composer/agent-controls/sheet-bridge"; +import { draftComposerAgent } from "@/composer/draft/composer-agent"; +import { agentComposer } from "@/composer/agent-composer"; +import { composerModelFromHalves } from "@/composer/halves-model"; +import { ComposerModelProvider } from "@/composer/model"; +import { ComposerAgentProvider, useComposerInput } from "@/composer/session"; +import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items"; import { useAgentProfileEditor, useAgentProfilePicker, @@ -111,15 +103,11 @@ interface AgentControlOption { label: string; } -type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; - const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = []; +const EMPTY_OPTIONS: ProviderOptions = {}; interface ControlledAgentControlsProps { provider: string; - providerOptions?: AgentControlOption[]; - selectedProviderId?: string; - onSelectProvider?: (providerId: string) => void; modelOptions?: AgentControlOption[]; selectedModelId?: string; onSelectModel?: (modelId: string) => void; @@ -135,8 +123,7 @@ interface ControlledAgentControlsProps { onEditAgentProfiles?: () => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void; onEditAgentProfile?: (profileId: string) => void; - features?: AgentFeature[]; - onSetFeature?: (featureId: string, value: unknown) => void; + optionControls?: ComposerOptionControls; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; @@ -158,13 +145,17 @@ export interface DraftAgentControlsProps { isModelLoading: boolean; modelSelectorProviders: ProviderSelectorProvider[]; isAllModelsLoading: boolean; - onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void; + /** `false` when the provider is unknown or not ready. */ + onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => boolean; thinkingOptions: NonNullable; selectedThinkingOptionId: string; onSelectThinkingOption: (thinkingOptionId: string) => void; onApplyAgentProfile: DraftAgentProfileControls["applyProfile"]; - features?: AgentFeature[]; - onSetFeature?: (featureId: string, value: unknown) => void; + /** The controls the daemon described for the draft; absent until it answers. */ + optionControls?: ComposerOptionControls; + /** What the draft will create with, edits included, whether or not the daemon has answered. */ + options: ProviderOptions; + onSetOption: (key: string, value: unknown) => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; @@ -172,6 +163,11 @@ export interface DraftAgentControlsProps { disabled?: boolean; modelSelectorServerId?: string | null; isCompactLayout?: boolean; + /** Which composer a plugin's `useComposer()` reports. */ + context?: "draft" | "new-workspace"; + /** What plugins set on the agent this draft creates. */ + metadata: Record; + onSetMetadata: (patch: Record) => void; } interface AgentControlsProps { @@ -224,59 +220,30 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) { return modeControl?.providerDefinitions ?? EMPTY_AGENT_PROVIDER_DEFINITIONS; } -function getFeatureIconColor( - feature: AgentFeature, - palette: { - blue: { 400: string }; - green: { 400: string }; - yellow: { 400: string }; - }, - restColor: string, -): string { - if (!isFeatureActive(feature)) { - return restColor; - } - - switch (getFeatureHighlightColor(feature.id)) { - case "blue": - return palette.blue[400]; - case "green": - return palette.green[400]; - case "yellow": - return palette.yellow[400]; - default: - return restColor; - } -} - -type ActiveSheet = "thinking" | "features" | null; +type ActiveSheet = "features" | null; function resolveHasAnyControl({ - providerOptions, canSelectModel, thinkingOptions, - features, + controls, hasMode, + pluginItemCount, }: { - providerOptions: AgentControlOption[] | undefined; canSelectModel: boolean; thinkingOptions: AgentControlOption[] | undefined; - features: AgentFeature[] | undefined; + controls: readonly AgentOptionControl[] | undefined; hasMode: boolean; + pluginItemCount: number; }) { return ( - Boolean(providerOptions?.length) || canSelectModel || Boolean(thinkingOptions?.length) || - Boolean(features?.length) || - hasMode + Boolean(controls?.length) || + hasMode || + pluginItemCount > 0 ); } -function toComboboxOptions(options: AgentControlOption[] | undefined): ComboboxOption[] { - return (options ?? []).map((o) => ({ id: o.id, label: o.label })); -} - function toThinkingControlOptions(options: AgentControlOption[] | undefined): AgentControlOption[] { return (options ?? []).map((option) => ({ id: option.id, @@ -340,42 +307,21 @@ function buildFallbackModelSelectorProviders( ]; } -function makeBadgePressableStyle( - baseStyle: StyleProp, - disabledStyle: StyleProp, - disabled: boolean, - isOpen: boolean, -) { - return ({ pressed, hovered }: PressableStateCallbackType) => [ - baseStyle, - hovered && styles.modeBadgeHovered, - (pressed || isOpen) && styles.modeBadgePressed, - disabled && disabledStyle, - ]; -} - function pickSheetModel({ nextProviderId, modelId, - currentProvider, onSelectProviderAndModel, - onSelectProvider, onSelectModel, }: { nextProviderId: string; modelId: string; - currentProvider: string; onSelectProviderAndModel?: (provider: string, modelId: string) => void; - onSelectProvider?: (providerId: string) => void; onSelectModel?: (modelId: string) => void; }) { if (onSelectProviderAndModel) { onSelectProviderAndModel(nextProviderId, modelId); return; } - if (nextProviderId !== currentProvider) { - onSelectProvider?.(nextProviderId); - } onSelectModel?.(modelId); } @@ -406,10 +352,12 @@ type AgentControlsSlice = { cwd: string | null; runtimeModelId: string | null; model: string | null | undefined; - features: AgentFeature[] | undefined; + options: ProviderOptions | undefined; + controls: AgentOptionControl[] | undefined; runtimeThinkingOptionId: string | null; thinkingOptionId: string | null | undefined; lastUsage: unknown; + metadata: Record | undefined; } | null; function selectAgentControlsSlice( @@ -426,25 +374,17 @@ function selectAgentControlsSlice( cwd: currentAgent.cwd, runtimeModelId: currentAgent.runtimeInfo?.model ?? null, model: currentAgent.model, - features: currentAgent.features, + options: currentAgent.options, + controls: currentAgent.controls, runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null, thinkingOptionId: currentAgent.thinkingOptionId, lastUsage: currentAgent.lastUsage, + metadata: currentAgent.metadata, }; } -function resolveSnapshotSelectedEntry( - snapshotEntries: ReturnType["entries"], - agentProvider: string | undefined, -) { - if (!snapshotEntries || !agentProvider) { - return null; - } - return snapshotEntries.find((e) => e.provider === agentProvider) ?? null; -} - function resolveSnapshotModeIds( - entry: ReturnType, + entry: ReturnType, ): string[] | null { if (entry?.status !== "ready" || !entry.modes) { return null; @@ -477,24 +417,8 @@ function densityFolds(density: ComposerControlDensity): boolean { return resolveComposerControlPresentation(density).foldControls; } -function buildOpenChangeHandler( - selector: AgentControlSelector, - setOpenSelector: (next: AgentControlSelector | null) => void, - onDropdownClose?: () => void, -) { - return (nextOpen: boolean) => { - setOpenSelector(nextOpen ? selector : null); - if (!nextOpen) { - onDropdownClose?.(); - } - }; -} - function ControlledAgentControls({ provider, - providerOptions, - selectedProviderId, - onSelectProvider, modelOptions, selectedModelId, onSelectModel, @@ -510,8 +434,7 @@ function ControlledAgentControls({ onEditAgentProfiles, onCreateAgentProfile, onEditAgentProfile, - features, - onSetFeature, + optionControls, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, @@ -529,7 +452,7 @@ function ControlledAgentControls({ const toolbarGeometry = useMemo(() => composerToolbarGeometry(toolbarSizing), [toolbarSizing]); const { fontScale } = useWindowDimensions(); const [activeSheet, setActiveSheet] = useState(null); - const [openSelector, setOpenSelector] = useState(null); + const [openSelector, setOpenSelector] = useState(null); // A compact composer always folds its controls into the model sheet; the width budget only // decides whether the model keeps its label. const minimumDensity: ComposerControlDensity = isCompact ? "compact" : "full"; @@ -538,23 +461,12 @@ function ControlledAgentControls({ const densityRef = useRef(initialDensity); const availableWidthRef = useRef(0); - const providerAnchorRef = useRef(null); - const _modelAnchorRef = useRef(null); - const thinkingAnchorRef = useRef(null); - - const canSelectProvider = Boolean( - onSelectProvider && providerOptions && providerOptions.length > 0, - ); const canSelectModel = Boolean(onSelectModel); const canSelectThinking = Boolean( onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0, ); + const pluginItemCount = usePluginComposerItemCount(modelSelectorServerId); - const displayProvider = findOptionLabel( - providerOptions, - selectedProviderId, - t("agentControls.provider.fallback"), - ); const formattedThinkingOptions = useMemo( () => toThinkingControlOptions(thinkingOptions), [thinkingOptions], @@ -566,23 +478,24 @@ function ControlledAgentControls({ ); const hasAnyControl = resolveHasAnyControl({ - providerOptions, canSelectModel, thinkingOptions, - features, + controls: optionControls?.controls, hasMode: modeControl !== null && modeControl !== undefined, + pluginItemCount, }); const featureControls = useMemo( () => - (features ?? []).map((feature) => { - if (feature.type === "toggle") return { type: "toggle" as const }; - const selectedOption = feature.options.find((option) => option.id === feature.value); + (optionControls?.controls ?? []).map((control) => { + if (control.type === "toggle") return { type: "toggle" as const }; + const selected = readSelectOption(control, optionControls?.options ?? {}); + const choice = control.choices.find((candidate) => candidate.value === selected); return { type: "select" as const, - label: selectedOption?.label ?? feature.label, + label: choice?.label ?? control.label, }; }), - [features], + [optionControls], ); const controlPresence = useMemo( () => ({ @@ -590,9 +503,10 @@ function ControlledAgentControls({ hasThinking: canSelectThinking, hasMode: modeControl !== null && modeControl !== undefined, features: featureControls, + pluginItems: pluginItemCount, fontScale, }), - [canSelectModel, canSelectThinking, featureControls, fontScale, modeControl], + [canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemCount], ); const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]); const layoutContextValue = useMemo( @@ -641,64 +555,43 @@ function ControlledAgentControls({ } }, [updateDensityForWidth]); - const modelDisabled = disabled; - - const comboboxProviderOptions = useMemo( - () => toComboboxOptions(providerOptions), - [providerOptions], - ); const fallbackModelSelectorProviders = useMemo( () => buildFallbackModelSelectorProviders(provider, modelOptions), [modelOptions, provider], ); const effectiveModelSelectorProviders = modelSelectorProviders ?? fallbackModelSelectorProviders; - const comboboxThinkingOptions = useMemo( - () => toComboboxOptions(formattedThinkingOptions), - [formattedThinkingOptions], - ); - const renderThinkingOption = useCallback( - (args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => ( - - ), - [], + const thinking = useMemo( + () => + canSelectThinking && onSelectThinkingOption + ? { + options: formattedThinkingOptions, + selectedId: selectedThinkingOptionId, + onSelect: onSelectThinkingOption, + displayLabel: displayThinking, + } + : null, + [ + canSelectThinking, + displayThinking, + formattedThinkingOptions, + onSelectThinkingOption, + selectedThinkingOptionId, + ], ); + const items = useAgentControlItems({ + surface: presentation.foldControls ? "sheet" : "toolbar", + presentation, + thinking, + optionControls, + }); - const handleOpenChange = useCallback( - (selector: AgentControlSelector) => - buildOpenChangeHandler(selector, setOpenSelector, onDropdownClose), - [onDropdownClose], - ); - const handleSheetOpenChange = useCallback( - (selector: AgentControlSelector) => (nextOpen: boolean) => { - setOpenSelector(nextOpen ? selector : null); + const handleToolbarOpenChange = useCallback( + (id: string | null) => { + setOpenSelector(id); + if (id === null) onDropdownClose?.(); }, - [], - ); - - const handleProviderPress = useCallback(() => { - handleOpenChange("provider")(openSelector !== "provider"); - }, [handleOpenChange, openSelector]); - - const handleThinkingPress = useCallback(() => { - handleOpenChange("thinking")(openSelector !== "thinking"); - }, [handleOpenChange, openSelector]); - - const handleProviderOpenChange = useMemo(() => handleOpenChange("provider"), [handleOpenChange]); - const handleThinkingOpenChange = useMemo(() => handleOpenChange("thinking"), [handleOpenChange]); - - const handleProviderSelect = useCallback( - (id: string) => onSelectProvider?.(id), - [onSelectProvider], - ); - const handleThinkingSelect = useCallback( - (id: string) => onSelectThinkingOption?.(id), - [onSelectThinkingOption], + [onDropdownClose], ); const handleDesktopModelSelect = useCallback( @@ -714,17 +607,6 @@ function ControlledAgentControls({ [onSelectModel, onSelectProviderAndModel, provider], ); - const providerPressableStyle = useMemo( - () => - makeBadgePressableStyle( - styles.modeBadge, - styles.disabledBadge, - disabled || !canSelectProvider, - openSelector === "provider", - ), - [canSelectProvider, disabled, openSelector], - ); - const handleOpenSheet = useCallback((sheet: Exclude) => { Keyboard.dismiss(); setActiveSheet(sheet); @@ -735,26 +617,16 @@ function ControlledAgentControls({ if (!presentation.foldControls) onDropdownClose?.(); }, [onDropdownClose, presentation.foldControls]); - const handleSelectThinkingAndClose = useCallback( - (thinkingOptionId: string) => { - onSelectThinkingOption?.(thinkingOptionId); - setActiveSheet(null); - }, - [onSelectThinkingOption], - ); - const handleSheetModelSelect = useCallback( (nextProviderId: string, modelId: string) => { pickSheetModel({ nextProviderId, modelId, - currentProvider: provider, onSelectProviderAndModel, - onSelectProvider, onSelectModel, }); }, - [onSelectModel, onSelectProvider, onSelectProviderAndModel, provider], + [onSelectModel, onSelectProviderAndModel], ); if (!hasAnyControl) { @@ -767,14 +639,9 @@ function ControlledAgentControls({ {!presentation.foldControls ? ( void; + thinkingItems: ComposerControlItem[]; + featureItems: ComposerControlItem[]; onApplyAgentProfile?: (profileId: string) => void; onEditAgentProfiles?: () => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void; @@ -882,57 +719,34 @@ interface DesktopAgentControlsContentProps { agentProfiles: AgentProfilePicker | null; disabled: boolean; isModelLoading: boolean; - canSelectProvider: boolean; canSelectModel: boolean; - canSelectThinking: boolean; modelSelectorProviders: ProviderSelectorProvider[]; - modelDisabled: boolean; - comboboxProviderOptions: ComboboxOption[]; - comboboxThinkingOptions: ComboboxOption[]; - displayProvider: string; - displayThinking: string; - openSelector: AgentControlSelector | null; - providerAnchorRef: RefObject; - thinkingAnchorRef: RefObject; - providerPressableStyle: (state: PressableStateCallbackType) => StyleProp; - handleProviderPress: () => void; - handleThinkingPress: () => void; - handleProviderSelect: (id: string) => void; - handleThinkingSelect: (id: string) => void; - handleDesktopModelSelect: (providerId: string, modelId: string) => void; - handleProviderOpenChange: (open: boolean) => void; - handleThinkingOpenChange: (open: boolean) => void; - handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - handleNestedOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - renderThinkingOption: (args: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ReactElement; + openSelector: string | null; + handleSheetOpenChange: (id: string | null) => void; modeControl?: AgentModeControlValue | null; - presentation: ComposerControlPresentation; glyphs: ToolbarGlyphs; - activeSheet: ActiveSheet; - handleOpenSheet: (sheet: Exclude) => void; - handleCloseSheet: () => void; modelSelectorServerId: string | null; } -const DESKTOP_SEARCH_THRESHOLD = 6; +interface DesktopAgentControlsContentProps extends AgentControlsContentProps { + handleDesktopModelSelect: (providerId: string, modelId: string) => void; + handleToolbarOpenChange: (id: string | null) => void; + presentation: ComposerControlPresentation; + pluginItemCount: number; + activeSheet: ActiveSheet; + handleOpenSheet: (sheet: Exclude) => void; + handleCloseSheet: () => void; +} function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { + const sheetBridge = useComposerSheetBridge(); const { theme } = useUnistyles(); const { t } = useTranslation(); const { provider, - providerOptions, - selectedProviderId, selectedModelId, - thinkingOptions, - selectedThinkingOptionId, - features, - onSetFeature, + thinkingItems, + featureItems, onApplyAgentProfile, onEditAgentProfiles, onCreateAgentProfile, @@ -944,31 +758,15 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { agentProfiles, disabled, isModelLoading, - canSelectProvider, canSelectModel, - canSelectThinking, modelSelectorProviders, - modelDisabled, - comboboxProviderOptions, - comboboxThinkingOptions, - displayProvider, - displayThinking, openSelector, - providerAnchorRef, - thinkingAnchorRef, - providerPressableStyle, - handleProviderPress, - handleThinkingPress, - handleProviderSelect, - handleThinkingSelect, handleDesktopModelSelect, - handleProviderOpenChange, - handleThinkingOpenChange, - handleOpenChange, - handleNestedOpenChange, - renderThinkingOption, + handleToolbarOpenChange, + handleSheetOpenChange, modeControl, presentation, + pluginItemCount, glyphs, activeSheet, handleOpenSheet, @@ -984,35 +782,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { [t], ); const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); + const hasFeatures = featureItems.length > 0 || pluginItemCount > 0; return ( <> - {providerOptions && providerOptions.length > 0 ? ( - <> - - {displayProvider} - - DESKTOP_SEARCH_THRESHOLD} - open={openSelector === "provider"} - onOpenChange={handleProviderOpenChange} - anchorRef={providerAnchorRef} - desktopPlacement="top-start" - /> - - ) : null} - {canSelectModel ? ( @@ -1028,7 +800,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { onCreateProfile={onCreateAgentProfile} onEditProfile={onEditAgentProfile} isLoading={isModelLoading} - disabled={modelDisabled} + disabled={disabled} onOpen={onModelSelectorOpen} onClose={onDropdownClose} onRetryProvider={onRetryModelProvider} @@ -1046,49 +818,17 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { ) : null} - {thinkingOptions && thinkingOptions.length > 0 ? ( - <> - - - - - - {t(getAgentControlHintKey("thinking"))} - - - DESKTOP_SEARCH_THRESHOLD} - open={openSelector === "thinking"} - onOpenChange={handleThinkingOpenChange} - anchorRef={thinkingAnchorRef} - desktopPlacement="top-start" - desktopMinWidth={200} - renderOption={renderThinkingOption} - /> - - ) : null} + {modeControl ? : null} - {presentation.aggregateFeatures && features?.length ? ( + {presentation.aggregateFeatures && hasFeatures ? ( <> - {features.map((feature) => ( - - ))} + + ) : ( - features?.map((feature) => ( - + - )) + + )} ); } -interface SheetAgentControlsContentProps { - provider: string; - selectedModelId?: string; - selectedThinkingOptionId?: string; - features?: AgentFeature[]; - onSetFeature?: (featureId: string, value: unknown) => void; - onApplyAgentProfile?: (profileId: string) => void; - onEditAgentProfiles?: () => void; - onCreateAgentProfile?: (seed: AgentProfileSeed) => void; - onEditAgentProfile?: (profileId: string) => void; - onDropdownClose?: () => void; - onModelSelectorOpen?: () => void; - onRetryModelProvider?: (provider: AgentProvider) => void; - isRetryingModelProvider: boolean; - agentProfiles: AgentProfilePicker | null; - disabled: boolean; - isModelLoading: boolean; - canSelectModel: boolean; - canSelectThinking: boolean; - modelSelectorProviders: ProviderSelectorProvider[]; - modelDisabled: boolean; - comboboxThinkingOptions: ComboboxOption[]; - openSelector: AgentControlSelector | null; - displayThinking: string; - activeSheet: ActiveSheet; - handleOpenSheet: (sheet: Exclude) => void; - handleCloseSheet: () => void; - handleSheetModelSelect: (providerId: string, modelId: string) => void; - handleSelectThinkingAndClose: (thinkingOptionId: string) => void; - handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - renderThinkingOption: (args: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ReactElement; - modeControl?: AgentModeControlValue | null; - glyphs: ToolbarGlyphs; +interface SheetAgentControlsContentProps extends AgentControlsContentProps { showLabels: boolean; - modelSelectorServerId: string | null; + displayThinking: string | null; + handleSheetModelSelect: (providerId: string, modelId: string) => void; canSwitchProvider: boolean; } function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { - const { t } = useTranslation(); + const sheetBridge = useComposerSheetBridge(); const { provider, selectedModelId, - selectedThinkingOptionId, - features, - onSetFeature, + thinkingItems, + featureItems, onApplyAgentProfile, onEditAgentProfiles, onCreateAgentProfile, @@ -1204,19 +906,11 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { disabled, isModelLoading, canSelectModel, - canSelectThinking, modelSelectorProviders, - modelDisabled, - comboboxThinkingOptions, openSelector, displayThinking, - activeSheet, - handleOpenSheet, - handleCloseSheet, handleSheetModelSelect, - handleSelectThinkingAndClose, - handleOpenChange, - renderThinkingOption, + handleSheetOpenChange, modeControl, glyphs, showLabels, @@ -1224,67 +918,26 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { canSwitchProvider, } = props; - const thinkingAnchorRef = useRef(null); - - const hasThinking = comboboxThinkingOptions.length > 0; - - const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]); - const handleThinkingSheetOpenChange = useCallback( - (nextOpen: boolean) => { - if (nextOpen) { - handleOpenSheet("thinking"); - } else { - handleCloseSheet(); - } - }, - [handleCloseSheet, handleOpenSheet], - ); - const sheetControls = ( - {hasThinking ? ( - <> - - - - ) : null} + {modeControl ? : null} - {(features ?? []).map((feature) => ( - - ))} + + ); @@ -1293,15 +946,16 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { providers={modelSelectorProviders} selectedProvider={provider} selectedModel={selectedModelId ?? ""} - thinkingLabel={hasThinking ? displayThinking : null} + thinkingLabel={displayThinking} onSelect={handleSheetModelSelect} profiles={agentProfiles} onApplyProfile={onApplyAgentProfile} onEditProfiles={onEditAgentProfiles} onCreateProfile={onCreateAgentProfile} onEditProfile={onEditAgentProfile} + contextBridge={sheetBridge} isLoading={isModelLoading} - disabled={modelDisabled} + disabled={disabled} onOpen={onModelSelectorOpen} onClose={onDropdownClose} onRetryProvider={onRetryModelProvider} @@ -1316,269 +970,15 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { ) : null; } -function DesktopFeatureItem({ - feature, - disabled, - openSelector, - handleOpenChange, - onSetFeature, - onActionComplete, -}: { - feature: AgentFeature; - disabled: boolean; - openSelector: AgentControlSelector | null; - handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - onSetFeature?: (featureId: string, value: unknown) => void; - onActionComplete?: () => void; -}) { - const { theme } = useUnistyles(); - const { glyphs } = useComposerControlLayout(); - const restColor = toolbarGlyphColor(theme, glyphs.icon.tone); - const featureSelector: AgentControlSelector = `feature-${feature.id}`; - const featureAnchorRef = useRef(null); - - const handleFeatureOpenChange = useMemo( - () => handleOpenChange(featureSelector), - [handleOpenChange, featureSelector], - ); - const handleSelectPress = useCallback( - () => handleFeatureOpenChange(openSelector !== featureSelector), - [featureSelector, handleFeatureOpenChange, openSelector], - ); - - const handleTogglePress = useCallback(() => { - if (feature.type === "toggle") { - onSetFeature?.(feature.id, !feature.value); - onActionComplete?.(); - } - }, [feature, onActionComplete, onSetFeature]); - - const handleSelectOption = useCallback( - (optionId: string) => { - onSetFeature?.(feature.id, optionId); - }, - [feature.id, onSetFeature], - ); - const comboboxOptions = useMemo( - () => - feature.type === "select" - ? feature.options.map((option) => ({ id: option.id, label: option.label })) - : [], - [feature], - ); - - if (feature.type === "toggle") { - const FeatureIcon = getAgentFeatureIcon(feature.icon); - return ( - - - - - - {getFeatureTooltip(feature)} - - - ); - } - - if (feature.type === "select") { - const FeatureIcon = getAgentFeatureIcon(feature.icon); - const selectedOption = feature.options.find((o) => o.id === feature.value); - const iconOnly = feature.desktopTrigger === "icon"; - const tooltip = iconOnly - ? `${feature.label}: ${selectedOption?.label ?? feature.label}` - : getFeatureTooltip(feature); - return ( - <> - - - - - - {tooltip} - - - - - ); - } - - return null; -} - -function SheetFeatureItem({ - feature, - disabled, - openSelector, - handleOpenChange, - onSetFeature, -}: { - feature: AgentFeature; - disabled: boolean; - openSelector: AgentControlSelector | null; - handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - onSetFeature?: (featureId: string, value: unknown) => void; -}) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const featureSelector: AgentControlSelector = `feature-${feature.id}`; - const featureAnchorRef = useRef(null); - - const handleFeatureOpenChange = useMemo( - () => handleOpenChange(featureSelector), - [handleOpenChange, featureSelector], - ); - const handleSelectPress = useCallback( - () => handleFeatureOpenChange(openSelector !== featureSelector), - [featureSelector, handleFeatureOpenChange, openSelector], - ); - const sheetHeader = useMemo(() => ({ title: feature.label }), [feature.label]); - - const handleSelectOption = useCallback( - (optionId: string) => { - onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId); - }, - [feature.id, feature.type, onSetFeature], - ); - const comboboxOptions = useMemo(() => { - if (feature.type === "select") { - return feature.options.map((option) => ({ id: option.id, label: option.label })); - } - return [ - { id: "true", label: t("agentControls.features.on") }, - { id: "false", label: t("agentControls.features.off") }, - ]; - }, [feature, t]); - - if (feature.type === "toggle") { - const FeatureIcon = getAgentFeatureIcon(feature.icon); - return ( - <> - - - - ); - } - - if (feature.type === "select") { - const FeatureIcon = getAgentFeatureIcon(feature.icon); - const selectedOption = feature.options.find((o) => o.id === feature.value); - return ( - <> - - - - ); - } - - return null; -} - -function ThinkingComboboxOption({ - option, - selected, - active, - onPress, -}: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; -}) { - const leadingSlot = useMemo( - () => , - [], - ); - return ( - +function useAgentOptionControls( + agent: { controls?: AgentOptionControl[]; options?: ProviderOptions } | null | undefined, + onSetOption: ComposerOptionControls["onSetOption"], +): ComposerOptionControls { + const controls = agent?.controls; + const options = agent?.options; + return useMemo( + () => ({ controls: controls ?? [], options: options ?? EMPTY_OPTIONS, onSetOption }), + [controls, onSetOption, options], ); } @@ -1607,7 +1007,7 @@ export const AgentControls = memo(function AgentControls({ } = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); const snapshotSelectedEntry = useMemo( - () => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider), + () => resolveProviderSnapshotEntry(snapshotEntries, agent?.provider), [snapshotEntries, agent?.provider], ); @@ -1733,8 +1133,8 @@ export const AgentControls = memo(function AgentControls({ [activeModelId, agentId, agentProvider, client, toast, updatePreferences], ); - const handleSetFeature = useCallback( - (featureId: string, value: unknown) => { + const handleSetOption = useCallback( + (key: string, value: unknown) => { if (!client || !agentProvider) { return; } @@ -1743,22 +1143,40 @@ export const AgentControls = memo(function AgentControls({ preferences: current, provider: agentProvider, updates: { - featureValues: { - [featureId]: value, + options: { + [key]: value, }, }, }), ).catch((error) => { - console.warn("[AgentControls] persist feature preference failed", error); + console.warn("[AgentControls] persist option preference failed", error); }); - void client.setAgentFeature(agentId, featureId, value).catch((error) => { - console.warn("[AgentControls] setAgentFeature failed", error); + void client.setAgentOptions(agentId, { [key]: value }).catch((error) => { + console.warn("[AgentControls] setAgentOptions failed", error); toast.error(toErrorMessage(error)); }); }, [agentId, agentProvider, client, toast, updatePreferences], ); + const agentOptionControls = useAgentOptionControls(agent, handleSetOption); + + // The agent's composer exists apart from this component; while this is mounted, prompt + // commands go through the input the user is typing in. + const composer = useMemo(() => agentComposer(serverId, agentId), [agentId, serverId]); + const composerInput = useComposerInput(); + useEffect( + () => + composerInput + ? composer.attachInput({ + isLive: composerInput.isLive, + setText: composerInput.setText, + insertText: composerInput.insertText, + }) + : undefined, + [composer, composerInput], + ); + const commandCenterControls = useMemo( () => ({ serverId, @@ -1777,21 +1195,18 @@ export const AgentControls = memo(function AgentControls({ select: handleSelectThinkingOption, }, modes: commandCenterModes, - features: { - list: agent?.features, - set: handleSetFeature, - }, + optionControls: client ? agentOptionControls : null, }), [ activeModelId, - agent?.features, + agentOptionControls, + client, agentId, agentModelSelectorProviders, agentProvider, commandCenterModes, handleSelectCommandCenterModel, handleSelectThinkingOption, - handleSetFeature, modeProviderDefinitions, modelSelection.selectedThinkingId, modelSelection.thinkingOptions, @@ -1823,7 +1238,7 @@ export const AgentControls = memo(function AgentControls({ } return ( - <> + {commandCenterRegistration} {profileEditor.element} 0 ? thinkingOptions : undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} onSelectThinkingOption={handleSelectThinkingOption} - features={agent.features} - onSetFeature={handleSetFeature} + optionControls={agentOptionControls} isModelLoading={snapshotIsLoading || selectedProviderIsLoading} onModelSelectorOpen={handleModelSelectorOpen} onRetryModelProvider={handleRetryModelProvider} @@ -1852,7 +1266,7 @@ export const AgentControls = memo(function AgentControls({ modelSelectorServerId={serverId} isCompactLayout={isCompactLayout} /> - + ); }); @@ -1873,8 +1287,9 @@ export function DraftAgentControls({ selectedThinkingOptionId, onSelectThinkingOption, onApplyAgentProfile, - features, - onSetFeature, + optionControls, + options, + onSetOption, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, @@ -1882,6 +1297,9 @@ export function DraftAgentControls({ disabled = false, modelSelectorServerId = null, isCompactLayout, + context = "draft", + metadata, + onSetMetadata, }: DraftAgentControlsProps) { const mappedThinkingOptions = useMemo(() => { return toThinkingControlOptions(thinkingOptions); @@ -1941,37 +1359,88 @@ export function DraftAgentControls({ [selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled], ); + const disabledRef = useRef(disabled); + disabledRef.current = disabled; + const isLocked = useCallback(() => disabledRef.current, []); + const composerAgent = useMemo( + () => + draftComposerAgent({ + context, + disabled, + isLocked, + selectedProvider, + selectedModel, + selectedMode, + thinking: effectiveSelectedThinkingOption ?? null, + options, + metadata, + onSelectProviderAndModel, + onSelectModel, + onSelectMode, + onSelectThinkingOption, + onSetOption, + onSetMetadata, + }), + [ + context, + disabled, + isLocked, + effectiveSelectedThinkingOption, + metadata, + onSelectMode, + onSelectModel, + onSelectProviderAndModel, + onSelectThinkingOption, + onSetMetadata, + onSetOption, + options, + selectedMode, + selectedModel, + selectedProvider, + ], + ); + + const composerInput = useComposerInput(); + const composerModel = useMemo( + () => + composerInput + ? composerModelFromHalves({ agent: composerAgent, input: composerInput }) + : null, + [composerAgent, composerInput], + ); + return ( - <> - {profileEditor.element} - 0 ? mappedThinkingOptions : undefined} - selectedThinkingOptionId={effectiveSelectedThinkingOption} - onSelectThinkingOption={onSelectThinkingOption} - features={features} - onSetFeature={onSetFeature} - onDropdownClose={onDropdownClose} - onModelSelectorOpen={onModelSelectorOpen} - onRetryModelProvider={onRetryModelProvider} - isRetryingModelProvider={isRetryingModelProvider} - disabled={disabled} - modeControl={modeControl} - modelSelectorServerId={modelSelectorServerId} - isCompactLayout={isCompactLayout} - /> - + + + {profileEditor.element} + 0 ? mappedThinkingOptions : undefined} + selectedThinkingOptionId={effectiveSelectedThinkingOption} + onSelectThinkingOption={onSelectThinkingOption} + optionControls={optionControls} + onDropdownClose={onDropdownClose} + onModelSelectorOpen={onModelSelectorOpen} + onRetryModelProvider={onRetryModelProvider} + isRetryingModelProvider={isRetryingModelProvider} + disabled={disabled} + modeControl={modeControl} + modelSelectorServerId={modelSelectorServerId} + isCompactLayout={isCompactLayout} + /> + + ); } @@ -1989,23 +1458,10 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: bySizing({ touch: theme.spacing[2], pointer: 0 }), marginHorizontal: bySizing({ touch: -theme.spacing[2], pointer: 0 }), }, - modeBadge: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "transparent", - gap: theme.spacing[1], - ...toolbarContentSlot(theme.spacing[2]), - borderRadius: theme.borderRadius.full, - }, modelControl: { minWidth: 0, flexShrink: 1, }, - toolbarCaret: { - width: 14, - height: 14, - flexShrink: 0, - }, modeIconBadge: { ...toolbarSlot(TOUCH_ICON_SIZE.md), alignItems: "center", @@ -2015,22 +1471,6 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: "transparent", borderRadius: theme.borderRadius.full, }, - modeBadgeHovered: { - backgroundColor: theme.colors.surface2, - }, - modeBadgePressed: { - backgroundColor: theme.colors.surface2, - }, - disabledBadge: { - opacity: 0.5, - }, - modeBadgeText: { - minWidth: 0, - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, tooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.base, diff --git a/packages/app/src/composer/agent-controls/layout.test.ts b/packages/app/src/composer/agent-controls/layout.test.ts index 906bc0c48f..15b6a4f3ac 100644 --- a/packages/app/src/composer/agent-controls/layout.test.ts +++ b/packages/app/src/composer/agent-controls/layout.test.ts @@ -47,6 +47,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" as const }], + pluginItems: 0, fontScale: 1, }; @@ -115,6 +116,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" }], + pluginItems: 0, fontScale: 1, }, }), @@ -127,6 +129,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" }, { type: "select", label: "Tools" }], + pluginItems: 0, fontScale: 1, }, }), @@ -139,6 +142,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" }], + pluginItems: 0, fontScale: 1.25, }, }), @@ -154,6 +158,7 @@ describe("composer control layout", () => { hasModel: true, hasThinking: true, hasMode: true, + pluginItems: 0, fontScale: 1, }, }; @@ -175,6 +180,36 @@ describe("composer control layout", () => { ).toBe("condensed"); }); + it("condenses to make room for plugin items and keeps them in the overflow", () => { + const base = { + availableWidth: 430, + currentDensity: "full" as const, + geometry: POINTER, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [], + fontScale: 1, + pluginItems: 0, + }, + }; + + 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 glyph per role and control sizing", () => { const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" }; expect(POINTER).toEqual({ @@ -205,6 +240,7 @@ describe("composer control layout", () => { hasMode: true, features: [{ type: "toggle" as const }], fontScale: 1, + pluginItems: 0, }, }; @@ -221,6 +257,7 @@ describe("composer control layout", () => { hasMode: true, features: [{ type: "toggle" as const }, { type: "toggle" as const }], fontScale: 1, + pluginItems: 0, }; it("keeps the model family label with icon-only controls on an empty draft", () => { @@ -279,6 +316,7 @@ describe("composer control layout", () => { hasMode: true, features: [], fontScale: 1, + pluginItems: 0, }, }; diff --git a/packages/app/src/composer/agent-controls/layout.ts b/packages/app/src/composer/agent-controls/layout.ts index ec6a5b8f26..0be3361f8b 100644 --- a/packages/app/src/composer/agent-controls/layout.ts +++ b/packages/app/src/composer/agent-controls/layout.ts @@ -26,6 +26,8 @@ export interface ComposerControlPresence { hasThinking: boolean; hasMode: boolean; features: readonly ComposerFeatureControlPresence[]; + /** Plugin composer items. Each is budgeted like a select with a short label. */ + pluginItems: number; fontScale: number; } @@ -72,6 +74,7 @@ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarG } const DENSITY_HYSTERESIS = 12; +const PLUGIN_ITEM_LABEL = "Plugin item"; /** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */ const MODEL_FAMILY_LABEL_WIDTH = 56; @@ -119,7 +122,9 @@ function resolveCondensedFloor( if (controls.hasModel) widths.push(chrome + 60 * fontScale); if (controls.hasThinking) widths.push(geometry.controlSize); if (controls.hasMode) widths.push(chrome + 96 * fontScale); - if (controls.features.length > 0) widths.push(geometry.controlSize); + if (controls.features.length > 0 || controls.pluginItems > 0) { + widths.push(geometry.controlSize); + } return sumControlWidths(widths, geometry); } @@ -137,6 +142,11 @@ function resolveFullFloor( for (const feature of controls.features) { widths.push(resolveFeatureControlWidth(feature, fontScale, geometry)); } + for (let index = 0; index < controls.pluginItems; index++) { + widths.push( + resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale, geometry), + ); + } return sumControlWidths(widths, geometry); } @@ -152,7 +162,9 @@ function resolveTightFloor( } if (controls.hasThinking) widths.push(geometry.controlSize); if (controls.hasMode) widths.push(geometry.controlSize); - if (controls.features.length > 0) widths.push(geometry.controlSize); + if (controls.features.length > 0 || controls.pluginItems > 0) { + widths.push(geometry.controlSize); + } return sumControlWidths(widths, geometry); } diff --git a/packages/app/src/composer/agent-controls/mode.test.ts b/packages/app/src/composer/agent-controls/mode.test.ts index 0905e3cd4c..8eca9b9251 100644 --- a/packages/app/src/composer/agent-controls/mode.test.ts +++ b/packages/app/src/composer/agent-controls/mode.test.ts @@ -25,15 +25,19 @@ describe("resolveAgentControlsMode", () => { onSelectMode: () => undefined, models: [], selectedModel: "", - onSelectModel: () => undefined, + onSelectModel: () => true, isModelLoading: false, modelSelectorProviders: [], isAllModelsLoading: false, - onSelectProviderAndModel: () => undefined, + onSelectProviderAndModel: () => true, + options: {}, + onSetOption: () => undefined, thinkingOptions: [], selectedThinkingOptionId: "", onSelectThinkingOption: () => undefined, onApplyAgentProfile: () => undefined, + metadata: {}, + onSetMetadata: () => undefined, }), ).toBe("draft"); }); diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index 68c7f9f834..6a34dbae4f 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -12,6 +12,7 @@ import { StyleSheet } from "react-native-unistyles"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { TailEllipsisText } from "@/components/ui/tail-ellipsis-text"; import { useProviderIcon } from "@/components/provider-icons"; @@ -61,6 +62,8 @@ interface CompactModelSheetProps { /** False when the toolbar is too narrow for labels: the trigger shows the provider glyph only. */ showLabels: boolean; canSwitchProvider: boolean; + /** Carries the composer's contexts into the sheets, which draw away from it. */ + contextBridge?: ContextBridge | null; children: ReactNode; } @@ -151,6 +154,7 @@ export function CompactModelSheet({ glyphs, showLabels, canSwitchProvider, + contextBridge = null, children, }: CompactModelSheetProps) { const { t } = useTranslation(); @@ -331,6 +335,7 @@ export function CompactModelSheet({ snapPoints={SNAP_POINTS} scrollable={false} contentStyle={styles.sheetBody} + contextBridge={contextBridge} testID="agent-controls-model-sheet" > ( + (content) => ( + + + + + + {input ? ( + {content} + ) : ( + content + )} + + + + + + ), + [agent, input, layout, model, queryClient, toast], + ); +} diff --git a/packages/app/src/composer/agent-controls/utils.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts index dfacec0ec7..ad8f6da983 100644 --- a/packages/app/src/composer/agent-controls/utils.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from "vitest"; import { getFeatureHighlightColor, - getFeatureTooltip, getAgentControlHintKey, - isFeatureActive, normalizeModelId, resolveAgentModelSelection, } from "./utils"; @@ -17,51 +15,12 @@ describe("getAgentControlHintKey", () => { }); describe("feature metadata helpers", () => { - it("prefers explicit feature tooltip copy", () => { - expect( - getFeatureTooltip({ - label: "Plan", - tooltip: "Toggle plan mode", - }), - ).toBe("Toggle plan mode"); - }); - - it("falls back to the feature label when no tooltip is provided", () => { - expect( - getFeatureTooltip({ - label: "Custom", - }), - ).toBe("Custom"); - }); - it("maps feature highlight colors by feature id", () => { expect(getFeatureHighlightColor("fast_mode")).toBe("yellow"); expect(getFeatureHighlightColor("service_tier")).toBe("yellow"); expect(getFeatureHighlightColor("plan_mode")).toBe("blue"); expect(getFeatureHighlightColor("other")).toBe("default"); }); - - it("treats a select feature as active when a non-default option is selected", () => { - const speed = { - type: "select" as const, - id: "service_tier", - label: "Speed", - options: [ - { id: "default", label: "Normal", isDefault: true }, - { id: "fast", label: "Fast" }, - ], - }; - expect(isFeatureActive({ ...speed, value: "default" })).toBe(false); - expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true); - expect( - isFeatureActive({ - type: "toggle", - id: "fast_mode", - label: "Fast", - value: true, - }), - ).toBe(true); - }); }); describe("normalizeModelId", () => { diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index c0b1da7c47..26a5cabe5b 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -1,4 +1,4 @@ -import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { i18n } from "@/i18n/i18next"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { @@ -35,16 +35,6 @@ export function normalizeModelId(modelId: string | null | undefined): string | n return normalized; } -export function getFeatureTooltip(feature: Pick): string { - return feature.tooltip ?? feature.label; -} - -export function isFeatureActive(feature: AgentFeature): boolean { - if (feature.type === "toggle") return feature.value; - const selectedOption = feature.options.find((option) => option.id === feature.value); - return selectedOption !== undefined && !selectedOption.isDefault; -} - export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor { switch (featureId) { case FAST_MODE_FEATURE_ID: @@ -59,6 +49,14 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol } } +export function resolveProviderSnapshotEntry( + entries: readonly ProviderSnapshotEntry[] | null | undefined, + provider: string | null | undefined, +): ProviderSnapshotEntry | null { + if (!entries || !provider) return null; + return entries.find((entry) => entry.provider === provider) ?? null; +} + function findModelById( models: AgentModelDefinition[] | null, modelId: string | null, diff --git a/packages/app/src/composer/draft/composer-agent.test.ts b/packages/app/src/composer/draft/composer-agent.test.ts new file mode 100644 index 0000000000..35256eba4c --- /dev/null +++ b/packages/app/src/composer/draft/composer-agent.test.ts @@ -0,0 +1,189 @@ +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", 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"); + }); + + it("accepts a model id the catalog does not list; the form resolves it as the model picker does", async () => { + const draft = openDraft(context); + const retained = draft.agent(); + + await retained.setProvider("codex"); + await expect(retained.setModel("missing")).resolves.toBeUndefined(); + + // The form keeps a listed model when the one asked for is not in the catalog. + 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"); + }); +}); diff --git a/packages/app/src/composer/draft/composer-agent.ts b/packages/app/src/composer/draft/composer-agent.ts new file mode 100644 index 0000000000..426f2edf4e --- /dev/null +++ b/packages/app/src/composer/draft/composer-agent.ts @@ -0,0 +1,50 @@ +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) => props.onSelectModel(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), + }; +} diff --git a/packages/app/src/composer/draft/input-draft-core.ts b/packages/app/src/composer/draft/input-draft-core.ts index 162869855b..6458126e7f 100644 --- a/packages/app/src/composer/draft/input-draft-core.ts +++ b/packages/app/src/composer/draft/input-draft-core.ts @@ -1,3 +1,4 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { DraftAgentControlsProps } from "@/composer/agent-controls"; import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state"; @@ -20,12 +21,26 @@ export function resolveDraftKey(input: { export function buildDraftAgentControls(input: { formState: UseAgentFormStateResult; - features?: DraftAgentControlsProps["features"]; - onSetFeature?: DraftAgentControlsProps["onSetFeature"]; + optionControls?: DraftAgentControlsProps["optionControls"]; + options: DraftAgentControlsProps["options"]; + onSetOption: DraftAgentControlsProps["onSetOption"]; onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"]; onDropdownClose?: DraftAgentControlsProps["onDropdownClose"]; + context: NonNullable; + metadata: Record; + onSetMetadata: (patch: Record) => void; }): DraftAgentControlsProps { - const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input; + const { + formState, + optionControls, + options, + onSetOption, + onApplyAgentProfile, + onDropdownClose, + context, + metadata, + onSetMetadata, + } = input; return { providerDefinitions: formState.providerDefinitions, selectedProvider: formState.selectedProvider, @@ -43,13 +58,17 @@ export function buildDraftAgentControls(input: { selectedThinkingOptionId: formState.selectedThinkingOptionId, onSelectThinkingOption: formState.setThinkingOptionFromUser, onApplyAgentProfile, - features, - onSetFeature, + optionControls, + options, + onSetOption, onDropdownClose, onModelSelectorOpen: formState.refetchProviderModelsIfStale, onRetryModelProvider: formState.refreshProviderModels, isRetryingModelProvider: formState.isProviderModelsRefreshing, modelSelectorServerId: formState.selectedServerId, + context, + metadata, + onSetMetadata, }; } diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 82de9d3d73..871650f270 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -144,6 +144,7 @@ describe("useAgentInputDraft live contract", () => { drafts: {}, createModalDraft: null, attachmentFocusRequestByDraftKey: {}, + textReplacementRequestByDraftKey: {}, }); }); @@ -486,6 +487,37 @@ describe("useAgentInputDraft live contract", () => { }); }); + it("shows text another module put in the draft while the input is retained off screen", async () => { + let latest: ReturnType | null = null; + function getLatest(): ReturnType { + if (!latest) throw new Error("Expected hook result"); + return latest; + } + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:replaced" }); + return null; + } + const container = document.getElementById("root"); + if (!container) throw new Error("Missing root container"); + const root = createTestRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + const before = getLatest().textReplacement.key; + + await act(async () => { + useDraftStore.getState().replaceDraftText({ draftKey: "draft:replaced", text: "from away" }); + }); + + expect(getLatest().textReplacement).toMatchObject({ kind: "replace", text: "from away" }); + expect(getLatest().textReplacement.key).not.toBe(before); + expect(getLatest().textSource.getSnapshot()).toBe("from away"); + }); + it("clear resets text and attachments", async () => { let latest: ReturnType | null = null; const image: AttachmentMetadata = { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 2eb606a7f7..3a445df10c 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { TextReplacement } from "@/composer/types"; import type { DraftAgentControlsProps } from "@/composer/agent-controls"; @@ -8,10 +9,9 @@ import { type CreateAgentInitialValues, type UseAgentFormStateResult, } from "@/hooks/use-agent-form-state"; -import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; +import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options"; import { buildDraftAgentControls, - hasDraftContent, resolveDraftKey, type DraftKeyInput, } from "@/composer/draft/input-draft-core"; @@ -22,12 +22,14 @@ import { resolveEffectiveComposerThinkingOptionId, type ProviderSelectionState, } from "@/provider-selection/provider-selection"; -import { useDraftStore } from "@/stores/draft-store"; +import { useDraftStore, type DraftInput } from "@/stores/draft-store"; import { AfterPaintPublication } from "@/composer/after-paint-publication"; import { useShallow } from "zustand/shallow"; import type { ComposerTextSource } from "@/composer/text-source"; import { isWeb } from "@/constants/platform"; +const NO_METADATA: Record = {}; + type AttachmentUpdater = | UserComposerAttachment[] | ((prev: UserComposerAttachment[]) => UserComposerAttachment[]); @@ -35,7 +37,9 @@ type AttachmentUpdater = interface AgentInputDraftComposerOptions { initialServerId: string | null; initialValues?: CreateAgentInitialValues; - initialFeatureValues?: Record; + initialProviderOptions?: Record; + /** Which composer a plugin's `useComposer()` reports. */ + context?: "draft" | "new-workspace"; isVisible?: boolean; lockedWorkingDir?: string; } @@ -50,7 +54,9 @@ type DraftComposerState = UseAgentFormStateResult & { effectiveModeId: string; effectiveModelId: string; effectiveThinkingOptionId: string; - featureValues: Record | undefined; + providerOptions: Record | undefined; + /** What plugins set on the agent this draft creates. */ + metadata: Record; agentControls: DraftAgentControlsProps; commandDraft: DraftCommandTarget; }; @@ -135,21 +141,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ); const saveDraft = useCallback( - ( - update: (draft: { text: string; attachments: UserComposerAttachment[] }) => { - text: string; - attachments: UserComposerAttachment[]; - }, - ) => { - const store = useDraftStore.getState(); - const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] }; - const next = update(current); - if (!hasDraftContent(next)) { - store.clearDraftInput({ draftKey, lifecycle: "abandoned" }); - return; - } - store.saveDraftInput({ draftKey, draft: next }); - }, + (update: (draft: DraftInput) => DraftInput) => + useDraftStore.getState().updateDraftInput({ draftKey, update }), [draftKey], ); @@ -161,6 +154,20 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [draftKey], ); + // Text another module put in the draft (a plugin writing to a composer that is not on screen) + // reaches the input the way a rewind restore does. + const replacementRequest = useDraftStore( + (state) => state.textReplacementRequestByDraftKey[draftKey] ?? 0, + ); + const shownReplacementRequestRef = useRef({ draftKey, request: replacementRequest }); + useEffect(() => { + const shown = shownReplacementRequestRef.current; + if (shown.draftKey === draftKey && shown.request === replacementRequest) return; + shownReplacementRequestRef.current = { draftKey, request: replacementRequest }; + textPublication.cancel(); + publishTextReplacement("replace", textSource.getSnapshot()); + }, [draftKey, publishTextReplacement, replacementRequest, textPublication, textSource]); + const editText = useCallback( (nextText: string) => { if (isWeb) { @@ -191,10 +198,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [saveDraft], ); + const metadata = useDraftStore((state) => state.metadataByDraftKey[draftKey] ?? NO_METADATA); + const setMetadata = useCallback( + (patch: Record) => + useDraftStore.getState().patchDraftMetadata({ draftKey, patch }), + [draftKey], + ); + const clear = useCallback( (lifecycle: "sent" | "abandoned") => { textPublication.cancel(); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle }); + if (lifecycle === "sent") useDraftStore.getState().clearDraftMetadata({ draftKey }); }, [draftKey, textPublication], ); @@ -274,26 +289,28 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ); const { - features: draftFeatures, - featureValues: draftFeatureValues, - setFeatureValue: setDraftFeatureValue, - applyProfileFeatureValues, - } = useDraftAgentFeatures({ + optionControls: draftOptionControls, + options: draftOptions, + shownOptions: draftShownOptions, + setOption: setDraftOption, + applyProfile: applyProfileOptions, + } = useDraftProviderOptions({ serverId: formState.selectedServerId, provider: formState.selectedProvider, + getProvider: formState.getSelectedProvider, cwd: workingDir, modeId: formState.selectedMode, modelId: effectiveModelId, thinkingOptionId: effectiveThinkingOptionId, - initialFeatureValues: composerOptions?.initialFeatureValues, + initialOptions: composerOptions?.initialProviderOptions, }); const applyDraftAgentProfile = useCallback( (profile: Parameters[0]) => { formState.applyProfileFromUser(profile); - applyProfileFeatureValues(profile.featureValues); + applyProfileOptions({ provider: profile.provider, options: profile.options }); }, - [applyProfileFeatureValues, formState], + [applyProfileOptions, formState], ); const commandDraft = useMemo( @@ -303,15 +320,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr cwd: workingDir, effectiveModelId, effectiveThinkingOptionId, - featureValues: draftFeatureValues, + providerOptions: draftOptions, }), - [ - effectiveModelId, - effectiveThinkingOptionId, - draftFeatureValues, - providerSelection, - workingDir, - ], + [effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir], ); const composerState = useMemo(() => { @@ -325,12 +336,17 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr effectiveModeId, effectiveModelId, effectiveThinkingOptionId, - featureValues: draftFeatureValues, + providerOptions: draftOptions, + metadata, agentControls: buildDraftAgentControls({ formState, - features: draftFeatures, - onSetFeature: setDraftFeatureValue, + optionControls: draftOptionControls, + options: draftShownOptions, + onSetOption: setDraftOption, onApplyAgentProfile: applyDraftAgentProfile, + context: composerOptions.context ?? "draft", + metadata, + onSetMetadata: setMetadata, }), commandDraft, }; @@ -340,11 +356,14 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr effectiveModeId, effectiveModelId, effectiveThinkingOptionId, - draftFeatures, - draftFeatureValues, + draftOptionControls, + draftOptions, + draftShownOptions, + setDraftOption, applyDraftAgentProfile, formState, - setDraftFeatureValue, + metadata, + setMetadata, workingDir, ]); diff --git a/packages/app/src/composer/draft/metadata.test.ts b/packages/app/src/composer/draft/metadata.test.ts new file mode 100644 index 0000000000..c1a0e28213 --- /dev/null +++ b/packages/app/src/composer/draft/metadata.test.ts @@ -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 }); + }); +}); diff --git a/packages/app/src/composer/draft/metadata.ts b/packages/app/src/composer/draft/metadata.ts new file mode 100644 index 0000000000..481abd24c2 --- /dev/null +++ b/packages/app/src/composer/draft/metadata.ts @@ -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>, + patch: Readonly>, +): Record { + const next = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete next[key]; + else next[key] = value; + } + return next; +} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 2b99ac8c09..0815603e55 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,4 +1,6 @@ +import type { ComposerOptionControls } from "@/agent-controls/option-controls"; import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { Keyboard, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; @@ -51,7 +53,7 @@ interface AutoSubmitConfig { modeId: string | null; model: string | null; thinkingOptionId: string | null; - featureValues: Record; + providerOptions: Record; } function resolveAutoSubmitConfig( @@ -60,7 +62,7 @@ function resolveAutoSubmitConfig( modeId?: string | null; model?: string | null; thinkingOptionId?: string | null; - featureValues?: Record; + providerOptions?: Record; } | null, ): AutoSubmitConfig | null { if (!pending) return null; @@ -69,7 +71,7 @@ function resolveAutoSubmitConfig( modeId: pending.modeId ?? null, model: pending.model ?? null, thinkingOptionId: pending.thinkingOptionId ?? null, - featureValues: pending.featureValues ?? {}, + providerOptions: pending.providerOptions ?? {}, }; } @@ -117,7 +119,8 @@ async function submitDraftCreateRequest(input: { effectiveModeId: string; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; - featureValues: Record | undefined; + providerOptions: Record | undefined; + metadata: Record; }; hostDisconnectedMessage: string; selectModelMessage: string; @@ -156,7 +159,7 @@ async function submitDraftCreateRequest(input: { model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined), thinkingOptionId: autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined), - featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues, + providerOptions: autoSubmitConfig?.providerOptions ?? composerState.providerOptions, }); const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; @@ -167,6 +170,7 @@ async function submitDraftCreateRequest(input: { workspaceId, initialPrompt: text, clientMessageId: attempt.clientMessageId, + ...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}), }; @@ -190,7 +194,7 @@ function buildDraftAgentSnapshot(input: { effectiveThinkingOptionId: string | null; effectiveModeId: string; selectedProvider: string | null; - agentControls: { features?: Agent["features"] }; + agentControls: { optionControls?: ComposerOptionControls }; }; selectModelMessage: string; }): Agent { @@ -227,7 +231,8 @@ function buildDraftAgentSnapshot(input: { title: "Agent", cwd: workspaceDirectory, model, - features: composerState.agentControls.features, + options: composerState.agentControls.optionControls?.options, + controls: composerState.agentControls.optionControls?.controls, thinkingOptionId, parentAgentId: null, labels: {}, @@ -313,7 +318,7 @@ export function WorkspaceDraftComposer({ composer: { initialServerId: serverId, initialValues: draftInitialValues, - initialFeatureValues: draftSetup?.featureValues, + initialProviderOptions: draftSetup?.providerOptions, isVisible: true, lockedWorkingDir: draftWorkingDirectory ?? undefined, }, @@ -331,8 +336,7 @@ export function WorkspaceDraftComposer({ const draftModeOptions = composerState.modeOptions; const draftSelectedMode = composerState.selectedMode; const draftSetMode = composerState.setModeFromUser; - const draftFeatures = composerState.agentControls.features; - const draftOnSetFeature = composerState.agentControls.onSetFeature; + const draftOptionControls = composerState.agentControls.optionControls; const clearDraftInput = draftInput.clear; const replaceDraftText = draftInput.replaceText; @@ -477,7 +481,9 @@ export function WorkspaceDraftComposer({ providers: composerState.modelSelectorProviders, selectedProvider: draftProvider, selectedModelId: composerState.effectiveModelId, - select: composerState.setProviderAndModelFromUser, + select: (provider, modelId) => { + composerState.setProviderAndModelFromUser(provider, modelId); + }, }, thinking: { options: draftThinkingOptions, @@ -489,10 +495,7 @@ export function WorkspaceDraftComposer({ selectedId: draftSelectedMode, select: draftSetMode, }, - features: { - list: draftFeatures, - set: draftOnSetFeature, - }, + optionControls: draftOptionControls ?? null, }, }); const isReadyForPendingAutoSubmit = Boolean( diff --git a/packages/app/src/composer/halves-model.ts b/packages/app/src/composer/halves-model.ts new file mode 100644 index 0000000000..3ff5eebca9 --- /dev/null +++ b/packages/app/src/composer/halves-model.ts @@ -0,0 +1,64 @@ +import type { ComposerAgent, ComposerInput } from "@/composer/session"; +import type { ComposerModel, ComposerState } from "@/composer/model"; + +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): Promise { + assertUnlocked(agent.isLocked); + await command(); +} + +/** + * The model of a draft or New workspace composer, built from the two halves its mounted + * composer provides. Its state belongs to the render that built it, so a change is a new model; + * only the prompt changes underneath it. + */ +export function composerModelFromHalves(input: { + agent: ComposerAgent; + input: ComposerInput; +}): ComposerModel { + const { agent, input: composerInput } = input; + let last: ComposerState | null = null; + return { + getState() { + const prompt = composerInput.textSource.getSnapshot(); + if (last && last.prompt === prompt) return last; + last = { + context: agent.context, + prompt, + attachments: composerInput.attachments, + provider: agent.provider, + model: agent.model, + mode: agent.mode, + thinking: agent.thinking, + options: agent.options, + metadata: agent.metadata, + }; + return last; + }, + subscribe: composerInput.textSource.subscribe, + setPrompt(text) { + assertUnlocked(composerInput.isLocked); + composerInput.setText(text); + }, + insertText(text) { + assertUnlocked(composerInput.isLocked); + composerInput.insertText(text); + }, + changeAttachments(updater) { + assertUnlocked(composerInput.isLocked); + composerInput.changeAttachments(updater); + }, + 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)), + }; +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 60b05be23e..11479e1346 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -129,6 +129,7 @@ import { } from "@/attachments/service"; import { resolveAgentControlsMode } from "@/composer/agent-controls/mode"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; +import { useIsInputLive } from "./input/use-input-live"; import { resolveActiveSendBehavior } from "./input/state"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; @@ -151,6 +152,8 @@ import type { } from "@/attachments/types"; import type { SelectedFile } from "@/attachments/selected-file"; import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; +import { ComposerInputProvider, type ComposerInput } from "@/composer/session"; +import { insertAtSelection } from "@/composer/insert-text"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store"; import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop"; @@ -1428,6 +1431,43 @@ function ComposerContentImpl({ [onChangeText], ); + const insertUserInput = useCallback( + (text: string) => { + const input = messageInputRef.current; + if (!input) { + onChangeText(textSource.getSnapshot() + text); + return; + } + const inserted = insertAtSelection(input.getInputSnapshot(), text); + input.replaceText(inserted.text, inserted.selection); + }, + [onChangeText, textSource], + ); + const isInputLive = useIsInputLive(messageInputRef); + const isComposerLockedRef = useRef(isComposerLocked); + isComposerLockedRef.current = isComposerLocked; + const composerInput = useMemo( + () => ({ + locked: isComposerLocked, + isLocked: () => isComposerLockedRef.current, + isLive: isInputLive, + textSource, + setText: replaceUserInput, + insertText: insertUserInput, + attachments, + changeAttachments: setSelectedAttachments, + }), + [ + attachments, + insertUserInput, + isComposerLocked, + isInputLive, + replaceUserInput, + setSelectedAttachments, + textSource, + ], + ); + const runClientSlashCommand = useCallback( (command: ClientSlashCommand): boolean => { if (command.execution !== "immediate" || !onClientSlashCommand) { @@ -2234,18 +2274,27 @@ function ComposerContentImpl({ [attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen], ); - const leftContent = useMemo( - () => - renderLeftContent({ - agentControls, - agentId, - serverId, - focusInput, - isCompactLayout, - showAgentControls: mode.showAgentControls, - }), - [agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId], - ); + const leftContent = useMemo(() => { + const controls = renderLeftContent({ + agentControls, + agentId, + serverId, + focusInput, + isCompactLayout, + showAgentControls: mode.showAgentControls, + }); + return controls ? ( + {controls} + ) : null; + }, [ + agentControls, + agentId, + composerInput, + focusInput, + isCompactLayout, + mode.showAgentControls, + serverId, + ]); const handleAttachButtonRef = useCallback((node: View | null) => { attachButtonRef.current = node; diff --git a/packages/app/src/composer/input/use-input-live.browser.test.tsx b/packages/app/src/composer/input/use-input-live.browser.test.tsx new file mode 100644 index 0000000000..d9e20029ca --- /dev/null +++ b/packages/app/src/composer/input/use-input-live.browser.test.tsx @@ -0,0 +1,47 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { beforeEach, expect, it, vi } from "vitest"; +import { RetainedPanelActivity } from "@/components/retained-panel"; +import { useIsInputLive } from "./use-input-live"; + +// App sources compile against the classic JSX runtime, which expects React on the global, as the +// other browser tests here arrange. +beforeEach(() => vi.stubGlobal("React", React)); + +interface InputRef { + current: object | null; +} + +function render(active: boolean, input: InputRef, publish: (isLive: () => boolean) => void) { + function Probe() { + publish(useIsInputLive(input)); + return null; + } + return ( + + + + ); +} + +it("an input kept mounted in a panel that is not showing is not live", () => { + const input: InputRef = { current: {} }; + let isLive: () => boolean = () => false; + const publish = (read: () => boolean) => { + isLive = read; + }; + const root = createRoot(document.createElement("div")); + + act(() => root.render(render(true, input, publish))); + expect(isLive()).toBe(true); + + act(() => root.render(render(false, input, publish))); + expect(isLive()).toBe(false); + + act(() => root.render(render(true, input, publish))); + expect(isLive()).toBe(true); + + input.current = null; + expect(isLive()).toBe(false); + act(() => root.unmount()); +}); diff --git a/packages/app/src/composer/input/use-input-live.ts b/packages/app/src/composer/input/use-input-live.ts new file mode 100644 index 0000000000..cf83ea6735 --- /dev/null +++ b/packages/app/src/composer/input/use-input-live.ts @@ -0,0 +1,14 @@ +import { useCallback, useRef } from "react"; +import { useRetainedPanelActive } from "@/components/retained-panel"; + +/** + * Whether the composer's input is on screen, as a read a saved plugin command can make later. A + * retained composer keeps its input mounted and hidden, and its cursor and selection are then + * stale, so only an input in a panel that is showing counts. + */ +export function useIsInputLive(input: { readonly current: unknown }): () => boolean { + const panelActive = useRetainedPanelActive(); + const panelActiveRef = useRef(panelActive); + panelActiveRef.current = panelActive; + return useCallback(() => input.current !== null && panelActiveRef.current, [input]); +} diff --git a/packages/app/src/composer/insert-text.test.ts b/packages/app/src/composer/insert-text.test.ts new file mode 100644 index 0000000000..b48c1a1248 --- /dev/null +++ b/packages/app/src/composer/insert-text.test.ts @@ -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 }, + }); + }); +}); diff --git a/packages/app/src/composer/insert-text.ts b/packages/app/src/composer/insert-text.ts new file mode 100644 index 0000000000..78127e744a --- /dev/null +++ b/packages/app/src/composer/insert-text.ts @@ -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 }, + }; +} diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts new file mode 100644 index 0000000000..99cd6fc076 --- /dev/null +++ b/packages/app/src/composer/model.ts @@ -0,0 +1,50 @@ +import { createContext, useContext } from "react"; +import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import type { UserComposerAttachment } from "@/attachments/types"; + +/** Everything a plugin can read of a composer. A new object whenever any of it changes. */ +export interface ComposerState { + /** `"agent"`: a running agent. `"draft"`: a new agent in a workspace. `"new-workspace"`: the New workspace screen. */ + context: "agent" | "draft" | "new-workspace"; + prompt: string; + attachments: readonly UserComposerAttachment[]; + provider: string | null; + model: string | null; + mode: string | null; + thinking: string | null; + /** The options the agent runs with, defaults included. */ + options: ProviderOptions; + metadata: Record; +} + +/** + * A composer as plugins use it: its state, a way to hear about changes, and the commands that + * change it. The commands enforce the rules the built-in controls follow (a draft being created + * is locked, a running agent keeps its provider). A running agent's model lives in the stores and + * exists whether or not its tab is open; a draft's is built from its mounted composer. + */ +export interface ComposerModel { + getState(): ComposerState; + subscribe(listener: () => void): () => void; + setPrompt(text: string): void; + /** Inserts at the cursor, replacing the selection. */ + insertText(text: string): void; + changeAttachments(updater: (current: UserComposerAttachment[]) => UserComposerAttachment[]): void; + setProvider(provider: string): Promise; + setModel(modelId: string): Promise; + setMode(modeId: string): Promise; + setThinking(thinkingOptionId: string): Promise; + /** Top-level keys replace; `null` removes the agent's own value. */ + setOptions(patch: ProviderOptions): Promise; + /** Top-level keys replace; `null` removes the key. */ + setMetadata(patch: Record): Promise; +} + +const ComposerModelContext = createContext(null); + +export const ComposerModelProvider = ComposerModelContext.Provider; + +/** The model of the composer this control sits in. */ +export function useComposerModel(): ComposerModel | null { + return useContext(ComposerModelContext); +} diff --git a/packages/app/src/composer/session.tsx b/packages/app/src/composer/session.tsx new file mode 100644 index 0000000000..eaa96130c6 --- /dev/null +++ b/packages/app/src/composer/session.tsx @@ -0,0 +1,94 @@ +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; + /** + * The input is on screen. A retained composer in a workspace that is not shown has none, so it + * cannot take the cursor-aware commands below. + */ + isLive: () => 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; + setProvider: (provider: string) => Promise; + setModel: (modelId: string) => Promise; + setMode: (modeId: string) => Promise; + setThinking: (thinkingOptionId: string) => Promise; + /** Top-level keys replace; `null` removes the agent's own value. */ + setOptions: (patch: ProviderOptions) => Promise; + /** Top-level keys replace; `null` removes the key. */ + setMetadata: (patch: Record) => Promise; +} + +const ComposerInputContext = createContext(null); +const ComposerAgentContext = createContext(null); + +export function ComposerInputProvider({ + value, + children, +}: { + value: ComposerInput; + children: ReactNode; +}) { + return {children}; +} + +export function ComposerAgentProvider({ + value, + children, +}: { + value: ComposerAgent | null; + children: ReactNode; +}) { + return {children}; +} + +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); +} diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index b30b2a66ca..543b49bb1c 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -80,7 +80,7 @@ describe("create agent preferences", () => { modelId: "gpt-5.5", modeId: "full-access", thinkingOptionId: "high", - featureValues: { fast_mode: true }, + options: { service_tier: "fast" }, }), ); @@ -95,7 +95,7 @@ describe("create agent preferences", () => { model: "gpt-5.5", mode: "full-access", thinkingByModel: { "gpt-5.5": "high" }, - featureValues: { fast_mode: true }, + options: { service_tier: "fast" }, }, }, }); @@ -119,7 +119,7 @@ describe("create agent preferences", () => { model: "gpt-5.6", mode: undefined, thinkingByModel: undefined, - featureValues: undefined, + options: undefined, }, }), ).toEqual({ @@ -167,13 +167,13 @@ describe("create agent preferences", () => { modelId: "one-minute-stream", modeId: "approval-test", thinkingOptionId: "", - featureValues: {}, + options: {}, }), ).toEqual({ provider: "mock", providerPreferences: { pi: { model: "anthropic/sonnet" }, - mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} }, + mock: { model: "one-minute-stream", mode: "approval-test", options: {} }, }, }); }); @@ -208,6 +208,60 @@ describe("create agent preferences", () => { }); }); + it("reads preferences saved as featureValues as options", () => { + expect( + parseFormPreferences({ + provider: "codex", + providerPreferences: { codex: { featureValues: { service_tier: "priority" } } }, + }), + ).toEqual({ + provider: "codex", + providerPreferences: { codex: { options: { service_tier: "priority" } } }, + }); + }); + + it("reads a remembered Codex fast_mode as the service tier it meant, so the draft shows Fast", () => { + expect( + parseFormPreferences({ + provider: "codex", + providerPreferences: { + codex: { options: { fast_mode: true, sandbox_mode: "read-only" } }, + claude: { options: { fast_mode: true } }, + }, + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { options: { service_tier: "fast", sandbox_mode: "read-only" } }, + claude: { options: { fast_mode: true } }, + }, + }); + + expect( + parseFormPreferences({ + providerPreferences: { codex: { featureValues: { fast_mode: false } } }, + }), + ).toEqual({ providerPreferences: { codex: { options: { service_tier: "default" } } } }); + + // A tier already remembered wins over the key it replaced. + expect( + parseFormPreferences({ + providerPreferences: { codex: { options: { fast_mode: true, service_tier: "default" } } }, + }), + ).toEqual({ providerPreferences: { codex: { options: { service_tier: "default" } } } }); + }); + + it("keeps options that are not booleans or strings, such as a profile's sandbox settings", () => { + const options = { + sandbox_workspace_write: { network_access: true, writable_roots: ["/tmp"] }, + max_turns: 3, + }; + + expect( + parseFormPreferences({ provider: "codex", providerPreferences: { codex: { options } } }), + ).toEqual({ provider: "codex", providerPreferences: { codex: { options } } }); + }); + it("rejects unknown persisted fields outside the explicit legacy shape", () => { expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({}); }); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index bdade894e8..cd9d012fe7 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -1,13 +1,14 @@ import { z } from "zod"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])); +const optionsSchema = z.record(z.string(), z.json()); export interface ProviderPreferences { model?: string; mode?: string; thinkingByModel?: Record; - featureValues?: Record; + /** Last-used values of the provider's option controls, by option key. */ + options?: Record; } export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string }; @@ -20,12 +21,41 @@ export interface FormPreferences { launchTarget?: LaunchTarget; } -const providerPreferencesSchema: z.ZodType = z.strictObject({ - model: z.string().optional(), - mode: z.string().optional(), - thinkingByModel: z.record(z.string(), z.string()).optional(), - featureValues: featureValuesSchema.optional(), -}); +// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Preferences written +// before the rename stored the same record as `featureValues`. +function renameLegacyFeatureValues(value: unknown): unknown { + if (typeof value !== "object" || value === null || !("featureValues" in value)) return value; + const { featureValues, ...rest } = value as { featureValues: unknown } & Record; + return "options" in rest ? rest : { ...rest, options: featureValues }; +} + +const providerPreferencesSchema: z.ZodType = z.preprocess( + renameLegacyFeatureValues, + z.strictObject({ + model: z.string().optional(), + mode: z.string().optional(), + thinkingByModel: z.record(z.string(), z.string()).optional(), + options: optionsSchema.optional(), + }), +); + +// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Codex took `fast_mode` +// before `service_tier`; the daemon converts the option on every agent and profile it touches, +// and this is the one place the app reads a remembered value without the daemon in between. Keyed +// on the built-in provider id: a custom provider extending Codex keeps the old key and the daemon +// still converts it on create. +function convertLegacyCodexOptions( + preferences: Record, +): Record { + const codex = preferences.codex; + if (!codex?.options || !("fast_mode" in codex.options)) return preferences; + const { fast_mode: legacy, ...rest } = codex.options; + const options = + "service_tier" in rest || typeof legacy !== "boolean" + ? rest + : { ...rest, service_tier: legacy ? "fast" : "default" }; + return { ...preferences, codex: { ...codex, options } }; +} const launchTargetSchema: z.ZodType = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("chat") }), @@ -34,7 +64,10 @@ const launchTargetSchema: z.ZodType = z.discriminatedUnion("kind", export const FormPreferencesSchema = z.strictObject({ provider: z.string().optional(), - providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(), + providerPreferences: z + .record(z.string(), providerPreferencesSchema) + .transform(convertLegacyCodexOptions) + .optional(), // COMPAT(agentProfileFavoriteMigration): favourites were removed in v0.3.2. // Keep the legacy payload alive until every capable host has had a chance to // import it; ordinary preference writes must not erase it first. @@ -116,7 +149,7 @@ function applyProviderPreferenceUpdates( ): ProviderPreferences { const next: ProviderPreferences = { ...existing }; const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel); - const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues); + const nextOptions = mergeDefinedRecord(existing.options, updates.options); if (updates.model !== undefined) { next.model = updates.model; @@ -129,8 +162,8 @@ function applyProviderPreferenceUpdates( if (nextThinkingByModel !== undefined) { next.thinkingByModel = nextThinkingByModel; } - if (nextFeatureValues !== undefined) { - next.featureValues = nextFeatureValues; + if (nextOptions !== undefined) { + next.options = nextOptions; } return next; @@ -161,7 +194,7 @@ export function mergeCreateAgentSelectionPreferences(args: { modelId?: string | null; modeId?: string | null; thinkingOptionId?: string | null; - featureValues?: Record; + options?: Record; }): FormPreferences { if (!args.provider) { return args.preferences; @@ -170,7 +203,7 @@ export function mergeCreateAgentSelectionPreferences(args: { const modelId = args.modelId?.trim() ?? ""; const modeId = args.modeId?.trim() ?? ""; const thinkingOptionId = args.thinkingOptionId?.trim() ?? ""; - const featureValues = featureValuesSchema.safeParse(args.featureValues); + const options = optionsSchema.safeParse(args.options); return mergeProviderPreferences({ preferences: args.preferences, @@ -179,7 +212,7 @@ export function mergeCreateAgentSelectionPreferences(args: { model: modelId || undefined, mode: args.modeId === undefined ? undefined : modeId || null, ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), - ...(featureValues.success ? { featureValues: featureValues.data } : {}), + ...(options.success ? { options: options.data } : {}), }, }); } @@ -192,7 +225,7 @@ export function applyAgentProfilePreferences(args: { modelId: string; modeId: string; thinkingOptionId: string; - featureValues: Record; + options: Record; }): FormPreferences { let next = args.preferences; if (args.previousProvider) { @@ -215,7 +248,7 @@ export function applyAgentProfilePreferences(args: { ...(args.modelId && args.thinkingOptionId ? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } } : {}), - featureValues: args.featureValues, + options: args.options, }, }); } diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 87c62f81ca..b2fe4bed11 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -119,6 +119,36 @@ export async function fetchProvidersSnapshot(input: { return snapshot; } +/** + * The providers snapshot as a replica query: its key and its fetch together, so the model picker + * and anything else that needs the catalog read the same cache entry. `client` is asked when the + * fetch runs. + */ +export function providersSnapshotQuery(input: { + serverId: string; + cwd?: string | null; + client: () => SnapshotClient; + queryClient?: QueryClient; + cache?: ProviderSnapshotCache; +}) { + const cwd = normalizeProvidersSnapshotCwd(input.cwd); + return { + queryKey: providersSnapshotQueryKey(input.serverId, cwd), + // The content-hash cache already owns catalog and model identity. + structuralSharing: false as const, + pushEvent: "providers_snapshot_update", + queryFn: ({ signal }: { signal: AbortSignal }) => + fetchProvidersSnapshot({ + client: input.client(), + serverId: input.serverId, + cwd, + queryClient: input.queryClient, + cache: input.cache, + signal, + }), + }; +} + export async function refreshAndApplyProvidersSnapshot(input: { client: SnapshotClient & Pick; queryClient: QueryClient; diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index e80ee5f32a..07f7a622ee 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -66,7 +66,7 @@ export function useFetchQueries( return useQueries({ queries: inputs.map((input) => fetchQueryOptions(input)) }); } -function replicaQueryOptions< +export function replicaQueryOptions< TQueryFnData, TError = Error, TData = TQueryFnData, diff --git a/packages/app/src/hooks/feature-preferences.test.ts b/packages/app/src/hooks/feature-preferences.test.ts deleted file mode 100644 index 7cfca13a68..0000000000 --- a/packages/app/src/hooks/feature-preferences.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveFeatureValues } from "./feature-preferences"; - -describe("feature-preferences", () => { - const features = [ - { - type: "toggle" as const, - id: "fast_mode", - label: "Fast", - value: false, - }, - { - type: "toggle" as const, - id: "plan_mode", - label: "Plan", - value: false, - }, - ]; - - it("restores persisted values for available features", () => { - expect( - resolveFeatureValues({ - features, - persistedFeatureValues: { - fast_mode: true, - unknown_feature: true, - }, - localFeatureValues: {}, - }), - ).toEqual({ - fast_mode: true, - }); - }); - - it("prefers local values over persisted values", () => { - expect( - resolveFeatureValues({ - features, - persistedFeatureValues: { - fast_mode: true, - plan_mode: false, - }, - localFeatureValues: { - fast_mode: false, - }, - }), - ).toEqual({ - fast_mode: false, - plan_mode: false, - }); - }); -}); diff --git a/packages/app/src/hooks/feature-preferences.ts b/packages/app/src/hooks/feature-preferences.ts deleted file mode 100644 index 4fbd956581..0000000000 --- a/packages/app/src/hooks/feature-preferences.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { AgentFeature } from "@getpaseo/protocol/agent-types"; - -export function pruneFeatureValues( - featureValues: Record, - features: AgentFeature[], -): Record { - const allowedFeatureIds = new Set(features.map((feature) => feature.id)); - let changed = false; - const next: Record = {}; - - for (const [featureId, value] of Object.entries(featureValues)) { - if (!allowedFeatureIds.has(featureId)) { - changed = true; - continue; - } - next[featureId] = value; - } - - return changed ? next : featureValues; -} - -export function applyFeatureValues( - features: AgentFeature[], - featureValues: Record, -): AgentFeature[] { - if (Object.keys(featureValues).length === 0) { - return features; - } - - return features.map((feature) => { - if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) { - return feature; - } - - return { - ...feature, - value: featureValues[feature.id], - } as AgentFeature; - }); -} - -export function resolveFeatureValues(args: { - features: AgentFeature[]; - persistedFeatureValues: Record; - localFeatureValues: Record; -}): Record { - const next: Record = {}; - - for (const feature of args.features) { - if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) { - next[feature.id] = args.localFeatureValues[feature.id]; - continue; - } - if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) { - next[feature.id] = args.persistedFeatureValues[feature.id]; - } - } - - return next; -} diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index a400cf6862..f08a69c32e 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -130,14 +130,14 @@ function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig const modeId = draftConfig.modeId?.trim() ?? ""; const model = draftConfig.model?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; - const featureValues = draftConfig.featureValues; + const providerOptions = draftConfig.providerOptions; return { provider: draftConfig.provider, cwd, ...(modeId ? { modeId } : {}), ...(model ? { model } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}), - ...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}), + ...(providerOptions && Object.keys(providerOptions).length > 0 ? { providerOptions } : {}), }; } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 1522ce082e..03de6b6806 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import type { AgentMode, @@ -28,14 +28,13 @@ import { mergeSelectedComposerPreferences, buildProviderDefinitionMap, buildProviderDefinitionMapForStatuses, - INITIAL_AGENT_FORM_RESOLUTION, - INITIAL_USER_MODIFIED, RESOLVABLE_PROVIDER_STATUSES, SELECTABLE_PROVIDER_STATUSES, type FormInitialValues, type FormState, type ProviderModelsByProvider, } from "@/provider-selection/resolve-agent-form"; +import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { MaterializedAgentProfile } from "@/agent-profiles"; @@ -52,6 +51,8 @@ export interface UseAgentFormStateOptions { export interface UseAgentFormStateResult { selectedServerId: string | null; selectedProvider: AgentProvider | null; + /** The provider as it is now, which a render may not have reached yet. */ + getSelectedProvider: () => AgentProvider | null; selectedMode: string; setModeFromUser: (modeId: string) => void; selectedModel: string; @@ -74,13 +75,20 @@ export interface UseAgentFormStateResult { modelError: string | null; refreshProviderModels: (provider?: AgentProvider) => void; refetchProviderModelsIfStale: () => void; - setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void; + /** `false` when the provider is unknown or not ready. */ + setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => boolean; applyProfileFromUser: (profile: MaterializedAgentProfile) => void; clearProviderSelectionFromUser: () => void; workingDirIsEmpty: boolean; persistFormPreferences: () => Promise; } +const EMPTY_CATALOG: AgentFormCatalog = { + selectableProviders: new Map(), + models: new Map(), + providerPreferences: () => undefined, +}; + function resolveSelectedProviderModes(input: { selectedEntry: ProviderSnapshotEntry | null; provider: AgentProvider | null; @@ -172,11 +180,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo [updatePreferences], ); - const [{ form: formState, userModified, resolution }, dispatch] = useReducer(resolveAgentForm, { - form: { provider: null, modeId: "", model: "", thinkingOptionId: "" }, - userModified: INITIAL_USER_MODIFIED, - resolution: INITIAL_AGENT_FORM_RESOLUTION, - }); + // The store reads what the host offers when a command runs; each render publishes the latest. + const catalogRef = useRef(EMPTY_CATALOG); + const [formStore] = useState(() => openAgentForm(() => catalogRef.current)); + const { form: formState, resolution } = useSyncExternalStore( + formStore.subscribe, + formStore.getState, + formStore.getState, + ); + const dispatch = formStore.dispatch; const { entries: snapshotEntries, @@ -246,6 +258,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo const providerDefinitionMap = snapshotProviderDefinitionMap; const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap; const allProviderModels = snapshotAllProviderModels; + catalogRef.current = { + selectableProviders: selectableProviderDefinitionMap, + models: allProviderModels, + providerPreferences: (provider) => + preferenceOverlayRef.current.current().providerPreferences?.[provider], + }; const modelSelectorProviders = snapshotModelSelectorProviders; const availableModels = snapshotSelectedProviderModels; const modeOptions = snapshotSelectedProviderModes; @@ -277,27 +295,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo preferences, snapshotProviderModelsByProvider, snapshotResolvableProviderDefinitionMap, + dispatch, ]); const setProviderAndModelFromUser = useCallback( - (provider: AgentProvider, modelId: string) => { - if (!selectableProviderDefinitionMap.has(provider)) { - return; - } - const providerDef = selectableProviderDefinitionMap.get(provider); + (provider: AgentProvider, modelId: string): boolean => { + if (!formStore.setProviderAndModel(provider, modelId)) return false; const providerModels = allProviderModels.get(provider) ?? null; - const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider]; - const normalizedModelId = normalizeSelectedModelId(modelId); - const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels); - - dispatch({ - type: "SET_PROVIDER_AND_MODEL_FROM_USER", - provider, - modelId, - providerDef, - providerModels, - providerPrefs, - }); + const nextModelId = + normalizeSelectedModelId(modelId) || resolveDefaultModelId(providerModels); void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ preferences: current, @@ -307,13 +313,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo }, }), ); + return true; }, - [allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences], + [allProviderModels, formStore, updateCurrentPreferences], ); const clearProviderSelectionFromUser = useCallback(() => { dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }); - }, []); + }, [dispatch]); const applyProfileFromUser = useCallback( (profile: MaterializedAgentProfile) => { @@ -322,7 +329,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo return; } - const previousProvider = formState.provider; + const formBefore = formStore.getState(); + const previousProvider = formBefore.form.provider; const providerDef = selectableProviderDefinitionMap.get(provider); const providerModels = allProviderModels.get(provider) ?? null; const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider]; @@ -336,7 +344,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo providerModels, providerPrefs, }; - const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action); + const nextState = resolveAgentForm(formBefore, action); const previousProviderModeIds = previousProvider ? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? []) : []; @@ -352,7 +360,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo modelId: model, modeId, thinkingOptionId, - featureValues: profile.featureValues, + options: profile.options, }); }).catch((error) => { console.warn("[useAgentFormState] persist profile preference failed", error); @@ -360,19 +368,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo }, [ allProviderModels, - formState, + dispatch, + formStore, providerDefinitionMap, - resolution, selectableProviderDefinitionMap, updateCurrentPreferences, - userModified, ], ); const setModeFromUser = useCallback( (modeId: string) => { dispatch({ type: "SET_MODE_FROM_USER", modeId }); - const provider = formState.provider; + const { provider } = formStore.getState().form; if (provider) { void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ @@ -385,24 +392,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo ); } }, - [formState.provider, updateCurrentPreferences], + [dispatch, formStore, updateCurrentPreferences], ); const setModelFromUser = useCallback( (modelId: string) => { - const provider = formState.provider; - const providerPrefs = provider - ? preferenceOverlayRef.current.current().providerPreferences?.[provider] - : undefined; - dispatch({ - type: "SET_MODEL_FROM_USER", - modelId, - availableModels, - providerPrefs, - }); + formStore.setModel(modelId); + const { provider } = formStore.getState().form; if (provider) { - const normalizedModelId = normalizeSelectedModelId(modelId); - const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels); + const listed = allProviderModels.get(provider); + const nextModelId = + normalizeSelectedModelId(modelId) || + resolveDefaultModelId(listed?.length ? listed : null); void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ preferences: current, @@ -414,13 +415,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo ); } }, - [availableModels, formState.provider, updateCurrentPreferences], + [allProviderModels, formStore, updateCurrentPreferences], ); const setThinkingOptionFromUser = useCallback( (thinkingOptionId: string) => { dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId }); - const { provider, model: modelId } = formState; + const { provider, model: modelId } = formStore.getState().form; if (provider && modelId) { void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ @@ -435,9 +436,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo ); } }, - [formState, updateCurrentPreferences], + [dispatch, formStore, updateCurrentPreferences], ); + const getSelectedProvider = useCallback(() => formStore.getState().form.provider, [formStore]); + const refreshProviderModels = useCallback( (provider?: AgentProvider) => { void refreshSnapshot(provider ? [provider] : undefined); @@ -479,6 +482,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo () => ({ selectedServerId: serverId, selectedProvider: formState.provider, + getSelectedProvider, selectedMode: formState.modeId, setModeFromUser, selectedModel: formState.model, @@ -509,6 +513,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo }), [ serverId, + getSelectedProvider, formState.provider, formState.modeId, formState.model, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index e0cc75ee06..082d11827e 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -1,9 +1,6 @@ import { useRef } from "react"; -import type { - AgentCapabilityFlags, - AgentFeature, - AgentProvider, -} from "@getpaseo/protocol/agent-types"; +import type { AgentCapabilityFlags, AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { Agent } from "@/stores/session-store"; import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync"; export interface AgentScreenAgent { @@ -22,7 +19,8 @@ export interface AgentScreenAgent { modeId?: string | null; thinkingOptionId?: string | null; } | null; - features?: readonly AgentFeature[]; + options?: Agent["options"]; + controls?: Agent["controls"]; lastError?: string | null; projectPlacement?: { projectKey?: string; diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts deleted file mode 100644 index 9ab23ad6d0..0000000000 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import type { AgentProvider, AgentSessionConfig } from "@getpaseo/protocol/agent-types"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences"; -import { - applyFeatureValues, - pruneFeatureValues, - resolveFeatureValues, -} from "./feature-preferences"; - -type DraftFeatureConfig = Pick< - AgentSessionConfig, - "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" ->; - -export function useDraftAgentFeatures(input: { - serverId: string | null | undefined; - provider: AgentProvider | null; - cwd: string | null | undefined; - modeId: string | null | undefined; - modelId: string | null | undefined; - thinkingOptionId: string | null | undefined; - initialFeatureValues?: Record; -}) { - const { t } = useTranslation(); - const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } = - input; - const [localFeatureValues, setLocalFeatureValues] = useState>( - () => initialFeatureValues ?? {}, - ); - const client = useHostRuntimeClient(serverId ?? ""); - const isConnected = useHostRuntimeIsConnected(serverId ?? ""); - const { preferences, updatePreferences } = useFormPreferences(); - const normalizedCwd = cwd?.trim() || ""; - const normalizedProvider = provider ?? null; - const previousProviderRef = useRef(normalizedProvider); - const persistedFeatureValues = useMemo( - () => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}), - [preferences.providerPreferences, provider], - ); - - const draftConfig = useMemo(() => { - if (!normalizedProvider || !normalizedCwd) { - return null; - } - - return { - provider: normalizedProvider, - cwd: normalizedCwd, - ...(modeId ? { modeId } : {}), - ...(modelId ? { model: modelId } : {}), - ...(thinkingOptionId ? { thinkingOptionId } : {}), - }; - }, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]); - - const featuresQuery = useQuery({ - queryKey: [ - "providerFeatures", - serverId ?? null, - normalizedProvider, - normalizedCwd || null, - modeId ?? null, - modelId ?? null, - thinkingOptionId ?? null, - ], - enabled: Boolean(serverId && client && isConnected && draftConfig), - staleTime: 5 * 60 * 1000, - queryFn: async () => { - if (!client || !draftConfig) { - throw new Error(t("workspace.terminal.hostDisconnected")); - } - const payload = await client.listProviderFeatures(draftConfig); - if (payload.error) { - throw new Error(payload.error); - } - return payload.features ?? []; - }, - }); - const availableFeaturesRaw = featuresQuery.data; - const availableFeatures = useMemo(() => availableFeaturesRaw ?? [], [availableFeaturesRaw]); - const featureValues = useMemo( - () => - resolveFeatureValues({ - features: availableFeatures, - persistedFeatureValues, - localFeatureValues, - }), - [availableFeatures, localFeatureValues, persistedFeatureValues], - ); - - const features = useMemo(() => { - return applyFeatureValues(availableFeatures, featureValues); - }, [availableFeatures, featureValues]); - - useEffect(() => { - const previousProvider = previousProviderRef.current; - previousProviderRef.current = normalizedProvider; - if (previousProvider === null) { - return; - } - if (previousProvider !== normalizedProvider) { - setLocalFeatureValues({}); - } - }, [normalizedProvider]); - - useEffect(() => { - if (availableFeaturesRaw === undefined) { - return; - } - const next = pruneFeatureValues(localFeatureValues, availableFeatures); - if (next !== localFeatureValues) { - setLocalFeatureValues(next); - } - }, [availableFeatures, availableFeaturesRaw, localFeatureValues]); - - const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined; - const setFeatureValue = useCallback( - (featureId: string, value: unknown) => { - setLocalFeatureValues((current) => { - if (Object.is(current[featureId], value)) { - return current; - } - - return { ...current, [featureId]: value }; - }); - if (!provider) { - return; - } - void updatePreferences((current) => - mergeProviderPreferences({ - preferences: current, - provider, - updates: { - featureValues: { - [featureId]: value, - }, - }, - }), - ).catch((error) => { - console.warn("[useDraftAgentFeatures] persist feature preference failed", error); - }); - }, - [provider, updatePreferences], - ); - - const applyProfileFeatureValues = useCallback((values: Record) => { - setLocalFeatureValues(values); - }, []); - - return { - features, - featureValues: effectiveFeatureValues, - isLoading: featuresQuery.isLoading, - setFeatureValue, - applyProfileFeatureValues, - }; -} diff --git a/packages/app/src/hooks/use-draft-provider-options.ts b/packages/app/src/hooks/use-draft-provider-options.ts new file mode 100644 index 0000000000..52309773c2 --- /dev/null +++ b/packages/app/src/hooks/use-draft-provider-options.ts @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import type { AgentProvider, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { + draftControlsRequest, + openDraftOptions, + ownDraftOptions, + shownDraftOptions, + viewDraftOptions, +} from "@/agent-controls/draft-options-model"; +import type { ComposerOptionControls } from "@/agent-controls/option-controls"; +import { useFetchQuery } from "@/data/query"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences"; + +/** + * Adapter between a draft's options model and the screen: it sends the model's complete options + * to the daemon, hands the answer back, and persists the user's toggles as last-used values. + */ +export function useDraftProviderOptions(input: { + serverId: string | null | undefined; + provider: AgentProvider | null; + /** The form's provider as it is now, which a render may not have reached yet. */ + getProvider: () => AgentProvider | null; + cwd: string | null | undefined; + modeId: string | null | undefined; + modelId: string | null | undefined; + thinkingOptionId: string | null | undefined; + initialOptions?: ProviderOptions; +}) { + const { t } = useTranslation(); + const { + serverId, + provider, + getProvider, + cwd, + modeId, + modelId, + thinkingOptionId, + initialOptions, + } = input; + const [model] = useState(() => + openDraftOptions({ provider, seed: initialOptions, currentProvider: getProvider }), + ); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + const client = useHostRuntimeClient(serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const { preferences, updatePreferences } = useFormPreferences(); + const normalizedCwd = cwd?.trim() || ""; + const persisted = preferences.providerPreferences?.[provider ?? ""]?.options; + + useEffect(() => model.observeProvider(provider), [model, provider]); + useEffect(() => model.setRemembered(persisted ?? {}), [model, persisted]); + + const ownOptions = ownDraftOptions(state); + const draftConfig = useMemo( + () => + draftControlsRequest(state, { + provider, + cwd: normalizedCwd, + modeId, + modelId, + thinkingOptionId, + }), + [modeId, modelId, normalizedCwd, provider, state, thinkingOptionId], + ); + const queryKey = useMemo( + () => ["providerControls", serverId ?? null, draftConfig], + [draftConfig, serverId], + ); + const requestKey = JSON.stringify(queryKey); + + const { data } = useFetchQuery({ + queryKey, + dataShape: "value", + staleTimeMs: 5 * 60 * 1000, + enabled: Boolean(serverId && client && isConnected && draftConfig), + queryFn: async () => { + if (!client || !draftConfig) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + const payload = await client.listProviderControls(draftConfig); + if (payload.error) { + throw new Error(payload.error); + } + return { controls: payload.controls ?? [], options: payload.options ?? {} }; + }, + }); + + useEffect(() => { + if (provider && data) model.applyResolved({ provider, requestKey, ...data }); + }, [data, model, provider, requestKey]); + + // An edit belongs to the provider the form has now, even before the render that shows it. + const setOption = useCallback( + (key: string, value: unknown) => { + model.setOption(key, value); + const current = getProvider(); + if (!current) return; + void updatePreferences((stored) => + mergeProviderPreferences({ + preferences: stored, + provider: current, + updates: { options: { [key]: value } }, + }), + ).catch((error) => { + console.warn("[useDraftProviderOptions] persist option preference failed", error); + }); + }, + [getProvider, model, updatePreferences], + ); + + const view = viewDraftOptions(state, { provider, requestKey }); + const viewControls = view?.controls; + const viewOptions = view?.options; + const optionControls = useMemo( + () => + viewControls && viewOptions + ? { controls: viewControls, options: viewOptions, onSetOption: setOption } + : undefined, + [setOption, viewControls, viewOptions], + ); + + // 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, + }; +} diff --git a/packages/app/src/hooks/use-fork-agent.ts b/packages/app/src/hooks/use-fork-agent.ts index f2cdf3f911..5ecb08ebe0 100644 --- a/packages/app/src/hooks/use-fork-agent.ts +++ b/packages/app/src/hooks/use-fork-agent.ts @@ -36,7 +36,7 @@ export type ForkAgentSource = Pick< | "model" | "thinkingOptionId" | "runtimeInfo" - | "features" + | "options" | "projectPlacement" >; @@ -95,18 +95,13 @@ function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | u return undefined; } - const featureValues: Record = {}; - for (const feature of agent.features ?? []) { - featureValues[feature.id] = feature.value; - } - return { provider: agent.provider, cwd: agent.cwd, modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, model: agent.model ?? agent.runtimeInfo?.model ?? null, thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, - featureValues, + providerOptions: { ...agent.options }, }; } diff --git a/packages/app/src/hooks/use-form-preferences.test.ts b/packages/app/src/hooks/use-form-preferences.test.ts index be0653fbc1..9c1dbc7be6 100644 --- a/packages/app/src/hooks/use-form-preferences.test.ts +++ b/packages/app/src/hooks/use-form-preferences.test.ts @@ -63,7 +63,7 @@ describe("mergeProviderPreferences", () => { providerPreferences: { codex: { model: "gpt-5.4", - featureValues: { + options: { fast_mode: true, }, }, @@ -71,7 +71,7 @@ describe("mergeProviderPreferences", () => { }, provider: "codex", updates: { - featureValues: { + options: { plan_mode: true, }, }, @@ -81,7 +81,7 @@ describe("mergeProviderPreferences", () => { providerPreferences: { codex: { model: "gpt-5.4", - featureValues: { + options: { fast_mode: true, plan_mode: true, }, diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index dc90c18f06..d823782cbf 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -10,6 +10,7 @@ import { useReplicaQuery } from "@/data/query"; import { queryClient as singletonQueryClient } from "@/data/query-client"; import { normalizeProvidersSnapshotCwd, + providersSnapshotQuery, providersSnapshotQueryKey, providersSnapshotQueryRoot, fetchProvidersSnapshot, @@ -78,17 +79,16 @@ export function useProvidersSnapshot( const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]); const snapshotQuery = useReplicaQuery({ - queryKey, - // The content-hash cache already owns catalog and model identity. - structuralSharing: false, + ...providersSnapshotQuery({ + serverId: serverId ?? "", + cwd, + client: () => { + if (!client || !serverId) throw new Error(t("workspace.terminal.hostDisconnected")); + return client; + }, + queryClient, + }), enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected), - pushEvent: "providers_snapshot_update", - queryFn: async ({ signal }) => { - if (!client || !serverId) { - throw new Error(t("workspace.terminal.hostDisconnected")); - } - return fetchProvidersSnapshot({ client, serverId, cwd, queryClient, signal }); - }, }); const refreshMutation = useMutation({ diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 4b93a54632..cf000b65e6 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { + pluginCombobox: { + searching: "جارٍ البحث…", + empty: "لا توجد نتائج", + failed: "فشل البحث: {{message}}", + }, paneFind: { connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.", historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9ad175d5b9..c00691c4d9 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1,4 +1,9 @@ export const en = { + pluginCombobox: { + searching: "Searching…", + empty: "No results", + failed: "Search failed: {{message}}", + }, paneFind: { connectionFailure: "Could not search this chat. Check the host connection and retry.", historyChangedFailure: "The chat changed while searching. Search again.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 38ba72c77f..0d4e0dfe63 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { + pluginCombobox: { + searching: "Buscando…", + empty: "Sin resultados", + failed: "La búsqueda falló: {{message}}", + }, paneFind: { connectionFailure: "No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 01b438b7e3..26f44d7834 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { + pluginCombobox: { + searching: "Recherche…", + empty: "Aucun résultat", + failed: "Échec de la recherche : {{message}}", + }, paneFind: { connectionFailure: "Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 52419d9851..e8029fd796 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { + pluginCombobox: { + searching: "検索中…", + empty: "結果がありません", + failed: "検索に失敗しました: {{message}}", + }, paneFind: { connectionFailure: "このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 173dc82268..bf61495d1e 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { + pluginCombobox: { + searching: "검색 중…", + empty: "결과 없음", + failed: "검색 실패: {{message}}", + }, paneFind: { connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.", historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 4f38145a6d..47de25204a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { + pluginCombobox: { + searching: "Pesquisando…", + empty: "Nenhum resultado", + failed: "Falha na pesquisa: {{message}}", + }, paneFind: { connectionFailure: "Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d06d2f3154..0486699628 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { + pluginCombobox: { + searching: "Поиск…", + empty: "Нет результатов", + failed: "Не удалось выполнить поиск: {{message}}", + }, paneFind: { connectionFailure: "Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index cacc0ea57c..6326bbe7bf 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { + pluginCombobox: { + searching: "搜索中…", + empty: "无结果", + failed: "搜索失败:{{message}}", + }, paneFind: { connectionFailure: "无法搜索此聊天。请检查主机连接后重试。", historyChangedFailure: "搜索期间聊天已更改。请重新搜索。", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index f85ae6b96d..ccae42afbe 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -116,7 +116,8 @@ interface ChatAgentStateShape { model?: Agent["model"]; thinkingOptionId?: Agent["thinkingOptionId"]; runtimeInfo?: Agent["runtimeInfo"]; - features?: Agent["features"]; + options?: Agent["options"]; + controls?: Agent["controls"]; lastError?: Agent["lastError"] | null; } @@ -175,7 +176,8 @@ function selectChatAgentState( model: agent.model, thinkingOptionId: agent.thinkingOptionId, runtimeInfo: agent.runtimeInfo, - features: agent.features, + options: agent.options, + controls: agent.controls, lastError: agent.lastError ?? null, archivedAt: agent.archivedAt ?? null, requiresAttention: agent.requiresAttention ?? false, @@ -202,7 +204,8 @@ function buildChatAgentFromState( model: state.model, thinkingOptionId: state.thinkingOptionId, runtimeInfo: state.runtimeInfo, - features: state.features, + options: state.options, + controls: state.controls, lastError: state.lastError ?? null, projectPlacement, }; @@ -624,7 +627,8 @@ function AgentPanelBody({ model: agentState.model, thinkingOptionId: agentState.thinkingOptionId, runtimeInfo: agentState.runtimeInfo, - features: agentState.features, + options: agentState.options, + controls: agentState.controls, lastError: agentState.lastError ?? null, projectPlacement, } diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index 257d198d24..e4a87f65e1 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -1,3 +1,4 @@ +import { pluginComposersSource } from "./composer-items/source"; import { getPluginClientApi } from "./client-state/api"; import { callPluginRpc } from "@getpaseo/plugin/client/host"; import type { @@ -41,6 +42,7 @@ export function createPluginCapabilities( navigation.openSettings(plugin.id, screenId); }, openScreen, + composers: (selector) => pluginComposersSource(plugin).resolve(selector), // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 openSurface: (screenId) => openScreen({ screenId }), }; diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index 6c2bbe91af..ac3aba7672 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -27,6 +27,8 @@ function installation(): InstalledPlugin { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/catalog-sync-model.test.ts b/packages/app/src/plugins/catalog-sync-model.test.ts index 7d9ad09e41..c6efe493ed 100644 --- a/packages/app/src/plugins/catalog-sync-model.test.ts +++ b/packages/app/src/plugins/catalog-sync-model.test.ts @@ -150,6 +150,7 @@ function harness() { }, openScreen() {}, openSurface() {}, + composers: () => [], async playAudio() {}, openPanel() {}, addComposerPill: () => ({ update() {}, remove() {} }), diff --git a/packages/app/src/plugins/client-state/fixtures.ts b/packages/app/src/plugins/client-state/fixtures.ts index 4439e385f4..08fbc53af3 100644 --- a/packages/app/src/plugins/client-state/fixtures.ts +++ b/packages/app/src/plugins/client-state/fixtures.ts @@ -50,4 +50,5 @@ export const agent: Agent = { attentionReason: "permission", parentAgentId: null, labels: { phase: "implementation" }, + metadata: { "linear.issue": { id: "ENG-1" } }, }; diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index 7d63b178ed..d0f09008d9 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -61,12 +61,16 @@ describe("plugin context snapshots", () => { attentionReason: "permission", parentAgentId: null, labels: { phase: "implementation" }, + metadata: { "linear.issue": { id: "ENG-1" } }, }); expect(snapshot.labels).not.toBe(agent.labels); expect(Object.isFrozen(snapshot)).toBe(true); expect(Object.isFrozen(snapshot.labels)).toBe(true); expect(Reflect.set(snapshot, "title", "Hostile rename")).toBe(false); expect(Reflect.set(snapshot.labels, "phase", "hostile")).toBe(false); + const issue = snapshot.metadata["linear.issue"] as { id: string }; + expect(Reflect.set(issue, "id", "hostile")).toBe(false); + expect(agent.metadata?.["linear.issue"]).toEqual({ id: "ENG-1" }); expect(snapshot.title).toBe("Implement plugin panels"); expect(snapshot.labels).toEqual({ phase: "implementation" }); expect(agent.title).toBe("Implement plugin panels"); diff --git a/packages/app/src/plugins/client-state/snapshots.ts b/packages/app/src/plugins/client-state/snapshots.ts index ac810cf8f0..d805913201 100644 --- a/packages/app/src/plugins/client-state/snapshots.ts +++ b/packages/app/src/plugins/client-state/snapshots.ts @@ -6,6 +6,7 @@ import type { PluginProjectSnapshot, } from "@getpaseo/plugin"; import type { Agent, WorkspaceDescriptor, ProjectDescriptor } from "@/stores/session-store"; +import { cloneAndFreeze } from "../freeze"; import { entityRefs } from "./refs"; import { deriveEffectiveWorkspaceStatus } from "@/workspace-status"; @@ -97,6 +98,7 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl attentionReason: agent.attentionReason ?? null, parentAgentId: agent.parentAgentId, labels: Object.freeze({ ...agent.labels }), + metadata: cloneAndFreeze(agent.metadata ?? {}), }); const byWorkspace = agentSnapshots.get(agent) ?? new Map(); byWorkspace.set(workspaceId, snapshot); diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 37a71ff255..95e739d50b 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -63,6 +63,7 @@ const agent: PluginAgentSnapshot = { attentionReason: null, parentAgentId: null, labels: {}, + metadata: {}, }; const inspect = defineRpc({ @@ -103,6 +104,8 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [ { id: "details", diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index faf3b9215f..9686aab731 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -1,5 +1,5 @@ import { usePathname } from "expo-router"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { useCommandCenterActions } from "@/command-center/provider"; import { useToast } from "@/contexts/toast-context"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; @@ -9,6 +9,7 @@ import { useWorkspaceExists } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { createPluginClientStateSource } from "../client-state/source"; +import { publishFocusedAgent } from "../focused-agent"; import { hostIdFromPathname } from "../routes"; import { useInstalledPlugins } from "../registry"; import { buildPluginCommandCenterContributions } from "./contributions"; @@ -37,6 +38,15 @@ export function PluginCommandCenterActions() { normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId) ); }); + const handedAgentId = agentExists ? focusedAgentId : null; + // The agent a plugin's `{ focused: true }` composer names is the one handed to agent items. + // Known limit: this publishes in an effect, so a command that runs on the first commit after a + // route change can still see the previous route's agent. Publishing from a shared + // navigation/focus context would remove the gap. + useEffect(() => { + publishFocusedAgent(serverId && handedAgentId ? { serverId, agentId: handedAgentId } : null); + return () => publishFocusedAgent(null); + }, [handedAgentId, serverId]); const client = useHostRuntimeClient(serverId ?? ""); const installed = useInstalledPlugins(); const plugins = useMemo( @@ -54,23 +64,13 @@ export function PluginCommandCenterActions() { plugins, state: stateSource, workspaceId: workspaceExists ? workspaceId : null, - agentId: agentExists ? focusedAgentId : null, + agentId: handedAgentId, navigation: createPluginNavigation({ serverId, workspaceId }), reportError(error) { toast.error(error instanceof Error ? error.message : String(error)); }, }); - }, [ - agentExists, - client, - focusedAgentId, - plugins, - serverId, - stateSource, - toast, - workspaceExists, - workspaceId, - ]); + }, [client, handedAgentId, plugins, serverId, stateSource, toast, workspaceExists, workspaceId]); useCommandCenterActions({ sourceId: serverId ? `plugins:${serverId}` : "plugins", diff --git a/packages/app/src/plugins/composer-items/index.tsx b/packages/app/src/plugins/composer-items/index.tsx new file mode 100644 index 0000000000..c329a1de6a --- /dev/null +++ b/packages/app/src/plugins/composer-items/index.tsx @@ -0,0 +1,93 @@ +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 { useComposerModel } from "@/composer/model"; +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 ( + + {plugins.map((plugin) => ( + + ))} + + ); +} + +function PluginComposerItemsHost({ + plugin, + theme, +}: { + plugin: InstalledPlugin; + theme: PluginHostProps["theme"]; +}) { + const model = useComposerModel(); + const { environment, props } = usePluginItemHost(plugin, theme); + const state = useSyncExternalStore( + model?.subscribe ?? subscribeNothing, + model ? model.getState : noState, + model ? model.getState : noState, + ); + const composer = useMemo( + () => (model && state ? toPluginComposer({ plugin, model, state }) : null), + [model, plugin, state], + ); + if (!composer) return null; + return ( + + + + + + ); +} + +const ThemedPluginComposerItems = withUnistyles(PluginComposerItemsHost); + +function subscribeNothing() { + return () => undefined; +} + +function noState(): null { + return null; +} diff --git a/packages/app/src/plugins/composer-items/model.test.ts b/packages/app/src/plugins/composer-items/model.test.ts new file mode 100644 index 0000000000..59e8e79804 --- /dev/null +++ b/packages/app/src/plugins/composer-items/model.test.ts @@ -0,0 +1,304 @@ +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 { composerModelFromHalves } from "@/composer/halves-model"; +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, + subscribe: () => { + throw new Error("Subscriptions are not used here"); + }, + cleanup: () => {}, + settingsScreens: [], + surfaces: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], + sidebarViews: [], + 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) => + 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, + isLive: () => true, + 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 }; +} + +function compose(agent: ComposerAgent, input: ComposerInput) { + const model = composerModelFromHalves({ agent, input }); + return toPluginComposer({ plugin: installed(), model, state: model.getState() }); +} + +describe("toPluginComposer", () => { + it("reads the agent the composer configures and the context it sits in", () => { + const { agent } = fakeAgent("new-workspace"); + const { input, state } = fakeInput(); + state.text = "hello"; + const composer = compose(agent, input); + 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 = compose(agent, input); + 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 compose(draft.agent, input).setProvider("codex"); + expect(draft.calls).toEqual(["provider:codex"]); + + const running = fakeAgent("agent"); + await expect(compose(running.agent, input).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 = compose(agent, input); + 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 = compose(agent, input); + + 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 = compose(agent, input); + + // 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 = compose(agent, input); + + 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("hands out attachment items a plugin cannot change", () => { + const { agent } = fakeAgent("draft"); + const { input, state } = fakeInput(); + const handedIn = { ...issue }; + compose(agent, input).addAttachment({ sourceId: "issues", item: handedIn }); + // The draft keeps its own copy, so the object the plugin handed in is not the draft's. + handedIn.title = "Changed after adding"; + + const [attachment] = compose(agent, input).attachments; + + expect(attachment.item).toEqual(issue); + expect(Object.isFrozen(attachment.item)).toBe(true); + const [stored] = state.attachments; + expect(stored.kind === "plugin_resource" && Object.isFrozen(stored.item)).toBe(false); + }); + + it("hands the composer its own copy of the options and metadata a plugin sets", async () => { + const { agent } = fakeAgent("draft"); + const kept: Record = {}; + agent.setOptions = async (patch) => void (kept.options = patch); + agent.setMetadata = async (patch) => void (kept.metadata = patch); + const { input } = fakeInput(); + const composer = compose(agent, input); + + const sandbox = { mode: "read-only" }; + const linked = { id: "ISS-1" }; + await composer.setOptions({ sandbox }); + await composer.setMetadata({ linked }); + sandbox.mode = "full-access"; + linked.id = "ISS-2"; + + expect(kept.options).toEqual({ sandbox: { mode: "read-only" } }); + expect(kept.metadata).toEqual({ linked: { id: "ISS-1" } }); + }); + + 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 = compose(agent, input); + + composer.addAttachment({ sourceId: "issues", item: issue }); + composer.addAttachment({ sourceId: "issues", item: issue }); + expect(state.attachments).toHaveLength(2); + + const next = compose(agent, input); + 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 = compose(agent, input); + expect(() => composer.addAttachment({ sourceId: "nope", item: issue })).toThrow( + "Unknown attachment source: nope", + ); + }); +}); diff --git a/packages/app/src/plugins/composer-items/model.ts b/packages/app/src/plugins/composer-items/model.ts new file mode 100644 index 0000000000..c39c0cabea --- /dev/null +++ b/packages/app/src/plugins/composer-items/model.ts @@ -0,0 +1,82 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { PluginComposer } from "@getpaseo/plugin/client"; +import type { UserComposerAttachment } from "@/attachments/types"; +import type { ComposerModel, ComposerState } from "@/composer/model"; +import type { InstalledPlugin } from "../types"; +import { createPluginResourceAttachment } from "../attachments/model"; +import { cloneAndFreeze, ownCopy } 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) + ); +} + +/** + * What a plugin sees of a composer. Reads are `state`; every command goes to the model, so the + * mounted composer, a panel's handle and a Command Center item all reach the same one. + */ +export function toPluginComposer(input: { + plugin: InstalledPlugin; + model: ComposerModel; + state: ComposerState; +}): PluginComposer { + const { plugin, model, state } = input; + return { + context: state.context, + prompt: state.prompt, + attachments: state.attachments.flatMap((attachment) => + attachment.kind === "plugin_resource" && attachment.pluginId === plugin.id + ? [{ sourceId: attachment.sourceId, item: cloneAndFreeze(attachment.item) }] + : [], + ), + // Provider options are JSON on the wire. + agent: { + provider: state.provider, + model: state.model, + mode: state.mode, + thinking: state.thinking, + options: cloneAndFreeze(state.options as Record), + metadata: cloneAndFreeze(state.metadata), + }, + setPrompt: (text) => model.setPrompt(text), + insertText: (text) => model.insertText(text), + addAttachment({ sourceId, item }) { + 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, + }, + ownCopy(item), + ); + model.changeAttachments((current) => + current.some((candidate) => isPluginAttachment(candidate, plugin.id, sourceId, item.id)) + ? current + : [...current, attachment], + ); + }, + removeAttachment({ sourceId, itemId }) { + model.changeAttachments((current) => + current.filter((candidate) => !isPluginAttachment(candidate, plugin.id, sourceId, itemId)), + ); + }, + setProvider: (provider) => model.setProvider(provider), + setModel: (modelId) => model.setModel(modelId), + setMode: (modeId) => model.setMode(modeId), + setThinking: (thinkingOptionId) => model.setThinking(thinkingOptionId), + setOptions: (patch) => model.setOptions(ownCopy(patch)), + setMetadata: (patch) => model.setMetadata(ownCopy(patch)), + }; +} diff --git a/packages/app/src/plugins/composer-items/source.test.ts b/packages/app/src/plugins/composer-items/source.test.ts new file mode 100644 index 0000000000..a9be4012d0 --- /dev/null +++ b/packages/app/src/plugins/composer-items/source.test.ts @@ -0,0 +1,393 @@ +import { defineRpc } from "@getpaseo/plugin"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; +import { QueryClient } from "@tanstack/react-query"; +import { z } from "zod"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { providersSnapshotQueryRoot } from "@/data/providers-snapshot"; +import { queryClient } from "@/data/query-client"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { useDraftStore } from "@/stores/draft-store"; +import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; +import { createPluginClientStateSource } from "../client-state/source"; +import { buildPluginCommandCenterContributions } from "../command-center/contributions"; +import { publishFocusedAgent } from "../focused-agent"; +import type { InstalledPlugin } from "../types"; +import { pluginComposersSource } from "./source"; + +const serverId = "host-1"; + +function plugin(): InstalledPlugin { + return { + id: "review", + serverId, + clientBundle: "bundle", + lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), + queryClient: new QueryClient(), + paseo: createPaseoApi( + new DaemonClient({ + url: "ws://127.0.0.1:1", + clientId: "plugin-composer-test", + clientType: "cli", + }), + ), + invoke: async () => undefined, + subscribe: () => { + throw new Error("Subscriptions are not used here"); + }, + cleanup: () => {}, + settingsScreens: [], + surfaces: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], + sidebarViews: [], + filePanels: [], + composerItems: [], + newWorkspaceItems: [], + workspacePanels: [], + commandCenterItems: [], + clientSlashCommands: [], + attachmentSources: [ + { + id: "issues", + title: "Issues", + pickerTitle: "Issues", + searchPlaceholder: "Search", + icon: "Bug", + search: defineRpc({ + name: "issues.search", + input: z.object({}), + output: z.array(z.object({})), + }), + }, + ], + themes: [], + timelineTransformers: [], + timelineRenderers: [], + }; +} + +function agent(id: string, archivedAt: Date | null = null): Agent { + return { + serverId, + id, + provider: "claude", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: new Date(), + updatedAt: new Date(), + lastUserMessageAt: null, + lastActivityAt: new Date(), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: "default", + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + cwd: "/repo", + workspaceId: "workspace-1", + model: null, + parentAgentId: null, + labels: {}, + archivedAt, + }; +} + +beforeEach(() => { + useDraftStore.setState({ drafts: {}, textReplacementRequestByDraftKey: {} }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, {} as never); + store.setAgents( + serverId, + new Map([ + ["agent-1", agent("agent-1")], + ["agent-2", agent("agent-2", new Date())], + ]), + ); +}); + +afterEach(() => { + publishFocusedAgent(null); + useSessionStore.getState().clearSession(serverId); +}); + +describe("a plugin's composers by reference", () => { + it("names a running agent by id, and nothing for an unknown or archived one", () => { + const source = pluginComposersSource(plugin()); + expect(source.resolve({ agentId: "agent-1" })).toHaveLength(1); + expect(source.resolve({ agentId: "nope" })).toEqual([]); + expect(source.resolve({ agentId: "agent-2" })).toEqual([]); + }); + + it("follows the agent the Command Center hands out, and is empty when there is none", () => { + const source = pluginComposersSource(plugin()); + let notified = 0; + const stop = source.subscribe({ focused: true }, () => { + notified += 1; + }); + expect(source.resolve({ focused: true })).toEqual([]); + + publishFocusedAgent({ serverId, agentId: "agent-1" }); + const [focused] = source.resolve({ focused: true }); + expect(focused?.context).toBe("agent"); + expect(notified).toBeGreaterThan(0); + + // Leaving the workspace for a plugin screen or New workspace publishes no agent. + publishFocusedAgent(null); + expect(source.resolve({ focused: true })).toEqual([]); + + // Another host's focused agent is not this plugin's. + publishFocusedAgent({ serverId: "host-2", agentId: "agent-1" }); + expect(source.resolve({ focused: true })).toEqual([]); + stop(); + }); + + it("is not told when another component creates a query while it renders", () => { + const source = pluginComposersSource(plugin()); + let notified = 0; + const stop = source.subscribe({ agentId: "agent-1" }, () => { + notified += 1; + }); + // `useQuery` does this during a render, and a subscriber that updates then makes React warn + // that one component updated another while rendering. + queryClient.getQueryCache().build(queryClient, { queryKey: ["created-during-a-render"] }); + expect(notified).toBe(0); + stop(); + }); + + it("delivers a draft change once to each of several components watching the same agent", () => { + const source = pluginComposersSource(plugin()); + const delivered = [0, 0, 0]; + const stops: Array<() => void> = []; + for (const index of delivered.keys()) { + stops.push(source.subscribe({ agentId: "agent-1" }, () => void (delivered[index] += 1))); + } + const before = [...delivered]; + source.resolve({ agentId: "agent-1" })[0]?.insertText("typed"); + expect(delivered.map((count, index) => count - before[index])).toEqual([1, 1, 1]); + for (const stop of stops) stop(); + }); + + it("returns the same array until a composer changes", () => { + const source = pluginComposersSource(plugin()); + const first = source.resolve({ agentId: "agent-1" }); + expect(source.resolve({ agentId: "agent-1" })).toBe(first); + first[0]?.insertText("hello"); + const next = source.resolve({ agentId: "agent-1" }); + expect(next).not.toBe(first); + expect(next[0]?.prompt).toBe("hello"); + }); +}); + +describe("a plugin component watching a composer by reference", () => { + const catalog = (defaultThinkingOptionId: string): ProviderSnapshotEntry[] => [ + { + provider: "claude", + status: "ready", + enabled: true, + models: [ + { + provider: "claude", + id: "opus", + label: "Opus", + isDefault: true, + defaultThinkingOptionId, + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + ], + }, + ]; + + afterEach(() => { + queryClient.clear(); + }); + + async function until(read: () => unknown, expected: unknown) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (Object.is(read(), expected)) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(read()).toBe(expected); + } + + function hostWithDefaultThinking(initial: string) { + const host = { defaultThinking: initial }; + const client = { + async getProvidersSnapshot() { + return { + entries: catalog(host.defaultThinking), + generatedAt: "2026-11-04T10:00:00.000Z", + requestId: "snapshot", + }; + }, + } as unknown as DaemonClient; + const store = useSessionStore.getState(); + store.clearSession(serverId); + store.initializeSession(serverId, client); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: null, + features: { providersSnapshot: true }, + } as never); + store.setAgents(serverId, new Map([["agent-1", agent("agent-1")]])); + return host; + } + + it("refreshes a catalog that was invalidated while nothing watched, when watching resumes", async () => { + const host = hostWithDefaultThinking("high"); + const source = pluginComposersSource(plugin()); + const stopFirst = source.subscribe({ agentId: "agent-1" }, () => {}); + await until(() => source.resolve({ agentId: "agent-1" })[0]?.agent.thinking, "high"); + stopFirst(); + + // Reconnect repair only refetches what is watched, so this catalog is invalidated and kept. + host.defaultThinking = "low"; + await queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) }); + + const stopAgain = source.subscribe({ agentId: "agent-1" }, () => {}); + await until(() => source.resolve({ agentId: "agent-1" })[0]?.agent.thinking, "low"); + stopAgain(); + }); + + it("refreshes an invalidated catalog for a command that reads once", async () => { + const host = hostWithDefaultThinking("high"); + const source = pluginComposersSource(plugin()); + const stopFirst = source.subscribe({ agentId: "agent-1" }, () => {}); + await until(() => source.resolve({ agentId: "agent-1" })[0]?.agent.thinking, "high"); + stopFirst(); + host.defaultThinking = "low"; + await queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) }); + + // A command cannot wait, so this read still shows the old catalog and starts the refresh. + source.resolve({ agentId: "agent-1" }); + await until(() => source.resolve({ agentId: "agent-1" })[0]?.agent.thinking, "low"); + }); + + it("sees the catalog the host changed while away once the host reconnects, with no picker open", async () => { + let hostDefault = "high"; + const client = { + async getProvidersSnapshot() { + return { + entries: catalog(hostDefault), + generatedAt: "2026-11-04T10:00:00.000Z", + requestId: "snapshot", + }; + }, + } as unknown as DaemonClient; + const store = useSessionStore.getState(); + store.clearSession(serverId); + store.initializeSession(serverId, client); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: null, + features: { providersSnapshot: true }, + } as never); + store.setAgents(serverId, new Map([["agent-1", agent("agent-1")]])); + + const source = pluginComposersSource(plugin()); + const stop = source.subscribe({ agentId: "agent-1" }, () => {}); + await until(() => source.resolve({ agentId: "agent-1" })[0]?.agent.thinking, "high"); + + // Reconnecting invalidates the provider catalogs, which refetches the ones something watches. + hostDefault = "low"; + await queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) }); + + await until(() => source.resolve({ agentId: "agent-1" })[0]?.agent.thinking, "low"); + stop(); + }); +}); + +describe("a Command Center item reaching an agent's composer by id", () => { + // The real client-state source reads these stores. A lookup that came back empty would make the + // Command Center return before calling the item, and nothing would be reported. + const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Repo", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "checkout", + name: "Repo", + title: null, + pinnedAt: null, + status: "running", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; + + function markForReview(rejection?: Error) { + const patches: unknown[] = []; + const client = { + async setAgentMetadata(_agentId: string, patch: unknown) { + patches.push(patch); + if (rejection) throw rejection; + }, + } as unknown as DaemonClient; + useSessionStore.getState().clearSession(serverId); + useSessionStore.getState().initializeSession(serverId, client); + useSessionStore.getState().setAgents(serverId, new Map([["agent-1", agent("agent-1")]])); + useSessionStore.getState().setWorkspaces(serverId, new Map([[workspace.id, workspace]])); + const installed = plugin(); + installed.commandCenterItems = [ + { + id: "mark", + title: "Mark for review", + icon: "Flag", + context: "agent", + async onSelect({ agent: handed, composers }) { + for (const composer of composers({ agentId: handed.id })) { + await composer.setMetadata({ needsReview: true }); + } + }, + }, + ]; + const reported: unknown[] = []; + const [item] = buildPluginCommandCenterContributions({ + plugins: [installed], + state: createPluginClientStateSource(serverId), + workspaceId: "workspace-1", + agentId: "agent-1", + navigation: { + openSettings() {}, + openSurface() {}, + openWorkspacePanel() {}, + openAgentPanel() {}, + }, + reportError: (error) => reported.push(error), + }); + return { run: item?.run, patches, reported }; + } + + it("changes the agent's metadata through the daemon", async () => { + const { run, patches, reported } = markForReview(); + await run?.(); + expect(patches).toEqual([{ needsReview: true }]); + expect(reported).toEqual([]); + }); + + it("reports what the daemon rejected", async () => { + const { run, reported } = markForReview(new Error("metadata refused")); + await run?.(); + expect(reported).toEqual([new Error("metadata refused")]); + }); +}); diff --git a/packages/app/src/plugins/composer-items/source.ts b/packages/app/src/plugins/composer-items/source.ts new file mode 100644 index 0000000000..12c3d8853d --- /dev/null +++ b/packages/app/src/plugins/composer-items/source.ts @@ -0,0 +1,98 @@ +import type { PluginComposer, PluginComposerSelector } from "@getpaseo/plugin/client"; +import type { PluginComposersSource } from "@getpaseo/plugin/client/host"; +import { agentComposer } from "@/composer/agent-composer"; +import type { ComposerState } from "@/composer/model"; +import { useSessionStore } from "@/stores/session-store"; +import { getFocusedAgent, subscribeFocusedAgent } from "../focused-agent"; +import type { InstalledPlugin } from "../types"; +import { toPluginComposer } from "./model"; + +const NO_COMPOSERS: readonly PluginComposer[] = Object.freeze([]); +const sources = new WeakMap(); + +/** + * The composers a plugin reaches by reference: one per running agent, open in a tab or not. The + * same source serves `useComposers` in every surface and `composers()` in every command. + */ +export function pluginComposersSource(plugin: InstalledPlugin): PluginComposersSource { + let source = sources.get(plugin); + if (!source) { + source = createSource(plugin); + sources.set(plugin, source); + } + return source; +} + +function createSource(plugin: InstalledPlugin): PluginComposersSource { + const { serverId } = plugin; + // Handles and result arrays are reused while nothing they read has changed, so a subscriber + // that re-reads on every notification renders only when its composers did change. + const handles = new WeakMap(); + const results = new WeakMap(); + + function runningAgentId(agentId: string): string | null { + const session = useSessionStore.getState().sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; + return agent && !agent.archivedAt ? agent.id : null; + } + + function focusedAgentId(): string | null { + const focused = getFocusedAgent(); + return focused?.serverId === serverId ? focused.agentId : null; + } + + /** The running agent a selector names now, or null. */ + function namedAgentId(selector: PluginComposerSelector): string | null { + const requested = "agentId" in selector ? selector.agentId : focusedAgentId(); + return requested === null ? null : runningAgentId(requested); + } + + return { + subscribe(selector, listener) { + // Watching a composer is what keeps its model catalog current, so the subscription holds + // the model the selector names now and moves with it as focus and agents change. + let held: { agentId: string; stop: () => void } | null = null; + function hold() { + const agentId = namedAgentId(selector); + if (held?.agentId === agentId) return; + held?.stop(); + held = agentId + ? { agentId, stop: agentComposer(serverId, agentId).subscribe(listener) } + : null; + } + hold(); + const stops = [ + useSessionStore.subscribe(() => { + hold(); + listener(); + }), + subscribeFocusedAgent(() => { + hold(); + listener(); + }), + ]; + return () => { + for (const stop of stops) stop(); + held?.stop(); + held = null; + }; + }, + resolve(selector: PluginComposerSelector) { + const agentId = namedAgentId(selector); + if (agentId === null) return NO_COMPOSERS; + const model = agentComposer(serverId, agentId); + const state = model.getState(); + let composer = handles.get(state); + if (!composer) { + composer = toPluginComposer({ plugin, model, state }); + handles.set(state, composer); + } + let result = results.get(composer); + if (!result) { + result = Object.freeze([composer]); + results.set(composer, result); + } + return result; + }, + }; +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 0621d0ce22..69ede1feed 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -554,6 +554,38 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("Duplicate Command Center item: review"); }); + it("collects composer items and releases an id on removal", () => { + const plugin = evaluatePluginClientBundle( + "review", + bundle(` + function Item() { return null; } + const remove = plugin.addComposerItem({ id: "flag", Component: Item }); + remove(); + plugin.addComposerItem({ id: "flag", Component: Item }); + `), + ); + expect(plugin.composerItems.map((item) => item.id)).toEqual(["flag"]); + }); + + it("rejects duplicate and non-component composer items", () => { + expect(() => + evaluatePluginClientBundle( + "review", + bundle(` + function Item() { return null; } + plugin.addComposerItem({ id: "flag", Component: Item }); + plugin.addComposerItem({ id: "flag", Component: Item }); + `), + ), + ).toThrow("Duplicate composer item: flag"); + expect(() => + evaluatePluginClientBundle( + "review", + bundle(`plugin.addComposerItem({ id: "flag", Component: "nope" })`), + ), + ).toThrow("Composer item flag is not a component"); + }); + it("runs the client entry with the full runtime context", () => { const plugin = evaluatePluginClientBundle( "review", diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index b5b0b71d13..f3e89122dd 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -110,6 +110,7 @@ export type PluginClientRuntime = Pick< | "openSettings" | "openScreen" | "openSurface" + | "composers" | "openPanel" | "subscribe" | "addComposerPill" @@ -133,6 +134,8 @@ export function runPluginClientBundle( sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -149,6 +152,8 @@ export function runPluginClientBundle( footer: new Set(), }; const filePanelIds = new Set(); + const composerItemIds = new Set(); + const newWorkspaceItemIds = new Set(); const workspacePanelIds = new Set(); const commandCenterItemIds = new Set(); const clientSlashCommandNames = new Set(); @@ -272,6 +277,34 @@ export function runPluginClientBundle( () => surfaceIds.delete(normalizedId), ); }, + addComposerItem(contribution) { + const normalizedId = requireId(contribution.id, "composer item id"); + if (composerItemIds.has(normalizedId)) + throw new Error(`Duplicate composer item: ${normalizedId}`); + if (!isComponentType(contribution.Component)) { + throw new Error(`Composer item ${normalizedId} is not a component`); + } + composerItemIds.add(normalizedId); + return register( + collector.composerItems, + { id: normalizedId, Component: contribution.Component }, + () => composerItemIds.delete(normalizedId), + ); + }, + addNewWorkspaceItem(contribution) { + const normalizedId = requireId(contribution.id, "New workspace item id"); + if (newWorkspaceItemIds.has(normalizedId)) + throw new Error(`Duplicate New workspace item: ${normalizedId}`); + if (!isComponentType(contribution.Component)) { + throw new Error(`New workspace item ${normalizedId} is not a component`); + } + newWorkspaceItemIds.add(normalizedId); + return register( + collector.newWorkspaceItems, + { id: normalizedId, Component: contribution.Component }, + () => newWorkspaceItemIds.delete(normalizedId), + ); + }, addSidebarView(contribution) { const normalizedId = requireId(contribution.id, "sidebar view id"); if (sidebarViewIds.has(normalizedId)) @@ -602,6 +635,8 @@ export function runPluginClientBundle( sidebarItems: collector.sidebarItems, legacySidebarItems: collector.legacySidebarItems, filePanels: collector.filePanels, + composerItems: collector.composerItems, + newWorkspaceItems: collector.newWorkspaceItems, workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"], commandCenterItems: collector.commandCenterItems, clientSlashCommands: collector.clientSlashCommands, diff --git a/packages/app/src/plugins/focused-agent.ts b/packages/app/src/plugins/focused-agent.ts new file mode 100644 index 0000000000..d5a7121067 --- /dev/null +++ b/packages/app/src/plugins/focused-agent.ts @@ -0,0 +1,30 @@ +/** + * The agent the user has focused: the one the Command Center hands to agent items. The Command + * Center registration reads the router and the workspace layout, publishes the answer here, and + * publishes `null` outside a workspace. A plugin's `{ focused: true }` composer reads it from here, + * so both name the same agent and neither imports the navigation or layout stores. + */ +export interface FocusedAgent { + serverId: string; + agentId: string; +} + +let current: FocusedAgent | null = null; +const listeners = new Set<() => void>(); + +export function publishFocusedAgent(next: FocusedAgent | null): void { + if (current?.serverId === next?.serverId && current?.agentId === next?.agentId) return; + current = next; + for (const listener of listeners) listener(); +} + +export function getFocusedAgent(): FocusedAgent | null { + return current; +} + +export function subscribeFocusedAgent(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/packages/app/src/plugins/freeze.ts b/packages/app/src/plugins/freeze.ts new file mode 100644 index 0000000000..7c2f4e9e74 --- /dev/null +++ b/packages/app/src/plugins/freeze.ts @@ -0,0 +1,25 @@ +// Plugins and the host never share an object: each direction crosses through a deep copy. + +function deepCopy(value: T, finish: (copied: V) => V): T { + if (Array.isArray(value)) { + return finish(value.map((entry) => deepCopy(entry, finish))) as T; + } + if (value !== null && typeof value === "object") { + return finish( + Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, deepCopy(entry, finish)]), + ), + ) as T; + } + return value; +} + +/** A deep copy no plugin can change. Plugins read data through it, never the host's own objects. */ +export function cloneAndFreeze(value: T): T { + return deepCopy(value, Object.freeze); +} + +/** A deep copy the host owns. Plugin commands hand data in through it, never the plugin's own objects. */ +export function ownCopy(value: T): T { + return deepCopy(value, (copied) => copied); +} diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts index aa2a5765c9..7956ac40dc 100644 --- a/packages/app/src/plugins/icons.ts +++ b/packages/app/src/plugins/icons.ts @@ -25,6 +25,11 @@ function findLucideIcon(name: string): LucideIcon | null { return isComponent(candidate) ? (candidate as LucideIcon) : null; } +/** Whether `name` is a Lucide icon, so a caller can fall back before drawing nothing. */ +export function isLucideIconName(name: string): boolean { + return findLucideIcon(name) !== null; +} + function isComponent(value: unknown): boolean { return ( typeof value === "function" || diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx index 737c614c8c..b4f7735e86 100644 --- a/packages/app/src/plugins/installation-provider.test.tsx +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -47,6 +47,8 @@ function installation(invoked: string[]): InstalledPlugin { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/installation-provider.tsx b/packages/app/src/plugins/installation-provider.tsx index a51875fa88..19fb3bc8ab 100644 --- a/packages/app/src/plugins/installation-provider.tsx +++ b/packages/app/src/plugins/installation-provider.tsx @@ -2,10 +2,12 @@ import { getPluginClientApi } from "./client-state/api"; import { QueryClientProvider } from "@tanstack/react-query"; import { PaseoApiProvider, + PluginComposersProvider, PluginRpcProvider, PluginTranslationProvider, } from "@getpaseo/plugin/client/host"; import React, { type ReactNode } from "react"; +import { pluginComposersSource } from "./composer-items/source"; import type { InstalledPlugin } from "./types"; /** Every plugin surface renders under its installation: one query cache, one Paseo client, RPCs. */ @@ -20,7 +22,11 @@ export function PluginInstallationProvider({ - {children} + + + {children} + + diff --git a/packages/app/src/plugins/item-host.tsx b/packages/app/src/plugins/item-host.tsx new file mode 100644 index 0000000000..2df4198e7a --- /dev/null +++ b/packages/app/src/plugins/item-host.tsx @@ -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( + () => ({ + installation: plugin, + toast, + state: createPluginClientStateSource(plugin.serverId), + }), + [plugin, toast], + ); + const props = useMemo( + () => ({ + 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 }[]; + props: PluginHostProps; +}) { + return ( + <> + {items.map(({ id, Component }) => ( + + + + ))} + + ); +} diff --git a/packages/app/src/plugins/new-workspace-items/index.tsx b/packages/app/src/plugins/new-workspace-items/index.tsx new file mode 100644 index 0000000000..642cc9360e --- /dev/null +++ b/packages/app/src/plugins/new-workspace-items/index.tsx @@ -0,0 +1,113 @@ +import { Fragment, 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. Items are mounted for one + * project on one host: a change of either remounts them, so nothing an item fetched or chose for + * the previous target stays on screen, and an item that sets up on mount sets up for the new one. + */ +export function PluginNewWorkspaceItems({ + serverId, + screen, + setup, +}: { + serverId: string; + screen: NewWorkspaceItemsScreen; + setup: NewWorkspaceSetup; +}) { + const plugins = useServerPlugins(serverId); + return ( + + + + {plugins.map((plugin) => ( + + ))} + + + + ); +} + +function PluginNewWorkspaceItemsHost({ + plugin, + theme, + serverId, + screen, + setup, +}: { + plugin: InstalledPlugin; + serverId: string; + theme: PluginHostProps["theme"]; + screen: NewWorkspaceItemsScreen; + setup: NewWorkspaceSetup; +}) { + const { environment, props } = usePluginItemHost(plugin, theme); + const { branch, metadata } = useNewWorkspaceSetupState(setup); + const projectId = screen.project?.id ?? null; + const commands = useMemo( + () => setup.commandsFor({ serverId, projectId }), + [setup, serverId, projectId], + ); + const newWorkspace = useMemo( + () => + toPluginNewWorkspace({ + host: props.host, + view: { ...screen, branch, metadata }, + commands, + }), + [branch, metadata, props.host, screen, commands], + ); + return ( + + + + + + ); +} + +const ThemedPluginNewWorkspaceItems = withUnistyles(PluginNewWorkspaceItemsHost); diff --git a/packages/app/src/plugins/new-workspace-items/model.test.ts b/packages/app/src/plugins/new-workspace-items/model.test.ts new file mode 100644 index 0000000000..1a90194126 --- /dev/null +++ b/packages/app/src/plugins/new-workspace-items/model.test.ts @@ -0,0 +1,90 @@ +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[0]["host"]; + +function viewOf(baseItem: PickerItem | null) { + 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.commandsFor({ serverId: "host-a", projectId: "p" }), + }); + + 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), + ); + }); +}); + +describe("the metadata a plugin sets", () => { + it("stays what the plugin set when the plugin reuses the object it handed in", async () => { + const setup = createNewWorkspaceSetup( + { + serverId: "host-a", + locked: false, + project: { id: "p", name: "repo" }, + canCreateWorktree: true, + supportsMetadata: true, + }, + { persistIsolation: () => {}, syncBaseAttachment: () => {} }, + ); + const plugin = toPluginNewWorkspace({ + host: HOST, + view: { ...viewOf(null), metadata: setup.getSnapshot().metadata }, + commands: setup.commandsFor({ serverId: "host-a", projectId: "p" }), + }); + + const issue = { id: "ISS-1" }; + await plugin.setMetadata({ issue }); + issue.id = "ISS-2"; + + expect(setup.getSnapshot().metadata).toEqual({ issue: { id: "ISS-1" } }); + }); +}); diff --git a/packages/app/src/plugins/new-workspace-items/model.ts b/packages/app/src/plugins/new-workspace-items/model.ts new file mode 100644 index 0000000000..099538a891 --- /dev/null +++ b/packages/app/src/plugins/new-workspace-items/model.ts @@ -0,0 +1,42 @@ +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, ownCopy } 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>; + 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, + setMetadata: (patch) => commands.setMetadata(ownCopy(patch)), + }; +} diff --git a/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx b/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx new file mode 100644 index 0000000000..d4cf2df5bc --- /dev/null +++ b/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx @@ -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((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; + value: ComboboxItem | null; + onSelect?: (event: { item: Issue }) => void; +}) { + const [queryClient] = React.useState(() => new QueryClient()); + return ( + + + + + + + + ); +} + +function renderCombobox(input: { + search: (event: { query: string }) => Promise; + value?: ComboboxItem | null; + onSelect?: (event: { item: Issue }) => void; +}) { + render(); + 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"); + }); +}); diff --git a/packages/app/src/plugins/react-native/composer-controls.tsx b/packages/app/src/plugins/react-native/composer-controls.tsx new file mode 100644 index 0000000000..eeb5d581bd --- /dev/null +++ b/packages/app/src/plugins/react-native/composer-controls.tsx @@ -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 ( + + ); +} + +export function Toggle({ title, icon, disabled, value, onValueChange }: ToggleProps) { + const Icon = usePluginControlIcon(icon); + const item = useMemo( + () => ({ + id: "plugin-toggle", + type: "toggle", + title, + icon: Icon, + highlight: value ? "blue" : undefined, + value, + onChange: (next) => onValueChange({ value: next }), + }), + [Icon, onValueChange, title, value], + ); + return ; +} + +export function Select({ + title, + icon, + disabled, + value, + options, + onValueChange, +}: SelectProps) { + const Icon = usePluginControlIcon(icon); + const item = useMemo( + () => ({ + 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 ; +} + +export function Action({ title, icon, disabled, onPress }: ActionProps) { + const Icon = usePluginControlIcon(icon); + const item = useMemo( + () => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress: () => onPress({}) }), + [Icon, onPress, title], + ); + return ; +} + +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({ + title, + icon, + disabled, + value, + search, + onSelect, +}: ComboboxProps) { + 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(() => { + 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 ; +} + +function comboboxEmptyText(input: { + t: (key: string, options?: Record) => 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"); +} diff --git a/packages/app/src/plugins/react-native/runtime-context-bridge.browser.test.tsx b/packages/app/src/plugins/react-native/runtime-context-bridge.browser.test.tsx new file mode 100644 index 0000000000..803d71ff0a --- /dev/null +++ b/packages/app/src/plugins/react-native/runtime-context-bridge.browser.test.tsx @@ -0,0 +1,79 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { beforeEach, expect, it, vi } from "vitest"; +import type { PluginNewWorkspace, PluginPaseoApi } from "@getpaseo/plugin/client"; +import { + PaseoApiProvider, + PluginNewWorkspaceProvider, + PluginRpcProvider, + PluginTranslationProvider, + PluginTranslationRuntime, + usePluginRuntimeContextBridge, + type PluginRuntimeContextBridge, + type PluginSubscribe, +} from "@getpaseo/plugin/client/host"; +import { useNewWorkspace } from "@getpaseo/plugin/client"; + +// App sources compile against the classic JSX runtime, which expects React on the global, as the +// other browser tests here arrange. +beforeEach(() => vi.stubGlobal("React", React)); + +const newWorkspace = { + host: { id: "host_1", label: "Laptop" }, + project: { id: "project_1", name: "paseo" }, + isolation: "worktree", + baseRef: { type: "branch", name: "refs/remotes/origin/main" }, + branch: null, + metadata: {}, +} as PluginNewWorkspace; + +const translations = new PluginTranslationRuntime({ + getLocale: () => "en", + subscribe: () => () => undefined, +}); +const paseo = {} as PluginPaseoApi; +const rejectRpc = () => Promise.reject(new Error("no rpc in this test")); +const noSubscribe: PluginSubscribe = () => + Object.assign(() => undefined, { ready: Promise.resolve(), release: () => Promise.resolve() }); + +/** An item the way a plugin writes one: it reads the setup it sits in and shows the branch base. */ +function BaseRefLabel() { + const setup = useNewWorkspace(); + return ( + {setup.baseRef?.type === "branch" ? setup.baseRef.name : "none"} + ); +} + +it("a New workspace item rendered through a portal host still finds its setup", () => { + let bridge: PluginRuntimeContextBridge | null = null; + function Capture() { + bridge = usePluginRuntimeContextBridge(); + return null; + } + const surface = createRoot(document.createElement("div")); + act(() => + surface.render( + + + + + + + + + , + ), + ); + if (!bridge) throw new Error("bridge did not mount"); + + // A portal host, such as the modal sheet, mounts the item in a separate React tree. + const portalElement = document.createElement("div"); + const portal = createRoot(portalElement); + act(() => portal.render(<>{bridge!()})); + expect(portalElement.querySelector('[data-testid="base"]')?.textContent).toBe( + "refs/remotes/origin/main", + ); + + act(() => portal.unmount()); + act(() => surface.unmount()); +}); diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts index e431aa32eb..8b7d17216d 100644 --- a/packages/app/src/plugins/react-native/ui.ts +++ b/packages/app/src/plugins/react-native/ui.ts @@ -20,6 +20,7 @@ export { FilePanelLoading as Loading } from "@/file-panels/loading"; export { SettingsGroup } from "@/components/settings"; export { Icon } from "../icons"; +export { Action, Combobox, Select, Toggle } from "./composer-controls"; export { Modal } from "./modal"; export { ScrollView, FlatList } from "./scroll-view"; export { TextInput } from "./text-input"; diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index ad85e7aec2..18a4a148b1 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -58,6 +58,7 @@ function registry( }, openScreen: () => {}, openSurface: () => {}, + composers: () => [], openSettings: () => {}, playAudio: async () => {}, openPanel: () => {}, diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 6add4ee6d6..6e9103b33a 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -36,6 +36,7 @@ function registry(version: string) { }, openScreen() {}, openSurface() {}, + composers: () => [], async playAudio() {}, openPanel() {}, addComposerPill: () => ({ update() {}, remove() {} }), diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index 6c1a3730f6..042050ed3a 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -126,6 +126,8 @@ export class PluginRegistry { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index a64af934d8..635f3d8eac 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -38,6 +38,8 @@ function installed( }, legacySidebarItems, filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index fff830f7e7..8af246cf81 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -57,6 +57,8 @@ function installation( icon: "Blocks", })), filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index 8a208b37c0..aeb7872a2e 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -65,6 +65,8 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index ef8d68d50c..957bd18686 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -28,6 +28,8 @@ function plugin(input: { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index f9e160f299..4670191746 100644 --- a/packages/app/src/plugins/timeline/projection.ts +++ b/packages/app/src/plugins/timeline/projection.ts @@ -1,5 +1,6 @@ import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream"; +import { cloneAndFreeze } from "../freeze"; import type { TimelineItemTransform } from "./model"; type AssistantPhase = "streaming" | "complete"; @@ -9,18 +10,6 @@ const projectionCache = new WeakMap< WeakMap>> >(); -function cloneAndFreeze(value: T): T { - if (Array.isArray(value)) { - return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T; - } - if (value !== null && typeof value === "object") { - return Object.freeze( - Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])), - ) as T; - } - return value; -} - function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem { const { callId, name, status, error, detail, metadata, agentMessage } = data; const base = { diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 81d29e0279..497df5c59c 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -11,6 +11,8 @@ import type { PluginFilePanelContribution as SDKPluginFilePanelContribution, PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution, PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution, + PluginComposerItemContribution, + PluginNewWorkspaceItemContribution, PluginComposerPillContribution as SDKPluginComposerPillContribution, PluginSidebarContribution as SDKPluginSidebarContribution, PluginSidebarViewContribution, @@ -55,6 +57,8 @@ export interface EvaluatedPlugin { /** `addSidebarItem` registrations, so `/plugin//sidebar/` routes keep resolving. */ legacySidebarItems: PluginSidebarContribution[]; filePanels: PluginFilePanelContribution[]; + composerItems: PluginComposerItemContribution[]; + newWorkspaceItems: PluginNewWorkspaceItemContribution[]; workspacePanels: EvaluatedPluginWorkspacePanelContribution[]; commandCenterItems: PluginCommandCenterItemContribution[]; clientSlashCommands: PluginClientSlashCommandContribution[]; @@ -86,6 +90,8 @@ export type { PluginAttachmentSourceContribution, PluginCommandCenterItemContribution, PluginClientSlashCommandContribution, + PluginComposerItemContribution, + PluginNewWorkspaceItemContribution, PluginComposerPillContribution, PluginSidebarContribution, PluginSidebarItemContribution, diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 546a8916ac..6ceb95f1a1 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -25,6 +25,8 @@ function installed(): InstalledPlugin { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], + newWorkspaceItems: [], workspacePanels: [ { id: "details", diff --git a/packages/app/src/provider-selection/agent-form-store.ts b/packages/app/src/provider-selection/agent-form-store.ts new file mode 100644 index 0000000000..2db3837d22 --- /dev/null +++ b/packages/app/src/provider-selection/agent-form-store.ts @@ -0,0 +1,87 @@ +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, + 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; + /** Each provider's selectable models; empty when the host has listed none. */ + models: ReadonlyMap; + providerPreferences: ( + provider: AgentProvider, + ) => NonNullable[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; + /** The id is kept as given; a catalog that does not list it is the provider's to refuse. */ + setModel: (modelId: string) => void; +} + +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; + dispatch({ + type: "SET_MODEL_FROM_USER", + modelId, + availableModels: models, + providerPrefs: provider ? host.providerPreferences(provider) : undefined, + }); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index 54d75f1da2..592824341a 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -312,7 +312,7 @@ export function buildDraftCommandTarget(input: { cwd: string; effectiveModelId: string; effectiveThinkingOptionId: string; - featureValues?: Record; + providerOptions?: Record; }): DraftCommandTarget { const cwd = input.cwd.trim(); if (!cwd) { @@ -332,7 +332,7 @@ export function buildDraftCommandTarget(input: { ...(input.effectiveThinkingOptionId ? { thinkingOptionId: input.effectiveThinkingOptionId } : {}), - ...(input.featureValues ? { featureValues: input.featureValues } : {}), + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), }; return { status: "ready", config }; } diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 3912d87e13..877898b979 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -284,7 +284,7 @@ describe("mergeSelectedComposerPreferences", () => { codex: { mode: "full-access", thinkingByModel: { "gpt-5.4-mini": "medium" }, - featureValues: { fast_mode: true }, + options: { fast_mode: true }, }, claude: { model: "claude-sonnet-4-6" }, }, @@ -299,7 +299,7 @@ describe("mergeSelectedComposerPreferences", () => { model: "gpt-5.4", mode: "full-access", thinkingByModel: { "gpt-5.4-mini": "medium" }, - featureValues: { fast_mode: true }, + options: { fast_mode: true }, }, claude: { model: "claude-sonnet-4-6" }, }, diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts index a6c1e81845..fd1e97dd11 100644 --- a/packages/app/src/screens/new-workspace-picker-item.test.ts +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -8,6 +8,7 @@ import { buildPickerOptionData, defaultBasePickerItem, pickerItemToCheckoutRequest, + startsNewBranch, type PickerItem, } from "./new-workspace-picker-item"; @@ -386,3 +387,20 @@ describe("starting-ref search", () => { }, ); }); + +describe("startsNewBranch", () => { + it("is true for a branch-off and for no request, which is the default base on a detached HEAD", () => { + expect(startsNewBranch(undefined)).toBe(true); + expect(startsNewBranch({ action: "branch-off", refName: "main" })).toBe(true); + }); + + it("is false for a checkout of a branch or a change request", () => { + expect(startsNewBranch({ action: "checkout", refName: "feature" })).toBe(false); + expect( + startsNewBranch({ + checkoutSource: { kind: "change_request", forge: "github", number: 7 }, + }), + ).toBe(false); + expect(startsNewBranch({ githubPrNumber: 7 })).toBe(false); + }); +}); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index d02c7a0bf2..897f99a561 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -132,7 +132,7 @@ export interface BaseRefCheckoutStatus { // Display only. The exact ref is what every request carries; this is just how a ref reads in // a row label, so "refs/remotes/origin/other-name" shows as "other-name". -function branchNameFromRef(refName: string): string { +export function branchNameFromRef(refName: string): string { if (refName.startsWith("refs/heads/")) return refName.slice("refs/heads/".length); if (refName.startsWith(REMOTE_TRACKING_PREFIX)) { const remainder = refName.slice(REMOTE_TRACKING_PREFIX.length); @@ -210,6 +210,17 @@ export function pickerItemToCheckoutRequest( } } +/** + * Whether creating a worktree from this request cuts a new branch, which is what the daemon does + * for anything but a checkout. No request at all (a detached HEAD gives the screen no default + * base) cuts one from the repository's default branch. + */ +export function startsNewBranch(request: PickerCheckoutRequest | undefined): boolean { + if (!request) return true; + if (request.action === "checkout") return false; + return !request.checkoutSource && request.githubPrNumber === undefined; +} + export function prPickerOptionId(number: number): string { return `${PR_OPTION_PREFIX}${number}`; } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 18173545a7..900fdad7d7 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -6,7 +6,7 @@ import type { import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; import { encodeImages } from "@/utils/encode-images"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; @@ -78,7 +78,7 @@ import type { MessagePayload } from "@/composer/types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { AgentProvider, JsonValue } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; import { @@ -90,6 +90,7 @@ import { defaultBasePickerItem, pickerItemLabel, pickerItemToCheckoutRequest, + startsNewBranch, type BranchPickerDetail, type PickerCheckoutRequest, type PickerItem, @@ -97,10 +98,17 @@ import { } from "./new-workspace-picker-item"; import { clearPickerPrAttachmentForTargetChange, - initialPickerSelectionState, - reducePickerSelection, syncPickerPrAttachment, } from "./new-workspace-picker-state"; +import { + PluginNewWorkspaceItems, + type NewWorkspaceItemsScreen, +} from "@/plugins/new-workspace-items"; +import { + createNewWorkspaceSetup, + useNewWorkspaceSetupState, + type NewWorkspaceSetup, +} from "./new-workspace/setup-model"; import { resolveNewWorkspaceAutomaticServerId, resolveNewWorkspaceInitialServerId, @@ -117,6 +125,7 @@ import { } from "./workspace/terminals/state"; import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff"; import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; +import { retriedAgentRequest } from "./new-workspace/retry-agent-request"; function useIsNewWorkspaceDraftHandoffActive(input: { draftId: string | undefined; @@ -192,30 +201,22 @@ interface WorkspaceIsolationState { // Preserve the user's worktree choice while route metadata is provisional. Once // the authoritative placement arrives, unsupported projects fall back to local. function useWorkspaceIsolation(input: { + setup: NewWorkspaceSetup; supportsMultiplicity: boolean; - worktreeSupport: "supported" | "unsupported" | "unknown"; + canCreateWorktree: boolean; }): WorkspaceIsolationState { - const { supportsMultiplicity, worktreeSupport } = input; + const { setup, supportsMultiplicity, canCreateWorktree } = input; // The last isolation choice is remembered alongside the other New Workspace // form preferences (provider, model, mode). A manual in-screen pick overrides // the remembered default until the screen remounts. - const { preferences, updatePreferences } = useFormPreferences(); - const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null); + const { preferences } = useFormPreferences(); + const { manualIsolation } = useNewWorkspaceSetupState(setup); const isolation = manualIsolation ?? preferences.isolation ?? "local"; - const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported"; const isWorktree = isolation === "worktree" && canCreateWorktree; - const setIsolation = useCallback( - (value: "local" | "worktree") => { - setManualIsolation(value); - void updatePreferences({ isolation: value }); - }, - [updatePreferences], - ); - return { isolation, - setIsolation, + setIsolation: setup.chooseIsolation, effectiveIsolation: isWorktree ? "worktree" : "local", canCreateWorktree, showRefPicker: !supportsMultiplicity || isWorktree, @@ -270,7 +271,7 @@ interface WorkspaceDraftSubmissionConfig { modeId: string | null; model: string | null; thinkingOptionId: string | null; - featureValues: Record | undefined; + providerOptions: Record | undefined; target: WorkspaceTabTarget; } @@ -287,6 +288,10 @@ async function createMultiplicityWorkspace(input: { project: HostProjectListItem; sourceDirectory: string; checkoutRequest: PickerCheckoutRequest | undefined; + /** Names the new branch of a worktree cut from a branch. */ + branchName: string | null; + /** Stored on the created workspace. */ + metadata: Readonly>; withInitialAgent: boolean; agent?: CreateWorkspaceRequestOptions["agent"]; onEvent?: (snapshot: CreationSnapshot) => void; @@ -317,6 +322,9 @@ async function createMultiplicityWorkspace(input: { projectId, worktreeSlug: input.worktreeSlug, ...input.checkoutRequest, + ...(input.branchName && startsNewBranch(input.checkoutRequest) + ? { branchName: input.branchName } + : {}), } : { kind: "directory", @@ -324,6 +332,7 @@ async function createMultiplicityWorkspace(input: { projectId, }, ...(firstAgentContext ? { firstAgentContext } : {}), + ...(Object.keys(input.metadata).length > 0 ? { metadata: { ...input.metadata } } : {}), }); if (payload.error || !payload.workspace) { throw new Error(payload.error ?? input.createFailedMessage); @@ -373,7 +382,7 @@ function buildWorkspaceDraftSetupFromComposer(input: { modeId: input.composerState.effectiveModeId || null, model: input.composerState.effectiveModelId || null, thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, - featureValues: input.composerState.featureValues ?? {}, + providerOptions: input.composerState.providerOptions ?? {}, }; } @@ -453,8 +462,9 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise 0 ? { metadata: composerState.metadata } : {}), initialPrompt: text, clientMessageId: `${input.draftId}:initial-message`, images: images?.length ? images : undefined, @@ -508,14 +518,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise execute()), retry: (request: CreateAgentRequestOptions) => - execute({ - ...initialAgent, - config: { ...request.config!, cwd }, - initialPrompt: request.initialPrompt ?? "", - clientMessageId: initialAgent.clientMessageId, - images: request.images, - attachments: request.attachments, - }), + execute(retriedAgentRequest(initialAgent, request, cwd)), }; await agentCreation.result; if (outcome === "background") clearConsumedDraft(); @@ -533,7 +536,8 @@ function buildComposerConfig(input: { return { initialServerId: serverId || null, initialValues: buildComposerInitialValues({ initialSetup }), - initialFeatureValues: initialSetup?.featureValues, + initialProviderOptions: initialSetup?.providerOptions, + context: "new-workspace" as const, isVisible: true, lockedWorkingDir: workingDir, }; @@ -566,7 +570,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { modeId: initialSetup.modeId, model: initialSetup.model, thinkingOptionId: initialSetup.thinkingOptionId, - featureValues: initialSetup.featureValues, + providerOptions: initialSetup.providerOptions, target: { kind: "draft", draftId, setup: initialSetup }, }; } @@ -576,7 +580,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { modeId: composerState.effectiveModeId || null, model: composerState.effectiveModelId || null, thinkingOptionId: composerState.effectiveThinkingOptionId || null, - featureValues: composerState.featureValues, + providerOptions: composerState.providerOptions, target: { kind: "draft", draftId }, }; } @@ -634,7 +638,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome { ...(submission.modeId ? { modeId: submission.modeId } : {}), ...(submission.model ? { model: submission.model } : {}), ...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}), - ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), + ...(submission.providerOptions ? { providerOptions: submission.providerOptions } : {}), allowEmptyText: true, agentCreation: input.agentCreation, }); @@ -999,22 +1003,31 @@ export function NewWorkspaceScreen({ }), }); const composerState = chatDraft.composerState; - const [pickerSelection, dispatchPickerSelection] = useReducer( - reducePickerSelection, - initialPickerSelectionState, - ); - const selectedItem = pickerSelection.selectedItem; + const { setup, pluginProject, canCreateWorktree, isPending } = usePluginSetup({ + serverId: selectedServerId, + project: selectedProject, + supportsMultiplicity: supportsWorkspaceMultiplicity, + pendingAction, + isDraftHandoffActive, + updatePreferences: updateFormPreferences, + setAttachments: chatDraft.setAttachments, + }); + const dispatchPickerSelection = setup.dispatchPicker; + const selectedItem = useNewWorkspaceSetupState(setup).pickerSelection.selectedItem; const handleForgeChangeRequestDetected = useCallback(() => { dispatchPickerSelection({ type: "pr-detected" }); - }, []); + }, [dispatchPickerSelection]); - const handleForgeChangeRequestAutoAttach = useCallback((item: ForgeSearchItem) => { - dispatchPickerSelection({ - type: "pr-added", - item: { kind: "github-pr", item }, - }); - }, []); + const handleForgeChangeRequestAutoAttach = useCallback( + (item: ForgeSearchItem) => { + dispatchPickerSelection({ + type: "pr-added", + item: { kind: "github-pr", item }, + }); + }, + [dispatchPickerSelection], + ); const withConnectedClient = useCallback(() => { const connectedClient = getHostRuntimeStore().getClient(selectedServerId); @@ -1033,15 +1046,11 @@ export function NewWorkspaceScreen({ cwd: selectedSourceDirectory ?? "", }); - const worktreeSupport = selectedProject - ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) - : "unsupported"; - const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); - const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } = - useWorkspaceIsolation({ - supportsMultiplicity: supportsWorkspaceMultiplicity, - worktreeSupport, - }); + const { effectiveIsolation, setIsolation, showRefPicker } = useWorkspaceIsolation({ + setup, + supportsMultiplicity: supportsWorkspaceMultiplicity, + canCreateWorktree, + }); const branchSuggestionsQuery = useQuery({ queryKey: [ @@ -1105,16 +1114,15 @@ export function NewWorkspaceScreen({ }, [itemById, selectedOptionId]); const selectPickerItem = useCallback( (item: PickerItem) => { - const nextAttachments = syncPickerPrAttachment({ - attachments: chatDraft.attachments, - item, - }); - - dispatchPickerSelection({ type: "picker-selected", item }); - chatDraft.setAttachments(nextAttachments); + setup.chooseBase(item); setPickerOpen(false); }, - [chatDraft], + [setup], + ); + + const pluginScreen = useMemo( + () => ({ project: pluginProject, isolation: effectiveIsolation, baseItem, locked: isPending }), + [baseItem, effectiveIsolation, isPending, pluginProject], ); const handleSelectOption = useCallback( @@ -1137,7 +1145,7 @@ export function NewWorkspaceScreen({ chatDraft.setAttachments(nextAttachments); dispatchPickerSelection({ type: "target-changed" }); }, - [chatDraft], + [chatDraft, dispatchPickerSelection], ); const handleSelectProjectOption = useCallback( @@ -1255,6 +1263,8 @@ export function NewWorkspaceScreen({ project: selectedProject, sourceDirectory: selectedSourceDirectory, checkoutRequest, + branchName: setup.getSnapshot().branch, + metadata: setup.getSnapshot().metadata, withInitialAgent: input.withInitialAgent, prompt: input.prompt, attachments: input.attachments, @@ -1277,6 +1287,7 @@ export function NewWorkspaceScreen({ selectedProject, selectedServerId, selectedSourceDirectory, + setup, supportsWorkspaceMultiplicity, t, withConnectedClient, @@ -1457,6 +1468,9 @@ export function NewWorkspaceScreen({ const formStack = useNewWorkspaceForm({ isCompact, isPending, + pluginItems: ( + + ), project: { anchorRef: projectPickerAnchorRef, open: openProjectPicker, @@ -1597,6 +1611,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[0] | null; + supportsMultiplicity: boolean; + pendingAction: Parameters[0]["pendingAction"]; + isDraftHandoffActive: boolean; + updatePreferences: ReturnType["updatePreferences"]; + setAttachments: ReturnType["setAttachments"]; +}) { + const { + serverId, + project, + supportsMultiplicity, + pendingAction, + isDraftHandoffActive, + updatePreferences, + setAttachments, + } = input; + const worktreeSupport = project + ? getWorktreeSupportForHostProject({ project, serverId }) + : "unsupported"; + const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported"; + const locked = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); + const supportsMetadata = useHostFeature(serverId, "entityMetadata"); + const projectId = project ? getHostProjectId(project, serverId) : null; + const projectName = project?.projectName ?? null; + const pluginProject = useMemo( + () => (projectId && projectName ? { id: projectId, name: projectName } : null), + [projectId, projectName], + ); + const inputs = { serverId, locked, project: pluginProject, canCreateWorktree, supportsMetadata }; + // Both are stable for the life of the screen: preferences write through the query client, and + // the draft is keyed per mount. A command and a pick run the same effects. + const [setup] = useState(() => + createNewWorkspaceSetup(inputs, { + persistIsolation: (isolation) => void updatePreferences({ isolation }), + syncBaseAttachment: (item) => + setAttachments((attachments) => syncPickerPrAttachment({ attachments, item })), + }), + ); + useLayoutEffect(() => setup.syncInputs(inputs)); + return { setup, pluginProject, canCreateWorktree, isPending: locked }; +} + /** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */ function useNewWorkspaceComposerEditing() { const terminal = useComposerEditingSession("terminal"); diff --git a/packages/app/src/screens/new-workspace/background-handoff.test.ts b/packages/app/src/screens/new-workspace/background-handoff.test.ts index a84369fdf4..f8dbe177dd 100644 --- a/packages/app/src/screens/new-workspace/background-handoff.test.ts +++ b/packages/app/src/screens/new-workspace/background-handoff.test.ts @@ -69,7 +69,7 @@ describe("createWorkspaceAgentInBackground", () => { modeId: null, model: null, thinkingOptionId: null, - featureValues: {}, + providerOptions: {}, }, }); }); @@ -89,7 +89,7 @@ describe("createWorkspaceAgentInBackground", () => { modeId: null, model: null, thinkingOptionId: null, - featureValues: {}, + providerOptions: {}, }, }); diff --git a/packages/app/src/screens/new-workspace/form/index.tsx b/packages/app/src/screens/new-workspace/form/index.tsx index c1cf1e2482..3b2040d389 100644 --- a/packages/app/src/screens/new-workspace/form/index.tsx +++ b/packages/app/src/screens/new-workspace/form/index.tsx @@ -33,6 +33,8 @@ interface PickerControl { export interface NewWorkspaceFormProps { isCompact: boolean; isPending: boolean; + /** Plugin fields, drawn after the base ref. */ + pluginItems: ReactNode; project: PickerControl & { options: ComboboxOption[]; projectByOptionId: Map; @@ -165,7 +167,7 @@ function useRefRenderOption(itemById: Map, isPending: boolea */ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement { const { t } = useTranslation(); - const { isCompact, isPending, project, host, isolation, base, launch } = props; + const { isCompact, isPending, pluginItems, project, host, isolation, base, launch } = props; const variant: FieldTriggerVariant = isCompact ? "field" : "badge"; const projectPicker = useProjectPicker(project, isPending); const isolationPicker = useIsolationPicker(isolation, isPending); @@ -337,6 +339,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {hostField} {isolationField} {baseField} + {pluginItems} {launchField} {/* Isolation and Base come and go with the project; their rows stay reserved so the stack keeps one height and nothing above the composer jumps. */} @@ -353,6 +356,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {hostField} {isolationField ? {isolationField} : null} {baseField ? {baseField} : null} + {pluginItems} {launchField} {pickers} diff --git a/packages/app/src/screens/new-workspace/retry-agent-request.test.ts b/packages/app/src/screens/new-workspace/retry-agent-request.test.ts new file mode 100644 index 0000000000..248a044a3c --- /dev/null +++ b/packages/app/src/screens/new-workspace/retry-agent-request.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { retriedAgentRequest, type WorkspaceAgentRequest } from "./retry-agent-request"; + +const initial: WorkspaceAgentRequest = { + config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, + metadata: { "issues.key": "PAS-1" }, + initialPrompt: "first try", + clientMessageId: "draft_1:initial-message", +}; + +describe("retriedAgentRequest", () => { + it("creates with the metadata the draft resubmitted", () => { + const retried = retriedAgentRequest( + initial, + { + config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, + initialPrompt: "second try", + metadata: { "issues.key": "PAS-2" }, + }, + "/repo", + ); + expect(retried.metadata).toEqual({ "issues.key": "PAS-2" }); + expect(retried.initialPrompt).toBe("second try"); + expect(retried.clientMessageId).toBe("draft_1:initial-message"); + }); + + it("creates with no metadata once the draft cleared it", () => { + const retried = retriedAgentRequest( + initial, + { config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, initialPrompt: "second try" }, + "/repo", + ); + expect("metadata" in retried).toBe(false); + }); + + it("keeps the first attempt's directory over the resubmitted config", () => { + const retried = retriedAgentRequest( + initial, + { + config: { provider: "codex", cwd: "/elsewhere", model: "gpt-6-mini" }, + initialPrompt: "second try", + }, + "/repo", + ); + expect(retried.config).toEqual({ provider: "codex", cwd: "/repo", model: "gpt-6-mini" }); + }); +}); diff --git a/packages/app/src/screens/new-workspace/retry-agent-request.ts b/packages/app/src/screens/new-workspace/retry-agent-request.ts new file mode 100644 index 0000000000..4a7efa02cf --- /dev/null +++ b/packages/app/src/screens/new-workspace/retry-agent-request.ts @@ -0,0 +1,28 @@ +import type { + CreateAgentRequestOptions, + CreateWorkspaceRequestOptions, +} from "@getpaseo/client/internal/daemon-client"; + +export type WorkspaceAgentRequest = NonNullable; + +/** + * The agent a retried creation asks for: everything the draft resubmitted (config, prompt, + * images, attachments and metadata, cleared included) on the first attempt's identity, so a + * retry creates what the composer shows now, not what it showed when creation first failed. + */ +export function retriedAgentRequest( + initial: WorkspaceAgentRequest, + request: CreateAgentRequestOptions, + cwd: string, +): WorkspaceAgentRequest { + const { metadata: _replaced, ...identity } = initial; + return { + ...identity, + config: { ...request.config!, cwd }, + initialPrompt: request.initialPrompt ?? "", + clientMessageId: initial.clientMessageId, + images: request.images, + attachments: request.attachments, + ...(request.metadata ? { metadata: request.metadata } : {}), + }; +} diff --git a/packages/app/src/screens/new-workspace/setup-model.test.ts b/packages/app/src/screens/new-workspace/setup-model.test.ts new file mode 100644 index 0000000000..2e395cd99f --- /dev/null +++ b/packages/app/src/screens/new-workspace/setup-model.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import type { PickerItem } from "../new-workspace-picker-item"; +import { + createNewWorkspaceSetup, + setupTargetOf, + type NewWorkspaceSetup, + type SetupEffects, + type SetupInputs, +} from "./setup-model"; + +const READY: SetupInputs = { + serverId: "host-a", + locked: false, + project: { id: "p1", name: "repo" }, + canCreateWorktree: true, + supportsMetadata: true, +}; + +/** The commands an item mounted for `inputs`' project and host receives. */ +function commandsOf(setup: NewWorkspaceSetup, inputs: SetupInputs = READY) { + return setup.commandsFor(setupTargetOf(inputs)); +} + +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 commandsOf(setup).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 commandsOf(setup).setBranch("pas-101"); + void commandsOf(setup).setMetadata({ issue: "PAS-101", keep: true }); + await commandsOf(setup).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 commandsOf(setup).setBaseRef({ type: "branch", name: " release " }); + await commandsOf(setup).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 commandsOf(setup).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 resolving = { ...READY, project: null }; + const setup = createNewWorkspaceSetup(resolving, recordingEffects().effects); + await commandsOf(setup, resolving).setBranch("pas-101"); + setup.syncInputs(READY); + expect(setup.getSnapshot().branch).toBe("pas-101"); + + await commandsOf(setup).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 commandsOf(setup).setBranch("pas-101"); + await commandsOf(setup).setMetadata({ issue: "PAS-101" }); + + setup.syncInputs({ ...READY, serverId: "host-b" }); + + expect(setup.getSnapshot().branch).toBeNull(); + expect(setup.getSnapshot().metadata).toEqual({}); + }); + + it("rejects a late command from the project or host the screen has left, and leaves the new choices alone", async () => { + const setup = createNewWorkspaceSetup(READY, recordingEffects().effects); + const old = commandsOf(setup); + const otherProject = { ...READY, project: { id: "p2", name: "other" } }; + const otherHost = { ...READY, serverId: "host-b" }; + + setup.syncInputs(otherProject); + await commandsOf(setup, otherProject).setBranch("chosen-for-p2"); + + const results = await Promise.allSettled([ + old.setBranch("late"), + old.setMetadata({ issue: "late" }), + old.setIsolation("local"), + old.setBaseRef({ type: "branch", name: "late" }), + ]); + expect(results.map((result) => result.status)).toEqual(Array(4).fill("rejected")); + await expect(old.setBranch("late")).rejects.toThrow("moved to another project or host"); + expect(setup.getSnapshot().branch).toBe("chosen-for-p2"); + expect(setup.getSnapshot().metadata).toEqual({}); + expect(setup.getSnapshot().manualIsolation).toBeNull(); + + setup.syncInputs(otherHost); + await expect(commandsOf(setup, otherProject).setBranch("late")).rejects.toThrow( + "moved to another project or host", + ); + }); + + it("accepts commands again when the screen returns to the project they were made for", async () => { + const setup = createNewWorkspaceSetup(READY, recordingEffects().effects); + const first = commandsOf(setup); + + setup.syncInputs({ ...READY, project: { id: "p2", name: "other" } }); + setup.syncInputs(READY); + await first.setBranch("back"); + + expect(setup.getSnapshot().branch).toBe("back"); + }); + + 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([ + commandsOf(setup).setBranch("x"), + commandsOf(setup).setMetadata({ a: 1 }), + commandsOf(setup).setIsolation("local"), + commandsOf(setup).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); + const resolving = { ...READY, project: null }; + setup.syncInputs(resolving); + + await expect( + commandsOf(setup, resolving).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(commandsOf(setup).setIsolation("worktree")).rejects.toThrow( + "cannot create worktrees", + ); + await expect(commandsOf(setup).setMetadata({ a: 1 })).rejects.toThrow("Update the host"); + await expect(commandsOf(setup).setBranch(" ")).rejects.toThrow("needs a name"); + await commandsOf(setup).setBranch(null); + expect(setup.getSnapshot().branch).toBeNull(); + }); +}); diff --git a/packages/app/src/screens/new-workspace/setup-model.ts b/packages/app/src/screens/new-workspace/setup-model.ts new file mode 100644 index 0000000000..2a810d24c1 --- /dev/null +++ b/packages/app/src/screens/new-workspace/setup-model.ts @@ -0,0 +1,186 @@ +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>; +} + +/** + * 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; +} + +/** The project on the host a screen creates in; what an item is mounted for. */ +export interface SetupTarget { + serverId: string; + /** Null while the screen is still resolving its project. */ + projectId: string | null; +} + +export function setupTargetOf(input: Pick): SetupTarget { + return { serverId: input.serverId, projectId: input.project?.id ?? null }; +} + +export interface SetupCommands { + setIsolation(isolation: Isolation): Promise; + setBaseRef(ref: { type: "branch"; name: string }): Promise; + setBranch(name: string | null): Promise; + setMetadata(patch: Record): Promise; +} + +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 for the items mounted for `target`. Once the screen is on another project or host + * they reject, so a lookup that finishes after the switch cannot write into the new choices. + */ + commandsFor(target: SetupTarget): 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 MOVED_MESSAGE = + "The New workspace screen moved to another project or host; this item belongs to the previous one."; +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 screenTarget = initial.project ? `${initial.serverId}\n${initial.project.id}` : null; + const listeners = new Set<() => void>(); + const update = (patch: Partial) => { + state = { ...state, ...patch }; + for (const listener of listeners) listener(); + }; + const sameTarget = (target: SetupTarget) => { + const current = setupTargetOf(inputs); + return current.serverId === target.serverId && current.projectId === target.projectId; + }; + 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); + }; + const commandsFor = (target: SetupTarget): SetupCommands => { + const unlocked = (): SetupInputs => { + if (!sameTarget(target)) throw new Error(MOVED_MESSAGE); + if (inputs.locked) throw new Error(LOCKED_MESSAGE); + return inputs; + }; + return { + 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) }); + }, + }; + }; + 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 (screenTarget && screenTarget !== nextTarget) update({ branch: null, metadata: {} }); + screenTarget = nextTarget; + }, + commandsFor, + }; +} + +export function useNewWorkspaceSetupState(setup: NewWorkspaceSetup): SetupState { + return useSyncExternalStore(setup.subscribe, setup.getSnapshot, setup.getSnapshot); +} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-config.ts b/packages/app/src/screens/workspace/workspace-draft-agent-config.ts index cf122ee566..38b97fc159 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-config.ts +++ b/packages/app/src/screens/workspace/workspace-draft-agent-config.ts @@ -6,7 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: { modeId?: string; model?: string; thinkingOptionId?: string; - featureValues?: Record; + providerOptions?: Record; }): AgentSessionConfig { return { provider: input.provider, @@ -14,6 +14,6 @@ export function buildWorkspaceDraftAgentConfig(input: { ...(input.modeId ? { modeId: input.modeId } : {}), ...(input.model ? { model: input.model } : {}), ...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}), - ...(input.featureValues ? { featureValues: input.featureValues } : {}), + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), }; } diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 9836d3dfec..c0085bba82 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -36,6 +36,9 @@ import { } from "./migration"; import { createDraftPersistStorage } from "./persistence"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import { applyMetadataPatch } from "@/composer/draft/metadata"; +import { hasDraftContent } from "@/composer/draft/input-draft-core"; export type { DraftInput, DraftLifecycleState } from "./state"; @@ -43,12 +46,25 @@ interface DraftStoreActions { getDraftInput: (draftKey: string) => DraftInput | undefined; hydrateDraftInput: (input: { draftKey: string }) => Promise; saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void; + /** Saves what `update` makes of the current input; an emptied input abandons the draft. */ + updateDraftInput: (input: { + draftKey: string; + update: (current: DraftInput) => DraftInput; + }) => void; editDraftText: (input: { draftKey: string; text: string }) => void; + /** + * Sets the text on behalf of something other than the input, which then shows it. Use + * `editDraftText` for the input's own edits. + */ + replaceDraftText: (input: { draftKey: string; text: string }) => void; markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void; clearDraftInput: (input: { draftKey: string; lifecycle?: Exclude; }) => void; + /** Plugin metadata the draft will create its agent with; see `composer/draft/metadata`. */ + patchDraftMetadata: (input: { draftKey: string; patch: Record }) => void; + clearDraftMetadata: (input: { draftKey: string }) => void; attachWorkspaceFile: (input: { draftKey: string; attachment: WorkspaceFileComposerAttachment; @@ -60,6 +76,13 @@ interface DraftStoreActions { interface DraftStoreRuntimeState { attachmentFocusRequestByDraftKey: Record; + /** Counts `replaceDraftText`, so a mounted input knows to show text it did not type. */ + textReplacementRequestByDraftKey: Record; + /** + * 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>; } type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions; @@ -256,6 +279,8 @@ export const useDraftStore = create()( drafts: {}, createModalDraft: null, attachmentFocusRequestByDraftKey: {}, + textReplacementRequestByDraftKey: {}, + metadataByDraftKey: {}, getDraftInput: (draftKey) => { const record = get().drafts[draftKey]; @@ -318,6 +343,12 @@ export const useDraftStore = create()( scheduleAttachmentGc(); }, + updateDraftInput: ({ draftKey, update }) => { + const next = update(get().getDraftInput(draftKey) ?? { text: "", attachments: [] }); + if (hasDraftContent(next)) get().saveDraftInput({ draftKey, draft: next }); + else get().clearDraftInput({ draftKey, lifecycle: "abandoned" }); + }, + editDraftText: ({ draftKey, text }) => { set((state) => { const previous = state.drafts[draftKey]; @@ -326,6 +357,16 @@ export const useDraftStore = create()( }); }, + replaceDraftText: ({ draftKey, text }) => { + get().editDraftText({ draftKey, text }); + set((state) => ({ + textReplacementRequestByDraftKey: { + ...state.textReplacementRequestByDraftKey, + [draftKey]: (state.textReplacementRequestByDraftKey[draftKey] ?? 0) + 1, + }, + })); + }, + markDraftLifecycle: ({ draftKey, lifecycle }) => { set((state) => { const existing = state.drafts[draftKey]; @@ -374,6 +415,25 @@ export const useDraftStore = create()( scheduleAttachmentGc(); }, + patchDraftMetadata: ({ draftKey, patch }) => { + set((state) => { + const next = applyMetadataPatch(state.metadataByDraftKey[draftKey] ?? {}, patch); + const { [draftKey]: _previous, ...others } = state.metadataByDraftKey; + return { + metadataByDraftKey: + Object.keys(next).length > 0 ? { ...others, [draftKey]: next } : others, + }; + }); + }, + + clearDraftMetadata: ({ draftKey }) => { + set((state) => { + if (!(draftKey in state.metadataByDraftKey)) return state; + const { [draftKey]: _removed, ...others } = state.metadataByDraftKey; + return { metadataByDraftKey: others }; + }); + }, + attachWorkspaceFile: async ({ draftKey, attachment }) => { await get().hydrateDraftInput({ draftKey }); set((state) => { diff --git a/packages/app/src/stores/draft-store/metadata.test.ts b/packages/app/src/stores/draft-store/metadata.test.ts new file mode 100644 index 0000000000..7f2f7a52d6 --- /dev/null +++ b/packages/app/src/stores/draft-store/metadata.test.ts @@ -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(); + }); +}); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 7c52269e07..ee73e0ee76 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -26,7 +26,9 @@ import type { ComposerAttachment } from "@/attachments/types"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; import type { AgentPermissionRequest, - AgentFeature, + AgentOptionControl, + JsonValue, + ProviderOptions, AgentProvider, AgentMode, AgentCapabilityFlags, @@ -92,7 +94,8 @@ export interface Agent { cwd: string; workspaceId?: string; model: string | null; - features?: AgentFeature[]; + options?: ProviderOptions; + controls?: AgentOptionControl[]; thinkingOptionId?: string | null; requiresAttention?: boolean; attentionReason?: "finished" | "error" | "permission" | null; @@ -102,6 +105,7 @@ export interface Agent { background?: boolean; parentAgentId: string | null; labels: Record; + metadata?: Record; projectPlacement?: ProjectPlacementPayload | null; } diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 639701d740..4a7d3ceaf3 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -23,7 +23,7 @@ export interface PendingWorkspaceDraftSubmission { modeId?: string; model?: string; thinkingOptionId?: string; - featureValues?: Record; + providerOptions?: Record; allowEmptyText?: boolean; } diff --git a/packages/app/src/stores/workspace-layout-persistence.test.ts b/packages/app/src/stores/workspace-layout-persistence.test.ts index 3398199ae4..47c4ab81da 100644 --- a/packages/app/src/stores/workspace-layout-persistence.test.ts +++ b/packages/app/src/stores/workspace-layout-persistence.test.ts @@ -95,6 +95,53 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra ); }); +it("keeps every saved layout when a draft tab's setup carries a nested provider option", async () => { + const values = new Map(); + const storage: StateStorage = { + getItem: (name) => values.get(name) ?? null, + setItem: (name, value) => { + values.set(name, value); + }, + removeItem: (name) => { + values.delete(name); + }, + }; + const store = createWorkspaceLayoutStore(undefined, storage); + await store.persist.rehydrate(); + store.getState().openTab({ + workspaceKey: "server:kept", + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + store.getState().openTab({ + workspaceKey: "server:forked", + target: { + kind: "draft", + draftId: "draft-1", + setup: { + provider: "codex", + cwd: "/repo", + modeId: null, + model: null, + thinkingOptionId: null, + providerOptions: { sandbox_workspace_write: { network_access: true }, max_turns: 3 }, + }, + }, + intent: "reveal", + }); + + const restored = createWorkspaceLayoutStore(undefined, storage); + await restored.persist.rehydrate(); + + expect(Object.keys(restored.getState().layoutByWorkspace).sort()).toEqual([ + "server:forked", + "server:kept", + ]); + expect(JSON.stringify(restored.getState().layoutByWorkspace["server:forked"])).toContain( + "network_access", + ); +}); + it.each([1, 2])( "restores the agent pane after parked focus in storage version %i", async (version) => { diff --git a/packages/app/src/stores/workspace-layout-storage.ts b/packages/app/src/stores/workspace-layout-storage.ts index c105d40a06..1dfa9e82db 100644 --- a/packages/app/src/stores/workspace-layout-storage.ts +++ b/packages/app/src/stores/workspace-layout-storage.ts @@ -1,14 +1,25 @@ import { z } from "zod"; import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions"; -const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ - provider: z.string(), - cwd: z.string(), - modeId: z.string().nullable(), - model: z.string().nullable(), - thinkingOptionId: z.string().nullable(), - featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])), -}); +// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Layouts saved before +// the rename stored the same record as `featureValues`. +function renameLegacyFeatureValues(value: unknown): unknown { + if (typeof value !== "object" || value === null || !("featureValues" in value)) return value; + const { featureValues, ...rest } = value as { featureValues: unknown } & Record; + return "providerOptions" in rest ? rest : { ...rest, providerOptions: featureValues }; +} + +const WorkspaceDraftTabSetupStorageSchema = z.preprocess( + renameLegacyFeatureValues, + z.strictObject({ + provider: z.string(), + cwd: z.string(), + modeId: z.string().nullable(), + model: z.string().nullable(), + thinkingOptionId: z.string().nullable(), + providerOptions: z.record(z.string(), z.json()), + }), +); const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("new_tab") }), z.strictObject({ diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 2c3e6f15a4..6096a55436 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -4551,7 +4551,6 @@ describe("compact workspace sheet", () => { title: agent.id, cwd: "/repo", model: null, - features: undefined, thinkingOptionId: undefined, requiresAttention: false, attentionReason: null, diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index ff2c8b2f4b..13fe6cfa81 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -63,7 +63,6 @@ const AGENT_DEFAULTS: Agent = { cwd: WORKSPACE_DIRECTORY, workspaceId: WORKSPACE_ID, model: null, - features: undefined, thinkingOptionId: undefined, requiresAttention: false, attentionReason: null, diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index a9d8ce4152..05d0fe2698 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -41,7 +41,6 @@ const AGENT_DEFAULTS: Agent = { title: "Agent", cwd: "/tmp/project", model: null, - features: undefined, thinkingOptionId: undefined, requiresAttention: false, attentionReason: null, diff --git a/packages/app/src/subagents/sheet-tab.test.tsx b/packages/app/src/subagents/sheet-tab.test.tsx index e295f7b7c7..c8e0ab7f13 100644 --- a/packages/app/src/subagents/sheet-tab.test.tsx +++ b/packages/app/src/subagents/sheet-tab.test.tsx @@ -60,7 +60,6 @@ const AGENT_DEFAULTS: Agent = { cwd: "/repo/worktree", workspaceId: WORKSPACE_ID, model: null, - features: undefined, thinkingOptionId: undefined, requiresAttention: false, attentionReason: null, diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index e1be8c5a0c..49c4bcb983 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { isControlActive, readSelectOption } from "@/agent-controls/option-controls"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots"; function createSnapshot( @@ -32,10 +33,39 @@ function createSnapshot( persistence: input.persistence ?? null, title: input.title ?? null, labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"], + ...(input.controls ? { controls: input.controls } : {}), + ...(input.options ? { options: input.options } : {}), }; } describe("normalizeAgentSnapshot", () => { + it("keeps a closed agent's stored options and controls, so its Fast control draws", () => { + const speed = { + type: "select" as const, + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Normal" }, + { value: "priority", label: "Fast" }, + ], + }; + const agent = normalizeAgentSnapshot( + createSnapshot({ + status: "closed", + controls: [speed], + options: { service_tier: "priority", sandbox_mode: "read-only" }, + }), + "server-1", + ); + + expect(agent.status).toBe("closed"); + expect(agent.controls).toEqual([speed]); + expect(agent.options).toEqual({ service_tier: "priority", sandbox_mode: "read-only" }); + expect(readSelectOption(speed, agent.options ?? {})).toBe("priority"); + expect(isControlActive(speed, agent.options ?? {})).toBe(true); + }); + it("round-trips identified active turns through the canonical snapshot boundary", () => { const snapshot = createSnapshot({ status: "running", diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 11640d800b..b5f2355d0a 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -64,7 +64,8 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { cwd: agent.cwd, ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), model: agent.model, - ...(agent.features ? { features: agent.features } : {}), + ...(agent.options ? { options: agent.options } : {}), + ...(agent.controls ? { controls: agent.controls } : {}), thinkingOptionId: agent.thinkingOptionId ?? null, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), @@ -82,6 +83,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { ...(agent.lastError ? { lastError: agent.lastError } : {}), title: agent.title, labels: agent.labels, + ...(agent.metadata ? { metadata: agent.metadata } : {}), requiresAttention: agent.requiresAttention ?? false, attentionReason: agent.attentionReason ?? null, attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null, @@ -130,7 +132,8 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: cwd: snapshot.cwd, workspaceId: snapshot.workspaceId, model: snapshot.model ?? null, - features: snapshot.features, + options: snapshot.options, + controls: snapshot.controls, thinkingOptionId: snapshot.thinkingOptionId ?? null, requiresAttention: snapshot.requiresAttention ?? false, attentionReason: snapshot.attentionReason ?? null, @@ -139,5 +142,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: background: snapshot.background === true, parentAgentId, labels: snapshot.labels, + metadata: snapshot.metadata, }; } diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index 6e15d506c2..ba6dcb52f5 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -92,7 +92,7 @@ export function normalizeWorkspaceDraftTabSetup( thinkingOptionId: trimOptionalString( typeof record.thinkingOptionId === "string" ? record.thinkingOptionId : null, ), - featureValues: isPlainRecord(record.featureValues) ? { ...record.featureValues } : {}, + providerOptions: isPlainRecord(record.providerOptions) ? { ...record.providerOptions } : {}, }; } @@ -171,7 +171,7 @@ function workspaceDraftTabSetupsEqual( left.modeId === right.modeId && left.model === right.model && left.thinkingOptionId === right.thinkingOptionId && - recordsShallowEqual(left.featureValues, right.featureValues) + recordsShallowEqual(left.providerOptions, right.providerOptions) ); } diff --git a/packages/app/src/workspace-tabs/model.ts b/packages/app/src/workspace-tabs/model.ts index fa0e155060..f1e17fb3a6 100644 --- a/packages/app/src/workspace-tabs/model.ts +++ b/packages/app/src/workspace-tabs/model.ts @@ -8,7 +8,7 @@ export interface WorkspaceDraftTabSetup { modeId: string | null; model: string | null; thinkingOptionId: string | null; - featureValues: Record; + providerOptions: Record; } export interface WorkspaceWorkingDiffTabTarget { diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index d213089efa..1cff249d47 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -117,6 +117,11 @@ export const Maximize2 = StubIcon; export const Workflow = StubIcon; export const ZoomIn = StubIcon; export const ZoomOut = StubIcon; +export const ListTodo = StubIcon; +export const Shield = StubIcon; +export const ShieldEllipsis = StubIcon; +export const ShieldPlus = StubIcon; +export const Zap = StubIcon; export const CheckSquare = StubIcon; export const Circle = StubIcon; export const FileSymlink = StubIcon; diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index 38a86c7567..04f61bc77e 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -32,8 +32,10 @@ const testTheme = { borderAccent: "#a1a1aa", palette: { amber: { 500: "#f59e0b" }, - blue: { 300: "#93c5fd" }, - green: { 500: "#22c55e" }, + blue: { 300: "#93c5fd", 400: "#60a5fa" }, + green: { 400: "#4ade80", 500: "#22c55e" }, + yellow: { 400: "#facc15" }, + zinc: { 600: "#52525b" }, red: { 300: "#fca5a5" }, white: "#ffffff", }, diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 386f63305c..c13780d6b6 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -253,6 +253,14 @@ export default defineConfig({ find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/, replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"), }, + // The CJS entry requires the real react-native, whose Flow sources do not parse. + { + find: /^@floating-ui\/react-native$/, + replacement: path.resolve( + rootNodeModules, + "@floating-ui/react-native/dist/floating-ui.react-native.esm.js", + ), + }, // Point to react-native-web's ESM build so Vite can transform its imports and apply the // react alias below (the CJS build uses require('react') which bypasses // Vite alias resolution). The stub re-exports it and adds what web lacks. diff --git a/packages/client/src/compat/legacy-provider-options.ts b/packages/client/src/compat/legacy-provider-options.ts new file mode 100644 index 0000000000..083232253c --- /dev/null +++ b/packages/client/src/compat/legacy-provider-options.ts @@ -0,0 +1,252 @@ +import { controlsFromFeatures, optionsFromFeatures } from "@getpaseo/protocol/option-controls"; +import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; +import type { + AgentConfigApply, + AgentProfile, + AgentSnapshotPayload, + MutableDaemonConfig, + ListProviderFeaturesResponseMessage, + ProviderControlsListResponseMessage, + ServerInfoStatusPayload, + SessionInboundMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +/** + * COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01 once daemon floor >= + * v0.15.0. + * + * A daemon without `server_info.features.providerOptionControls` sends `features` (a control with + * its value attached) and takes composer values as `featureValues`. This is the one place that + * turns that into the shape everyone else uses: `controls` over `options`, written through + * `providerOptions`. + */ +export function usesLegacyProviderOptions(info: ServerInfoStatusPayload): boolean { + return info.features?.providerOptionControls !== true; +} + +export class LegacyProviderOptions { + /** Gives agent snapshots `controls` and `options`, built from their `features`. */ + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + switch (message.type) { + case "agent_update": + if (message.payload.kind !== "upsert") return message; + return { + ...message, + payload: { ...message.payload, agent: this.snapshot(message.payload.agent) }, + }; + case "agent_status": + return { + ...message, + payload: { ...message.payload, info: this.snapshot(message.payload.info) }, + }; + case "fetch_agents_response": + return { + ...message, + payload: { + ...message.payload, + entries: message.payload.entries.map((entry) => ({ + ...entry, + agent: this.snapshot(entry.agent), + })), + }, + }; + case "fetch_agent_response": + case "agent.create.response": + case "cancel_agent_response": + return { + ...message, + payload: { + ...message.payload, + agent: message.payload.agent && this.snapshot(message.payload.agent), + }, + } as SessionOutboundMessage; + case "workspace.create.response": + return { + ...message, + payload: { + ...message.payload, + ...(message.payload.agent ? { agent: this.snapshot(message.payload.agent) } : {}), + }, + }; + case "wait_for_finish_response": + return { + ...message, + payload: { + ...message.payload, + final: message.payload.final && this.snapshot(message.payload.final), + }, + }; + case "get_daemon_config_response": + case "set_daemon_config_response": + return { + ...message, + payload: { ...message.payload, config: this.daemonConfig(message.payload.config) }, + }; + case "fetch_agent_timeline_response": + return { + ...message, + payload: { + ...message.payload, + agent: message.payload.agent && this.snapshot(message.payload.agent), + }, + }; + case "status": + return this.status(message); + default: + return message; + } + } + + private status( + message: Extract, + ): SessionOutboundMessage { + const { payload } = message; + if (payload.status === "agent_created" || payload.status === "agent_resumed") { + const { agent } = payload as unknown as { agent: AgentSnapshotPayload }; + return { ...message, payload: { ...payload, agent: this.snapshot(agent) } }; + } + if (payload.status === "daemon_config_changed") { + const config = payload.config as MutableDaemonConfig; + return { ...message, payload: { ...payload, config: this.daemonConfig(config) } }; + } + return message; + } + + /** + * Splits a create's options for a legacy daemon: the ids it lists as features for that very + * config go out as `featureValues`, which is where it reads them, and the rest stay + * `providerOptions`. Which options are features depends on the model, mode and thinking level + * as well as the provider, so the ids come from a listing for this config, never from memory. + */ + routeCreateConfig< + T extends { + providerOptions?: Record; + featureValues?: Record; + }, + >(config: T, featureIds: ReadonlySet): T { + if (!config.providerOptions) return config; + const featureValues: Record = {}; + const providerOptions: Record = {}; + for (const [key, value] of Object.entries(config.providerOptions)) { + (featureIds.has(key) ? featureValues : providerOptions)[key] = value; + } + const { providerOptions: _options, ...rest } = config; + return { + ...(rest as T), + ...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}), + ...(Object.keys(featureValues).length > 0 + ? { featureValues: { ...config.featureValues, ...featureValues } } + : {}), + }; + } + + /** Sends options as `featureValues`, which is where a legacy daemon reads them. */ + prepare(message: SessionInboundMessage): SessionInboundMessage { + switch (message.type) { + case "list_commands_request": { + if (!message.draftConfig?.providerOptions) return message; + const { providerOptions, ...draftConfig } = message.draftConfig; + return { + ...message, + draftConfig: { + ...draftConfig, + featureValues: { ...draftConfig.featureValues, ...providerOptions }, + }, + }; + } + case "set_daemon_config_request": + if (!message.config.agentProfiles) return message; + return { + ...message, + config: { + ...message.config, + agentProfiles: message.config.agentProfiles.map(profileWithFeatureValues), + }, + }; + default: + return message; + } + } + + /** + * An options patch as feature writes. `null` removes an option, which the old API cannot say: + * its setters take a value, and the Codex one rejects anything but a choice id. A toggle goes + * off and a choice goes to its default; an option with no control to say what its default is + * cannot be reset, and nothing is written. + */ + featureWrites( + patch: Record, + controls: readonly AgentOptionControl[], + ): [string, unknown][] { + return Object.entries(patch).map(([key, value]) => { + if (value !== null) return [key, value]; + const control = controls.find((candidate) => candidate.key === key); + if (control?.type === "toggle") return [key, false]; + if (control?.type === "select" && control.defaultValue !== undefined) { + return [key, control.defaultValue]; + } + throw new Error(`Update the host to reset ${key}.`); + }); + } + + /** A config apply with its `options` as `featureValues`, the way a daemon without them takes it. */ + featureValuesConfig( + config: AgentConfigApply, + controls: readonly AgentOptionControl[], + ): AgentConfigApply { + const { options, ...rest } = config; + if (!options) return config; + return { + ...rest, + featureValues: { + ...rest.featureValues, + ...Object.fromEntries(this.featureWrites(options, controls)), + }, + }; + } + + /** The legacy answer to a controls listing, in the shape of the current response. */ + controlsResponse( + legacy: ListProviderFeaturesResponseMessage["payload"], + ): ProviderControlsListResponseMessage["payload"] { + const { features, ...rest } = legacy; + return { + ...rest, + ...(features + ? { controls: controlsFromFeatures(features), options: optionsFromFeatures(features) } + : {}), + }; + } + + private daemonConfig(config: MutableDaemonConfig): MutableDaemonConfig { + if (!config.agentProfiles) return config; + return { ...config, agentProfiles: config.agentProfiles.map(profileWithOptions) }; + } + + private readonly snapshot = (agent: AgentSnapshotPayload): AgentSnapshotPayload => { + if (agent.controls || !agent.features) return agent; + return { + ...agent, + controls: controlsFromFeatures(agent.features), + options: optionsFromFeatures(agent.features), + }; + }; +} + +/** + * A legacy daemon stores a profile's options under `featureValues`, and keeps an `options` field + * a newer daemon left on it. Both are read, and an explicit `options` key wins, as it does on the + * daemon (`foldFeatureValues`). + */ +function profileWithOptions(profile: AgentProfile): AgentProfile { + if (!profile.featureValues) return profile; + const { featureValues, ...rest } = profile; + return { ...rest, options: { ...featureValues, ...profile.options } }; +} + +function profileWithFeatureValues(profile: AgentProfile): AgentProfile { + if (!profile.options) return profile; + const { options, ...rest } = profile; + return { ...rest, featureValues: options }; +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 2e695d6d7f..2ddef8cc61 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -4299,6 +4299,35 @@ test("rejects an internal create before sending when the host predates internal expect(mock.sent).toHaveLength(0); }); +test("metadata needs a host that advertises entityMetadata and sends nothing to one that does not", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen({ features: {} }); + await connectPromise; + + const update = "Update the host to use metadata."; + await expect( + client.createAgent({ provider: "codex", cwd: "/tmp/project", metadata: { key: "value" } }), + ).rejects.toThrow(update); + await expect( + client.createWorkspace({ + source: { kind: "directory", path: "/tmp/project" }, + metadata: { key: "value" }, + }), + ).rejects.toThrow(update); + await expect(client.setAgentMetadata("agent-1", { key: null })).rejects.toThrow(update); + await expect(client.setWorkspaceMetadata("workspace-1", { key: null })).rejects.toThrow(update); + expect(mock.sent).toHaveLength(0); +}); + test("sends internal workspace creation and internal listings only to hosts that support them", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -9555,3 +9584,552 @@ test("routed channels inherit an origin without drain support", async () => { expect(route.supportsDrain).toBe(false); route.close(); }); + +describe("provider options across daemon versions", () => { + const agentSnapshot = { + id: "agent-1", + provider: "codex", + cwd: "/repo", + model: null, + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + requiresAttention: false, + attentionReason: null, + }; + const speedFeature = { + type: "select", + id: "service_tier", + label: "Speed", + value: "priority", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + }; + + async function connect(features: Record) { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "provider_options", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features }); + await connecting; + const sentMessages = () => + mock.sent.map( + (frame) => (JSON.parse(frame as string) as { message: Record }).message, + ); + return { mock, client, sentMessages }; + } + + test("a legacy create and a timeline read return the agent with controls over options", async () => { + const { mock, client, sentMessages } = await connect({}); + const legacyAgent = { ...agentSnapshot, features: [speedFeature] }; + const answerLast = (reply: (requestId: string) => Record) => { + const sent = sentMessages(); + const requestId = String(sent[sent.length - 1].requestId); + mock.triggerMessage(wrapSessionMessage(reply(requestId) as never)); + }; + + const creating = client.createAgent({ provider: "codex", cwd: "/repo" }); + answerLast((requestId) => ({ + type: "status", + payload: { status: "agent_created", requestId, agentId: "agent-1", agent: legacyAgent }, + })); + const created = await creating; + + const reading = client.fetchAgentTimeline("agent-1"); + answerLast((requestId) => ({ + type: "fetch_agent_timeline_response", + payload: { + requestId, + agentId: "agent-1", + agent: legacyAgent, + direction: "tail", + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 1 }, + startCursor: null, + endCursor: null, + hasOlder: false, + hasNewer: false, + entries: [], + error: null, + }, + })); + const timeline = await reading; + + expect(created.options).toEqual({ service_tier: "priority" }); + expect(created.controls).toEqual([expect.objectContaining({ key: "service_tier" })]); + expect(timeline.agent?.options).toEqual({ service_tier: "priority" }); + expect(timeline.agent?.controls).toEqual([expect.objectContaining({ key: "service_tier" })]); + }); + + test("a legacy daemon's features arrive as controls over options", async () => { + const { mock, client } = await connect({}); + const received: Array<{ controls?: unknown; options?: unknown }> = []; + client.on("agent_update", (message) => { + if (message.payload.kind === "upsert") received.push(message.payload.agent); + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_update", + payload: { kind: "upsert", agent: { ...agentSnapshot, features: [speedFeature] } }, + }), + ); + expect(received).toEqual([ + expect.objectContaining({ + options: { service_tier: "priority" }, + controls: [ + expect.objectContaining({ + type: "select", + key: "service_tier", + defaultValue: "default", + choices: [ + { value: "default", label: "Normal" }, + { value: "priority", label: "Fast" }, + ], + }), + ], + }), + ]); + }); + + test("a legacy daemon takes option writes as feature writes", async () => { + const { client, sentMessages, mock } = await connect({}); + const write = client.setAgentOptions("agent-1", { service_tier: "default" }); + const [request] = sentMessages(); + expect(request).toMatchObject({ + type: "set_agent_feature_request", + agentId: "agent-1", + featureId: "service_tier", + value: "default", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "set_agent_feature_response", + payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null }, + }), + ); + await write; + }); + + // The old Codex setter rejects a non-string `service_tier` and the old Claude setter reads + // `null` as off, so a reset is sent as the value the old API can represent. + async function resetOnLegacyDaemon(agent: Record, patch: Record) { + const { client, sentMessages, mock } = await connect({}); + const reply = (message: Record) => + mock.triggerMessage(wrapSessionMessage(message as never)); + const writing = client.setAgentOptions("agent-1", patch); + const [fetch] = sentMessages(); + expect(fetch).toMatchObject({ type: "fetch_agent_request", agentId: "agent-1" }); + reply({ + type: "fetch_agent_response", + payload: { requestId: fetch.requestId, agent, project: null, error: null }, + }); + return { writing, sentMessages, reply }; + } + + test("a legacy daemon resets a choice to the default the old API names", async () => { + const { writing, sentMessages, reply } = await resetOnLegacyDaemon( + { ...agentSnapshot, provider: "codex", features: [speedFeature] }, + { service_tier: null }, + ); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(2)); + const write = sentMessages()[1]; + expect(write).toMatchObject({ + type: "set_agent_feature_request", + featureId: "service_tier", + value: "default", + }); + reply({ + type: "set_agent_feature_response", + payload: { requestId: write.requestId, agentId: "agent-1", accepted: true, error: null }, + }); + await writing; + }); + + test("a legacy daemon resets a toggle to off", async () => { + const { writing, sentMessages, reply } = await resetOnLegacyDaemon( + { + ...agentSnapshot, + features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: true }], + }, + { fast_mode: null }, + ); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(2)); + const write = sentMessages()[1]; + expect(write).toMatchObject({ featureId: "fast_mode", value: false }); + reply({ + type: "set_agent_feature_response", + payload: { requestId: write.requestId, agentId: "agent-1", accepted: true, error: null }, + }); + await writing; + }); + + test("a legacy daemon cannot reset an option no control draws, and writes nothing", async () => { + const { writing, sentMessages } = await resetOnLegacyDaemon( + { ...agentSnapshot, features: [speedFeature] }, + { token_budget: null }, + ); + await expect(writing).rejects.toThrow("Update the host to reset token_budget."); + expect(sentMessages()).toHaveLength(1); + }); + + test("a legacy daemon applies a config reset as the default the old API names", async () => { + const { client, sentMessages, mock } = await connect({}); + const reply = (message: Record) => + mock.triggerMessage(wrapSessionMessage(message as never)); + const applying = client.applyAgentConfig("agent-1", { + modeId: "plan", + options: { service_tier: null, sandbox_mode: "read-only" }, + }); + const [fetch] = sentMessages(); + expect(fetch).toMatchObject({ type: "fetch_agent_request", agentId: "agent-1" }); + reply({ + type: "fetch_agent_response", + payload: { + requestId: fetch.requestId, + agent: { ...agentSnapshot, provider: "codex", features: [speedFeature] }, + project: null, + error: null, + }, + }); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(2)); + const apply = sentMessages()[1]; + expect(apply).toMatchObject({ + type: "agent.config.apply.request", + config: { + modeId: "plan", + featureValues: { service_tier: "default", sandbox_mode: "read-only" }, + }, + }); + expect(apply.config).not.toHaveProperty("options"); + reply({ + type: "agent.config.apply.response", + payload: { requestId: apply.requestId, agentId: "agent-1", accepted: true, error: null }, + }); + await applying; + }); + + test("a legacy daemon cannot apply a config reset it cannot name, and applies nothing", async () => { + const { client, sentMessages, mock } = await connect({}); + const applying = client.applyAgentConfig("agent-1", { options: { token_budget: null } }); + const [fetch] = sentMessages(); + mock.triggerMessage( + wrapSessionMessage({ + type: "fetch_agent_response", + payload: { + requestId: fetch.requestId, + agent: { ...agentSnapshot, features: [speedFeature] }, + project: null, + error: null, + }, + } as never), + ); + await expect(applying).rejects.toThrow("Update the host to reset token_budget."); + expect(sentMessages()).toHaveLength(1); + }); + + test("a current daemon takes option writes as one options patch", async () => { + const { client, sentMessages, mock } = await connect({ providerOptionControls: true }); + const write = client.setAgentOptions("agent-1", { service_tier: null, sandbox_mode: "x" }); + const [request] = sentMessages(); + expect(request).toMatchObject({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { options: { service_tier: null, sandbox_mode: "x" } }, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "agent.config.apply.response", + payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null }, + }), + ); + await write; + }); + + test("a legacy draft listing answers with controls and options", async () => { + const { client, sentMessages, mock } = await connect({}); + const listing = client.listProviderControls({ + provider: "codex", + cwd: "/repo", + providerOptions: { service_tier: "default" }, + }); + const [request] = sentMessages(); + expect(request).toMatchObject({ + type: "list_provider_features_request", + draftConfig: { provider: "codex", featureValues: { service_tier: "default" } }, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "list_provider_features_response", + payload: { + provider: "codex", + features: [{ ...speedFeature, value: "default" }], + error: null, + fetchedAt: "2026-04-30T00:00:00.000Z", + requestId: request.requestId, + }, + }), + ); + await expect(listing).resolves.toMatchObject({ + provider: "codex", + options: { service_tier: "default" }, + controls: [expect.objectContaining({ key: "service_tier" })], + }); + }); + + test("a legacy daemon's profiles are read and saved by their options", async () => { + const { client, sentMessages, mock } = await connect({}); + const reading = client.getDaemonConfig(); + const [read] = sentMessages(); + mock.triggerMessage( + wrapSessionMessage({ + type: "get_daemon_config_response", + payload: { + requestId: read.requestId, + config: { + mcp: { injectIntoAgents: true }, + agentProfiles: [ + { + id: "p1", + name: "Fast", + provider: "codex", + featureValues: { service_tier: "fast" }, + }, + ], + }, + }, + }), + ); + const { config } = await reading; + expect(config.agentProfiles?.[0]).toMatchObject({ options: { service_tier: "fast" } }); + expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues"); + + void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {}); + const written = sentMessages().find((message) => message.type === "set_daemon_config_request"); + expect(written).toMatchObject({ + config: { agentProfiles: [{ featureValues: { service_tier: "fast" } }] }, + }); + expect(written).not.toMatchObject({ config: { agentProfiles: [{ options: {} }] } }); + }); + + test("a legacy daemon's profile with both featureValues and options keeps both when saved", async () => { + // `options` survives on a legacy daemon's passthrough profile after a downgrade, while its + // own app keeps editing `featureValues`. + const { client, sentMessages, mock } = await connect({}); + const reading = client.getDaemonConfig(); + const [read] = sentMessages(); + mock.triggerMessage( + wrapSessionMessage({ + type: "get_daemon_config_response", + payload: { + requestId: read.requestId, + config: { + mcp: { injectIntoAgents: true }, + agentProfiles: [ + { + id: "p1", + name: "Mixed", + provider: "codex", + featureValues: { service_tier: "fast", sandbox_mode: "workspace-write" }, + options: { sandbox_mode: "read-only" }, + }, + ], + }, + }, + }), + ); + const { config } = await reading; + expect(config.agentProfiles?.[0]).toMatchObject({ + options: { service_tier: "fast", sandbox_mode: "read-only" }, + }); + expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues"); + + void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {}); + const written = sentMessages().find((message) => message.type === "set_daemon_config_request"); + expect(written).toMatchObject({ + config: { + agentProfiles: [{ featureValues: { service_tier: "fast", sandbox_mode: "read-only" } }], + }, + }); + }); + + test("a legacy daemon applies a profile's options as feature values", async () => { + const { client, sentMessages } = await connect({}); + void client.applyAgentConfig("agent-1", { options: { service_tier: "fast" } }).catch(() => {}); + const [request] = sentMessages(); + expect(request).toMatchObject({ + type: "agent.config.apply.request", + config: { featureValues: { service_tier: "fast" } }, + }); + }); + + test("a cold legacy create lists the provider's features before routing its options", async () => { + const { client, sentMessages, mock } = await connect({}); + void client + .createAgent({ + config: { + provider: "claude", + cwd: "/repo", + providerOptions: { fast_mode: true, sandbox_mode: "read-only" }, + }, + }) + .catch(() => {}); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(1)); + const [listing] = sentMessages(); + expect(listing).toMatchObject({ + type: "list_provider_features_request", + draftConfig: { provider: "claude", cwd: "/repo" }, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "list_provider_features_response", + payload: { + provider: "claude", + features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }], + error: null, + fetchedAt: "2026-04-30T00:00:00.000Z", + requestId: listing.requestId, + }, + }), + ); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(2)); + expect(sentMessages()[1].config).toMatchObject({ + featureValues: { fast_mode: true }, + providerOptions: { sandbox_mode: "read-only" }, + }); + }); + + test("a legacy workspace create routes its agent's options from its own model's controls", async () => { + const { client, sentMessages, mock } = await connect({ creationLifecycle: true }); + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_update", + payload: { + kind: "upsert", + agent: { ...agentSnapshot, provider: "codex", model: "mini", features: [] }, + }, + }), + ); + void client + .createWorkspace({ + source: { kind: "directory", path: "/repo" }, + agent: { + config: { + provider: "codex", + cwd: "/repo", + model: "gpt-5.5", + providerOptions: { service_tier: "fast" }, + }, + }, + } as never) + .catch(() => {}); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(1)); + const [listing] = sentMessages(); + expect(listing).toMatchObject({ + type: "list_provider_features_request", + draftConfig: { provider: "codex", cwd: "/repo", model: "gpt-5.5" }, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "list_provider_features_response", + payload: { + provider: "codex", + features: [speedFeature], + error: null, + fetchedAt: "2026-04-30T00:00:00.000Z", + requestId: listing.requestId, + }, + }), + ); + const sentTypes = () => sentMessages().map((message) => message.type); + await vi.waitFor(() => expect(sentTypes()).toContain("workspace.create.request")); + const created = sentMessages()[sentTypes().indexOf("workspace.create.request")]; + expect(created).toMatchObject({ + agent: { config: { featureValues: { service_tier: "fast" } } }, + }); + }); + + test("a legacy create lists the controls of its own model, whatever a snapshot showed", async () => { + const { client, sentMessages, mock } = await connect({}); + // A Haiku agent has no Fast control; that says nothing about Opus. + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_update", + payload: { + kind: "upsert", + agent: { ...agentSnapshot, provider: "claude", model: "haiku", features: [] }, + }, + }), + ); + void client + .createAgent({ + config: { + provider: "claude", + cwd: "/repo", + model: "opus", + modeId: "plan", + thinkingOptionId: "high", + providerOptions: { fast_mode: true, sandbox_mode: "read-only" }, + }, + }) + .catch(() => {}); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(1)); + const [listing] = sentMessages(); + expect(listing).toMatchObject({ + type: "list_provider_features_request", + draftConfig: { + provider: "claude", + cwd: "/repo", + model: "opus", + modeId: "plan", + thinkingOptionId: "high", + }, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "list_provider_features_response", + payload: { + provider: "claude", + features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }], + error: null, + fetchedAt: "2026-04-30T00:00:00.000Z", + requestId: listing.requestId, + }, + }), + ); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(2)); + expect(sentMessages()[1].config).toMatchObject({ + model: "opus", + featureValues: { fast_mode: true }, + providerOptions: { sandbox_mode: "read-only" }, + }); + }); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9a669db834..fa6a0306cc 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -27,6 +27,7 @@ import { } from "./connection/index.js"; import { CreationClient } from "./creation/index.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; @@ -108,6 +109,7 @@ import type { WorkspaceSetupStatusResponseMessage, ListCommandsResponse, ListProviderFeaturesResponseMessage, + ProviderControlsListResponseMessage, ListProviderModelsResponseMessage, ListProviderModesResponseMessage, ListAvailableProvidersResponse, @@ -149,6 +151,7 @@ import type { AgentSkillsSaveResult, } from "@getpaseo/protocol/messages"; import type { + AgentOptionControl, AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, @@ -195,6 +198,10 @@ import { normalizeProviderSnapshotUpdateMessage, normalizeProvidersSnapshotPayload, } from "./compat/normalize-provider-models.js"; +import { + LegacyProviderOptions, + usesLegacyProviderOptions, +} from "./compat/legacy-provider-options.js"; import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js"; import type { BrowserAutomationExecuteRequest, @@ -516,11 +523,15 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { worktreeName?: string; requestId?: string; labels?: Record; + /** Plugin-owned data stored on the agent. Needs a host with `entityMetadata`. */ + metadata?: CreateAgentRequestMessage["metadata"]; } export interface CreateWorkspaceRequestOptions { source: WorkspaceCreateRequest["source"]; title?: string; + /** Plugin-owned data stored on the workspace. Needs a host with `entityMetadata`. */ + metadata?: WorkspaceCreateRequest["metadata"]; /** Workspace discovery visibility; contents retain normal durability. */ background?: boolean; callerAgentId?: string; @@ -610,6 +621,7 @@ export interface FileUploadInput { export type FileUploadResult = FileUploadResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"]; +export type ListProviderControlsPayload = ProviderControlsListResponseMessage["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; @@ -636,7 +648,7 @@ type WriteProjectConfigPayload = Extract< type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< AgentSessionConfig, - "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" + "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" | "providerOptions" >; export interface WriteProjectConfigInput { repoRoot: string; @@ -2153,17 +2165,13 @@ export class DaemonClient { } private sendSessionPayload(payload: SessionInboundMessage): Promise { - return this.sendJsonMessage( - "session", - payload.type, - { type: "session", message: payload }, - () => { - if (!("requestId" in payload) || typeof payload.requestId !== "string") return; - for (const waiter of this.waiters) { - if (waiter.requestId === payload.requestId) waiter.submitted = true; - } - }, - ); + const wire = this.legacyProviderOptions()?.prepare(payload) ?? payload; + return this.sendJsonMessage("session", payload.type, { type: "session", message: wire }, () => { + if (!("requestId" in payload) || typeof payload.requestId !== "string") return; + for (const waiter of this.waiters) { + if (waiter.requestId === payload.requestId) waiter.submitted = true; + } + }); } private sendTransportFrame( @@ -3433,6 +3441,13 @@ export class DaemonClient { legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId), }); + // Entity metadata is gated once at the transport boundary. + private requireEntityMetadata(): void { + if (this.lastServerInfoMessage?.features?.entityMetadata !== true) { + throw new Error("Update the host to use metadata."); + } + } + // Background workspaces are gated once at the transport boundary. private requireBackgroundWorkspaces(): void { if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) { @@ -3462,14 +3477,45 @@ export class DaemonClient { ); } if (options.background !== undefined) this.requireBackgroundCreation(options); + if (options.metadata !== undefined) this.requireEntityMetadata(); + const config = resolveAgentConfig(options); const result = await this.creations.createAgent({ ...options, - config: resolveAgentConfig(options), + config: this.carriesLegacyOptions(config) + ? await this.routeLegacyCreateConfig(config) + : config, }); if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed"); return result.agent; } + /** + * A legacy daemon reads control options as `featureValues`, and which options are controls + * depends on the config (Claude's differ by model), so a create that carries options first lists + * the controls for its own config. Callers never prime anything. + */ + private carriesLegacyOptions(config: AgentSessionConfig): boolean { + return ( + this.legacyProviderOptions() !== null && Object.keys(config.providerOptions ?? {}).length > 0 + ); + } + + private async routeLegacyCreateConfig(config: AgentSessionConfig): Promise { + const payload = await this.listProviderFeatures({ + provider: config.provider, + cwd: config.cwd || "~", + ...(config.modeId ? { modeId: config.modeId } : {}), + ...(config.model ? { model: config.model } : {}), + ...(config.thinkingOptionId ? { thinkingOptionId: config.thinkingOptionId } : {}), + featureValues: config.providerOptions, + }); + if (payload.error) throw new Error(payload.error); + return this.legacyOptions.routeCreateConfig( + config, + new Set((payload.features ?? []).map((feature) => feature.id)), + ); + } + private async createLegacyAgent( options: CreateAgentRequestOptions, ): Promise { @@ -3668,6 +3714,42 @@ export class DaemonClient { return { removedWorkspaceIds: payload.removedWorkspaceIds }; } + /** Top-level keys replace; `null` removes the key. Resolves to the agent's metadata afterwards. */ + async setAgentMetadata( + agentId: string, + patch: Record, + requestId?: string, + ): Promise> { + this.requireEntityMetadata(); + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"agent.metadata.update.response">({ + requestId, + message: { type: "agent.metadata.update.request", agentId, patch }, + }); + if (!payload.accepted || !payload.metadata) { + throw new Error(payload.error ?? "setAgentMetadata rejected"); + } + return payload.metadata; + } + + /** Top-level keys replace; `null` removes the key. Resolves to the workspace's metadata afterwards. */ + async setWorkspaceMetadata( + workspaceId: string, + patch: Record, + requestId?: string, + ): Promise> { + this.requireEntityMetadata(); + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"workspace.metadata.update.response">({ + requestId, + message: { type: "workspace.metadata.update.request", workspaceId, patch }, + }); + if (!payload.accepted || !payload.metadata) { + throw new Error(payload.error ?? "setWorkspaceMetadata rejected"); + } + return payload.metadata; + } + async setWorkspaceTitle( workspaceId: string, title: string | null, @@ -4345,6 +4427,41 @@ export class DaemonClient { } } + /** + * Changes the agent's own provider options: top-level keys replace, `null` removes the key. + * A daemon without `providerOptionControls` takes each key as a feature write. + */ + async setAgentOptions( + agentId: string, + patch: Record, + ): Promise { + const legacy = this.legacyProviderOptions(); + if (legacy) { + // COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. + const pending = this.legacyResetControls(agentId, patch); + const controls = pending ? await pending : []; + for (const [featureId, value] of legacy.featureWrites(patch, controls)) { + await this.setAgentFeature(agentId, featureId, value); + } + return null; + } + return this.applyAgentConfig(agentId, { options: patch }); + } + + /** + * COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. + * A reset (`null`) on a daemon without `providerOptionControls` is written as the value its + * control resets to, which takes the agent's controls. Without a reset nothing is fetched, so + * the write is not delayed. + */ + private legacyResetControls( + agentId: string, + patch: Record, + ): Promise | null { + if (!Object.values(patch).includes(null)) return null; + return this.fetchAgent(agentId).then((result) => result?.agent.controls ?? []); + } + async setAgentThinkingOption( agentId: string, thinkingOptionId: string | null, @@ -4387,11 +4504,18 @@ export class DaemonClient { agentId: string, config: AgentConfigApply, ): Promise { + let wireConfig = config; + const legacy = this.legacyProviderOptions(); + if (legacy && config.options) { + // COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. + const pending = this.legacyResetControls(agentId, config.options); + wireConfig = legacy.featureValuesConfig(config, pending ? await pending : []); + } const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ type: "agent.config.apply.request", agentId, - config, + config: wireConfig, requestId, }); const payload = await this.sendRequest({ @@ -5308,12 +5432,16 @@ export class DaemonClient { if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces(); if (input.agent?.background !== undefined) throw new Error("Configure background on the workspace, not its initial agent."); + if (input.metadata !== undefined || input.agent?.metadata !== undefined) + this.requireEntityMetadata(); + let agentConfig = input.agent ? resolveAgentConfig(input.agent) : undefined; + if (agentConfig && this.carriesLegacyOptions(agentConfig)) { + agentConfig = await this.routeLegacyCreateConfig(agentConfig); + } const result = await this.creations.createWorkspace({ ...input, requestId: resolvedRequestId, - ...(input.agent - ? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } } - : {}), + ...(input.agent && agentConfig ? { agent: { ...input.agent, config: agentConfig } } : {}), }); return { ...result, @@ -5795,6 +5923,36 @@ export class DaemonClient { }); } + /** + * The controls a draft agent would show and the options it would run with: `config.json` + * defaults merged with the draft's own `providerOptions`, resolved by the daemon. + */ + async listProviderControls( + draftConfig: ListCommandsDraftConfig, + options?: { requestId?: string }, + ): Promise { + const legacy = this.legacyProviderOptions(); + if (legacy) { + const { providerOptions, ...rest } = draftConfig; + const payload = await this.listProviderFeatures( + { + ...rest, + // A legacy daemon reads the options it exposes as features, whatever the others are. + ...(providerOptions ? { featureValues: providerOptions } : {}), + }, + options, + ); + return legacy.controlsResponse(payload); + } + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { type: "provider.controls.list.request", draftConfig }, + responseType: "provider.controls.list.response", + timeout: 90000, + }); + } + + /** @deprecated Use `listProviderControls`. */ async listProviderFeatures( draftConfig: ListCommandsDraftConfig, options?: { requestId?: string }, @@ -7091,6 +7249,14 @@ export class DaemonClient { return requestId ?? crypto.randomUUID(); } + private readonly legacyOptions = new LegacyProviderOptions(); + + /** The translation for a daemon without `providerOptionControls`; null for a current one. */ + private legacyProviderOptions(): LegacyProviderOptions | null { + const info = this.lastServerInfoMessage; + return info && usesLegacyProviderOptions(info) ? this.legacyOptions : null; + } + getLastServerInfoMessage(): ServerInfoStatusPayload | null { return this.lastServerInfoMessage; } @@ -7700,6 +7866,7 @@ export class DaemonClient { private handleSessionMessage(msg: SessionOutboundMessage): void { msg = this.owned.normalize(msg); + msg = this.legacyProviderOptions()?.normalize(msg) ?? msg; if ( msg.type === "providers_snapshot_update" && this.config.providerSnapshots !== "wire" && @@ -7989,6 +8156,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon worktreeName: _worktreeName, requestId: _requestId, labels: _labels, + metadata: _metadata, ...overrides } = options; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 63f523dc11..6a49405a4a 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1706,7 +1706,12 @@ test("config actions delegate to existing daemon config RPCs", async () => { }); test("agent config maps provider/model and provider-native options to the daemon", async () => { - const { client, ws } = await connectClient(); + const { client, ws } = await connectClient({ + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + providerOptionControls: true, + }); const createdAgent = createAgent({ model: "gpt-5.4", currentModeId: "full-access", @@ -1738,8 +1743,8 @@ test("agent config maps provider/model and provider-native options to the daemon model: "gpt-5.4", modeId: "full-access", thinkingOptionId: "high", - featureValues: { webSearch: true }, providerOptions: { + webSearch: true, approval_policy: "never", sandbox_mode: "workspace-write", sandbox_workspace_write: { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 356613cb2b..7f1c5fd14f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -9,7 +9,7 @@ export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/ import type { OwnedSubscription } from "./connection/index.js"; export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js"; import type { DaemonClientConfig } from "./daemon-client.js"; -import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; +import type { AgentPermissionResponse, JsonValue } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload, CreationSnapshot, @@ -21,6 +21,8 @@ import type { ListCommandsResponse, ListProviderFeaturesRequestMessage, ListProviderFeaturesResponseMessage, + ProviderControlsListRequestMessage, + ProviderControlsListResponseMessage, ListProviderModelsResponseMessage, ProjectListRequestMessage, ProjectListResponseMessage, @@ -184,6 +186,8 @@ export interface PaseoWorkspaceHandle { readonly directory: string | null; readonly name: string | null; readonly status: PaseoWorkspace["status"] | null; + /** Data plugins stored on the workspace. Paseo never reads a key. */ + readonly metadata: NonNullable | null; readonly agents: { create(options: PaseoWorkspaceAgentCreateOptions): Promise; }; @@ -191,6 +195,8 @@ export interface PaseoWorkspaceHandle { current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; + /** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */ + setMetadata(patch: Record): Promise; setPinned(pinned: boolean): Promise<{ pinnedAt: string | null }>; setLabel(name: string, options: { assigned: boolean }): Promise<{ labels: string[] }>; setBaseBranch(options: { @@ -247,8 +253,12 @@ export interface PaseoAgentConfig { provider: string; modeId?: PaseoAgentSessionConfig["modeId"]; thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"]; + /** @deprecated Put these in `options`; they are folded into it on send. */ featureValues?: PaseoProviderFeatureValues; - /** JSON-safe provider-native settings, validated by the selected provider. */ + /** + * JSON-safe provider settings, validated by the selected provider. They include the settings the + * provider offers a composer control for, such as `service_tier` or `fast_mode`. + */ options?: PaseoAgentSessionConfig["providerOptions"]; systemPrompt?: PaseoAgentSessionConfig["systemPrompt"]; toolPolicy?: PaseoAgentSessionConfig["toolPolicy"]; @@ -280,6 +290,8 @@ export interface PaseoAgentCreateOptions { background?: CreateAgentRequestMessage["background"]; requestId?: string; labels?: Record; + /** Data plugins store on the agent: JSON values under keys such as `"linear.issue"`. */ + metadata?: CreateAgentRequestMessage["metadata"]; } export type PaseoWorkspaceAgentCreateOptions = Omit; @@ -406,7 +418,14 @@ export interface PaseoAgentHandle { readonly activeTurn: NonNullable | null; readonly lastUsage: NonNullable | null; readonly lastError: NonNullable | null; + /** @deprecated Use `controls` and `options`. */ readonly features: NonNullable | null; + /** The provider options the agent runs with: `config.json` defaults merged with its own. */ + readonly options: NonNullable | null; + /** How the provider wants its options drawn. Controls hold no value; read `options`. */ + readonly controls: NonNullable | null; + /** Data plugins stored on the agent. Paseo never reads a key. */ + readonly metadata: NonNullable | null; readonly runtimeInfo: NonNullable | null; readonly archivedAt: NonNullable | null; readonly timeline: PaseoAgentTimelineHandle; @@ -420,6 +439,14 @@ export interface PaseoAgentHandle { reload(options?: { requestId?: string }): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise; + /** + * Changes the agent's own options: top-level keys replace, `null` removes the key so the + * `config.json` default (or the provider's own default) applies again. Resolves once the + * provider has applied the patch; a provider that cannot apply a key live rejects it. + */ + setOptions(patch: Record): Promise; + /** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */ + setMetadata(patch: Record): Promise; /** Sends a prompt and resolves when that turn finishes or needs attention. */ run(text: string, options?: PaseoAgentRunOptions): Promise; /** Waits for the current turn, including one started with `prompt`. */ @@ -456,14 +483,21 @@ export interface PaseoAgentActions { export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"]; export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"]; -type PaseoProviderFeaturesDraft = ListProviderFeaturesRequestMessage["draftConfig"]; -export interface PaseoProviderFeaturesInput extends Omit< - PaseoProviderFeaturesDraft, +type PaseoProviderControlsDraft = ProviderControlsListRequestMessage["draftConfig"]; +export interface PaseoProviderControlsInput extends Omit< + PaseoProviderControlsDraft, "provider" | "model" > { /** Provider and model in `provider/model` format. */ provider: string; } +export type PaseoProviderControlsResult = ProviderControlsListResponseMessage["payload"]; +/** @deprecated Use `PaseoProviderControlsInput`. */ +export type PaseoProviderFeaturesInput = Omit< + ListProviderFeaturesRequestMessage["draftConfig"], + "provider" | "model" +> & { provider: string }; +/** @deprecated Use `PaseoProviderControlsResult`. */ export type PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"]; export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"]; export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"]; @@ -502,6 +536,15 @@ export interface PaseoProviderActions { provider: PaseoAgentProvider, options?: PaseoProviderListOptions, ): Promise; + /** + * The controls a draft agent would show and the options it would run with, resolved by the + * daemon from `config.json` defaults and the draft's own `providerOptions`. + */ + listControls( + draftConfig: PaseoProviderControlsInput, + options?: { requestId?: string }, + ): Promise; + /** @deprecated Use `listControls`. */ listFeatures( draftConfig: PaseoProviderFeaturesInput, options?: { requestId?: string }, @@ -586,7 +629,14 @@ function toDaemonAgentCreateOptions( serverId?: string, ): CreateAgentRequestOptions { const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; - const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; + const { + provider: providerModel, + options: ownOptions, + featureValues, + ...runtimeConfig + } = agentConfig; + // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. An explicit option wins. + const providerOptions = featureValues ? { ...featureValues, ...ownOptions } : ownOptions; const { provider, model } = parseProviderModel(providerModel); const relationship = parent ? encodeParent(parent, serverId) : {}; return { @@ -822,6 +872,10 @@ export function createPaseoApi( const { provider, model } = parseProviderModel(providerModel); return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options); }, + listControls: ({ provider: providerModel, ...draftConfig }, options) => { + const { provider, model } = parseProviderModel(providerModel); + return daemonClient.listProviderControls({ ...draftConfig, provider, model }, options); + }, listAvailable: (options) => daemonClient.listAvailableProviders(options), snapshot: (options) => daemonClient.getProvidersSnapshot(options), waitForReady: (options) => @@ -883,6 +937,9 @@ function createWorkspaceHandleFactory( get status() { return current?.status ?? null; }, + get metadata() { + return current?.metadata ?? null; + }, agents: { create: async (options) => { const snapshot = current ?? (await refresh()); @@ -902,6 +959,10 @@ function createWorkspaceHandleFactory( current: () => current, refresh, setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), + setMetadata: async (patch) => { + const metadata = await daemonClient.setWorkspaceMetadata(id, patch); + if (current) current = { ...current, metadata }; + }, setPinned: (pinned) => daemonClient.setWorkspacePinned(id, pinned), setLabel: (name, options) => assignWorkspaceLabel({ @@ -1056,6 +1117,15 @@ function createAgentHandleFactory( get features() { return current?.features ?? null; }, + get options() { + return current?.options ?? null; + }, + get controls() { + return current?.controls ?? null; + }, + get metadata() { + return current?.metadata ?? null; + }, get runtimeInfo() { return current?.runtimeInfo ?? null; }, @@ -1077,6 +1147,13 @@ function createAgentHandleFactory( send: async (text, options) => { await daemonClient.sendAgentMessage(id, text, options); }, + setOptions: async (patch) => { + await daemonClient.setAgentOptions(id, patch); + }, + setMetadata: async (patch) => { + const metadata = await daemonClient.setAgentMetadata(id, patch); + if (current) current = { ...current, metadata }; + }, respondToPermission: async ({ requestId, response }) => { await daemonClient.respondToPermission(id, requestId, response); }, diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 24385a3a16..1b093019c3 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -213,6 +213,133 @@ 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 ( + <> + composer.setMetadata({ flag: value })} /> + + ); +} + +export function ReviewFlag() { + const composer = useComposer(); + const change = useCallback( + ({ value }: { value: boolean }) => composer.setMetadata({ [REVIEW_KEY]: value ? true : null }), + [composer], + ); + return ( + + ); +} + +/** + * 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 ( + + ); +} + +/** 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 ; +} diff --git a/plugin-examples/composer-controls/client/panel.tsx b/plugin-examples/composer-controls/client/panel.tsx new file mode 100644 index 0000000000..1c76219ee5 --- /dev/null +++ b/plugin-examples/composer-controls/client/panel.tsx @@ -0,0 +1,56 @@ +import { useCallback, useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; +import { useComposers, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client"; +import { REVIEW_KEY } from "./controls"; + +/** + * Lives in the Explorer, away from any composer, and reaches the composer of the agent whose tab + * is focused. The agent's tab does not have to be open for the composer to exist: the text lands in + * its prompt either way. + */ +export function ComposerTools({ theme, layout }: PluginWorkspacePanelProps) { + const [focused] = useComposers({ focused: true }); + const styles = useMemo( + () => ({ + screen: { + flex: 1, + padding: layout.compact ? 16 : 24, + gap: 12, + backgroundColor: theme.colors.surface0, + }, + label: { color: theme.colors.foregroundMuted }, + button: { + padding: 12, + borderRadius: 8, + backgroundColor: focused ? theme.colors.accent : theme.colors.surface2, + }, + buttonText: { + color: focused ? theme.colors.accentForeground : theme.colors.foregroundMuted, + textAlign: "center" as const, + }, + }), + [focused, layout.compact, theme], + ); + const insert = useCallback(() => focused?.insertText("- [ ] Tests\n- [ ] Docs\n"), [focused]); + return ( + + + {focused ? `Focused agent: ${focused.agent.provider}` : "No agent is focused"} + + {focused ? ( + + {focused.agent.metadata[REVIEW_KEY] === true ? "Needs review: yes" : "Needs review: no"} + + ) : null} + + Insert checklist into the focused agent + + + ); +} diff --git a/plugin-examples/composer-controls/index.client.tsx b/plugin-examples/composer-controls/index.client.tsx new file mode 100644 index 0000000000..f8d5fd84ee --- /dev/null +++ b/plugin-examples/composer-controls/index.client.tsx @@ -0,0 +1,30 @@ +import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { Checklist, ContextLength, PlanFirst, REVIEW_KEY, ReviewFlag } from "./client/controls"; +import { ComposerTools } from "./client/panel"; + +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 }); + client.addWorkspacePanel({ + id: "composer-tools", + title: "Composer tools", + icon: "ListChecks", + locations: ["explorer"], + Component: ComposerTools, + }); + client.addCommandCenterItem({ + id: "mark-for-review", + title: "Composer controls: mark for review", + icon: "Flag", + context: "agent", + async onSelect({ agent, composers }) { + // The agent's tab does not have to be open. + for (const composer of composers({ agentId: agent.id })) { + await composer.setMetadata({ [REVIEW_KEY]: true }); + } + }, + }); + return () => {}; +} diff --git a/plugin-examples/composer-controls/paseo-plugin.json b/plugin-examples/composer-controls/paseo-plugin.json new file mode 100644 index 0000000000..34107f376a --- /dev/null +++ b/plugin-examples/composer-controls/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "composer-controls-example", + "requirements": { "paseo": ">=0.8.0" } +} diff --git a/plugin-examples/new-workspace-issues/README.md b/plugin-examples/new-workspace-issues/README.md new file mode 100644 index 0000000000..3ad55f49ca --- /dev/null +++ b/plugin-examples/new-workspace-issues/README.md @@ -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`. diff --git a/plugin-examples/new-workspace-issues/client/issue-field.tsx b/plugin-examples/new-workspace-issues/client/issue-field.tsx new file mode 100644 index 0000000000..3786a1128f --- /dev/null +++ b/plugin-examples/new-workspace-issues/client/issue-field.tsx @@ -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 ( + + ); +} diff --git a/plugin-examples/new-workspace-issues/index.client.tsx b/plugin-examples/new-workspace-issues/index.client.tsx new file mode 100644 index 0000000000..1d1132923e --- /dev/null +++ b/plugin-examples/new-workspace-issues/index.client.tsx @@ -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 () => {}; +} diff --git a/plugin-examples/new-workspace-issues/index.server.ts b/plugin-examples/new-workspace-issues/index.server.ts new file mode 100644 index 0000000000..544ab0081c --- /dev/null +++ b/plugin-examples/new-workspace-issues/index.server.ts @@ -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 () => {}; +} diff --git a/plugin-examples/new-workspace-issues/paseo-plugin.json b/plugin-examples/new-workspace-issues/paseo-plugin.json new file mode 100644 index 0000000000..7a1bb2513d --- /dev/null +++ b/plugin-examples/new-workspace-issues/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "new-workspace-issues-example", + "requirements": { "paseo": ">=0.8.0" } +} diff --git a/plugin-examples/new-workspace-issues/server/issues.ts b/plugin-examples/new-workspace-issues/server/issues.ts new file mode 100644 index 0000000000..a89ea0e3e3 --- /dev/null +++ b/plugin-examples/new-workspace-issues/server/issues.ts @@ -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) { + const needle = query.trim().toLowerCase(); + return { + issues: ISSUES.filter((issue) => issue.label.toLowerCase().includes(needle)), + }; +} diff --git a/plugin-examples/new-workspace-issues/shared/issues.ts b/plugin-examples/new-workspace-issues/shared/issues.ts new file mode 100644 index 0000000000..3f04c5afff --- /dev/null +++ b/plugin-examples/new-workspace-issues/shared/issues.ts @@ -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; + +export const searchIssuesRpc = defineRpc({ + name: "issues.search", + input: z.object({ query: z.string() }), + output: z.object({ issues: z.array(IssueSchema) }), +}); diff --git a/plugin-examples/provider-direct/README.md b/plugin-examples/provider-direct/README.md index 8bd5921330..dd360ac159 100644 --- a/plugin-examples/provider-direct/README.md +++ b/plugin-examples/provider-direct/README.md @@ -1,7 +1,8 @@ # Direct provider example This plugin implements `ProviderRegistration` directly. It demonstrates connection negotiation, -catalog and session listing, session creation, prompt and command disposition, settings, +catalog and session listing, session creation, prompt and command disposition, option controls +with icons (`controls` and `providerOptions`), persistence and replay, archive actions, and an ordinary child session. The provider emits versioned `provider-result` timeline items for its root and child sessions. diff --git a/plugin-examples/provider-direct/server/provider.test.ts b/plugin-examples/provider-direct/server/provider.test.ts index db7282df86..3fbbbdb35e 100644 --- a/plugin-examples/provider-direct/server/provider.test.ts +++ b/plugin-examples/provider-direct/server/provider.test.ts @@ -40,7 +40,7 @@ describe("direct provider example", () => { await connection.close(); }); - it("demonstrates settings, commands, persistence, and provider-rendered timeline items", async () => { + it("demonstrates option controls, commands, persistence, and provider-rendered timeline items", async () => { const connection = await createDirectExampleProvider().connect({ versions: [1], capabilities: [ @@ -65,8 +65,8 @@ describe("direct provider example", () => { mcpServers: {}, model: "example-1", mode: "build", - settings: { concise: true, voice: "calm" }, - providerOptions: { privateValue: 1 }, + settings: {}, + providerOptions: { concise: true, voice: "calm", privateValue: 1 }, persist: true, }, history: "skip", @@ -75,10 +75,11 @@ describe("direct provider example", () => { expect.objectContaining({ type: "session.config", config: expect.objectContaining({ - settings: expect.arrayContaining([ - expect.objectContaining({ id: "concise", value: true }), - expect.objectContaining({ id: "voice", value: "calm" }), - ]), + controls: [ + expect.objectContaining({ key: "concise", icon: "Zap" }), + expect.objectContaining({ key: "voice", icon: "Mic", defaultValue: "calm" }), + ], + providerOptions: { concise: true, voice: "calm" }, }), }), ); @@ -88,7 +89,29 @@ describe("direct provider example", () => { type: "session.configure", requestId: "configure-complete", sessionId: "session-complete", - changes: { settings: { voice: "direct" } }, + changes: { providerOptions: { voice: "direct", concise: null } }, + }); + expect(events.findLast((event) => event.type === "session.config")).toMatchObject({ + config: { providerOptions: { voice: "direct" } }, + }); + await connection.send({ + type: "session.configure", + requestId: "configure-refused", + sessionId: "session-complete", + changes: { model: "other-model", providerOptions: { voice: "loud" } }, + }); + expect(events).toContainEqual( + expect.objectContaining({ type: "request.failed", requestId: "configure-refused" }), + ); + // A refused request changes nothing, the model it named included. + await connection.send({ + type: "session.configure", + requestId: "configure-after-refused", + sessionId: "session-complete", + changes: {}, + }); + expect(events.findLast((event) => event.type === "session.config")).not.toMatchObject({ + config: { model: "other-model" }, }); await connection.send({ type: "session.prompt", diff --git a/plugin-examples/provider-direct/server/provider.ts b/plugin-examples/provider-direct/server/provider.ts index 31ea177dd6..d4c83bf3a1 100644 --- a/plugin-examples/provider-direct/server/provider.ts +++ b/plugin-examples/provider-direct/server/provider.ts @@ -1,7 +1,9 @@ import { negotiateProviderCapabilities, requireProviderCapabilities, + type ProviderConfigChanges, type ProviderConfigState, + type ProviderOptionControl, type ProviderConnection, type ProviderEvent, type ProviderInput, @@ -25,8 +27,29 @@ const CAPABILITIES = [ "timeline.plugin", ] as const; +const VOICES = [ + { value: "calm", label: "Calm" }, + { value: "direct", label: "Direct" }, +]; + +// Which option keys get a composer control. The control holds no value: Paseo reads it from +// `providerOptions`, in a draft as well as on a running session. +const CONTROLS: ProviderOptionControl[] = [ + { type: "toggle", key: "concise", label: "Concise responses", icon: "Zap" }, + { + type: "select", + key: "voice", + label: "Voice", + icon: "Mic", + defaultValue: "calm", + choices: VOICES, + }, +]; + interface ExampleSession { config: ProviderSessionConfig; + /** What the session runs with. A key with no value runs its default. */ + options: { concise?: boolean; voice?: string }; persistence: ProviderPersistence; turn: number; activeTurnId: string | null; @@ -175,8 +198,13 @@ function openSession( version: 1, data: { nativeSessionId: `example:${input.sessionId}` }, }; + const { concise, voice } = input.config.providerOptions ?? {}; const session: ExampleSession = { config: input.config, + options: { + ...(typeof concise === "boolean" ? { concise } : {}), + ...(VOICES.some((candidate) => candidate.value === voice) ? { voice: voice as string } : {}), + }, persistence, turn: 0, activeTurnId: null, @@ -344,6 +372,19 @@ function configureSession( ): void { const session = requireSession(state, input.sessionId); const changes = input.changes; + // Work out everything first, so a refused request changes nothing. + let options: ExampleSession["options"]; + try { + options = applyOptions(session.options, changes.providerOptions ?? {}); + } catch (error) { + state.emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: error instanceof Error ? error.message : String(error) }, + }); + return; + } + session.options = options; session.config = { ...session.config, model: changes.model === null ? undefined : (changes.model ?? session.config.model), @@ -352,14 +393,31 @@ function configureSession( changes.thinkingOption === null ? undefined : (changes.thinkingOption ?? session.config.thinkingOption), - settings: changes.settings - ? { ...session.config.settings, ...changes.settings } - : session.config.settings, }; state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) }); state.emit({ type: "request.completed", requestId: input.requestId }); } +/** Apply the whole patch or throw. `null` returns a key to its default. */ +function applyOptions( + options: ExampleSession["options"], + patch: NonNullable, +): ExampleSession["options"] { + const next = { ...options }; + for (const [key, value] of Object.entries(patch)) { + if (key === "concise" && (value === null || typeof value === "boolean")) { + if (value === null) delete next.concise; + else next.concise = value; + } else if (key === "voice" && (value === null || VOICES.some((v) => v.value === value))) { + if (value === null) delete next.voice; + else next.voice = value as string; + } else { + throw new Error(`Cannot set ${key} to ${JSON.stringify(value)}`); + } + } + return next; +} + function configState(session: ExampleSession): ProviderConfigState { return { model: session.config.model ?? "example-1", @@ -368,25 +426,9 @@ function configState(session: ExampleSession): ProviderConfigState { models: [{ id: "example-1", label: "Example 1" }], modes: [{ id: "build", label: "Build" }], thinkingOptions: [], - settings: [ - { - type: "toggle", - id: "concise", - label: "Concise responses", - value: session.config.settings.concise === true, - }, - { - type: "select", - id: "voice", - label: "Voice", - value: - typeof session.config.settings.voice === "string" ? session.config.settings.voice : null, - options: [ - { label: "Calm", value: "calm" }, - { label: "Direct", value: "direct" }, - ], - }, - ], + controls: CONTROLS, + // What the session ended up with, so Paseo stores what runs. + providerOptions: { ...session.options }, }; } diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md index e00f69d83e..a75600f942 100644 --- a/public-docs/plugins/hooks.md +++ b/public-docs/plugins/hooks.md @@ -49,22 +49,22 @@ Paseo only sees the returned promise, so detached work gets no error reporting a ## Hooks -| Hook | Waits | Payload | Handler may change | -| ------------------------- | ----- | -------------------------------------- | ---------------------------------------------- | -| `workspace.create.before` | yes | `{ request }` | Entire create request | -| `workspace.create.after` | no | `{ workspace }` | | -| `workspace.archive.after` | no | `{ workspace }` | | -| `worktree.create.before` | yes | `{ request }` | Creation options and destination | -| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation | -| `agent.create.before` | yes | `{ request: { config, env? } }` | Saved config except `cwd`; `env` | -| `agent.create.after` | no | `{ agent }` | | -| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` | -| `agent.close.after` | no | `{ agent }` | | -| `agent.archive.after` | no | `{ agent, archivedAt }` | | -| `agent.turn.before` | no | `{ agent, turnId }` | | -| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | | -| `agent.permission.before` | no | `{ agent, request }` | | -| `agent.permission.after` | no | `{ agent, requestId, resolution }` | | +| Hook | Waits | Payload | Handler may change | +| ------------------------- | ----- | ----------------------------------------- | ---------------------------------------------- | +| `workspace.create.before` | yes | `{ request }` | Entire create request | +| `workspace.create.after` | no | `{ workspace }` | | +| `workspace.archive.after` | no | `{ workspace }` | | +| `worktree.create.before` | yes | `{ request }` | Creation options and destination | +| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation | +| `agent.create.before` | yes | `{ request: { config, env?, metadata } }` | Saved config except `cwd`; `env`; `metadata` | +| `agent.create.after` | no | `{ agent }` | | +| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` | +| `agent.close.after` | no | `{ agent }` | | +| `agent.archive.after` | no | `{ agent, archivedAt }` | | +| `agent.turn.before` | no | `{ agent, turnId }` | | +| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | | +| `agent.permission.before` | no | `{ agent, request }` | | +| `agent.permission.after` | no | `{ agent, requestId, resolution }` | | ## Shared payloads @@ -76,6 +76,7 @@ interface PluginHookAgent { provider: string; cwd: string; title: string | null; + metadata: Record; } interface PluginHookWorkspace { @@ -84,6 +85,7 @@ interface PluginHookWorkspace { cwd: string; name: string | null; archivedAt: string | null; + metadata: Record; } type PluginTurnOutcome = @@ -100,9 +102,29 @@ type PluginTurnOutcome = | `resolution` | SDK `AgentPermissionResponse` | | `archivedAt` | Timestamp string | +## Metadata + +Agents and workspaces carry a `metadata` record for plugin data. Keys are strings, values are any JSON value, and Paseo stores the record without reading a key. Prefix keys with your plugin id so plugins do not collide. Labels stay strings for filtering and tagging; use metadata for structured data. + +Set metadata when you create the agent or workspace, or change it later. `null` removes a key, and any other value replaces the key's whole value: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.5" }, + cwd: "/projects/shop", + metadata: { "linear.issue": { id: "ENG-123" } }, +}); + +agent.metadata; // { "linear.issue": { id: "ENG-123" } } +await agent.setMetadata({ "linear.issue": null }); +await paseo.workspaces.ref(workspaceId).setMetadata({ "linear.issue": { id: "ENG-123" } }); +``` + +Metadata survives a daemon restart. Every hook payload carries it: `workspace.create.before` and `agent.create.before` receive the metadata the caller sent, and the `after` hooks and `agent.open.before` receive what is stored. Against a host that predates metadata, these calls throw "Update the host to use metadata." + ## workspace.create.before -Receives `{ request }` with `source`, optional `title`, and `firstAgentContext`. You may replace the entire explicit create request; a throw, invalid result, or timeout fails creation. Existing workspaces and directory lookup/import are unaffected. +Receives `{ request }` with `source`, optional `title`, optional `metadata`, and `firstAgentContext`. You may replace the entire explicit create request; a throw, invalid result, or timeout fails creation. Existing workspaces and directory lookup/import are unaffected. ### Choose workspace isolation @@ -202,18 +224,18 @@ server.addHook("worktree.create.after", async ({ worktree }) => { ## agent.create.before -Receives `{ request: { config, env? } }`. Config changes are saved and remain after the plugin is disabled or removed; `env` applies to the initial launch and is not saved. You cannot change `config.cwd`. A throw, invalid result, or timeout fails creation. +Receives `{ request: { config, env?, metadata } }`. Config and `metadata` changes are saved and remain after the plugin is disabled or removed; `env` applies to the initial launch and is not saved. You cannot change `config.cwd`. A throw, invalid result, or timeout fails creation. **`agent.create.before.config`** uses `AgentSessionConfig`: -| Fields | Constraint | -| --------------------------------------------- | -------------------------------------------------------------------------- | -| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options | -| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections | -| `title`, `systemPrompt` | Agent configuration | -| `providerOptions` | Opaque provider-specific options | -| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | -| `cwd` | Cannot change | +| Fields | Constraint | +| ---------------------------- | -------------------------------------------------------------------------- | +| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options | +| `modeId`, `thinkingOptionId` | Provider-specific selections | +| `title`, `systemPrompt` | Agent configuration | +| `providerOptions` | Provider options, including speed and auto-accept selections | +| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | +| `cwd` | Cannot change | ### Change saved configuration @@ -261,6 +283,17 @@ server.addHook("agent.create.after", async ({ agent }) => { Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for this launch. All other fields are read-only. A throw, invalid result, or timeout fails the open. +Do not await a write to the agent being opened: `setMetadata`, `setLabels`, or an option, model, thinking or mode change. The write waits for the open to finish, the open waits for your hook, so the hook times out and the open fails. Start the write without awaiting it; it runs right after the open: + +```ts +server.addHook("agent.open.before", async ({ request }, { paseo }) => { + void paseo.agents + .ref(request.agentId) + .setMetadata({ "my.plugin": { openedBy: "hook" } }) + .catch((error) => console.error(error)); +}); +``` + **`agent.open.before` request example:** ```json @@ -271,6 +304,7 @@ Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for th "cwd": "/projects/shop", "title": "Workspace notes", "labels": { "notes/enabled": "true" }, + "metadata": { "linear.issue": { "id": "ENG-123" } }, "tools": [], "mcpServers": {}, "reason": "resume", @@ -284,6 +318,7 @@ Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for th | `workspaceId` | String or `null` | | `title` | Read-only string or `null`; creation title on create, stored title on resume. | | `labels` | Read-only string map; creation labels on create, stored labels on resume. | +| `metadata` | Read-only JSON map; the [metadata](#metadata) stored on the agent. | | `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). | | `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). | | `reason` | `create`, `resume`, `refresh`, `import` | diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md index 7329675523..adc8986632 100644 --- a/public-docs/plugins/hooks.zh-CN.md +++ b/public-docs/plugins/hooks.zh-CN.md @@ -47,22 +47,22 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没 ## 钩子一览 {#hooks} -| 钩子 | 等待 | 载荷 | 可修改 | -| ------------------------- | ---- | -------------------------------------- | ----------------------------------------- | -| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 | -| `workspace.create.after` | 否 | `{ workspace }` | | -| `workspace.archive.after` | 否 | `{ workspace }` | | -| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 | -| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 | -| `agent.create.before` | 是 | `{ request: { config, env? } }` | 除 `cwd` 外的已保存配置;`env` | -| `agent.create.after` | 否 | `{ agent }` | | -| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` | -| `agent.close.after` | 否 | `{ agent }` | | -| `agent.archive.after` | 否 | `{ agent, archivedAt }` | | -| `agent.turn.before` | 否 | `{ agent, turnId }` | | -| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | | -| `agent.permission.before` | 否 | `{ agent, request }` | | -| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | | +| 钩子 | 等待 | 载荷 | 可修改 | +| ------------------------- | ---- | ----------------------------------------- | ------------------------------------------ | +| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 | +| `workspace.create.after` | 否 | `{ workspace }` | | +| `workspace.archive.after` | 否 | `{ workspace }` | | +| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 | +| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 | +| `agent.create.before` | 是 | `{ request: { config, env?, metadata } }` | 除 `cwd` 外的已保存配置;`env`;`metadata` | +| `agent.create.after` | 否 | `{ agent }` | | +| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` | +| `agent.close.after` | 否 | `{ agent }` | | +| `agent.archive.after` | 否 | `{ agent, archivedAt }` | | +| `agent.turn.before` | 否 | `{ agent, turnId }` | | +| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | | +| `agent.permission.before` | 否 | `{ agent, request }` | | +| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | | ## 共享载荷 {#shared-payloads} @@ -74,6 +74,7 @@ interface PluginHookAgent { provider: string; cwd: string; title: string | null; + metadata: Record; } interface PluginHookWorkspace { @@ -82,6 +83,7 @@ interface PluginHookWorkspace { cwd: string; name: string | null; archivedAt: string | null; + metadata: Record; } type PluginTurnOutcome = @@ -98,9 +100,29 @@ type PluginTurnOutcome = | `resolution` | SDK 的 `AgentPermissionResponse` | | `archivedAt` | 时间戳字符串 | +## 元数据 {#metadata} + +Agent 和工作区带有 `metadata` 记录,用于保存插件数据。键是字符串,值可以是任意 JSON 值,Paseo 只保存、不读取任何键。给键加上插件 ID 前缀,避免不同插件冲突。标签仍是用于筛选和标记的字符串;结构化数据请用元数据。 + +可以在创建 Agent 或工作区时设置元数据,也可以之后修改。`null` 删除该键,其他值会替换该键的整个值: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.5" }, + cwd: "/projects/shop", + metadata: { "linear.issue": { id: "ENG-123" } }, +}); + +agent.metadata; // { "linear.issue": { id: "ENG-123" } } +await agent.setMetadata({ "linear.issue": null }); +await paseo.workspaces.ref(workspaceId).setMetadata({ "linear.issue": { id: "ENG-123" } }); +``` + +元数据在 daemon 重启后仍然保留。所有钩子载荷都带有元数据:`workspace.create.before` 和 `agent.create.before` 收到调用方发送的元数据,`after` 钩子和 `agent.open.before` 收到已保存的元数据。主机版本过旧时,这些调用会抛出 “Update the host to use metadata.”。 + ## workspace.create.before {#workspacecreatebefore} -收到 `{ request }`,其中包含 `source`、可选的 `title` 和 `firstAgentContext`。可替换整个显式创建请求;抛出错误、无效返回或超时会让创建失败。已有工作区和目录查找、导入不受影响。 +收到 `{ request }`,其中包含 `source`、可选的 `title`、可选的 `metadata` 和 `firstAgentContext`。可替换整个显式创建请求;抛出错误、无效返回或超时会让创建失败。已有工作区和目录查找、导入不受影响。 ### 选择工作区隔离方式 {#choose-workspace-isolation} @@ -199,18 +221,18 @@ server.addHook("worktree.create.after", async ({ worktree }) => { ## agent.create.before {#agentcreatebefore} -收到 `{ request: { config, env? } }`。配置修改会保存,即使插件以后被停用或移除;`env` 只用于首次启动,不会保存。不能修改 `config.cwd`。抛出错误、无效返回或超时会让创建失败。 +收到 `{ request: { config, env?, metadata } }`。配置和 `metadata` 的修改会保存,即使插件以后被停用或移除;`env` 只用于首次启动,不会保存。不能修改 `config.cwd`。抛出错误、无效返回或超时会让创建失败。 **`agent.create.before.config`** 使用 `AgentSessionConfig`: -| 字段 | 约束 | -| --------------------------------------------- | -------------------------------------------------------- | -| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 | -| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 | -| `title`, `systemPrompt` | Agent 配置 | -| `providerOptions` | 不透明的、特定于 Provider 的选项 | -| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 | -| `cwd` | 不能修改 | +| 字段 | 约束 | +| ---------------------------- | -------------------------------------------------------- | +| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 | +| `modeId`, `thinkingOptionId` | 特定于 Provider 的选择 | +| `title`, `systemPrompt` | Agent 配置 | +| `providerOptions` | Provider 选项,包括速度和自动批准的选择 | +| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 | +| `cwd` | 不能修改 | ### 修改已保存的配置 {#change-saved-configuration} @@ -257,6 +279,17 @@ server.addHook("agent.create.after", async ({ agent }) => { 收到 `{ request }`,只能修改 `env`、`tools` 和 `mcpServers`,修改只用于此次启动。其他字段只读。抛出错误、无效返回或超时会让打开操作失败。 +不要 await 对正在打开的 Agent 的写入,例如 `setMetadata`。该写入要等打开完成,而打开在等你的钩子,结果钩子超时,打开失败。启动写入但不要 await,它会在打开完成后立即执行: + +```ts +server.addHook("agent.open.before", async ({ request }, { paseo }) => { + void paseo.agents + .ref(request.agentId) + .setMetadata({ "my.plugin": { openedBy: "hook" } }) + .catch((error) => console.error(error)); +}); +``` + **`agent.open.before` 请求示例:** ```json @@ -267,6 +300,7 @@ server.addHook("agent.create.after", async ({ agent }) => { "cwd": "/projects/shop", "title": "Workspace notes", "labels": { "notes/enabled": "true" }, + "metadata": { "linear.issue": { "id": "ENG-123" } }, "tools": [], "mcpServers": {}, "reason": "resume", @@ -280,6 +314,7 @@ server.addHook("agent.create.after", async ({ agent }) => { | `workspaceId` | 字符串或 `null` | | `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | | `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | +| `metadata` | 只读的 JSON 映射;Agent 上已保存的[元数据](#metadata)。 | | `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | | `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 | | `reason` | `create`、`resume`、`refresh`、`import` | diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index ce6b549bf9..d4d12cc238 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -14,6 +14,8 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add [file panels](/docs/plugins/reference#file-panels), [Command Center items](/docs/plugins/reference#command-center-items), [slash commands](/docs/plugins/reference#slash-commands), +[composer items](/docs/plugins/reference#composer-items), +[New workspace items](/docs/plugins/reference#new-workspace-items), [composer pills](/docs/plugins/reference#composer-pills), [timeline items](/docs/plugins/reference#timeline-items), [themes](/docs/plugins/reference#contribute-a-theme), diff --git a/public-docs/plugins/index.zh-CN.md b/public-docs/plugins/index.zh-CN.md index 2f6c1bccff..d1367dac47 100644 --- a/public-docs/plugins/index.zh-CN.md +++ b/public-docs/plugins/index.zh-CN.md @@ -6,7 +6,7 @@ nav: 快速上手 # 插件快速上手 {#plugin-quickstart} -插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。 +插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 项](/docs/plugins/reference#composer-items)、[新建工作区项](/docs/plugins/reference#new-workspace-items)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。 浏览[已发布的插件](https://paseo.sh/plugins),然后在你的 daemon 上安装一个: diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index 0293e3fb0d..836a8e57b6 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -81,6 +81,8 @@ Use this table as the complete registration checklist. | `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | | `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | | `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function | +| New composer item contribution | `client.addComposerItem(item)` in `index.client.tsx`; see [composer items](reference#composer-items) | +| New New workspace item contribution | `client.addNewWorkspaceItem(item)` in `index.client.tsx`; see [New workspace items](reference#new-workspace-items) | | New header contribution | `client.addHeaderButton({ id, workspaceId, button })` | | `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` | | New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) | @@ -185,6 +187,26 @@ For paced timeline text, replace `useRevealedText(text, phase)` and the surround `` from `/client/ui`. Use `SettingsSection` for plugin settings headings; `SettingsGroup` is deprecated. +### Provider options in v0.15 + +A provider plugin has one configuration record, `providerOptions`, and describes which keys get a +composer control. A provider that still reports `settings` keeps working through v0.15: Paseo reads each +setting `id` as an option key and each `value` as that option, and sends changes in both +`changes.providerOptions` and `changes.settings`. Remove `settings` before the shim is deleted after +2027-05-01. + +| Previous API | Replacement | +| ---------------------------------------------- | -------------------------------------------------------------------------- | +| `session.config` `settings: ProviderSetting[]` | `controls: ProviderOptionControl[]` plus `providerOptions` | +| Setting `id` | Control `key` | +| Setting `value` | `providerOptions[key]`; the control holds no value | +| Select setting `options: { label, value }[]` | Select control `choices: { value, label, description? }[]` | +| Setting with no icon or default | Control `icon` (a Lucide name) and `defaultValue` | +| `session.configure` `changes.settings` | `changes.providerOptions`; `null` returns a key to its default | +| `session.open` `config.settings` | `config.providerOptions` | +| `ProviderSetting` type | `ProviderOptionControl` | +| `agent.create.before` `config.featureValues` | `config.providerOptions`; a returned `featureValues` edit is still applied | + ## 5. Recognize half-migration errors | Compiler or load error | Meaning and fix | diff --git a/public-docs/plugins/migration.zh-CN.md b/public-docs/plugins/migration.zh-CN.md index 600b65f4d9..216f2437b9 100644 --- a/public-docs/plugins/migration.zh-CN.md +++ b/public-docs/plugins/migration.zh-CN.md @@ -73,6 +73,8 @@ my-plugin/ | 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` | | 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 | | 旧客户端回调中的 `client.addComposerPill(pill)` | `index.client.tsx` 或被导入的 `client/` 函数中的 `client.addComposerPill(pill)` | +| 新的编辑器项贡献 | `index.client.tsx` 中的 `client.addComposerItem(item)`;见[编辑器项](reference#composer-items) | +| 新的“新建工作区”项贡献 | `index.client.tsx` 中的 `client.addNewWorkspaceItem(item)`;见[新建工作区项](reference#new-workspace-items) | | 新的标题栏贡献 | `client.addHeaderButton({ id, workspaceId, button })` | | 旧根入口中的 `plugin.addAttachmentSource(source)` | `index.client.tsx` 中的 `client.addAttachmentSource(source)` | | 新的设置界面贡献 | `index.client.tsx` 中的 `client.addSettingsScreen(screen)`;见[设置界面](reference#settings-screens) | @@ -152,6 +154,22 @@ pill.remove(); 对于逐步显示的时间线文本,把 `useRevealedText(text, phase)` 及其外层的 `Text` 换成 `/client/ui` 中的 ``。插件设置的标题使用 `SettingsSection`;`SettingsGroup` 已弃用。 +### v0.15 中的 Provider 选项 {#provider-options-in-v015} + +Provider 插件只有一个配置记录 `providerOptions`,并用控件描述哪些键显示在 Composer 中。仍然报告 `settings` 的 Provider 在 v0.15 中继续可用:Paseo 把每个 setting 的 `id` 当作选项键,把 `value` 当作该选项的值,并同时在 `changes.providerOptions` 和 `changes.settings` 中发送修改。请在 2027-05-01 之后兼容层被删除之前移除 `settings`。 + +| 之前的 API | 替代方式 | +| ------------------------------------------------- | --------------------------------------------------------------- | +| `session.config` 的 `settings: ProviderSetting[]` | `controls: ProviderOptionControl[]` 加 `providerOptions` | +| Setting 的 `id` | 控件的 `key` | +| Setting 的 `value` | `providerOptions[key]`;控件本身不保存值 | +| 选择类 setting 的 `options: { label, value }[]` | 选择类控件的 `choices: { value, label, description? }[]` | +| 没有图标或默认值的 setting | 控件的 `icon`(Lucide 名称)和 `defaultValue` | +| `session.configure` 的 `changes.settings` | `changes.providerOptions`;`null` 表示恢复该键的默认值 | +| `session.open` 的 `config.settings` | `config.providerOptions` | +| `ProviderSetting` 类型 | `ProviderOptionControl` | +| `agent.create.before` 的 `config.featureValues` | `config.providerOptions`;返回的 `featureValues` 修改仍会被应用 | + ## 5. 识别只迁移了一半的错误 {#5-recognize-half-migration-errors} | 编译器或加载错误 | 含义与修复方法 | diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 9a87dfb155..5495405bfd 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -22,7 +22,7 @@ Choose one implementation path: The examples are: -- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, settings, prompts, persistence, child sessions, and a provider-owned timeline renderer; +- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, option controls, prompts, persistence, child sessions, and a provider-owned timeline renderer; - [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command template with a Zod-validated vendor edit transformer. Replace `example-acp --stdio` with an installed ACP agent before loading it; - [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking): a renderer-only plugin that does not implement a provider. @@ -138,7 +138,7 @@ later through `session.config`. ## Open a session `session.open` contains the complete launch configuration: working directory, environment, system -prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence +prompt, MCP servers, tool policy, model, mode, `providerOptions`, and persistence preference. For persisted host-scoped plugin settings, use the handle returned by `server.registerSettings()`. @@ -177,7 +177,8 @@ emit({ models, modes, thinkingOptions, - settings: [], + controls: [], + providerOptions: {}, }, }); emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); @@ -229,10 +230,16 @@ terminal `completed`, `failed`, or `canceled` event. At this point the provider is usable: users can select a model, open a session, send a message, and see the response. -## Add session-specific composer controls +## Add composer controls for provider options -Add `session.configure` only when the native agent has settings users should change after opening a -session. Publish them in `session.config` as toggle or select controls: +`providerOptions` is the provider's own configuration: one record of JSON values that Paseo +stores per agent and hands to `session.open`. It includes the defaults a user set in `config.json` +for your provider. Validate every key you read. + +Add `controls` only when users should see and change some of those options in the composer. A +control draws one top-level key of `providerOptions` as a toggle or a select. It holds no value: +Paseo reads the value from `providerOptions`. Publish the controls and the values you run with in +`session.config`: ```ts emit({ @@ -243,35 +250,48 @@ emit({ models: [{ id: "agent-large", label: "Agent Large" }], modes: [], thinkingOptions: [], - settings: [ - { - type: "toggle", - id: "fast", - label: "Fast mode", - value: nativeSession.fast, - }, + controls: [ + { type: "toggle", key: "fast", label: "Fast mode", icon: "Zap", defaultValue: false }, { type: "select", - id: "approval", + key: "approval", label: "Approvals", - value: nativeSession.approval, - options: [ - { label: "Ask", value: "ask" }, - { label: "Auto accept", value: "auto" }, + icon: "Shield", + defaultValue: "ask", + choices: [ + { value: "ask", label: "Ask" }, + { value: "auto", label: "Auto accept" }, ], }, ], + providerOptions: { fast: nativeSession.fast, approval: nativeSession.approval }, }, }); ``` -Paseo renders these controls in the composer. A user change arrives as `session.configure`; apply it -and publish the committed `session.config` before `request.completed`. The provider may change the -available controls at runtime, including after a model change. +`icon` is a Lucide icon name. `defaultValue` is what a control shows for a key that has no value. +Paseo stores every value you report when a session opens or a change is applied, including values +at the default and values nobody set. The same controls appear in the New workspace screen, in draft +tabs, and on running agents. Offer a control only for a key you can change on a running session. +The provider may change its controls at runtime, including after a model change. -`settings` are user-facing controls. `providerOptions` is opaque JSON supplied at session creation -for provider-specific configuration that Paseo does not render. A provider-specific token budget -belongs in one of those two places; it is not a dedicated prompt field. +A change arrives as `session.configure` with `changes.providerOptions` holding only the keys +that changed, whether or not they have a control. Apply every key and publish the committed +`session.config` before `request.completed`. Fail the request with `request.failed` and leave +every key as it was when you cannot apply one, for example a key you read only when a session +opens. A +value of `null` returns that key to the provider's default. Publish what the session ended up +with, not a copy of the request: Paseo stores your published values so the stored options match +what runs. + +If the provider changes an option on its own, for example a model switch turns Fast off, publish +a new `session.config` with the new values. Paseo stores them and does not set the old ones +back. + +Not every option needs a control. A provider-specific token budget, for example, belongs in +`providerOptions` and is not a dedicated prompt field. It is delivered in `session.open`, a caller +can change it on a running agent, and your `session.configure` handler decides whether it can +apply it live. Report it in `providerOptions` so Paseo stores what runs. ## Add persistence and replay @@ -467,7 +487,7 @@ export const vendorEdits: AcpTransformer = { Test the provider against the real agent, not only mocked frames: 1. install the plugin as a directory source; -2. confirm its SVG, models, modes, and settings appear; +2. confirm its SVG, models, modes, and option controls appear; 3. create a session and complete a real prompt; 4. exercise permissions, steering, cancellation, persistence, and replay when supported; 5. render every custom timeline item on desktop and mobile-width layouts; diff --git a/public-docs/plugins/providers.zh-CN.md b/public-docs/plugins/providers.zh-CN.md index 6cc0a2cb14..e917ba0538 100644 --- a/public-docs/plugins/providers.zh-CN.md +++ b/public-docs/plugins/providers.zh-CN.md @@ -19,7 +19,7 @@ Provider 插件把一个编程 Agent 接入 Paseo,而无需把它加入 Paseo 示例有: -- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct):会话、设置、提示词、持久化、子会话,以及一个由 Provider 拥有的时间线渲染器; +- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct):会话、选项控件、提示词、持久化、子会话,以及一个由 Provider 拥有的时间线渲染器; - [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer):一个 ACP 命令模板,带有一个用 Zod 校验的厂商编辑转换器。加载之前,请把 `example-acp --stdio` 替换为一个已安装的 ACP Agent; - [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking):一个只包含渲染器、不实现 Provider 的插件。 @@ -125,7 +125,7 @@ if (input.type === "catalog") { ## 打开会话 {#open-a-session} -`session.open` 包含完整的启动配置:工作目录、环境变量、系统提示词、MCP 服务器、工具策略、模型、模式、设置、不透明的 Provider 选项以及持久化偏好。 +`session.open` 包含完整的启动配置:工作目录、环境变量、系统提示词、MCP 服务器、工具策略、模型、模式、`providerOptions` 以及持久化偏好。 对于持久化的、作用于主机的插件设置,请使用 `server.registerSettings()` 返回的句柄。在目录和会话回调中读取它;当 Provider 在内存中保留配置时,订阅它。`read()` 和 `subscribe()` 的契约见[设置](./reference.md#persisted-values)。 @@ -160,7 +160,8 @@ emit({ models, modes, thinkingOptions, - settings: [], + controls: [], + providerOptions: {}, }, }); emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); @@ -207,9 +208,11 @@ emit({ type: "session.turn", sessionId, turnId, state: "completed" }); 到这一步,Provider 已经可用:用户可以选择模型、打开会话、发送消息并看到回复。 -## 添加会话专属的 Composer 控件 {#add-session-specific-composer-controls} +## 为 Provider 选项添加 Composer 控件 {#add-composer-controls-for-provider-options} -只有当原生 Agent 有用户在打开会话后需要修改的设置时,才添加 `session.configure`。在 `session.config` 中以开关或选择控件的形式发布它们: +`providerOptions` 是 Provider 自己的配置:一个由 JSON 值组成的记录,Paseo 按 Agent 保存它,并在 `session.open` 中交给你。它包含用户在 `config.json` 中为你的 Provider 设置的默认值。请校验你读取的每个键。 + +只有当用户需要在 Composer 中查看并修改其中一部分选项时,才添加 `controls`。一个控件把 `providerOptions` 的一个顶层键绘制为开关或选择器。控件本身不保存值:Paseo 从 `providerOptions` 读取值。在 `session.config` 中发布控件,以及你正在使用的值: ```ts emit({ @@ -220,31 +223,32 @@ emit({ models: [{ id: "agent-large", label: "Agent Large" }], modes: [], thinkingOptions: [], - settings: [ - { - type: "toggle", - id: "fast", - label: "Fast mode", - value: nativeSession.fast, - }, + controls: [ + { type: "toggle", key: "fast", label: "Fast mode", icon: "Zap", defaultValue: false }, { type: "select", - id: "approval", + key: "approval", label: "Approvals", - value: nativeSession.approval, - options: [ - { label: "Ask", value: "ask" }, - { label: "Auto accept", value: "auto" }, + icon: "Shield", + defaultValue: "ask", + choices: [ + { value: "ask", label: "Ask" }, + { value: "auto", label: "Auto accept" }, ], }, ], + providerOptions: { fast: nativeSession.fast, approval: nativeSession.approval }, }, }); ``` -Paseo 会在 Composer 中渲染这些控件。用户的修改会以 `session.configure` 的形式到达;应用它,并在 `request.completed` 之前发布已确定的 `session.config`。Provider 可以在运行时改变可用的控件,包括在模型变化之后。 +`icon` 是 Lucide 图标名称。`defaultValue` 是控件在键没有值时显示的值。会话打开或应用修改时,你报告的每个值(包括处于默认值的值和无人设置过的值)Paseo 都会保存。相同的控件会出现在“新建工作区”界面、草稿标签页和运行中的 Agent 上。只为你能在运行中的会话里修改的键提供控件。Provider 可以在运行时改变控件,包括在模型变化之后。 -`settings` 是面向用户的控件。`providerOptions` 是在创建会话时提供的不透明 JSON,用于 Paseo 不会渲染的 Provider 专属配置。Provider 专属的 token 预算应放在这两处之一;它不是一个专门的提示词字段。 +修改会以 `session.configure` 的形式到达,`changes.providerOptions` 只包含发生变化的键,无论这些键是否有控件。应用每一个键,并在 `request.completed` 之前发布已确定的 `session.config`。如果无法应用其中某个键(例如只在会话打开时读取的键),用 `request.failed` 使请求失败,并让每个键保持原样。值为 `null` 表示把该键恢复为 Provider 的默认值。请发布会话最终实际使用的值,而不是请求的副本:Paseo 会保存你发布的值,使保存的选项与实际运行的一致。 + +如果 Provider 自行改变了某个选项(例如切换模型后关闭了 Fast),请发布带有新值的 `session.config`。Paseo 会保存它们,不会把旧值设置回去。 + +并非每个选项都需要控件。例如 Provider 专属的 token 预算应放在 `providerOptions` 中,它不是一个专门的提示词字段。它会在 `session.open` 中交付,调用方可以在运行中的 Agent 上修改它,由你的 `session.configure` 处理程序决定能否实时应用。请在 `providerOptions` 中报告它,使 Paseo 保存实际运行的值。 ## 添加持久化与重放 {#add-persistence-and-replay} @@ -405,7 +409,7 @@ export const vendorEdits: AcpTransformer = { 用真实的 Agent 测试 Provider,而不只是模拟的数据帧: 1. 以目录来源安装插件; -2. 确认它的 SVG、模型、模式和设置都会显示; +2. 确认它的 SVG、模型、模式和选项控件都会显示; 3. 创建会话并完成一段真实的提示词; 4. 在支持的情况下,试用权限、引导、取消、持久化和重放; 5. 在桌面和移动端宽度的布局中渲染每一种自定义时间线条目; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 1489372e8b..b79064ed15 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -409,7 +409,7 @@ process, credential, and other machine-local work under `server/`. A plugin with ### Providers Follow [Build a provider plugin](/docs/plugins/providers) for direct and ACP implementations, -session lifecycle, composer settings, timeline renderers, testing, and distribution. +session lifecycle, option controls, timeline renderers, testing, and distribution. Call `server.registerProvider()` with a `ProviderRegistration` from `@getpaseo/plugin/server/provider`. Its connection accepts inputs with `send()` and emits complete state @@ -420,10 +420,12 @@ Use the single `session.prompt` input for messages, structured commands, steerin effects. Repeat `clientMessageId` on the live user timeline item and publish exactly one matching `session.prompt_result`. Publish provider-created children as sessions with `parentSessionId`. -Provider settings are toggle/select descriptors that Paseo renders in the composer. Keep -provider-private options under `ProviderSessionConfig.providerOptions` on `session.open`. -This record includes configured defaults and per-agent overrides; validate and apply it in your -provider. See [Provider options](/docs/sdk/provider-options) for configuration and merge semantics. +Provider options are one record, `ProviderSessionConfig.providerOptions` on `session.open`. It +includes configured defaults and per-agent overrides; validate and apply it in your provider. Report +`controls` in `session.config` for the top-level keys that get a toggle or select in the composer, +with the values the session runs under `providerOptions`. A running-session change arrives as +`session.configure` with `changes.providerOptions`. See [Provider options](/docs/sdk/provider-options) +for configuration and merge semantics. Host tools arrive as MCP servers in the complete session config. @@ -1902,6 +1904,231 @@ header has no room for plugin buttons, so each one is a row of the workspace men icon and title; its menus and popovers open as pages of that sheet. Placement and overflow are host decisions. +## Composer items + +`client.addComposerItem({ id, Component })` puts a component in the composer: the agent composer, +the draft composer for a new agent, and the composer on the New workspace screen. `useComposer()` +reads and changes what the composer configures, and `Toggle`, `Select` and `Action` from +`@getpaseo/plugin/client/ui` draw the controls. + +```tsx +import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client"; +import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui"; + +function ReviewFlag() { + const composer = useComposer(); + return ( + + composer.setMetadata({ "review.needsReview": value ? true : null }) + } + /> + ); +} + +function Checklist() { + const composer = useComposer(); + return ( + composer.insertText("- [ ] Tests\n- [ ] Docs\n")} + /> + ); +} + +export default function contribute(client: PluginClientContext) { + client.addComposerItem({ id: "review-flag", Component: ReviewFlag }); + client.addComposerItem({ id: "checklist", Component: Checklist }); + return () => {}; +} +``` + +`useComposer()` returns the composer the item sits in: + +| Field or command | Meaning | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `context` | `"agent"` for a running agent, `"draft"` for a new agent in a workspace, `"new-workspace"` for the New workspace screen. | +| `prompt`, `setPrompt(text)`, `insertText(text)` | The prompt. `insertText` replaces the selection or inserts at the cursor. | +| `attachments`, `addAttachment`, `removeAttachment` | Items from [your own attachment sources](#add-a-composer-attachment-source). Attachments from other plugins and Paseo's own are not listed. | +| `agent.provider`, `model`, `mode`, `thinking` | What the agent runs with, or what the draft will create. | +| `agent.options` | The provider options in effect, defaults included. In a draft: the options the agent will be created with, including your changes. | +| `agent.metadata` | The agent's [metadata](/docs/plugins/hooks#metadata). In New workspace, the first agent's. | +| `setProvider`, `setModel`, `setMode`, `setThinking` | Return promises. `setProvider` resets the model like the provider picker. It rejects on `context: "agent"`, which keeps its provider, and for a provider the host does not offer. `setModel` and `setThinking` pass the value on as given; on a running agent the provider's refusal rejects the promise. | +| `setOptions(patch)`, `setMetadata(patch)` | Top-level keys replace; `null` removes the key. Metadata set in a draft is sent when the agent is created and cleared once the prompt is sent. | + +The same call does the same thing in every composer; a plugin does not choose a composer's +implementation. `setMetadata` on a running agent needs a host that supports metadata. On an older +host the promise rejects with "Update the host to use metadata." + +`Toggle` takes `value` and `onValueChange({ value })`. `Select` takes `value`, `options` (`{ value, label, +description? }`) and `onValueChange({ value })`. `Action` takes `onPress(event)`. All three take `title`, +`icon` (a [Lucide name, SVG or component](#icons)) and `disabled`. A callback may return a promise: +the control stays disabled until it settles, and Paseo shows a rejection to the user as an error +toast, the way it does for its own controls. Paseo owns where each control sits: inline in the +toolbar when the composer has room, and a row in the composer's overflow sheet when it does not, +which is always the case on a phone. A `Select` opens its list in place of the sheet row. A +`Component` that renders something other than these controls draws it where its item is placed; in +the overflow sheet that is the sheet. + +Each command acts on the composer's state when it runs, not on the render that created your +callback, so `await composer.setProvider("codex")` followed by `await composer.setModel("gpt")` +changes both. A command's promise resolves once the change has taken effect, and `composer.agent` +shows it on the next render. + +While the New workspace screen creates the workspace and its agent, the composer is locked. Your +controls render disabled, `setPrompt` and `insertText` throw, and the agent commands reject with +"The composer is locked while the agent is being created." `composer.agent.options` and +`composer.agent.metadata` are frozen copies; change them through `setOptions` and `setMetadata`. + +A composer item counts toward how much fits before Paseo knows what it renders. An item that +renders nothing can still leave an empty overflow sheet on a narrow composer, so always render a +control. + +The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls) +registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`, plus an +Explorer panel and a Command Center item that reach a composer by reference. + +### Reach a composer by reference + +A running agent has a composer whether or not its tab is open. `useComposers(selector)` in any plugin +component, and `composers(selector)` on every command context, return the same `PluginComposer` that +`useComposer()` gives an item, for an agent you name or for the focused one: + +```tsx +import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client"; + +function InsertChecklist() { + const [focused] = useComposers({ focused: true }); + return ( + focused?.insertText("- [ ] Tests\n")}> + Insert checklist + + ); +} + +export default function contribute(client: PluginClientContext) { + client.addWorkspacePanel({ + id: "tools", + title: "Tools", + icon: "ListChecks", + locations: ["explorer"], + Component: InsertChecklist, + }); + client.addCommandCenterItem({ + id: "mark-for-review", + title: "Mark for review", + icon: "Flag", + context: "agent", + async onSelect({ agent, composers }) { + for (const composer of composers({ agentId: agent.id })) { + await composer.setMetadata({ "review.needsReview": true }); + } + }, + }); + return () => {}; +} +``` + +| Selector | Result | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `{ agentId }` | The composer of that agent on this host. Empty for an agent that is unknown or archived. | +| `{ focused: true }` | The composer of the agent the Command Center hands to agent items: the focused agent tab of the workspace on screen. Empty when the screen is not a workspace, such as a plugin screen or New workspace, or when the focused tab is not an agent. It follows focus as the user changes tabs. | + +The result is always an array, empty or with one composer, so the same code reads either selector. +`useComposers` re-renders when a composer changes. A composer from `composers()` is the state at the +call; call `composers()` again for the next state. + +The commands are those of [`useComposer()`](#composer-items), with these differences for a composer +you reach from outside: + +- `insertText` inserts at the cursor while the agent's composer is on screen. While it is not, for + example when the user is in another workspace, it appends to the agent's saved prompt, and the text + is in the input when the user returns. `setPrompt` replaces the saved prompt the same way. +- A running agent's composer is never locked. +- `setProvider` rejects, as it does inside the agent's own composer. +- `setModel` and `setThinking` send the value to the provider unchecked. A value the provider refuses + rejects with the provider's message. `model` and `thinking` read what the model picker + shows at that moment: until the provider's catalog loads that is the id the agent reports or was + created with, which may be an alias, and once it loads the catalog's own id. +- Drafts and the New workspace screen are not addressable by reference. Only an item inside one + reaches its composer, through `useComposer()`. + +## New workspace items + +`client.addNewWorkspaceItem({ id, Component })` puts a field on the New workspace screen, beside +the project, host and branch pickers. `useNewWorkspace()` reads and changes what the screen will +create, and `Combobox`, `Select`, `Toggle` and `Action` from `@getpaseo/plugin/client/ui` draw the +fields. On desktop an item is a badge in the setup row; on a phone it is a full-width field. + +```tsx +import { useNewWorkspace, useRpc, type PluginClientContext } from "@getpaseo/plugin/client"; +import { Combobox } from "@getpaseo/plugin/client/ui"; +import { searchIssuesRpc } from "../shared/issues"; + +function IssueField() { + const newWorkspace = useNewWorkspace(); + const searchIssues = useRpc(searchIssuesRpc); + const issueId = newWorkspace.metadata["issues.id"]; + return ( + (await searchIssues({ query })).issues} + onSelect={async ({ item }) => { + await newWorkspace.setBranch(item.branch); + await newWorkspace.setMetadata({ "issues.id": item.id }); + }} + /> + ); +} + +export default function contribute(client: PluginClientContext) { + client.addNewWorkspaceItem({ id: "issue", Component: IssueField }); + return () => {}; +} +``` + +`useNewWorkspace()` returns the setup the item sits in: + +| Field or command | Meaning | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `host`, `project` | The user's choices, `{ id, label }` and `{ id, name } \| null`. Read-only: an item cannot switch host or project. | +| `isolation`, `setIsolation` | `"local"` or `"worktree"`. A project that cannot make worktrees reads `"local"`, and `setIsolation("worktree")` rejects. | +| `baseRef`, `setBaseRef` | What a new worktree starts from: `{ type: "branch", name }`, `{ type: "change-request", number }`, or `null` until the 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. `setBaseRef` takes any ref the Base picker offers, or a bare branch name, which Paseo resolves as it does anywhere else. It rejects while no project is chosen. | +| `branch`, `setBranch` | 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, and it names the new branch of any worktree that cuts one, from the default base or a chosen branch. It does not apply to a worktree that checks out an existing branch or change request. | +| `metadata`, `setMetadata` | The created workspace's [metadata](/docs/plugins/hooks#metadata), not the first agent's. Top-level keys replace; `null` removes a key. Rejects on a host that does not support metadata with "Update the host to use metadata." | + +Commands follow the [composer item](#composer-items) rules: each one acts on the screen's state +when it runs, so `await newWorkspace.setBranch(name)` followed by `newWorkspace.setMetadata(patch)` +changes both, and its promise resolves once the change has taken effect. A command works from your component's first effect, including a mount effect. Payloads are JSON values. +Your items are mounted for one project on one host. When the user changes either, Paseo remounts them and clears `branch` and `metadata`, so state and `Combobox` results for the previous project do not carry over; an item that sets up in a mount effect sets up again for the new project. A command called after its project or host has been left, such as the end of a lookup the user abandoned by switching, rejects with "The New workspace screen moved to another project or host; this item belongs to the previous one." and changes nothing; the items mounted for the new project are not affected. +`host`, `project`, `baseRef` and `metadata` are frozen. While the screen creates the workspace it is locked: your fields render +disabled and every command rejects with "The New workspace screen is locked while the workspace is +being created." A callback that returns a promise keeps the control disabled until it settles, and a +rejection reaches the user as an error toast. + +`Combobox` is a field with a searchable list whose results come from your code: + +| Prop | Meaning | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `value` | `{ id, label, description? } \| null`, what the field shows. It need not be among the search results, so it can show a choice made earlier. | +| `search` | `({ query }) => Promise`. Called with `""` when the list opens and again, debounced, as the user types. Each `Item` is `{ id, label, description? }` plus any fields you add. | +| `onSelect` | `({ item }) => void \| Promise`. `item` is the object `search` returned. | + +Paseo shows "Searching…" while a search runs, "Search failed" with the error message when it +rejects, and an empty state for no results. It keeps the previous results while a newer search runs +and ignores a search that a newer one replaced, so `search` needs no cancellation of its own. +`Combobox` takes the same `title`, `icon` and `disabled` as the other controls, and draws as a row +with a list in the composer's overflow sheet too. + +The [New workspace issues example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/new-workspace-issues) +searches a fixed issue list from a daemon handler and sets the branch and metadata from the choice. + ## Composer pills `client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor) diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index de3e486908..2262399a5b 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -353,13 +353,13 @@ Paseo 为服务端代码提供 `@getpaseo/plugin`、`@getpaseo/plugin/server`、 ### Provider {#providers} -直接实现和 ACP 实现、会话生命周期、Composer 设置、时间线渲染器、测试和分发,见[构建 Provider 插件](/docs/plugins/providers)。 +直接实现和 ACP 实现、会话生命周期、选项控件、时间线渲染器、测试和分发,见[构建 Provider 插件](/docs/plugins/providers)。 使用来自 `@getpaseo/plugin/server/provider` 的 `ProviderRegistration` 调用 `server.registerProvider()`。它的连接通过 `send()` 接收输入,并通过 `onEvent()` 发出完整的状态快照。`send()` 只报告是否接受;提示词的处理结果、轮次、配置、持久化、权限和失败都是事件。 消息、结构化命令、引导和命令副作用都使用同一个 `session.prompt` 输入。在实时的用户时间线条目上重复 `clientMessageId`,并恰好发布一个与之匹配的 `session.prompt_result`。把 Provider 创建的子会话发布为带有 `parentSessionId` 的会话。 -Provider 设置是 Paseo 在 Composer 中渲染的开关/选择描述符。把 Provider 私有的选项保留在 `session.open` 的 `ProviderSessionConfig.providerOptions` 中。这个记录包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。配置和合并语义见 [Provider 选项](/docs/sdk/provider-options)。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。 +Provider 选项是一个记录,即 `session.open` 中的 `ProviderSessionConfig.providerOptions`。它包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。在 `session.config` 中为需要在 Composer 里显示开关或选择器的顶层键报告 `controls`,并在 `providerOptions` 下报告会话正在使用的值。运行中会话的修改会以 `session.configure` 的形式到达,内容在 `changes.providerOptions` 中。配置和合并语义见 [Provider 选项](/docs/sdk/provider-options)。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。 Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,再用当前的配置和持久化值打开它。Provider 在 `session.open` 期间重新读取外部状态。 @@ -1604,6 +1604,182 @@ review.remove(); 贡献移入一个共享的溢出菜单。在手机和较窄的窗口中,标题栏没有空间放置插件按钮,因此每个按钮都会成为工作区 菜单(`···`)中的一行,显示它的图标和标题;它的菜单和弹出框以该底部面板中的页面打开。位置和溢出由宿主决定。 +## Composer 项 {#composer-items} + +`client.addComposerItem({ id, Component })` 把一个组件放进 composer:Agent composer、为新 Agent 准备的草稿 composer,以及新建工作区界面上的 composer。`useComposer()` 读取并修改 composer 所配置的内容,`@getpaseo/plugin/client/ui` 中的 `Toggle`、`Select` 和 `Action` 负责绘制控件。 + +```tsx +import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client"; +import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui"; + +function ReviewFlag() { + const composer = useComposer(); + return ( + + composer.setMetadata({ "review.needsReview": value ? true : null }) + } + /> + ); +} + +function Checklist() { + const composer = useComposer(); + return ( + composer.insertText("- [ ] Tests\n- [ ] Docs\n")} + /> + ); +} + +export default function contribute(client: PluginClientContext) { + client.addComposerItem({ id: "review-flag", Component: ReviewFlag }); + client.addComposerItem({ id: "checklist", Component: Checklist }); + return () => {}; +} +``` + +`useComposer()` 返回该项所在的 composer: + +| 字段或命令 | 含义 | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `context` | `"agent"` 表示运行中的 Agent,`"draft"` 表示工作区中的新 Agent,`"new-workspace"` 表示新建工作区界面。 | +| `prompt`、`setPrompt(text)`、`insertText(text)` | 提示词。`insertText` 会替换选区,或在光标处插入。 | +| `attachments`、`addAttachment`、`removeAttachment` | 来自[你自己的附件来源](#add-a-composer-attachment-source)的项目。其他插件和 Paseo 自身的附件不会列出。 | +| `agent.provider`、`model`、`mode`、`thinking` | Agent 当前使用的配置,或草稿将要创建的配置。 | +| `agent.options` | 当前生效的 Provider 选项,包含默认值。在草稿中:创建 Agent 时将使用的选项,包含你所做的修改。 | +| `agent.metadata` | Agent 的[元数据](/docs/plugins/hooks#metadata)。在新建工作区中是第一个 Agent 的元数据。 | +| `setProvider`、`setModel`、`setMode`、`setThinking` | 返回 Promise。`setProvider` 会像 Provider 选择器一样重置模型。在 `context: "agent"` 下(运行中的 Agent 保持其 Provider)以及主机不提供该 Provider 时会被拒绝。`setModel` 和 `setThinking` 原样传递该值;对运行中的 Agent,Provider 拒绝时 Promise 会被拒绝。 | +| `setOptions(patch)`、`setMetadata(patch)` | 顶层键替换,`null` 删除该键。草稿中设置的元数据在创建 Agent 时发送,发送提示词后清空。 | + +同一个调用在每个 composer 中行为一致,插件不需要选择 composer 的实现。在运行中的 Agent 上调用 `setMetadata` 需要支持元数据的主机;主机过旧时,Promise 会以 “Update the host to use metadata.” 拒绝。 + +`Toggle` 接收 `value` 和 `onValueChange({ value })`。`Select` 接收 `value`、`options`(`{ value, label, description? }`)和 `onValueChange({ value })`。`Action` 接收 `onPress(event)`。三者都接收 `title`、`icon`([Lucide 名称、SVG 或组件](#icons))和 `disabled`。回调可以返回 Promise:在它完成之前控件保持禁用,被拒绝时 Paseo 会像对待自身控件一样,以错误提示向用户显示。控件的位置由 Paseo 决定:composer 空间足够时显示在工具栏中,不够时(手机上始终如此)成为 composer 溢出面板中的一行。`Select` 会就地打开列表,而不是替换面板中的行。`Component` 如果渲染这些控件以外的内容,会在其所在位置绘制;在溢出面板中,该位置就是面板。 + +每个命令都按运行时 composer 的当前状态执行,而不是按创建回调的那次渲染,所以 `await composer.setProvider("codex")` 之后再 `await composer.setModel("gpt")` 会同时生效。命令的 Promise 在更改生效后完成,`composer.agent` 在下一次渲染时反映它。 + +新建工作区界面在创建工作区及其 Agent 期间会锁定 composer。你的控件显示为禁用,`setPrompt` 和 `insertText` 会抛出错误,Agent 命令会以 "The composer is locked while the agent is being created." 拒绝。`composer.agent.options` 和 `composer.agent.metadata` 是冻结的副本;请通过 `setOptions` 和 `setMetadata` 修改它们。 + +在 Paseo 知道渲染结果之前,composer 项就已计入可容纳的数量。不渲染任何内容的项仍可能在窄 composer 上留下空的溢出面板,所以请始终渲染一个控件。 + +[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`,还有一个资源管理器面板和一个 Command Center 条目,它们按引用访问 composer。 + +### 按引用访问 composer {#reach-a-composer-by-reference} + +运行中的 Agent 无论标签页是否打开,都有自己的 composer。任意插件组件中的 `useComposers(selector)`,以及每个命令上下文中的 `composers(selector)`,都会返回与 `useComposer()` 给 composer 项的相同的 `PluginComposer`,对象可以是你指定的 Agent,也可以是当前聚焦的 Agent: + +```tsx +import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client"; + +function InsertChecklist() { + const [focused] = useComposers({ focused: true }); + return ( + focused?.insertText("- [ ] Tests\n")}> + Insert checklist + + ); +} + +export default function contribute(client: PluginClientContext) { + client.addWorkspacePanel({ + id: "tools", + title: "Tools", + icon: "ListChecks", + locations: ["explorer"], + Component: InsertChecklist, + }); + client.addCommandCenterItem({ + id: "mark-for-review", + title: "Mark for review", + icon: "Flag", + context: "agent", + async onSelect({ agent, composers }) { + for (const composer of composers({ agentId: agent.id })) { + await composer.setMetadata({ "review.needsReview": true }); + } + }, + }); + return () => {}; +} +``` + +| 选择器 | 结果 | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `{ agentId }` | 此主机上该 Agent 的 composer。Agent 未知或已归档时为空。 | +| `{ focused: true }` | Command Center 交给 Agent 项的那个 Agent 的 composer:屏幕上工作区中聚焦的 Agent 标签页。当前界面不是工作区(例如插件界面或新建工作区),或聚焦的标签页不是 Agent 时为空,并随用户切换标签页而变化。 | + +结果始终是数组,为空或只含一个 composer,所以同一段代码可以读取任一选择器。composer 变化时 `useComposers` 会重新渲染。`composers()` 返回的 composer 是调用时的状态,要获取下一个状态请再次调用 `composers()`。 + +命令与 [`useComposer()`](#composer-items) 相同,从外部访问时有以下差异: + +- Agent 的 composer 在屏幕上时,`insertText` 在光标处插入。不在屏幕上时(例如用户在另一个工作区),它会追加到该 Agent 已保存的提示词,用户回来时文本就在输入框里。`setPrompt` 以同样的方式替换已保存的提示词。 +- 运行中 Agent 的 composer 从不锁定。 +- `setProvider` 会被拒绝,与在 Agent 自己的 composer 内一样。 +- `setModel` 和 `setThinking` 不加检查地把值发给 Provider。Provider 拒绝的值会以 Provider 的消息拒绝。`model` 和 `thinking` 读取的是模型选择器此刻显示的值:在 Provider 的模型目录加载完成之前,是 Agent 上报或创建时使用的 id(可能是别名),加载完成后是目录中的 id。 +- 草稿和新建工作区界面不能按引用访问,只有其中的项能通过 `useComposer()` 访问其 composer。 + +## 新建工作区项 {#new-workspace-items} + +`client.addNewWorkspaceItem({ id, Component })` 在新建工作区界面上放置一个字段,位于项目、主机和分支选择器旁边。`useNewWorkspace()` 读取并修改界面将要创建的内容,`@getpaseo/plugin/client/ui` 中的 `Combobox`、`Select`、`Toggle` 和 `Action` 负责绘制字段。桌面端的项是设置行中的徽章;手机上是整行宽度的字段。 + +```tsx +import { useNewWorkspace, useRpc, type PluginClientContext } from "@getpaseo/plugin/client"; +import { Combobox } from "@getpaseo/plugin/client/ui"; +import { searchIssuesRpc } from "../shared/issues"; + +function IssueField() { + const newWorkspace = useNewWorkspace(); + const searchIssues = useRpc(searchIssuesRpc); + const issueId = newWorkspace.metadata["issues.id"]; + return ( + (await searchIssues({ query })).issues} + onSelect={async ({ item }) => { + await newWorkspace.setBranch(item.branch); + await newWorkspace.setMetadata({ "issues.id": item.id }); + }} + /> + ); +} + +export default function contribute(client: PluginClientContext) { + client.addNewWorkspaceItem({ id: "issue", Component: IssueField }); + return () => {}; +} +``` + +`useNewWorkspace()` 返回该项所在的设置: + +| 字段或命令 | 含义 | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `host`、`project` | 用户的选择,分别是 `{ id, label }` 和 `{ id, name } \| null`。只读:项不能切换主机或项目。 | +| `isolation`、`setIsolation` | `"local"` 或 `"worktree"`。无法创建 worktree 的项目读取为 `"local"`,此时 `setIsolation("worktree")` 会被拒绝。 | +| `baseRef`、`setBaseRef` | 新 worktree 的起点:`{ type: "branch", name }`、`{ type: "change-request", number }`,默认值未知时为 `null`。分支的 `name` 是创建时实际使用的完整 ref,例如 `refs/remotes/origin/main`,所以 `setBaseRef(newWorkspace.baseRef)` 不会改变任何内容。`setBaseRef` 接受 Base 选择器提供的任何 ref,或一个裸分支名,Paseo 会按其他地方一贯的方式解析。未选择项目时会被拒绝。 | +| `branch`、`setBranch` | 新 worktree 所创建的分支名称。`null` 表示由 Paseo 选择名称。隔离为本地或起点为变更请求时会保留该值,它为任何新建分支的 worktree 命名,起点可以是默认基线或所选分支;检出已有分支或变更请求的 worktree 不适用。 | +| `metadata`、`setMetadata` | 所创建工作区的[元数据](/docs/plugins/hooks#metadata),而不是第一个 Agent 的元数据。顶层键替换,`null` 删除该键。主机不支持元数据时以 “Update the host to use metadata.” 拒绝。 | + +命令遵循 [Composer 项](#composer-items)的规则:每个命令都按界面运行时的状态执行,所以 `await newWorkspace.setBranch(name)` 之后再调用 `newWorkspace.setMetadata(patch)` 会同时生效,命令的 Promise 在更改生效后完成。命令从组件的第一个 effect 起即可使用,包括挂载时的 effect。载荷是 JSON 值。你的项针对某一台主机上的某一个项目挂载。用户更改其中任何一个时,Paseo 会重新挂载你的项并清除 `branch` 和 `metadata`,因此上一个项目的状态和 `Combobox` 结果不会保留;在挂载 effect 中做初始化的项会针对新项目再次初始化。在其项目或主机已被离开之后才调用的命令(例如用户切换项目时被放弃的查询最终返回)会以 "The New workspace screen moved to another project or host; this item belongs to the previous one." 拒绝,并且不做任何更改;为新项目挂载的项不受影响。`host`、`project`、`baseRef` 和 `metadata` 是冻结的。界面创建工作区期间会被锁定:你的字段显示为禁用,所有命令都会以 "The New workspace screen is locked while the workspace is being created." 拒绝。返回 Promise 的回调在完成之前会让控件保持禁用,被拒绝时会以错误提示向用户显示。 + +`Combobox` 是带可搜索列表的字段,结果由你的代码提供: + +| 属性 | 含义 | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `value` | `{ id, label, description? } \| null`,字段显示的内容。它不必出现在搜索结果中,因此可以显示之前所做的选择。 | +| `search` | `({ query }) => Promise`。列表打开时以 `""` 调用,之后随用户输入防抖调用。每个 `Item` 是 `{ id, label, description? }` 加上你添加的字段。 | +| `onSelect` | `({ item }) => void \| Promise`。`item` 就是 `search` 返回的对象。 | + +搜索进行时 Paseo 显示 “Searching…”,搜索被拒绝时显示 “Search failed” 和错误信息,没有结果时显示空状态。新搜索进行期间会保留之前的结果,并忽略已被更新搜索取代的结果,所以 `search` 不需要自己处理取消。`Combobox` 接收与其他控件相同的 `title`、`icon` 和 `disabled`。 + +[新建工作区 Issue 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/new-workspace-issues)通过守护进程处理器搜索一个固定的 Issue 列表,并根据所选项设置分支和元数据。 + ## Composer 胶囊 {#composer-pills} `client.addComposerPill({ id, workspaceId, agentId, button })` 使用同样的[按钮描述符](#button-descriptor),