Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 96a47b42e0 fix(usage): preserve saved usage when a provider is renamed
Polling identity belongs to the ordered login inputs. Apply current display labels when returning cached or pending reports so renaming a configured provider keeps cached values and successful history.
2026-11-08 06:52:04 +00:00
paseo-bot[bot]andQichang Zheng bde2cfadf1 test(usage): cover configured accounts with an empty host store
The host listing must discover enabled custom providers even when the built-in provider is disabled and no default login exists.

Co-authored-by: Qichang Zheng <qichang.zheng2000@gmail.com>
2026-11-08 06:41:33 +00:00
paseo-bot[bot] 7a428d5774 Merge remote-tracking branch 'origin/main' into diagnose/3589-contributor-6b87ee9f 2026-11-08 06:37:04 +00:00
qichangzheng 83582a0537 Merge main into fix/usage-screen-provider-logins 2026-10-30 19:12:15 +08:00
Qichang ZhengandClaude Opus 5.5 abec60eb72 fix(usage): key each host scope's kept accounts by launch environment only
Renaming a provider no longer drops what its scope kept, providers that share
an environment are discovered once under the first one's name, and
unregistering a source is covered by a test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 14:57:17 +08:00
Qichang ZhengandClaude Opus 5.5 e8fc388867 Merge main into fix/usage-screen-provider-logins
#7246 keeps the host's last discovered accounts when a source's discovery
fails. Host listings now discover several scopes (the host's own plus each
provider env), so each scope keeps its own last result, keyed by its launch
environment. A provider's failed read keeps that provider's logins; a changed
env or a disabled provider keeps nothing, and the host's failed read keeps
only the host's logins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 14:48:43 +08:00
Qichang ZhengandClaude Opus 5.5 88b7e096a6 refactor(usage): name the provider usage types and pass options objects
Follow the server conventions: the provider environments list returns a named
ProviderUsageEnvironment, and the registry's discover() and mergeReports() and
the e2e injectedUsageSources() helper take a single options object. No
behavior change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:33:56 +08:00
Qichang ZhengandClaude Sonnet 5.5 6d41463582 fix(usage): name each failed provider login in the host's Usage list
A provider whose token has expired has no account to read, so the host list
showed one more card for it, and every such card read "Claude: Login expired".
A login found through a provider is now named after that provider ("Claude
(work): Login expired ..."). A login the host's own discovery already found
keeps its name, and logins are compared by their store input only, so a login
that several scopes find is still listed once.

The Usage e2e test inherited ANTHROPIC_BASE_URL, OPENAI_BASE_URL and the
Bedrock and Vertex switches from the shell through the daemon's environment,
and failed on a machine that sets any of them. It now clears them, and a second
test pins the rule it was tripping over: a provider whose agents talk to another
API host has no login in the host list.

Refs #3589

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 21:31:49 +08:00
Qichang ZhengandClaude Opus 5.5 85e195931b fix(usage): list each custom provider's own login in Usage
Usage and `paseo usage` read only the daemon's default logins. A provider
that sets CLAUDE_CONFIG_DIR or CODEX_HOME in its env had no card until one
of its agents loaded its context window details.

Host listings now also run session discovery once for each enabled
provider that sets its own env, with the environment it launches with.
That is the scope its agents already send, so the usage sources and the
protocol are unchanged. Accounts still group by the source's account key:
a login that several providers share, or that is also the daemon's
default, stays one card, with the host's own login first in its fallback
order.

Refs #3589

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 21:31:49 +08:00
430 changed files with 4970 additions and 24554 deletions
+24 -58
View File
@@ -1,8 +1,8 @@
# Agent stream disclosures
Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group,
a tool call's inline details, a todo card, a long user message's show more, a plan card) keeps the
tapped trigger at the same screen y in the first painted frame. No animation.
a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the
first painted frame. No animation.
## The contract
@@ -14,20 +14,12 @@ const handleToggle = () => hold(() => setExpanded((value) => !value));
```
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
`holdRow(rowId, apply, triggerEdge)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a
timeline row, `apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
A toggle that calls `setState` directly skips the hold: on Android the expanded content paints
over the next row for a frame before the list learns the new size.
`triggerEdge` names the edge of the row the trigger sits on, and the strategy keeps that edge in
place. Header triggers sit on the top edge, the default. A long user message's show more sits under
the text it opens, so `MessagePreview` passes `{ triggerEdge: "bottom" }`: the text grows and shrinks
above the trigger, and the rows below stay put.
`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row,
`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
Following output:
- A hold detaches the bottom anchor controller (`holdForDisclosure`), except a bottom-edge hold on
native while following: there the end pin already keeps the row's bottom in place.
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll
owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves
the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes
@@ -41,16 +33,13 @@ ends at its end and the trigger moves by the difference.
## Web
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
reconcile restores that edge after layout, before paint (`strategy-web.tsx`). The pin records the
scroll offset it was taken at, because the browser clamps `scrollTop` when a collapse shrinks the
content past the offset, before the reconcile reads it. Restoring from the clamped offset would
apply the shrink twice.
`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores
the row's top after layout, before paint (`strategy-web.tsx`).
## Native
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
not hold. Summary lines, todo cards, plan cards and show more expand inline.
not hold. Summary lines and todo cards expand inline.
The timeline is a LegendList in forward order: one `data` array of history rows followed by the
live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size
@@ -62,14 +51,14 @@ toggle; its applied commit took up to 3.5 s of JS on a long turn.
One owner keeps the scroll offset at any moment:
| State | Owner |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
| Following the end | The end pin (`native-end-pin.tsx`), also for a bottom-edge hold. |
| Disclosure hold | LegendList's `maintainVisibleContentPosition` on the hold's anchor row, or the end pin for the last row's bottom. |
| User scroll | The finger. The controller detaches at drag start. |
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
| State | Owner |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
| Following the end | The end pin (`native-end-pin.tsx`). |
| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. |
| User scroll | The finger. The controller detaches at drag start. |
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
The end pin is a zero-height view at the bottom of the content and the content's first native
child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The
@@ -89,38 +78,15 @@ jumps by the keyboard height after the keyboard settles.
### The hold
`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event:
`holdRow(rowId, apply)` runs in the toggle's event:
1. Pick the anchor and detach the controller (`holdForDisclosure`). LegendList restores row
tops only, so a top-edge hold anchors the toggled row and a bottom-edge hold anchors the row
below it, whose top is the toggled row's bottom (`disclosureAnchor`). The pin goes away
and LegendList's `maintainVisibleContentPosition` takes over, restoring only the anchor row
(`shouldRestorePosition`). A bottom-edge hold while following skips this step: the end pin
keeps the content end, and the row's bottom with it, in the frame the new height mounts.
A bottom-edge hold on the last row, such as a prompt the agent has not answered yet, has no
row below it: its bottom is the content end (the running indicator is the list footer, not a
row). The hold mounts the end pin while detached, and `apply` runs a frame later. The pin
anchors only once it is mounted, and a pin that mounts in the same commit as the new height
anchors nothing. The pin's commit mounts before that frame's JS callbacks run, so `apply`'s
commit mounts with the pin already in place. The pin keeps the distance from the end, and each
content size change clamps it to the scroll room left, so the hold re-sticks if the list was
at its end when tapped or a collapse used up the room. The release tracks that distance
instead of reading the offset: the new content size reaches JS before the pin's scroll does.
A reply landing below the row ends the pin in the commit that mounts the reply, so the reply
grows below the row and the trigger stays where it was. A reply that lands before `apply` runs
moves the hold to the reply's row.
Upstream LegendList anchors only rows from the first fully visible one down to the last fully
visible one, and a size change with no data change keeps the first row in view whatever
1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and
LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row
(`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible
one down, and a size change with no data change keeps the first row in view whatever
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
instead, and a bottom-edge anchor just under the viewport would not hold at all. The LegendList
patch counts the rows cut off at the top, and the row just past the bottom, as in view and
applies `shouldRestorePosition` to size changes too. It also drops LegendList's Android clamp
prediction: the scroll view clamps after the adjustment
([scroll view corrections](#scroll-view-corrections)), so the prediction moved a collapse near
the end to the end.
instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off
at the top as in view and applies `shouldRestorePosition` to size changes too.
2. Run `apply`.
3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so
LegendList's container does not re-render and would learn the new height from `onLayout` a task
@@ -164,7 +130,7 @@ With every turn collapsed, flinging from a cold open into history did not blank
### LegendList patch
`patches/@legendapp+list+3.7.1.patch` carries twelve changes for the timeline. Its header lists
`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists
them by purpose, and
[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream.
Drop a hunk when the installed LegendList ships its fix.
+1 -1
View File
@@ -276,7 +276,7 @@ its teardown; reconnect creates new server IDs for surviving client handles. See
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting; a window with no validated inbound frame is a miss, and two misses in a row tear the socket down. When the app returns to the foreground the client runs a fresh cycle of two 5-second windows back to back, then reconnects without backoff, because a socket that died while the OS had JS suspended stays `OPEN` and accepts sends: only inbound frames prove it. Misses and probes from before the suspension do not count toward that cycle, and a window whose timer fires long after its deadline spanned another suspension, so it is discarded and the cycle starts over. Any complete frame inside a window keeps the connection, so a slow multi-frame transfer survives; a single frame that takes longer than the two windows to arrive after foreground does not, and the fix for that is smaller daemon-to-client frames, not a longer window. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
-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. |
+4 -141
View File
@@ -118,7 +118,7 @@ appear in the plugin catalog. Editing one in development requires a daemon resta
Directory, Git, and npm installs cannot use a built-in ID.
A provider registration with `requiresConfiguration: true` participates only while its ID exists
under `agents.providers`. Hoplite and fx use this for catalog opt-in. Add writes `{ "enabled": true }`
under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }`
without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting
`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent
provider does not enter executable resolution or catalog discovery.
@@ -474,10 +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` or
`agent.create.before` hook that awaits a write to the agent being opened (`setMetadata`, `setLabels`,
an option, model, thinking or mode change) waits on the open, which waits on the hook. The hooks
reference tells plugin authors to start such a write without awaiting it.
## Contribute a provider
@@ -533,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;
@@ -673,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 -68
View File
@@ -30,71 +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. Throwing rejects the whole patch and none of it is
stored. Emit nothing from inside the call. If the provider's own state moved before it refused,
it reports the moved values once as `options_changed` after the call has ended, because the
stored options must match what runs.
- **One change at a time per agent.** `AgentManager` runs every option, model, thinking and mode
change inside the agent's lifecycle lane, the same queue that opens, reopens, closes and
archives it. `applyOptions`, `setModel`, `setThinkingOption` and `setMode` are never in flight
together on one session, and none of them overlaps an open or reopen. Providers do no
sequencing of their own: no queues, no "apply over the options as they are now" handling. A
change that arrives while another runs waits for it and then reads the agent's stored options
as that one left them.
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
behind the provider id, following `derivedFromProviderId`, so a custom provider with
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
and provider-agnostic; it stays at the protocol and legacy edges.
- **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the
agent snapshot for a running or closed agent. A draft and a profile form send everything they
would create with (seed, the provider's last-used control values once the provider has said it
still draws them, and the user's edits) in `provider.controls.list.request` and draw only what
comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once,
by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's
layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile
replaces provider and options in one step, while choosing a provider by hand starts over. A
profile saves its stored options plus the form's edits, so options no control draws survive an
edit. A daemon without `server_info.features.providerOptionControls` sends `features` and
`featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that
turns those into `controls` and `options` and back, so nothing in the app branches on it. A
create that carries options first lists the controls for its own provider, model, mode and
thinking level, since which options are controls differs by model, and sends those as
`featureValues`; callers do not prime anything.
- **Provider-initiated changes.** A setting the provider changes on its own is an
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
@@ -180,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. It returns `{ controls, options }`: the options a session opened from the draft would run with, so a value the provider resolves natively (an ACP select nobody set) shows in the draft the same way it would on the agent.
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.
@@ -543,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>;
@@ -565,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;
+8 -38
View File
@@ -102,45 +102,15 @@ updated after the checkpoint does not block it. Forking the current context rema
## Resume behavior
Opening, reconnecting, and returning from app background establish whether the chat is current.
Switching between continuously subscribed open chats needs no fetch. Focus alone does not mutate
timeline state; the response is compared with the local authoritative range first.
Opening, reconnecting, and returning from app background establish the current timeline through
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
Focus alone does not mutate timeline state; the response is compared with the local
authoritative range first.
A chat with a cached cursor asks the daemon first. The `agent.timeline.set_subscription.request`
that attaches the chat carries its `{ epoch, seq }`, and the response answers `current` or `behind`
per chat. The daemon attaches before it compares, so an entry committed in between arrives live or
counts as behind. `current` means the agent is loaded, the epoch matches, and the cursor is at the
head; it completes catch-up with no fetch. The response carries the agent row for each `current`
chat, because the timeline fetch is also what refreshes that row and the replica cache stores only
part of it. `behind`, or no entry, runs the bounded catch-up. The subscribe handler never loads an
agent, so an agent the daemon has not loaded answers `behind` and resumes in that fetch. Hosts without `features.timelineFreshnessCheck` get no cursors and always fetch.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host
is offline and until a reconnected chat's check answers, so an unchanged chat never shows a second
toast. A check on cached content shows nothing. Updating messages appears for a chat that is behind,
until the fetch completes, and for a check still unanswered `FRESHNESS_CHECK_QUIET_MS` after it was
sent. That delay measures the daemon, so it starts at the request: on a cold start the cache read
alone can take longer. Socket connectivity alone cannot certify that the displayed conversation is
current. The timeline owner publishes freshness; the view renders it without a toast timer or a
separate resume workflow.
The turn footer in a chat follows its messages, not the agent status. Turn activity comes from the
agent directory and messages from the chat's own catch-up, and either can land first. So the
timeline owner keeps an unsettled turn per chat: the turn its messages were captured during. It is
set when a check or catch-up becomes owed while the turn is open, or when cached messages are
painted that were saved while one was, and it is saved with those messages in the replica. It
carries the turn's start time. While it is set and the agent reads idle, the chat keeps showing that
running turn, with its loader and elapsed timer unchanged, and there is no separate catching-up
footer. The commit that brings the chat current clears it, in the same store write as the rows, so
the final messages and the completed footer land together. A `current` answer is that commit with no
rows. A check in flight keeps the hold, and `behind` keeps it until the fetched rows land. A failed
catch-up clears it too, and the footer takes the true status. Losing the connection is not a failed
catch-up: none can run or fail until the host returns, so the running footer stays beside
"Reconnecting to host" and the check on reconnect resolves it. Releasing it offline would put a
completed footer under messages known to be behind. Surfaces outside the timeline (the sidebar,
tabs, the composer's stop and send controls) read the true status and never see this state. Live
delivery needs no hold because `applyStreamEvent` commits queued rows before it applies turn
liveness.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot
certify that the displayed conversation is current. The timeline owner publishes freshness; the
view renders it without a toast timer or a separate resume workflow.
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
@@ -28,7 +28,7 @@ const ACP_PROVIDER = {
interface ProviderCatalogDaemonClient {
getLastServerInfoMessage(): {
features?: { fxProvider?: boolean; hopliteProvider?: boolean; plugins?: boolean };
features?: { hopliteProvider?: boolean; plugins?: boolean };
} | null;
connect(): Promise<void>;
close(): Promise<void>;
@@ -58,31 +58,6 @@ test.describe("ACP provider catalog", () => {
});
});
test("adds fx from the catalog with only a config entry", async ({ page }, testInfo) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "fx-catalog-e2e",
});
try {
expect(client.getLastServerInfoMessage()?.features?.fxProvider).toBe(true);
await client.patchDaemonConfig({ removeProviders: ["fx"] });
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
await findAcpCatalogProvider(page, "fx");
await page.screenshot({ path: testInfo.outputPath("fx-catalog.png") });
await installAcpCatalogProvider(page, "fx");
await expectProviderInstalledInSettings(page, "fx");
await page.screenshot({ path: testInfo.outputPath("fx-installed.png") });
expect((await client.getDaemonConfig()).config.providers?.fx).toEqual({ enabled: true });
} finally {
await client.patchDaemonConfig({ removeProviders: ["fx"] }).catch(() => undefined);
await client.close().catch(() => undefined);
}
});
for (const theme of ["light", "dark"] as const) {
test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
@@ -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 {
@@ -13,17 +13,10 @@ import {
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectResumeAnsweredWithoutFetch,
expectOneResumeCheckWithoutTail,
expectResumeOverflowFallsBackToOneTail,
hideTabAndReturn,
rememberTimelineRequestCounts,
releaseHeldCatchUp,
restoreUnchangedViewedTimeline,
restoreViewedTimelineHoldingCatchUp,
restoreViewedTimelineWithHeldResponse,
waitForResumeAnswer,
reopenAppWatchingForUpdatingToast,
watchForUpdatingToast,
} from "../support/helpers/timeline-resume";
test.describe("Agent timeline resume", () => {
@@ -49,9 +42,7 @@ test.describe("Agent timeline resume", () => {
}
});
test("returning to an unchanged chat shows no update and leaves it untouched", async ({
page,
}) => {
test("an unchanged resume leaves a scrolled-up timeline untouched", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
@@ -60,65 +51,13 @@ test.describe("Agent timeline resume", () => {
await expectTimelinePromptVisible(page, agent.newestPrompt);
await scrollTimelinePromptIntoView(page, agent.initialTailAnchorPrompt);
const presentation = await rememberTimelinePresentation(page, agent.initialTailAnchorPrompt);
await test.step("back from the background", async () => {
const requests = rememberTimelineRequestCounts(gate);
const expectNoUpdatingToast = await watchForUpdatingToast(page);
await hideTabAndReturn(page);
await waitForResumeAnswer(gate, requests);
await expectNoUpdatingToast();
await expectTimelinePresentationUnchanged(page, presentation);
expectResumeAnsweredWithoutFetch(gate, requests);
});
await test.step("back from a dropped connection", async () => {
const requests = rememberTimelineRequestCounts(gate);
const expectNoUpdatingToast = await watchForUpdatingToast(page);
await disconnectViewedTimeline(page, gate);
await restoreUnchangedViewedTimeline(page, gate);
await expectNoUpdatingToast();
await expectTimelinePresentationUnchanged(page, presentation);
expectResumeAnsweredWithoutFetch(gate, requests);
});
await test.step("back from reopening the app", async () => {
const requests = rememberTimelineRequestCounts(gate);
const expectNoUpdatingToast = await reopenAppWatchingForUpdatingToast(page);
await waitForResumeAnswer(gate, requests);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectNoUpdatingToast();
expectResumeAnsweredWithoutFetch(gate, requests);
});
} finally {
gate.restore();
await agent.cleanup();
}
});
test("returning to a changed chat reports the update and shows the new messages", async ({
page,
}) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
const presentation = await rememberTimelinePresentation(page, agent.newestPrompt);
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
const background = await commitTimelineTurnsWhileDisconnected(agent, 1);
await restoreViewedTimelineHoldingCatchUp(page, gate);
await restoreViewedTimelineWithHeldResponse(page, gate);
await expectTimelinePresentationUnchanged(page, presentation);
await releaseHeldCatchUp(page, gate);
await expectTimelinePromptVisible(page, background.newestPrompt);
expectOneResumeCheckWithoutTail(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
@@ -6,7 +6,6 @@ import {
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
restoreViewedTimelineWithHeldResponse,
} from "../support/helpers/timeline-resume";
@@ -26,7 +25,6 @@ test.describe("Agent timeline sync retry", () => {
gate.failTimelineResponses(agent.agentId);
await disconnectViewedTimeline(page, gate);
await commitTimelineTurnsWhileDisconnected(agent, 1);
await restoreViewedTimelineWithHeldResponse(page, gate);
const callout = page.getByTestId("agent-timeline-sync-error");
+6 -204
View File
@@ -21,10 +21,7 @@ interface Box {
interface TurnEndFrame {
at: number;
running: boolean;
replyLength: number;
footer: Box | null;
footerText: string;
elapsed: string | null;
userRow: Box | null;
replyVisible: boolean;
}
@@ -64,13 +61,10 @@ async function recordTurnEndFrames(
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const footer = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.map((element) => ({ element, box: boxOf(element) }))
.filter((row): row is { element: Element; box: Box } => row.box !== null)
.sort((left, right) => left.box.top - right.box.top)
.at(-1);
const elapsed =
viewport?.querySelector('[data-testid="turn-working-elapsed"]')?.textContent ?? null;
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.map(boxOf)
.filter((box): box is Box => box !== null)
.sort((left, right) => left.top - right.top);
const userRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(prompt));
@@ -80,16 +74,10 @@ async function recordTurnEndFrames(
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
(candidate) => candidate.textContent?.includes(reply),
);
const assistantRows = Array.from(
viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? [],
);
state.frames.push({
at: performance.now(),
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
replyLength: assistantRows.reduce((sum, row) => sum + (row.textContent?.length ?? 0), 0),
footer: footer?.box ?? null,
footerText: (footer?.element.textContent ?? "").replace(elapsed ?? "", ""),
elapsed,
footer: footers.at(-1) ?? null,
userRow: boxOf(userRow),
replyVisible: boxOf(replyRow) !== null,
});
@@ -159,7 +147,7 @@ function describeFrames(frames: TurnEndFrame[]): string {
(frame, index) =>
`${index}: ${frame.running ? "running " : "complete"} footer=${
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
} timer=${frame.elapsed ?? "none"} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
)
.join("\n");
}
@@ -194,192 +182,6 @@ function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | nu
).toBeLessThanOrEqual(1);
}
interface ReturnedTurn {
frames: TurnEndFrame[];
reconnectedAt: number;
finalReplyLength: number;
}
// The agent finishes while the socket is down, then the catch-up is held so the idle status
// reaches the chat first. Painted frames are sampled from the running footer until everything
// has settled.
async function returnAfterTurnFinished(input: {
page: Page;
agent: MockAgentWorkspace;
prompt: string;
leaveWhile: (gate: DaemonWebSocketGate) => Promise<void>;
}): Promise<ReturnedTurn> {
const { page, agent, prompt, leaveWhile } = input;
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await recordTurnEndFrames(page, { prompt, reply: "" });
await leaveWhile(gate);
gate.holdTimelineResponses(agent.agentId);
const reconnectedAt = await page.evaluate(() => performance.now());
gate.restore();
await gate.waitForHeldTimelineResponse();
await expectAgentIdle(page);
await recordPaintsFor(page, 2_000);
gate.releaseHeldTimelineResponses();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(page.getByTestId("turn-footer").last()).toBeVisible();
await recordPaintsFor(page, 500);
const frames = await stopRecording(page);
return { frames, reconnectedAt, finalReplyLength: frames.at(-1)?.replyLength ?? 0 };
}
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
async function leaveWithOnlyThePromptOnScreen(
page: Page,
agent: MockAgentWorkspace,
gate: DaemonWebSocketGate,
prompt: string,
): Promise<void> {
gate.setAgentStreamItemSuppressed("assistant_message", true);
gate.holdNextAgentStreamEvent("turn_completed");
gate.holdNextAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", true);
await submitMessage(page, prompt);
await Promise.all([
gate.waitForHeldAgentStreamEvent("turn_completed"),
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
]);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 15_000);
gate.setAgentStreamItemSuppressed("assistant_message", false);
gate.setServerMessageSuppressed("agent_update", false);
}
async function leaveWithAPartialReplyOnScreen(
page: Page,
agent: MockAgentWorkspace,
gate: DaemonWebSocketGate,
prompt: string,
): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 30_000);
}
function elapsedSeconds(frame: TurnEndFrame): number {
const units: Record<string, number> = { h: 3600, m: 60, s: 1 };
return [...(frame.elapsed ?? "").matchAll(/(\d+)([hms])/g)].reduce(
(total, [, value, unit]) => total + Number(value) * units[unit],
0,
);
}
function expectFooterUnchangedUntilMessagesLand(
{ frames, reconnectedAt }: ReturnedTurn,
record: string,
): void {
const held = frames.slice(
frames.findIndex((frame) => frame.at >= reconnectedAt),
frames.findIndex((frame) => frame.at >= reconnectedAt && !frame.running),
);
expect(
held.length,
`the running footer must hold until the messages land\n${record}`,
).toBeGreaterThan(0);
expect(
new Set(held.map((frame) => `${frame.footer?.top}+${frame.footer?.height}`)).size,
`the held footer must not move or resize\n${record}`,
).toBe(1);
expect(
new Set(held.map((frame) => frame.footerText)).size,
`the held footer's content must not change\n${record}`,
).toBe(1);
expect(
held.filter((frame) => frame.elapsed === null).map((frame) => frame.at),
`the timer must stay on screen\n${record}`,
).toEqual([]);
const seconds = held.map(elapsedSeconds);
expect(seconds, `the timer must keep counting up\n${record}`).toEqual(
seconds.toSorted((left, right) => left - right),
);
}
function expectReturnLandsWhole(returned: ReturnedTurn): void {
const { frames, finalReplyLength } = returned;
const record = `frames:\n${describeFrames(frames)}`;
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
const fromRunning = frames.slice(firstRunning);
expect(
fromRunning.filter((frame) => frame.footer === null).map((frame) => frame.at),
`the footer must never disappear\n${record}`,
).toEqual([]);
const completed = fromRunning.filter((frame) => !frame.running);
expect(completed.length, record).toBeGreaterThan(0);
// The first Markdown on a page paints an empty row until the web parser loads. Only text that
// is present and short of the final reply is stale.
expect(
completed
.filter((frame) => frame.replyLength > 0 && frame.replyLength !== finalReplyLength)
.map((frame) => frame.at),
`a completed footer must never sit under stale messages\n${record}`,
).toEqual([]);
expectFooterUnchangedUntilMessagesLand(returned, record);
}
async function withFirstTurnAgent(
model: string,
run: (agent: MockAgentWorkspace) => Promise<void>,
): Promise<void> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-return-",
title: "Turn return",
model,
});
try {
await run(agent);
} finally {
await agent.cleanup();
}
}
test.describe("Returning to a turn that finished while away", () => {
test("keeps the running footer until the messages land when only the prompt was on screen", async ({
page,
}) => {
await withFirstTurnAgent("e2e-fast-stream", async (agent) => {
const prompt = "emit 1 coalesced agent stream updates";
const returned = await returnAfterTurnFinished({
page,
agent,
prompt,
leaveWhile: (gate) => leaveWithOnlyThePromptOnScreen(page, agent, gate, prompt),
});
expectReturnLandsWhole(returned);
});
});
test("keeps the running footer until the messages land under a partial reply", async ({
page,
}) => {
await withFirstTurnAgent("ten-second-stream", async (agent) => {
const prompt = "Return partial prompt";
const returned = await returnAfterTurnFinished({
page,
agent,
prompt,
leaveWhile: (gate) => leaveWithAPartialReplyOnScreen(page, agent, gate, prompt),
});
expectReturnLandsWhole(returned);
});
});
});
test.describe("Turn end", () => {
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
@@ -13,30 +13,25 @@ async function setFontFamily(page: Page, name: string, family: string) {
await input.press("Tab");
}
test("keeps settings usable while editing code and interface font families", async ({
page,
}, testInfo) => {
await openAppearance(page);
for (const name of ["Code font family", "Interface font family"]) {
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
await openAppearance(page);
await setFontFamily(page, name, "'Fira Code', monospace");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
for (const name of ["Code font family", "Interface font family"]) {
await test.step(`reject an unfinished quote in ${name} and restore the default`, async () => {
await setFontFamily(page, name, "'Fira Code', monospace");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await setFontFamily(page, name, "'foo");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
await setFontFamily(page, name, "'foo");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
await page.screenshot({ path: testInfo.outputPath(`rejected-${name}.png`) });
await page.reload();
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await setFontFamily(page, name, "");
await expect(page.getByRole("textbox", { name })).toHaveValue("");
});
}
});
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
await page.reload();
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await setFontFamily(page, name, "");
await expect(page.getByRole("textbox", { name })).toHaveValue("");
});
}
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
await page.addInitScript(() => {
@@ -5,7 +5,6 @@ import {
type IsolatedHostDaemon,
} from "../support/helpers/isolated-host-daemon";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { setTabVisibility } from "../support/helpers/workspace-ui";
const test = base.extend<{ reconnectHost: IsolatedHostDaemon }>({
reconnectHost: async ({ e2eWorker }, provide) => {
@@ -38,6 +37,17 @@ async function refocusConnectedTab(page: Page): Promise<void> {
await expect(page.getByText("Online", { exact: true })).toBeVisible();
}
async function setTabVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
async function openHostOverview(page: Page, daemon: IsolatedHostDaemon): Promise<void> {
await page.goto("/");
await seedSavedSettingsHosts(page, [
@@ -1,20 +1,13 @@
import { test, expect } from "../support/fixtures";
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import {
composerLocator,
expectComposerVisible,
fillComposerDraft,
} from "../support/helpers/composer";
import { fillComposerDraft } from "../support/helpers/composer";
import {
expectComposerToolbarOnTextRails,
expectTrailingActionSwap,
expectNoCollapsedComposerToolbarFrame,
expectNoFrameTruncatesModelBesideThinking,
readSettledModelTriggerLabels,
readTrailingActionFrames,
recordComposerToolbarFrames,
recordModelTriggerFramesFromLoad,
recordTrailingActionFrames,
sweepModelTriggerLabels,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
@@ -22,9 +15,6 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const SETTLE_MS = 1_000;
const TRAILING_ACTION_SETTLE_MS = 500;
const VOICE_MODE = "Enable Voice mode";
const SEND = "Send message";
async function seedSettledMockAgent(workspace: SeededWorkspace, title: string) {
const agent = await workspace.client.createAgent({
@@ -47,52 +37,6 @@ function visibleAgentTab(page: import("@playwright/test").Page, agentId: string)
return page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first();
}
test.describe("Composer trailing action", () => {
test.describe.configure({ timeout: 120_000 });
for (const viewport of [
{ name: "wide", width: 1280, height: 800 },
{ name: "compact", width: 390, height: 844 },
]) {
test(`swaps in place between voice mode and Send at ${viewport.name} width`, async ({
page,
}) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-trailing-action-",
title: "Trailing action",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expect(page.getByRole("button", { name: VOICE_MODE, exact: true })).toBeVisible();
const composer = composerLocator(page);
await composer.focus();
await recordTrailingActionFrames(page);
await composer.press("x");
await expect(composer).toHaveValue("x");
await page.waitForTimeout(TRAILING_ACTION_SETTLE_MS);
expectTrailingActionSwap(await readTrailingActionFrames(page), {
from: VOICE_MODE,
to: SEND,
});
await recordTrailingActionFrames(page);
await composer.press("Backspace");
await expect(composer).toHaveValue("");
await page.waitForTimeout(TRAILING_ACTION_SETTLE_MS);
expectTrailingActionSwap(await readTrailingActionFrames(page), {
from: SEND,
to: VOICE_MODE,
});
} finally {
await agent.cleanup();
}
});
}
});
test.describe("Composer control density across tab switches", () => {
test.describe.configure({ timeout: 180_000 });
@@ -21,7 +21,6 @@ import {
openAgent,
pressContextWindowMeter,
qaScreenshot,
recordContextWindowFrames,
refreshUsageCard,
reloadAgent,
scriptAgentUsage,
@@ -300,10 +299,9 @@ for (const theme of ["light", "dark"] as const) {
const slowSource = gate();
usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] });
const closedEarly = await hoverContextWindowMeter(page);
await expect(usageCard(closedEarly, "claude:work").getByText(LOGIN_EXPIRED)).toBeVisible();
await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible();
await leaveContextWindowMeter(page);
await usage.resumeAgent(agent);
const resumed = gate();
const finished = gate();
usage.answerNext({
@@ -387,58 +385,6 @@ for (const theme of ["light", "dark"] as const) {
});
}
test("an open with loaded usage paints it in its first frame and never moves", async ({
page,
agent,
}) => {
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
usage.answerNext([onWorkLogin(claude!)]);
const loaded = await hoverContextWindowMeter(page);
await expect(usageCard(loaded, "claude:work")).toBeVisible();
await leaveContextWindowMeter(page);
// The reopen's own request stays unanswered until the card has been on screen a while.
const refetch = gate();
usage.answerNext({ stream: [refetch.promise, onWorkLogin(claude!)] });
const recording = await recordContextWindowFrames(page);
const reopened = await hoverContextWindowMeter(page);
await page.waitForTimeout(300);
refetch.open();
await page.waitForTimeout(300);
await expect(usageCard(reopened, "claude:work")).toBeVisible();
const frames = (await recording.stop()).filter((frame) => frame.mounted);
await leaveContextWindowMeter(page);
expect(frames.filter((frame) => frame.loading)).toEqual([]);
expect(frames.filter((frame) => frame.usageCards !== 1)).toEqual([]);
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
});
test("usage arriving after the card opens never moves its bottom edge", async ({ page, agent }) => {
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const arrival = gate();
usage.answerNext({ stream: [arrival.promise, onWorkLogin(claude!)] });
const recording = await recordContextWindowFrames(page);
const card = await hoverContextWindowMeter(page);
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
arrival.open();
await expect(usageCard(card, "claude:work")).toBeVisible();
await page.waitForTimeout(300);
const frames = (await recording.stop()).filter((frame) => frame.mounted);
await leaveContextWindowMeter(page);
expect(frames.some((frame) => frame.loading)).toBe(true);
expect(frames.at(-1)?.usageCards).toBe(1);
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
});
test("context details support keyboard entry and dismiss when the viewport moves", async ({
page,
agent,
@@ -51,7 +51,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
});
});
async function prepareNarrowSplit(page: Page, workspaceId: string) {
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 1600, height: 900 });
await gotoWorkspace(page, workspaceId);
await waitForWorkspaceTabsVisible(page);
@@ -74,38 +74,33 @@ async function prepareNarrowSplit(page: Page, workspaceId: string) {
};
}
async function expectPanesKeepTabRails(page: Page, panes: Locator[]) {
for (const pane of panes) {
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
}
await expect(
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
).toHaveCount(0);
}
async function launchTerminalFromPaneRail(page: Page, pane: Locator) {
await pane.getByTestId("workspace-new-tab-button").click();
await page.getByTestId("workspace-new-tab-menu-terminal").filter({ visible: true }).click();
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
await pane.getByTestId("workspace-tab-switcher-trigger").click();
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
}
async function maximizeAndRestorePane(pane: Locator) {
await pane.getByTestId("workspace-maximize-pane").click();
await pane.getByTestId("workspace-restore-pane").click();
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
await pane.getByTestId("workspace-header-menu-trigger").click();
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
await pane.getByTestId("workspace-header-menu-trigger").click();
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
}
test("narrow split panes keep their tab rails and launches stay in the owning pane", async ({
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-launch-" });
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
try {
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
await launchTerminalFromPaneRail(page, originalPane);
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
page,
workspace.workspaceId,
);
await launchTerminalFromCompactPane(page, originalPane);
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
await maximizeAndRestorePane(originalPane);
await maximizeAndRestoreCompactPane(page, originalPane);
await expect(otherPane).toBeVisible();
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
} finally {
await workspace.cleanup();
}
@@ -123,15 +118,17 @@ async function expectPaneHasWindowDragSurface(pane: Locator) {
.toBe(true);
}
test("narrow desktop split panes retain window dragging with both sidebars closed", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-window-drag-" });
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
try {
await installLocalDesktopHost(page, workspace.repoPath);
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
page,
workspace.workspaceId,
);
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
await expectPaneHasWindowDragSurface(originalPane);
await expectPaneHasWindowDragSurface(otherPane);
} finally {
@@ -9,7 +9,7 @@ import {
expectHostInHostPicker,
hostConfirmation,
openPairingLink,
reopenPairingLinkWithSavedHosts,
saveLinkedHostBeforeLoad,
type LinkedHost,
} from "../support/helpers/host-confirmation";
@@ -20,9 +20,7 @@ const linkedHost: LinkedHost = {
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
};
test("a pairing link asks before saving a host and reconnects without asking again", async ({
page,
}) => {
test("a pairing link for a new host asks before saving it", async ({ page }) => {
const link = buildPairingLink(linkedHost);
await test.step("Cancel saves nothing and stays on the page", async () => {
@@ -41,10 +39,13 @@ test("a pairing link asks before saving a host and reconnects without asking aga
await openSettings(page);
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
});
});
await test.step("reopening the saved host link connects without asking", async () => {
await reopenPairingLinkWithSavedHosts(page, link);
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
await expect(hostConfirmation(page)).toHaveCount(0);
});
test("a pairing link for a saved host connects without asking", async ({ page }) => {
await saveLinkedHostBeforeLoad(page, linkedHost);
await openPairingLink(page, buildPairingLink(linkedHost));
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
await expect(hostConfirmation(page)).toHaveCount(0);
});
@@ -1,7 +1,6 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { expectComposerModel } from "../support/helpers/agent-profiles";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
@@ -70,8 +69,6 @@ test.describe("New workspace live pin", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, seeded);
// The composer appears before its provider catalog has hydrated.
await expectComposerModel(page, "Ten second stream");
await submitNewWorkspacePrompt(page, "Pin me while I start");
await creation.waitForCreateRequest();
@@ -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" });
},
);
});
@@ -24,12 +24,11 @@ for (const width of [1100, 390]) {
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
const recovery = trackPluginChatRecovery(page);
// A chat with a cached cursor answers current and never fetches, so hold the subscribe itself.
gate.holdNextClientRequest("agent.timeline.set_subscription.request");
gate.holdTimelineResponses(agent.agentId);
await page.reload({ waitUntil: "domcontentloaded" });
await gate.waitForHeldClientRequest();
await gate.waitForHeldTimelineResponse();
await recovery.expectCatalogWithoutRetransmission();
gate.releaseHeldClientRequest();
gate.releaseHeldTimelineResponses();
await expectWholeCompletedCard(page);
await recovery.expectRecoveredWithoutRetransmission();
recovery.reset();
@@ -79,9 +79,7 @@ test.describe("Schedules", () => {
cleanupTasks.length = 0;
});
test("hydrates schedule editing and opens a pristine creation form afterward", async ({
page,
}) => {
test("edit form hydrates the scheduled model selection", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Hydrate model ${Date.now()}`;
@@ -95,45 +93,29 @@ test.describe("Schedules", () => {
await row.click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
const hostTrigger = page.getByTestId("schedule-host-trigger");
const projectTrigger = page.getByTestId("schedule-project-trigger");
const modelTrigger = page.getByTestId("schedule-model-trigger");
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
const modeTrigger = page.getByTestId("schedule-mode-trigger");
await test.step("edit form hydrates the saved model and controls", async () => {
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
await expect(hostTrigger).toBeDisabled();
await expectSettled(hostTrigger);
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toContainText("Low");
await expectSettled(thinkingTrigger);
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
await expectSettled(modeTrigger);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
await expect(page.getByText(/Times are in/)).toHaveCount(0);
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
});
await test.step("closing edit leaves the new schedule form pristine", async () => {
await page.getByRole("button", { name: "Cancel" }).click();
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("schedules-new").click();
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expect(projectTrigger).toContainText(/select project/i);
await expectSettled(projectTrigger);
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
});
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
await expect(hostTrigger).toBeDisabled();
await expectSettled(hostTrigger);
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toContainText("Low");
await expectSettled(thinkingTrigger);
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
await expectSettled(modeTrigger);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
await expect(page.getByText(/Times are in/)).toHaveCount(0);
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
});
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
@@ -183,4 +165,32 @@ test.describe("Schedules", () => {
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
});
test("create opens pristine after closing an edit form", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Pristine create ${Date.now()}`;
const scheduleId = await seedMockSchedule(workspace, scheduleName);
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
await page.goto(buildSchedulesRoute());
const row = page.getByTestId(`schedule-row-${scheduleId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("schedules-new").click();
await expect(formSheet).toBeVisible({ timeout: 10_000 });
const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(/select project/i);
await expectSettled(projectTrigger);
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
});
});
@@ -300,11 +300,15 @@ test("clicking the already focused timeline acknowledges a new completion", asyn
async function reconnectWithoutInteracting(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
agentId: string,
) {
await gate.drop();
await gate.waitForBlockedConnection();
gate.holdTimelineResponses(agentId);
gate.restore();
await gate.waitForHeldTimelineResponse();
await expectReconnectingToastGone(page);
gate.releaseHeldTimelineResponses();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
@@ -321,7 +325,7 @@ test("reconnecting a focused chat preserves its unread completion until another
);
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await reconnectWithoutInteracting(page, gate);
await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await page.getByText("Unread across reconnect.", { exact: true }).click();
await expectStatus(page, workspaces.subject.workspaceId, "done");
@@ -18,11 +18,8 @@ import {
createGatedTeardown,
expectDirectoryPresence,
expectHostWorkspaceSettledActive,
holdNextArchiveRequest,
outlastUndoWindow,
pressArchiveShortcut,
pressUndoShortcut,
resizeSidebarToMinimum,
rowArchiveButton,
rowKebab,
undoButton,
@@ -87,90 +84,7 @@ test.describe("Workspace archive undo", () => {
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
rowKebab(page, worktree.workspaceId).boundingBox(),
]);
if (!buttonBox || !kebabBox) throw new Error("Row actions are not laid out");
expect(kebabBox.x - (buttonBox.x + buttonBox.width)).toBeGreaterThanOrEqual(8);
});
test("the archived toast sits over the sidebar above its footer, and only Undo presses", async ({
page,
}) => {
const worktree = await seedWorktree(page, "archive-toast-sidebar");
await archiveFromRowButton(page, worktree.workspaceId);
const [toastBox, footerBox] = await Promise.all([
page.getByTestId("workspace-archived-toast").boundingBox(),
page.getByTestId("sidebar-footer").boundingBox(),
]);
if (!toastBox || !footerBox) throw new Error("Toast or footer is not laid out");
expect(toastBox.x).toBeGreaterThanOrEqual(footerBox.x);
expect(toastBox.x + toastBox.width).toBeLessThanOrEqual(footerBox.x + footerBox.width);
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(footerBox.y);
expect(footerBox.y - (toastBox.y + toastBox.height)).toBeLessThanOrEqual(16);
await expect(page.getByTestId("app-toast-message")).toHaveCSS("cursor", "auto");
await expect(undoButton(page)).toHaveCSS("cursor", "pointer");
const [labelBox, shortcutBox] = await Promise.all([
undoButton(page).getByText("Undo", { exact: true }).boundingBox(),
undoButton(page).getByText(/Z$/).boundingBox(),
]);
if (!labelBox || !shortcutBox) throw new Error("Undo button is not laid out");
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
});
test("an archive that fails after the sidebar was replaced still shows its error", async ({
page,
}) => {
const archive = await holdNextArchiveRequest(page);
const worktree = await seedWorktree(page, "archive-late-failure");
await archiveFromRowButton(page, worktree.workspaceId);
await archive.waitForHeld();
// Crossing the compact breakpoint swaps the sidebar, unmounting the toast's host.
await page.setViewportSize({ width: 400, height: 720 });
await expect(undoButton(page)).toHaveCount(0);
archive.releaseForUnknownWorkspace("workspace-the-daemon-never-had");
await expect(page.getByTestId("app-toast-message")).toHaveText(
"Workspace not found: workspace-the-daemon-never-had",
);
});
test("the archived toast keeps Undo inside the card at the narrowest sidebar", async ({
page,
}) => {
const worktree = await seedWorktree(page, "archive-toast-narrow");
await resizeSidebarToMinimum(page);
await archiveFromRowButton(page, worktree.workspaceId);
const [toastBox, undoBox] = await Promise.all([
page.getByTestId("workspace-archived-toast").boundingBox(),
undoButton(page).boundingBox(),
]);
if (!toastBox || !undoBox) throw new Error("Toast is not laid out");
expect(undoBox.x + undoBox.width).toBeLessThanOrEqual(toastBox.x + toastBox.width);
expect(undoBox.y + undoBox.height).toBeLessThanOrEqual(toastBox.y + toastBox.height);
});
test("archiving by shortcut with the sidebar collapsed shows the toast over the app", async ({
page,
}) => {
const worktree = await seedWorktree(page, "archive-toast-collapsed");
await workspaceRow(page, worktree.workspaceId).click();
await page.keyboard.press("ControlOrMeta+b");
await expect(workspaceRow(page, worktree.workspaceId)).not.toBeInViewport();
page.once("dialog", (dialog) => void dialog.accept());
await pressArchiveShortcut(page);
const toast = page.getByTestId("workspace-archived-toast");
await expect(toast).toBeInViewport();
const toastBox = await toast.boundingBox();
if (!toastBox) throw new Error("Toast is not laid out");
expect(toastBox.y).toBeLessThan(page.viewportSize()!.height / 2);
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
});
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
@@ -1,131 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true });
}
function panes(page: Page): Locator {
return page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true });
}
function splitDividers(page: Page): Locator {
return visible(page, "workspace-split-resize-handle");
}
async function openSplitWorkspace(
page: Page,
repoPrefix: string,
split: "Split pane right" | "Split pane down",
): Promise<SeededWorkspace> {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
await runWorkspaceActionFromCommandCenter(page, split);
await expect(splitDividers(page)).toHaveCount(1);
await expect(panes(page)).toHaveCount(2);
return workspace;
}
async function expectOneSharedWorkspaceHeader(page: Page): Promise<void> {
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
await expect(visible(page, "workspace-header-menu-trigger")).toHaveCount(1);
await expect(panes(page).getByTestId("workspace-header-title")).toHaveCount(0);
await expect(panes(page).getByTestId("workspace-header-menu-trigger")).toHaveCount(0);
}
/** The tab counter belongs to the single-pane layout: split panes always show their rails. */
async function expectPanesKeepTabRails(page: Page): Promise<void> {
for (const pane of await panes(page).all()) {
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
}
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(0);
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX, y, { steps: 8 });
await page.mouse.up();
}
test.describe("Workspace header with split panes", () => {
test("wide side-by-side panes share one header and keep their tab rails", async ({ page }) => {
const workspace = await openSplitWorkspace(page, "split-header-wide-", "Split pane right");
try {
await expectOneSharedWorkspaceHeader(page);
await expectPanesKeepTabRails(page);
} finally {
await workspace.cleanup();
}
});
test("stacked panes share one header", async ({ page }) => {
const workspace = await openSplitWorkspace(page, "split-header-stacked-", "Split pane down");
try {
await expectOneSharedWorkspaceHeader(page);
await expectPanesKeepTabRails(page);
} finally {
await workspace.cleanup();
}
});
test("a narrow workspace shows one compact header and every pane keeps its tab rail", async ({
page,
}) => {
const workspace = await openSplitWorkspace(page, "split-header-narrow-", "Split pane right");
try {
await page.setViewportSize({ width: 760, height: 900 });
await expectPanesKeepTabRails(page);
await expectOneSharedWorkspaceHeader(page);
} finally {
await workspace.cleanup();
}
});
test("a narrow pane beside a wide one keeps its tab rail", async ({ page }) => {
const workspace = await openSplitWorkspace(page, "split-header-mixed-", "Split pane right");
try {
await dragDivider(splitDividers(page), -300);
const [left, right] = await panes(page).all();
const leftBox = await left.boundingBox();
const rightBox = await right.boundingBox();
expect(leftBox!.width).toBeLessThan(500);
expect(rightBox!.width).toBeGreaterThan(500);
await expectPanesKeepTabRails(page);
await expectOneSharedWorkspaceHeader(page);
} finally {
await workspace.cleanup();
}
});
test("closing back to one pane returns the merged compact header", async ({ page }) => {
const workspace = await openSplitWorkspace(page, "split-header-close-", "Split pane right");
try {
const [, emptyPane] = await panes(page).all();
const emptyPaneRail = emptyPane.getByTestId("workspace-tabs-row");
await emptyPaneRail.locator('[data-testid^="workspace-tab-"]').first().hover();
await emptyPaneRail.locator('[data-testid*="-close-"]').first().click();
await expect(splitDividers(page)).toHaveCount(0);
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
await page.setViewportSize({ width: 760, height: 900 });
await expect(visible(page, "workspace-tabs-row")).toHaveCount(0);
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(1);
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
} finally {
await workspace.cleanup();
}
});
});
@@ -54,116 +54,6 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
}
/** One painted frame of the composer's trailing controls: the slot and what sits beside it. */
export interface TrailingActionFrame {
atMs: number;
/** Every trailing action on screen, as `label` or `label (disabled)`. */
actions: string[];
slotX: number | null;
micX: number | null;
ringX: number | null;
ringWidth: number | null;
}
declare global {
interface Window {
__trailingActionFrames?: TrailingActionFrame[];
__trailingActionStop?: () => void;
}
}
const TRAILING_ACTION_LABELS = [
"Enable Voice mode",
"Send message",
"Queue message",
"Send and interrupt",
"Send and steer",
"Interrupt agent",
"Stop agent",
];
/**
* Records every painted frame of the visible composer's trailing controls. Takes one sample before
* the first paint, so input that lands right after this resolves still has a frame to compare to.
*/
export async function recordTrailingActionFrames(page: Page): Promise<void> {
await page.evaluate((labels) => {
const frames: TrailingActionFrame[] = [];
window.__trailingActionFrames = frames;
const start = performance.now();
const visibleRoot = () =>
Array.from(document.querySelectorAll('[data-testid="message-input-root"]')).find(
(root) => root.getClientRects().length > 0,
);
const sample = () => {
const root = visibleRoot();
const control = (selector: string) =>
Array.from(root?.querySelectorAll(selector) ?? []).find(
(node) => node.getClientRects().length > 0,
);
const actions = labels.flatMap((label) => {
const node = control(`[aria-label="${label}"]`);
if (!node) return [];
return [node.getAttribute("aria-disabled") === "true" ? `${label} (disabled)` : label];
});
const slot = labels.map((label) => control(`[aria-label="${label}"]`)).find(Boolean);
const mic = control('[aria-label="Start dictation"]');
const ring = control('[aria-label^="Context window"]');
frames.push({
atMs: Math.round(performance.now() - start),
actions,
slotX: slot ? slot.getBoundingClientRect().x : null,
micX: mic ? mic.getBoundingClientRect().x : null,
ringX: ring ? ring.getBoundingClientRect().x : null,
ringWidth: ring ? ring.getBoundingClientRect().width : null,
});
};
sample();
let running = true;
const tick = () => {
if (!running) return;
sample();
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
window.__trailingActionStop = () => {
running = false;
};
}, TRAILING_ACTION_LABELS);
}
export async function readTrailingActionFrames(page: Page): Promise<TrailingActionFrame[]> {
return page.evaluate(() => {
window.__trailingActionStop?.();
return window.__trailingActionFrames ?? [];
});
}
/**
* Every frame of the span shows exactly one trailing action, in the one slot, moving straight from
* `from` to `to`, with the mic and context ring where they started.
*/
export function expectTrailingActionSwap(
frames: TrailingActionFrame[],
expected: { from: string; to: string },
): void {
expect(frames.length).toBeGreaterThan(1);
const [first] = frames;
const wrongFrames = frames.filter(
(frame) =>
frame.actions.length !== 1 ||
frame.slotX !== first.slotX ||
frame.micX !== first.micX ||
frame.ringX !== first.ringX ||
frame.ringWidth !== first.ringWidth,
);
expect(wrongFrames, `frames that moved or showed the wrong action`).toEqual([]);
const sequence = frames
.map((frame) => frame.actions[0])
.filter((action, index, all) => action !== all[index - 1]);
expect(sequence).toEqual([expected.from, expected.to]);
}
/** What the folded model control's labels show, read from the DOM. */
export interface ModelTriggerLabelState {
thinkingShown: boolean;
@@ -49,83 +49,6 @@ export async function moveOntoContextWindowDetails(page: Page, card: Locator): P
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
}
/** The hover card as one painted frame showed it, or unmounted. */
export interface ContextWindowFrame {
mounted: boolean;
bottom: number | null;
loading: boolean;
usageCards: number;
}
/**
* Reads the hover card as it was painted, every frame, until stopped, so a state that lasted one
* frame is seen. Stop it only after everything has settled.
*/
export async function recordContextWindowFrames(
page: Page,
): Promise<{ stop: () => Promise<ContextWindowFrame[]> }> {
await page.evaluate(() => {
const recorder = window as unknown as {
__contextWindowFrames: ContextWindowFrame[];
__contextWindowRecording: boolean;
};
recorder.__contextWindowFrames = [];
recorder.__contextWindowRecording = true;
const findCard = () =>
document.querySelector<HTMLElement>('[data-testid="context-window-details"]');
const read = (card: HTMLElement | null): ContextWindowFrame => ({
mounted: card !== null,
bottom: card?.getBoundingClientRect().bottom ?? null,
loading: card?.innerText.includes("Loading usage...") ?? false,
usageCards: card?.querySelectorAll('[data-testid^="usage-report-"]').length ?? 0,
});
// A card that just resized has not been painted yet: its frame is read from an observer,
// which runs before that paint.
let watched: HTMLElement | null = null;
let paintedHeight: number | null = null;
let observer: ResizeObserver | null = null;
const watch = (card: HTMLElement) => {
observer = new ResizeObserver(() => {
paintedHeight = card.offsetHeight;
recorder.__contextWindowFrames.push(read(card));
});
observer.observe(card);
};
const channel = new MessageChannel();
// A message posted from an animation frame runs after that frame's paint.
channel.port1.addEventListener("message", () => {
const card = findCard();
if (card !== watched) {
observer?.disconnect();
watched = card;
paintedHeight = null;
if (card) watch(card);
}
if (!card || card.offsetHeight === paintedHeight) {
recorder.__contextWindowFrames.push(read(card));
}
});
channel.port1.start();
const sample = () => {
if (!recorder.__contextWindowRecording) return;
channel.port2.postMessage(null);
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
return {
stop: () =>
page.evaluate(() => {
const recorder = window as unknown as {
__contextWindowFrames: ContextWindowFrame[];
__contextWindowRecording: boolean;
};
recorder.__contextWindowRecording = false;
return recorder.__contextWindowFrames;
}),
};
}
export async function leaveContextWindowMeter(page: Page): Promise<void> {
await page.mouse.move(0, 0);
await expect(contextWindowDetails(page)).toHaveCount(0);
@@ -216,8 +139,6 @@ export interface AgentUsageScript {
agentRequests(): string[];
/** Each scoped agent Refresh, by agent ID. */
refreshedAgents(): string[];
/** Reloads the agent's session, which starts it under a new usage scope, as a resume does. */
resumeAgent(agent: MockAgentSession): Promise<void>;
}
/**
@@ -248,8 +169,6 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
usage
.listRequests()
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
resumeAgent: (agent) =>
usage.untilUsageScopeChanges(agent.agentId, () => agent.client.refreshAgent(agent.agentId)),
};
}
@@ -1,5 +1,7 @@
import { expect, type Locator, type Page } from "@playwright/test";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
export interface LinkedHost {
serverId: string;
relayEndpoint: string;
@@ -29,17 +31,31 @@ export async function openPairingLink(page: Page, link: string): Promise<void> {
await page.goto(link);
}
/** Reopens a link from a fresh document while retaining hosts saved through the UI. */
export async function reopenPairingLinkWithSavedHosts(page: Page, link: string): Promise<void> {
for (const target of ["/", link]) {
// The fixture otherwise replaces the registry on every document load.
await page.evaluate(() => {
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
if (!nonce) throw new Error("Expected the e2e seed nonce before reopening a pairing link.");
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
});
await page.goto(target);
}
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
await page.goto("/");
const nowIso = new Date().toISOString();
const connectionId = `relay:${host.relayEndpoint}`;
const savedHost = {
serverId: host.serverId,
label: host.serverId,
connections: [
{
id: connectionId,
type: "relay",
relayEndpoint: host.relayEndpoint,
useTls: false,
daemonPublicKeyB64: host.daemonPublicKeyB64,
},
],
preferredConnectionId: connectionId,
createdAt: nowIso,
updatedAt: nowIso,
};
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
key: EXTRA_HOSTS_KEY,
hosts: [savedHost],
});
}
export function hostConfirmation(page: Page): Locator {
@@ -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();
}
}
@@ -48,13 +48,7 @@ export function trackPluginChatRecovery(page: Page) {
async expectRecoveredWithoutRetransmission() {
await this.expectCatalogWithoutRetransmission();
await expect
.poll(() =>
messages.some(
(message) =>
message.type === "fetch_agent_timeline_response" ||
message.type === "agent.timeline.set_subscription.response",
),
)
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
.toBe(true);
},
};
@@ -110,18 +110,6 @@ function sidebarViews(
label: { name: "Unlabelled", color: "red" },
assigned: true,
});
// Wait for the named label folder alongside the built-in unlabelled bucket.
// Counts alone can match stale Inbox/Later rows from the preceding move.
await expect(
slot.getByRole("button", { name: "Toggle Unlabelled", exact: true }),
).toHaveCount(2);
// Observe both label rows before removing Later so a stale single row
// cannot satisfy the final readiness check.
const moveWorkspace = slot.getByRole("button", {
name: `Move workspace ${workspaceId}`,
exact: true,
});
await expect(moveWorkspace).toHaveCount(2);
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Later", color: "red" },
@@ -134,9 +122,9 @@ function sidebarViews(
?.labels,
)
.toEqual(["Unlabelled"]);
// The daemon reply does not guarantee the browser has applied the removal.
await expect(moveWorkspace).toHaveCount(1);
await moveWorkspace.click();
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(
@@ -1,25 +1,14 @@
import { expect, type Page } from "@playwright/test";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import type { LongTimelineAgent } from "./timeline-pagination";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
setTabVisibility,
} from "./workspace-ui";
import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui";
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
const SUBSCRIPTION_REQUEST = "agent.timeline.set_subscription.request";
const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response";
const TIMELINE_REQUEST = "fetch_agent_timeline_request";
const TIMELINE_RESPONSE = "fetch_agent_timeline_response";
export interface TimelineRequestCounts {
agentId?: string;
after: number;
tail: number;
subscriptions: number;
fetches: number;
}
export interface BackgroundTimelineTurns {
@@ -53,89 +42,17 @@ export function rememberTimelineRequestCounts(
agentId,
after: gate.getTimelineRequestCount("after", agentId),
tail: gate.getTimelineRequestCount("tail", agentId),
subscriptions: gate.getClientRequestCount(SUBSCRIPTION_REQUEST),
fetches: gate.getClientRequestCount(TIMELINE_REQUEST),
};
}
export function expectResumeAnsweredWithoutFetch(
export function expectOneResumeCheckWithoutTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(0);
expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail", before.agentId) - before.tail).toBe(0);
}
/** Resolves once the daemon has told the app where this chat stands, whichever way it asked. */
export async function waitForResumeAnswer(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): Promise<void> {
await Promise.race([
gate.waitForServerMessage(SUBSCRIPTION_RESPONSE, before.subscriptions + 1),
gate.waitForServerMessage(TIMELINE_RESPONSE, before.fetches + 1),
]);
}
/** Records every appearance of the toast, including one that lasts a single commit. */
export async function watchForUpdatingToast(page: Page): Promise<() => Promise<void>> {
const watch = await page.evaluateHandle(() => {
let shown = false;
const check = () => {
shown ||= document.querySelector('[data-testid="agent-updating-toast"]') !== null;
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
check();
return {
stop() {
observer.disconnect();
return shown;
},
};
});
return async () => {
await waitForNextPaint(page);
const shown = await watch.evaluate((recorder) => recorder.stop());
await watch.dispose();
expect(shown, "the Updating messages toast appeared").toBe(false);
};
}
/** Reopens the app on this chat, recording the toast from the first paint of the new page. */
export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise<void>> {
await page.addInitScript(() => {
const recorder = window as Window & { updatingToastShown?: boolean };
new MutationObserver(() => {
recorder.updatingToastShown ||=
document.querySelector('[data-testid="agent-updating-toast"]') !== null;
}).observe(document, { childList: true, subtree: true });
});
await page.reload();
return async () => {
await waitForNextPaint(page);
const shown = await page.evaluate(
() => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true,
);
expect(shown, "the Updating messages toast appeared").toBe(false);
};
}
async function waitForNextPaint(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
export async function hideTabAndReturn(page: Page): Promise<void> {
await setTabVisibility(page, "hidden");
await waitForNextPaint(page);
await setTabVisibility(page, "visible");
}
export function expectResumeOverflowFallsBackToOneTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
@@ -152,34 +69,15 @@ export async function disconnectViewedTimeline(
await expectReconnectingToastVisible(page);
}
export async function restoreViewedTimelineHoldingCatchUp(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
gate.holdNextServerMessage(TIMELINE_RESPONSE);
gate.restore();
await gate.waitForHeldServerMessage();
await expectReconnectingToastGone(page);
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
}
export async function releaseHeldCatchUp(page: Page, gate: DaemonWebSocketGate): Promise<void> {
gate.releaseHeldServerMessage();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
export async function restoreViewedTimelineWithHeldResponse(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
await restoreViewedTimelineHoldingCatchUp(page, gate);
await releaseHeldCatchUp(page, gate);
}
export async function restoreUnchangedViewedTimeline(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.restore();
await gate.waitForHeldServerMessage();
await expectReconnectingToastGone(page);
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
gate.releaseHeldServerMessage();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
@@ -11,8 +11,6 @@ export interface UsageListRequest {
export interface UsageReportsFixture {
listRequests(): UsageListRequest[];
waitForListRequests(count: number): Promise<void>;
/** Runs `action`, then waits until the app has received the agent's next usage scope. */
untilUsageScopeChanges(agentId: string, action: () => Promise<unknown>): Promise<void>;
}
interface UsageReportsFixtureOptions {
@@ -99,26 +97,6 @@ function withUsageSupportFeature(
});
}
function agentUsageScope(message: WebSocketMessage): { agentId: string; key: string } | null {
const envelope = parseJson(message) as {
type?: unknown;
message?: {
type?: unknown;
payload?: { kind?: unknown; agent?: { id?: unknown; usageScopeKey?: unknown } };
};
} | null;
const agent = envelope?.message?.payload?.agent;
if (
envelope?.message?.type !== "agent_update" ||
envelope.message.payload?.kind !== "upsert" ||
typeof agent?.id !== "string" ||
typeof agent.usageScopeKey !== "string"
) {
return null;
}
return { agentId: agent.id, key: agent.usageScopeKey };
}
function pick<T>(values: T[], index: number): T {
const value = values[Math.min(index, values.length - 1)];
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
@@ -149,16 +127,6 @@ export async function installUsageReportsFixture(
): Promise<UsageReportsFixture> {
const listRequests: UsageListRequest[] = [];
const listCounter = createCounter();
const usageScopes = new Map<string, string>();
const usageScopeChanges = new Map<string, ReturnType<typeof createCounter>>();
const usageScopeCounts = new Map<string, number>();
const usageScopeCounter = (agentId: string) => {
const existing = usageScopeChanges.get(agentId);
if (existing) return existing;
const created = createCounter();
usageScopeChanges.set(agentId, created);
return created;
};
const usageSupported = options.usageSupported ?? true;
const isUsageSupported = () =>
typeof usageSupported === "function" ? usageSupported() : usageSupported;
@@ -271,15 +239,6 @@ export async function installUsageReportsFixture(
options.usageSettingSupported ?? true,
)
: null;
const scope = agentUsageScope(message);
if (scope && usageScopes.get(scope.agentId) !== scope.key) {
usageScopes.set(scope.agentId, scope.key);
usageScopeCounts.set(scope.agentId, (usageScopeCounts.get(scope.agentId) ?? 0) + 1);
// The app must hold the new scope before a test acts on it.
ws.send(serverInfo ?? message);
usageScopeCounter(scope.agentId).increment();
return;
}
ws.send(serverInfo ?? message);
});
});
@@ -287,10 +246,5 @@ export async function installUsageReportsFixture(
return {
listRequests: () => [...listRequests],
waitForListRequests: (count) => listCounter.waitFor(count),
untilUsageScopeChanges: async (agentId, action) => {
const next = (usageScopeCounts.get(agentId) ?? 0) + 1;
await action();
await usageScopeCounter(agentId).waitFor(next);
},
};
}
@@ -4,15 +4,12 @@ import path from "node:path";
import type { Page } from "@playwright/test";
import { expect } from "../fixtures";
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { daemonWsRoutePattern } from "./daemon-port";
import { getServerId } from "./server-id";
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
const ARCHIVE_SHORTCUT =
process.platform === "darwin" ? "Meta+Shift+Backspace" : "Control+Shift+Backspace";
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
const UNDO_WINDOW_MS = 6000;
@@ -49,25 +46,6 @@ export async function archiveFromRowButton(page: Page, workspaceId: string): Pro
await expect(undoButton(page)).toBeVisible();
}
/** Drags the sidebar's edge past its minimum, leaving it as narrow as it goes. */
export async function resizeSidebarToMinimum(page: Page): Promise<void> {
const handle = await page.getByTestId("left-sidebar-resize-handle").boundingBox();
if (!handle) throw new Error("Sidebar resize handle is not laid out");
const y = handle.y + handle.height / 2;
await page.mouse.move(handle.x + handle.width / 2, y);
await page.mouse.down();
await page.mouse.move(handle.x - 30, y, { steps: 8 });
await page.mouse.move(10, y, { steps: 16 });
await page.mouse.up();
await expect
.poll(async () => (await page.getByTestId("sidebar-footer").boundingBox())?.width)
.toBeLessThanOrEqual(200);
}
export async function pressArchiveShortcut(page: Page): Promise<void> {
await page.keyboard.press(ARCHIVE_SHORTCUT);
}
export async function pressUndoShortcut(page: Page): Promise<void> {
await page.keyboard.press(UNDO_SHORTCUT);
}
@@ -98,51 +76,6 @@ export async function expectDirectoryPresence(directory: string, present: boolea
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
}
export interface HeldArchiveRequest {
waitForHeld(): Promise<void>;
/** Sends the held request to the daemon naming a workspace it has no record of, so it refuses. */
releaseForUnknownWorkspace(workspaceId: string): void;
}
/** Keeps the next archive request from reaching the daemon until the test releases it. */
export async function holdNextArchiveRequest(page: Page): Promise<HeldArchiveRequest> {
let held: { request: SessionRequest; forward: (raw: string) => void } | null = null;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const request = parseSessionRequest(message);
if (!held && request?.type === "archive_workspace_request") {
held = { request, forward: (raw) => server.send(raw) };
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
return {
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
releaseForUnknownWorkspace: (workspaceId) => {
if (!held) throw new Error("No archive request is held");
held.forward(JSON.stringify({ type: "session", message: { ...held.request, workspaceId } }));
},
};
}
interface SessionRequest {
type: string;
requestId: string;
workspaceId: string;
}
function parseSessionRequest(message: string | Buffer): SessionRequest | null {
try {
const envelope = JSON.parse(typeof message === "string" ? message : message.toString("utf8"));
return envelope?.type === "session" ? envelope.message : null;
} catch {
return null;
}
}
export interface GatedTeardown {
/** The `paseo.json` config whose worktree teardown parks until released. */
paseoConfig: Record<string, unknown>;
@@ -91,20 +91,6 @@ export async function expectWorkspaceHeader(
});
}
export async function setTabVisibility(
page: Page,
visibility: DocumentVisibilityState,
): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
+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 {
@@ -62,9 +61,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" });
@@ -174,7 +159,6 @@ describe("openAgentProfileForm", () => {
provider: "codex",
model: "gpt-5.2-codex",
modeId: "read-only",
options: {},
});
});
@@ -189,7 +173,7 @@ describe("openAgentProfileForm", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { webSearch: true },
featureValues: { webSearch: true },
notes: "Visual work only.",
},
});
@@ -245,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();
@@ -259,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");
});
@@ -352,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",
@@ -384,235 +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);
// Empty, not absent: the daemon reads a profile write without `options` as one from an
// app that predates them and keeps the stored options.
expect(model.getState().submitValue?.options).toEqual({});
expect(model.getState().controlsRequest?.providerOptions).toBeUndefined();
});
it("saves a toggle again when the model that draws it returns", () => {
const model = toggledOnOpus();
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
});
it("does not save a stored toggle profile either", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "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).toEqual({});
});
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);
});
});
@@ -645,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,18 +336,6 @@ 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();
@@ -399,18 +350,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
// Always sent, empty included: the daemon reads a write without `options` as one from an
// app that predates them and keeps the profile's stored options.
options: ownOptionsOf(state),
...(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";
@@ -443,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,
@@ -477,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,
@@ -523,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,
@@ -552,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
@@ -565,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 &&
@@ -604,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 })),
@@ -638,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,
@@ -650,8 +572,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modeDisplay: null,
thinkingOptionId: "",
thinkingDisplay: null,
options: {},
optionEdits: {},
featureValues: {},
};
}),
setModel: (modelId, display) =>
@@ -677,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,147 +0,0 @@
import React, { act, createRef, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import { PlanCard } from "@/components/plan-card";
import { MessagePreview } from "@/components/user-message";
import { DisclosureRow } from "./disclosure";
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
// Load translations so the toggles expose their real accessible names.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
let root: Root;
let container: HTMLDivElement;
// The plan body renders through the app's Markdown renderer, which reads through React Query.
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient();
});
afterEach(() => {
act(() => root.unmount());
queryClient.clear();
container.remove();
});
interface Hold {
rowId: string;
triggerEdge: DisclosureTriggerEdge;
/** Whether the expanded content was already on screen when the strategy received the hold. */
appliedBefore: boolean;
}
/**
* A timeline row whose viewport records each hold. Like the strategies, it runs `apply` itself, so
* a toggle that skips the hold still changes state but leaves no record.
*/
function mountRow(rowId: string, isApplied: () => boolean, children: ReactNode) {
const holds: Hold[] = [];
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdRow: (heldRowId, apply, triggerEdge) => {
holds.push({ rowId: heldRowId, triggerEdge, appliedBefore: isApplied() });
apply();
},
};
const viewportRef = createRef<StreamViewportHandle>();
viewportRef.current = viewport;
act(() =>
root.render(
<QueryClientProvider client={queryClient}>
<DisclosureRow rowId={rowId} viewportRef={viewportRef}>
{children}
</DisclosureRow>
</QueryClientProvider>,
),
);
return holds;
}
const view = () => within(container);
const preLine = { whiteSpace: "pre-line" } as const;
async function waitForLayout() {
await act(async () => {
for (let frame = 0; frame < 2; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
});
}
describe("timeline disclosures hold their row", () => {
it("routes user message show more and show less through the row's hold", async () => {
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
const holds = mountRow(
"message-1",
() => view().queryByRole("button", { name: "Show less" }) !== null,
<MessagePreview>
<span style={preLine}>{longText}</span>
</MessagePreview>,
);
// The preview learns it overflows from its first layout.
await waitForLayout();
act(() => view().getByRole("button", { name: "Show more" }).click());
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
act(() => view().getByRole("button", { name: "Show less" }).click());
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
expect(holds).toEqual([
// Show more sits under the text it opens.
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: false },
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: true },
]);
});
it("routes plan card collapse and expand through the row's hold", () => {
const header = () => view().getByRole("button", { name: "Approved plan" });
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
const holds = mountRow(
"plan-1",
isExpanded,
<PlanCard title="Plan" text="Step one" outcome="approved" />,
);
act(() => header().click());
expect(isExpanded()).toBe(false);
act(() => header().click());
expect(isExpanded()).toBe(true);
expect(holds).toEqual([
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: true },
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: false },
]);
});
it("toggles a plan card outside a timeline row, as the pending permission card renders", () => {
act(() =>
root.render(
<QueryClientProvider client={queryClient}>
<PlanCard title="Plan" text="Step one" outcome="pending" />
</QueryClientProvider>,
),
);
const header = () => view().getByRole("button", { name: "Plan" });
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
act(() => header().click());
expect(isExpanded()).toBe(false);
act(() => header().click());
expect(isExpanded()).toBe(true);
});
});
+6 -10
View File
@@ -1,15 +1,11 @@
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react";
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
import type { StreamViewportHandle } from "./strategy";
/**
* Runs a disclosure's state change. Inside a timeline row, the trigger keeps its screen
* position through the change; elsewhere `apply` runs as is. A trigger below the content it
* opens passes `triggerEdge: "bottom"`.
* Runs a disclosure's state change. Inside a timeline row, the row that owns the
* trigger keeps its screen position through the change; elsewhere `apply` runs as is.
*/
export type DisclosureHold = (
apply: () => void,
options?: { triggerEdge?: DisclosureTriggerEdge },
) => void;
export type DisclosureHold = (apply: () => void) => void;
const applyInPlace: DisclosureHold = (apply) => apply();
@@ -25,10 +21,10 @@ export function DisclosureRow({
children: ReactNode;
}) {
const hold = useMemo<DisclosureHold>(
() => (apply, options) => {
() => (apply) => {
const viewport = viewportRef.current;
if (viewport) {
viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top");
viewport.holdRow(rowId, apply);
} else {
apply();
}
@@ -3,7 +3,6 @@ import React, { act, useSyncExternalStore } from "react";
import { createRoot, type Root } from "react-dom/client";
import { View } from "react-native";
import { afterEach, beforeEach, expect, it } from "vitest";
import { disclosureAnchor } from "./native-disclosure-hold";
// The native chat timeline runs on the patched LegendList (patches/@legendapp+list+3.7.1.patch).
// A disclosure hold keeps only the held row in place: `shouldRestorePosition` accepts no other row.
@@ -131,16 +130,7 @@ function mountList(heldRowId: string) {
return position - state.scroll;
};
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
const rowBottom = (rowId: string) => {
const state = listRef.current?.getState();
const position = state?.positionByKey(rowId);
const size = state?.sizes.get(rowId);
if (state === undefined || position === undefined || size === undefined) {
throw new Error(`Row ${rowId} is not laid out`);
}
return position + size - state.scroll;
};
return { render, scrollTo, heldRowTop, rowSize, rowBottom };
return { render, scrollTo, heldRowTop, rowSize };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
@@ -181,72 +171,6 @@ it("anchors a size change on a held row whose top is above the viewport", async
expect(list.heldRowTop()).toBe(-20);
});
// A long message's show more sits under the text it opens, on the bottom edge of its row. The
// strategy anchors the row below it, whose top is that bottom edge.
const bottomTriggerRows: Row[] = Array.from({ length: 10 }, (_, index) => ({
id: `row-${index}`,
height: index === 3 ? 60 : 40,
}));
function mountBottomTriggerHold(rowId: string) {
const anchor = disclosureAnchor(bottomTriggerRows, rowId, "bottom");
if (anchor.kind !== "row") throw new Error(`Row ${rowId} has no row below it`);
return mountList(anchor.rowId);
}
it("keeps the bottom of a row whose trigger sits under its growing content", async () => {
const list = mountBottomTriggerHold("row-3");
list.render(bottomTriggerRows);
await wait(150);
await list.scrollTo(100);
const triggerBottom = list.rowBottom("row-3");
expect(triggerBottom).toBe(80);
act(() => growRow("row-3", 260));
await wait(150);
expect(list.rowBottom("row-3")).toBe(triggerBottom);
act(() => growRow("row-3", 60));
await wait(150);
expect(list.rowBottom("row-3")).toBe(triggerBottom);
});
it("keeps the bottom of a bottom-trigger row whose top is above the viewport", async () => {
const list = mountBottomTriggerHold("row-3");
list.render(bottomTriggerRows);
await wait(150);
await list.scrollTo(140);
const triggerBottom = list.rowBottom("row-3");
expect(triggerBottom).toBe(40);
act(() => growRow("row-3", 260));
await wait(150);
expect(list.rowBottom("row-3")).toBe(triggerBottom);
act(() => growRow("row-3", 60));
await wait(150);
expect(list.rowBottom("row-3")).toBe(triggerBottom);
});
it("keeps the bottom of a bottom-trigger row that ends below the viewport", async () => {
// In the app the row ends with the message's timestamp under the trigger, so a trigger near the
// bottom of the viewport has its row's bottom, and the row below, out of view.
const list = mountBottomTriggerHold("row-3");
list.render(bottomTriggerRows);
await wait(150);
await list.scrollTo(40);
const rowBottom = list.rowBottom("row-3");
expect(rowBottom).toBe(140);
act(() => growRow("row-3", 260));
await wait(150);
expect(list.rowBottom("row-3")).toBe(rowBottom);
act(() => growRow("row-3", 60));
await wait(150);
expect(list.rowBottom("row-3")).toBe(rowBottom);
});
it("keeps measured row sizes while the list is not laid out", async () => {
// A hidden retained tab is display: none. Its rows still render, and a row measured there
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
@@ -1,10 +1,5 @@
import { describe, expect, it } from "vitest";
import {
createNativeDisclosureHold,
disclosureAnchor,
createEndHoldDistance,
type DisclosureAnchor,
} from "./native-disclosure-hold";
import { createNativeDisclosureHold } from "./native-disclosure-hold";
function createFrames() {
let nextId = 1;
@@ -44,22 +39,22 @@ describe("native disclosure hold", () => {
it("holds the row until two frames pass without an item size change", () => {
const { frames, hold, releases } = createHarness();
hold.hold({ kind: "row", rowId: "turn-1" });
hold.hold("turn-1");
frames.runFrame();
expect(hold.anchorRowId()).toBe("turn-1");
expect(hold.heldRowId()).toBe("turn-1");
expect(releases).toEqual([]);
frames.runFrame();
expect(hold.anchorRowId()).toBeNull();
expect(hold.heldRowId()).toBeNull();
expect(releases).toEqual(["released"]);
});
it("restarts the quiet frames on every item size change", () => {
const { frames, hold, releases } = createHarness();
hold.hold({ kind: "row", rowId: "turn-1" });
hold.hold("turn-1");
frames.runFrame();
hold.noteItemSizeChanged();
frames.runFrame();
@@ -84,11 +79,11 @@ describe("native disclosure hold", () => {
it("moves the hold to the latest toggled row and releases once", () => {
const { frames, hold, releases } = createHarness();
hold.hold({ kind: "row", rowId: "turn-1" });
hold.hold("turn-1");
frames.runFrame();
hold.hold({ kind: "row", rowId: "turn-2" });
hold.hold("turn-2");
expect(hold.anchorRowId()).toBe("turn-2");
expect(hold.heldRowId()).toBe("turn-2");
frames.runFrame();
frames.runFrame();
@@ -99,137 +94,12 @@ describe("native disclosure hold", () => {
it("drops the hold without releasing when cancelled", () => {
const { frames, hold, releases } = createHarness();
hold.hold({ kind: "row", rowId: "turn-1" });
hold.hold("turn-1");
hold.cancel();
frames.runFrame();
frames.runFrame();
expect(hold.anchorRowId()).toBeNull();
expect(hold.heldRowId()).toBeNull();
expect(releases).toEqual([]);
});
it("holds the content end until two quiet frames pass, with no row to anchor on", () => {
const { frames, hold, releases } = createHarness();
hold.hold({ kind: "end" });
frames.runFrame();
hold.noteItemSizeChanged();
frames.runFrame();
expect(hold.anchorRowId()).toBeNull();
expect(releases).toEqual([]);
frames.runFrame();
expect(releases).toEqual(["released"]);
});
});
describe("next-frame disclosure hold", () => {
it("anchors the row a reply landed as while the toggle waited for its frame", () => {
const { frames, hold } = createHarness();
let anchor: DisclosureAnchor = { kind: "end" };
const applied: DisclosureAnchor[] = [];
hold.holdNextFrame(
() => anchor,
(resolved) => applied.push(resolved),
);
expect(applied).toEqual([]);
anchor = { kind: "row", rowId: "turn-2" };
frames.runFrame();
expect(hold.anchorRowId()).toBe("turn-2");
expect(applied).toEqual([{ kind: "row", rowId: "turn-2" }]);
});
it("still lands the toggle when the hold is cancelled before its frame", () => {
const { frames, hold, releases } = createHarness();
const applied: DisclosureAnchor[] = [];
hold.holdNextFrame(
() => ({ kind: "end" }),
(resolved) => applied.push(resolved),
);
hold.cancel();
frames.runFrame();
frames.runFrame();
expect(applied).toEqual([{ kind: "end" }]);
expect(releases).toEqual([]);
});
it("lands a waiting toggle before the next hold starts", () => {
const { frames, hold } = createHarness();
const applied: DisclosureAnchor[] = [];
hold.holdNextFrame(
() => ({ kind: "end" }),
(resolved) => applied.push(resolved),
);
hold.hold({ kind: "row", rowId: "turn-1" });
expect(applied).toEqual([{ kind: "end" }]);
frames.runFrame();
expect(applied).toEqual([{ kind: "end" }]);
expect(hold.anchorRowId()).toBe("turn-1");
});
});
describe("disclosure anchor", () => {
const rows = [{ id: "message-1" }, { id: "turn-1" }, { id: "message-2" }];
it("keeps the top of a row whose trigger sits on its top edge", () => {
expect(disclosureAnchor(rows, "turn-1", "top")).toEqual({ kind: "row", rowId: "turn-1" });
});
it("keeps the top of the row below a row whose trigger sits on its bottom edge", () => {
expect(disclosureAnchor(rows, "message-1", "bottom")).toEqual({
kind: "row",
rowId: "turn-1",
});
});
// A prompt the agent has not answered yet is the last row; the running indicator is the footer.
it("keeps the content end for a bottom-edge trigger on the last row", () => {
expect(disclosureAnchor(rows, "message-2", "bottom")).toEqual({ kind: "end" });
});
// The strategy re-derives this each render, so a reply lets go of the end in the commit it lands.
it("stops keeping the content end once a reply lands below the row", () => {
expect(disclosureAnchor([...rows, { id: "turn-2" }], "message-2", "bottom")).toEqual({
kind: "row",
rowId: "turn-2",
});
});
});
describe("end hold distance", () => {
const viewportHeight = 600;
it("keeps the distance from the end the toggle started at", () => {
const distance = createEndHoldDistance(20);
distance.noteContentSize({ contentHeight: 2000, viewportHeight });
expect(distance.current()).toBe(20);
});
it("shrinks to the scroll room a collapse leaves", () => {
const distance = createEndHoldDistance(20);
distance.noteContentSize({ contentHeight: 610, viewportHeight });
expect(distance.current()).toBe(10);
});
it("stays at the end when output grows the content after a collapse reached the end", () => {
const distance = createEndHoldDistance(20);
distance.noteContentSize({ contentHeight: 600, viewportHeight });
distance.noteContentSize({ contentHeight: 700, viewportHeight });
expect(distance.current()).toBe(0);
});
});
@@ -1,30 +1,13 @@
import type { DisclosureTriggerEdge } from "./strategy";
/** What a disclosure hold keeps in place: the top of a row, or the end of the content. */
export type DisclosureAnchor = { kind: "row"; rowId: string } | { kind: "end" };
/**
* The native viewport's disclosure hold. While it holds a row anchor, that row is the only row the
* list may anchor on (`disclosureAnchor`). The hold ends once item sizes stop changing for
* `quietFrames` frames: the list measures the toggled row and any rows it inserted over one or more
* frames, and end following must not resume between the change and the list's last layout
* correction.
* The native viewport's disclosure hold. While a row is held, it is the only row the list may
* anchor on. The hold ends once item sizes stop changing for `quietFrames` frames: the list
* measures the toggled row and any rows it inserted over one or more frames, and end following
* must not resume between the change and the list's last layout correction.
*/
export interface NativeDisclosureHold {
/** The row the list may anchor on; null outside a hold and while the hold keeps the end. */
anchorRowId(): string | null;
hold(anchor: DisclosureAnchor): void;
/**
* Holds on the next frame and runs `apply` there, for a toggle that has to land after a commit
* already on its way. `resolveAnchor` runs in that frame, because rows may land while it waits.
* A later hold or a cancel lands a waiting toggle right away.
*/
holdNextFrame(
resolveAnchor: () => DisclosureAnchor,
apply: (anchor: DisclosureAnchor) => void,
): void;
heldRowId(): string | null;
hold(rowId: string): void;
noteItemSizeChanged(): void;
/** Drops the hold without releasing it. */
cancel(): void;
}
@@ -34,10 +17,9 @@ export function createNativeDisclosureHold(input: {
cancelFrame: (id: number) => void;
onRelease: () => void;
}): NativeDisclosureHold {
let anchor: DisclosureAnchor | null = null;
let heldRowId: string | null = null;
let frameId: number | null = null;
let remainingFrames = 0;
let waitingToggle: { frameId: number; land: () => void } | null = null;
const stopCountdown = () => {
if (frameId !== null) {
@@ -53,7 +35,7 @@ export function createNativeDisclosureHold(input: {
return;
}
frameId = null;
anchor = null;
heldRowId = null;
input.onRelease();
};
@@ -63,80 +45,20 @@ export function createNativeDisclosureHold(input: {
frameId = input.requestFrame(tick);
};
const landWaitingToggle = () => {
if (!waitingToggle) return;
const { frameId: waitingFrameId, land } = waitingToggle;
waitingToggle = null;
input.cancelFrame(waitingFrameId);
land();
};
const startHold = (nextAnchor: DisclosureAnchor) => {
anchor = nextAnchor;
restartCountdown();
};
return {
anchorRowId: () => (anchor?.kind === "row" ? anchor.rowId : null),
hold(nextAnchor) {
landWaitingToggle();
startHold(nextAnchor);
},
holdNextFrame(resolveAnchor, apply) {
landWaitingToggle();
const waitingFrameId = input.requestFrame(() => {
waitingToggle = null;
const nextAnchor = resolveAnchor();
startHold(nextAnchor);
apply(nextAnchor);
});
waitingToggle = { frameId: waitingFrameId, land: () => apply(resolveAnchor()) };
heldRowId: () => heldRowId,
hold(rowId) {
heldRowId = rowId;
restartCountdown();
},
noteItemSizeChanged() {
if (anchor !== null) {
if (heldRowId !== null) {
restartCountdown();
}
},
cancel() {
landWaitingToggle();
stopCountdown();
anchor = null;
heldRowId = null;
},
};
}
/**
* What the list keeps in place while a disclosure toggles `rowId`. LegendList restores row tops, so
* a trigger on the bottom edge of its row anchors the row below: that row's top is the toggled
* row's bottom. The last row's bottom is the end of the content.
*/
export function disclosureAnchor(
rows: readonly { id: string }[],
rowId: string,
triggerEdge: DisclosureTriggerEdge,
): DisclosureAnchor {
if (triggerEdge === "top") return { kind: "row", rowId };
const index = rows.findIndex((row) => row.id === rowId);
const below = rows[index + 1];
return below ? { kind: "row", rowId: below.id } : { kind: "end" };
}
/**
* The list's distance from the end during an end hold. The end pin keeps the distance through every
* content size change, and the scroll view clamps it to the scroll room the content has at that
* moment. A clamp is permanent: content that grows afterwards does not bring the distance back.
*/
export function createEndHoldDistance(distanceAtToggle: number): EndHoldDistance {
let distance = distanceAtToggle;
return {
noteContentSize({ contentHeight, viewportHeight }) {
distance = Math.min(distance, Math.max(0, contentHeight - viewportHeight));
},
current: () => distance,
};
}
export interface EndHoldDistance {
noteContentSize(input: { contentHeight: number; viewportHeight: number }): void;
current(): number;
}
@@ -7,7 +7,7 @@ describe("reading anchor", () => {
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
const replacements = new Map([["tool", "prompt"]]);
expect(anchor.getRowId(replacements)).toBe("prompt");
expect(anchor.project(0, { id: "prompt", top: 1048, height: 100 }, replacements)).toBe(1000);
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
// Projection does not consume the correction; an unmounted host keeps the pin.
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
expect(anchor.getRowId()).toBe("tool");
@@ -59,32 +59,13 @@ describe("reading anchor", () => {
it("keeps a pinned row in place instead of the row under the reading line", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
anchor.pin(rows[2]!, "top", 600);
anchor.pin(rows[2]!);
// The row being read grows; only the pinned row below it holds its position.
expect(
anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 800 }, { ...rows[2]!, top: 1300 }]),
).toBe(900);
});
it("keeps the bottom of a row pinned by a trigger below its content", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
anchor.pin(rows[2]!, "bottom", 600);
// The pinned row grows downward; its bottom, where the trigger sits, holds its position.
const grown = { ...rows[2]!, height: rows[2]!.height + 300 };
expect(anchor.reconcile(600, [rows[0]!, rows[1]!, grown])).toBe(900);
});
it("measures a pinned row's correction from the offset at the toggle", () => {
const anchor = createReadingAnchor();
anchor.reconcile(900, rows);
anchor.pin(rows[2]!, "bottom", 900);
// The row shrinks and the browser clamps the offset to the shorter content before the
// reconcile reads it.
const shrunk = { ...rows[2]!, height: rows[2]!.height - 300 };
expect(anchor.reconcile(700, [rows[0]!, rows[1]!, shrunk])).toBe(600);
});
it("leaves expansion of the row being read in place", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
@@ -128,8 +109,8 @@ describe("reading anchor", () => {
it("projects a prepend range without consuming the correction before layout commits", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
expect(
anchor.reconcile(
440,
@@ -1,14 +1,9 @@
import type { DisclosureTriggerEdge } from "./strategy";
interface RowGeometry {
id: string;
top: number;
height: number;
}
const edgeOf = (row: RowGeometry, edge: DisclosureTriggerEdge) =>
edge === "top" ? row.top : row.top + row.height;
// A row must clear the reading line before the next row takes ownership.
const READING_POSITION_OFFSET_PX = 8;
export interface ReadingRowReplacements {
@@ -19,16 +14,7 @@ const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
// Content coordinates survive user scrolling. Layout commits replace the geometry;
// scroll events reuse it, without another DOM measurement pass.
export function createReadingAnchor() {
// The row being read keeps its top. A disclosure pins the edge of its row the trigger sits on,
// with the offset at the toggle: a toggle that shrinks the content lets the browser clamp the
// offset before the next reconcile reads it.
let anchor: {
id: string;
edge: DisclosureTriggerEdge;
position: number;
pinnedScrollTop?: number;
} | null = null;
const anchorTop = (row: RowGeometry) => ({ id: row.id, edge: "top" as const, position: row.top });
let anchor: { id: string; top: number } | null = null;
let geometry: readonly RowGeometry[] = [];
const readingRow = (scrollTop: number) =>
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
@@ -36,12 +22,9 @@ export function createReadingAnchor() {
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
const project = (
scrollTop: number,
row: RowGeometry | undefined,
row: Pick<RowGeometry, "id" | "top"> | undefined,
replacements = NO_REPLACEMENTS,
) =>
anchor && row && row.id === getRowId(replacements)
? (anchor.pinnedScrollTop ?? scrollTop) + edgeOf(row, anchor.edge) - anchor.position
: scrollTop;
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
return {
getRowId,
getReadingRowId: (scrollTop: number) =>
@@ -50,14 +33,14 @@ export function createReadingAnchor() {
reset() {
anchor = null;
},
// A disclosure names the row that owns its trigger and the edge the trigger sits on. That
// edge stays in place through the toggle, even when another row sits under the reading line.
pin(row: RowGeometry, edge: DisclosureTriggerEdge, scrollTop: number) {
anchor = { id: row.id, edge, position: edgeOf(row, edge), pinnedScrollTop: scrollTop };
// A disclosure names the row that owns its trigger. That row keeps its top
// through the toggle, even when another row sits under the reading line.
pin(row: Pick<RowGeometry, "id" | "top">) {
anchor = { id: row.id, top: row.top };
},
scroll(scrollTop: number) {
const next = readingRow(scrollTop);
anchor = next ? anchorTop(next) : null;
anchor = next ? { id: next.id, top: next.top } : null;
},
reconcile(
scrollTop: number,
@@ -74,7 +57,7 @@ export function createReadingAnchor() {
// A prepend can expose the bottom of an estimated row above the reader.
// Do not transfer ownership to it until the user moves the reading position.
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
anchor = next ? anchorTop(next) : null;
anchor = next ? { id: next.id, top: next.top } : null;
return correctedTop;
},
};
@@ -35,20 +35,10 @@ import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
import { useRevisedHistoryRows } from "./history-row-revision";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import {
createNativeDisclosureHold,
disclosureAnchor,
createEndHoldDistance,
type EndHoldDistance,
} from "./native-disclosure-hold";
import { createNativeDisclosureHold } from "./native-disclosure-hold";
import { EndPinnedScrollView } from "./native-end-pin";
import { createNewRowEstimates } from "./new-row-estimates";
import type {
DisclosureTriggerEdge,
StreamRenderInput,
StreamStrategy,
StreamViewportHandle,
} from "./strategy";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
import {
isHistoryStartLoadingOperation,
@@ -316,14 +306,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isAtEnd = useStableEvent(
() => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX,
);
// A hold on the content end mounts the end pin while detached (`holdRow`). The release reads the
// tracked distance instead of the offset: the content size reaches JS before the pin's scroll does.
const [endHold, setEndHold] = useState<{ rowId: string; distance: EndHoldDistance } | null>(null);
const releaseDisclosure = useStableEvent(() => {
setEndHold(null);
bottomAnchorController.releaseDisclosure({
isAtEnd: endHold ? endHold.distance.current() <= AT_END_OFFSET_PX : isAtEnd(),
});
bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() });
});
const [disclosureHold] = useState(() =>
createNativeDisclosureHold({
@@ -333,54 +317,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onRelease: releaseDisclosure,
}),
);
const cancelDisclosureHold = useStableEvent(() => {
disclosureHold.cancel();
setEndHold(null);
});
useEffect(() => () => cancelDisclosureHold(), [cancelDisclosureHold]);
useEffect(() => () => disclosureHold.cancel(), [disclosureHold]);
// The hold detaches the controller in the same event as `apply`, so the toggle's commit already
// has end following off and LegendList anchoring on the held row, or on the row below it for a
// trigger on the row's bottom edge. The row's measurement trigger makes that same commit carry
// the row's new height. While following, a bottom trigger keeps following: the end pin holds the
// content end, and with it the bottom of the row, in the frame the new height mounts.
// The last row's bottom is the content end, which LegendList cannot anchor. The end pin can, from
// any offset, but only once it is mounted: a pin that mounts with the new height anchors nothing.
// So the toggle lands a frame after the pin's commit, whose mount runs before the frame's timers.
const latestDisclosureAnchor = useStableEvent(
(rowId: string, triggerEdge: DisclosureTriggerEdge) =>
disclosureAnchor(rows, rowId, triggerEdge),
);
const holdRow = useStableEvent(
(rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => {
const applyAndMeasure = () => {
apply();
rowLayoutSyncRegistry.get(rowId)?.();
};
if (triggerEdge === "bottom" && bottomAnchorController.mode === "sticky-bottom") {
applyAndMeasure();
return;
}
const anchor = disclosureAnchor(rows, rowId, triggerEdge);
bottomAnchorController.holdForDisclosure();
if (anchor.kind === "row" || !isListPlacedRef.current) {
setEndHold(null);
disclosureHold.hold(anchor);
applyAndMeasure();
return;
}
setEndHold({
rowId,
distance: createEndHoldDistance(distanceFromEnd(streamViewportMetricsRef.current)),
});
disclosureHold.holdNextFrame(
() => latestDisclosureAnchor(rowId, triggerEdge),
(latestAnchor) => {
if (latestAnchor.kind === "row") setEndHold(null);
applyAndMeasure();
},
);
},
);
// has end following off and LegendList anchoring on the held row. The row's measurement trigger
// makes that same commit carry the row's new height.
const holdRow = useStableEvent((rowId: string, apply: () => void) => {
disclosureHold.hold(rowId);
bottomAnchorController.holdForDisclosure();
apply();
rowLayoutSyncRegistry.get(rowId)?.();
});
const handleItemSizeChanged = useStableEvent(
(info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => {
newRowEstimates.noteMeasured(info.itemData, info.size);
@@ -388,8 +334,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
);
const shouldRestorePosition = useStableEvent((item: StreamItem) => {
const anchorRowId = disclosureHold.anchorRowId();
return anchorRowId === null || item.id === anchorRowId;
const heldRowId = disclosureHold.heldRowId();
return heldRowId === null || item.id === heldRowId;
});
// One owner keeps the scroll offset at any moment:
// - Before the list has placed its rows for the agent: LegendList's initial scroll to the end. It
@@ -403,23 +349,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
// scrolls to the end its JS sizes predict, which runs ahead of the native mount, so it lands
// short of the real end and the controller reads that as the user scrolling away.
// - A disclosure hold: the held row. The hold detaches the controller, which removes the pin, and
// LegendList keeps only the held row in place, or the row below it when the trigger sits on the
// held row's bottom edge. A bottom-edge hold while following leaves the end pin in charge, and
// one on the last row mounts the pin for the hold: the row's bottom is the content end.
// LegendList keeps only the held row in place.
// - A user scroll: the finger. The controller detaches for the gesture, so streamed lines grow below
// the rows the user is dragging, and resumes following if the gesture ends at the end.
// - Detached: the visible rows. LegendList keeps the first visible row in place, so history loading
// above or rows growing below leave what the user is reading where it is.
const isFollowingEnd = bottomAnchorController.mode === "sticky-bottom";
// An end hold pins the end only while its row's bottom is the end. A reply landing below the row
// drops the pin in the commit that mounts the reply, so the reply grows below the held row.
const isEndHeld =
endHold !== null && disclosureAnchor(rows, endHold.rowId, "bottom").kind === "end";
const isEndPinned = isFollowingEnd || isEndHeld;
const pinsEnd = isEndPinned && isListPlaced;
const pinsEnd = isFollowingEnd && isListPlaced;
const maintainVisibleContentPosition = useMemo(
() => (isEndPinned ? false : { data: true, size: true, shouldRestorePosition }),
[isEndPinned, shouldRestorePosition],
() => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }),
[isFollowingEnd, shouldRestorePosition],
);
// A new identity re-renders LegendList's scroll component, which is how the pin follows the mode.
const renderScrollComponent = useCallback(
@@ -566,7 +505,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
// The gesture takes the viewport from a disclosure hold. Size changes from a streaming row can
// keep the hold's quiet frames from ever passing, and a held row that scrolls away would leave
// no row LegendList may anchor a history page on. The gesture's end decides following.
cancelDisclosureHold();
disclosureHold.cancel();
bottomAnchorController.beginUserScroll();
historyStartPagination.rearm();
});
@@ -642,7 +581,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentHeight: nextContentHeight,
contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null,
};
endHold?.distance.noteContentSize(streamViewportMetricsRef.current);
bottomAnchorController.handleContentSizeChange();
// Content growing below a detached viewport moves the end away without a scroll event.
if (
@@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import type { StreamItem } from "@/types/stream";
import type { DisclosureTriggerEdge, StreamRenderInput, StreamViewportHandle } from "./strategy";
import type { StreamRenderInput, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -91,15 +91,10 @@ function mountTimeline(historyMounted: StreamItem[]) {
return {
render: (segments: Partial<StreamRenderInput["segments"]>) => act(() => render(segments)),
hold: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge = "top") =>
act(() => viewportRef.current!.holdRow(rowId, apply, triggerEdge)),
hold: (rowId: string, apply: () => void) =>
act(() => viewportRef.current!.holdRow(rowId, apply)),
rowTop: (id: string) =>
rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
rowBottom: (id: string) =>
rowElement(id).getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().top,
scrollTo: (top: number) => {
scrollContainer.scrollTop = top;
},
distanceFromEnd: () =>
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
// Resize observers and the hold's settle run on animation frames.
@@ -144,53 +139,6 @@ describe("web disclosure hold", () => {
expect(timeline.rowTop("message-5")).toBe(triggerTop);
});
// A long message's show more sits under the text it opens, on the bottom edge of its row.
it("keeps the bottom of a bottom-trigger row through show more and show less while following output", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
await timeline.settle();
const triggerBottom = timeline.rowBottom("message-5");
timeline.hold(
"message-5",
() => timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }),
"bottom",
);
await timeline.settle();
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
timeline.hold("message-5", () => timeline.render({ historyMounted: history }), "bottom");
await timeline.settle();
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
});
it("keeps the bottom of a bottom-trigger row through show more and show less when scrolled up", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
await timeline.settle();
timeline.scrollTo(60);
await timeline.settle();
// message-2 is cut off at the top; message-3 is fully in view.
for (const index of [2, 3]) {
const triggerBottom = timeline.rowBottom(`message-${index}`);
const grown = history.map((item, position) =>
position === index - 1 ? row(index, 240) : item,
);
timeline.hold(`message-${index}`, () => timeline.render({ historyMounted: grown }), "bottom");
await timeline.settle();
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
timeline.hold(
`message-${index}`,
() => timeline.render({ historyMounted: history }),
"bottom",
);
await timeline.settle();
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
}
});
it("resumes following when collapsing an anchored row lands back at the end", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
@@ -418,7 +418,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
{
id: String(measurement.key),
top: measurement.start,
height: measurement.size,
},
props.readingRowReplacements,
)
@@ -1173,12 +1172,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
scheduleStickToBottom();
},
holdRow: (rowId, apply, triggerEdge) => {
holdRow: (rowId, apply) => {
cancelPendingStickToBottom();
setFollowOutput(false);
const row = measureRows()?.find((candidate) => candidate.id === rowId);
const scrollTop = scrollContainerRef.current?.scrollTop;
if (row && scrollTop !== undefined) readingAnchor.pin(row, triggerEdge, scrollTop);
if (row) readingAnchor.pin(row);
disclosurePendingRef.current = true;
apply();
},
+3 -10
View File
@@ -46,23 +46,16 @@ export interface ScrollToMessageOccurrence {
targetTop(): number | null;
}
/**
* The edge of its row a disclosure's trigger sits on. A header trigger sits on the top: the content
* it opens grows below it. A trigger under the content it opens, such as a long message's show
* more, sits on the bottom.
*/
export type DisclosureTriggerEdge = "top" | "bottom";
export interface StreamViewportHandle {
/** Give keyboard navigation back to the scrolling surface on web. */
focus?: () => void;
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
/**
* Run a disclosure's state change so the row that owns its trigger keeps the edge the trigger
* sits on in place. Following output stops; it resumes if the change leaves the list at its end.
* Run a disclosure's state change so the row that owns its trigger keeps its visual top.
* Following output stops; it resumes if the change leaves the list at its end.
*/
holdRow: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => void;
holdRow: (rowId: string, apply: () => void) => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
}
+1 -2
View File
@@ -1259,8 +1259,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;
}
File diff suppressed because one or more lines are too long
@@ -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,
@@ -46,7 +46,6 @@ const LANGUAGE_ALIASES: Record<string, string> = {
"objective-c": "m",
markdown: "md",
elixir: "ex",
terraform: "tf",
};
function fenceLanguageToExtension(info: string | null | undefined): string | null {
+50 -55
View File
@@ -39,7 +39,6 @@ import {
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarToastRegion } from "@/components/sidebar/sidebar-toast-region";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
@@ -645,33 +644,31 @@ function MobileSidebar({
<SidebarSeparator />
<SidebarViewSlot dismiss={handleWorkspacePress}>
<SidebarToastRegion active={active}>
{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}
/>
)}
</SidebarToastRegion>
{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
@@ -845,33 +842,31 @@ function DesktopSidebar({
</View>
<SidebarViewSlot dismiss={closeSidebar}>
<SidebarToastRegion active={active}>
{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}
/>
)}
</SidebarToastRegion>
{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 -3
View File
@@ -1,4 +1,3 @@
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
@@ -51,8 +50,7 @@ function PlanCardContent({
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
[expanded],
);
const hold = useDisclosureHold();
const toggleExpanded = useCallback(() => hold(() => setExpanded((value) => !value)), [hold]);
const toggleExpanded = useCallback(() => setExpanded((value) => !value), []);
const containerStyle = useMemo(
() => [styles.container, disableOuterSpacing && styles.containerCompact],
[disableOuterSpacing],
@@ -1,52 +0,0 @@
import { useEffect, useMemo, useRef, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host";
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
/**
* Shows toasts raised by the sidebar's list over its bottom edge, just above the footer.
*
* A collapsed sidebar stays mounted and its rows still handle shortcuts, so while it is hidden
* their toasts go to the app's host instead. The sidebar's API stays stable across open and close
* so that toggling the panel doesn't re-render every row.
*/
export function SidebarToastRegion({ active, children }: { active: boolean; children: ReactNode }) {
const appToast = useToast();
const { api: sidebarToast, toast, dismiss } = useToastHost();
const activeRef = useRef(active);
useEffect(() => {
activeRef.current = active;
return () => {
// An archive holds on to this API, so a failure that lands later needs a host on screen.
activeRef.current = false;
// A toast must not outlive its visibility: its dismissal is what ends an archive's undo.
dismiss();
};
}, [active, dismiss]);
const api = useMemo<ToastApi>(() => {
const visibleHost = () => (activeRef.current ? sidebarToast : appToast);
return {
show: (content, options) => visibleHost().show(content, options),
copied: (label) => visibleHost().copied(label),
error: (message) => visibleHost().error(message),
};
}, [appToast, sidebarToast]);
return (
<View style={styles.region}>
<ToastApiProvider api={api}>{children}</ToastApiProvider>
<ToastViewport toast={toast} onDismiss={dismiss} placement="sidebar" />
</View>
);
}
const styles = StyleSheet.create({
region: {
position: "relative",
flex: 1,
minHeight: 0,
},
});
@@ -74,11 +74,7 @@ const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMappin
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
/** Pointer-only, so it is always the kebab's pointer size. */
const ARCHIVE_BUTTON_SIZE = 18;
/** Keeps a press aimed at the kebab from landing on the one-click archive. */
const ARCHIVE_BUTTON_KEBAB_GAP = 8;
/** The width the archive button and its gap take up left of the kebab. */
export const ARCHIVE_BUTTON_FOOTPRINT = ARCHIVE_BUTTON_SIZE + ARCHIVE_BUTTON_KEBAB_GAP;
export const ARCHIVE_BUTTON_SIZE = 18;
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
@@ -486,6 +482,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
marginLeft: 2,
// Center the touch box on the 20px title line it is anchored to.
marginTop: bySizing({ touch: -6, pointer: 0 }),
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
@@ -496,11 +493,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
// The kebab's box without its offsets: the archive glyph fills its view box, so it needs
// none of the nudging the three dots do.
// none of the nudging the three dots do. The kebab's marginLeft is the gap between them.
archiveButton: {
width: ARCHIVE_BUTTON_SIZE,
height: ARCHIVE_BUTTON_SIZE,
marginRight: ARCHIVE_BUTTON_KEBAB_GAP,
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
@@ -31,7 +31,7 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
import { DensityIcon } from "@/components/ui/density-icon";
import { SCRIM_WIDTH, TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import {
ARCHIVE_BUTTON_FOOTPRINT,
ARCHIVE_BUTTON_SIZE,
SidebarWorkspaceArchiveButton,
} from "@/components/sidebar/sidebar-workspace-menu";
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
@@ -446,6 +446,9 @@ export function SidebarWorkspaceTrailingActionBase({
);
}
// The archive button plus the kebab's marginLeft, which is the gap between them.
const ARCHIVE_BUTTON_SCRIM_WIDTH = ARCHIVE_BUTTON_SIZE + 2;
export function SidebarWorkspaceTrailingActionOverlay({
visible,
scrimBackdrop,
@@ -470,7 +473,7 @@ export function SidebarWorkspaceTrailingActionOverlay({
{scrimBackdrop ? (
<TrailingActionScrim
backdrop={scrimBackdrop}
width={onArchiveButtonPress ? SCRIM_WIDTH + ARCHIVE_BUTTON_FOOTPRINT : SCRIM_WIDTH}
width={onArchiveButtonPress ? SCRIM_WIDTH + ARCHIVE_BUTTON_SCRIM_WIDTH : SCRIM_WIDTH}
testID="sidebar-workspace-trailing-scrim"
/>
) : null}
+15 -57
View File
@@ -1,6 +1,6 @@
import {
AdaptiveWorkspaceChrome,
type WorkspaceCompactHeaderTabs,
type WorkspaceCompactHeaderActions,
} from "@/screens/workspace/adaptive-workspace-chrome";
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
import {
@@ -66,13 +66,12 @@ import { isNative } from "@/constants/platform";
interface SplitContainerProps {
layout: WorkspaceLayout;
/** The full workspace header: title, project row and workspace actions. */
renderMainHeader?: () => ReactNode;
/**
* The compact workspace header. With one presented pane it carries that pane's tab switcher in
* place of its tab rail; above a split it carries workspace controls only.
*/
renderCompactHeader: (paneTabs: WorkspaceCompactHeaderTabs | null) => ReactNode;
renderCompactHeader: (
tabs: WorkspaceTabDescriptor[],
activeTabKey: string,
actions: WorkspaceCompactHeaderActions,
) => ReactNode;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
@@ -129,11 +128,6 @@ interface SplitNodeViewProps extends Omit<
windowChromeCorners: WindowChromeCorners;
maximizedPaneId: string | null;
workspaceHasMultiplePanes: boolean;
/**
* One presented pane draws the workspace header itself and may merge its tabs into the compact
* header. Split panes share one header above them and always show their tab rails.
*/
ownsWorkspaceHeader: boolean;
onTogglePaneMaximized: (paneId: string) => void;
}
@@ -250,27 +244,11 @@ export function SplitContainer({
: inheritedWindowChromeCorners;
const mainColumnStyle = styles.mainColumn;
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
const panesShareWorkspaceHeader = hasMultipleVisiblePanes(splitRoot.root);
const sharedCompactHeader = useMemo(
() =>
panesShareWorkspaceHeader ? (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(null)}
</WindowChromeSafeArea>
) : null,
[panesShareWorkspaceHeader, renderCompactHeader],
);
return (
<RenderProfile id="SplitContainer">
<View style={styles.workspaceShell}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{panesShareWorkspaceHeader ? (
<AdaptiveWorkspaceChrome compactHeader={sharedCompactHeader}>
{renderMainHeader?.()}
</AdaptiveWorkspaceChrome>
) : null}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
@@ -306,12 +284,7 @@ export function SplitContainer({
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={
splitRoot.usesFallbackStrip || panesShareWorkspaceHeader
? "none"
: windowChromeCorners
}
ownsWorkspaceHeader={!panesShareWorkspaceHeader}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={handleTogglePaneMaximized}
@@ -450,7 +423,6 @@ function SplitNodeView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
ownsWorkspaceHeader,
renderMainHeader,
renderCompactHeader,
}: SplitNodeViewProps) {
@@ -539,7 +511,6 @@ function SplitNodeView({
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
ownsWorkspaceHeader={ownsWorkspaceHeader}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
@@ -607,7 +578,6 @@ function SplitNodeView({
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
ownsWorkspaceHeader={ownsWorkspaceHeader}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
@@ -668,7 +638,6 @@ const SplitPaneView = memo(function SplitPaneView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
ownsWorkspaceHeader,
renderMainHeader,
renderCompactHeader,
}: SplitPaneViewProps) {
@@ -801,24 +770,13 @@ const SplitPaneView = memo(function SplitPaneView({
createNewTab,
});
const compactHeader = useMemo(
() =>
ownsWorkspaceHeader ? (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader({
tabs: paneTabs,
activeTabKey: activeTabDescriptor?.key ?? "",
actions: compactHeaderActions,
})}
</WindowChromeSafeArea>
) : null,
[
ownsWorkspaceHeader,
renderCompactHeader,
paneTabs,
activeTabDescriptor?.key,
compactHeaderActions,
],
() => (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
</WindowChromeSafeArea>
),
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
@@ -829,7 +787,7 @@ const SplitPaneView = memo(function SplitPaneView({
testID={`workspace-pane-${pane.id}`}
>
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
{ownsWorkspaceHeader ? renderMainHeader?.() : null}
{renderMainHeader?.()}
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
+22 -32
View File
@@ -4,7 +4,14 @@ import {
} from "@/components/floating-header";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Platform, StyleSheet as NativeStyleSheet, Text, ToastAndroid, View } from "react-native";
import {
Platform,
Pressable,
StyleSheet as NativeStyleSheet,
Text,
ToastAndroid,
View,
} from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -52,7 +59,7 @@ export interface ToastApi {
error: (message: string) => void;
}
type ToastViewportPlacement = "app-shell" | "panel" | "sidebar";
type ToastViewportPlacement = "app-shell" | "panel";
const DEFAULT_DURATION_MS = 2200;
const noopDismiss: DismissToast = () => {};
@@ -214,17 +221,11 @@ export function ToastViewport({
const content = (
<View
style={
placement === "sidebar" ? styles.sidebarContainer : styles.container(placement, topOffset)
}
style={styles.container(placement, topOffset)}
pointerEvents="box-none"
collapsable={false}
>
<View
style={placement === "sidebar" ? styles.sidebarWidthBoundary : styles.widthBoundary}
pointerEvents="box-none"
collapsable={false}
>
<View style={styles.widthBoundary} pointerEvents="box-none" collapsable={false}>
{toast ? (
<Animated.View
entering={toastEntering}
@@ -233,7 +234,7 @@ export function ToastViewport({
testID={toast.testID ?? "app-toast"}
accessibilityRole="alert"
>
<ToastCard toast={toast} onPointerEnter={pauseDismiss} onPointerLeave={resumeDismiss} />
<ToastCard toast={toast} onHoverIn={pauseDismiss} onHoverOut={resumeDismiss} />
</Animated.View>
) : null}
</View>
@@ -249,12 +250,12 @@ export function ToastViewport({
function ToastCard({
toast,
onPointerEnter,
onPointerLeave,
onHoverIn,
onHoverOut,
}: {
toast: ToastState;
onPointerEnter: () => void;
onPointerLeave: () => void;
onHoverIn: () => void;
onHoverOut: () => void;
}) {
let defaultIcon: ReactNode = null;
if (toast.variant === "info") {
@@ -269,9 +270,10 @@ function ToastCard({
const icon = toast.icon ?? defaultIcon;
return (
<View
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
<Pressable
accessible={false}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
style={[
styles.toast,
toast.variant === "info" ? styles.toastInfo : null,
@@ -289,7 +291,7 @@ function ToastCard({
{toast.content}
</View>
)}
</View>
</Pressable>
);
}
@@ -298,7 +300,7 @@ const animatedStyles = NativeStyleSheet.create({
});
const styles = StyleSheet.create((theme) => ({
container: (placement: Exclude<ToastViewportPlacement, "sidebar">, topOffset: number) => ({
container: (placement: ToastViewportPlacement, topOffset: number) => ({
position: "absolute",
left: theme.spacing[4],
right: theme.spacing[4],
@@ -306,23 +308,11 @@ const styles = StyleSheet.create((theme) => ({
zIndex: OVERLAY_Z.toast,
alignItems: "center",
}),
sidebarContainer: {
position: "absolute",
left: theme.spacing[2],
right: theme.spacing[2],
bottom: theme.spacing[2],
zIndex: OVERLAY_Z.toast,
alignItems: "center",
},
widthBoundary: {
width: "92%",
maxWidth: TOAST_MAX_WIDTH,
alignItems: "center",
},
sidebarWidthBoundary: {
width: "100%",
alignItems: "center",
},
toast: {
alignSelf: "center",
maxWidth: "100%",
+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,
],
@@ -74,15 +74,12 @@ export function HorizontalScrollBoundaryShades({
leftStyle,
rightStyle,
testIDPrefix = "horizontal-scroll-boundary",
bottomInset = 1,
}: {
visible: boolean;
backdrop: "surface" | "sidebar";
leftStyle: AnimatedStyle<{ opacity: number }>;
rightStyle: AnimatedStyle<{ opacity: number }>;
testIDPrefix?: string;
/** Set to 0 when the scrolled content's own border reaches the bottom edge and must fade too. */
bottomInset?: number;
}) {
if (!visible) return null;
const colorMapping = backdrop === "sidebar" ? sidebarColorMapping : surfaceColorMapping;
@@ -91,14 +88,14 @@ export function HorizontalScrollBoundaryShades({
<Animated.View
pointerEvents="none"
testID={`${testIDPrefix}-left`}
style={[styles.shade(bottomInset), styles.left, leftStyle]}
style={[styles.shade, styles.left, leftStyle]}
>
<ThemedScrollBoundaryShadeSvg side="left" uniProps={colorMapping} />
</Animated.View>
<Animated.View
pointerEvents="none"
testID={`${testIDPrefix}-right`}
style={[styles.shade(bottomInset), styles.right, rightStyle]}
style={[styles.shade, styles.right, rightStyle]}
>
<ThemedScrollBoundaryShadeSvg side="right" uniProps={colorMapping} />
</Animated.View>
@@ -107,12 +104,12 @@ export function HorizontalScrollBoundaryShades({
}
const styles = StyleSheet.create(() => ({
shade: (bottomInset: number) => ({
shade: {
position: "absolute",
top: 0,
bottom: bottomInset,
bottom: 1,
width: SHADE_WIDTH,
}),
},
left: { left: 0 },
right: { right: 0 },
}));
+10 -21
View File
@@ -322,17 +322,6 @@ export function HoverCardContent(props: HoverCardContentProps): ReactElement | n
);
}
// A card above its trigger holds its bottom edge, so content that loads while it is open grows it
// upward without a correction. Anchored by its top, it would paint over the trigger until placed.
type CardPosition = { left: number; top: number } | { left: number; bottom: number };
function isSamePosition(current: CardPosition | null, next: CardPosition): boolean {
if (!current || current.left !== next.left) return false;
return "top" in current
? "top" in next && current.top === next.top
: "bottom" in next && current.bottom === next.bottom;
}
function readDomRect(view: View | null): Rect | null {
const element = view as unknown as HTMLElement | null;
if (!element) return null;
@@ -360,7 +349,7 @@ function HoverCardSurface({
contentRef: RefObject<View | null>;
}): ReactElement {
const overlayRef = useRef<View>(null);
const [position, setPosition] = useState<CardPosition | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const place = useCallback(() => {
const triggerRect = readDomRect(triggerRef.current);
@@ -368,7 +357,7 @@ function HoverCardSurface({
const content = contentRef.current as unknown as HTMLElement | null;
if (!triggerRect || !overlayRect || !content) return;
const { width, height } = Dimensions.get("window");
const { x, y, actualPlacement } = computePosition({
const { x, y } = computePosition({
triggerRect,
contentSize: { width: content.offsetWidth, height: content.offsetHeight },
displayArea: { x: 0, y: 0, width, height },
@@ -377,19 +366,19 @@ function HoverCardSurface({
alignment,
offset,
});
const left = x - overlayRect.x;
const next: CardPosition =
actualPlacement === "top"
? { left, bottom: overlayRect.height - (y + content.offsetHeight - overlayRect.y) }
: { left, top: y - overlayRect.y };
setPosition((current) => (isSamePosition(current, next) ? current : next));
const next = { x: x - overlayRect.x, y: y - overlayRect.y };
setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next));
}, [alignment, contentRef, offset, placement, triggerRef]);
useLayoutEffect(place, [place]);
const frameStyle = useMemo(
() => ({ position: "absolute" as const, ...(position ?? { top: -9999, left: -9999 }) }),
[position],
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
[position?.x, position?.y],
);
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
@@ -9,11 +9,8 @@ import { useProviderIcon } from "@/components/provider-icons";
import { useSessionStore } from "@/stores/session-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { isNative } from "@/constants/platform";
import { useDisclosureHold } from "@/agent-stream/disclosure";
const PREVIEW_HEIGHT = 240;
// Show more and show less sit under the text they open and close.
const TRIGGER_BELOW_CONTENT = { triggerEdge: "bottom" } as const;
// Native View still clips with "scroll", but Yoga measures its children without
// the height cap. "hidden" caps the measured body too, hiding the expand control.
const previewStyle: ViewStyle = {
@@ -29,11 +26,7 @@ export function MessagePreview({ children }: { children: ReactNode }) {
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
[],
);
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT),
[hold],
);
const toggle = useCallback(() => setExpanded((value) => !value), []);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
return (
<>
@@ -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,161 +0,0 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { DensityIcon } from "@/components/ui/density-icon";
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
type ComposerOptionControls,
} from "@/agent-controls/option-controls";
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import {
type ComposerControlItem,
type ComposerControlSurface,
} from "@/composer/agent-controls/composer-control";
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
import { getAgentControlHintKey, getFeatureHighlightColor } from "@/composer/agent-controls/utils";
const SEARCH_THRESHOLD = 6;
function getFeatureHighlight(
control: AgentOptionControl,
options: ProviderOptions,
): ComposerControlItem["highlight"] {
if (!isControlActive(control, options)) return undefined;
const color = getFeatureHighlightColor(control.key);
return color === "default" ? undefined : color;
}
function toFeatureItem(
control: AgentOptionControl,
optionControls: ComposerOptionControls,
): ComposerControlItem {
const { options, onSetOption } = optionControls;
const base = {
id: `feature-${control.key}`,
title: control.label,
icon: getAgentFeatureIcon(control.icon),
highlight: getFeatureHighlight(control, options),
testID: `agent-feature-${control.key}`,
};
if (control.type === "toggle") {
return {
...base,
type: "toggle",
tooltip: getControlTooltip(control),
value: readToggleOption(control, options),
onChange: (value) => onSetOption(control.key, value),
};
}
const iconOnly = control.desktopTrigger === "icon";
return {
...base,
type: "select",
tooltip: getControlTooltip(control),
value: readSelectOption(control, options),
choices: control.choices,
iconOnly,
onChange: (value) => onSetOption(control.key, value),
};
}
interface ThinkingControl {
options: readonly { id: string; label: string }[];
selectedId: string | undefined;
onSelect: (id: string) => void;
/** The select's displayed label, falling back like the compact model sheet. */
displayLabel: string;
}
/** The thinking select and the option controls, as the data `ComposerControl` draws. */
export function useAgentControlItems({
surface,
presentation,
thinking,
optionControls,
}: {
surface: ComposerControlSurface;
presentation: ComposerControlPresentation;
thinking: ThinkingControl | null;
optionControls: ComposerOptionControls | undefined;
}): { thinking: ComposerControlItem[]; features: ComposerControlItem[] } {
const { t } = useTranslation();
const thinkingItem = useMemo(
() => (thinking ? [toThinkingItem({ thinking, surface, presentation, t })] : []),
[presentation, surface, t, thinking],
);
const features = useMemo(
() =>
optionControls
? optionControls.controls.map((control) => toFeatureItem(control, optionControls))
: [],
[optionControls],
);
return { thinking: thinkingItem, features };
}
function toThinkingItem({
thinking,
surface,
presentation,
t,
}: {
thinking: ThinkingControl;
surface: ComposerControlSurface;
presentation: ComposerControlPresentation;
t: TFunction;
}): ComposerControlItem {
const isSheet = surface === "sheet";
return {
id: "thinking",
type: "select",
title: t("agentControls.thinking.title"),
icon: ThinkingIcon,
tooltip: t(getAgentControlHintKey("thinking")),
accessibilityLabel: t("agentControls.thinking.selectWithValue", {
value: thinking.displayLabel,
}),
testID: isSheet ? "agent-controls-thinking" : "agent-thinking-selector",
value: thinking.options.some((option) => option.id === thinking.selectedId)
? (thinking.selectedId ?? null)
: (thinking.options[0]?.id ?? null),
choices: thinking.options.map((option) => ({ value: option.id, label: option.label })),
onChange: thinking.onSelect,
iconOnly: !presentation.showThinkingLabel,
showCaret: presentation.showCarets,
searchable: !isSheet && thinking.options.length > SEARCH_THRESHOLD,
desktopMinWidth: 200,
renderOption: (args) => <ThinkingOption {...args} />,
};
}
function ThinkingOption({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) {
const leadingSlot = useMemo(
() => <DensityIcon icon={ThinkingIcon} size="md" color="foreground" />,
[],
);
return (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -47,7 +47,6 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
pluginItems: 0,
fontScale: 1,
};
@@ -116,7 +115,6 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
fontScale: 1,
},
}),
@@ -129,7 +127,6 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
pluginItems: 0,
fontScale: 1,
},
}),
@@ -142,7 +139,6 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
fontScale: 1.25,
},
}),
@@ -158,7 +154,6 @@ describe("composer control layout", () => {
hasModel: true,
hasThinking: true,
hasMode: true,
pluginItems: 0,
fontScale: 1,
},
};
@@ -180,36 +175,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 role and control sizing", () => {
const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" };
expect(POINTER).toEqual({
@@ -240,7 +205,6 @@ describe("composer control layout", () => {
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
pluginItems: 0,
},
};
@@ -257,7 +221,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", () => {
@@ -316,7 +279,6 @@ describe("composer control layout", () => {
hasMode: true,
features: [],
fontScale: 1,
pluginItems: 0,
},
};
@@ -26,8 +26,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;
}
@@ -74,7 +72,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;
@@ -122,9 +119,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);
}
@@ -142,11 +137,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);
}
@@ -162,9 +152,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");
});
@@ -12,7 +12,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 { TailEllipsisText } from "@/components/ui/tail-ellipsis-text";
import { useProviderIcon } from "@/components/provider-icons";
@@ -62,8 +61,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;
}
@@ -154,7 +151,6 @@ export function CompactModelSheet({
glyphs,
showLabels,
canSwitchProvider,
contextBridge = null,
children,
}: CompactModelSheetProps) {
const { t } = useTranslation();
@@ -335,7 +331,6 @@ export function CompactModelSheet({
snapPoints={SNAP_POINTS}
scrollable={false}
contentStyle={styles.sheetBody}
contextBridge={contextBridge}
testID="agent-controls-model-sheet"
>
<View
@@ -384,7 +379,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,
};
}

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