Compare commits

..
Author SHA1 Message Date
Mohamed Boudra c4e5a0e739 feat(plugin): observe terminals across connected hosts
Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp.
2026-11-06 09:22:19 +01:00
Mohamed Boudra 62bd21214b feat(plugin): add sidebar view examples and ref actions
Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders.
2026-11-06 09:22:19 +01:00
Mohamed Boudra dc309586f0 feat(plugin): let users select a sidebar view
An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery.
2026-11-06 09:22:19 +01:00
Mohamed Boudra e4106b4e2d feat(plugin): expose stable sidebar entity data across hosts
Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase.
2026-11-06 09:22:19 +01:00
368 changed files with 7576 additions and 16974 deletions
-6
View File
@@ -136,12 +136,6 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
This is informational ancestry for future workspace grouping. It does not change placement,
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
never inferred from directories, projects, or Git branches.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
-2
View File
@@ -96,7 +96,6 @@ 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<string, string>` | 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<string, JsonValue>` | 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) |
@@ -483,7 +482,6 @@ 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<string, JsonValue>` | 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. |
+10 -139
View File
@@ -474,9 +474,6 @@ 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` hook
that awaits a write to the agent being opened (`setMetadata`, `setLabels`) 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
@@ -532,21 +529,13 @@ 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`.
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.
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.
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;
@@ -581,6 +570,13 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Replace sidebar content
The sidebar view slot is global across hosts, while header and footer contributions retain their
host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns
its layout. This keeps plugin failures from removing navigation or recovery controls. The
[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
@@ -665,131 +661,6 @@ 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
+3 -62
View File
@@ -30,65 +30,6 @@ 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, onto the agent's options
as they are at that moment, so the last write wins. 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.
- **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.
@@ -174,7 +115,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 `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.
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.
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.
@@ -537,7 +478,7 @@ interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly controls?: AgentOptionControl[];
readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -559,7 +500,7 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
applyOptions?(options: ProviderOptions): Promise<void>;
setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null;
@@ -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]?.options ?? null;
return preferences.providerPreferences?.[providerId]?.featureValues ?? 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",
options: { fast_mode: true },
featureValues: { 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,
providerOptions: { fast_mode: true },
featureValues: { fast_mode: true },
},
});
} finally {
@@ -1,12 +0,0 @@
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");
});
});
@@ -1,53 +0,0 @@
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");
});
});
@@ -1,110 +0,0 @@
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" });
},
);
});
@@ -0,0 +1,18 @@
import { test } from "../support/fixtures";
import { withSidebarViews } from "../support/helpers/sidebar-views";
test("sidebar views replace the content, preserve chrome, and recover to the default", async ({
page,
}) => {
test.setTimeout(150_000);
await withSidebarViews(page, async (views) => {
await views.expectDefault();
await views.chooseWorkspaces();
await views.exerciseExamples();
await views.exerciseTerminals();
await views.restoreDefault();
await views.recoverFromFailure();
await views.chooseWorkspaces();
await views.disableChosenView();
});
});
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "createTerminal"
| "killTerminal"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
@@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick<
| "removePlugin"
| "reloadPlugin"
| "setWorkspaceTitle"
| "setWorkspaceLabel"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -1,59 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
const PLUGIN_ID = "composer-controls-example";
const NARROW = { width: 500, height: 800 };
/** Runs `run` in an agent's composer with the composer-controls example installed. */
export async function withComposerControlsExample(
page: Page,
run: (composer: {
narrowTheWindow(): Promise<void>;
openAgentFeatures(): Promise<void>;
expectFeatureRow(name: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({
repoPrefix: "composer-items-",
title: "Composer items",
});
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Composer items agent",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("composer-controls");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await gotoAppShell(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await run({
narrowTheWindow: () =>
test.step("narrow the window until the composer folds its controls away", () =>
page.setViewportSize(NARROW)),
openAgentFeatures: () =>
test.step("open the agent features sheet", async () => {
await page.getByRole("button", { name: "Open agent features", exact: true }).click();
}),
expectFeatureRow: (name) =>
test.step(`the sheet has a row for "${name}"`, () =>
expect(page.getByRole("button", { name, exact: true })).toBeVisible()),
});
} finally {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
@@ -1,208 +0,0 @@
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<ReturnType<typeof seedWorkspace>>;
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<void>;
typeInComposer(text: string, cursorAfter: number): Promise<void>;
insertChecklistFromPanel(): Promise<void>;
expectComposerText(text: string): Promise<void>;
markForReviewFromCommandCenter(): Promise<void>;
focusANewAgentTab(): Promise<void>;
focusTheAgentTab(): Promise<void>;
expectPanelFocusedAgent(focused: boolean): Promise<void>;
expectPanelShowsReview(needsReview: boolean): Promise<void>;
}) => Promise<void>,
) {
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<void>;
goToAnotherWorkspace(): Promise<void>;
insertFromCommandCenter(): Promise<void>;
returnToTargetWorkspace(): Promise<void>;
expectComposerText(text: string): Promise<void>;
}) => Promise<void>,
) {
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 };
@@ -1,228 +0,0 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import {
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected,
openNewWorkspaceComposer,
openProjectViaDaemon,
submitNewWorkspacePrompt,
} from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { getServerId } from "./server-id";
import { createTempGitRepo } from "./workspace";
import { waitForSidebarHydration } from "./workspace-ui";
/** Presses Create, typing the prompt first unless the test wants an empty one. */
async function submitCreate(page: Page, prompt: string | undefined) {
if (prompt !== "") return submitNewWorkspacePrompt(page, prompt);
await page.getByTestId("message-input-root").getByRole("button", { name: "Create" }).click();
}
/** An item that sets the branch and metadata from its mount effect, before the user touches anything. */
async function replaceWithMountEffectItem(directory: string) {
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useEffect } from "react";
import { useNewWorkspace } from "@getpaseo/plugin/client";
import { Action } from "@getpaseo/plugin/client/ui";
function OnMount() {
const newWorkspace = useNewWorkspace();
useEffect(() => {
void newWorkspace.setIsolation("worktree");
void newWorkspace.setBranch("set-on-mount");
void newWorkspace.setMetadata({ "new-workspace-issues.issue": "ON-MOUNT" });
}, []);
return <Action title="Mount" icon="Flag" onPress={() => {}} />;
}
export default function contribute(client) {
client.addNewWorkspaceItem({ id: "on-mount", Component: OnMount });
return () => {};
}
`,
);
}
/**
* 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 (
<Combobox
title="Issue"
icon="CircleDot"
value={null}
search={() =>
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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
/** The daemon fills in the git runtime after it creates the workspace, so wait for the branch. */
async function readCreatedWorkspace(client: Client, workspaceId: string) {
const deadline = Date.now() + 30_000;
for (;;) {
const { entries } = await client.fetchWorkspaces();
const workspace = entries.find((entry) => entry.id === workspaceId);
const branch = workspace?.gitRuntime?.currentBranch ?? null;
if (workspace && branch) return { branch, metadata: workspace.metadata };
if (Date.now() > deadline) throw new Error(`Workspace ${workspaceId} never reported a branch`);
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
const PLUGIN_ID = "new-workspace-issues-example";
const COMPACT = { width: 500, height: 800 };
/** Runs `run` on the New workspace screen with the new-workspace-issues example installed. */
export async function withNewWorkspaceIssuesExample(
page: Page,
options: {
compact: boolean;
detachedHead?: boolean;
setsOnMount?: boolean;
/** 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<void>;
chooseIssue(label: string): Promise<void>;
createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>;
switchToOtherProject(): Promise<void>;
openIssueList(): Promise<void>;
closeIssueList(): Promise<void>;
/** The Issue list shows `text`, or does not show it when `shown` is false. */
expectIssueListText(text: string, shown: boolean): Promise<void>;
/** The name the first project's items read as `project.name`. */
firstProjectName: string;
expectIsolation(isolation: "local" | "worktree"): Promise<void>;
/** Removes the plugin, leaves the screen, and opens New workspace for the project again. */
reopenWithoutPlugin(): Promise<void>;
}) => Promise<void>,
) {
const client = await connectNewWorkspaceDaemonClient();
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("new-workspace-issues");
if (options.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();
}
}
@@ -0,0 +1,191 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { copyPluginExample } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { gotoWorkspace } from "./launcher";
import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings";
import { visibleTestId } from "./plugin-sidebar-items";
import { getServerId } from "./server-id";
export async function withSidebarViews(
page: Page,
run: (views: ReturnType<typeof sidebarViews>) => Promise<void>,
) {
const example = await copyPluginExample("sidebar-views");
const entry = path.join(example.directory, "index.client.tsx");
const original = await readFile(entry, "utf8");
await writeFile(
entry,
original.replace(
"const cleanups = [",
'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [',
),
);
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const workspace = await seedWorkspace({
repoPrefix: "sidebar-views-",
title: "Sidebar view workspace",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await run(sidebarViews(page, workspace.workspaceId, client));
} finally {
await client.removePlugin("sidebar-views");
await client.close();
await workspace.cleanup();
await example.cleanup();
}
}
function sidebarViews(
page: Page,
workspaceId: string,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
) {
const builtinRow = () =>
visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
async function choose(title: string) {
await openSidebarNavSettings(page);
await page.getByRole("button", { name: /^View:/ }).click();
await page.getByRole("menuitem", { name: title, exact: true }).click();
await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible();
await leaveSettings(page);
}
async function expectDefault() {
await expect(builtinRow()).toBeVisible();
}
async function expectPlugin() {
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", {
exact: true,
}),
).toBeVisible();
await expect(builtinRow()).toHaveCount(0);
await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible();
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
}
return {
expectDefault,
async chooseWorkspaces() {
await choose("Example workspaces");
await expectPlugin();
},
async exerciseExamples() {
await choose("Example agents");
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }),
).toBeVisible();
await choose("Example nested agents");
await expectPlugin();
await choose("Example label folders");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toContain("Inbox");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Later", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Later"]);
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Unlabelled", color: "red" },
assigned: true,
});
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Later", color: "red" },
assigned: false,
});
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Unlabelled"]);
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Inbox"]);
},
async exerciseTerminals() {
await choose("Example terminals");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
.click();
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
const workspace = (await client.fetchWorkspaces()).entries.find(
(item) => item.id === workspaceId,
)!;
const created = await client.createTerminal(
workspace.workspaceDirectory,
"Sidebar terminal",
undefined,
{ workspaceId },
);
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
try {
await expect(
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
).toBeVisible();
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
await expect
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
.not.toContain(created.terminal!.id);
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
} finally {
await client.killTerminal(created.terminal.id);
}
},
async restoreDefault() {
await choose("Default view");
await expectDefault();
},
async recoverFromFailure() {
await choose("Broken example");
await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Use default view", exact: true }).click();
await expectDefault();
},
async disableChosenView() {
await client.disablePlugin("sidebar-views");
await expectDefault();
await openSidebarNavSettings(page);
await expect(
page.getByText("The selected view is unavailable or disabled. Showing Default view.", {
exact: true,
}),
).toBeVisible();
await leaveSettings(page);
},
};
}
+2 -2
View File
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
export function normalizeAgentCommandsCwd(cwd: string): string {
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
"thinking",
draftConfig.thinkingOptionId ?? null,
"features",
draftConfig.providerOptions ?? null,
draftConfig.featureValues ?? null,
] as const;
}
@@ -1,189 +0,0 @@
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<string, unknown>,
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,
);
});
});
@@ -1,217 +0,0 @@
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<string>;
}
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;
}
@@ -1,16 +0,0 @@
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);
});
});
+1 -5
View File
@@ -13,7 +13,6 @@ 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,9 +60,6 @@ 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 {
if (!icon) return Settings2;
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
}
@@ -1,17 +0,0 @@
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<string>,
current: ReadonlySet<string>,
): ProviderOptions {
return Object.fromEntries(
Object.entries(options).filter(([key]) => !drawn.has(key) || current.has(key)),
);
}
@@ -1,50 +0,0 @@
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");
});
});
@@ -1,47 +0,0 @@
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<AgentOptionControl, "label" | "tooltip">): string {
return control.tooltip ?? control.label;
}
@@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
}),
),
).toEqual({
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
});
});
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
modelId: "",
modeId: "",
thinkingOptionId: "",
options: {},
featureValues: {},
});
});
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
}),
),
),
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { 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({ options: {} }))),
).not.toHaveProperty("options");
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
).not.toHaveProperty("featureValues");
});
it("never carries a provider, which a running agent cannot change", () => {
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
modelId: string;
modeId: string;
thinkingOptionId: string;
options: Record<string, unknown>;
featureValues: Record<string, unknown>;
}
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),
options: profile.options ?? {},
featureValues: profile.featureValues ?? {},
};
}
@@ -52,6 +52,8 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf
...(profile.modelId ? { modelId: profile.modelId } : {}),
...(profile.modeId ? { modeId: profile.modeId } : {}),
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}),
...(Object.keys(profile.featureValues).length > 0
? { featureValues: profile.featureValues }
: {}),
};
}
@@ -1,7 +1,7 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import {
buildControlsRequestKey,
buildFeatureRequestKey,
openAgentProfileForm,
type AgentProfileFormModel,
} from "./profile-form-model";
@@ -70,21 +70,6 @@ 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" });
@@ -188,7 +173,7 @@ describe("openAgentProfileForm", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { webSearch: true },
featureValues: { webSearch: true },
notes: "Visual work only.",
},
});
@@ -244,11 +229,10 @@ describe("openAgentProfileForm", () => {
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" });
model.applyControls(model.getState().controlsRequestKey ?? "", {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
model.setOption("webSearch", true);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setFeatureValue("webSearch", true);
model.setProvider("codex", { label: "Codex" });
const state = model.getState();
@@ -258,9 +242,8 @@ describe("openAgentProfileForm", () => {
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
expect(state.thinkingOptionId).toBe("");
expect(state.thinkingDisplay).toBeNull();
expect(state.options).toEqual({});
expect(state.optionEdits).toEqual({});
expect(state.controls).toEqual([]);
expect(state.featureValues).toEqual({});
expect(state.features).toEqual([]);
expect(state.name).toBe("Test writer");
});
@@ -351,28 +334,23 @@ describe("openAgentProfileForm", () => {
});
});
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", () => {
describe("features", () => {
it("asks for features per provider/model/mode/thinking combination", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().controlsRequest).toEqual({
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think",
});
const firstKey = model.getState().controlsRequestKey;
const firstKey = model.getState().featureRequestKey;
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().controlsRequestKey).not.toBe(firstKey);
expect(model.getState().controlsRequest).toEqual({
expect(model.getState().featureRequestKey).not.toBe(firstKey);
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-haiku-4-5",
modeId: "plan",
@@ -383,233 +361,77 @@ describe("openAgentProfileForm", () => {
it("moves from pending to complete only for the current request", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
const key = model.getState().controlsRequestKey ?? "";
expect(model.getState().controlsResolution).toBe("pending");
const key = model.getState().featureRequestKey ?? "";
expect(model.getState().featureResolution).toBe("pending");
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("stale-key", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().features).toEqual([]);
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);
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);
});
it("reflects a set option back into the rendered controls", () => {
it("reflects a set feature value back into the rendered feature", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("Searcher");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setOption("webSearch", true);
model.setFeatureValue("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 });
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
expect(model.getState().featureValues).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", () => {
it("keeps stored feature values while the feature list is still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
options: { webSearch: true, sandbox_mode: "read-only" },
featureValues: { webSearch: true },
},
});
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",
});
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
});
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", () => {
it("prunes stored values the provider no longer reports", () => {
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.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().controls).toEqual([]);
});
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);
expect(model.getState().submitValue?.options).toBeUndefined();
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: "Fast",
name: "UI work",
provider: "claude",
model: "claude-opus-5",
options: { fast_mode: true },
featureValues: { webSearch: true, retiredFlag: true },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toBeUndefined();
});
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);
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 });
expect(model.getState().featureValues).toEqual({ webSearch: true });
});
it("resolves an unavailable feature listing without showing the field", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().disclosure.showControlsField).toBe(false);
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(false);
});
});
@@ -642,15 +464,15 @@ describe("openAgentProfileForm", () => {
});
});
describe("buildControlsRequestKey", () => {
describe("buildFeatureRequestKey", () => {
it("is null without a provider", () => {
expect(buildControlsRequestKey(null)).toBeNull();
expect(buildFeatureRequestKey(null)).toBeNull();
});
it("separates the fields so a value cannot bleed across positions", () => {
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildControlsRequestKey({ provider: "claude", model: "a" }),
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildFeatureRequestKey({ provider: "claude", model: "a" }),
);
});
});
@@ -1,14 +1,13 @@
import type {
AgentOptionControl,
ProviderOptions,
AgentFeature,
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";
/**
@@ -58,15 +57,11 @@ export interface AgentProfileFormSnapshot {
}
/**
* 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.
* 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.
*/
export interface AgentProfileControlsRequest {
export interface AgentProfileFeatureRequest {
provider: string;
providerOptions?: ProviderOptions;
model?: string;
modeId?: string;
thinkingOptionId?: string;
@@ -78,7 +73,7 @@ export interface AgentProfileFormDisclosure {
showModelField: boolean;
showModeField: boolean;
showThinkingField: boolean;
showControlsField: boolean;
showFeaturesField: boolean;
}
export interface AgentProfileFormState {
@@ -91,22 +86,13 @@ export interface AgentProfileFormState {
modelId: string;
modeId: string;
thinkingOptionId: string;
/** The options the profile was opened with, including ones no control draws. */
options: ProviderOptions;
/** Changes made in this form, over `options`. */
optionEdits: ProviderOptions;
featureValues: Record<string, unknown>;
providerOptions: AgentProfileFormOption[];
modelOptions: AgentProfileFormOption[];
modeOptions: AgentProfileFormOption[];
thinkingOptions: AgentProfileFormOption[];
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<string>;
/** The keys the latest answer draws as controls. */
answeredControlKeys: ReadonlySet<string>;
features: AgentFeature[];
providerDisplay: AgentProfileFormDisplay | null;
modelDisplay: AgentProfileFormDisplay | null;
@@ -114,9 +100,9 @@ export interface AgentProfileFormState {
thinkingDisplay: AgentProfileFormDisplay | null;
catalogResolution: AgentProfileResolutionStatus;
controlsResolution: AgentProfileResolutionStatus;
controlsRequest: AgentProfileControlsRequest | null;
controlsRequestKey: string | null;
featureResolution: AgentProfileResolutionStatus;
featureRequest: AgentProfileFeatureRequest | null;
featureRequestKey: string | null;
disclosure: AgentProfileFormDisclosure;
isSubmitting: boolean;
@@ -132,12 +118,9 @@ 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. */
applyControls: (
requestKey: string,
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
) => void;
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
/** Resolve a request that produced no usable features (provider error). */
applyControlsUnavailable: (requestKey: string) => void;
applyFeaturesUnavailable: (requestKey: string) => void;
setName: (value: string) => void;
setAppearance: (value: { icon: string; color: string }) => void;
setNotes: (value: string) => void;
@@ -145,7 +128,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;
setOption: (key: string, value: unknown) => void;
setFeatureValue: (featureId: string, value: unknown) => void;
setSubmitting: (value: boolean) => void;
setSubmitError: (value: string | null) => void;
}
@@ -329,39 +312,19 @@ function refineDisplay(
return current ?? { label: selectedId };
}
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null {
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | 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 } : {}),
};
}
/**
* 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 {
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
if (!request) {
return null;
}
@@ -373,25 +336,12 @@ function buildControlsScopeKey(request: AgentProfileControlsRequest | null): str
].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();
if (!name || !state.provider) {
return null;
}
const options = ownOptionsOf(state);
return {
name,
...(state.icon ? { icon: state.icon } : {}),
@@ -400,16 +350,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(options).length > 0 ? { options } : {}),
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
...(notes ? { notes } : {}),
};
}
function resolveControlsStatus(
controlsRequestKey: string | null,
function resolveFeatureStatus(
featureRequestKey: string | null,
featuresAreCurrent: boolean,
): AgentProfileResolutionStatus {
if (controlsRequestKey === null) {
if (featureRequestKey === null) {
return "idle";
}
return featuresAreCurrent ? "complete" : "pending";
@@ -442,29 +392,25 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
modelId,
modeId: profile.modeId ?? "",
thinkingOptionId: profile.thinkingOptionId ?? "",
options: { ...profile.options },
optionEdits: {},
featureValues: { ...profile.featureValues },
providerOptions: [],
modelOptions: [],
modeOptions: [],
thinkingOptions: [],
controls: [],
controlOptions: {},
drawnControlKeys: new Set(),
answeredControlKeys: new Set(),
features: [],
providerDisplay: seedDisplay(provider),
modelDisplay: seedDisplay(modelId),
modeDisplay: seedDisplay(profile.modeId),
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
catalogResolution: "idle",
controlsResolution: "idle",
controlsRequest: null,
controlsRequestKey: null,
featureResolution: "idle",
featureRequest: null,
featureRequestKey: null,
disclosure: {
showModelField: false,
showModeField: false,
showThinkingField: false,
showControlsField: false,
showFeaturesField: false,
},
isSubmitting: false,
submitError: null,
@@ -476,45 +422,28 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
let entries: readonly ProviderSnapshotEntry[] = [];
let catalogResolution: AgentProfileResolutionStatus = "idle";
let resolvedControlsKey: string | null = null;
let resolvedScopeKey: string | null = null;
let resolvedControls: AgentOptionControl[] = [];
let resolvedOptions: ProviderOptions = {};
let drawnKeys: ReadonlySet<string> = new Set();
let answeredKeys: ReadonlySet<string> = new Set();
let resolvedFeatureKey: string | null = null;
let resolvedFeatures: AgentFeature[] = [];
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: AgentProfileFormState = {
...seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
}),
drawnControlKeys: drawnKeys,
answeredControlKeys: answeredKeys,
};
const next = seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
});
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
const controlsRequest = buildControlsRequest(next);
const controlsRequestKey = buildControlsRequestKey(controlsRequest);
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
const featureRequest = buildFeatureRequest(next);
const featureRequestKey = buildFeatureRequestKey(featureRequest);
const 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);
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
const features = featuresAreCurrent
? applyFeatureValues(resolvedFeatures, next.featureValues)
: [];
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
const withOptions: AgentProfileFormState = {
...next,
@@ -522,8 +451,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modelOptions: buildModelOptions(models),
modeOptions: buildModeOptions(modes),
thinkingOptions: buildThinkingOptions(thinking),
controls,
controlOptions,
features,
providerDisplay: refineDisplay(
next.providerDisplay,
next.provider,
@@ -551,9 +479,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
})(),
),
catalogResolution,
controlsResolution,
controlsRequest,
controlsRequestKey,
featureResolution,
featureRequest,
featureRequestKey,
};
// A field appears once the catalog can populate it, and stays visible while
@@ -564,7 +492,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
showThinkingField:
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
showControlsField: hasProvider && controls.length > 0,
showFeaturesField: hasProvider && features.length > 0,
};
const canSubmit =
withOptions.name.trim().length > 0 &&
@@ -603,26 +531,23 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
catalogResolution = "complete";
publish((current) => current);
},
applyControls: (requestKey, resolved) => {
if (requestKey !== state.controlsRequestKey) {
applyFeatures: (requestKey, features) => {
if (requestKey !== state.featureRequestKey) {
return;
}
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);
resolvedFeatureKey = requestKey;
resolvedFeatures = [...features];
publish((current) => ({
...current,
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
}));
},
applyControlsUnavailable: (requestKey) => {
if (requestKey !== state.controlsRequestKey) {
applyFeaturesUnavailable: (requestKey) => {
if (requestKey !== state.featureRequestKey) {
return;
}
resolvedControlsKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
resolvedControls = [];
resolvedOptions = {};
resolvedFeatureKey = requestKey;
resolvedFeatures = [];
publish((current) => current);
},
setName: (value) => publish((current) => ({ ...current, name: value })),
@@ -637,8 +562,6 @@ 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,
@@ -649,8 +572,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modeDisplay: null,
thinkingOptionId: "",
thinkingDisplay: null,
options: {},
optionEdits: {},
featureValues: {},
};
}),
setModel: (modelId, display) =>
@@ -676,10 +598,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
publish((current) => ({ ...current, modeId, modeDisplay: display })),
setThinking: (thinkingOptionId, display) =>
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
setOption: (key, value) =>
setFeatureValue: (featureId, value) =>
publish((current) => ({
...current,
optionEdits: { ...current.optionEdits, [key]: value },
featureValues: { ...current.featureValues, [featureId]: value },
})),
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
@@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: {
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
}
const featureCount = Object.keys(input.profile.options ?? {}).length;
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
if (featureCount > 0) {
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
}
@@ -117,7 +117,9 @@ export function useAgentProfilePicker(
modelId: resolved.modelId,
modeId: resolved.modeId,
thinkingOptionId: resolved.thinkingOptionId,
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}),
...(Object.keys(resolved.featureValues).length > 0
? { featureValues: resolved.featureValues }
: {}),
}),
).catch((error) => {
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
}, [entries, input.model]);
}
export function useAgentProfileFormControls(input: {
export function useAgentProfileFormFeatures(input: {
serverId: string;
model: AgentProfileFormModel;
state: AgentProfileFormState;
@@ -35,14 +35,14 @@ export function useAgentProfileFormControls(input: {
const { t } = useTranslation();
const client = useHostRuntimeClient(input.serverId);
const isConnected = useHostRuntimeIsConnected(input.serverId);
const request = input.state.controlsRequest;
const requestKey = input.state.controlsRequestKey;
const request = input.state.featureRequest;
const requestKey = input.state.featureRequestKey;
// `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 controlsQuery = useFetchQuery({
queryKey: ["agentProfileControls", input.serverId, requestKey],
const featuresQuery = useFetchQuery({
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(client && isConnected && request),
@@ -51,28 +51,28 @@ export function useAgentProfileFormControls(input: {
if (!client || !request) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD });
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
if (payload.error) {
throw new Error(payload.error);
}
return { controls: payload.controls ?? [], options: payload.options ?? {} };
return payload.features ?? [];
},
});
const { data, isError } = controlsQuery;
const { data, isError } = featuresQuery;
const { model } = input;
useEffect(() => {
if (!requestKey || !data) {
return;
}
model.applyControls(requestKey, data);
model.applyFeatures(requestKey, data);
}, [data, model, requestKey]);
useEffect(() => {
if (!requestKey || !isError) {
return;
}
model.applyControlsUnavailable(requestKey);
model.applyFeaturesUnavailable(requestKey);
}, [isError, model, requestKey]);
}
@@ -2,8 +2,7 @@ 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 { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -25,7 +24,7 @@ import type {
} from "../internal/profile-form-model";
import {
useAgentProfileFormCatalog,
useAgentProfileFormControls,
useAgentProfileFormFeatures,
} from "../internal/use-profile-form-inputs";
import {
useAgentProfileFormModel,
@@ -141,7 +140,7 @@ function OpenAgentProfileEditModal({
const model = useAgentProfileFormModel(snapshot);
const state = useAgentProfileFormState(model);
useAgentProfileFormCatalog({ serverId, model });
useAgentProfileFormControls({ serverId, model, state });
useAgentProfileFormFeatures({ serverId, model, state });
const sheetHeader = useMemo<SheetHeader>(
() => ({
@@ -328,17 +327,16 @@ function OpenAgentProfileEditModal({
/>
) : null}
{state.disclosure.showControlsField ? (
{state.disclosure.showFeaturesField ? (
<Field
label={t("settings.host.agentProfiles.featuresLabel")}
testID="agent-profile-features-field"
>
<View style={styles.featureCard} testID="agent-profile-features-card">
{state.controls.map((control, index) => (
<AgentProfileControlRow
key={control.key}
control={control}
options={state.controlOptions}
{state.features.map((feature, index) => (
<AgentProfileFeatureRow
key={feature.id}
feature={feature}
isFirst={index === 0}
disabled={state.isSubmitting}
size={controlSize}
@@ -401,16 +399,14 @@ function OpenAgentProfileEditModal({
);
}
function AgentProfileControlRow({
control,
options,
function AgentProfileFeatureRow({
feature,
isFirst,
disabled,
size,
model,
}: {
control: AgentOptionControl;
options: ProviderOptions;
feature: AgentFeature;
isFirst: boolean;
disabled: boolean;
size: ControlSize;
@@ -421,75 +417,74 @@ function AgentProfileControlRow({
[isFirst],
);
const handleToggle = useCallback(
(value: boolean) => model.setOption(control.key, value),
[control.key, model],
(value: boolean) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const handleSelect = useCallback(
(value: string) => model.setOption(control.key, value),
[control.key, model],
(value: string) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const selectOptions = useMemo<SelectFieldOption<string>[]>(
() =>
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.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],
[feature],
);
const selectedValue = control.type === "select" ? readSelectOption(control, options) : null;
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
if (control.type !== "select" || !selectedValue) {
if (feature.type !== "select" || !feature.value) {
return null;
}
const choice = control.choices.find((candidate) => candidate.value === selectedValue);
return { label: choice?.label ?? selectedValue };
}, [control, selectedValue]);
const option = feature.options.find((candidate) => candidate.id === feature.value);
return { label: option?.label ?? feature.value };
}, [feature]);
if (control.type === "select") {
if (feature.type === "select") {
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<SelectField
label={control.label}
value={selectedValue}
label={feature.label}
value={feature.value}
selectedDisplay={selectedDisplay}
options={selectOptions}
onChange={handleSelect}
placeholder={control.label}
emptyText={control.label}
placeholder={feature.label}
emptyText={feature.label}
disabled={disabled}
{...(control.description ? { hint: control.description } : {})}
title={control.label}
{...(feature.description ? { hint: feature.description } : {})}
title={feature.label}
size={size}
testID={`agent-profile-feature-field-${control.key}`}
triggerTestID={`agent-profile-feature-trigger-${control.key}`}
testID={`agent-profile-feature-field-${feature.id}`}
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
/>
</View>
);
}
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<View style={styles.featureMeta}>
<Text style={styles.featureLabel} numberOfLines={1}>
{control.label}
{feature.label}
</Text>
{control.description ? (
{feature.description ? (
<Text style={styles.featureDescription} numberOfLines={2}>
{control.description}
{feature.description}
</Text>
) : null}
</View>
<Switch
value={readToggleOption(control, options)}
value={feature.value === true}
onValueChange={handleToggle}
disabled={disabled}
accessibilityLabel={control.label}
testID={`agent-profile-feature-switch-${control.key}`}
accessibilityLabel={feature.label}
testID={`agent-profile-feature-switch-${feature.id}`}
/>
</View>
);
+1 -2
View File
@@ -1290,8 +1290,7 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent)
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId");
}
if (left.options !== right.options) reasons.push("agent.options");
if (left.controls !== right.controls) reasons.push("agent.controls");
if (left.features !== right.features) reasons.push("agent.features");
return reasons;
}
@@ -40,7 +40,16 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
cwd: "/repo",
model: "agent-model",
thinkingOptionId: "think-hard",
options: { "web-search": true, effort: "high" },
features: [
{ type: "toggle", id: "web-search", label: "Web search", value: true },
{
type: "select",
id: "effort",
label: "Effort",
value: "high",
options: [{ id: "high", label: "High" }],
},
],
parentAgentId: null,
labels: {},
...overrides,
@@ -105,7 +114,7 @@ describe("buildDraftAgentSetup", () => {
modeId: "mode-current",
model: "agent-model",
thinkingOptionId: "think-hard",
providerOptions: {
featureValues: {
"web-search": true,
effort: "high",
},
@@ -65,12 +65,17 @@ export function resolveClientSlashCommand(input: {
}
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
const featureValues: Record<string, unknown> = {};
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,
providerOptions: { ...agent.options },
featureValues,
};
}
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import type {
AgentMode,
AgentOptionControl,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import type { CommandCenterContribution } from "./contributions";
import {
@@ -35,7 +31,7 @@ const ICONS: AgentControlContributionSource["icons"] = {
thinking: TestIcon,
planMode: TestIcon,
mode: () => TestIcon,
control: () => TestIcon,
feature: () => TestIcon,
};
function provider(input: {
@@ -69,8 +65,8 @@ function provider(input: {
};
}
function toggleControl(key: string, icon?: string): AgentOptionControl {
return { type: "toggle", key, label: key, icon };
function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
return { type: "toggle", id, label: id, value, icon };
}
function makeSource(
@@ -79,7 +75,7 @@ function makeSource(
models?: Partial<AgentControlContributionSource["models"]>;
thinking?: Partial<AgentControlContributionSource["thinking"]>;
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
optionControls?: Partial<AgentControlContributionSource["optionControls"]>;
features?: Partial<AgentControlContributionSource["features"]>;
} = {},
): AgentControlContributionSource {
return {
@@ -108,11 +104,10 @@ function makeSource(
select: () => undefined,
...overrides.modes,
},
optionControls: {
controls: [],
options: {},
onSetOption: () => undefined,
...overrides.optionControls,
features: {
list: [],
set: () => undefined,
...overrides.features,
},
};
}
@@ -138,7 +133,7 @@ describe("Command Center agent-control contributions", () => {
const contributions = buildAgentControlContributions({
...makeSource({
models: { providers: [provider({ id: "claude", label: "Claude" })] },
optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } },
features: { list: [toggleFeature("plan_mode", true)] },
}),
modes: undefined,
});
@@ -328,10 +323,9 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default",
defaultModeId: "default",
},
optionControls: {
controls: [toggleControl("plan_mode", "list-todo")],
options: { plan_mode: true },
onSetOption: (id, value) => {
features: {
list: [toggleFeature("plan_mode", true, "list-todo")],
set: (id, value) => {
calls.push([id, value]);
},
},
@@ -347,10 +341,9 @@ describe("Command Center agent-control contributions", () => {
const calls: [string, unknown][] = [];
const contributions = buildAgentControlContributions(
makeSource({
optionControls: {
controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")],
options: { fast_mode: false, auto_accept: true },
onSetOption: (id, value) => {
features: {
list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
set: (id, value) => {
calls.push([id, value]);
},
},
@@ -383,7 +376,7 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default",
defaultModeId: "default",
},
optionControls: { controls: [toggleControl("fast_mode")], options: {} },
features: { list: [toggleFeature("fast_mode")] },
}),
);
const ranks = new Map<string, number>();
@@ -1,10 +1,9 @@
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 {
@@ -62,7 +61,7 @@ export interface AgentControlContributionIcons {
thinking: CommandCenterIcon;
planMode: CommandCenterIcon;
mode(modeId: string): CommandCenterIcon;
control(control: AgentOptionControl): CommandCenterIcon;
feature(feature: AgentFeature): CommandCenterIcon;
}
interface CommandCenterChoice {
@@ -106,7 +105,10 @@ export interface AgentControlContributionSource {
defaultModeId: string | null;
select(modeId: string): void | Promise<void>;
};
optionControls: ComposerOptionControls;
features: {
list: readonly AgentFeature[];
set(featureId: string, value: unknown): void | Promise<void>;
};
}
function buildContributions(
@@ -240,19 +242,18 @@ function toggleChoices(input: {
}
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls;
const planControl = controls.find(
(control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle",
const planFeature = source.features.list.find(
(feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
);
let choices: CommandCenterChoice[] = [];
if (planControl?.type === "toggle") {
if (planFeature?.type === "toggle") {
choices = toggleChoices({
source,
key: "plan",
icon: source.icons.control(planControl),
isOn: readToggleOption(planControl, options),
turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false),
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),
});
} else if (source.modes) {
const modes = source.modes;
@@ -279,19 +280,18 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent
}
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls;
const fastControl = controls.find(
(control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle",
const fastFeature = source.features.list.find(
(feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
);
const choices =
fastControl?.type === "toggle"
fastFeature?.type === "toggle"
? toggleChoices({
source,
key: "fast",
icon: source.icons.control(fastControl),
isOn: readToggleOption(fastControl, options),
turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false),
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),
})
: [];
return {
@@ -1,6 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types";
import type {
AgentFeature,
AgentMode,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
getAgentFeatureIcon,
@@ -9,7 +14,6 @@ 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,
@@ -40,15 +44,15 @@ export interface AgentControlCommandCenterSource {
selectedId: string | null | undefined;
select(modeId: string): void | Promise<void>;
};
/** Absent when the surface cannot change options (the agent has no client). */
optionControls: ComposerOptionControls | null;
features: {
list: readonly AgentFeature[] | undefined;
set?: (featureId: string, value: unknown) => void | Promise<void>;
};
}
const NO_OPTION_CONTROLS: ComposerOptionControls = {
controls: [],
options: {},
onSetOption: () => undefined,
};
function ignoreAgentFeatureChange(): void {
return;
}
function resolveDefaultModeId(
provider: string | null | undefined,
@@ -83,7 +87,7 @@ export function useAgentControlCommandCenterActions(input: {
}): void {
const { t } = useTranslation();
const { controls } = input;
const { optionControls, models, modes, thinking } = controls;
const { features, models, modes, thinking } = controls;
const getProviderIcon = useProviderIcons(controls.serverId);
const actions = useMemo(
() =>
@@ -100,7 +104,7 @@ export function useAgentControlCommandCenterActions(input: {
getCommandCenterIcon(
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
),
control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)),
feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
},
models: {
providers: models.providers,
@@ -119,7 +123,10 @@ export function useAgentControlCommandCenterActions(input: {
modes?.select,
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
),
optionControls: optionControls ?? NO_OPTION_CONTROLS,
features: {
list: features.set ? (features.list ?? []) : [],
set: features.set ?? ignoreAgentFeatureChange,
},
}),
[
getProviderIcon,
@@ -127,7 +134,8 @@ export function useAgentControlCommandCenterActions(input: {
controls.provider,
controls.providerDefinitions,
controls.serverId,
optionControls,
features.list,
features.set,
models.providers,
models.select,
models.selectedModelId,
+55 -50
View File
@@ -1,3 +1,4 @@
import { SidebarViewSlot } from "@/plugins/sidebar-view";
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
@@ -642,31 +643,33 @@ function MobileSidebar({
)}
<SidebarSeparator />
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarViewSlot dismiss={handleWorkspacePress}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
<SidebarFooter
handleOpenProject={handleOpenProject}
@@ -838,31 +841,33 @@ function DesktopSidebar({
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
</View>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarViewSlot dismiss={closeSidebar}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
<SidebarCalloutSlot />
+1 -9
View File
@@ -87,8 +87,6 @@ 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;
@@ -1282,10 +1280,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
);
}
function filtersLocally(input: { searchable: boolean; remoteSearch?: boolean }): boolean {
return input.searchable && !input.remoteSearch;
}
export function Combobox({
options,
value,
@@ -1293,7 +1287,6 @@ export function Combobox({
renderOption,
onSearchQueryChange,
searchable = true,
remoteSearch,
placeholder,
searchPlaceholder,
emptyText,
@@ -1453,7 +1446,7 @@ export function Combobox({
buildVisibleComboboxOptions({
options,
searchQuery,
searchable: filtersLocally({ searchable, remoteSearch }),
searchable,
allowCustomValue,
customValuePrefix,
customValueDescription,
@@ -1465,7 +1458,6 @@ export function Combobox({
customValueKind,
customValuePrefix,
options,
remoteSearch,
searchQuery,
searchable,
],
@@ -1,434 +0,0 @@
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> = {}): 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<Agent>);
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);
});
});
@@ -1,317 +0,0 @@
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<string, ManagedAgentComposer>();
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);
}
}
@@ -1,175 +0,0 @@
import React from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Scan } from "lucide-react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import type { ToastApi } from "@/components/toast-host";
import { ToastApiProvider } from "@/contexts/toast-api-context";
import { ComposerControls, type ComposerControlItem } from "./composer-control";
import { useAgentControlItems } from "./control-items";
import { resolveComposerControlPresentation } from "./layout";
beforeEach(() => vi.stubGlobal("React", React));
afterEach(cleanup);
const base = { id: "x", title: "Context", icon: Scan };
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
function createToast(): RecordingToast {
return { error: vi.fn() } as unknown as RecordingToast;
}
function ignoreOpenChange() {}
function Controls({
items,
surface,
toast,
}: {
items: ComposerControlItem[];
surface: "toolbar" | "sheet";
toast: ToastApi;
}) {
return (
<ToastApiProvider api={toast}>
<ComposerControls
items={items}
surface={surface}
openId={null}
onOpenIdChange={ignoreOpenChange}
/>
</ToastApiProvider>
);
}
function renderControls(items: ComposerControlItem[], surface: "toolbar" | "sheet" = "toolbar") {
const toast = createToast();
render(<Controls items={items} surface={surface} toast={toast} />);
return toast;
}
describe("ComposerControls", () => {
it("shows a select's chosen label, or only its icon when asked", () => {
const select: ComposerControlItem = {
...base,
type: "select",
value: "large",
choices: [
{ value: "small", label: "Small" },
{ value: "large", label: "Large" },
],
onChange: () => undefined,
};
renderControls([select]);
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("Large");
});
it("names the choice on an icon-only select, without drawing it", () => {
renderControls([
{
...base,
type: "select",
value: "large",
iconOnly: true,
choices: [{ value: "large", label: "Large" }],
onChange: () => undefined,
},
]);
const trigger = screen.getByRole("button", { name: "Context: Large" });
expect(trigger.textContent).not.toContain("Large");
});
it("flips a toggle on press in the toolbar", () => {
const onChange = vi.fn();
renderControls([{ ...base, type: "toggle", value: true, onChange }]);
fireEvent.click(screen.getByRole("button", { name: "Context" }));
expect(onChange).toHaveBeenCalledWith(false);
});
it("lists a toggle as On or Off in the sheet", () => {
renderControls([{ ...base, type: "toggle", value: true, onChange: () => undefined }], "sheet");
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On");
});
it("reports a failed command the way a built-in control does, and frees the control", async () => {
let reject: (error: Error) => void = () => undefined;
const onPress = vi.fn(
() =>
new Promise<void>((_resolve, rejectPromise) => {
reject = rejectPromise;
}),
);
const toast = renderControls([{ ...base, type: "action", onPress }]);
const button = screen.getByRole("button", { name: "Context" });
fireEvent.click(button);
// While the command is in flight the control cannot be pressed again.
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
fireEvent.click(button);
expect(onPress).toHaveBeenCalledTimes(1);
reject(new Error("Unknown or unavailable provider: x"));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Unknown or unavailable provider: x"),
);
await waitFor(() => expect(button.getAttribute("aria-disabled")).not.toBe("true"));
});
it("reports a command that throws before it returns", () => {
const toast = renderControls([
{
...base,
type: "action",
onPress: () => {
throw new Error("No such control");
},
},
]);
fireEvent.click(screen.getByRole("button", { name: "Context" }));
expect(toast.error).toHaveBeenCalledWith("No such control");
});
});
const FAST_ON = { fast_mode: true };
describe("provider option controls", () => {
const fastMode: AgentOptionControl = {
type: "toggle",
key: "fast_mode",
label: "Fast",
defaultValue: false,
};
function OptionControls({
options,
onSetOption,
}: {
options: Record<string, unknown>;
onSetOption: (key: string, value: unknown) => void;
}) {
const optionControls = React.useMemo(
() => ({ controls: [fastMode], options, onSetOption }),
[onSetOption, options],
);
const items = useAgentControlItems({
surface: "toolbar",
presentation: resolveComposerControlPresentation("full"),
thinking: null,
optionControls,
});
return <Controls items={items.features} surface="toolbar" toast={TOAST} />;
}
const TOAST = createToast();
function recordWrites(writes: Array<[string, unknown]>) {
return (key: string, value: unknown) => void writes.push([key, value]);
}
it("write the toggled option from the agent's current options", () => {
const writes: Array<[string, unknown]> = [];
const onSetOption = recordWrites(writes);
render(<OptionControls options={FAST_ON} onSetOption={onSetOption} />);
fireEvent.click(screen.getByTestId("agent-feature-fast_mode"));
expect(writes).toEqual([["fast_mode", false]]);
});
});
@@ -1,403 +0,0 @@
import {
createContext,
forwardRef,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactElement,
} from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentControlIcon } from "@/agent-controls/icons";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FieldTrigger } from "@/screens/new-workspace/form/field-trigger";
import { ICON_SIZE } from "@/styles/theme";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { useToast } from "@/contexts/toast-api-context";
import { toErrorMessage } from "@/utils/error-messages";
interface ComposerControlBase {
id: string;
title: string;
icon: AgentControlIcon;
/** Tints the icon; muted when absent. */
highlight?: "blue" | "green" | "yellow";
/** Defaults to the title, or to "title: choice" on an icon-only select. */
tooltip?: string;
/** Defaults to the tooltip. */
accessibilityLabel?: string;
testID?: string;
}
export interface ComposerControlChoice {
value: string;
label: string;
description?: string;
}
/**
* One control, drawn from data. Provider option controls, thinking and plugin controls all come
* through here, so each is an icon in the composer toolbar, a row in its sheet, or, in a New
* workspace item, a field. The name stays from when the composer was the only place it drew.
*/
export type ComposerControlItem =
| (ComposerControlBase & {
type: "toggle";
value: boolean;
onChange: (value: boolean) => void | Promise<void>;
})
| (ComposerControlBase & {
type: "select";
value: string | null;
choices: readonly ComposerControlChoice[];
/** Names a value the choices do not list, such as a pick that a search no longer returns. */
valueLabel?: string;
onChange: (value: string) => void | Promise<void>;
/** The toolbar trigger shows only the icon. */
iconOnly?: boolean;
showCaret?: boolean;
searchable?: boolean;
/** The list answers `onSearchQueryChange` itself, so it is not filtered again. */
remoteSearch?: boolean;
onSearchQueryChange?: (query: string) => void;
emptyText?: string;
desktopMinWidth?: number;
renderOption?: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
})
| (ComposerControlBase & {
type: "action";
onPress: () => void | Promise<void>;
});
/** `field`: a New workspace setup field, a badge on desktop and a full-width field when compact. */
export type ComposerControlSurface = "toolbar" | "sheet" | "field";
const ComposerControlSurfaceContext = createContext<ComposerControlSurface>("toolbar");
export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider;
const ComposerControlLockContext = createContext(false);
/** Disables every control below it, for a surface that is not a composer and has its own lock. */
export const ComposerControlLockProvider = ComposerControlLockContext.Provider;
export function useComposerControlLocked(): boolean {
return useContext(ComposerControlLockContext);
}
/** Where the control being drawn sits: the toolbar, a row in the overflow sheet, or a field. */
export function useComposerControlSurface(): ComposerControlSurface {
return useContext(ComposerControlSurfaceContext);
}
interface ComposerControlProps {
item: ComposerControlItem;
surface: ComposerControlSurface;
disabled?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Runs after a toolbar toggle or action, so the composer can take focus back. */
onActionComplete?: () => void;
}
interface ControlView {
options: ComboboxOption[];
/** The list's selected id. */
selectedId: string;
/** What the trigger shows beside the icon. */
value: string;
iconOnly: boolean;
tooltip: string;
/** Whether pressing the trigger opens a list instead of acting. */
opensList: boolean;
}
function describeControl(
item: ComposerControlItem,
surface: ComposerControlSurface,
t: TFunction,
): ControlView {
const isSheet = surface === "sheet";
if (item.type === "action") {
return {
options: [],
selectedId: "",
value: "",
iconOnly: true,
tooltip: item.tooltip ?? item.title,
opensList: false,
};
}
if (item.type === "toggle") {
const label = item.value ? t("agentControls.features.on") : t("agentControls.features.off");
return {
options: [
{ id: "true", label: t("agentControls.features.on") },
{ id: "false", label: t("agentControls.features.off") },
],
selectedId: String(item.value),
value: label,
iconOnly: true,
tooltip: item.tooltip ?? item.title,
opensList: isSheet,
};
}
const choiceLabel =
item.choices.find((choice) => choice.value === item.value)?.label ??
item.valueLabel ??
item.title;
const iconOnly = item.iconOnly === true;
return {
options: item.choices.map((choice) => ({
id: choice.value,
label: choice.label,
description: choice.description,
})),
selectedId: String(item.value ?? ""),
value: choiceLabel,
iconOnly,
// An icon-only trigger names its value; a sheet row shows the value, so it names the action.
tooltip: iconOnly && !isSheet ? `${item.title}: ${choiceLabel}` : (item.tooltip ?? item.title),
opensList: true,
};
}
type SelectItem = Extract<ComposerControlItem, { type: "select" }>;
/** The list options only a select carries; a toggle's two-choice list has none of them. */
function listProps(select: SelectItem | null) {
if (!select) return { searchable: false };
return {
desktopMinWidth: select.desktopMinWidth,
searchable: select.searchable,
remoteSearch: select.remoteSearch,
onSearchQueryChange: select.onSearchQueryChange,
emptyText: select.emptyText,
renderOption: select.renderOption,
};
}
// A command that returns a promise keeps the control pending until it settles, and a failure
// reaches the user the way a built-in live control's does.
function usePendingRun() {
const toast = useToast();
const [pending, setPending] = useState(false);
const run = useCallback(
(invoke: () => void | Promise<void>) => {
try {
const result = invoke();
if (!result) return;
setPending(true);
result
.catch((error: unknown) => toast.error(toErrorMessage(error)))
.finally(() => setPending(false));
} catch (error) {
toast.error(toErrorMessage(error));
}
},
[toast],
);
return { pending, run };
}
export function ComposerControl({
item,
surface,
disabled = false,
open,
onOpenChange,
onActionComplete,
}: ComposerControlProps) {
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const isSheet = surface === "sheet";
const isField = surface === "field";
const sheetHeader = useMemo<SheetHeader>(() => ({ title: item.title }), [item.title]);
const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]);
const { pending, run } = usePendingRun();
const handlePress = useCallback(() => {
if (item.type === "toggle") run(() => item.onChange(!item.value));
else if (item.type === "action") run(item.onPress);
onActionComplete?.();
}, [item, onActionComplete, run]);
const handleOpenPress = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
const handleSelect = useCallback(
(value: string) => {
if (item.type === "select") run(() => item.onChange(value));
else if (item.type === "toggle") run(() => item.onChange(value === "true"));
},
[item, run],
);
const select = item.type === "select" ? item : null;
const accessibilityLabel = item.accessibilityLabel ?? view.tooltip;
const trigger = isField ? (
<FieldControlTrigger
ref={anchorRef}
item={item}
view={view}
accessibilityLabel={accessibilityLabel}
disabled={disabled || pending}
onPress={view.opensList ? handleOpenPress : handlePress}
/>
) : (
<AgentControlTrigger
ref={anchorRef}
icon={item.icon}
iconColor={item.highlight ? styles[item.highlight].color : undefined}
surface={surface}
label={item.title}
value={view.value}
showToolbarLabel={!view.iconOnly}
showCaret={select?.showCaret === true && !isSheet}
open={view.opensList && open}
disabled={disabled || pending}
onPress={view.opensList ? handleOpenPress : handlePress}
accessibilityLabel={accessibilityLabel}
testID={item.testID}
/>
);
return (
<>
{isSheet || isField ? (
trigger
) : (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
{trigger}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{view.tooltip}</Text>
</TooltipContent>
</Tooltip>
)}
{view.opensList ? (
<Combobox
options={view.options}
value={view.selectedId}
onSelect={handleSelect}
open={open}
onOpenChange={onOpenChange}
anchorRef={anchorRef}
presentation={isSheet ? "push" : undefined}
desktopPlacement={DESKTOP_PLACEMENT[surface]}
{...listProps(select)}
header={isSheet ? sheetHeader : undefined}
/>
) : null}
</>
);
}
/** Where a list opens on desktop; the sheet has its own presentation. */
const DESKTOP_PLACEMENT = {
toolbar: "top-start",
sheet: undefined,
field: "bottom-start",
} as const;
/** What a field shows: a badge has no label, so a toggle names itself beside its state. */
function fieldValue(item: ComposerControlItem, view: ControlView, compact: boolean): string {
if (item.type === "action") return item.title;
if (item.type === "toggle" && !compact) return `${item.title}: ${view.value}`;
return view.value;
}
const FieldControlTrigger = forwardRef<
View,
{
item: ComposerControlItem;
view: ControlView;
accessibilityLabel: string;
disabled: boolean;
onPress: () => void;
}
>(function FieldControlTrigger({ item, view, accessibilityLabel, disabled, onPress }, ref) {
const compact = useIsCompactFormFactor();
const Icon = item.icon;
const iconColor = item.highlight ? styles[item.highlight].color : styles.fieldIcon.color;
const icon = useMemo(() => <Icon size={ICON_SIZE.sm} color={iconColor} />, [Icon, iconColor]);
return (
<FieldTrigger
ref={ref}
variant={compact ? "field" : "badge"}
label={item.title}
icon={icon}
value={fieldValue(item, view, compact)}
tooltip={view.tooltip}
accessibilityLabel={accessibilityLabel}
disabled={disabled}
onPress={onPress}
testID={item.testID}
/>
);
});
interface ComposerControlsProps {
items: readonly ComposerControlItem[];
surface: ComposerControlSurface;
disabled?: boolean;
/** The id of the item whose list is open. */
openId: string | null;
onOpenIdChange: (id: string | null) => void;
onActionComplete?: () => void;
}
/** A row of Paseo-owned controls that share one open list at a time. */
export function ComposerControls({
items,
onOpenIdChange,
openId,
...rest
}: ComposerControlsProps) {
return (
<>
{items.map((item) => (
<ComposerControlSlot
key={item.id}
item={item}
open={openId === item.id}
onOpenIdChange={onOpenIdChange}
{...rest}
/>
))}
</>
);
}
function ComposerControlSlot({
item,
open,
onOpenIdChange,
...rest
}: Omit<ComposerControlsProps, "items" | "openId"> & { item: ComposerControlItem; open: boolean }) {
const handleOpenChange = useCallback(
(next: boolean) => onOpenIdChange(next ? item.id : null),
[item.id, onOpenIdChange],
);
return <ComposerControl item={item} open={open} onOpenChange={handleOpenChange} {...rest} />;
}
const styles = StyleSheet.create((theme) => ({
fieldIcon: { color: theme.colors.foregroundMuted },
blue: { color: theme.colors.palette.blue[400] },
green: { color: theme.colors.palette.green[400] },
yellow: { color: theme.colors.palette.yellow[400] },
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: theme.fontSize.base * 1.4,
},
}));
@@ -1,165 +0,0 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
type ComposerOptionControls,
} from "@/agent-controls/option-controls";
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import {
type ComposerControlItem,
type ComposerControlSurface,
} from "@/composer/agent-controls/composer-control";
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
import { getAgentControlHintKey, getFeatureHighlightColor } from "@/composer/agent-controls/utils";
const SEARCH_THRESHOLD = 6;
function getFeatureHighlight(
control: AgentOptionControl,
options: ProviderOptions,
): ComposerControlItem["highlight"] {
if (!isControlActive(control, options)) return undefined;
const color = getFeatureHighlightColor(control.key);
return color === "default" ? undefined : color;
}
function toFeatureItem(
control: AgentOptionControl,
optionControls: ComposerOptionControls,
): ComposerControlItem {
const { options, onSetOption } = optionControls;
const base = {
id: `feature-${control.key}`,
title: control.label,
icon: getAgentFeatureIcon(control.icon),
highlight: getFeatureHighlight(control, options),
testID: `agent-feature-${control.key}`,
};
if (control.type === "toggle") {
return {
...base,
type: "toggle",
tooltip: getControlTooltip(control),
value: readToggleOption(control, options),
onChange: (value) => onSetOption(control.key, value),
};
}
const iconOnly = control.desktopTrigger === "icon";
return {
...base,
type: "select",
tooltip: getControlTooltip(control),
value: readSelectOption(control, options),
choices: control.choices,
iconOnly,
onChange: (value) => onSetOption(control.key, value),
};
}
interface ThinkingControl {
options: readonly { id: string; label: string }[];
selectedId: string | undefined;
onSelect: (id: string) => void;
/** The select's displayed label, falling back like the compact model sheet. */
displayLabel: string;
}
/** The thinking select and the option controls, as the data `ComposerControl` draws. */
export function useAgentControlItems({
surface,
presentation,
thinking,
optionControls,
}: {
surface: ComposerControlSurface;
presentation: ComposerControlPresentation;
thinking: ThinkingControl | null;
optionControls: ComposerOptionControls | undefined;
}): { thinking: ComposerControlItem[]; features: ComposerControlItem[] } {
const { t } = useTranslation();
const thinkingItem = useMemo(
() => (thinking ? [toThinkingItem({ thinking, surface, presentation, t })] : []),
[presentation, surface, t, thinking],
);
const features = useMemo(
() =>
optionControls
? optionControls.controls.map((control) => toFeatureItem(control, optionControls))
: [],
[optionControls],
);
return { thinking: thinkingItem, features };
}
function toThinkingItem({
thinking,
surface,
presentation,
t,
}: {
thinking: ThinkingControl;
surface: ComposerControlSurface;
presentation: ComposerControlPresentation;
t: TFunction;
}): ComposerControlItem {
const isSheet = surface === "sheet";
return {
id: "thinking",
type: "select",
title: t("agentControls.thinking.title"),
icon: ThinkingIcon,
tooltip: t(getAgentControlHintKey("thinking")),
accessibilityLabel: t("agentControls.thinking.selectWithValue", {
value: thinking.displayLabel,
}),
testID: isSheet ? "agent-controls-thinking" : "agent-thinking-selector",
value: thinking.options.some((option) => option.id === thinking.selectedId)
? (thinking.selectedId ?? null)
: (thinking.options[0]?.id ?? null),
choices: thinking.options.map((option) => ({ value: option.id, label: option.label })),
onChange: thinking.onSelect,
iconOnly: !presentation.showThinkingLabel,
showCaret: presentation.showCarets,
searchable: !isSheet && thinking.options.length > SEARCH_THRESHOLD,
desktopMinWidth: 200,
renderOption: (args) => <ThinkingOption {...args} />,
};
}
function ThinkingOption({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) {
const leadingSlot = useMemo(
() => <ThinkingIcon size={16} color={styles.thinkingIcon.color} />,
[],
);
return (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
const styles = StyleSheet.create((theme) => ({
thinkingIcon: { color: theme.colors.foreground },
}));
File diff suppressed because it is too large Load Diff
@@ -46,7 +46,6 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
pluginItems: 0,
fontScale: 1,
};
@@ -115,7 +114,6 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
fontScale: 1,
},
}),
@@ -128,7 +126,6 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
pluginItems: 0,
fontScale: 1,
},
}),
@@ -141,7 +138,6 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
fontScale: 1.25,
},
}),
@@ -157,7 +153,6 @@ describe("composer control layout", () => {
hasModel: true,
hasThinking: true,
hasMode: true,
pluginItems: 0,
fontScale: 1,
},
};
@@ -179,36 +174,6 @@ 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 control sizing", () => {
expect(POINTER).toEqual({
controlSize: 28,
@@ -231,7 +196,6 @@ describe("composer control layout", () => {
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
pluginItems: 0,
},
};
@@ -248,7 +212,6 @@ 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", () => {
@@ -296,7 +259,6 @@ describe("composer control layout", () => {
hasMode: true,
features: [],
fontScale: 1,
pluginItems: 0,
},
};
@@ -24,8 +24,6 @@ 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;
}
@@ -63,7 +61,6 @@ 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;
/** A thinking label beside the folded model label, such as "Medium". */
@@ -113,9 +110,7 @@ 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 || controls.pluginItems > 0) {
widths.push(geometry.controlSize);
}
if (controls.features.length > 0) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
}
@@ -133,11 +128,6 @@ 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);
}
@@ -153,9 +143,7 @@ function resolveTightFloor(
}
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(geometry.controlSize);
if (controls.features.length > 0 || controls.pluginItems > 0) {
widths.push(geometry.controlSize);
}
if (controls.features.length > 0) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
}
@@ -25,19 +25,15 @@ describe("resolveAgentControlsMode", () => {
onSelectMode: () => undefined,
models: [],
selectedModel: "",
onSelectModel: () => true,
onSelectModel: () => undefined,
isModelLoading: false,
modelSelectorProviders: [],
isAllModelsLoading: false,
onSelectProviderAndModel: () => true,
options: {},
onSetOption: () => undefined,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [],
selectedThinkingOptionId: "",
onSelectThinkingOption: () => undefined,
onApplyAgentProfile: () => undefined,
metadata: {},
onSetMetadata: () => undefined,
}),
).toBe("draft");
});
@@ -5,7 +5,6 @@ import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { useProviderIcon } from "@/components/provider-icons";
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
@@ -47,8 +46,6 @@ 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;
}
@@ -102,7 +99,6 @@ export function CompactModelSheet({
glyphSize,
showLabels,
canSwitchProvider,
contextBridge = null,
children,
}: CompactModelSheetProps) {
const { t } = useTranslation();
@@ -311,7 +307,6 @@ export function CompactModelSheet({
snapPoints={SNAP_POINTS}
scrollable={false}
contentStyle={styles.sheetBody}
contextBridge={contextBridge}
testID="agent-controls-model-sheet"
>
<View
@@ -360,7 +355,6 @@ export function CompactModelSheet({
scrollable={false}
presentation="push"
contentStyle={styles.sheetBody}
contextBridge={contextBridge}
testID="agent-controls-model-browser-sheet"
>
<View
@@ -1,47 +0,0 @@
import { useCallback } from "react";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { ComposerControlLayoutProvider, useComposerControlLayout } from "./layout-context";
import {
ComposerAgentProvider,
ComposerInputProvider,
useComposerAgent,
useComposerInput,
} from "@/composer/session";
import { ComposerModelProvider, useComposerModel } from "@/composer/model";
import { ToastApiProvider, useToast } from "@/contexts/toast-api-context";
/**
* A phone's overflow draws in a sheet whose content lands at the app root, away from the
* composer. This carries across what a control in it reads from the composer: the agent, model and input
* a plugin's `useComposer()` sits on, the layout, and the toast and query client the controls use.
* Call it from inside the composer, outside any sheet.
*/
export function useComposerSheetBridge(): ContextBridge {
const layout = useComposerControlLayout();
const agent = useComposerAgent();
const input = useComposerInput();
const model = useComposerModel();
const toast = useToast();
const queryClient = useQueryClient();
return useCallback<ContextBridge>(
(content) => (
<QueryClientProvider client={queryClient}>
<ToastApiProvider api={toast}>
<ComposerControlLayoutProvider value={layout}>
<ComposerAgentProvider value={agent}>
<ComposerModelProvider value={model}>
{input ? (
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
) : (
content
)}
</ComposerModelProvider>
</ComposerAgentProvider>
</ComposerControlLayoutProvider>
</ToastApiProvider>
</QueryClientProvider>
),
[agent, input, layout, model, queryClient, toast],
);
}
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHintKey,
isFeatureActive,
normalizeModelId,
resolveAgentModelSelection,
} from "./utils";
@@ -15,12 +17,51 @@ 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", () => {
@@ -1,4 +1,4 @@
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import {
@@ -35,6 +35,16 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized;
}
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): 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:
@@ -49,14 +59,6 @@ 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,
@@ -1,189 +0,0 @@
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");
});
});
@@ -1,50 +0,0 @@
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),
};
}
@@ -1,4 +1,3 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
@@ -21,26 +20,12 @@ export function resolveDraftKey(input: {
export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult;
optionControls?: DraftAgentControlsProps["optionControls"];
options: DraftAgentControlsProps["options"];
onSetOption: DraftAgentControlsProps["onSetOption"];
features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
context: NonNullable<DraftAgentControlsProps["context"]>;
metadata: Record<string, JsonValue>;
onSetMetadata: (patch: Record<string, JsonValue>) => void;
}): DraftAgentControlsProps {
const {
formState,
optionControls,
options,
onSetOption,
onApplyAgentProfile,
onDropdownClose,
context,
metadata,
onSetMetadata,
} = input;
const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
@@ -58,17 +43,13 @@ export function buildDraftAgentControls(input: {
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
onApplyAgentProfile,
optionControls,
options,
onSetOption,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
onRetryModelProvider: formState.refreshProviderModels,
isRetryingModelProvider: formState.isProviderModelsRefreshing,
modelSelectorServerId: formState.selectedServerId,
context,
metadata,
onSetMetadata,
};
}
@@ -144,7 +144,6 @@ describe("useAgentInputDraft live contract", () => {
drafts: {},
createModalDraft: null,
attachmentFocusRequestByDraftKey: {},
textReplacementRequestByDraftKey: {},
});
});
@@ -487,37 +486,6 @@ describe("useAgentInputDraft live contract", () => {
});
});
it("shows text another module put in the draft while the input is retained off screen", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
function getLatest(): ReturnType<typeof useAgentInputDraft> {
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(
<QueryClientProvider client={new QueryClient()}>
<Probe />
</QueryClientProvider>,
);
});
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<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
+42 -61
View File
@@ -1,5 +1,4 @@
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";
@@ -9,9 +8,10 @@ import {
type CreateAgentInitialValues,
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
type DraftKeyInput,
} from "@/composer/draft/input-draft-core";
@@ -22,14 +22,12 @@ import {
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore, type DraftInput } from "@/stores/draft-store";
import { useDraftStore } 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<string, JsonValue> = {};
type AttachmentUpdater =
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
@@ -37,9 +35,7 @@ type AttachmentUpdater =
interface AgentInputDraftComposerOptions {
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
initialProviderOptions?: Record<string, unknown>;
/** Which composer a plugin's `useComposer()` reports. */
context?: "draft" | "new-workspace";
initialFeatureValues?: Record<string, unknown>;
isVisible?: boolean;
lockedWorkingDir?: string;
}
@@ -54,9 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveModeId: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
providerOptions: Record<string, unknown> | undefined;
/** What plugins set on the agent this draft creates. */
metadata: Record<string, JsonValue>;
featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps;
commandDraft: DraftCommandTarget;
};
@@ -141,8 +135,21 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
);
const saveDraft = useCallback(
(update: (draft: DraftInput) => DraftInput) =>
useDraftStore.getState().updateDraftInput({ draftKey, update }),
(
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 });
},
[draftKey],
);
@@ -154,20 +161,6 @@ 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) {
@@ -198,18 +191,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[saveDraft],
);
const metadata = useDraftStore((state) => state.metadataByDraftKey[draftKey] ?? NO_METADATA);
const setMetadata = useCallback(
(patch: Record<string, JsonValue>) =>
useDraftStore.getState().patchDraftMetadata({ draftKey, patch }),
[draftKey],
);
const clear = useCallback(
(lifecycle: "sent" | "abandoned") => {
textPublication.cancel();
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
if (lifecycle === "sent") useDraftStore.getState().clearDraftMetadata({ draftKey });
},
[draftKey, textPublication],
);
@@ -289,28 +274,26 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
);
const {
optionControls: draftOptionControls,
options: draftOptions,
shownOptions: draftShownOptions,
setOption: setDraftOption,
applyProfile: applyProfileOptions,
} = useDraftProviderOptions({
features: draftFeatures,
featureValues: draftFeatureValues,
setFeatureValue: setDraftFeatureValue,
applyProfileFeatureValues,
} = useDraftAgentFeatures({
serverId: formState.selectedServerId,
provider: formState.selectedProvider,
getProvider: formState.getSelectedProvider,
cwd: workingDir,
modeId: formState.selectedMode,
modelId: effectiveModelId,
thinkingOptionId: effectiveThinkingOptionId,
initialOptions: composerOptions?.initialProviderOptions,
initialFeatureValues: composerOptions?.initialFeatureValues,
});
const applyDraftAgentProfile = useCallback(
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
formState.applyProfileFromUser(profile);
applyProfileOptions({ provider: profile.provider, options: profile.options });
applyProfileFeatureValues(profile.featureValues);
},
[applyProfileOptions, formState],
[applyProfileFeatureValues, formState],
);
const commandDraft = useMemo(
@@ -320,9 +303,15 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
providerOptions: draftOptions,
featureValues: draftFeatureValues,
}),
[effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir],
[
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
providerSelection,
workingDir,
],
);
const composerState = useMemo<DraftComposerState | null>(() => {
@@ -336,17 +325,12 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId,
effectiveModelId,
effectiveThinkingOptionId,
providerOptions: draftOptions,
metadata,
featureValues: draftFeatureValues,
agentControls: buildDraftAgentControls({
formState,
optionControls: draftOptionControls,
options: draftShownOptions,
onSetOption: setDraftOption,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile,
context: composerOptions.context ?? "draft",
metadata,
onSetMetadata: setMetadata,
}),
commandDraft,
};
@@ -356,14 +340,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId,
effectiveModelId,
effectiveThinkingOptionId,
draftOptionControls,
draftOptions,
draftShownOptions,
setDraftOption,
draftFeatures,
draftFeatureValues,
applyDraftAgentProfile,
formState,
metadata,
setMetadata,
setDraftFeatureValue,
workingDir,
]);
@@ -1,19 +0,0 @@
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 });
});
});
@@ -1,14 +0,0 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
/** Top-level keys replace; `null` removes the key. The daemon applies the same rule to an agent. */
export function applyMetadataPatch(
current: Readonly<Record<string, JsonValue>>,
patch: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
const next = { ...current };
for (const [key, value] of Object.entries(patch)) {
if (value === null) delete next[key];
else next[key] = value;
}
return next;
}
@@ -1,6 +1,4 @@
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
@@ -67,7 +65,7 @@ interface AutoSubmitConfig {
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
providerOptions: Record<string, unknown>;
featureValues: Record<string, unknown>;
}
function resolveAutoSubmitConfig(
@@ -76,7 +74,7 @@ function resolveAutoSubmitConfig(
modeId?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
} | null,
): AutoSubmitConfig | null {
if (!pending) return null;
@@ -85,7 +83,7 @@ function resolveAutoSubmitConfig(
modeId: pending.modeId ?? null,
model: pending.model ?? null,
thinkingOptionId: pending.thinkingOptionId ?? null,
providerOptions: pending.providerOptions ?? {},
featureValues: pending.featureValues ?? {},
};
}
@@ -133,8 +131,7 @@ async function submitDraftCreateRequest(input: {
effectiveModeId: string;
effectiveModelId: string | null;
effectiveThinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined;
metadata: Record<string, JsonValue>;
featureValues: Record<string, unknown> | undefined;
};
hostDisconnectedMessage: string;
selectModelMessage: string;
@@ -173,7 +170,7 @@ async function submitDraftCreateRequest(input: {
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId:
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
providerOptions: autoSubmitConfig?.providerOptions ?? composerState.providerOptions,
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
@@ -184,7 +181,6 @@ 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 } : {}),
};
@@ -208,7 +204,7 @@ function buildDraftAgentSnapshot(input: {
effectiveThinkingOptionId: string | null;
effectiveModeId: string;
selectedProvider: string | null;
agentControls: { optionControls?: ComposerOptionControls };
agentControls: { features?: Agent["features"] };
};
selectModelMessage: string;
}): Agent {
@@ -245,8 +241,7 @@ function buildDraftAgentSnapshot(input: {
title: "Agent",
cwd: workspaceDirectory,
model,
options: composerState.agentControls.optionControls?.options,
controls: composerState.agentControls.optionControls?.controls,
features: composerState.agentControls.features,
thinkingOptionId,
parentAgentId: null,
labels: {},
@@ -338,7 +333,7 @@ export function WorkspaceDraftAgentTab({
composer: {
initialServerId: serverId,
initialValues: draftInitialValues,
initialProviderOptions: draftSetup?.providerOptions,
initialFeatureValues: draftSetup?.featureValues,
isVisible: true,
lockedWorkingDir: draftWorkingDirectory ?? undefined,
},
@@ -356,7 +351,8 @@ export function WorkspaceDraftAgentTab({
const draftModeOptions = composerState.modeOptions;
const draftSelectedMode = composerState.selectedMode;
const draftSetMode = composerState.setModeFromUser;
const draftOptionControls = composerState.agentControls.optionControls;
const draftFeatures = composerState.agentControls.features;
const draftOnSetFeature = composerState.agentControls.onSetFeature;
const clearDraftInput = draftInput.clear;
const replaceDraftText = draftInput.replaceText;
@@ -513,9 +509,7 @@ export function WorkspaceDraftAgentTab({
providers: composerState.modelSelectorProviders,
selectedProvider: draftProvider,
selectedModelId: composerState.effectiveModelId,
select: (provider, modelId) => {
composerState.setProviderAndModelFromUser(provider, modelId);
},
select: composerState.setProviderAndModelFromUser,
},
thinking: {
options: draftThinkingOptions,
@@ -527,7 +521,10 @@ export function WorkspaceDraftAgentTab({
selectedId: draftSelectedMode,
select: draftSetMode,
},
optionControls: draftOptionControls ?? null,
features: {
list: draftFeatures,
set: draftOnSetFeature,
},
},
});
const isReadyForPendingAutoSubmit = Boolean(
-64
View File
@@ -1,64 +0,0 @@
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<void>): Promise<void> {
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)),
};
}
+12 -61
View File
@@ -123,7 +123,6 @@ 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";
@@ -146,8 +145,6 @@ 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";
@@ -1424,43 +1421,6 @@ 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<ComposerInput>(
() => ({
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) {
@@ -2264,27 +2224,18 @@ function ComposerContentImpl({
[attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
);
const leftContent = useMemo(() => {
const controls = renderLeftContent({
agentControls,
agentId,
serverId,
focusInput,
isCompactLayout,
showAgentControls: mode.showAgentControls,
});
return controls ? (
<ComposerInputProvider value={composerInput}>{controls}</ComposerInputProvider>
) : null;
}, [
agentControls,
agentId,
composerInput,
focusInput,
isCompactLayout,
mode.showAgentControls,
serverId,
]);
const leftContent = useMemo(
() =>
renderLeftContent({
agentControls,
agentId,
serverId,
focusInput,
isCompactLayout,
showAgentControls: mode.showAgentControls,
}),
[agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
attachButtonRef.current = node;
@@ -1,47 +0,0 @@
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 (
<RetainedPanelActivity active={active}>
<Probe />
</RetainedPanelActivity>
);
}
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());
});
@@ -1,14 +0,0 @@
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]);
}
@@ -1,18 +0,0 @@
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 },
});
});
});
-15
View File
@@ -1,15 +0,0 @@
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 },
};
}
-50
View File
@@ -1,50 +0,0 @@
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<string, JsonValue>;
}
/**
* 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<void>;
setModel(modelId: string): Promise<void>;
setMode(modeId: string): Promise<void>;
setThinking(thinkingOptionId: string): Promise<void>;
/** Top-level keys replace; `null` removes the agent's own value. */
setOptions(patch: ProviderOptions): Promise<void>;
/** Top-level keys replace; `null` removes the key. */
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
}
const ComposerModelContext = createContext<ComposerModel | null>(null);
export const ComposerModelProvider = ComposerModelContext.Provider;
/** The model of the composer this control sits in. */
export function useComposerModel(): ComposerModel | null {
return useContext(ComposerModelContext);
}
-94
View File
@@ -1,94 +0,0 @@
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<string, JsonValue>;
setProvider: (provider: string) => Promise<void>;
setModel: (modelId: string) => Promise<void>;
setMode: (modeId: string) => Promise<void>;
setThinking: (thinkingOptionId: string) => Promise<void>;
/** Top-level keys replace; `null` removes the agent's own value. */
setOptions: (patch: ProviderOptions) => Promise<void>;
/** Top-level keys replace; `null` removes the key. */
setMetadata: (patch: Record<string, JsonValue>) => Promise<void>;
}
const ComposerInputContext = createContext<ComposerInput | null>(null);
const ComposerAgentContext = createContext<ComposerAgent | null>(null);
export function ComposerInputProvider({
value,
children,
}: {
value: ComposerInput;
children: ReactNode;
}) {
return <ComposerInputContext.Provider value={value}>{children}</ComposerInputContext.Provider>;
}
export function ComposerAgentProvider({
value,
children,
}: {
value: ComposerAgent | null;
children: ReactNode;
}) {
return <ComposerAgentContext.Provider value={value}>{children}</ComposerAgentContext.Provider>;
}
export function useComposerInput(): ComposerInput | null {
return useContext(ComposerInputContext);
}
export function useComposerAgent(): ComposerAgent | null {
return useContext(ComposerAgentContext);
}
/** True while the composer holds its prompt or its agent's settings for a submit in flight. */
export function useComposerLocked(): boolean {
const input = useComposerInput();
const agent = useComposerAgent();
return Boolean(input?.locked || agent?.locked);
}
@@ -80,7 +80,7 @@ describe("create agent preferences", () => {
modelId: "gpt-5.5",
modeId: "full-access",
thinkingOptionId: "high",
options: { fast_mode: true },
featureValues: { fast_mode: true },
}),
);
@@ -95,7 +95,7 @@ describe("create agent preferences", () => {
model: "gpt-5.5",
mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" },
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
},
});
@@ -119,7 +119,7 @@ describe("create agent preferences", () => {
model: "gpt-5.6",
mode: undefined,
thinkingByModel: undefined,
options: undefined,
featureValues: undefined,
},
}),
).toEqual({
@@ -167,13 +167,13 @@ describe("create agent preferences", () => {
modelId: "one-minute-stream",
modeId: "approval-test",
thinkingOptionId: "",
options: {},
featureValues: {},
}),
).toEqual({
provider: "mock",
providerPreferences: {
pi: { model: "anthropic/sonnet" },
mock: { model: "one-minute-stream", mode: "approval-test", options: {} },
mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} },
},
});
});
@@ -208,29 +208,6 @@ 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("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({});
});
@@ -1,14 +1,13 @@
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
const optionsSchema = z.record(z.string(), z.json());
const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()]));
export interface ProviderPreferences {
model?: string;
mode?: string;
thinkingByModel?: Record<string, string>;
/** Last-used values of the provider's option controls, by option key. */
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
@@ -21,23 +20,12 @@ export interface FormPreferences {
launchTarget?: LaunchTarget;
}
// 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<string, unknown>;
return "options" in rest ? rest : { ...rest, options: featureValues };
}
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.preprocess(
renameLegacyFeatureValues,
z.strictObject({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string(), z.string()).optional(),
options: optionsSchema.optional(),
}),
);
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.strictObject({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string(), z.string()).optional(),
featureValues: featureValuesSchema.optional(),
});
const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("chat") }),
@@ -128,7 +116,7 @@ function applyProviderPreferenceUpdates(
): ProviderPreferences {
const next: ProviderPreferences = { ...existing };
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
const nextOptions = mergeDefinedRecord(existing.options, updates.options);
const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues);
if (updates.model !== undefined) {
next.model = updates.model;
@@ -141,8 +129,8 @@ function applyProviderPreferenceUpdates(
if (nextThinkingByModel !== undefined) {
next.thinkingByModel = nextThinkingByModel;
}
if (nextOptions !== undefined) {
next.options = nextOptions;
if (nextFeatureValues !== undefined) {
next.featureValues = nextFeatureValues;
}
return next;
@@ -173,7 +161,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
modelId?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}): FormPreferences {
if (!args.provider) {
return args.preferences;
@@ -182,7 +170,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
const options = optionsSchema.safeParse(args.options);
const featureValues = featureValuesSchema.safeParse(args.featureValues);
return mergeProviderPreferences({
preferences: args.preferences,
@@ -191,7 +179,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
model: modelId || undefined,
mode: args.modeId === undefined ? undefined : modeId || null,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(options.success ? { options: options.data } : {}),
...(featureValues.success ? { featureValues: featureValues.data } : {}),
},
});
}
@@ -204,7 +192,7 @@ export function applyAgentProfilePreferences(args: {
modelId: string;
modeId: string;
thinkingOptionId: string;
options: Record<string, unknown>;
featureValues: Record<string, unknown>;
}): FormPreferences {
let next = args.preferences;
if (args.previousProvider) {
@@ -227,7 +215,7 @@ export function applyAgentProfilePreferences(args: {
...(args.modelId && args.thinkingOptionId
? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } }
: {}),
options: args.options,
featureValues: args.featureValues,
},
});
}
@@ -119,36 +119,6 @@ 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<DaemonClient, "refreshProvidersSnapshot">;
queryClient: QueryClient;
+4 -4
View File
@@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
} {
const handlers: Record<RouterMessageType, RouterHandler[]> = {
providers_snapshot_update: [],
@@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
function on<K extends RouterMessageType>(
type: K,
+6 -3
View File
@@ -41,8 +41,11 @@ export function useReplicaQuery<
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input));
>(
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input), queryClient);
}
export function useFetchQuery<
@@ -63,7 +66,7 @@ export function useFetchQueries<TData>(
return useQueries({ queries: inputs.map((input) => fetchQueryOptions(input)) });
}
export function replicaQueryOptions<
function replicaQueryOptions<
TQueryFnData,
TError = Error,
TData = TQueryFnData,
@@ -0,0 +1,53 @@
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,
});
});
});
@@ -0,0 +1,60 @@
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
export function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
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<string, unknown>,
): 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<string, unknown>;
localFeatureValues: Record<string, unknown>;
}): Record<string, unknown> {
const next: Record<string, unknown> = {};
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;
}
@@ -128,14 +128,14 @@ function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const providerOptions = draftConfig.providerOptions;
const featureValues = draftConfig.featureValues;
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
...(providerOptions && Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
};
}
+51 -56
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import type {
AgentMode,
@@ -28,13 +28,14 @@ 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";
@@ -51,8 +52,6 @@ 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;
@@ -75,20 +74,13 @@ export interface UseAgentFormStateResult {
modelError: string | null;
refreshProviderModels: (provider?: AgentProvider) => void;
refetchProviderModelsIfStale: () => void;
/** `false` when the provider is unknown or not ready. */
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => boolean;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
applyProfileFromUser: (profile: MaterializedAgentProfile) => void;
clearProviderSelectionFromUser: () => void;
workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>;
}
const EMPTY_CATALOG: AgentFormCatalog = {
selectableProviders: new Map(),
models: new Map(),
providerPreferences: () => undefined,
};
function resolveSelectedProviderModes(input: {
selectedEntry: ProviderSnapshotEntry | null;
provider: AgentProvider | null;
@@ -180,15 +172,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
[updatePreferences],
);
// The store reads what the host offers when a command runs; each render publishes the latest.
const catalogRef = useRef<AgentFormCatalog>(EMPTY_CATALOG);
const [formStore] = useState(() => openAgentForm(() => catalogRef.current));
const { form: formState, resolution } = useSyncExternalStore(
formStore.subscribe,
formStore.getState,
formStore.getState,
);
const dispatch = formStore.dispatch;
const [{ form: formState, userModified, resolution }, dispatch] = useReducer(resolveAgentForm, {
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
userModified: INITIAL_USER_MODIFIED,
resolution: INITIAL_AGENT_FORM_RESOLUTION,
});
const {
entries: snapshotEntries,
@@ -258,12 +246,6 @@ 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;
@@ -295,15 +277,27 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
preferences,
snapshotProviderModelsByProvider,
snapshotResolvableProviderDefinitionMap,
dispatch,
]);
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string): boolean => {
if (!formStore.setProviderAndModel(provider, modelId)) return false;
(provider: AgentProvider, modelId: string) => {
if (!selectableProviderDefinitionMap.has(provider)) {
return;
}
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerModels = allProviderModels.get(provider) ?? null;
const nextModelId =
normalizeSelectedModelId(modelId) || resolveDefaultModelId(providerModels);
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,
});
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
@@ -313,14 +307,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
},
}),
);
return true;
},
[allProviderModels, formStore, updateCurrentPreferences],
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
);
const clearProviderSelectionFromUser = useCallback(() => {
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
}, [dispatch]);
}, []);
const applyProfileFromUser = useCallback(
(profile: MaterializedAgentProfile) => {
@@ -329,8 +322,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
return;
}
const formBefore = formStore.getState();
const previousProvider = formBefore.form.provider;
const previousProvider = formState.provider;
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerModels = allProviderModels.get(provider) ?? null;
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
@@ -344,7 +336,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
providerModels,
providerPrefs,
};
const nextState = resolveAgentForm(formBefore, action);
const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action);
const previousProviderModeIds = previousProvider
? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? [])
: [];
@@ -360,7 +352,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
modelId: model,
modeId,
thinkingOptionId,
options: profile.options,
featureValues: profile.featureValues,
});
}).catch((error) => {
console.warn("[useAgentFormState] persist profile preference failed", error);
@@ -368,18 +360,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
},
[
allProviderModels,
dispatch,
formStore,
formState,
providerDefinitionMap,
resolution,
selectableProviderDefinitionMap,
updateCurrentPreferences,
userModified,
],
);
const setModeFromUser = useCallback(
(modeId: string) => {
dispatch({ type: "SET_MODE_FROM_USER", modeId });
const { provider } = formStore.getState().form;
const provider = formState.provider;
if (provider) {
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
@@ -392,18 +385,24 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
);
}
},
[dispatch, formStore, updateCurrentPreferences],
[formState.provider, updateCurrentPreferences],
);
const setModelFromUser = useCallback(
(modelId: string) => {
formStore.setModel(modelId);
const { provider } = formStore.getState().form;
const provider = formState.provider;
const providerPrefs = provider
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
: undefined;
dispatch({
type: "SET_MODEL_FROM_USER",
modelId,
availableModels,
providerPrefs,
});
if (provider) {
const listed = allProviderModels.get(provider);
const nextModelId =
normalizeSelectedModelId(modelId) ||
resolveDefaultModelId(listed?.length ? listed : null);
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
@@ -415,13 +414,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
);
}
},
[allProviderModels, formStore, updateCurrentPreferences],
[availableModels, formState.provider, updateCurrentPreferences],
);
const setThinkingOptionFromUser = useCallback(
(thinkingOptionId: string) => {
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
const { provider, model: modelId } = formStore.getState().form;
const { provider, model: modelId } = formState;
if (provider && modelId) {
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
@@ -436,11 +435,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
);
}
},
[dispatch, formStore, updateCurrentPreferences],
[formState, updateCurrentPreferences],
);
const getSelectedProvider = useCallback(() => formStore.getState().form.provider, [formStore]);
const refreshProviderModels = useCallback(
(provider?: AgentProvider) => {
void refreshSnapshot(provider ? [provider] : undefined);
@@ -482,7 +479,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
() => ({
selectedServerId: serverId,
selectedProvider: formState.provider,
getSelectedProvider,
selectedMode: formState.modeId,
setModeFromUser,
selectedModel: formState.model,
@@ -513,7 +509,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
}),
[
serverId,
getSelectedProvider,
formState.provider,
formState.modeId,
formState.model,
@@ -1,6 +1,9 @@
import { useRef } from "react";
import type { AgentCapabilityFlags, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { Agent } from "@/stores/session-store";
import type {
AgentCapabilityFlags,
AgentFeature,
AgentProvider,
} from "@getpaseo/protocol/agent-types";
import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync";
export interface AgentScreenAgent {
@@ -19,8 +22,7 @@ export interface AgentScreenAgent {
modeId?: string | null;
thinkingOptionId?: string | null;
} | null;
options?: Agent["options"];
controls?: Agent["controls"];
features?: readonly AgentFeature[];
lastError?: string | null;
projectPlacement?: {
projectKey?: string;
@@ -0,0 +1,159 @@
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<string, unknown>;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
input;
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
() => initialFeatureValues ?? {},
);
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || "";
const normalizedProvider = provider ?? null;
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
const persistedFeatureValues = useMemo(
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
[preferences.providerPreferences, provider],
);
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
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<string, unknown>) => {
setLocalFeatureValues(values);
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
applyProfileFeatureValues,
};
}
@@ -1,134 +0,0 @@
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<ComposerOptionControls | undefined>(
() =>
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,
};
}
+7 -2
View File
@@ -36,7 +36,7 @@ export type ForkAgentSource = Pick<
| "model"
| "thinkingOptionId"
| "runtimeInfo"
| "options"
| "features"
| "projectPlacement"
>;
@@ -95,13 +95,18 @@ function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | u
return undefined;
}
const featureValues: Record<string, unknown> = {};
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,
providerOptions: { ...agent.options },
featureValues,
};
}
@@ -63,7 +63,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: {
codex: {
model: "gpt-5.4",
options: {
featureValues: {
fast_mode: true,
},
},
@@ -71,7 +71,7 @@ describe("mergeProviderPreferences", () => {
},
provider: "codex",
updates: {
options: {
featureValues: {
plan_mode: true,
},
},
@@ -81,7 +81,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: {
codex: {
model: "gpt-5.4",
options: {
featureValues: {
fast_mode: true,
plan_mode: true,
},
@@ -10,7 +10,6 @@ import { useReplicaQuery } from "@/data/query";
import { queryClient as singletonQueryClient } from "@/data/query-client";
import {
normalizeProvidersSnapshotCwd,
providersSnapshotQuery,
providersSnapshotQueryKey,
providersSnapshotQueryRoot,
fetchProvidersSnapshot,
@@ -79,16 +78,17 @@ export function useProvidersSnapshot(
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
const snapshotQuery = useReplicaQuery({
...providersSnapshotQuery({
serverId: serverId ?? "",
cwd,
client: () => {
if (!client || !serverId) throw new Error(t("workspace.terminal.hostDisconnected"));
return client;
},
queryClient,
}),
queryKey,
// The content-hash cache already owns catalog and model identity.
structuralSharing: false,
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({
+14 -5
View File
@@ -2,11 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
pluginCombobox: {
searching: "جارٍ البحث…",
empty: "لا توجد نتائج",
failed: "فشل البحث: {{message}}",
},
paneFind: {
connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.",
@@ -1166,6 +1161,14 @@ export const ar: TranslationResources = {
},
},
sidebar: {
view: {
failed: "تعذر تحميل عرض الشريط الجانبي.",
useDefault: "استخدام العرض الافتراضي",
readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.",
agentUnavailable: "لم يعد الوكيل متاحًا",
terminalUnavailable: "لم تعد الطرفية متاحة",
projectUnavailable: "لم يعد المشروع متاحًا",
},
statusGroupAccessibility: "مجموعة {{label}}",
statusBucket: {
needsInput: "تحتاج إدخالاً",
@@ -2586,6 +2589,12 @@ export const ar: TranslationResources = {
description: "عرض مخطط للتنقل بين المطالبات",
},
sidebar: {
view: {
title: "عرض الشريط الجانبي",
label: "العرض",
default: "العرض الافتراضي",
unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.",
},
header: {
title: "الرأس",
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
+14 -5
View File
@@ -1,9 +1,4 @@
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.",
@@ -1174,6 +1169,14 @@ export const en = {
},
},
sidebar: {
view: {
failed: "Sidebar view failed to load.",
useDefault: "Use default view",
readFailed: "Could not load your sidebar view preference.",
agentUnavailable: "Agent is no longer available",
terminalUnavailable: "Terminal is no longer available",
projectUnavailable: "Project is no longer available",
},
statusGroupAccessibility: "{{label}} group",
statusBucket: {
needsInput: "Needs input",
@@ -2677,6 +2680,12 @@ export const en = {
description: "Show an outline for jumping between prompts",
},
sidebar: {
view: {
title: "Sidebar view",
label: "View",
default: "Default view",
unavailable: "The selected view is unavailable or disabled. Showing Default view.",
},
header: {
title: "Header",
description: "Choose which items appear at the top of the sidebar and in what order",
+15 -5
View File
@@ -2,11 +2,6 @@ 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.",
@@ -1202,6 +1197,14 @@ export const es: TranslationResources = {
},
},
sidebar: {
view: {
failed: "No se pudo cargar la vista de la barra lateral.",
useDefault: "Usar vista predeterminada",
readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.",
agentUnavailable: "El agente ya no está disponible",
terminalUnavailable: "La terminal ya no está disponible",
projectUnavailable: "El proyecto ya no está disponible",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Necesita datos",
@@ -2606,6 +2609,13 @@ export const es: TranslationResources = {
description: "Muestra un esquema para saltar entre instrucciones",
},
sidebar: {
view: {
title: "Vista de la barra lateral",
label: "Vista",
default: "Vista predeterminada",
unavailable:
"La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.",
},
header: {
title: "Encabezado",
description:
+15 -5
View File
@@ -2,11 +2,6 @@ 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.",
@@ -1195,6 +1190,14 @@ export const fr: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Impossible de charger la vue de la barre latérale.",
useDefault: "Utiliser la vue par défaut",
readFailed: "Impossible de charger votre préférence de vue de la barre latérale.",
agentUnavailable: "Cet agent n’est plus disponible",
terminalUnavailable: "Ce terminal n’est plus disponible",
projectUnavailable: "Ce projet n’est plus disponible",
},
statusGroupAccessibility: "Groupe {{label}}",
statusBucket: {
needsInput: "Attend une réponse",
@@ -2607,6 +2610,13 @@ export const fr: TranslationResources = {
description: "Afficher un plan pour naviguer entre les prompts",
},
sidebar: {
view: {
title: "Vue de la barre latérale",
label: "Vue",
default: "Vue par défaut",
unavailable:
"La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.",
},
header: {
title: "En-tête",
description:
+15 -5
View File
@@ -2,11 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
pluginCombobox: {
searching: "検索中…",
empty: "結果がありません",
failed: "検索に失敗しました: {{message}}",
},
paneFind: {
connectionFailure:
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
@@ -1179,6 +1174,14 @@ export const ja: TranslationResources = {
},
},
sidebar: {
view: {
failed: "サイドバーのビューを読み込めませんでした。",
useDefault: "デフォルトのビューを使用",
readFailed: "サイドバーのビュー設定を読み込めませんでした。",
agentUnavailable: "このエージェントは利用できなくなりました",
terminalUnavailable: "このターミナルは利用できなくなりました",
projectUnavailable: "このプロジェクトは利用できなくなりました",
},
statusGroupAccessibility: "{{label}} グループ",
statusBucket: {
needsInput: "入力待ち",
@@ -2573,6 +2576,13 @@ export const ja: TranslationResources = {
description: "プロンプト間を移動するためのアウトラインを表示します",
},
sidebar: {
view: {
title: "サイドバーのビュー",
label: "ビュー",
default: "デフォルトのビュー",
unavailable:
"選択したビューは利用できないか無効です。デフォルトのビューを表示しています。",
},
header: {
title: "ヘッダー",
description: "サイドバー上部に表示する項目とその順序を選択します",
+15 -5
View File
@@ -2,11 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
pluginCombobox: {
searching: "검색 중…",
empty: "결과 없음",
failed: "검색 실패: {{message}}",
},
paneFind: {
connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.",
@@ -1173,6 +1168,14 @@ export const ko: TranslationResources = {
},
},
sidebar: {
view: {
failed: "사이드바 보기를 불러오지 못했습니다.",
useDefault: "기본 보기 사용",
readFailed: "사이드바 보기 설정을 불러오지 못했습니다.",
agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다",
terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다",
projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다",
},
statusGroupAccessibility: "{{label}} 그룹",
statusBucket: {
needsInput: "입력 필요",
@@ -2564,6 +2567,13 @@ export const ko: TranslationResources = {
description: "프롬프트 사이를 이동하기 위한 개요 표시",
},
sidebar: {
view: {
title: "사이드바 보기",
label: "보기",
default: "기본 보기",
unavailable:
"선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.",
},
header: {
title: "헤더",
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
+15 -5
View File
@@ -2,11 +2,6 @@ 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.",
@@ -1192,6 +1187,14 @@ export const ptBR: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Não foi possível carregar a visualização da barra lateral.",
useDefault: "Usar visualização padrão",
readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.",
agentUnavailable: "O agente não está mais disponível",
terminalUnavailable: "O terminal não está mais disponível",
projectUnavailable: "O projeto não está mais disponível",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Precisa de resposta",
@@ -2588,6 +2591,13 @@ export const ptBR: TranslationResources = {
description: "Mostrar uma estrutura para navegar entre prompts",
},
sidebar: {
view: {
title: "Visualização da barra lateral",
label: "Visualização",
default: "Visualização padrão",
unavailable:
"A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.",
},
header: {
title: "Cabeçalho",
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
+14 -5
View File
@@ -2,11 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
pluginCombobox: {
searching: "Поиск…",
empty: "Нет результатов",
failed: "Не удалось выполнить поиск: {{message}}",
},
paneFind: {
connectionFailure:
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
@@ -1183,6 +1178,14 @@ export const ru: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Не удалось загрузить вид боковой панели.",
useDefault: "Использовать стандартный вид",
readFailed: "Не удалось загрузить настройку вида боковой панели.",
agentUnavailable: "Агент больше недоступен",
terminalUnavailable: "Терминал больше недоступен",
projectUnavailable: "Проект больше недоступен",
},
statusGroupAccessibility: "Группа «{{label}}»",
statusBucket: {
needsInput: "Ожидает ввода",
@@ -2594,6 +2597,12 @@ export const ru: TranslationResources = {
description: "Показывать оглавление для перехода между запросами",
},
sidebar: {
view: {
title: "Вид боковой панели",
label: "Вид",
default: "Стандартный вид",
unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.",
},
header: {
title: "Верх",
description:
+14 -5
View File
@@ -2,11 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
pluginCombobox: {
searching: "搜索中…",
empty: "无结果",
failed: "搜索失败:{{message}}",
},
paneFind: {
connectionFailure: "无法搜索此聊天。请检查主机连接后重试。",
historyChangedFailure: "搜索期间聊天已更改。请重新搜索。",
@@ -1158,6 +1153,14 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
view: {
failed: "无法加载侧边栏视图。",
useDefault: "使用默认视图",
readFailed: "无法加载侧边栏视图偏好设置。",
agentUnavailable: "此智能体已不可用",
terminalUnavailable: "此终端已不可用",
projectUnavailable: "此项目已不可用",
},
statusGroupAccessibility: "{{label}} 分组",
statusBucket: {
needsInput: "需要输入",
@@ -2523,6 +2526,12 @@ export const zhCN: TranslationResources = {
description: "显示用于在提示词之间跳转的大纲",
},
sidebar: {
view: {
title: "侧边栏视图",
label: "视图",
default: "默认视图",
unavailable: "所选视图不可用或已禁用。正在显示默认视图。",
},
header: {
title: "顶部",
description: "选择侧边栏顶部显示的项目及其顺序",
+4 -8
View File
@@ -116,8 +116,7 @@ interface ChatAgentStateShape {
model?: Agent["model"];
thinkingOptionId?: Agent["thinkingOptionId"];
runtimeInfo?: Agent["runtimeInfo"];
options?: Agent["options"];
controls?: Agent["controls"];
features?: Agent["features"];
lastError?: Agent["lastError"] | null;
}
@@ -176,8 +175,7 @@ function selectChatAgentState(
model: agent.model,
thinkingOptionId: agent.thinkingOptionId,
runtimeInfo: agent.runtimeInfo,
options: agent.options,
controls: agent.controls,
features: agent.features,
lastError: agent.lastError ?? null,
archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false,
@@ -204,8 +202,7 @@ function buildChatAgentFromState(
model: state.model,
thinkingOptionId: state.thinkingOptionId,
runtimeInfo: state.runtimeInfo,
options: state.options,
controls: state.controls,
features: state.features,
lastError: state.lastError ?? null,
projectPlacement,
};
@@ -638,8 +635,7 @@ function AgentPanelBody({
model: agentState.model,
thinkingOptionId: agentState.thinkingOptionId,
runtimeInfo: agentState.runtimeInfo,
options: agentState.options,
controls: agentState.controls,
features: agentState.features,
lastError: agentState.lastError ?? null,
projectPlacement,
}
+2 -3
View File
@@ -1,4 +1,4 @@
import { pluginComposersSource } from "./composer-items/source";
import { getPluginClientApi } from "./client-state/api";
import { callPluginRpc } from "@getpaseo/plugin/client/host";
import type {
PluginAgentCommandContext,
@@ -33,7 +33,7 @@ export function createPluginCapabilities(
navigation.openSurface(plugin.id, screenId, params);
}
return {
paseo: plugin.paseo,
paseo: getPluginClientApi(plugin).paseo,
rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input),
openSettings(screenId) {
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
@@ -41,7 +41,6 @@ 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 }),
};
@@ -23,11 +23,10 @@ function installation(): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import appPackage from "../../package.json";
import { afterEach, expect, it } from "vitest";
import { QueryObserver } from "@tanstack/react-query";
@@ -139,7 +140,7 @@ function harness() {
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected runtime RPC");
@@ -149,7 +150,6 @@ function harness() {
},
openScreen() {},
openSurface() {},
composers: () => [],
async playAudio() {},
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
+2 -3
View File
@@ -1,6 +1,5 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createPluginHosts } from "./hosts";
import { getPluginClientApi } from "./client-state/api";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import {
@@ -27,7 +26,7 @@ export function createPluginClientRuntime(
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
hosts: getPluginClientApi(installation).hosts,
addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution);
},
@@ -0,0 +1,68 @@
import { createPluginReferenceApi } from "@getpaseo/plugin/client/host";
import { router } from "expo-router";
import { i18n } from "@/i18n/i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { createPluginHosts } from "../hosts";
import type { InstalledPlugin } from "../types";
import { createPluginClientStateSource } from "./source";
interface InstallationApi {
paseo: ReturnType<typeof createPluginReferenceApi>;
hosts: ReturnType<typeof createPluginHosts>;
}
// Registry presentation clones share the installation lifetime and its client pool.
const apis = new WeakMap<AbortSignal, InstallationApi>();
export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi {
const cached = apis.get(plugin.lifetime.signal);
if (cached) return cached;
const state = createPluginClientStateSource();
const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal);
const paseo = createPluginReferenceApi({
paseo: plugin.paseo,
resolveHost: hosts.getPaseoClient,
readProject: state.getProject,
navigation: {
openWorkspace(ref) {
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
},
openAgent(ref) {
const agent = state.getAgent(ref);
if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: agent.workspace.id,
target: { kind: "agent", agentId: ref.id },
});
},
openTerminal(ref) {
const terminal = state.getTerminal(ref);
if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: terminal.workspace.id,
target: { kind: "terminal", terminalId: ref.id },
});
},
openProject(ref) {
const project = state.getProject(ref);
if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable"));
usePanelStore.getState().showMobileAgent();
router.push(
buildNewWorkspaceRoute({
serverId: ref.hostId,
projectId: ref.id,
sourceDirectory: project.rootPath,
displayName: project.displayName,
}),
);
},
},
});
const api = { paseo, hosts };
apis.set(plugin.lifetime.signal, api);
return api;
}
@@ -0,0 +1,72 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { PluginConnectedHostsSource } from "./source";
interface PluginHostsRuntime {
getHosts(): readonly { serverId: string }[];
getSnapshot(serverId: string): { connectionStatus: string } | null | undefined;
subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void;
acquireDirectoryDemand(serverId: string): () => void;
}
export function createConnectedPluginHosts(
getRuntime: () => PluginHostsRuntime,
): PluginConnectedHostsSource {
let snapshot: readonly string[] = [];
const listeners = new Set<() => void>();
let release: (() => void) | null = null;
const source: PluginConnectedHostsSource = {
getSnapshot() {
const runtime = getRuntime();
const next = runtime
.getHosts()
.filter((host) => runtime.getSnapshot(host.serverId)?.connectionStatus === "online")
.map((host) => host.serverId);
if (next.length !== snapshot.length || next.some((id, index) => id !== snapshot[index]))
snapshot = Object.freeze(next);
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
if (!release) {
const runtime = getRuntime();
const demands = new Map<string, () => void>();
const reconcile = () => {
const ids = new Set(source.getSnapshot());
for (const [id, releaseDemand] of demands) {
if (!ids.has(id)) {
demands.delete(id);
releaseDemand();
}
}
for (const id of ids) {
if (!demands.has(id)) demands.set(id, runtime.acquireDirectoryDemand(id));
}
};
const notify = () => {
reconcile();
for (const subscriber of listeners) subscriber();
};
const releaseRuntime = runtime.subscribeAll(notify);
const releaseHosts = runtime.subscribeHostList(notify);
reconcile();
release = () => {
releaseRuntime();
releaseHosts();
for (const releaseDemand of demands.values()) releaseDemand();
demands.clear();
};
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && release) {
release();
release = null;
}
};
},
};
return source;
}
export const connectedPluginHosts = createConnectedPluginHosts(getHostRuntimeStore);
@@ -0,0 +1,53 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
export const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
export const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
@@ -0,0 +1,3 @@
import { createEntityRefs } from "@getpaseo/plugin/client/host";
export const entityRefs = createEntityRefs();

Some files were not shown because too many files have changed in this diff Show More