Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 64ea11f61e Merge origin/main into plan-plugin-api-phase7-composers-by-ref 2026-11-06 05:10:31 +01:00
Mohamed Boudra 2d976b0b19 fix(plugins): a plugin reporting settings is sent every key of an options patch 2026-11-06 04:50:52 +01:00
Mohamed Boudra 746165c300 fix(plugins): a model or thinking change, an unreported option key, and a draft model reach the provider unchecked
Paseo no longer checks these in front of the provider. A running agent's model or thinking
change no longer waits for the catalog or checks it, a plugin provider is sent every option
key and refuses what it cannot apply, and a draft setModel keeps the id as the model picker
does.
2026-11-06 04:48:23 +01:00
Mohamed Boudra 7834559915 test(plugins): the retained input liveness test runs in real Chromium 2026-11-05 23:25:43 +01:00
Mohamed Boudra 3240c653ed fix(plugins): a retained composer that is not showing takes plugin writes through the draft
On native a retained composer keeps its input mounted under display none, so the ref alone said
the input was live and a plugin insertText replaced the stale selection instead of appending to
the draft. The input is live only while its retained panel is showing.
2026-11-05 23:25:43 +01:00
Mohamed Boudra 09e8139f39 test(server): the test providers list two models with thinking options, and the live switches run
The fake providers listed one model and no thinking options, so once the daemon validated
selections against the catalog the live model and thinking switches had nothing valid to switch to
and skipped. Every fake provider now lists two models with thinking options, and the switches run
for all three providers without depending on installed binaries.
2026-11-05 23:25:43 +01:00
Mohamed Boudra 64146208bd test(server): live model and thinking switches use values the provider lists
The daemon rejects a model or thinking option its provider does not list, so the live-preferences
e2e can no longer switch to a made-up id. A provider that lists nothing to switch to skips the case.
2026-11-05 23:25:43 +01:00
Mohamed Boudra 3606a3c7a2 fix(plugins): a composer refreshes a stale catalog on resumed demand, shares its store watches, and is released with its last holder
Watching again, or reading once, after the catalog was invalidated or failed
while nothing watched now starts one deduplicated refresh; the observer
refetches on mount. Reconnect repair only refetches watched queries, so an
unwatched catalog stayed stale.

Each subscribe installed its own session and draft store callbacks, so N
watchers received N squared notifications, including for other agents' drafts.
One pair of subscriptions now serves all listeners, and the draft callback
ignores drafts of other agents.

A composer whose agent was removed while it was held was only released on the
next session change. The last holder letting go now checks too, and an archived
agent's idle composer is releasable.
2026-11-05 23:25:43 +01:00
Mohamed Boudra b1c6d8916c fix(plugins): useComposers holds the composer it names, and a composer lives as long as its agent
PluginComposersSource.subscribe watched the stores directly and never the
composer model, so a component using useComposers did not keep the model's
catalog observer alive and reconnect repair skipped it. subscribe now takes the
selector and holds the model it names, following focus.

Dropping a composer when it went idle left a command a plugin had saved calling
a dead model after the tab reopened. A composer is now dropped when its agent
leaves the session and nothing watches it or has one mounted.
2026-11-05 23:25:43 +01:00
Mohamed Boudra 8215535631 fix(plugins): a running agent's composer validates and refreshes against the agent's own catalog
setAgentModel and setAgentThinkingOption checked the provider's global catalog,
while the picker reads the catalog of the agent's directory, so a model only
the project lists was rejected. The validation input now carries the directory.

The composer read the catalog once with no observer, so reconnect repair, which
refetches only active queries, never refreshed it and a failed load never
retried. While anything watches, the composer now observes the catalog query.

The shared composer map kept every agent's composer for the session. It is now
dropped when nothing watches it and no composer is mounted.
2026-11-05 23:25:43 +01:00
Mohamed Boudra 58bb1c5667 test(plugins): a Command Center agent item reaches the composer through the real client state
The command-path test now reads workspace and agent through
createPluginClientStateSource over seeded session stores, so an empty
lookup, which makes the Command Center return before calling the item,
would fail it. Also records at the focused-agent publisher that it
publishes in an effect.
2026-11-05 23:25:42 +01:00
Mohamed Boudra ded20eb85c fix(plugins): composers by reference follow the focused agent and reach retained composers
- `{ focused: true }` is the agent Command Center hands to agent items, published by its
  registration through plugins/focused-agent.ts and empty outside a workspace. The composer source
  reads that module instead of the remembered workspace selection, the navigation store and the
  layout store, which also removes the require cycles those imports added.
- A composer the app retains in a workspace the user left has no live input. `insertText` and
  `setPrompt` now store the text with `replaceDraftText`, which asks the mounted input to show it;
  before, the text reached the store but the retained input never displayed it.
- `model` and `thinking` read exactly what the picker resolves before the catalog loads (no
  models), then re-resolve when it lands, instead of reading null.
- The daemon validates a thinking option against the model the agent runs: the runtime model when
  the provider lists it, else the configured one.
- The composer sources subscribe to query-cache updates only. A query another component creates
  during its render is announced as `added`, and a subscriber that updates then made React warn
  about updating a component while rendering a different one. A catalog read no longer starts its
  load synchronously either.
2026-11-05 23:25:42 +01:00
Mohamed Boudra f665cc697c feat(plugins): reach a running agent's composer by reference
useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open.

A running agent now has one store-backed composer model apart from any mounted tab. The mounted composer plugs its cursor-aware insert in while mounted; otherwise insertText and setPrompt write the agent's saved prompt. model and thinking resolve through the provider catalog with the picker's own function, so a read equals what the picker shows.

The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.
2026-11-05 23:25:42 +01:00
Mohamed Boudra 03580ce50b fix(plugins): a New workspace item's commands stop working once its project or host is left
Every item received the same command functions, which act on the current screen state. A lookup
that finished after the user switched projects could still call setBranch or setMetadata and
overwrite the new project's choices; the reset on a switch only clears what was already written.

The setup model now hands out commands per target with commandsFor({ serverId, projectId }).
They reject with a "moved to another project or host" error when the screen is on another target,
like the lock rejects, so the promise only resolves when the change took effect.
2026-11-05 22:20:09 +01:00
Mohamed Boudra 9c5025ad63 docs(plugins): a layout-effect command on mount also races the reset on a project switch 2026-11-05 22:20:09 +01:00
Mohamed Boudra ee01397101 fix(plugins): New workspace items remount when the project or host changes
An item stayed mounted across a project switch, and its Combobox kept the previous project's
results (same query key, cached data) selectable until the next search returned, so an issue from
the old project could set the branch and metadata on the new one. A Combobox cannot detect that
its inline search now means another project, so the item host keys the items by host and project
id. Items and their search results start fresh for the new target, and a mount effect sets up
again after the model clears the branch and metadata.
2026-11-05 22:20:09 +01:00
Mohamed Boudra d484a841ed fix(server): a branch the caller named is not renamed from the first agent context
The daemon marked every new branch-off worktree for automatic naming, recording the chosen branch
as the placeholder, so the first agent's prompt could rename a branch a plugin or client had
named through branchName. Only a branch the daemon derived from the worktree slug is a
placeholder now.
2026-11-05 22:20:09 +01:00
Mohamed Boudra cd787881d9 fix(plugins): a host switch drops the branch and metadata even when the project id repeats
A remote repository keeps the same project id on every host, so comparing the project id alone
missed a switch from host A to host B for the same repository. The target is now the host and the
project id together.
2026-11-05 22:20:09 +01:00
Mohamed Boudra 8d260ddcb6 test(plugins): the project-switch spec expects the other project's metadata to be empty 2026-11-05 22:20:09 +01:00
Mohamed Boudra 08aaaae15b fix(plugins): a branch and metadata chosen for one project are not sent for another
The setup model outlives the project and host it was chosen for, so an issue picked for project A
was sent on workspace.create after the user switched to project B. The model now drops the branch
and metadata when the project changes (project ids are host-scoped, so a host switch counts). A
project that is still resolving is not a switch. The base selection is left alone: the built-in
picker keeps a branch pick across a project switch, and that behavior is unchanged.
2026-11-05 22:20:09 +01:00
Mohamed Boudra 45cee0b613 fix(plugins): a Combobox shows a failed refresh instead of the old results
The query keeps its cached data when a reopened list fails to refresh, so the old items stayed
selectable and the error never showed. A failed search now clears the list so its error shows.
The migration guide maps addNewWorkspaceItem, which its registration-coverage test requires, and
the superseded-search test waits on a later search instead of sleeping.
2026-11-05 22:20:09 +01:00
Mohamed Boudra b376a34cc4 fix(plugins): New workspace commands have the same effects as a pick, even from a mount effect
The screen attached the pick side effects (remembering the isolation, syncing the picker's PR
attachment) as listeners in its own useEffect, which runs after a child item's mount effect. A
mount-time setIsolation("worktree") switched the screen but was not saved, and setBaseRef skipped
the attachment sync. The effects are now injected when the setup model is built, so a command and
a pick run the same code from the first render. Closing the picker stays in the picker's handler.
2026-11-05 22:20:09 +01:00
Mohamed Boudra 97284bea8a fix(plugins): New workspace commands work from mount effects, and branch and base ref match what creation uses
A command called from a New workspace item's mount effect rejected because the
setup model only received its inputs and the pickers' handlers from a passive
effect on the screen, which runs after a child's. The model now takes its
inputs at construction, picks the base and isolation itself, and is refreshed
in a layout effect; the screen supplies the side effects of a pick (the PR
attachment, the remembered isolation) as listeners.

The branch a plugin sets was dropped when no base was resolved, such as on a
detached HEAD, because it was sent only for an explicit branch-off. It now
applies whenever the worktree cuts a new branch, decided in one place that
mirrors the daemon.

baseRef.name was the display-shortened branch name, so setting back what was
read based the worktree on the local branch instead of the origin one. It is
now the exact ref creation uses, and host and baseRef are frozen like project
and metadata.
2026-11-05 22:20:08 +01:00
Mohamed Boudra 5a17ff1fd3 feat(plugins): New workspace items set the branch and workspace metadata
addNewWorkspaceItem mounts a plugin field on the New workspace screen, and
useNewWorkspace() exposes host, project, isolation, base ref, branch and
metadata with commands that follow the composer item rules: they act on the
screen's state when they run, resolve once applied, reject while the screen
is locked, and take JSON payloads. Host and project are read-only because a
command that switched them would unmount the item that called it.

The screen's pickable state (base selection, manual isolation, branch,
metadata) moves into one plain store so pickers and plugin commands write to
the same place; the screen reports derived facts to it on each render. The
branch is sent as source.branchName only for a branch-off worktree, and
metadata on workspace.create.

Items render through ComposerControl with a new "field" surface (badge on
desktop, full-width field when compact). Combobox ships here: Paseo owns the
loading and error states, keeps previous results, and drops superseded
searches.
2026-11-05 22:20:08 +01:00
Mohamed Boudra ba207ff5ef test(app): composer item test installation carries the subscribe main added to InstalledPlugin
Main's plugin streaming (#8527) made subscribe a required field of InstalledPlugin; the
composer item model test builds its own installation and predates it.
2026-11-05 21:55:10 +01:00
Mohamed Boudra c4d5915732 fix(plugins): a built-in select in the compact sheet keeps its action name
The shared control named every icon-only select by its value ("Speed: Fast"),
including in the compact sheet, where the row already shows the value. The sheet
row is named by its action again ("Select speed"), as before the shared renderer.
2026-11-05 21:55:10 +01:00
Mohamed Boudra 46f3ac94a3 fix(plugins): attachment commands honor the composer lock
The creation request captures the attachments when it starts, so an attachment a plugin added or
removed afterwards was lost or left in the request. They refuse like the prompt commands.
2026-11-05 21:55:10 +01:00
Mohamed Boudra 4d5b761bc8 fix(plugins): a kept command honors the composer lock, and useAgent metadata is frozen
A plugin action that awaits an RPC holds the composer it was created with. The lock was captured
at that render, so a submit that started meanwhile did not stop its insertText. Commands now ask
the composer whether it is locked when they run.

The agent snapshot a plugin gets from useAgent froze only the outer metadata record. It now uses
the same deep frozen copy as the composer.
2026-11-05 21:55:10 +01:00
Mohamed Boudra f9fde693cf fix(plugins): draft metadata survives a remount, plugin commands follow the composer lock
Draft metadata was component state while the prompt lived in the draft store, so a composer that
remounted across the compact breakpoint kept its text and lost the plugin's choices. It now lives
in the draft store beside the prompt, is cleared when the draft is sent, and is not cleared when
the prompt is emptied. It is not persisted across app restarts.

While the New workspace composer is creating the agent, the built-in controls are disabled but
plugin controls and commands were not. They read the composer's own lock: Paseo controls render
disabled, and setPrompt, insertText and the agent commands refuse.

The options and metadata a plugin reads are frozen copies, through the same helper the timeline
projection uses. The composer control test mounted components in JSDOM, which the testing rules
ban; it now runs in the browser project.
2026-11-05 21:55:10 +01:00
Mohamed Boudra 173a1197db fix(plugins): composer option resets and model checks match the documented commands
A draft kept `null` as an option value, so `setOptions({ key: null })` sent the null to the
provider and creation failed. A null edit now removes the key from every draft layer, as the patch
does on a running agent. A running agent's `setModel` accepted ids its catalog does not list; it
now rejects them the way a draft does.

The composer controls imported the toast host through the toast context, which pulled the host's
animation module into every module that loads plugins. They use the API-only context instead.
The migration guide's registration table gets the `addComposerItem` row its test requires.
2026-11-05 21:55:10 +01:00
Mohamed Boudra 9e528c5776 test(plugins): lock draft option ownership without a subscription, name the overflow-sheet steps
The draft option test subscribed the options model to the form, which in the
app happens in a render effect, so it passed without the model observing the
form's provider itself. A case with no subscription now fails when that
observation is removed.

The overflow-sheet spec reads as steps through a helper.
2026-11-05 21:55:10 +01:00
Mohamed Boudra 3685a08c43 test(plugins): composer commands tested on the models, overflow sheet in Playwright
The draft composer's command rules (unavailable provider, unlisted model,
edits belonging to the provider the form has now) moved from the React hooks
into the form store and the draft options model. They take the host's catalog
as a plain input, so the command tests run on the real models with no module
mocks and no renderer.

The overflow-sheet test faked the portal it was meant to verify. It is
replaced by a Playwright spec that installs the composer-controls example and
checks its rows in the narrow composer's Features sheet; it fails when the
sheet's context bridge is removed.
2026-11-05 21:55:10 +01:00
Mohamed Boudra d165b07682 fix(plugins): composer commands run on current state, report failures, and reach the overflow sheet
Draft commands read the state captured at render, so a plugin's
`await setProvider(); await setModel()` acted on a stale provider. The draft
form and options are now plain stores that commands read live, and
`setProvider`/`setModel` reject an unavailable provider or a model the loaded
catalog does not list. Draft `options` include the plugin's pending edits.

Shared composer controls own the pending state and show a rejected command as
an error toast, like the built-in controls. Callback payloads are objects
(`{ value }`, `onPress(event)`) so they can grow without breaking plugins.

The overflow sheet's content portals to the app root and lost the composer's
agent, input, toast and query contexts, so plugin items drew nothing in it.
`useComposerSheetBridge` carries them across.
2026-11-05 21:55:10 +01:00
Mohamed Boudra 4e96928c64 feat(plugins): composer items and controls in all three composers
A plugin registers a component with addComposerItem and reads and changes the
composer through useComposer(): provider, model, mode, thinking, options,
prompt, attachments and agent metadata. The agent, draft and New workspace
composers provide the same ComposerInput and ComposerAgent halves, so a
command reaches the right model without the plugin knowing which composer
it sits in.

Toggle, Select and Action are drawn by one ComposerControl renderer that
the thinking select and provider option controls now use too. Controls sit in
the toolbar when there is room and become rows in the existing overflow sheet
otherwise, so on a phone they appear in the overflow. Plugin items count in
the density presence so the composer condenses to make room. Model and mode
stay specialized but share the trigger, density and overflow.

Draft metadata is kept locally, sent at creation and reset when the prompt is
sent. PluginAgentSnapshot.metadata, deferred from the metadata phase, lands
here. The unused provider badge is removed.

vitest resolves @floating-ui/react-native to its ESM build: the CJS entry
requires the real react-native, whose Flow sources do not parse.
2026-11-05 21:55:10 +01:00
Mohamed Boudra 5828fd5ce4 docs(plugins): an agent.open.before hook starts writes to its own agent without awaiting
An agent.open.before hook that awaits setMetadata (or any write) on the
agent being opened deadlocks: the write joins the agent's lifecycle lane
behind the open, and the open waits on the hook until it times out. The
hooks reference now says so and shows the supported shape, a write
started without awaiting, which runs right after the open.

Deferred, not in this stack: reject such a write immediately instead of
letting the hook time out. The host knows which plugin hook it is
awaiting for the agent and which channel the write arrives on, so the
rejection can be keyed on "this plugin is inside an open hook for this
agent" without cross-process tagging, and an unrelated client's write
would still wait as it does today.
2026-11-05 21:25:11 +01:00
Mohamed Boudra c22b97506f docs(server): the handle-less recreate gap spans the agent.create.before hook 2026-11-05 21:25:10 +01:00
Mohamed Boudra 4216cdc7b5 docs(server): name the metadata gap when a handle-less agent is recreated on load 2026-11-05 21:25:10 +01:00
Mohamed Boudra e07440ae06 fix(server): reopening a closed agent keeps metadata saved just before
The loader reads the agent record, then queues a resume. A setMetadata
queued ahead of that resume saves a newer record, and the open hooks saw
it, but registration took metadata from the loader's older read and wrote
it back over the saved patch. Registration now uses the metadata read
inside the lifecycle lane.
2026-11-05 21:25:10 +01:00
Mohamed Boudra 4713ba8819 docs(plugins): the hook result comment names metadata as the field that stays 2026-11-05 21:25:10 +01:00
Mohamed Boudra 762f5f023e test(server): fixtures for agents and workspaces carry metadata
Agent snapshots and workspace records now always carry metadata, so the
hand-built fixtures and expectations in three existing suites name it.
2026-11-05 21:25:10 +01:00
Mohamed Boudra 50e830ac01 fix(plugins): omitted metadata in a workspace.create.before result means unchanged
Same handoff as agent.create.before: a legacy hook that returned only
{ source, ... } dropped the caller's metadata and anything an earlier
hook added, and workspace provisioning then defaulted it to {}. The
omitted field is carried forward at each handoff; an explicit {} still
replaces.
2026-11-05 21:25:10 +01:00
Mohamed Boudra f2f8a84041 fix(plugins): omitted metadata in a hook result means unchanged
An agent.create.before hook that returned its own { config, env } dropped
metadata that an earlier hook in the chain had added, because only the
caller's original metadata was restored at the end. The omitted field is
now carried forward at each hook handoff, as agent.open.before already
does.

The agent.open.before result type also required metadata, so an older
TypeScript plugin that returned its own object no longer compiled even
though the daemon accepted it. The result type now makes it optional.
2026-11-05 21:25:10 +01:00
Mohamed Boudra 7ddaf59aa7 fix(plugins): an agent.open.before result may omit metadata
The result schema required metadata, so a plugin written before metadata
existed that returns its own object instead of spreading the request
failed validation and the session open failed, even for agents with no
metadata. An omitted metadata now means unchanged and is filled back in
from the request so later plugins in the chain still see it; a present
value that differs is still rejected.

The restart test now sets metadata through the PaseoApi handles and
checks that agent.open.before sees the patched value after the restart.
2026-11-05 21:25:10 +01:00
Mohamed Boudra 35e136314d feat(plugins): agents and workspaces carry plugin metadata
Plugins had nowhere to keep their own data on an agent or workspace, so
Phase 5 and 6 surfaces would each have invented a side store. Both now
hold an opaque Record<string, JsonValue> that Paseo stores, snapshots and
hands to hooks without reading a key.

Set at creation or changed with agent.metadata.update.request /
workspace.metadata.update.request (top-level keys replace, null removes).
Persisted on the agent and workspace records, so it survives a restart.
Reaches agent.create.before (which may add to it), agent.open.before
(read-only), agent.create.after and workspace.create.before/after.

Gated once on server_info.features.entityMetadata; the client throws
"Update the host" instead of sending to a host without it.
2026-11-05 21:25:10 +01:00
Mohamed Boudra f4ddcf7a5b test(server): an apply to a plugin that reports controls sends its changes as provider options 2026-11-05 20:25:11 +01:00
Mohamed Boudra 46b0466f3b fix(server): an apply never reads a requested option as one the plugin dropped
A plugin may run an option it never reports. Only a key it reported before can be
reported as removed, and a rewind reopens with the accepted request plus what the
plugin reported back.
2026-11-05 20:25:11 +01:00
Mohamed Boudra 429cf715ca fix(server): an apply reports the options the plugin reset along the way
Applying and unsolicited reports now share one conversion from the plugin's
report to a patch, so a key the plugin stops reporting is a removal on both.
2026-11-05 20:25:11 +01:00
Mohamed Boudra 4196fae928 fix(server): a plugin that stops reporting an option has reset it
The adapter only visited keys present in a new report, so a key the plugin
dropped to return to its default kept its stored value and its rewind value.
A previously reported key that disappears is now reported as removed.
2026-11-05 20:25:11 +01:00
Mohamed Boudra bbd93f76ee fix(server): a rewound plugin session reopens with its options as they stand
The reopen merged the plugin's reported options over the launch record, so a
key the plugin reset by leaving it out came back at its launch value. The
session now keeps the committed options, removals included. The provider-direct
example also refuses a combined request before changing anything.
2026-11-05 20:25:11 +01:00
Mohamed Boudra 9e70f5665a fix(server): a plugin decides which options it applies live
A plugin reporting `controls` and `providerOptions` receives every key a live
change touches, with or without a control, and applies them or rejects the
whole request. The daemon's live-key guard now runs only for a plugin still
reporting `settings`, which cannot refuse a key.
2026-11-05 20:25:11 +01:00
Mohamed Boudra f4210f39aa fix(server): a plugin provider's reported options are stored like any provider's
A control's `defaultValue` only says what the control shows for a key with no
value. Opening and applying store every option the session reports, defaults
and values nobody set included, as built-in providers do; the special case that
left a value at the default unset is removed.
2026-11-05 20:25:11 +01:00
Mohamed Boudra 448f258583 feat(plugin): provider plugins declare option controls and receive option changes
A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.

A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).

The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.
2026-11-05 20:25:11 +01:00
Mohamed Boudra 152a5ae731 fix(client): applying a config with a reset option on an old daemon writes the default it can take
applyAgentConfig({ options: { service_tier: null } }) reached an old
daemon as featureValues with null, which its Codex setter rejects. The
config now takes the same translation as setAgentOptions: a toggle goes
off, a choice goes to its default, and an option with no named default
fails with the host-update message before anything is sent.
2026-11-05 19:31:56 +01:00
Mohamed Boudra 9f39663722 fix(client): resetting an option on an old daemon writes the default it can take
setAgentOptions({ service_tier: null }) sent a feature write with value
null, which the old Codex setter rejects, leaving the paid Fast tier on.
A toggle now resets to off and a choice to its default; an option with
no control to name a default fails with the host-update message before
anything is written.
2026-11-05 19:28:40 +01:00
Mohamed Boudra 568bd447e3 fix(app): a profile stops saving a toggle the model in use does not offer
Switching a profile from Opus to Haiku kept the Fast edit, so the saved
profile carried fast_mode that no control draws and an old daemon's
provider rejects at create. The profile editor now drops a key an earlier
answer drew as a control and the latest one does not, the rule the draft
already follows; options no control ever drew are never touched.
2026-11-05 15:30:11 +01:00
Mohamed Boudra 41a36fa593 test(server): the MCP test agent manager resolves effective options 2026-11-05 15:30:11 +01:00
Mohamed Boudra f91ec81f7c fix(app): a draft keeps sending an option no control has drawn
The previous change stopped sending any edit the latest answer drew no control
for, which also dropped options set without a control (a plugin setting a
token budget on the draft). Only an edit of a key an earlier answer for this
draft drew as a control stops applying when a later answer draws none; every
other edit always applies.
2026-11-05 15:30:11 +01:00
Mohamed Boudra af2fb8ab35 fix(server): a closed agent's options include the provider defaults, like a live one's
The stored snapshot projected only the options the agent owns, so an agent
that ran with a config.json default (for example Claude fast_mode: true)
showed the option as missing, and its legacy Fast control as off, until it
was resumed. The projection takes the same resolver the live agent uses
(the provider's defaults under the agent's own options).
2026-11-05 15:30:11 +01:00
Mohamed Boudra cdc64ecfa4 fix(app): a draft stops sending a toggle the model in use does not offer
A toggle made on one model stayed in the draft's edits after the form moved
to a model whose controls do not include it, and was sent with the create.
On a legacy daemon that key is not a feature for the new model, so it went
out as a native provider option the daemon rejects. The draft now sends an
edit only while the latest answer draws a control for it, and sends it again
when a control for it returns.
2026-11-05 15:30:11 +01:00
Mohamed Boudra 042bc36ba5 fix(client): a legacy profile with both featureValues and options keeps both
A legacy daemon keeps an unknown `options` field on a profile it stored
(profiles are passthrough), for example after a downgrade, while its own app
keeps editing `featureValues`. Reading such a profile took `options` and
dropped `featureValues`, so a Fast setting there was lost and the next save
replaced it. Both are merged on read with an explicit `options` key winning,
the same rule the daemon applies when it folds `featureValues`.

The draft form input of draftControlsRequest is a named type.
2026-11-05 15:30:11 +01:00
Mohamed Boudra 4f6e71e8d6 fix: persisted options keep their full shape, and every legacy snapshot gains controls
Draft setups and form preferences validated option values as booleans,
strings or null, but the options they now receive come from agents and
profiles and can be objects, arrays or numbers. One such value failed
validation and wiped the whole saved layout or the saved preferences. Both
schemas now accept any JSON value.

The legacy-daemon translation skipped the snapshots carried by
agent_created, agent_resumed and timeline responses, so a created agent or
a timeline refresh left the SDK handle without options and controls.

The profile journey asserted the create request's options field; the wire
field is providerOptions.
2026-11-05 15:30:11 +01:00
Mohamed Boudra 51505a7d3b fix(app): a closed agent shows its stored options, and a provider switch asks about its own options
A closed agent's snapshot is projected from its stored record, which kept
controls and provider options but dropped them from the payload, so the
app drew no speed or other controls for it. The stored projection now
carries them, with the legacy features derived from the same source.

The draft read the form's provider and the model's options separately, so
for the render after a manual provider change it asked the new provider
about the previous provider's options. The request is now built from one
snapshot of the model and is absent until the model has observed the new
provider.
2026-11-05 15:30:11 +01:00
Mohamed Boudra 13c318838f fix(client): a legacy create routes its options from the controls of its own config
Which options a legacy daemon exposes as features depends on the model, mode
and thinking level, not only the provider: a Haiku snapshot with no features
says nothing about Opus. The provider-keyed memory of control keys sent an
Opus create's fast_mode as providerOptions, which that daemon does not read.

A create that carries options now lists the controls for its own draft
config and splits on that answer. The memory, and the snapshot and listing
hooks that fed it, are gone.
2026-11-05 15:30:11 +01:00
Mohamed Boudra d9fb2ffe93 fix(app): drafts and profile forms send every option layer to the daemon
A cold create against a daemon without providerOptionControls routed control
options (Claude fast_mode, Codex service_tier) as providerOptions, which that
daemon does not read for them. The client now lists the provider's features
before the first create or workspace create that carries options, so callers
never prime a cache.

Draft options moved into a plain model. Applying a profile replaces provider
and options in one step, so the form's provider change that follows no longer
clears a cross-provider profile's non-control options; choosing a provider by
hand still starts over. The model sends its complete options (seed, last-used
values once the provider says it still draws them, edits) to the daemon and
the controls draw only the answer, so a remembered legacy fast is drawn as
what it runs as. A toggle shows over the last answer until its own arrives.
The profile form does the same: its edits go through resolution too.
2026-11-05 15:30:11 +01:00
Mohamed Boudra 0d82eed6b5 feat(app): the client and app work on options and controls
The agent snapshot's `options` are the state and `controls` only draw them.
Running and closed agents draw from the snapshot; drafts and profile forms ask
`provider.controls.list.request` with their `providerOptions`, so aliases such
as a legacy Fast profile's `service_tier: "fast"` are resolved once, by the
daemon, and the draft keeps Fast highlighted. A draft adds only the user's
edits and each provider's last-used control values; applying a profile seeds
its non-control options (e.g. `sandbox_mode`) into the draft, and a profile
form saves them back instead of pruning to the controls it can draw.

A daemon without `server_info.features.providerOptionControls` still sends
`features` and `featureValues`. `LegacyProviderOptions` in the client package
is the one place that translates snapshots, draft listings, create and apply
requests, and daemon-config profiles, so the app never branches on it.
Persisted preferences and tab layouts rename `featureValues` through tagged
COMPAT preprocessors.

The features/controls bridge moves from the server into the protocol package
so the client can reuse it.
2026-11-05 15:30:11 +01:00
Mohamed Boudra 2ec0ac3c25 refactor(server): openCodeOpenOptionsPatch takes one named-fields object 2026-11-05 15:00:18 +01:00
Mohamed Boudra 6cb9685a6d fix(server): plugin option applies run one at a time, and OpenCode reports the Auto Accept legacy full-access turns on
Plugin sessions let two applyOptions overlap. The second finished and re-recorded the
settings the plugin held, which included a change the first had already made and not yet
reported; when the first then failed, nothing was left to report and the stored options
kept the old value while the plugin ran the new one. Applies now queue on the session,
the way the ACP adapter's do, so each reconciles against what the one before left.

OpenCode normalizes the legacy full-access mode to build with Auto Accept on, but the
session never said so, so the options the manager keeps did not hold Auto Accept and a
reopen turned it off. The session now reports it through openOptionsPatch on create and
resume.
2026-11-05 12:28:49 +01:00
Mohamed Boudra 8a972b8c2e fix(server): an ACP removal reports the choice the select lands on, and the Codex plan tests enable plan mode
A removal asks the select for its empty choice and compared the result against that
same empty choice, so applyOptions returned nothing and the manager stored the removal
as null while the session ran the Default choice. The difference is now computed against
what was asked (null), so the options hold the value the session runs with.

The Codex plan-mode end-to-end tests called the removed session.setFeature through an
optional call and silently skipped plan mode; they apply the option and fail loudly if
the method is gone. The MCP parity wrapper forwards listControls instead of the removed
listFeatures.
2026-11-04 09:35:24 +01:00
Mohamed Boudra cc56b392de fix(server): ACP option applies run one at a time
Two overlapping applies shared one transaction guard, so when the first was refused after the agent had moved a select, its report was dropped as 'still in a transaction' and the second saw no difference. Applies are serialized, as OpenCode's are, so each reconciles against the state the previous one left.
2026-11-04 09:35:23 +01:00
Mohamed Boudra cb331532ad fix(server): draft speed shows the tier the first turn will run with
Draft discovery reported the options as requested, so a Fast configured as a legacy fast_mode or the fast alias read as Normal while creating sent the model's own Fast tier. The draft now reports the options the scratch session opened with, as registration does.
2026-11-04 09:35:23 +01:00
Mohamed Boudra da994fb400 fix(server): profile options convert against the provider graph a write commits
A write that edited a provider and a profile together folded the profile against a shallow merge that dropped the provider's extends, so its legacy Fast was not converted. The fold now sees the same deep-merged providers the write commits.
2026-11-04 09:35:23 +01:00
Mohamed Boudra c4baf750f2 fix(server): a profile is stored in the current option keys, converted for the adapter its provider extends
Profiles in config.json are old data entering, so the store converts them (stored value and incoming patch) with the same conversion the agent manager uses. Storage and clients now share one vocabulary: resetting a legacy Codex profile's speed removes what was shown, and a malformed fast_mode rejects the write instead of breaking later config reads. The config API goes back to copying the field shape only. The extends chain is followed by one function over either the registry's definitions or config.json's providers.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 31a408d108 fix(server): a malformed Codex fast_mode is rejected, and profile options are renamed for clients by the agent manager's conversion
convertLegacyOptions read any truthy fast_mode as Fast, so the string "false" ran priority. It now accepts only true, false or null. The profile store no longer renames keys: a literal codex profile was renamed in storage while an extends-codex profile was not. The config API now converts each profile's options with the manager's adapter-aware conversion, so codex and my-codex profiles read the same.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 3be00f5105 fix(server): OMP turn completion publishes a Fast change, and Claude opens with Fast off on a model that has none
OMP installed runtime state in several places and only one compared Fast; every state read from the runtime now goes through one install step. Claude kept a Fast default on a model without Fast, which made unrelated options reject until the user cleared it; the session now opens with Fast off and reports it in openOptionsPatch, as the model-switch path already does.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 6e704f803c fix(server): an OMP replacement runtime that refuses the session's Fast publishes what it accepted
The approval-mode restart installed the new runtime's state without comparing its Fast result to the session's, so a refusal never reached options_changed, the remembered value, or storage. The replacement now goes through the same comparison as refreshState.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 949462f39b fix(server): legacy Fast reaches draft discovery and saved profiles, and overlapping or replaced options keep the state they committed
Draft controls and commands convert the config they hand to the provider, so inspecting a
legacy Fast preference no longer fails Codex's strict options. A profile naming codex converts
its saved fast_mode for the app's controls. OpenCode serializes auto_accept applies so a failed
one restores exactly what it changed, and a replacement OMP runtime starts from the Fast the
session opened with.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 53d91d63da fix(server): a rejected OpenCode auto_accept leaves the session asking for permission
applyOptions committed the new flag before approving pending requests, so a failed reply left
the session auto-approving while the stored option stayed off. The flag is restored when the
apply throws.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 4709b49dc4 refactor(server): the agent manager converts Codex's legacy fast_mode for the adapter behind the provider id
The rename is adapter-specific, so it lives where the provider id resolves to its adapter:
AgentManager converts an agent's own options on create, resume, import, reload, apply and
draft controls, following derivedFromProviderId, so a custom provider extending codex keeps
working with a saved or requested fast_mode. Storage, MCP, schedules, hub and profiles pass
options through. The featureValues-to-options fold stays provider-agnostic at the legacy edges.
2026-11-04 09:35:23 +01:00
Mohamed Boudra f938bf963d fix(server): a legacy hook's replaced speed and an apply request's options convert before they are compared or applied
A hook that swaps the canonical speed it was sent for fast_mode is diffed in canonical form, so
its replacement is not read as a deletion. An apply request carrying featureValues and options
sends one options patch with options winning, so a superseded legacy value is never applied.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 1131241b73 refactor(server): Codex's legacy fast_mode converts to service_tier where old data enters
Stored records, config.json defaults, profiles, older clients' requests, MCP settings and
plugin hooks go through convertLegacyOptions as they come in. Past that point the defaults
merge, the manager and the Codex session never see fast_mode for Codex, so the alias branch
in the defaults merge and Codex's fast_mode handling are deleted.
2026-11-04 09:35:23 +01:00
Mohamed Boudra ce39ab6f09 fix(server): an OMP replacement runtime starts with the Fast the session has now
A restart (for example an approval-mode change) restored Fast from the options the session opened with, turning back on a Fast the user had switched off. The session now keeps the Fast it applied or the provider changed, which is what a replacement reads.
2026-11-04 09:35:23 +01:00
Mohamed Boudra c242f4d8db fix(server): the legacy Codex Fast alias folds into one speed on apply and in configured defaults
A patch with both speed keys left the alias in the stored options, so a reload started Fast again after the speed was removed; the apply now reports the folded alias. A configured fast_mode default now supplies the service_tier default, so removing an agent's own speed falls back to the configured Fast like opening does.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 66aeff70d1 fix(server): Codex keeps a Fast chosen by alias as the catalog's tier id
A live fast_mode or service_tier "fast" was stored as the alias while the apply reported the catalog id, so a later switch to a model with a different Fast id ran Fast under an option that said otherwise. The session now stores the id it accepted and drops the folded fast_mode.
2026-11-04 09:35:23 +01:00
Mohamed Boudra adf8badade fix(server): a saved legacy Codex Fast preference wins over a configured service_tier default
The defaults merge put a configured service_tier next to the agent's own legacy fast_mode, and Codex reads service_tier first, so the agent opened Normal. The agent's own alias now replaces the default of the key it stands for.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 1b45d92dac fix(server): every opening path hands registration the options it launched with
Registration took the launch options as optional and reread the defaults when a path did not supply them, as provider-session import did. It is now required, so create, resume, reload and import each resolve once before opening. The option helpers take named inputs.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 42ed0a8a99 fix(server): opening snapshots keep the defaults the session launched with, and config-change pushes carry legacy profile values
An opening resolved defaults once for the session and again at registration; an edit in between showed options the session never received. Create, resume and reload now resolve once, before the provider opens, and registration starts from that. The daemon_config_changed push skipped the featureValues projection that config replies apply, so the unchanged app lost a profile's legacy values when any setting changed.
2026-11-04 09:35:23 +01:00
Mohamed Boudra d8fca6fdc6 fix(server): Codex keeps the speed it opened with when the legacy Fast key is removed
Opening moves a legacy fast_mode into service_tier, so removing fast_mode afterwards changed the runtime speed to Normal while the stored options still said Fast. Removing the alias now changes no speed.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 77f97f82bc fix(server): an option a session drops when opening is not brought back by a configured default
The opening snapshot merged defaults over the options after the session's openOptionsPatch had removed a key. Defaults now merge into what the session was given and the patch applies after.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 26b1e122ae fix(server): a live apply shows the merged defaults the provider was given in the options snapshot
The snapshot took the raw patch, so a nested patch over configured defaults dropped the defaults the provider had accepted. The snapshot now takes what the provider was sent plus what it reports back; stored options still keep the raw patch.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 7dfb63d0c4 fix(server): a loaded agent's options stay what its session opened with when a provider default is edited
effectiveOptions was recomputed from the current config.json defaults on every state emission, so editing a default changed the snapshot of an agent whose session never received it. It is now computed when the session opens and moves only through an apply or an options_changed. Editing a default affects agents the next time they open.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 5c81d08959 fix(server): ACP reports a select that appears after startup with its native value
A model switch can introduce a select (Cursor Fast) that the session did not have at open.
Its value is now stored like any other native selection instead of being skipped because
the previous snapshot did not name it.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 96b2bea130 refactor(server): option helpers take named inputs, control types come from their schemas, and a plugin accepts launch options it opened with
A whole profile is applied by the unchanged app, so a launch-only plugin option whose value
has not changed must not fail the apply. applyProviderOptionsPatch and featuresFromControls
take one object, and the protocol control types are inferred from the wire schemas.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 33338be16e fix(server): plugins reject keys they do not report as settings, and Codex opening reports the speed it runs
A plugin session applied the settings it knew and silently stored the rest; it now rejects
a patch that names a key the plugin does not report. Codex opening on a model without the
saved speed ran Normal while the stored options still said priority; opening now reports
the tier it runs, as a later model switch does.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 32230e24c5 fix(server): a live options patch is rejected when the session cannot apply it
A session with no applyOptions (Pi) now rejects a patch instead of storing a value that
fails on reopen. Codex applies only service_tier, plan_mode and fast_mode live, since the
approval policy and sandbox are fixed when the thread starts. Claude also rejects a launch
option change while its first query is starting, after the options have been copied.
2026-11-04 09:35:22 +01:00
Mohamed Boudra d641cb5179 fix(server): Claude rejects a live change to an option it reads when the query starts
Replacing a started query to apply the option would end its background shells and
tasks. Once the query exists only Fast can change; before the first prompt every option
still applies, because it is read when the query is built.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 58094053dc fix(server): a live patch is rejected for an option the session only reads at start, and opening reports every option it ended up with
A stored option has to describe what runs. OpenCode, OMP and ACP now reject a live change
to a key they apply only when the agent opens; Claude applies such a change from the next
turn and rejects it while a turn is running. Opening reports every select or setting the
session came up with, including ones nobody set, so a native default (Cursor opening with
Fast on) is stored as option state like any other.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 3f75a11a49 refactor(server): the legacy featureValues folds take one named-input object 2026-11-04 09:35:22 +01:00
Mohamed Boudra f0807c5deb fix(server): a plugin that moves settings and then refuses an apply reports what moved 2026-11-04 09:35:22 +01:00
Mohamed Boudra e0ff0959a1 fix(server): Claude applies only the keys a patch sets over its current options
A Fast patch kept a copy of the options taken before the runtime answered, and
assigning it afterwards dropped a patch that landed meanwhile.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 591dd3bc92 fix(server): Codex returns the tier it resolved and drops a speed the new model lacks
A live service_tier: fast is stored as the catalog's Fast tier id instead of
a value the control cannot show, and a model switch to a model without the
stored speed reports Normal so choosing a supporting model again does not
bring the paid tier back.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 8fb31cf8c0 test(server): removal of auto_accept returns ACP to asking; Claude keeps opening restrictions across a patch 2026-11-04 09:35:22 +01:00
Mohamed Boudra b99d2a010d test(server): assert the forwarded plugin setting without nesting callbacks 2026-11-04 09:35:22 +01:00
Mohamed Boudra 41011ddc60 test(server): a setting a plugin changes on its own is forwarded as options_changed 2026-11-04 09:35:22 +01:00
Mohamed Boudra e9d440259c test(server): a patch key is resolved against nested configured defaults 2026-11-04 09:35:22 +01:00
Mohamed Boudra 0d697e6330 docs(server): a rejected apply reports what the provider moved after the call ends 2026-11-04 09:35:21 +01:00
Mohamed Boudra 5413b8122c fix(server): fold an old app's profile featureValues by the provider and options the write carries
A write with options folds featureValues over those options. A write
without them patches the stored options, unless the profile moved to
another provider, which starts from no options.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 8960e76d19 fix(server): Claude reports Fast as off, not removed, when a model switch drops it
A removed key lets a config.json default of fast_mode: true fill back in
while Fast is off at runtime.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 218bcaaa6b fix(server): providers validate live option patches and Claude reports Fast dropped by a model switch
Live patches go through the schema used at creation. A Claude model switch
that cannot keep Fast reports fast_mode as removed so the stored options
match the session.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 30cc0a8c21 fix(server): ACP reports the selects it moved during open or apply as that call's result
A config_option_update that arrived before the write response was emitted
as an event on top of the result the manager stores. Notifications during
open or apply now fold into the open patch or apply result, and a rejected
apply reports what the agent moved since nothing is stored. Live options
are validated with the creation schema. Tests drive a stdio ACP agent.
2026-11-04 09:35:21 +01:00
Mohamed Boudra a362664f47 fix(server): apply an old app's profile featureValues over the profile's stored options
The unchanged app's profile form only knows the controls, so a name-only
edit dropped options it never shows, such as sandbox_mode. Its
featureValues now apply as a patch over the options already stored for the
profile (null removes a key), and a write with no featureValues keeps them.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 20ce734367 refactor(server): providers return what apply and open ended with instead of reporting it afterwards
applyOptions returns the options the session ended up with and the session
exposes its open difference as openOptionsPatch, so the manager stores the
patch and the provider's answer in one step. Removal is an ordinary patch:
the provider receives the default the key falls back to, or null, and returns
what it kept. Controls no longer take a default from a stored legacy feature
value.
2026-11-04 09:35:21 +01:00
Mohamed Boudra ca2bdd66eb refactor(server): providers report option differences once, after create, resume and apply
Providers used to write their own reports into the agent's options while a session was being
created, resumed or updated, which needed a growing set of reconciliation, baseline and
suppression rules to stay in step with the daemon.

The contract is now transactional. A provider opens or updates with the options it is given and
emits nothing while doing so; if the result differs it says so once, afterwards, as
`options_changed`. Live apply hands the provider only the keys the patch sets and the daemon stores
them onto the agent's current options when the provider accepts, so the last write wins. A change
the provider makes on its own is stored and forwarded, not undone. Removing an option drops the key
and sends nothing live. Controls carry no state; `options` is the only option state.

A legacy `agent.create.before` hook's `featureValues` edits are applied as a patch over the
returned options, so its deletions and replacements hold. The unreleased `aliases` field on option
choices is gone; a pre-v0.10 Codex Fast preference is reported once as the model's tier id.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 1aaac5b231 chore(website): regenerate the config schema for profile options 2026-11-04 09:35:21 +01:00
Mohamed Boudra b60b74384b fix(server): show Fast for a stored Codex fast_mode: true without rewriting the record
Reverts the storage migration from a987b4a43. The Codex speed control now takes its default from
the session's current options with the same rule the runtime uses (an explicit service_tier wins;
fast_mode: true applies only while service_tier is absent). This holds on create, resume, live
apply and for config.json defaults, and stored options stay as set.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 034f639c88 fix(server): read a pre-v0.10 Codex fast_mode record as service_tier "fast" and store it that way
A record holding `fast_mode: true` without `service_tier` ran Fast but the speed control showed
Normal, and picking Normal then wrote "default". The Codex session now normalizes the old boolean
at construction and reports the new shape through its existing options_changed event.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 55e8cff9f4 fix(server): keep snapshot options as configured, restore OMP baseline, cover options through public interfaces
- Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone.
- OMP: removing fast_mode restores the runtime's starting setting.
- ACP/Cursor removal tests run through real session initialization.
- agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e).
- Daemon config store persists legacy-featureValues profiles as options on the next profile save.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 06be203c5c fix(server): address Phase 1 audit findings on option controls
- Removing auto_accept (OpenCode, ACP) turns live approval off; ACP selects return to their session default.
- agent.create.before hook featureValues edits override options the create already set.
- Claude commits fast_mode only after the runtime accepts it.
- Codex snapshots show a saved Fast preference as the tier the model resolves it to (AgentSession.displayOptions).
- Profiles are folded into options in the daemon config store; the config API still answers with featureValues.
- Snapshot options and provider.controls.list carry all effective options.
- Test: Claude fast_mode survives a restart through stored providerOptions.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 007a8e36ae feat(server): fold featureValues into providerOptions and describe composer controls
Providers emit option controls over top-level providerOptions keys; the daemon stores one
options record per agent, merges config.json defaults as effective values, and applies patches
live via AgentSession.applyOptions. Legacy featureValues (wire, stored records, profiles,
schedules, hooks, MCP) fold into providerOptions; snapshots still carry features built from
controls. Snapshot options expose only keys that have a control.
2026-11-04 09:35:21 +01:00
Mohamed Boudra eb6bed8447 feat(protocol): option controls, options patch, and provider.controls.list
Add AgentOptionControl descriptors, snapshot options/controls, an options patch on
agent.config.apply, provider.controls.list RPC, and server_info.features.providerOptionControls.
featureValues stays accepted on the wire (COMPAT).
2026-11-04 09:35:21 +01:00
532 changed files with 19045 additions and 17891 deletions
-6
View File
@@ -1,11 +1,5 @@
# Changelog
## 0.15.3 - 2026-11-06
### Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### Added
-6
View File
@@ -1,11 +1,5 @@
# Changelog
## 0.15.3 - 2026-11-06
### 修复
- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### 新增
+3 -27
View File
@@ -259,27 +259,6 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
The daemon runs one archive lane per workspace, shared by every connection
(`packages/server/src/server/workspace-archive-operations.ts`):
| A request arrives while | Result |
| ------------------------ | --------------------------------------------------------- |
| an archive runs: archive | joins the running archive |
| an archive runs: undo | aborts the archive, waits for it to settle, then restores |
| an undo runs: archive | waits for the undo, then archives |
| an undo runs: undo | joins the running undo |
A request takes its place in the lane before its first `await`. Arrival order decides, so an undo
sent right behind its archive still cancels it.
An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only
the agents that archive itself archived. Deleting the worktree directory is the point of no
return: an undo after it recreates the worktree through workspace recovery. The client shows or
hides the row by the latest local intent, so a late remove or upsert from a superseded request
does not flip it back. Tabs and layout are purged only once the undo window closes with the
archive committed.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
@@ -334,13 +313,10 @@ Running provider-native subagents contribute `running` to the workspace owned by
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores
its selected tab; attention on another agent remains unread until that agent is focused or the
workspace is explicitly marked read.
`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow.
Attention is set by the agent finishing or failing and acknowledged by deliberate focus or
interaction with its chat, including repeat clicks in an already focused pane. A completion
arriving while focused stays unread until the next interaction; leaving does not acknowledge it. Loading an agent's runtime is
Attention is set by the agent finishing or failing and cleared by the client's
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
neither, so resuming carries the stored attention and the stored last-activity time through
untouched. Forging either makes a background resume look like the user read a workspace and like the
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
-1
View File
@@ -34,7 +34,6 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice.
- **A history row re-renders only when its item or layout item identity changes.** Every
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
+2
View File
@@ -96,6 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<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) |
@@ -482,6 +483,7 @@ Array of workspace records. A workspace is a specific working directory within a
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `metadata` | `Record<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. |
+2 -6
View File
@@ -68,11 +68,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`).
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
@@ -144,7 +140,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
+1 -1
View File
@@ -68,7 +68,7 @@ When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
Pass only `serverId` and `workspaceId` to restore the workspace’s selected tab.
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
When the action names a specific tab, pass it as `target`; that explicit choice
is authoritative. Callers should not choose between separate route and tab
navigation APIs.
-7
View File
@@ -84,10 +84,3 @@ its DOM-derived scope.
Escape interrupts an agent only in the message input or an unclaimed surface.
A focused terminal excludes that binding. The source leaves terminal Escape alone;
terminal Escape/Ctrl forwarding is a separate feature.
## Keys shared with text editing
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
workspace archive undo, declares `editable: false` and registers its handler with
`isActive` true only while it has work. An inactive handler leaves the key
unhandled, so the pipeline skips `preventDefault` and the platform's own undo runs.
+139 -3
View File
@@ -474,6 +474,9 @@ path shape: a plugin can place a worktree several directories below the default
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
The same holds for a hook that runs inside an agent's lifecycle lane: an `agent.open.before` hook
that awaits a write to the agent being opened (`setMetadata`, `setLabels`) waits on the open, which
waits on the hook. The hooks reference tells plugin authors to start such a write without awaiting it.
## Contribute a provider
@@ -529,13 +532,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`.
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon.
Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
(`providerOptions`) in `session.config`. A running-agent change arrives as
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
its value into an option and is sent both shapes in `session.configure`; delete it with
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
@@ -654,6 +665,131 @@ Mounted surfaces and command invocations have shorter API lifetimes.
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
## Contribute composer items
`addComposerItem` mounts a plugin component in the agent, draft and New workspace composers. The
public contract is the [composer items reference](../public-docs/plugins/reference.md#composer-items).
A plugin reads one `ComposerModel` (`packages/app/src/composer/model.ts`): state, subscribe and
commands. `toPluginComposer` (`packages/app/src/plugins/composer-items/`) turns its state into the
`PluginComposer` an item reads, and the host never learns which kind of composer it got. Two
implementations provide it:
- A running agent's `agentComposer(serverId, agentId)` (`composer/agent-composer/`). It exists
without a mounted tab: the prompt and attachments are the agent's draft in the draft store, the
settings are the session store's agent, and `model` and `thinking` resolve through the provider
catalog with `resolveAgentModelSelection`, the picker's own function, so a read equals what the
picker shows. The model loads the catalog itself and resolves with no models until it lands, as the picker does,
so a read never differs from what the picker shows. While anything watches the model it holds a
`QueryObserver` on the agent's catalog: that is what lets reconnect repair, which refetches only
active queries, refresh a catalog nobody has a picker open for, and retry one that failed.
`PluginComposersSource.subscribe(selector, listener)` is what holds it for `useComposers`, so
the subscription must go through the model's own `subscribe`. Watching again after the catalog
was invalidated or failed refetches it, and a read with no watcher starts one deduplicated
refresh and shows the cached catalog until it lands. The model keeps one pair of store
subscriptions however many components watch it. It is shared for as long as its agent is in the
session and not archived, so a command a plugin saved keeps reaching the composer a later tab
mounts; it is dropped when the agent is gone or archived and nothing watches it or has one
mounted, checked on session changes and when the last holder lets go. The mounted
`AgentControls` plugs its input in with `attachInput` so `insertText` lands at the cursor.
Without a live input the model writes the stored prompt with `replaceDraftText`, which also asks
a mounted input to show it. A workspace the user left stays mounted, display none. Web
drops the input ref there; native keeps the input, with a stale cursor and selection.
`ComposerInput.isLive()` is how the model tells: it needs the ref and a retained panel that is
showing (`useRetainedPanelActive`). Closing a root agent's tab archives
it, so a running agent with no mounted composer at all only exists as a subagent whose tab was
closed. The daemon does not check `setModel` and `setThinking` against a catalog; the provider
decides and its refusal rejects the call.
- A draft or New workspace screen's `composerModelFromHalves`, an adapter over the two contexts
in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments),
`DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock.
`ComposerAgent` stays for the built-in controls. Add a capability to the `ComposerModel`, both
implementations and `toPluginComposer`; never branch on `context` in the host.
`useComposers` and `composers()` reach an agent's model by reference (`plugins/composer-items/source.ts`):
`PluginInstallationProvider` provides the plugin's source to every surface, and
`createPluginCapabilities` puts `composers` on every command context. `{ focused: true }` is the
agent Command Center registration hands to agent items, published through `plugins/focused-agent.ts`
(null outside a workspace route). The source reads that module, not the navigation or layout
stores, so the plugin runtime does not import them. The remembered workspace selection is
navigation restore state and is not the focused agent. The query-cache subscriptions react to
`updated` events only: a `useQuery` in another component's render announces `added`, and a
subscriber that updates then triggers React's "update a component while rendering a different one"
warning.
Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all
render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`),
a toolbar icon or a row in the overflow sheet. Model and mode stay specialized but share its trigger,
density and overflow. Plugin items count in the density presence in `layout.ts`, so a composer
condenses to make room for them. A custom component renders wherever its item is placed, and an
item that renders nothing still counts for density; there is no registration for either.
A command runs against the owner's current state, not the render that built the callback. The
draft's form (`openAgentForm`) and options (`openDraftOptions`) are plain stores for that reason,
and `draftComposerAgent` rejects a provider the host does not offer. It passes a model id to the form
as the picker does, with no catalog check; for a running agent the provider decides. `ComposerControl` owns the pending state and the error toast for callbacks that
return a promise.
The overflow sheet's content portals to the app root, away from the composer. Pass
`useComposerSheetBridge()` as the sheet's `contextBridge`, or `useComposer()` returns nothing and
the sheet draws no plugin rows.
## Contribute New workspace items
`addNewWorkspaceItem` mounts a plugin component on the New workspace screen. The public contract
is the [New workspace items reference](../public-docs/plugins/reference.md#new-workspace-items).
The screen's choices that a plugin may change live in one plain store,
`createNewWorkspaceSetup` (`packages/app/src/screens/new-workspace/setup-model.ts`): the base
picker selection, the manual isolation, the branch name and the workspace metadata. The pickers and
the plugin commands write to it, so a command sees what an earlier command left. Project and host
stay in the screen's hooks (`useNewWorkspaceHostSelector` reconciles them against each other), and
they are read-only to plugins; a command that switched them would unmount the item that called it.
The store is built in the render that creates the screen, with what the screen derives from its
hooks (the lock, the project, whether the project can make worktrees, whether the host supports
metadata), so a command is valid from a child's first effect, which runs before the screen's own
effects. `syncInputs` refreshes those inputs in a layout effect on every commit, and commands read
them when they run, never from the render that built them. The pickers and the commands pick
through the same `chooseBase` and `chooseIsolation`, so a plugin command has the effects of a
pick; the screen injects those effects when it builds the store (syncing the picker's PR attachment,
remembering the isolation), so a command from a mount effect does the same as a click. Closing the
picker stays in the picker's own handler. Add a capability by extending the store;
do not read screen state from a plugin component.
The item host (`PluginNewWorkspaceItems`) keys the items by `(serverId, project id)`, so a change of
project or host remounts them. That is what keeps an item's own state and a `Combobox`'s cached
results from outliving their project: the `Combobox` cannot tell that an author's inline `search`
now means another project, because its identity changes every render. The store clears `branch`
and `metadata` on the same change in `syncInputs`, and a remounted item's mount effect sets them
again.
An item's commands come from `setup.commandsFor({ serverId, projectId })`, bound to the target the
item was mounted for. Once `syncInputs` reports another target they reject with a "moved" error, so
a lookup that finishes after a project switch cannot write into the new project's choices. The
reset only clears what was already written; this check covers what is written later. Rejecting, not
ignoring, matches the lock: a command's promise resolves only when the change took effect.
Known limit: an item that issues a command from `useLayoutEffect` on mount after a project or host
switch runs before the screen's `syncInputs` layout effect, so its commands (bound to the new
target) still see the previous target and reject as moved. This applies to a project switch as well as a host switch, since items
remount per `(serverId, project id)`. Passive effects are not affected.
`ensureWorkspace` sends the store's `branch` as `source.branchName` whenever the worktree cuts a
new branch (`startsNewBranch`, which mirrors the daemon: anything but a checkout, including no base
at all on a detached HEAD), and its `metadata` on `workspace.create` when it is not empty. The
server uses `branchName ?? worktreeSlug` as the new branch. `baseRef.name` is the exact ref
creation uses; `branchNameFromRef` is for labels only.
Items render through `ComposerControl` with `surface="field"`: a `FieldTrigger` (a badge on
desktop, a full-width field when compact) and the same pending state, error toast and list as the
composer's controls. `ComposerControlLockProvider` carries the screen's lock to them. Plugin
`Combobox` is a `select` item with `remoteSearch`: the app `Combobox` does not filter locally, and
`PluginCombobox` (`plugins/react-native/composer-controls.tsx`) runs `search` through
`useFetchQuery` with `dataShape: "list"`, which keeps the previous results while a newer query runs.
`plugins/item-host.tsx` is the host shared by composer and New workspace items.
## Contribute timeline items
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
+62 -6
View File
@@ -30,6 +30,65 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
### Options flow
`options` on the agent snapshot is the only option state. Controls describe how to draw an
option and read `options`; they hold no value of their own. Any option can be read and set with
or without a control.
- **Create and resume.** The provider opens with the options it is given and emits nothing about
them while opening. `openOptionsPatch` carries every option the session ended up with that
differs from what it was given, including a setting nobody asked for (a native default such as
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
daemon stores it before the agent is published. A reopened agent resumes that state, so a
native default the provider later changes does not follow. `options` on the snapshot is what
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
Editing a provider default in `config.json` affects agents the next time they open.
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
there is none; the provider returns the setting to its own default if it can, or throws to
reject. A key the running session cannot apply (one the provider reads only when it opens) is
rejected too, so a stored option always describes what runs. Claude reads most options when it
starts its query, and replacing a started query would end its background work, so after the
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
it ended up with when they differ from the request. The daemon
stores the patch and that result in one step after the call resolves, onto the agent's options
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
before it refused, it reports the moved values once as `options_changed` after the call has
ended, because the stored options must match what runs.
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
behind the provider id, following `derivedFromProviderId`, so a custom provider with
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
and provider-agnostic; it stays at the protocol and legacy edges.
- **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the
agent snapshot for a running or closed agent. A draft and a profile form send everything they
would create with (seed, the provider's last-used control values once the provider has said it
still draws them, and the user's edits) in `provider.controls.list.request` and draw only what
comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once,
by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's
layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile
replaces provider and options in one step, while choosing a provider by hand starts over. A
profile saves its stored options plus the form's edits, so options no control draws survive an
edit. A daemon without `server_info.features.providerOptionControls` sends `features` and
`featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that
turns those into `controls` and `options` and back, so nothing in the app branches on it. A
create that carries options first lists the controls for its own provider, model, mode and
thinking level, since which options are controls differs by model, and sends those as
`featureValues`; callers do not prime anything.
- **Provider-initiated changes.** A setting the provider changes on its own is an
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
@@ -115,7 +174,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
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.
@@ -195,9 +254,6 @@ See the [public usage source reference](../public-docs/plugins/reference.md#usag
contract, account and window identity, provider-derived period names, login fallback, and
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
renders their names and resolves pins without provider-specific duration guesses.
Claude's OAuth usage endpoint is undocumented. Extra-usage amounts are reported in minor currency
units and displayed only for USD; the balance contract does not represent other currencies.
Codex individual spending limits provide percentages, not dollar amounts.
---
@@ -481,7 +537,7 @@ interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
readonly controls?: AgentOptionControl[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -503,7 +559,7 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
applyOptions?(options: ProviderOptions): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null;
+5 -8
View File
@@ -21,14 +21,11 @@ Which triggers keep terminals and which close them is owned by the stop reason (
## Record, socket and secret
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its pid, protocol version, socket path, random secret, runtime copy and terminal programs. One daemon holds the home's pid lock. Its records and sockets belong to that home.
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
- The first frame must be `hello` with the secret; anything else closes the connection. Anyone authenticated can type into every shell, so the record and Unix socket are `0600` inside a `0700` directory.
- New socket names include the record id. A stale record whose pid belongs to a newer worker cannot reach that newer worker. Existing records keep their recorded paths. Legacy paths use only the home and pid, so force-killing through one also requires successful authentication. A legacy socket that cannot authenticate stays unconfirmed.
- A missing pid or refused socket retires the record without signaling the worker. An accepting socket identifies the live worker. Cleanup asks it to shut down, then probes again before force-killing it after the grace period (`taskkill /T` on Windows). An inconclusive probe counts toward the worker limit and is retried on the next start or stop without signaling it.
- Workers never query their own birth time or command line. Readers accept legacy identity fields but ignore them. Daemons through 0.15.1 report new records unreadable and cannot adopt them.
- Windows terminal programs have no recorded birth time and terminal lifecycle operations never launch PowerShell. Linux and macOS retain program birth-time checks through `ps` with `LC_ALL=C`; Linux also walks sessions to find surviving jobs.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Long dev homes use an owner-only fallback under `$TMPDIR/paseo-<uid>/` or `/tmp/paseo-<uid>/`. An existing directory must be a real `0700` directory owned by you; a shared root is never chmodded.
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
@@ -43,7 +40,7 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
| two or more older | shuts it down; its terminals close (logged) |
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable worker counts, as does one whose socket probe is inconclusive, and one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.3",
"version": "0.15.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/server": "0.15.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.3",
"version": "0.15.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/highlight": "0.15.2",
"@getpaseo/plugin": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.3",
"version": "0.15.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.3",
"version": "0.15.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -235,7 +235,6 @@ async function beginTimelineRowStabilityCheck(
lastTop: initialRow.getBoundingClientRect().top,
largestDownwardShift: 0,
sawMissing: false,
sawHidden: false,
};
const windowState = window as unknown as Record<string, unknown>;
windowState.__messageSubmissionTimelineRowStability = state;
@@ -246,9 +245,6 @@ async function beginTimelineRowStabilityCheck(
requestAnimationFrame(checkFrame);
return;
}
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
state.sawHidden = true;
}
const top = initialRow.getBoundingClientRect().top;
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
state.lastTop = top;
@@ -261,24 +257,14 @@ async function beginTimelineRowStabilityCheck(
const result = await page.evaluate(() => {
const windowState = window as unknown as Record<string, unknown>;
const state = windowState.__messageSubmissionTimelineRowStability as
| {
active: boolean;
largestDownwardShift: number;
sawMissing: boolean;
sawHidden: boolean;
}
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
| undefined;
if (!state) throw new Error("Timeline-row stability check was not started");
state.active = false;
delete windowState.__messageSubmissionTimelineRowStability;
return {
largestDownwardShift: state.largestDownwardShift,
sawMissing: state.sawMissing,
sawHidden: state.sawHidden,
};
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
});
expect(result.sawMissing).toBe(false);
expect(result.sawHidden).toBe(false);
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
};
}
@@ -1192,13 +1178,7 @@ test.describe("Agent message submission", () => {
test.setTimeout(120_000);
const toasts = await recordPanelToasts(page);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
page,
pending.userMessage,
);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
await finishTimelineRowStabilityCheck();
// A chat this client just created is current by construction; it is never out of date.
await toasts.expectNeverShown("agent-updating-toast");
});
@@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const preferences = JSON.parse(raw) as FormPreferences;
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
return preferences.providerPreferences?.[providerId]?.options ?? null;
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
);
@@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
provider: featureProviderId,
model: featureModelId,
modeId: "bypassPermissions",
featureValues: { fast_mode: true },
options: { fast_mode: true },
},
]);
const provider = await seedModelProvider({
@@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
expect(createAgentRequest).toMatchObject({
config: {
provider: featureProviderId,
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
},
});
} finally {
@@ -18,10 +18,8 @@ test.describe("Agent timeline sync retry", () => {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 2 });
try {
gate.holdNextClientRequest("clear_agent_attention");
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await gate.waitForHeldClientRequest();
gate.failTimelineResponses(agent.agentId);
await disconnectViewedTimeline(page, gate);
@@ -1,200 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import { submitMessage } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
// within a few milliseconds of each other. These tests hold all three, let the running
// turn paint, then release them together and sample every painted frame.
interface Box {
top: number;
height: number;
}
interface TurnEndFrame {
at: number;
running: boolean;
footer: Box | null;
userRow: Box | null;
replyVisible: boolean;
}
declare global {
interface Window {
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
}
}
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
agent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-end-",
title: "Turn end",
initialPrompt: "emit 4 tool agent stream updates",
});
await agent.client.waitForFinish(agent.agentId, 15_000);
await provide(agent);
await agent.cleanup();
},
});
async function recordTurnEndFrames(
page: Page,
input: { prompt: string; reply: string | null },
): Promise<void> {
await page.evaluate(({ prompt, reply }) => {
const state = { active: true, frames: [] as TurnEndFrame[] };
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
const boxOf = (element: Element | null | undefined): Box | null => {
if (!element || !isVisible(element)) return null;
const rect = element.getBoundingClientRect();
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
};
const sample = () => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
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));
const replyRow =
reply === null
? undefined
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
(candidate) => candidate.textContent?.includes(reply),
);
state.frames.push({
at: performance.now(),
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
footer: footers.at(-1) ?? null,
userRow: boxOf(userRow),
replyVisible: boxOf(replyRow) !== null,
});
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
};
window.__turnEndFrames = state;
requestAnimationFrame(() => setTimeout(sample, 0));
}, input);
}
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
await expect
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
.toBeGreaterThanOrEqual(until);
}
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
return page.evaluate(() => {
const state = window.__turnEndFrames;
if (!state) throw new Error("Turn end frames were not recorded");
state.active = false;
return state.frames;
});
}
async function recordTurnEnd(
page: Page,
agent: MockAgentWorkspace,
input: { prompt: string; reply: string | null },
): Promise<TurnEndFrame[]> {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectAgentIdle(page);
if (input.reply === null) {
gate.setAgentStreamItemSuppressed("assistant_message", true);
} else {
gate.holdNextAgentStreamItem("assistant_message");
}
gate.holdNextAgentStreamEvent("turn_completed");
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
gate.holdNextAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", true);
await recordTurnEndFrames(page, input);
await submitMessage(page, input.prompt);
await Promise.all([
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
gate.waitForHeldAgentStreamEvent("turn_completed"),
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
]);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await recordPaintsFor(page, 200);
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
gate.releaseHeldAgentStreamEvent("turn_completed");
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", false);
await expectAgentIdle(page);
await recordPaintsFor(page, 300);
return stopRecording(page);
}
function describeFrames(frames: TurnEndFrame[]): string {
return frames
.map(
(frame, index) =>
`${index}: ${frame.running ? "running " : "complete"} footer=${
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
)
.join("\n");
}
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
const record = `frames:\n${describeFrames(frames)}`;
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
expect(firstComplete, record).toBeGreaterThan(firstRunning);
const transition = frames.slice(firstRunning);
const runningHeight = transition[0].footer?.height;
expect(
transition.map((frame) => frame.footer?.height ?? null),
`the footer must stay painted at one height\n${record}`,
).toEqual(transition.map(() => runningHeight));
if (input.reply !== null) {
expect(
frames.slice(firstComplete).every((frame) => frame.replyVisible),
`a completed turn must show its reply in the same frame\n${record}`,
).toBe(true);
}
// Tool rows land while the turn runs; only the turn end itself must be one step.
const ending = frames.slice(firstComplete - 1);
const promptMoves = ending
.slice(1)
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
expect(
promptMoves.length,
`the prompt must move at most once as the turn ends\n${record}`,
).toBeLessThanOrEqual(1);
}
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" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
});
@@ -1,7 +1,4 @@
import { writeFile, unlink } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { gotoAppShell } from "../support/helpers/app";
import {
expectNoBranchSwitcherInWorkspaceHeader,
@@ -17,88 +14,6 @@ import {
waitForSidebarHydration,
} from "../support/helpers/workspace-ui";
// Hold the real daemon response until the test has observed the pending UI.
async function holdBranchSuggestions(page: Page) {
let requestCount = 0;
let release!: () => void;
let gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const envelope = JSON.parse(message.toString());
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
requestCount++;
server.send(message);
});
server.onMessage(async (message) => {
const envelope = JSON.parse(message.toString());
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") {
await gate;
}
browser.send(message);
});
});
return {
release: () => release(),
holdNextResponse: () => {
gate = new Promise<void>((resolve) => {
release = resolve;
});
},
requestCount: () => requestCount,
};
}
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click();
const picker = page.getByTestId("combobox-desktop-container");
await expect(picker.getByText("Loading...", { exact: true })).toBeVisible();
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
release();
await expect(picker.getByText("dev", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(picker).not.toBeVisible();
}
async function expectFailedRefreshRecovery(
page: Page,
repoPath: string,
responses: Awaited<ReturnType<typeof holdBranchSuggestions>>,
) {
// Expire the actual query freshness window before reopening the cached picker.
const started = Date.now();
await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000);
const picker = page.getByTestId("combobox-desktop-container");
const packedRefs = path.join(repoPath, ".git", "packed-refs");
await writeFile(packedRefs, "invalid packed refs\n");
try {
await page
.getByRole("button", { name: "Current branch: dev. Press to switch branch." })
.click();
await expect(picker.getByRole("alert")).toHaveText(
"Could not load branches. Reopen the picker to try again.",
);
} finally {
await unlink(packedRefs);
}
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
responses.holdNextResponse();
const beforeRetry = responses.requestCount();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click();
await expect(picker.getByRole("alert")).toHaveText("Loading...");
await expect.poll(responses.requestCount).toBe(beforeRetry + 1);
await expect(picker).toBeVisible();
responses.release();
await expect(picker.getByRole("alert")).toHaveCount(0);
await expect(picker.getByText("main", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expectWorkspaceBranch(page, "dev");
expect(responses.requestCount()).toBe(beforeRetry + 1);
}
async function renameWorkspaceViaSidebar(
page: Page,
input: { workspaceId: string; title: string },
@@ -144,7 +59,6 @@ test.describe("Branch switcher", () => {
try {
expect(workspace.workspaceName).toBe("main");
const releaseBranches = await holdBranchSuggestions(page);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
@@ -168,10 +82,8 @@ test.describe("Branch switcher", () => {
// and switching it checks out the real branch on disk.
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await expectInitialBranchLoading(page, releaseBranches.release);
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches);
// The custom title is unaffected by the branch switch.
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
@@ -427,15 +427,6 @@ test("Changes shows the base as text on a host without the base-branch capabilit
expect(baseRequests).toBe(0);
});
test("Changes shows no base on a checkout that is not a Paseo worktree", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace);
const header = page.getByTestId("changes-repository-header").filter({ visible: true });
await expect(header).toContainText("feature");
await expect(header).not.toContainText("main");
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
});
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
@@ -5,10 +5,8 @@ import {
holdDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
expectLayoutObservationToCatchPanelShift,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
shiftOutsideCommandCenter,
} from "../support/helpers/command-center-file-search";
import {
scrollCommandCenterAwayFromSelection,
@@ -86,9 +84,7 @@ test("workspace file search stays geometrically stable through delayed loading a
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await shiftOutsideCommandCenter(page);
await expectStableCommandCenterLayout(page);
await expectLayoutObservationToCatchPanelShift(page);
await row.click();
await expectFileTabOpen(page, FILE_PATH);
@@ -105,21 +105,7 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void;
}
interface ListCommandsReply {
commands: readonly { name: string; description: string; argumentHint: string }[];
error: string | null;
}
const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null };
/**
* Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds
* the response until it settles, standing in for a slow host.
*/
async function installListCommandsStub(
page: Page,
reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => TEST_COMMANDS_REPLY,
): Promise<void> {
async function installListCommandsStub(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
@@ -144,20 +130,14 @@ async function installListCommandsStub(
};
};
};
const payload = parsed.message?.payload;
if (
parsed.type === "session" &&
parsed.message?.type === "list_commands_response" &&
payload
parsed.message.payload
) {
const send = (result: ListCommandsReply) => {
payload.commands = result.commands;
payload.error = result.error;
ws.send(JSON.stringify(parsed));
};
const result = reply();
if (result instanceof Promise) void result.then(send);
else send(result);
parsed.message.payload.commands = TEST_COMMANDS;
parsed.message.payload.error = null;
ws.send(JSON.stringify(parsed));
return;
}
} catch {
@@ -169,17 +149,6 @@ async function installListCommandsStub(
});
}
function createHeldReply(): {
reply: () => Promise<ListCommandsReply>;
release: () => void;
} {
let release = () => {};
const held = new Promise<ListCommandsReply>((resolve) => {
release = () => resolve(TEST_COMMANDS_REPLY);
});
return { reply: () => held, release };
}
async function openAppWideNewWorkspace(page: Page): Promise<void> {
await page.getByTestId("sidebar-global-new-workspace").first().click();
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
@@ -576,17 +545,16 @@ test.describe("Composer autocomplete", () => {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
// `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows.
await input.fill("/hea");
await input.fill("/he");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const beforeDelete = await visiblePopoverBox(page);
await input.press("Backspace");
await expect(input).toHaveValue("/he");
await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({
await expect(input).toHaveValue("/h");
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const afterDelete = await visiblePopoverBox(page);
@@ -765,116 +733,6 @@ test.describe("Composer autocomplete", () => {
}
});
test("forgets an arrow-key choice once the search changes", async ({ page }) => {
await installListCommandsStub(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-stale-choice-",
title: "Autocomplete stale choice",
});
try {
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
// Down wraps from the default bottom row to the top: /clear, then /exit.
await input.press("ArrowDown");
await input.press("ArrowDown");
await input.press("h");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
await input.press("Backspace");
await expect(input).toHaveValue("/");
await input.press("Enter");
await expect(input).toHaveValue("/tdd ");
await expectWorkspaceTabVisible(page, session.agentId);
} finally {
await session.cleanup();
}
});
test("shows loading inside the popover until a slow host answers", async ({ page }) => {
const host = createHeldReply();
await installListCommandsStub(page, host.reply);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({
timeout: 30_000,
});
const loading = await visiblePopoverBox(page);
host.release();
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0);
const results = await visiblePopoverBox(page);
expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4);
} finally {
await agent.cleanup();
}
});
test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({
page,
}) => {
let reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => ({
commands: [],
error: "Timed out waiting for the agent session",
});
await installListCommandsStub(page, () => reply());
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-failing-host-",
title: "Autocomplete failing host",
});
try {
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
const errorRow = popover.getByTestId("composer-autocomplete-error");
await expect(errorRow).toContainText("Timed out waiting for the agent session", {
timeout: 30_000,
});
await input.press("Enter");
await page.waitForTimeout(500);
await expectWorkspaceTabVisible(page, session.agentId);
await input.fill("/");
await expect(errorRow).toBeVisible({ timeout: 30_000 });
const host = createHeldReply();
reply = host.reply;
await errorRow.getByRole("button", { name: "Retry" }).click();
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible();
host.release();
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
} finally {
await session.cleanup();
}
});
test.describe("compact sidebar layering", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
@@ -1,14 +1,8 @@
import { test, expect } from "../support/fixtures";
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { fillComposerDraft } from "../support/helpers/composer";
import {
expectComposerToolbarOnTextRails,
expectNoCollapsedComposerToolbarFrame,
expectNoFrameTruncatesModelBesideThinking,
readSettledModelTriggerLabels,
recordComposerToolbarFrames,
recordModelTriggerFramesFromLoad,
sweepModelTriggerLabels,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -130,128 +124,3 @@ test.describe("Composer controls folding on a touch device", () => {
}
});
});
test.describe("Folded model control on a phone", () => {
// Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit.
test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
test("hides the thinking label before the model label truncates", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-density-labels-",
title: "Label priority chat",
model: "ten-second-stream",
});
try {
await recordModelTriggerFramesFromLoad(page);
await openAgentRoute(page, session);
await expect(
page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }),
).toHaveText("Low");
await readSettledModelTriggerLabels(page);
// From the first frame on, the control never guesses: it paints both labels only once it
// has measured that they fit.
await expectNoFrameTruncatesModelBesideThinking(page);
const sweep = await sweepModelTriggerLabels(page, {
from: 480,
to: 280,
step: 4,
height: 844,
});
expect(sweep.widest).toMatchObject({
modelText: "Ten second stream",
thinkingShown: true,
modelTruncated: false,
});
expect(sweep.thinkingHidden).toMatchObject({
modelText: "Ten second stream",
modelTruncated: false,
});
expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width);
// A clean tail: the label ends on a whole word and the ellipsis, never on a space.
expect(sweep.modelTruncated).toMatchObject({
modelText: "Ten second…",
thinkingShown: false,
});
} finally {
await session.cleanup();
}
});
});
test.describe("Composer toolbar rails on touch", () => {
test.use({ isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
for (const viewport of [
{ device: "phone", width: 390, height: 844 },
{ device: "tablet", width: 1024, height: 1366 },
]) {
test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({
page,
}) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const idle = await seedMockAgentWorkspace({
repoPrefix: "composer-rails-idle-",
title: "Rails idle chat",
});
let running: Awaited<ReturnType<typeof seedMockAgentWorkspace>> | null = null;
try {
await openAgentRoute(page, idle);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
await test.step("empty composer: attach on the left, voice mode on the right", async () => {
await expectComposerToolbarOnTextRails(page);
});
await test.step("draft: the send disc on the right", async () => {
await fillComposerDraft(page, "Refactor the composer toolbar");
await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible();
await expectComposerToolbarOnTextRails(page);
});
running = await seedMockAgentWorkspace({
repoPrefix: "composer-rails-running-",
title: "Rails running chat",
model: "one-minute-stream",
initialPrompt: "stream for a while",
});
await openAgentRoute(page, running);
await test.step("running agent: the stop disc on the right", async () => {
await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({
timeout: 30_000,
});
await expectComposerToolbarOnTextRails(page);
});
} finally {
await idle.cleanup();
await running?.cleanup();
}
});
}
});
test.describe("Composer trailing disc on a new chat", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } });
test("an empty draft holds a disabled send disc until there is something to send", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const send = page
.getByTestId("message-input-root")
.filter({ visible: true })
.getByRole("button", { name: "Send message", exact: true });
await expect(send).toBeDisabled({ timeout: 30_000 });
await fillComposerDraft(page, "Refactor the composer toolbar");
await expect(send).toBeEnabled();
} finally {
await workspace.cleanup();
}
});
});
@@ -1,9 +1,7 @@
import type { CDPSession, Locator } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedSubagent } from "../support/helpers/subagents";
import { submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
@@ -862,91 +860,3 @@ async function expectReviewEditorBody(page: Page, body: string) {
body,
);
}
async function swipeComposerTracks(page: Page, client: CDPSession, direction: "left" | "right") {
const track = page.getByTestId("composer-track-scroll");
const box = await track.boundingBox();
if (!box) throw new Error("Composer tracks are not visible");
const start = direction === "left" ? box.x + box.width - 5 : box.x + 5;
const end = direction === "left" ? box.x + 5 : box.x + box.width - 5;
await swipeTouch(client, start, end, box.y + box.height / 2);
}
async function swipeTouch(client: CDPSession, start: number, end: number, y: number) {
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: start, y }],
});
for (let step = 1; step <= 8; step++) {
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: start + ((end - start) * step) / 8, y }],
});
}
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
}
async function trackScrollOffset(track: Locator): Promise<number> {
return track.evaluate((node) => node.scrollLeft);
}
test.describe("touch composer tracks", () => {
test.use({ hasTouch: true, isMobile: true });
test("compact composer scrolls its task, subagent and changes tracks without squeezing them", async ({
page,
}) => {
const workspace = await seedChangedAgent("composer-tracks-overflow-");
const touch = await page.context().newCDPSession(page);
try {
await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Track child" },
);
await page.setViewportSize({ width: 1000, height: 844 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await submitMessage(page, "emit 1 agent stream updates");
const tasks = page.getByTestId("agent-task-list-header");
const subagents = page.getByTestId("subagents-track-header");
const changes = page.getByTestId("composer-diff-stat-pill");
await expect(tasks).toBeVisible();
await expect(subagents).toBeVisible();
await expect(changes).toBeVisible();
const wideWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
await page.setViewportSize({ width: 200, height: 844 });
const narrowWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
for (const [index, width] of wideWidths.entries())
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
const track = page.getByTestId("composer-track-scroll");
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
await track.evaluate((node) => node.clientWidth),
);
await swipeComposerTracks(page, touch, "left");
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
await swipeComposerTracks(page, touch, "right");
await expect.poll(() => trackScrollOffset(track)).toBeLessThanOrEqual(1);
await swipeComposerTracks(page, touch, "left");
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport();
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1 });
await page.getByTestId("sidebar-close").click();
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
await expect(changes).toBeInViewport();
await changes.click();
await expect(
page.getByTestId("changes-header").filter({ visible: true }).first(),
).toBeVisible();
} finally {
await touch.detach();
await workspace.cleanup();
}
});
});
@@ -1,8 +1,6 @@
import { test } from "../support/fixtures";
import {
closeDiagramFullscreen,
expectInlineDiagramFitsViewport,
toggleDiagramSourceAndVerifyControls,
closeDiagramFullscreenFromOutside,
expectCompletedDiagram,
expectFullscreenDiagram,
@@ -23,36 +21,6 @@ const MERMAID_DIAGRAM = [
"```",
].join("\n");
test("bounds a tall inline diagram", async ({ page }) => {
test.setTimeout(120_000);
const diagram = [
"```mermaid",
"flowchart TD",
...Array.from({ length: 30 }, (_, i) => `N${i} --> N${i + 1}`),
"```",
].join("\n");
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-tall-",
title: "Tall Mermaid",
featureValues: {
mockStreamingAssistantResponse: diagram,
mockStreamingAssistantIntervalMs: 10,
},
});
try {
await openAgentRoute(page, agent);
await requestDiagram(agent);
await waitForDiagramTurnToComplete(agent);
await expectCompletedDiagram(page, ["N0", "N30"]);
await expectInlineDiagramFitsViewport(page);
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["N0", "N30"]);
await closeDiagramFullscreen(page);
} finally {
await agent.cleanup();
}
});
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
test.setTimeout(120_000);
@@ -72,10 +40,6 @@ test("opens a Mermaid diagram fullscreen and closes it again", async ({ page })
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("Source toggles preserve the diagram and exclude hidden controls", async () => {
await toggleDiagramSourceAndVerifyControls(page);
});
await test.step("The toolbar opens the diagram fullscreen", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
@@ -0,0 +1,12 @@
import { test } from "../support/fixtures";
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
test.setTimeout(120_000);
await withComposerControlsExample(page, async (composer) => {
await composer.narrowTheWindow();
await composer.openAgentFeatures();
await composer.expectFeatureRow("Needs review");
await composer.expectFeatureRow("Insert checklist");
});
});
@@ -0,0 +1,53 @@
import { test } from "../support/fixtures";
import {
CHECKLIST,
withComposerToolsExample,
withPluginWritingToAnotherWorkspace,
} from "../support/helpers/plugin-composers-by-reference";
test("a panel in the Explorer inserts at the cursor of the focused agent's composer", async ({
page,
}) => {
test.setTimeout(120_000);
await withComposerToolsExample(page, async (author) => {
await author.openComposerToolsInExplorer();
await author.typeInComposer("start end", 5);
await author.insertChecklistFromPanel();
await author.expectComposerText(`start${CHECKLIST} end`);
});
});
test("a Command Center item changes an agent's metadata through its composer", async ({ page }) => {
test.setTimeout(120_000);
await withComposerToolsExample(page, async (author) => {
await author.openComposerToolsInExplorer();
await author.expectPanelShowsReview(false);
await author.markForReviewFromCommandCenter();
await author.expectPanelShowsReview(true);
});
});
test("the panel follows which agent is focused", async ({ page }) => {
test.setTimeout(120_000);
await withComposerToolsExample(page, async (author) => {
await author.openComposerToolsInExplorer();
await author.expectPanelFocusedAgent(true);
await author.focusANewAgentTab();
await author.expectPanelFocusedAgent(false);
await author.focusTheAgentTab();
await author.expectPanelFocusedAgent(true);
});
});
test("a plugin writes to an agent's composer while its workspace is not on screen", async ({
page,
}) => {
test.setTimeout(120_000);
await withPluginWritingToAnotherWorkspace(page, async (author) => {
await author.openTargetAgent();
await author.goToAnotherWorkspace();
await author.insertFromCommandCenter();
await author.returnToTargetWorkspace();
await author.expectComposerText("A note from a plugin");
});
});
@@ -0,0 +1,110 @@
import { expect, test } from "../support/fixtures";
import { withNewWorkspaceIssuesExample } from "../support/helpers/plugin-new-workspace-items";
for (const layout of [
{ name: "desktop", compact: false },
{ name: "compact", compact: true },
]) {
test(`a plugin's New workspace item sets the branch and metadata on ${layout.name}`, async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(page, { compact: layout.compact }, async (screen) => {
await screen.chooseWorktree();
await screen.chooseIssue("PAS-102 Crash on resume");
const created = await screen.createWorkspace();
expect(created.branch).toBe("pas-102-crash-on-resume");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-102" });
});
});
}
test("a branch chosen while Local applies once the user switches to a new worktree", async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(page, { compact: false }, async (screen) => {
await screen.chooseIssue("PAS-101 Slow sidebar");
await screen.chooseWorktree();
const created = await screen.createWorkspace({ prompt: "" });
expect(created.branch).toBe("pas-101-slow-sidebar");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-101" });
});
});
test("a plugin's branch applies when the project has no default base to branch off", async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, detachedHead: true },
async (screen) => {
await screen.chooseWorktree();
await screen.chooseIssue("PAS-101 Slow sidebar");
const created = await screen.createWorkspace();
expect(created.branch).toBe("pas-101-slow-sidebar");
},
);
});
test("a branch and metadata chosen for one project are not sent for another", async ({ page }) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, otherProject: true },
async (screen) => {
await screen.chooseWorktree();
await screen.chooseIssue("PAS-101 Slow sidebar");
await screen.switchToOtherProject();
const created = await screen.createWorkspace();
expect(created.branch).not.toBe("pas-101-slow-sidebar");
expect(created.metadata ?? {}).toEqual({});
},
);
});
test("an item's Issue list does not keep the previous project's results after a project switch", async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, otherProject: true, searchesByProject: true },
async (screen) => {
await screen.openIssueList();
await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, true);
await screen.closeIssueList();
await screen.switchToOtherProject();
await screen.openIssueList();
await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, false);
},
);
});
test("an isolation set from a mount effect is remembered like a pick", async ({ page }) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, setsOnMount: true },
async (screen) => {
await screen.expectIsolation("worktree");
await screen.reopenWithoutPlugin();
await screen.expectIsolation("worktree");
},
);
});
test("an item can set the branch and metadata from its mount effect", async ({ page }) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, setsOnMount: true },
async (screen) => {
await screen.expectIsolation("worktree");
const created = await screen.createWorkspace();
expect(created.branch).toBe("set-on-mount");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "ON-MOUNT" });
},
);
});
@@ -1,8 +1,7 @@
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { expect, test } from "../support/fixtures";
import {
cleanupRewindFlow,
launchAgent,
@@ -15,11 +14,7 @@ import {
expectTaskListEntries,
openCompletedTaskList,
} from "../support/helpers/task-list";
import { submitMessage, submitMessageWithButton } from "../support/helpers/composer";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { gotoAppShell } from "../support/helpers/app";
import { submitMessage } from "../support/helpers/composer";
interface TaskScenario {
provider: RewindFlowProvider;
@@ -119,425 +114,3 @@ test.describe("real provider composer task lists", () => {
}
});
});
async function openBackgroundInventory(
page: Page,
options: { compact: boolean; count: number; timeout: number },
): Promise<Locator> {
const pill = page.getByRole("button", {
name: options.count === 1 ? "1 background task" : `${options.count} background tasks`,
});
await expect(pill).toBeVisible({ timeout: options.timeout });
await pill.click();
const panel = page.getByTestId(
options.compact ? "background-work-panel-content" : "background-work-panel",
);
if (options.compact) await waitForSettledPosition(panel);
return panel;
}
async function openBackgroundWork(page: Page, compact: boolean): Promise<void> {
const panel = await openBackgroundInventory(page, { compact, count: 1, timeout: 30_000 });
await expect(panel).toContainText(/sleep 120/);
}
async function expectBackgroundItems(
panel: Locator,
selected: string,
sibling: string,
): Promise<void> {
await expect(panel).toContainText(selected);
await expect(panel).toContainText(sibling);
}
async function stopBackgroundItem(panel: Locator, selected: string): Promise<void> {
const escaped = selected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await panel.getByRole("button", { name: new RegExp(`Stop .*${escaped}`) }).click();
}
async function expectStoppedItemAndSibling(
panel: Locator,
selected: string,
sibling: string,
): Promise<void> {
await expect(panel).not.toContainText(selected, { timeout: 40_000 });
await expect(panel).toContainText(sibling);
}
async function closeBackgroundWork(page: Page, compact: boolean): Promise<void> {
const panel = page.getByTestId(
compact ? "background-work-panel-content" : "background-work-panel",
);
if (compact) {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.click({ position: { x: 12, y: 12 } });
} else {
await page.keyboard.press("Escape");
}
await expect(panel).toBeHidden();
}
async function expectBackgroundAgentRunning(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-status-indicator-running").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
}
async function expectAssistantReply(page: Page, text: string): Promise<void> {
await expect(page.getByTestId("assistant-message").filter({ hasText: text }).last()).toBeVisible({
timeout: 120_000,
});
}
async function expectBackgroundPill(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "1 background task" })).toBeVisible();
}
async function expectBackgroundWorkOnAnotherClient(page: Page): Promise<void> {
const another = await page.context().newPage();
try {
await another.goto(page.url());
await openBackgroundWork(another, false);
} finally {
await another.close();
}
}
async function startBackgroundWorkWithoutOpeningPane(workspace: SeededWorkspace): Promise<void> {
const agent = await workspace.client.createAgent({
provider: "claude",
model: "haiku",
modeId: "bypassPermissions",
cwd: workspace.workspaceDirectory,
workspaceId: workspace.workspaceId,
title: "Background work before opening",
});
await workspace.client.sendAgentMessage(
agent.id,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
const result = await workspace.client.waitForFinish(agent.id, 120_000);
expect(result.status).toBe("idle");
}
async function expectWorkspaceRunning(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row.getByTestId("workspace-status-indicator-running")).toBeVisible({
timeout: 30_000,
});
}
test("Claude keeps background work visible while accepting another turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-work-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await sendMessage(
handle,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase1-claude-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude background work remains readable in dark mode", async ({ page }) => {
test.setTimeout(300_000);
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
});
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-dark-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await sendMessage(
handle,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/review-background-dark.png"),
animations: "disabled",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude background work opens in a compact sheet", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 390, height: 844 });
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-compact-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await submitMessageWithButton(
page,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await expectAssistantReply(page, "SPAWNED");
await openBackgroundWork(page, true);
await page.screenshot({
path: path.resolve("../../.artifacts/phase1-claude-compact.png"),
animations: "disabled",
});
await closeBackgroundWork(page, true);
await submitMessageWithButton(
page,
"Reply exactly HELLO without stopping the background command.",
);
await expectAssistantReply(page, "HELLO");
await expectBackgroundPill(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("background work updates the sidebar before the agent is opened", async ({ page }) => {
test.setTimeout(300_000);
const workspace = await seedWorkspace({ repoPrefix: "background-unopened-" });
try {
await gotoAppShell(page);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`),
).toBeVisible();
await startBackgroundWorkWithoutOpeningPane(workspace);
await expectWorkspaceRunning(page, workspace.workspaceId);
} finally {
await workspace.cleanup();
}
});
test("Codex keeps a live command visible after its turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-background-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
providerConfig: { model: "gpt-6-astra" },
});
await sendMessage(
handle,
"Use exec_command to run sleep 120 with yield_time_ms 1000. Do not wait for or stop it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase2-codex-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("OpenCode keeps a live command visible after its turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-opencode-background-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "opencode",
cwd,
mode: "full-access",
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
});
await sendMessage(
handle,
"Use the shell tool with background true to run sleep 120. Do not wait or stop it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase2-opencode-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
for (const scenario of [
{
provider: "claude" as const,
prompt:
"Use Bash with run_in_background to start sleep 180 and sleep 181 as two separate background commands. Do not wait for them. Reply SPAWNED immediately.",
providerConfig: { model: "haiku" },
},
{
provider: "codex" as const,
prompt:
"Use exec_command twice to start sleep 180 and sleep 181 separately with yield_time_ms 1000. Do not wait for or stop either process. Reply SPAWNED immediately.",
providerConfig: { model: "gpt-6-astra" },
},
{
provider: "opencode" as const,
prompt:
"Use the shell tool twice with background true: start sleep 180, then start sleep 181. Do not wait for or stop either. Reply SPAWNED immediately.",
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
},
]) {
test(`${scenario.provider} stops selected background work after the turn`, async ({ page }) => {
test.setTimeout(300_000);
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-qa-workspace-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await sendMessage(handle, scenario.prompt);
const panel = await openBackgroundInventory(page, {
compact: false,
count: 2,
timeout: 60_000,
});
await expectBackgroundItems(panel, "sleep 180", "sleep 181");
await stopBackgroundItem(panel, "sleep 180");
await expectStoppedItemAndSibling(panel, "sleep 180", "sleep 181");
await page.screenshot({
path: path.resolve(`../../.artifacts/phase3-${scenario.provider}-stop.png`),
animations: "disabled",
});
await page.keyboard.press("Escape");
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
test("Claude stops a selected monitor in the compact sheet", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 390, height: 844 });
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-monitor-workspace-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "opus" },
});
await submitMessageWithButton(
page,
'Use exactly two separate Monitor tool calls, each with command `sleep 150`, timeout_ms 150000, persistent false, descriptions "probe monitor one" and "probe monitor two". Launch both independently, do not wait, then reply SPAWNED. Do not use any other tools.',
);
await expectAssistantReply(page, "SPAWNED");
const panel = await openBackgroundInventory(page, { compact: true, count: 2, timeout: 30_000 });
await expectBackgroundItems(panel, "probe monitor one", "probe monitor two");
await expect(panel.getByText("sleep 150", { exact: true })).toHaveCount(2);
await page.screenshot({
path: path.resolve("../../.artifacts/refinement-monitor-compact.png"),
animations: "disabled",
});
await stopBackgroundItem(panel, "probe monitor one");
await expectStoppedItemAndSibling(panel, "probe monitor one", "probe monitor two");
await page.screenshot({
path: path.resolve("../../.artifacts/phase3-claude-monitor-compact.png"),
animations: "disabled",
});
await closeBackgroundWork(page, true);
await submitMessageWithButton(
page,
"Reply exactly HELLO without stopping the background monitor.",
);
await expectAssistantReply(page, "HELLO");
await expectBackgroundPill(page);
await handle.client.sendAgentMessage(
handle.agentId,
"Use Bash to run sleep 20 in the foreground, then reply WAITED.",
);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({
timeout: 30_000,
});
await handle.client.sendAgentMessage(
handle.agentId,
"Reply exactly INTERRUPTED without stopping the background monitor.",
{ activeTurnBehavior: "interrupt" },
);
expect((await handle.client.waitForFinish(handle.agentId, 120_000)).status).toBe("idle");
await expectBackgroundPill(page);
await expect
.poll(async () => handle!.client.listAgentBackgroundWork(handle!.agentId))
.toEqual(
expect.arrayContaining([
expect.objectContaining({ description: "probe monitor two", kind: "monitor" }),
]),
);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude shows monitor description and command in the wide track", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(
mkdtempSync(path.resolve("../../.artifacts/refinement-wide-workspace-")),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "opus" },
});
await sendMessage(
handle,
'Use Monitor with command `sleep 150`, timeout_ms 150000, persistent false, description "Watch the build". Do not wait; reply SPAWNED. Do not use other tools.',
);
await expectBackgroundAgentRunning(page);
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${handle.workspaceId}`,
);
await workspaceRow.scrollIntoViewIfNeeded();
await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toBeVisible();
const panel = await openBackgroundInventory(page, {
compact: false,
count: 1,
timeout: 30_000,
});
await expect(panel).toContainText("Watch the build");
await expect(panel).toContainText("sleep 150");
await expect(panel.getByRole("button", { name: "Stop Watch the build" })).toBeVisible();
await page.screenshot({
path: path.resolve("../../.artifacts/refinement-monitor-wide.png"),
animations: "disabled",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
@@ -1,13 +1,12 @@
import type { Page } from "@playwright/test";
import { test as base, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { expectOpenTabCount } from "../support/helpers/sheet";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { expectReconnectingToastGone } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id";
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
@@ -102,13 +101,11 @@ async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: Fin
}
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
await test.step("ordinary completion still clears on departure", async () => {
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
await subject.client.waitForFinish(subject.agentId, 20_000);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspace(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspace(page, subject.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
@@ -144,15 +141,13 @@ async function leaveMarkedWorkspaceAndReopenOnCompact(
}
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
await test.step("ordinary completion still clears on departure", async () => {
await closeMobileAgentSidebar(page);
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
await subject.client.waitForFinish(subject.agentId, 20_000);
await openMobileAgentSidebar(page);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspaceOnCompact(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspaceOnCompact(page, subject.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
@@ -199,12 +194,11 @@ async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
});
}
test("manual unread survives departure and clears on reopening and ordinary completion survives departure", async ({
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
page,
workspaces,
}) => {
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await gotoAppShell(page);
await openWorkspace(page, workspaces.other.workspaceId);
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
await leaveMarkedWorkspaceAndRead(page, workspaces);
@@ -213,19 +207,16 @@ test("manual unread survives departure and clears on reopening and ordinary comp
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
});
test("clicking a multi-agent workspace preserves its selected agent and leaves the other unread", async ({
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
page,
workspaces,
}) => {
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await expectSelectedAgent(page, workspaces.subject.agentId);
await openWorkspace(page, workspaces.other.workspaceId);
await addFinishedAgent(workspaces.subject);
await markAsUnread(page, workspaces.subject.workspaceId);
await openWorkspace(page, workspaces.subject.workspaceId);
await expectSelectedAgent(page, workspaces.subject.agentId);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
const newest = await addFinishedAgent(workspaces.subject);
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
await expectSelectedAgent(page, newest.id);
});
test("manual unread survives leaving the current workspace on compact layout", async ({
@@ -239,7 +230,7 @@ test("manual unread survives leaving the current workspace on compact layout", a
await completeTurnAndLeaveOnCompact(page, workspaces);
});
async function reopenWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
const route = page.url();
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
await page.goto("about:blank");
@@ -274,72 +265,9 @@ test("reopening a marked unread workspace restores only its agent tab and curren
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
await openMobileAgentSidebar(page);
await markAsUnread(page, workspaces.subject.workspaceId);
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
});
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
await expectRestoredCompactChat(page);
});
});
test("clicking the already focused timeline acknowledges a new completion", async ({
page,
workspaces,
}) => {
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await workspaces.subject.client.sendAgentMessage(
workspaces.subject.agentId,
"Finish another turn.",
);
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await page.getByText("Finish another turn.", { exact: true }).click();
await expectStatus(page, workspaces.subject.workspaceId, "done");
});
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);
}
test("reconnecting a focused chat preserves its unread completion until another click", async ({
page,
workspaces,
}) => {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await workspaces.subject.client.sendAgentMessage(
workspaces.subject.agentId,
"Unread across reconnect.",
);
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
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");
});
test("a cached read chat acknowledges a completion that arrived while the app was closed", async ({
page,
workspaces,
}) => {
await openCompactWorkspace(page, workspaces.subject);
await expectStatus(page, workspaces.subject.workspaceId, "done");
await closeMobileAgentSidebar(page);
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
await expectRestoredCompactChat(page);
await openMobileAgentSidebar(page);
await expectStatus(page, workspaces.subject.workspaceId, "done");
});
@@ -45,11 +45,6 @@ process.stdin.on("data", (chunk) => {
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
const pasted = JSON.stringify(paste[1]).replace(
/\s/g,
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
);
process.stdout.write("PASTED:" + pasted + "\r\n");
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
@@ -207,21 +202,6 @@ test.describe("Terminal protocol queries", () => {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
// Every bracketed paste body the receiver consumed, in order and joined: what a
// shell would read as one command line.
async function pastedText(terminalId: string): Promise<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
@@ -258,23 +238,6 @@ test.describe("Terminal protocol queries", () => {
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
try {
await dropFiles(page, [first]);
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
await dropFiles(page, [second]);
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
+7 -69
View File
@@ -9,7 +9,6 @@ import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { withDirectoryPlugin } from "../support/helpers/plugin-timeline";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.use({
@@ -51,16 +50,15 @@ async function seedAnsweredAgent(title: string) {
}
/** Chat rows between the latest turn's summary line and its reply, in document order. */
async function rowsBetweenLatestSummaryAndReply(
page: Page,
replyTestId = "assistant-message",
): Promise<number> {
return page.evaluate((testId) => {
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const replyRow = rows.findLastIndex((row) => row.querySelector(`[data-testid="${testId}"]`));
const replyRow = rows.findLastIndex((row) =>
row.querySelector("[data-testid=assistant-message]"),
);
return replyRow - summaryRow - 1;
}, replyTestId);
});
}
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
@@ -128,67 +126,6 @@ async function expandAndCollapseLongPrompt(page: Page) {
}
test.describe("Turn collapse", () => {
test("folds work before a plugin-rendered reply and reopens it", async ({ page }, info) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedMockAgentWorkspace({
repoPrefix: "plugin-reply-fold-",
title: "Plugin reply folding",
model: "ten-second-stream",
initialPrompt: "Make the fix and run the burst script.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await withDirectoryPlugin({
pluginId: "reply-fold-e2e",
source: `import React from "react";
import { Text } from "react-native";
import { z } from "zod";
export default function contribute(client) {
client.addTimelineTransformer({
id: "reply", query: { itemType: "assistant_message" },
transform({ item, phase }) {
if (phase === "streaming") return undefined;
return { items: [{ type: "plugin", kind: "reply", version: 1, data: { text: item.text } }] };
},
});
client.addTimelineRenderer({
kind: "reply", version: 1, schema: z.object({ text: z.string() }),
Component: ({ item }) => <Text testID="plugin-reply">{item.data.text}</Text>,
});
return () => {};
}`,
run: async () => {
await page.setViewportSize({ width: 1280, height: 1000 });
await openAgentRoute(page, agent);
await expectComposerVisible(page);
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 1000 });
const summary = page.getByTestId("turn-summary").last().getByRole("button");
const reply = page.getByTestId("plugin-reply").last();
await expect(summary).toBeVisible();
await expect(reply).toContainText("(end of synthetic stream)");
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
await page.screenshot({ path: info.outputPath(`plugin-reply-folded-${width}.png`) });
await summary.click();
await expect
.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply"))
.toBeGreaterThan(0);
await expect(reply).toContainText("(end of synthetic stream)");
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
}
await page.reload();
await expect(page.getByTestId("turn-summary").last()).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
},
});
} finally {
await agent.cleanup();
}
});
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
@@ -407,6 +344,7 @@ test("opening a turn keeps the received message above it clipped on every frame"
.getByTestId("user-message")
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
await bubble.scrollIntoViewIfNeeded();
await watchReceivedMessagePreview(page, "Sent by Reviewer");
await page
@@ -1,186 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
import {
archiveFromRowButton,
createGatedTeardown,
expectDirectoryPresence,
expectHostWorkspaceSettledActive,
outlastUndoWindow,
pressUndoShortcut,
rowArchiveButton,
rowKebab,
undoButton,
workspaceRow,
workspaceTitleWidth,
} from "../support/helpers/workspace-archive-undo";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
test.describe("Workspace archive undo", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const worktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
tempRepo = await createTempGitRepo("archive-undo-");
});
test.afterEach(async () => {
for (const directory of worktreeDirectories) {
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
}
worktreeDirectories.clear();
await client?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
});
async function seedWorktree(page: Page, slug: string) {
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `${slug}-${Date.now()}`,
});
worktreeDirectories.add(worktree.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: worktree.workspaceId,
});
return worktree;
}
test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-button-overlay");
await page.mouse.move(0, 0);
await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0);
const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).hover();
await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible();
await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible();
expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth);
const [buttonBox, kebabBox] = await Promise.all([
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
rowKebab(page, worktree.workspaceId).boundingBox(),
]);
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
});
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-shortcut");
await archiveFromRowButton(page, worktree.workspaceId);
await pressUndoShortcut(page);
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
await expect(undoButton(page)).toHaveCount(0);
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
// Once the host settled, any remove the canceled archive sent has already arrived.
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
await expectDirectoryPresence(worktree.workspaceDirectory, true);
});
test("Undo after the worktree was removed recreates it", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-recreate");
await archiveFromRowButton(page, worktree.workspaceId);
// Hovering the toast holds it, and with it the undo, open while the archive finishes.
await undoButton(page).hover();
await expectDirectoryPresence(worktree.workspaceDirectory, false);
await undoButton(page).click();
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
await expectDirectoryPresence(worktree.workspaceDirectory, true);
});
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
page,
}) => {
await page.clock.install({ time: Date.now() });
const worktree = await seedWorktree(page, "archive-undo-hold");
await archiveFromRowButton(page, worktree.workspaceId);
await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } });
await undoButton(page).hover();
await outlastUndoWindow(page);
await expect(undoButton(page)).toBeVisible();
await page.mouse.move(0, 0);
await outlastUndoWindow(page);
await expect(undoButton(page)).toHaveCount(0);
// The toast took the undo with it.
await pressUndoShortcut(page);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
});
test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => {
const teardown = createGatedTeardown();
const repo = await createTempGitRepo("archive-undo-tabs-", {
paseoConfig: teardown.paseoConfig,
});
const seedClient = await connectSeedClient();
try {
await openProjectViaDaemon(client, repo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: repo.path,
slug: `archive-undo-tabs-${Date.now()}`,
});
worktreeDirectories.add(worktree.workspaceDirectory);
const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId };
const agents = [
await createMockIdleAgent(seedClient, { ...target, title: "first" }),
await createMockIdleAgent(seedClient, { ...target, title: "second" }),
] as const;
await openWorkspaceWithAgents(page, [agents[0], agents[1]]);
const tabsBefore = await getWorkspaceTabTestIds(page);
await archiveFromRowButton(page, worktree.workspaceId);
// The archive has taken its agents down and now parks in teardown.
await teardown.expectStarted();
await undoButton(page).click();
await workspaceRow(page, worktree.workspaceId).click();
for (const agent of agents) {
await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible();
}
teardown.release();
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore);
} finally {
teardown.release();
await seedClient.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-none");
await pressUndoShortcut(page);
await expect(undoButton(page)).toHaveCount(0);
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
});
});
@@ -509,8 +509,6 @@ check_autocomplete() {
fi
fi
ad wait 'id="composer-autocomplete-popover"' 10000
# Rows replace the popover's loading row once the host lists them; a cold Claude list is slow.
ad wait "text=\"${expected}\"" 30000
# The popover remeasures once keyboard motion settles.
sleep 1
capture_screen "${prefix}.png"
@@ -69,23 +69,17 @@ async function waitFor(name, predicate, timeoutMs = 10_000) {
}
}
// Keyboard motion has settled once two snapshots agree on the composer position. The
// popover shows a loading row until the host lists its commands; a cold Claude list is slow.
// Keyboard motion has settled once two snapshots agree on the composer position.
async function settledPlacement(name, keyboard) {
let previousTop;
return waitFor(
name,
(snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (nodeById(snap, "composer-autocomplete-loading")) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
},
30_000,
);
return waitFor(name, (snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
});
}
function assertPlacement(name, snap, expected) {
@@ -99,7 +99,6 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
const observedElements = new WeakSet<Element>();
const resizeObserver = new ResizeObserver(record);
const mutationObserver = new MutationObserver(discover);
let performanceObserver: PerformanceObserver | undefined;
function record(): void {
if (!observation.active) return;
@@ -133,7 +132,6 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (!observation.active) {
mutationObserver.disconnect();
resizeObserver.disconnect();
performanceObserver?.disconnect();
return;
}
record();
@@ -143,16 +141,9 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (typeof PerformanceObserver !== "undefined") {
try {
performanceObserver = new PerformanceObserver((list) => {
const panel = document.querySelector(selectors.panel);
const performanceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node && panel?.contains(source.node))) {
observation.layoutShift += shift.value;
}
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
}
});
performanceObserver.observe({ type: "layout-shift", buffered: false });
@@ -183,54 +174,7 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
).toBeLessThanOrEqual(1);
}
}
expect(
observation.layoutShift,
`command center layoutShift: ${JSON.stringify(observation)}`,
).toBeLessThanOrEqual(0.001);
}
/** Exercise an unrelated page shift while the real search panel is being observed. */
export async function shiftOutsideCommandCenter(page: Page): Promise<void> {
await shiftLayoutProbe(page.locator("body"));
}
export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise<void> {
await startCommandCenterLayoutObservation(page);
await shiftLayoutProbe(page.getByTestId("command-center-panel"));
await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift");
}
async function shiftLayoutProbe(parent: Locator): Promise<void> {
const recordedShift = await parent.evaluate(async (container) => {
const sibling = document.createElement("div");
sibling.textContent = "Layout probe";
sibling.style.cssText =
"position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999";
container.append(sibling);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const value = await new Promise<number>((resolve) => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node === sibling)) {
observer.disconnect();
resolve(shift.value);
}
}
});
observer.observe({ type: "layout-shift", buffered: false });
sibling.style.left = "400px";
});
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
sibling.remove();
return value;
});
expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001);
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
}
export async function expectOneLineTruncatedFileResult(
@@ -53,192 +53,3 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
}
/** What the folded model control's labels show, read from the DOM. */
export interface ModelTriggerLabelState {
thinkingShown: boolean;
modelShown: boolean;
modelText: string;
modelTruncated: boolean;
}
declare global {
interface Window {
__modelTriggerFrames?: ModelTriggerLabelState[];
}
}
function readModelTriggerLabelStateInPage(): ModelTriggerLabelState {
const visible = (node: Element) => node.getClientRects().length > 0;
const model = Array.from(
document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'),
).find(visible);
const thinking = Array.from(
document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'),
).find(visible);
const shown = (node: Element | null): boolean => {
for (let current = node; current; current = current.parentElement) {
if (Number(getComputedStyle(current).opacity) === 0) return false;
}
return true;
};
const modelShown = model !== undefined && shown(model);
const modelText = model?.textContent ?? "";
return {
modelShown,
modelText,
thinkingShown: modelShown && thinking !== undefined,
modelTruncated:
model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth),
};
}
/** Records the folded model control's labels on every painted frame from page load. */
export async function recordModelTriggerFramesFromLoad(page: Page): Promise<void> {
await page.addInitScript(`(() => {
const read = ${readModelTriggerLabelStateInPage.toString()};
const frames = [];
window.__modelTriggerFrames = frames;
const tick = () => {
frames.push(read());
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
}
/** The model control's labels once two reads 100ms apart agree. */
export async function readSettledModelTriggerLabels(page: Page): Promise<ModelTriggerLabelState> {
let previous = await page.evaluate(readModelTriggerLabelStateInPage);
for (;;) {
await page.waitForTimeout(100);
const next = await page.evaluate(readModelTriggerLabelStateInPage);
if (JSON.stringify(next) === JSON.stringify(previous)) return next;
previous = next;
}
}
/** The labels at the widths where the folded model control changes what it shows. */
export interface ModelTriggerLabelSweep {
/** The labels at the widest width. */
widest: ModelTriggerLabelState & { width: number };
/** The first width at which the thinking label is hidden. */
thinkingHidden: ModelTriggerLabelState & { width: number };
/** The first width at which the model label is truncated. */
modelTruncated: ModelTriggerLabelState & { width: number };
}
/**
* Narrows the viewport step by step and records where the thinking label disappears and where the
* model label first truncates.
*/
export async function sweepModelTriggerLabels(
page: Page,
widths: { from: number; to: number; step: number; height: number },
): Promise<ModelTriggerLabelSweep> {
const steps: (ModelTriggerLabelState & { width: number })[] = [];
for (let width = widths.from; width >= widths.to; width -= widths.step) {
await page.setViewportSize({ width, height: widths.height });
const labels = await readSettledModelTriggerLabels(page);
steps.push({ ...labels, width });
if (labels.modelTruncated || !labels.modelShown) break;
}
const thinkingHidden = steps.find((step) => !step.thinkingShown);
const modelTruncated = steps.find((step) => step.modelTruncated);
if (!thinkingHidden || !modelTruncated) {
throw new Error(`the sweep never hid the thinking label and truncated the model label`);
}
return { widest: steps[0], thinkingHidden, modelTruncated };
}
/** No frame recorded so far painted a truncated model label beside the thinking label. */
export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise<void> {
const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []);
expect(frames.some((frame) => frame.modelShown)).toBe(true);
const wrong = frames
.map((frame, index) => ({ ...frame, index }))
.filter((frame) => frame.thinkingShown && frame.modelTruncated);
expect(wrong, `of ${frames.length} frames`).toEqual([]);
}
/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */
export interface ComposerToolbarRails {
/** Where the text column starts. */
textLeft: number;
/** Where the toolbar's leftmost ink starts. */
inkLeft: number;
/** How far the trailing disc sits from the composer's right edge. */
discRightInset: number;
/** How far the trailing disc sits from the composer's bottom edge. */
discBottomInset: number;
}
/**
* Reads where the toolbar's ink starts against the text column and where the trailing disc sits
* in the composer's corner. A control's ink is its filled disc when it has one, otherwise its
* glyph's stroked geometry.
*/
export async function readComposerToolbarRails(page: Page): Promise<ComposerToolbarRails> {
const root = page.locator('[data-testid="message-input-root"]:visible').first();
return root.evaluate((element) => {
const textarea = element.querySelector("textarea");
if (!textarea) throw new Error("composer has no text input");
const composerRect = element.getBoundingClientRect();
const textLeft =
textarea.getBoundingClientRect().left +
Number.parseFloat(getComputedStyle(textarea).paddingLeft);
const isFilled = (node: Element) => {
const color = getComputedStyle(node).backgroundColor;
return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
};
const glyphInkLeft = (svg: SVGSVGElement) => {
let left = Number.POSITIVE_INFINITY;
for (const shape of Array.from(
svg.querySelectorAll<SVGGraphicsElement>("path, circle, rect, line, polyline"),
)) {
const matrix = shape.getScreenCTM();
if (!matrix) continue;
const box = shape.getBBox();
const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0");
left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e);
}
return left;
};
const band = composerRect.bottom - 64;
let inkLeft = Number.POSITIVE_INFINITY;
let trailingDisc: DOMRect | null = null;
for (const control of Array.from(element.querySelectorAll('[role="button"]'))) {
const rect = control.getBoundingClientRect();
if (rect.width === 0 || rect.top < band) continue;
const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled);
const svg = control.querySelector("svg");
if (disc) {
const discRect = disc.getBoundingClientRect();
inkLeft = Math.min(inkLeft, discRect.left);
if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect;
} else if (svg) {
inkLeft = Math.min(inkLeft, glyphInkLeft(svg));
}
}
if (!trailingDisc) throw new Error("composer toolbar has no trailing disc");
return {
textLeft,
inkLeft,
discRightInset: composerRect.right - trailingDisc.right,
discBottomInset: composerRect.bottom - trailingDisc.bottom,
};
});
}
/**
* The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the
* corner, as far from the right edge as from the bottom edge. Within half a pixel.
*/
export async function expectComposerToolbarOnTextRails(page: Page): Promise<void> {
const rails = await readComposerToolbarRails(page);
// A precision of 0 digits passes within half a pixel.
expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0);
expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0);
}
@@ -148,59 +148,3 @@ export async function expectFullscreenDiagram(
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
}
/** Tall diagrams stay within the screen so their controls remain reachable. */
export async function expectInlineDiagramFitsViewport(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const box = await viewport.boundingBox();
expect(box, "the inline diagram has measurable layout").not.toBeNull();
const screenHeight = page.viewportSize()!.height;
expect(box!.height, "the inline diagram fits within the screen").toBeLessThan(screenHeight);
const contentHeight = await diagramSvg(page, "mermaid-viewport").evaluate(
(svg) => (svg as SVGSVGElement).viewBox.baseVal.height,
);
expect(box!.height, "a tall diagram is bounded rather than expanding the chat").toBeLessThan(
contentHeight,
);
}
/** Exercise pointer toggles and the controls encountered along that same journey. */
export async function toggleDiagramSourceAndVerifyControls(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const iframe = await viewport.locator("iframe").elementHandle();
for (let iteration = 0; iteration < 3; iteration++) {
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const viewDiagram = page.getByRole("button", { name: "View diagram" }).last();
await viewDiagram.click({ timeout: 10_000 });
await expect(viewDiagram).toHaveCount(0);
await expectFullscreenDiagramClosed(page);
expect(
await viewport.locator("iframe").evaluate((node, original) => node === original, iframe),
"switching views preserves the rendered diagram",
).toBe(true);
}
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const hiddenFullscreen = viewport.getByRole("button", {
name: "Fullscreen",
includeHidden: true,
});
await hiddenFullscreen.focus();
await expect(hiddenFullscreen).not.toBeFocused();
await page.getByRole("button", { name: "View diagram" }).last().click();
await viewport.hover();
const fullscreen = viewport.getByRole("button", { name: "Fullscreen", exact: true });
const box = await viewport.boundingBox();
const control = await fullscreen.boundingBox();
expect(box).not.toBeNull();
expect(control).not.toBeNull();
expect(control!.y, "the toolbar starts inside the preview").toBeGreaterThanOrEqual(box!.y);
expect(control!.y + control!.height, "the toolbar fits inside the preview").toBeLessThanOrEqual(
box!.y + box!.height,
);
await viewport.getByRole("button", { name: "Zoom in" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeEnabled();
await viewport.getByRole("button", { name: "Reset view" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeDisabled();
}
@@ -0,0 +1,59 @@
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
const PLUGIN_ID = "composer-controls-example";
const NARROW = { width: 500, height: 800 };
/** Runs `run` in an agent's composer with the composer-controls example installed. */
export async function withComposerControlsExample(
page: Page,
run: (composer: {
narrowTheWindow(): Promise<void>;
openAgentFeatures(): Promise<void>;
expectFeatureRow(name: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({
repoPrefix: "composer-items-",
title: "Composer items",
});
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Composer items agent",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("composer-controls");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await gotoAppShell(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await run({
narrowTheWindow: () =>
test.step("narrow the window until the composer folds its controls away", () =>
page.setViewportSize(NARROW)),
openAgentFeatures: () =>
test.step("open the agent features sheet", async () => {
await page.getByRole("button", { name: "Open agent features", exact: true }).click();
}),
expectFeatureRow: (name) =>
test.step(`the sheet has a row for "${name}"`, () =>
expect(page.getByRole("button", { name, exact: true })).toBeVisible()),
});
} finally {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
@@ -0,0 +1,208 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { openCommandCenter } from "./command-center";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
import { getServerId } from "./server-id";
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
const WIDE = { width: 1440, height: 900 };
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
type Workspace = Awaited<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 };
@@ -0,0 +1,228 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import {
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected,
openNewWorkspaceComposer,
openProjectViaDaemon,
submitNewWorkspacePrompt,
} from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { getServerId } from "./server-id";
import { createTempGitRepo } from "./workspace";
import { waitForSidebarHydration } from "./workspace-ui";
/** Presses Create, typing the prompt first unless the test wants an empty one. */
async function submitCreate(page: Page, prompt: string | undefined) {
if (prompt !== "") return submitNewWorkspacePrompt(page, prompt);
await page.getByTestId("message-input-root").getByRole("button", { name: "Create" }).click();
}
/** An item that sets the branch and metadata from its mount effect, before the user touches anything. */
async function replaceWithMountEffectItem(directory: string) {
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useEffect } from "react";
import { useNewWorkspace } from "@getpaseo/plugin/client";
import { Action } from "@getpaseo/plugin/client/ui";
function OnMount() {
const newWorkspace = useNewWorkspace();
useEffect(() => {
void newWorkspace.setIsolation("worktree");
void newWorkspace.setBranch("set-on-mount");
void newWorkspace.setMetadata({ "new-workspace-issues.issue": "ON-MOUNT" });
}, []);
return <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();
}
}
@@ -134,7 +134,7 @@ export default function contribute(client) {
}`;
}
export async function withDirectoryPlugin(options: {
async function withDirectoryPlugin(options: {
pluginId: string;
source: string;
run: () => Promise<void>;
@@ -157,11 +157,8 @@ export interface SeedDaemonClient {
sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string; activeTurnBehavior?: "interrupt" | "steer" | "queue" },
options?: { sourceAgentId?: string },
): Promise<void>;
listAgentBackgroundWork(
agentId: string,
): Promise<import("@getpaseo/client").PaseoAgentBackgroundWorkItem[]>;
waitForFinish(
agentId: string,
timeout?: number,
@@ -1,97 +0,0 @@
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect } from "../fixtures";
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
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";
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
const UNDO_WINDOW_MS = 6000;
export function workspaceRow(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
export function rowArchiveButton(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-archive-button-${getServerId()}:${workspaceId}`);
}
export function rowKebab(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`);
}
export function undoButton(page: Page) {
return page.getByRole("button", { name: /^Undo/ });
}
export async function workspaceTitleWidth(page: Page, workspaceId: string): Promise<number> {
const box = await page
.getByTestId(`sidebar-workspace-title-${getServerId()}:${workspaceId}`)
.boundingBox();
if (!box) throw new Error(`Title of ${workspaceId} is not laid out`);
return box.width;
}
/** Archives a workspace with its row's hover button and waits for the undo toast. */
export async function archiveFromRowButton(page: Page, workspaceId: string): Promise<void> {
page.once("dialog", (dialog) => void dialog.accept());
await workspaceRow(page, workspaceId).hover();
await rowArchiveButton(page, workspaceId).click();
await expectWorkspaceAbsentFromSidebar(page, workspaceId);
await expect(undoButton(page)).toBeVisible();
}
export async function pressUndoShortcut(page: Page): Promise<void> {
await page.keyboard.press(UNDO_SHORTCUT);
}
/** Moves the page clock past the moment an unheld archived toast would have closed. */
export async function outlastUndoWindow(page: Page): Promise<void> {
await page.clock.fastForward(UNDO_WINDOW_MS + 1000);
}
/** Waits until the host lists the workspace as active with no archive running on it. */
export async function expectHostWorkspaceSettledActive(
client: DaemonClient,
workspaceId: string,
): Promise<void> {
await expect
.poll(
async () => {
const { entries } = await client.fetchWorkspaces();
const entry = entries.find((candidate) => candidate.id === workspaceId);
return entry ? { active: true, archiving: Boolean(entry.archivingAt) } : null;
},
{ timeout: 30_000 },
)
.toEqual({ active: true, archiving: false });
}
export async function expectDirectoryPresence(directory: string, present: boolean): Promise<void> {
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
}
export interface GatedTeardown {
/** The `paseo.json` config whose worktree teardown parks until released. */
paseoConfig: Record<string, unknown>;
expectStarted(): Promise<void>;
release(): void;
}
/** A worktree teardown that parks an archive mid-flight, so an undo lands while it runs. */
export function createGatedTeardown(): GatedTeardown {
const gateDir = mkdtempSync(path.join(tmpdir(), "archive-undo-gate-"));
const startedPath = path.join(gateDir, "started");
const releasePath = path.join(gateDir, "release");
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${startedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
return {
paseoConfig: { worktree: { teardown: [teardown] } },
expectStarted: () => expectDirectoryPresence(startedPath, true),
release: () => writeFileSync(releasePath, "release\n"),
};
}
@@ -54,28 +54,6 @@ using namespace facebook::react;
[[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self];
}
// iOS 26 blurs a scroll view's edges and places the blur in the scroll view's own coordinates,
// ignoring transforms. The keyboard lifts the chat with a translateY, so on iPhones the blur lands
// over the readable chat and stays until the next scroll (#7883). React Native hides it for
// inverted lists for the same reason (https://github.com/react/react-native/pull/58684). The app
// draws its own header fade, so every edge is hidden; hiding only top and bottom leaves the side
// effects, which also cover a transformed list on device.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0
if (@available(iOS 26.0, *)) {
UIScrollView *scrollView = self.scrollView;
scrollView.topEdgeEffect.hidden = YES;
scrollView.bottomEdgeEffect.hidden = YES;
scrollView.leftEdgeEffect.hidden = YES;
scrollView.rightEdgeEffect.hidden = YES;
}
#endif
}
return self;
}
- (void)mountingTransactionWillMount:(const MountingTransaction &)transaction
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"main": "index.ts",
"scripts": {
+2 -2
View File
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
providerOptions?: Record<string, unknown>;
}
export function normalizeAgentCommandsCwd(cwd: string): string {
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
"thinking",
draftConfig.thinkingOptionId ?? null,
"features",
draftConfig.featureValues ?? null,
draftConfig.providerOptions ?? null,
] as const;
}
@@ -1,5 +1,3 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useFetchQuery } from "@/data/query";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -77,7 +75,6 @@ export function useAgentCommandsQuery({
}),
);
const queryClient = useQueryClient();
const query = useFetchQuery({
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
queryFn: async () => {
@@ -104,20 +101,10 @@ export function useAgentCommandsQuery({
// isLoading is true when fetching and no data yet
const isLoading = query.isPending || query.isLoading;
// Reset rather than refetch: a refetch keeps the error status while it runs, and the menu
// shows loading for the retry.
const retry = useCallback(() => {
void queryClient.resetQueries({
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
exact: true,
});
}, [agentId, draftConfig, queryClient, serverId]);
return {
commands: query.data ?? [],
isLoading,
isError: query.isError && query.data === undefined,
error: query.error,
retry,
};
}
@@ -0,0 +1,189 @@
import { describe, expect, it } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import { isControlActive } from "./option-controls";
import {
draftControlsRequest,
openDraftOptions,
ownDraftOptions,
viewDraftOptions,
} from "./draft-options-model";
const SPEED: AgentOptionControl = {
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
};
const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" };
function answer(
provider: string,
requestKey: string,
options: Record<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,
);
});
});
@@ -0,0 +1,217 @@
import type {
AgentOptionControl,
AgentSessionConfig,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
import { withoutLostControls } from "./lost-controls";
/**
* The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers
* make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon
* (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias,
* default or legacy key is resolved here.
*
* - `seed`: options the draft started from (a profile, a forked agent).
* - `remembered`: the provider's last-used control values.
* - `edits`: toggles made in this draft.
*
* An edit of `null` removes the key from every layer, the way a patch does on a running agent, so
* the provider's default applies again.
*
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
* and the options in one step, so the provider change that follows does not clear them.
*/
export interface ResolvedDraftOptions {
provider: string;
/** Names the request that produced this answer; see `viewDraftOptions`. */
requestKey: string;
controls: AgentOptionControl[];
options: ProviderOptions;
}
export interface DraftOptionsState {
provider: string | null;
seed: ProviderOptions;
remembered: ProviderOptions;
edits: ProviderOptions;
resolved: ResolvedDraftOptions | null;
/** Every key an answer for this provider has drawn as a control. */
drawn: ReadonlySet<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;
}
@@ -0,0 +1,16 @@
import { Settings2 } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { getAgentFeatureIcon } from "./icons";
describe("getAgentFeatureIcon", () => {
it("draws the Lucide icon a plugin provider names", () => {
const icon = getAgentFeatureIcon("Mic");
expect(icon).not.toBe(Settings2);
expect(getAgentFeatureIcon("Mic")).toBe(icon);
});
it("falls back to the generic icon for a missing or unknown name", () => {
expect(getAgentFeatureIcon()).toBe(Settings2);
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
});
});
+5 -2
View File
@@ -13,12 +13,12 @@ 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 {
size: number;
color: string;
strokeWidth?: number;
}
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
@@ -61,6 +61,9 @@ export function getAgentModeOptionIcon(
return icon ? MODE_ICONS[icon] : undefined;
}
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
if (!icon) return Settings2;
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
}
@@ -0,0 +1,17 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
/**
* The options that still apply after an answer from the daemon. A key an earlier answer drew as a
* control and the latest answer does not (the model changed to one without that control) is
* dropped, and applies again if a control for it returns. A key no answer has drawn is an option
* without a control and always applies.
*/
export function withoutLostControls(
options: ProviderOptions,
drawn: ReadonlySet<string>,
current: ReadonlySet<string>,
): ProviderOptions {
return Object.fromEntries(
Object.entries(options).filter(([key]) => !drawn.has(key) || current.has(key)),
);
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
} from "./option-controls";
const speed: AgentOptionControl = {
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
};
const fast: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
describe("option controls", () => {
it("reads a select from options and falls back to the control's default", () => {
if (speed.type !== "select") throw new Error("select expected");
expect(readSelectOption(speed, { service_tier: "priority" })).toBe("priority");
expect(readSelectOption(speed, {})).toBe("default");
expect(readSelectOption(speed, { service_tier: "unknown-tier" })).toBe("default");
});
it("reads a toggle from options and is off when unset", () => {
if (fast.type !== "toggle") throw new Error("toggle expected");
expect(readToggleOption(fast, { fast_mode: true })).toBe(true);
expect(readToggleOption(fast, {})).toBe(false);
});
it("is active when the option differs from the control's default", () => {
expect(isControlActive(speed, { service_tier: "priority" })).toBe(true);
expect(isControlActive(speed, { service_tier: "default" })).toBe(false);
expect(isControlActive(speed, {})).toBe(false);
expect(isControlActive(fast, { fast_mode: true })).toBe(true);
expect(isControlActive(fast, {})).toBe(false);
});
it("prefers a control's tooltip over its label", () => {
expect(getControlTooltip({ label: "Plan", tooltip: "Toggle plan mode" })).toBe(
"Toggle plan mode",
);
expect(getControlTooltip({ label: "Custom" })).toBe("Custom");
});
});
@@ -0,0 +1,47 @@
import type {
AgentOptionControl,
AgentOptionSelectControl,
AgentOptionToggleControl,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
/**
* The composer's option controls. A control only describes how to draw one option key; the
* value is read from `options`, which the daemon resolved (defaults, aliases, what runs).
*/
export interface ComposerOptionControls {
controls: AgentOptionControl[];
options: ProviderOptions;
onSetOption: (key: string, value: unknown) => void;
}
export function readToggleOption(
control: AgentOptionToggleControl,
options: ProviderOptions,
): boolean {
const value = options[control.key];
return typeof value === "boolean" ? value : (control.defaultValue ?? false);
}
/** The selected choice's value, or the control's default when the option names no choice. */
export function readSelectOption(
control: AgentOptionSelectControl,
options: ProviderOptions,
): string | null {
const value = options[control.key];
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
return value;
}
return control.defaultValue ?? null;
}
/** Whether the option is set to something other than the control's default. */
export function isControlActive(control: AgentOptionControl, options: ProviderOptions): boolean {
if (control.type === "toggle") return readToggleOption(control, options);
const value = readSelectOption(control, options);
return value !== null && value !== control.defaultValue;
}
export function getControlTooltip(control: Pick<AgentOptionControl, "label" | "tooltip">): string {
return control.tooltip ?? control.label;
}
+9 -19
View File
@@ -1,6 +1,4 @@
import { useShallow } from "zustand/shallow";
import { useTranslation } from "react-i18next";
import { i18n } from "@/i18n/i18next";
import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
@@ -35,7 +33,6 @@ export function useAgentDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
useTranslation();
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
@@ -45,7 +42,6 @@ export function useAgentDescriptor(
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
status: agent?.status ?? null,
backgroundWork: agent?.backgroundWork,
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
@@ -61,7 +57,6 @@ export function buildAgentDescriptor(input: {
provider: Agent["provider"];
title: string | null;
status: Agent["status"] | null;
backgroundWork?: Agent["backgroundWork"];
pendingPermissionCount: number;
requiresAttention: boolean;
attentionReason: Agent["attentionReason"];
@@ -70,25 +65,20 @@ export function buildAgentDescriptor(input: {
}): PanelDescriptor {
const provider = input.provider;
const label = resolveAgentLabel(input.title);
const statusBucket = input.status
? deriveSidebarStateBucket({
status: input.isTurnActive ? "running" : input.status,
backgroundWork: input.backgroundWork,
pendingPermissionCount: input.pendingPermissionCount,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
})
: null;
return {
label: label ?? "",
subtitle:
statusBucket === "running" && !input.isTurnActive && input.status !== "running"
? i18n.t("message.backgroundWork.running")
: `${formatProviderLabel(provider)} agent`,
subtitle: `${formatProviderLabel(provider)} agent`,
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: input.icon,
statusBucket,
statusBucket: input.status
? deriveSidebarStateBucket({
status: input.isTurnActive ? "running" : input.status,
pendingPermissionCount: input.pendingPermissionCount,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
})
: null,
};
}
@@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
options: { web_search: true },
}),
),
).toEqual({
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
options: { web_search: true },
});
});
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
modelId: "",
modeId: "",
thinkingOptionId: "",
featureValues: {},
options: {},
});
});
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
options: { web_search: true },
}),
),
),
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
options: { web_search: true },
});
});
@@ -81,8 +81,8 @@ describe("toAgentConfigApply", () => {
it("omits an empty feature map so the agent keeps its own feature values", () => {
expect(
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
).not.toHaveProperty("featureValues");
toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))),
).not.toHaveProperty("options");
});
it("never carries a provider, which a running agent cannot change", () => {
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
modelId: string;
modeId: string;
thinkingOptionId: string;
featureValues: Record<string, unknown>;
options: 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),
featureValues: profile.featureValues ?? {},
options: profile.options ?? {},
};
}
@@ -52,8 +52,6 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf
...(profile.modelId ? { modelId: profile.modelId } : {}),
...(profile.modeId ? { modeId: profile.modeId } : {}),
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
...(Object.keys(profile.featureValues).length > 0
? { featureValues: profile.featureValues }
: {}),
...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}),
};
}
@@ -1,7 +1,7 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import {
buildFeatureRequestKey,
buildControlsRequestKey,
openAgentProfileForm,
type AgentProfileFormModel,
} from "./profile-form-model";
@@ -70,6 +70,21 @@ function optionValues(options: readonly { value: string }[]): string[] {
return options.map((option) => option.value);
}
const FAST = {
controls: [{ type: "toggle" as const, key: "fast_mode", label: "Fast" }],
options: { fast_mode: false },
};
const NO_CONTROLS = { controls: [], options: {} };
function toggledOnOpus(): AgentProfileFormModel {
const model = openWithCatalog({ mode: "create" });
model.setName("Fast");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
model.setOption("fast_mode", true);
return model;
}
describe("openAgentProfileForm", () => {
it("starts a create form empty and cannot submit", () => {
const model = openWithCatalog({ mode: "create" });
@@ -173,7 +188,7 @@ describe("openAgentProfileForm", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
options: { webSearch: true },
notes: "Visual work only.",
},
});
@@ -229,10 +244,11 @@ describe("openAgentProfileForm", () => {
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" });
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setFeatureValue("webSearch", true);
model.applyControls(model.getState().controlsRequestKey ?? "", {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
model.setOption("webSearch", true);
model.setProvider("codex", { label: "Codex" });
const state = model.getState();
@@ -242,8 +258,9 @@ describe("openAgentProfileForm", () => {
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
expect(state.thinkingOptionId).toBe("");
expect(state.thinkingDisplay).toBeNull();
expect(state.featureValues).toEqual({});
expect(state.features).toEqual([]);
expect(state.options).toEqual({});
expect(state.optionEdits).toEqual({});
expect(state.controls).toEqual([]);
expect(state.name).toBe("Test writer");
});
@@ -334,23 +351,28 @@ describe("openAgentProfileForm", () => {
});
});
describe("features", () => {
it("asks for features per provider/model/mode/thinking combination", () => {
const WEB_SEARCH_CONTROLS = {
controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }],
options: { webSearch: false },
};
describe("controls", () => {
it("asks for controls per provider/model/mode/thinking combination", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().featureRequest).toEqual({
expect(model.getState().controlsRequest).toEqual({
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think",
});
const firstKey = model.getState().featureRequestKey;
const firstKey = model.getState().controlsRequestKey;
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().featureRequestKey).not.toBe(firstKey);
expect(model.getState().featureRequest).toEqual({
expect(model.getState().controlsRequestKey).not.toBe(firstKey);
expect(model.getState().controlsRequest).toEqual({
provider: "claude",
model: "claude-haiku-4-5",
modeId: "plan",
@@ -361,77 +383,233 @@ describe("openAgentProfileForm", () => {
it("moves from pending to complete only for the current request", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
const key = model.getState().featureRequestKey ?? "";
expect(model.getState().featureResolution).toBe("pending");
const key = model.getState().controlsRequestKey ?? "";
expect(model.getState().controlsResolution).toBe("pending");
model.applyFeatures("stale-key", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().features).toEqual([]);
model.applyControls("stale-key", {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
expect(model.getState().controlsResolution).toBe("pending");
expect(model.getState().controls).toEqual([]);
model.applyFeatures(key, [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(true);
model.applyControls(key, {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().disclosure.showControlsField).toBe(true);
});
it("reflects a set feature value back into the rendered feature", () => {
it("reflects a set option back into the rendered controls", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("Searcher");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setOption("webSearch", true);
expect(model.getState().controls[0]).toMatchObject({ key: "webSearch" });
expect(model.getState().controlOptions).toEqual({ webSearch: true });
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
});
it("keeps stored options while the controls are still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "UI work", provider: "claude", options: { webSearch: true } },
});
model.applyProviderCatalog(ENTRIES);
expect(model.getState().controlsResolution).toBe("pending");
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
});
it("keeps options no control draws, so editing a profile does not drop them", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
options: { webSearch: true, sandbox_mode: "read-only" },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setOption("webSearch", false);
expect(model.getState().submitValue?.options).toEqual({
webSearch: false,
sandbox_mode: "read-only",
});
});
it("draws the options the daemon resolved from the stored ones, then the edits", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "Fast", provider: "codex", options: { service_tier: "fast" } },
});
model.applyProviderCatalog(ENTRIES);
// The request carries the stored options so the daemon can resolve the legacy tier.
expect(model.getState().controlsRequest?.providerOptions).toEqual({ service_tier: "fast" });
model.applyControls(model.getState().controlsRequestKey ?? "", {
controls: [
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
},
],
options: { service_tier: "priority" },
});
expect(model.getState().controlOptions).toEqual({ service_tier: "priority" });
// Nothing was edited, so the stored value is saved as it was.
expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" });
});
it("sends an edit to the daemon with the stored options, and draws the answer", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "Fast",
provider: "codex",
options: { service_tier: "fast", sandbox_mode: "read-only" },
},
});
model.applyProviderCatalog(ENTRIES);
const speed = {
controls: [
{
type: "select" as const,
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
},
],
options: { service_tier: "priority", sandbox_mode: "read-only" },
};
model.applyControls(model.getState().controlsRequestKey ?? "", speed);
model.setOption("service_tier", "default");
// The controls stay up while the daemon answers, with the toggle laid over the last answer.
expect(model.getState().controls).toHaveLength(1);
expect(model.getState().controlOptions).toMatchObject({ service_tier: "default" });
// The request carries everything the profile would save, so the daemon resolves it.
expect(model.getState().controlsRequest?.providerOptions).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
expect(model.getState().controlsResolution).toBe("pending");
model.applyControls(model.getState().controlsRequestKey ?? "", {
...speed,
options: { service_tier: "default", sandbox_mode: "read-only" },
});
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().controlOptions).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
});
it("draws no controls for the previous model while another model's answer is pending", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setFeatureValue("webSearch", true);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
expect(model.getState().featureValues).toEqual({ webSearch: true });
expect(model.getState().controls).toEqual([]);
});
it("keeps stored feature values while the feature list is still pending", () => {
it("does not save a toggle once the model that drew it is replaced by one without it", () => {
const model = toggledOnOpus();
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toBeUndefined();
expect(model.getState().controlsRequest?.providerOptions).toBeUndefined();
});
it("saves a toggle again when the model that draws it returns", () => {
const model = toggledOnOpus();
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
});
it("does not save a stored toggle profile either", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
name: "Fast",
provider: "claude",
featureValues: { webSearch: true },
model: "claude-opus-5",
options: { fast_mode: true },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toBeUndefined();
});
it("prunes stored values the provider no longer reports", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
featureValues: { webSearch: true, retiredFlag: true },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
it("never drops an option no control has drawn", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("Fast");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
model.setOption("token_budget", 8000);
model.setOption("fast_mode", true);
expect(model.getState().featureValues).toEqual({ webSearch: true });
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toEqual({ token_budget: 8000 });
});
it("keeps a toggle when the daemon cannot list controls", () => {
const model = toggledOnOpus();
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
});
it("resolves an unavailable feature listing without showing the field", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(false);
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().disclosure.showControlsField).toBe(false);
});
});
@@ -464,15 +642,15 @@ describe("openAgentProfileForm", () => {
});
});
describe("buildFeatureRequestKey", () => {
describe("buildControlsRequestKey", () => {
it("is null without a provider", () => {
expect(buildFeatureRequestKey(null)).toBeNull();
expect(buildControlsRequestKey(null)).toBeNull();
});
it("separates the fields so a value cannot bleed across positions", () => {
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildFeatureRequestKey({ provider: "claude", model: "a" }),
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildControlsRequestKey({ provider: "claude", model: "a" }),
);
});
});
@@ -1,13 +1,14 @@
import type {
AgentFeature,
AgentOptionControl,
ProviderOptions,
AgentMode,
AgentModelDefinition,
AgentSelectOption,
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { withoutLostControls } from "@/agent-controls/lost-controls";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
/**
@@ -57,11 +58,15 @@ export interface AgentProfileFormSnapshot {
}
/**
* The inputs a feature listing needs. Features are provider-scoped and change
* with model/mode/thinking, so every one of those is part of the request.
* The inputs a controls listing needs. Controls are provider-scoped and change
* with model/mode/thinking, so every one of those is part of the request. The
* profile's own options ride along so the daemon can resolve them (a legacy
* `service_tier: "fast"` is drawn as the model's Fast tier); they are the same
* for the life of one provider, so they are not part of the key.
*/
export interface AgentProfileFeatureRequest {
export interface AgentProfileControlsRequest {
provider: string;
providerOptions?: ProviderOptions;
model?: string;
modeId?: string;
thinkingOptionId?: string;
@@ -73,7 +78,7 @@ export interface AgentProfileFormDisclosure {
showModelField: boolean;
showModeField: boolean;
showThinkingField: boolean;
showFeaturesField: boolean;
showControlsField: boolean;
}
export interface AgentProfileFormState {
@@ -86,13 +91,22 @@ export interface AgentProfileFormState {
modelId: string;
modeId: string;
thinkingOptionId: string;
featureValues: Record<string, unknown>;
/** The options the profile was opened with, including ones no control draws. */
options: ProviderOptions;
/** Changes made in this form, over `options`. */
optionEdits: ProviderOptions;
providerOptions: AgentProfileFormOption[];
modelOptions: AgentProfileFormOption[];
modeOptions: AgentProfileFormOption[];
thinkingOptions: AgentProfileFormOption[];
features: AgentFeature[];
controls: AgentOptionControl[];
/** What the controls show: the options the daemon resolved from `options`, then the edits. */
controlOptions: ProviderOptions;
/** Every key an answer for this provider has drawn as a control. */
drawnControlKeys: ReadonlySet<string>;
/** The keys the latest answer draws as controls. */
answeredControlKeys: ReadonlySet<string>;
providerDisplay: AgentProfileFormDisplay | null;
modelDisplay: AgentProfileFormDisplay | null;
@@ -100,9 +114,9 @@ export interface AgentProfileFormState {
thinkingDisplay: AgentProfileFormDisplay | null;
catalogResolution: AgentProfileResolutionStatus;
featureResolution: AgentProfileResolutionStatus;
featureRequest: AgentProfileFeatureRequest | null;
featureRequestKey: string | null;
controlsResolution: AgentProfileResolutionStatus;
controlsRequest: AgentProfileControlsRequest | null;
controlsRequestKey: string | null;
disclosure: AgentProfileFormDisclosure;
isSubmitting: boolean;
@@ -118,9 +132,12 @@ export interface AgentProfileFormModel {
/** Late input: the host-scoped provider catalog. Never touches selections. */
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
/** Late input: the feature list for one request. Stale keys are ignored. */
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
applyControls: (
requestKey: string,
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
) => void;
/** Resolve a request that produced no usable features (provider error). */
applyFeaturesUnavailable: (requestKey: string) => void;
applyControlsUnavailable: (requestKey: string) => void;
setName: (value: string) => void;
setAppearance: (value: { icon: string; color: string }) => void;
setNotes: (value: string) => void;
@@ -128,7 +145,7 @@ export interface AgentProfileFormModel {
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
setFeatureValue: (featureId: string, value: unknown) => void;
setOption: (key: string, value: unknown) => void;
setSubmitting: (value: boolean) => void;
setSubmitError: (value: string | null) => void;
}
@@ -312,19 +329,39 @@ function refineDisplay(
return current ?? { label: selectedId };
}
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null {
if (!state.provider) {
return null;
}
const ownOptions = ownOptionsOf(state);
return {
provider: state.provider,
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(ownOptions).length > 0 ? { providerOptions: ownOptions } : {}),
};
}
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
/**
* Everything the profile would save as its options; the daemon resolves them for the form. A
* toggle the model in use does not draw is not saved (see `withoutLostControls`).
*/
function ownOptionsOf(
state: Pick<
AgentProfileFormState,
"options" | "optionEdits" | "drawnControlKeys" | "answeredControlKeys"
>,
): ProviderOptions {
return withoutLostControls(
{ ...state.options, ...state.optionEdits },
state.drawnControlKeys,
state.answeredControlKeys,
);
}
/** The part of a request that decides which controls exist; options only change their values. */
function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null {
if (!request) {
return null;
}
@@ -336,12 +373,25 @@ export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | nul
].join("|");
}
export function buildControlsRequestKey(
request: AgentProfileControlsRequest | null,
): string | null {
if (!request) {
return null;
}
const scope = buildControlsScopeKey(request);
return request.providerOptions
? `${scope}|${JSON.stringify(Object.entries(request.providerOptions).sort(([a], [b]) => a.localeCompare(b)))}`
: scope;
}
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
const name = state.name.trim();
const notes = state.notes.trim();
if (!name || !state.provider) {
return null;
}
const options = ownOptionsOf(state);
return {
name,
...(state.icon ? { icon: state.icon } : {}),
@@ -350,16 +400,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
...(Object.keys(options).length > 0 ? { options } : {}),
...(notes ? { notes } : {}),
};
}
function resolveFeatureStatus(
featureRequestKey: string | null,
function resolveControlsStatus(
controlsRequestKey: string | null,
featuresAreCurrent: boolean,
): AgentProfileResolutionStatus {
if (featureRequestKey === null) {
if (controlsRequestKey === null) {
return "idle";
}
return featuresAreCurrent ? "complete" : "pending";
@@ -392,25 +442,29 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
modelId,
modeId: profile.modeId ?? "",
thinkingOptionId: profile.thinkingOptionId ?? "",
featureValues: { ...profile.featureValues },
options: { ...profile.options },
optionEdits: {},
providerOptions: [],
modelOptions: [],
modeOptions: [],
thinkingOptions: [],
features: [],
controls: [],
controlOptions: {},
drawnControlKeys: new Set(),
answeredControlKeys: new Set(),
providerDisplay: seedDisplay(provider),
modelDisplay: seedDisplay(modelId),
modeDisplay: seedDisplay(profile.modeId),
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
catalogResolution: "idle",
featureResolution: "idle",
featureRequest: null,
featureRequestKey: null,
controlsResolution: "idle",
controlsRequest: null,
controlsRequestKey: null,
disclosure: {
showModelField: false,
showModeField: false,
showThinkingField: false,
showFeaturesField: false,
showControlsField: false,
},
isSubmitting: false,
submitError: null,
@@ -422,28 +476,45 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
let entries: readonly ProviderSnapshotEntry[] = [];
let catalogResolution: AgentProfileResolutionStatus = "idle";
let resolvedFeatureKey: string | null = null;
let resolvedFeatures: AgentFeature[] = [];
let resolvedControlsKey: string | null = null;
let resolvedScopeKey: string | null = null;
let resolvedControls: AgentOptionControl[] = [];
let resolvedOptions: ProviderOptions = {};
let drawnKeys: ReadonlySet<string> = new Set();
let answeredKeys: ReadonlySet<string> = new Set();
let listeners = new Set<() => void>();
let closed = false;
function derive(incoming: AgentProfileFormState): AgentProfileFormState {
const models = resolveModels(entries, incoming.provider);
const modes = resolveModes(entries, incoming.provider);
const next = seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
});
const next: AgentProfileFormState = {
...seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
}),
drawnControlKeys: drawnKeys,
answeredControlKeys: answeredKeys,
};
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
const featureRequest = buildFeatureRequest(next);
const featureRequestKey = buildFeatureRequestKey(featureRequest);
const controlsRequest = buildControlsRequest(next);
const controlsRequestKey = buildControlsRequestKey(controlsRequest);
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
const featuresAreCurrent =
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
const features = featuresAreCurrent
? applyFeatureValues(resolvedFeatures, next.featureValues)
: [];
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey;
// A toggle changes only the options, so the controls stay up while the daemon answers and the
// toggle shows over the last answer. A change of provider, model, mode or thinking draws none
// until its own answer arrives.
const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey;
const controls = controlsApply ? resolvedControls : [];
let controlOptions: ProviderOptions = next.options;
if (controlsApply) {
controlOptions = featuresAreCurrent
? resolvedOptions
: { ...resolvedOptions, ...next.optionEdits };
}
const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent);
const withOptions: AgentProfileFormState = {
...next,
@@ -451,7 +522,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modelOptions: buildModelOptions(models),
modeOptions: buildModeOptions(modes),
thinkingOptions: buildThinkingOptions(thinking),
features,
controls,
controlOptions,
providerDisplay: refineDisplay(
next.providerDisplay,
next.provider,
@@ -479,9 +551,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
})(),
),
catalogResolution,
featureResolution,
featureRequest,
featureRequestKey,
controlsResolution,
controlsRequest,
controlsRequestKey,
};
// A field appears once the catalog can populate it, and stays visible while
@@ -492,7 +564,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
showThinkingField:
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
showFeaturesField: hasProvider && features.length > 0,
showControlsField: hasProvider && controls.length > 0,
};
const canSubmit =
withOptions.name.trim().length > 0 &&
@@ -531,23 +603,26 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
catalogResolution = "complete";
publish((current) => current);
},
applyFeatures: (requestKey, features) => {
if (requestKey !== state.featureRequestKey) {
applyControls: (requestKey, resolved) => {
if (requestKey !== state.controlsRequestKey) {
return;
}
resolvedFeatureKey = requestKey;
resolvedFeatures = [...features];
publish((current) => ({
...current,
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
}));
resolvedControlsKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
resolvedControls = [...resolved.controls];
resolvedOptions = resolved.options;
answeredKeys = new Set(resolved.controls.map((control) => control.key));
drawnKeys = new Set([...drawnKeys, ...answeredKeys]);
publish((current) => current);
},
applyFeaturesUnavailable: (requestKey) => {
if (requestKey !== state.featureRequestKey) {
applyControlsUnavailable: (requestKey) => {
if (requestKey !== state.controlsRequestKey) {
return;
}
resolvedFeatureKey = requestKey;
resolvedFeatures = [];
resolvedControlsKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
resolvedControls = [];
resolvedOptions = {};
publish((current) => current);
},
setName: (value) => publish((current) => ({ ...current, name: value })),
@@ -562,6 +637,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
// Everything below the provider is provider-scoped: a model id, mode id,
// thinking id or feature id from the old provider means nothing here.
// Clearing them is enough — `derive` seeds the new provider's defaults.
drawnKeys = new Set();
answeredKeys = new Set();
return {
...current,
provider: providerId,
@@ -572,7 +649,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modeDisplay: null,
thinkingOptionId: "",
thinkingDisplay: null,
featureValues: {},
options: {},
optionEdits: {},
};
}),
setModel: (modelId, display) =>
@@ -598,10 +676,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
publish((current) => ({ ...current, modeId, modeDisplay: display })),
setThinking: (thinkingOptionId, display) =>
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
setFeatureValue: (featureId, value) =>
setOption: (key, value) =>
publish((current) => ({
...current,
featureValues: { ...current.featureValues, [featureId]: value },
optionEdits: { ...current.optionEdits, [key]: value },
})),
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
@@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: {
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
}
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
const featureCount = Object.keys(input.profile.options ?? {}).length;
if (featureCount > 0) {
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
}
@@ -117,9 +117,7 @@ export function useAgentProfilePicker(
modelId: resolved.modelId,
modeId: resolved.modeId,
thinkingOptionId: resolved.thinkingOptionId,
...(Object.keys(resolved.featureValues).length > 0
? { featureValues: resolved.featureValues }
: {}),
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}),
}),
).catch((error) => {
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
}, [entries, input.model]);
}
export function useAgentProfileFormFeatures(input: {
export function useAgentProfileFormControls(input: {
serverId: string;
model: AgentProfileFormModel;
state: AgentProfileFormState;
@@ -35,14 +35,14 @@ export function useAgentProfileFormFeatures(input: {
const { t } = useTranslation();
const client = useHostRuntimeClient(input.serverId);
const isConnected = useHostRuntimeIsConnected(input.serverId);
const request = input.state.featureRequest;
const requestKey = input.state.featureRequestKey;
const request = input.state.controlsRequest;
const requestKey = input.state.controlsRequestKey;
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
// page's data while the next request is in flight, which would hand the
// features of one provider/model combination to the key of another.
const featuresQuery = useFetchQuery({
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
const controlsQuery = useFetchQuery({
queryKey: ["agentProfileControls", input.serverId, requestKey],
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(client && isConnected && request),
@@ -51,28 +51,28 @@ export function useAgentProfileFormFeatures(input: {
if (!client || !request) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD });
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
return { controls: payload.controls ?? [], options: payload.options ?? {} };
},
});
const { data, isError } = featuresQuery;
const { data, isError } = controlsQuery;
const { model } = input;
useEffect(() => {
if (!requestKey || !data) {
return;
}
model.applyFeatures(requestKey, data);
model.applyControls(requestKey, data);
}, [data, model, requestKey]);
useEffect(() => {
if (!requestKey || !isError) {
return;
}
model.applyFeaturesUnavailable(requestKey);
model.applyControlsUnavailable(requestKey);
}, [isError, model, requestKey]);
}
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement }
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -24,7 +25,7 @@ import type {
} from "../internal/profile-form-model";
import {
useAgentProfileFormCatalog,
useAgentProfileFormFeatures,
useAgentProfileFormControls,
} from "../internal/use-profile-form-inputs";
import {
useAgentProfileFormModel,
@@ -140,7 +141,7 @@ function OpenAgentProfileEditModal({
const model = useAgentProfileFormModel(snapshot);
const state = useAgentProfileFormState(model);
useAgentProfileFormCatalog({ serverId, model });
useAgentProfileFormFeatures({ serverId, model, state });
useAgentProfileFormControls({ serverId, model, state });
const sheetHeader = useMemo<SheetHeader>(
() => ({
@@ -327,16 +328,17 @@ function OpenAgentProfileEditModal({
/>
) : null}
{state.disclosure.showFeaturesField ? (
{state.disclosure.showControlsField ? (
<Field
label={t("settings.host.agentProfiles.featuresLabel")}
testID="agent-profile-features-field"
>
<View style={styles.featureCard} testID="agent-profile-features-card">
{state.features.map((feature, index) => (
<AgentProfileFeatureRow
key={feature.id}
feature={feature}
{state.controls.map((control, index) => (
<AgentProfileControlRow
key={control.key}
control={control}
options={state.controlOptions}
isFirst={index === 0}
disabled={state.isSubmitting}
size={controlSize}
@@ -399,14 +401,16 @@ function OpenAgentProfileEditModal({
);
}
function AgentProfileFeatureRow({
feature,
function AgentProfileControlRow({
control,
options,
isFirst,
disabled,
size,
model,
}: {
feature: AgentFeature;
control: AgentOptionControl;
options: ProviderOptions;
isFirst: boolean;
disabled: boolean;
size: ControlSize;
@@ -417,74 +421,75 @@ function AgentProfileFeatureRow({
[isFirst],
);
const handleToggle = useCallback(
(value: boolean) => model.setFeatureValue(feature.id, value),
[feature.id, model],
(value: boolean) => model.setOption(control.key, value),
[control.key, model],
);
const handleSelect = useCallback(
(value: string) => model.setFeatureValue(feature.id, value),
[feature.id, model],
(value: string) => model.setOption(control.key, value),
[control.key, model],
);
const selectOptions = useMemo<SelectFieldOption<string>[]>(
() =>
feature.type === "select"
? feature.options.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
control.type === "select"
? control.choices.map((choice) => ({
id: choice.value,
value: choice.value,
label: choice.label,
...(choice.description ? { description: choice.description } : {}),
testID: `agent-profile-feature-option-${control.key}-${choice.value}`,
}))
: [],
[feature],
[control],
);
const selectedValue = control.type === "select" ? readSelectOption(control, options) : null;
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
if (feature.type !== "select" || !feature.value) {
if (control.type !== "select" || !selectedValue) {
return null;
}
const option = feature.options.find((candidate) => candidate.id === feature.value);
return { label: option?.label ?? feature.value };
}, [feature]);
const choice = control.choices.find((candidate) => candidate.value === selectedValue);
return { label: choice?.label ?? selectedValue };
}, [control, selectedValue]);
if (feature.type === "select") {
if (control.type === "select") {
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
<SelectField
label={feature.label}
value={feature.value}
label={control.label}
value={selectedValue}
selectedDisplay={selectedDisplay}
options={selectOptions}
onChange={handleSelect}
placeholder={feature.label}
emptyText={feature.label}
placeholder={control.label}
emptyText={control.label}
disabled={disabled}
{...(feature.description ? { hint: feature.description } : {})}
title={feature.label}
{...(control.description ? { hint: control.description } : {})}
title={control.label}
size={size}
testID={`agent-profile-feature-field-${feature.id}`}
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
testID={`agent-profile-feature-field-${control.key}`}
triggerTestID={`agent-profile-feature-trigger-${control.key}`}
/>
</View>
);
}
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
<View style={styles.featureMeta}>
<Text style={styles.featureLabel} numberOfLines={1}>
{feature.label}
{control.label}
</Text>
{feature.description ? (
{control.description ? (
<Text style={styles.featureDescription} numberOfLines={2}>
{feature.description}
{control.description}
</Text>
) : null}
</View>
<Switch
value={feature.value === true}
value={readToggleOption(control, options)}
onValueChange={handleToggle}
disabled={disabled}
accessibilityLabel={feature.label}
testID={`agent-profile-feature-switch-${feature.id}`}
accessibilityLabel={control.label}
testID={`agent-profile-feature-switch-${control.key}`}
/>
</View>
);
@@ -65,6 +65,7 @@ interface BottomAnchorControllerDriver {
pendingVerification: AttemptContext | null;
blockedReason: BottomAnchorBlockedReason | null;
};
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
@@ -479,6 +480,17 @@ function createBottomAnchorControllerDriver(
blockedReason,
};
},
resetForAgent() {
lastRouteRequestKey = null;
pendingRequest = null;
blockedReason = null;
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
applyRouteRequest(request) {
if (!request) {
return;
@@ -729,6 +741,10 @@ export function useBottomAnchorController(input: {
});
}
useEffect(() => {
driverRef.current?.resetForAgent();
}, [input.agentId]);
useEffect(() => {
driverRef.current?.applyRouteRequest(input.routeRequest);
}, [input.routeRequest]);
@@ -129,8 +129,7 @@ function mountList(heldRowId: string) {
}
return position - state.scroll;
};
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
return { render, scrollTo, heldRowTop, rowSize };
return { render, scrollTo, heldRowTop };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
@@ -170,21 +169,3 @@ it("anchors a size change on a held row whose top is above the viewport", async
expect(list.heldRowTop()).toBe(-20);
});
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
// event, so a 0 recorded while hidden would stay and the next row would overlap it.
const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 }));
const list = mountList("row-0");
list.render(rows);
await wait(150);
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
host.style.display = "none";
await wait(150);
list.render(rows.map((row) => ({ ...row })));
await wait(150);
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
});
@@ -593,92 +593,6 @@ describe("timeline presentation", () => {
});
});
it("folds work before a plugin-rendered reply and leaves the reply visible", () => {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
];
const result = createStreamPresentation()({
...presentationOptions,
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
tail,
head: [],
display: "collapseTurns",
isTurnActive: false,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
});
it("keeps streaming replies native, then folds work without losing manual expansion", () => {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const work: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
toolCall("one", "Bash"),
];
const reply: StreamItem = {
kind: "assistant_message",
id: "reply",
text: "$$x^2$$",
timestamp,
};
const input = {
...presentationOptions,
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
display: "collapseTurns" as const,
};
const present = createStreamPresentation();
const live = present({ ...input, tail: work, head: [reply], isTurnActive: true });
expect(rows(live).map((item) => item.id)).toEqual(["prompt", "tool-one", "reply"]);
const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false };
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"probe-plugin/reply/0",
]);
const expandedTurnHostIds = new Set(["prompt"]);
const opened = present({ ...completed, expandedTurnHostIds });
expect(opened.tail.map((item) => item.id)).toEqual([
"prompt",
"tool-one",
"probe-plugin/reply/0",
]);
expect(opened.turns.nestedItemIds.has("probe-plugin/reply/0")).toBe(false);
expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail);
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"probe-plugin/reply/0",
]);
});
it.each(["full", "collapseToolCalls"] as const)(
"leaves plugin replies and individual work visible in %s",
(display) => {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
];
const result = createStreamPresentation()({
...presentationOptions,
transform: installedTransform(installProbe("assistant_message")),
tail,
head: [],
display,
isTurnActive: false,
});
expect(result.tail.map((item) => item.id)).toEqual([
"prompt",
"tool-one",
"probe-plugin/reply/0",
]);
expect(result.turns.turnsByHostId.size).toBe(0);
},
);
it("opening a turn reveals individual calls rather than another tool group", () => {
const present = createStreamPresentation();
const timestamp = new Date();
@@ -137,8 +137,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const listRef = useRef<LegendListRef>(null);
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
const [isListPlaced, setIsListPlaced] = useState(false);
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
const isListPlaced = placedAgentId === agentId;
const isListPlacedRef = useRef(isListPlaced);
isListPlacedRef.current = isListPlaced;
const streamViewportMetricsRef = useRef({
@@ -383,6 +384,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
);
useEffect(() => {
streamViewportMetricsRef.current = {
containerKey: "native-virtualized",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
disclosureHold.cancel();
historyStartReadyRef.current = false;
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -393,7 +411,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -605,7 +623,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const handleListPlaced = useStableEvent(() => {
isListPlacedRef.current = true;
setIsListPlaced(true);
setPlacedAgentId(agentId);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
contentMeasuredForKey: "native-virtualized",
@@ -693,6 +711,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
dataKey={agentId}
maintainVisibleContentPosition={maintainVisibleContentPosition}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
+10 -2
View File
@@ -839,6 +839,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
useEffect(() => {
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
readingAnchor.reset();
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -849,7 +853,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart]);
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -1391,7 +1395,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
return createStreamStrategy({
render: (renderInput) => (
<WebStreamViewport {...renderInput} isMobileBreakpoint={input.isMobileBreakpoint} />
<WebStreamViewport
key={renderInput.agentId}
{...renderInput}
isMobileBreakpoint={input.isMobileBreakpoint}
/>
),
orderTailReverse: false,
orderHeadReverse: false,
@@ -12,21 +12,6 @@ function reply(id: string): StreamItem {
return { kind: "assistant_message", id, text: id, timestamp: at };
}
function pluginReply(sourceId: string, part = "0"): StreamItem {
return {
kind: "plugin",
id: `math/${sourceId}/${part}`,
sourceId,
sourceKind: "assistant_message",
timestamp: at,
pluginId: "math",
pluginItemId: `${sourceId}/${part}`,
itemKind: "formula",
version: 1,
data: { text: "$$x^2$$" },
};
}
function thought(id: string): StreamItem {
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
}
@@ -64,44 +49,6 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
const ids = (items: StreamItem[]) => items.map((item) => item.id);
describe("collapseTurns", () => {
it("keeps every card of the final reply outside the folded turn", () => {
const items = [
user("prompt"),
reply("acknowledgment"),
shell("call"),
pluginReply("final", "left"),
pluginReply("final", "right"),
];
const result = collapse(items);
expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]);
expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt");
expect(result.hostIdByFoldedMessageId.has("final")).toBe(false);
const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) });
expect(expanded.tail).toEqual(items);
expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]);
});
it("folds intermediate plugin replies even without tool calls", () => {
const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]);
expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]);
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
});
it.each([undefined, "thought", "tool_call"] as const)(
"does not treat a plugin row with source kind %s as a reply",
(sourceKind) => {
const card = { ...pluginReply("card"), sourceKind };
const items = [user("prompt"), shell("call"), card];
expect(collapse(items).tail).toEqual(items);
expect(collapse(items).turnsByHostId.size).toBe(0);
},
);
it("keeps a plugin-rendered reply and its work unfolded while the turn is open", () => {
const items = [user("prompt"), shell("call"), pluginReply("final")];
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
});
// A history row renders again only when its item changes, so a prompt that is already retained
// when its turn closes needs its row revised to show the summary line.
it("reports the hosts whose summary line appeared, changed or went away", () => {
@@ -100,25 +100,9 @@ function staysVisibleWhenCollapsed(item: StreamItem): boolean {
return isTurnTrigger(item) || item.kind === "compaction";
}
function isAssistantReply(item: StreamItem): boolean {
return (
item.kind === "assistant_message" ||
(item.kind === "plugin" && item.sourceKind === "assistant_message")
);
}
function findLastReplyIndex(tail: StreamItem[], start: number, end: number): number {
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
for (let index = end - 1; index >= start; index -= 1) {
const item = tail[index]!;
if (!isAssistantReply(item)) continue;
const messageId = getStreamItemMessageId(item);
// A transformer can split one reply into several cards; keep the whole reply visible.
while (index > start) {
const previous = tail[index - 1]!;
if (!isAssistantReply(previous) || getStreamItemMessageId(previous) !== messageId) break;
index -= 1;
}
return index;
if (tail[index]?.kind === "assistant_message") return index;
}
return -1;
}
@@ -141,7 +125,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const flushRun = (runEnd: number) => {
const host = tail[runStart - 1];
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
const replyIndex = findLastReplyIndex(tail, runStart, runEnd);
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
if (
!host ||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
@@ -153,7 +137,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary) && !folded.some(isAssistantReply)) {
if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) {
output.push(...tail.slice(runStart, runEnd));
return;
}
+4 -16
View File
@@ -23,10 +23,6 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
// Every footer variant (running, completed with Copy, completed with only its duration) gets
// this content height, so a turn ending swaps the footer's controls without moving the rows
// above it.
const TURN_FOOTER_CONTENT_HEIGHT = 24;
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -212,11 +208,7 @@ function CompletedTurnFooter({
function TurnFooterRow({ children }: { children: ReactNode }) {
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
return (
<View style={rowStyle} testID="turn-footer">
{children}
</View>
);
return <View style={rowStyle}>{children}</View>;
}
const stylesheet = StyleSheet.create((theme) => ({
@@ -226,22 +218,18 @@ const stylesheet = StyleSheet.create((theme) => ({
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
// The message above drops its bottom padding for the footer (compactBottom), so this margin
// alone sets the gap. 20 puts the footer as far below the text as the text sits below a
// tool row: 12 message padding + 4 stream gap + 5 badge padding/border, less the footer's
// 1px taller row.
turnFooterRow: {
marginTop: theme.spacing[4] + theme.spacing[1],
marginTop: theme.spacing[2] + 5,
},
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
minHeight: 24,
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
height: TURN_FOOTER_CONTENT_HEIGHT,
height: 24,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
@@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => {
]).flat();
const { result } = renderHook(() =>
useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }),
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
);
expect(result.current.start).toBe(2);
@@ -35,6 +35,7 @@ describe("useStreamHistoryWindow", () => {
const loadRemoteOlder = vi.fn(async () => true);
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -66,7 +67,8 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -89,7 +91,8 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -105,7 +108,8 @@ describe("useStreamHistoryWindow", () => {
configurable: true,
});
const { result, rerender } = renderHook(
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{
initialProps: {
history: [
@@ -138,6 +142,7 @@ describe("useStreamHistoryWindow", () => {
});
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -28,17 +28,22 @@ function findEarlierHistoryWindowStart(input: {
}
interface HistoryWindowBoundary {
agentId: string;
boundaryItemId: string | null;
initialized: boolean;
}
function reconcileHistoryWindow(input: {
current: HistoryWindowBoundary;
agentId: string;
boundaryIndex: number;
initialBoundaryItemId: string | null;
hasItems: boolean;
}): HistoryWindowBoundary {
const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input;
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
if (current.agentId !== agentId) {
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
}
if (!current.initialized && hasItems) {
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
}
@@ -49,26 +54,28 @@ function reconcileHistoryWindow(input: {
}
export function useStreamHistoryWindow(input: {
agentId: string;
items: StreamItem[];
loadRemoteOlder: () => boolean | Promise<boolean>;
}) {
const { items, loadRemoteOlder } = input;
const { agentId, items, loadRemoteOlder } = input;
const initialStart = useMemo(
() => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }),
[items],
);
const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null);
const [window, setWindow] = useState(() => ({
agentId,
boundaryItemId: initialBoundaryItemId,
initialized: items.length > 0,
}));
const boundaryItemId = window.boundaryItemId;
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
const boundaryIndex = useMemo(
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
[boundaryItemId, items],
);
let start = initialStart;
if (window.initialized) {
if (window.agentId === agentId && window.initialized) {
if (window.boundaryItemId === null) {
start = 0;
} else if (boundaryIndex >= 0) {
@@ -80,12 +87,13 @@ export function useStreamHistoryWindow(input: {
setWindow((current) =>
reconcileHistoryWindow({
current,
agentId,
boundaryIndex,
initialBoundaryItemId,
hasItems: items.length > 0,
}),
);
}, [boundaryIndex, initialBoundaryItemId, items.length]);
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
const revealLoadedHistory = useCallback(
(itemId?: string): boolean => {
@@ -98,12 +106,13 @@ export function useStreamHistoryWindow(input: {
return false;
}
setWindow({
agentId,
boundaryItemId: items[nextStart]?.id ?? null,
initialized: true,
});
return true;
},
[items, start],
[agentId, items, start],
);
const loadOlder = useCallback(async (): Promise<boolean> => {
return revealLoadedHistory() || (await loadRemoteOlder());
+8 -2
View File
@@ -430,6 +430,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
useEffect(() => {
setExpandedInlineToolCallIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
const openInlineFile = useStableEvent(
@@ -472,7 +476,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
return () => inlinePathOpener.cancel();
}, [inlinePathOpener, isActive]);
}, [inlinePathOpener, isActive, agentId]);
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (isActive) void inlinePathOpener.open(target, disposition);
@@ -547,6 +551,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
revealLoadedHistory,
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: presentation.tail,
loadRemoteOlder,
});
@@ -1285,7 +1290,8 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent)
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId");
}
if (left.features !== right.features) reasons.push("agent.features");
if (left.options !== right.options) reasons.push("agent.options");
if (left.controls !== right.controls) reasons.push("agent.controls");
return reasons;
}
-2
View File
@@ -55,7 +55,6 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { WorkspaceArchiveUndoShortcutHost } from "@/workspace/archive-undo";
import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
@@ -558,7 +557,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<WorkspaceArchiveUndoShortcutHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -1,97 +0,0 @@
import { expect, test } from "vitest";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { createHostBackgroundWorkModel } from "./host";
test("retries a failed observation and receives subsequent updates", async () => {
interface Observer {
snapshot(): void;
update(message: SessionOutboundMessage): void;
error(error: unknown): void;
}
const observers: Observer[] = [];
let releases = 0;
const item = {
id: "shell",
kind: "shell",
description: "sleep",
startedAt: "2026-01-01T00:00:00Z",
};
const client = {
getLastServerInfoMessage: () => ({ features: { agentBackgroundWork: true } }),
stopAgentBackgroundWork: async () => {},
listAgentBackgroundWork: async () => [item],
observeAgentBackgroundWork: () => ({
subscribe: (observer: Observer) => {
observers.push(observer);
},
release: async () => {
releases++;
},
}),
};
const model = createHostBackgroundWorkModel("host", "agent", {
getClient: () => client,
subscribe: () => () => {},
});
const unsubscribe = model.subscribe(() => {});
observers[0].snapshot();
await Promise.resolve();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
observers[0].error(new Error("subscription failed"));
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
model.retry();
expect(observers).toHaveLength(2);
expect(releases).toBe(1);
observers[1].snapshot();
await Promise.resolve();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
observers[0].error(new Error("late error"));
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
observers[1].update({
type: "agent.background_work.update",
payload: { agentId: "agent", items: [] },
});
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
unsubscribe();
});
test("starts observing after the same client reconnects", async () => {
let connected = false;
let notify = () => {};
let snapshot = () => {};
const item = {
id: "shell",
kind: "shell",
description: "sleep",
startedAt: "2026-01-01T00:00:00Z",
};
const client = {
getLastServerInfoMessage: () =>
connected ? { features: { agentBackgroundWork: true } } : null,
stopAgentBackgroundWork: async () => {},
listAgentBackgroundWork: async () => [item],
observeAgentBackgroundWork: () => ({
subscribe: (observer: { snapshot(): void }) => {
snapshot = observer.snapshot;
},
release: async () => {},
}),
};
const model = createHostBackgroundWorkModel("host", "agent", {
getClient: () => client,
subscribe: (listener) => {
notify = listener;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
connected = true;
notify();
snapshot();
await Promise.resolve();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
unsubscribe();
});
-115
View File
@@ -1,115 +0,0 @@
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createBackgroundWorkModel } from "./model";
interface BackgroundWorkClient {
getLastServerInfoMessage(): {
features?: { agentBackgroundWork?: boolean; agentBackgroundWorkStop?: boolean } | null;
} | null;
stopAgentBackgroundWork(agentId: string, item: PaseoAgentBackgroundWorkItem): Promise<void>;
listAgentBackgroundWork(agentId: string): Promise<PaseoAgentBackgroundWorkItem[]>;
observeAgentBackgroundWork(): {
subscribe(observer: {
snapshot(): void;
update(message: SessionOutboundMessage): void;
error(error: unknown): void;
}): unknown;
release(): Promise<void>;
};
}
interface BackgroundWorkHost {
getClient(): BackgroundWorkClient | null;
subscribe(listener: () => void): () => void;
}
function hostFor(serverId: string): BackgroundWorkHost {
const host = getHostRuntimeStore();
return {
getClient: () => host.getSnapshot(serverId)?.client ?? null,
subscribe: (listener) => host.subscribe(serverId, listener),
};
}
export function createHostBackgroundWorkModel(
serverId: string,
agentId: string,
host: BackgroundWorkHost = hostFor(serverId),
) {
let client: BackgroundWorkClient | null = null;
const controls = (items: PaseoAgentBackgroundWorkItem[]) => {
// COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03.
if (client?.getLastServerInfoMessage()?.features?.agentBackgroundWorkStop === true)
return items;
return items.map(({ canStop: _canStop, stopToken: _stopToken, ...item }) => item);
};
return createBackgroundWorkModel({
stop: (item) => {
if (!client) return Promise.reject(new Error("Host disconnected"));
return client.stopAgentBackgroundWork(agentId, item);
},
list: () => {
if (!client) return Promise.reject(new Error("Host disconnected"));
return client.listAgentBackgroundWork(agentId).then(controls);
},
observe: ({ snapshot, update, disconnected, failed }) => {
let release: (() => void) | undefined;
let currentObservation:
| ReturnType<BackgroundWorkClient["observeAgentBackgroundWork"]>
| undefined;
const sync = () => {
const candidate = host.getClient();
// The runtime keeps the client across reconnects, but clears server info until handshake.
const next = candidate?.getLastServerInfoMessage() ? candidate : null;
if (next === client) return;
const previousRelease = release;
client = next;
release = undefined;
disconnected();
previousRelease?.();
if (!client) return;
// COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06.
if (client.getLastServerInfoMessage()?.features?.agentBackgroundWork !== true) {
failed();
return;
}
const observedClient = client;
const observation = client.observeAgentBackgroundWork();
currentObservation = observation;
observation.subscribe({
snapshot: () => {
if (client === observedClient && currentObservation === observation) snapshot();
},
update: (message) => {
if (
client === observedClient &&
currentObservation === observation &&
message.type === "agent.background_work.update" &&
message.payload.agentId === agentId
)
update(controls(message.payload.items));
},
error: () => {
if (client === observedClient && currentObservation === observation) failed();
},
});
release = () => {
if (currentObservation === observation) currentObservation = undefined;
void observation
.release()
.catch((error: unknown) =>
console.error("Background work subscription cleanup failed", error),
);
};
};
const unsubscribe = host.subscribe(sync);
sync();
return () => {
unsubscribe();
client = null;
release?.();
};
},
});
}
-155
View File
@@ -1,155 +0,0 @@
import { useCallback, useSyncExternalStore } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
import { LiveElapsed } from "@/components/message";
import { Button } from "@/components/ui/button";
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
import { createHostBackgroundWorkModel } from "./host";
import { backgroundWorkText } from "./presentation";
export { createHostBackgroundWorkModel } from "./host";
function kindKey(kind: string): "shell" | "monitor" | "other" {
if (kind === "shell" || kind === "monitor") return kind;
return "other";
}
export function BackgroundWorkTrack({
model,
count,
}: {
model: ReturnType<typeof createHostBackgroundWorkModel>;
count: number;
}) {
const { t } = useTranslation();
const { items, status, stops } = useSyncExternalStore(
model.subscribe,
model.getSnapshot,
model.getSnapshot,
);
const missing = Object.values(stops ?? {}).filter(
(entry) => !items?.some((item) => item.stopToken === entry.item.stopToken),
);
const rows = [...(items ?? []), ...missing.map((entry) => entry.item)];
if (count === 0 && rows.length === 0) return null;
const visibleCount = items?.length || count;
return (
<ComposerTrackPill
testID="background-work"
panelTitle={t("message.backgroundWork.title")}
segments={[
{
bucket: "running",
text:
visibleCount === 1
? t("message.backgroundWork.pillOne")
: t("message.backgroundWork.pillMany", { count: visibleCount }),
},
]}
>
{status === "loading" || status === "stale" ? (
<ComposerTrackRow>
<Text style={styles.facts}>{t("message.backgroundWork.waiting")}</Text>
</ComposerTrackRow>
) : null}
{status === "error" ? (
<>
<ComposerTrackRow>
<Text style={styles.facts}>{t("message.backgroundWork.error")}</Text>
</ComposerTrackRow>
<ComposerTrackRow
onPress={model.retry}
closeOnSelect={false}
accessibilityLabel={t("message.backgroundWork.retry")}
>
<Text style={styles.title}>{t("message.backgroundWork.retry")}</Text>
</ComposerTrackRow>
</>
) : null}
{rows.map((item) => (
<BackgroundWorkRow
key={item.stopToken ?? item.id}
item={item}
model={model}
live={Boolean(items?.some((entry) => entry.stopToken === item.stopToken))}
ready={status === "ready"}
stopStatus={stops?.[item.stopToken ?? ""]?.status}
/>
))}
</ComposerTrackPill>
);
}
function BackgroundWorkRow({
item,
model,
live,
ready,
stopStatus,
}: {
item: PaseoAgentBackgroundWorkItem;
model: ReturnType<typeof createHostBackgroundWorkModel>;
live: boolean;
ready: boolean;
stopStatus?: "pending" | "error";
}) {
const { t } = useTranslation();
const kind = t(`message.backgroundWork.${kindKey(item.kind)}`);
const stop = useCallback(() => {
void model.stopItem(item);
}, [model, item]);
const dismiss = useCallback(
() => model.dismissStop(item.stopToken ?? ""),
[model, item.stopToken],
);
let detail = t("message.backgroundWork.notRunning");
if (stopStatus === "pending") detail = t("message.backgroundWork.stopping");
const { title, command } = backgroundWorkText(item, kind);
const action = item.canStop && ready && live;
const label =
stopStatus === "pending"
? t("message.backgroundWork.stopping")
: t("message.backgroundWork.stop");
return (
<ComposerTrackRow>
<View style={styles.row}>
<Text style={styles.title}>{title}</Text>
{command ? <Text style={styles.facts}>{command}</Text> : null}
<Text style={styles.facts}>
{kind} ·{" "}
{live ? (
<LiveElapsed startedAt={new Date(item.startedAt)} active style={styles.facts} />
) : (
detail
)}
</Text>
{stopStatus === "error" ? (
<Text style={styles.facts}>{t("message.backgroundWork.stopError")}</Text>
) : null}
</View>
{action ? (
<Button
variant="ghost"
size="sm"
accessibilityLabel={`${t("message.backgroundWork.stop")} ${title}`}
disabled={stopStatus === "pending"}
onPress={stop}
>
{label}
</Button>
) : null}
{!live && stopStatus === "error" ? (
<Button variant="ghost" size="sm" onPress={dismiss}>
{t("message.backgroundWork.dismiss")}
</Button>
) : null}
</ComposerTrackRow>
);
}
const styles = StyleSheet.create((theme) => ({
row: { flexGrow: 1, flexShrink: 1, flexBasis: "auto", minWidth: 0 },
title: { fontSize: theme.fontSize.base, color: theme.colors.foreground },
facts: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
}));
@@ -1,233 +0,0 @@
import { expect, test } from "vitest";
import { createBackgroundWorkModel } from "./model";
test("keeps a newer update when an earlier list resolves after it", async () => {
let resolve!: (
value: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
) => void;
let update!: (
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
) => void;
let snapshot!: () => void;
const model = createBackgroundWorkModel({
list: () =>
new Promise((done) => {
resolve = done;
}),
observe: (callbacks) => {
snapshot = callbacks.snapshot;
update = callbacks.update;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
expect(model.getSnapshot().items).toBeNull();
snapshot();
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
update([item]);
resolve([]);
await Promise.resolve();
expect(model.getSnapshot().items).toEqual([item]);
unsubscribe();
});
test("retains the last inventory while reconnecting and replaces it after resync", async () => {
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
const reads: Array<(value: (typeof item)[]) => void> = [];
let snapshot!: () => void;
const model = createBackgroundWorkModel({
list: () => new Promise((resolve) => reads.push(resolve)),
observe: (callbacks) => {
snapshot = callbacks.snapshot;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
snapshot();
reads[0]([item]);
await Promise.resolve();
snapshot();
expect(model.getSnapshot().items).toEqual([item]);
reads[1]([]);
await Promise.resolve();
expect(model.getSnapshot().items).toEqual([]);
unsubscribe();
});
test("ignores a list response from a connection that was replaced", async () => {
let snapshot!: () => void;
let disconnected!: () => void;
let resolve!: (
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
) => void;
const model = createBackgroundWorkModel({
list: () =>
new Promise((done) => {
resolve = done;
}),
observe: (callbacks) => {
snapshot = callbacks.snapshot;
disconnected = callbacks.disconnected;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
snapshot();
disconnected();
resolve([{ id: "old", kind: "shell", description: null, startedAt: "2026-01-01T00:00:00Z" }]);
await Promise.resolve();
expect(model.getSnapshot().items).toBeNull();
unsubscribe();
});
test("retains stale items on failure and retries the inventory", async () => {
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
let snapshot!: () => void;
let resolve!: (items: (typeof item)[]) => void;
let reject!: (error: Error) => void;
const model = createBackgroundWorkModel({
list: () =>
new Promise((done, fail) => {
resolve = done;
reject = fail;
}),
observe: (callbacks) => {
snapshot = callbacks.snapshot;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
snapshot();
resolve([item]);
await Promise.resolve();
snapshot();
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
reject(new Error("offline"));
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
model.retry();
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
resolve([]);
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
unsubscribe();
});
test("reports an initial read failure without claiming an empty inventory", async () => {
let snapshot!: () => void;
const model = createBackgroundWorkModel({
list: async () => {
throw new Error("offline");
},
observe: (callbacks) => {
snapshot = callbacks.snapshot;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
snapshot();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
unsubscribe();
});
test("recreates a failed observation on retry and resumes live updates", async () => {
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
let observation!: Parameters<Parameters<typeof createBackgroundWorkModel>[0]["observe"]>[0];
let starts = 0;
let releases = 0;
const model = createBackgroundWorkModel({
list: async () => [item],
observe: (callbacks) => {
observation = callbacks;
starts++;
return () => {
releases++;
};
},
});
const unsubscribe = model.subscribe(() => {});
observation.failed();
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
model.retry();
expect(starts).toBe(2);
expect(releases).toBe(1);
observation.snapshot();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
observation.update([]);
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
unsubscribe();
});
test("keeps a stop pending across a roster removal and exposes a failed outcome", async () => {
const item = {
id: "1",
kind: "shell",
description: "sleep",
startedAt: "2026-01-01",
canStop: true,
stopToken: "generation-1",
};
let update!: (items: (typeof item)[]) => void;
let reject!: (error: Error) => void;
const model = createBackgroundWorkModel({
list: async () => [item],
stop: () =>
new Promise((_, fail) => {
reject = fail;
}),
observe: (callbacks) => {
update = callbacks.update;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
update([item]);
const pending = model.stopItem(item);
update([]);
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "pending", item });
reject(new Error("connection lost"));
await pending;
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "error", item });
model.dismissStop("generation-1");
expect(model.getSnapshot().stops).toEqual({});
unsubscribe();
});
test("ignores a stop response from a disconnected generation", async () => {
const first = {
id: "1",
kind: "shell",
description: "old",
startedAt: "2026-01-01",
canStop: true,
stopToken: "first",
};
const replacement = { ...first, description: "new", stopToken: "second" };
let update!: (items: (typeof first)[]) => void;
let disconnect!: () => void;
let resolve!: () => void;
const model = createBackgroundWorkModel({
list: async () => [],
stop: () =>
new Promise((done) => {
resolve = done;
}),
observe: (callbacks) => {
update = callbacks.update;
disconnect = callbacks.disconnected;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
update([first]);
const pending = model.stopItem(first);
disconnect();
update([replacement]);
resolve();
await pending;
expect(model.getSnapshot().items).toEqual([replacement]);
expect(model.getSnapshot().stops?.first).toEqual({ status: "error", item: first });
unsubscribe();
});
-121
View File
@@ -1,121 +0,0 @@
import type { PaseoAgentBackgroundWorkItem as AgentBackgroundWorkItem } from "@getpaseo/client";
interface Port {
list(): Promise<AgentBackgroundWorkItem[]>;
stop?(item: AgentBackgroundWorkItem): Promise<void>;
observe(callbacks: {
snapshot(): void;
disconnected(): void;
failed(): void;
update(items: AgentBackgroundWorkItem[]): void;
}): () => void;
}
interface BackgroundWorkState {
status: "loading" | "stale" | "ready" | "error";
items: AgentBackgroundWorkItem[] | null;
stops?: Record<string, { status: "pending" | "error"; item: AgentBackgroundWorkItem }>;
}
export function createBackgroundWorkModel(port: Port) {
let state: BackgroundWorkState = { status: "loading", items: null };
let sequence = 0;
let stopGeneration = 0;
let stop: (() => void) | undefined;
let observationFailed = false;
const listeners = new Set<() => void>();
const publish = (next: BackgroundWorkState) => {
state =
next.stops === undefined && state.stops === undefined
? next
: { ...next, stops: next.stops ?? state.stops };
for (const listener of listeners) listener();
};
const refresh = async () => {
const request = ++sequence;
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
try {
const items = await port.list();
if (request === sequence) publish({ status: "ready", items });
} catch {
if (request === sequence) publish({ status: "error", items: state.items });
}
};
const start = () => {
observationFailed = false;
stop = port.observe({
snapshot: () => {
void refresh();
},
disconnected: () => {
sequence++;
stopGeneration++;
const stops =
state.stops &&
Object.fromEntries(
Object.entries(state.stops).map(([id, entry]) => [
id,
{ ...entry, status: "error" as const },
]),
);
publish({ status: state.items === null ? "loading" : "stale", items: state.items, stops });
},
failed: () => {
observationFailed = true;
sequence++;
publish({ status: "error", items: state.items });
},
update: (items) => {
sequence++;
publish({ status: "ready", items });
},
});
};
return {
getSnapshot: () => state,
dismissStop: (token: string) => {
const stops = { ...state.stops };
delete stops[token];
publish({ ...state, stops });
},
stopItem: async (item: AgentBackgroundWorkItem) => {
const token = item.stopToken;
if (!port.stop || !token || state.stops?.[token]?.status === "pending") return;
if (!state.items?.some((entry) => entry.stopToken === token)) return;
const generation = stopGeneration;
publish({ ...state, stops: { ...state.stops, [token]: { status: "pending", item } } });
try {
await port.stop(item);
if (generation !== stopGeneration) return;
const stops = { ...state.stops };
delete stops[token];
publish({ ...state, stops });
} catch {
if (generation === stopGeneration)
publish({ ...state, stops: { ...state.stops, [token]: { status: "error", item } } });
}
},
retry: () => {
if (observationFailed) {
stop?.();
stop = undefined;
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
start();
} else {
void refresh();
}
},
subscribe(listener: () => void) {
listeners.add(listener);
if (!stop) start();
return () => {
listeners.delete(listener);
if (!listeners.size) {
sequence++;
stop?.();
stop = undefined;
}
};
},
};
}
@@ -1,20 +0,0 @@
import { expect, test } from "vitest";
import { backgroundWorkText } from "./presentation";
test("shows descriptions and commands once, with useful fallbacks", () => {
expect(
backgroundWorkText({ description: "Watch build", command: "tail -f build.log" }, "Monitor"),
).toEqual({ title: "Watch build", command: "tail -f build.log" });
expect(backgroundWorkText({ description: null, command: "sleep 10" }, "Shell")).toEqual({
title: "sleep 10",
command: null,
});
expect(backgroundWorkText({ description: "sleep 10", command: "sleep 10" }, "Shell")).toEqual({
title: "sleep 10",
command: null,
});
expect(backgroundWorkText({ description: null }, "Shell")).toEqual({
title: "Shell",
command: null,
});
});
@@ -1,7 +0,0 @@
export function backgroundWorkText(
item: { description: string | null; command?: string },
kind: string,
): { title: string; command: string | null } {
const title = item.description ?? item.command ?? kind;
return { title, command: item.command && item.command !== title ? item.command : null };
}
@@ -40,16 +40,7 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
cwd: "/repo",
model: "agent-model",
thinkingOptionId: "think-hard",
features: [
{ type: "toggle", id: "web-search", label: "Web search", value: true },
{
type: "select",
id: "effort",
label: "Effort",
value: "high",
options: [{ id: "high", label: "High" }],
},
],
options: { "web-search": true, effort: "high" },
parentAgentId: null,
labels: {},
...overrides,
@@ -114,7 +105,7 @@ describe("buildDraftAgentSetup", () => {
modeId: "mode-current",
model: "agent-model",
thinkingOptionId: "think-hard",
featureValues: {
providerOptions: {
"web-search": true,
effort: "high",
},
@@ -65,17 +65,12 @@ 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,
featureValues,
providerOptions: { ...agent.options },
};
}
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
import type {
AgentMode,
AgentOptionControl,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import type { CommandCenterContribution } from "./contributions";
import {
@@ -31,7 +35,7 @@ const ICONS: AgentControlContributionSource["icons"] = {
thinking: TestIcon,
planMode: TestIcon,
mode: () => TestIcon,
feature: () => TestIcon,
control: () => TestIcon,
};
function provider(input: {
@@ -65,8 +69,8 @@ function provider(input: {
};
}
function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
return { type: "toggle", id, label: id, value, icon };
function toggleControl(key: string, icon?: string): AgentOptionControl {
return { type: "toggle", key, label: key, icon };
}
function makeSource(
@@ -75,7 +79,7 @@ function makeSource(
models?: Partial<AgentControlContributionSource["models"]>;
thinking?: Partial<AgentControlContributionSource["thinking"]>;
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
features?: Partial<AgentControlContributionSource["features"]>;
optionControls?: Partial<AgentControlContributionSource["optionControls"]>;
} = {},
): AgentControlContributionSource {
return {
@@ -104,10 +108,11 @@ function makeSource(
select: () => undefined,
...overrides.modes,
},
features: {
list: [],
set: () => undefined,
...overrides.features,
optionControls: {
controls: [],
options: {},
onSetOption: () => undefined,
...overrides.optionControls,
},
};
}
@@ -133,7 +138,7 @@ describe("Command Center agent-control contributions", () => {
const contributions = buildAgentControlContributions({
...makeSource({
models: { providers: [provider({ id: "claude", label: "Claude" })] },
features: { list: [toggleFeature("plan_mode", true)] },
optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } },
}),
modes: undefined,
});
@@ -323,9 +328,10 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default",
defaultModeId: "default",
},
features: {
list: [toggleFeature("plan_mode", true, "list-todo")],
set: (id, value) => {
optionControls: {
controls: [toggleControl("plan_mode", "list-todo")],
options: { plan_mode: true },
onSetOption: (id, value) => {
calls.push([id, value]);
},
},
@@ -341,9 +347,10 @@ describe("Command Center agent-control contributions", () => {
const calls: [string, unknown][] = [];
const contributions = buildAgentControlContributions(
makeSource({
features: {
list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
set: (id, value) => {
optionControls: {
controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")],
options: { fast_mode: false, auto_accept: true },
onSetOption: (id, value) => {
calls.push([id, value]);
},
},
@@ -376,7 +383,7 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default",
defaultModeId: "default",
},
features: { list: [toggleFeature("fast_mode")] },
optionControls: { controls: [toggleControl("fast_mode")], options: {} },
}),
);
const ranks = new Map<string, number>();
@@ -1,9 +1,10 @@
import type {
AgentFeature,
AgentMode,
AgentOptionControl,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import { readToggleOption, type ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import {
@@ -61,7 +62,7 @@ export interface AgentControlContributionIcons {
thinking: CommandCenterIcon;
planMode: CommandCenterIcon;
mode(modeId: string): CommandCenterIcon;
feature(feature: AgentFeature): CommandCenterIcon;
control(control: AgentOptionControl): CommandCenterIcon;
}
interface CommandCenterChoice {
@@ -105,10 +106,7 @@ export interface AgentControlContributionSource {
defaultModeId: string | null;
select(modeId: string): void | Promise<void>;
};
features: {
list: readonly AgentFeature[];
set(featureId: string, value: unknown): void | Promise<void>;
};
optionControls: ComposerOptionControls;
}
function buildContributions(
@@ -242,18 +240,19 @@ function toggleChoices(input: {
}
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const planFeature = source.features.list.find(
(feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
const { controls, options, onSetOption } = source.optionControls;
const planControl = controls.find(
(control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle",
);
let choices: CommandCenterChoice[] = [];
if (planFeature?.type === "toggle") {
if (planControl?.type === "toggle") {
choices = toggleChoices({
source,
key: "plan",
icon: source.icons.feature(planFeature),
isOn: planFeature.value,
turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true),
turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false),
icon: source.icons.control(planControl),
isOn: readToggleOption(planControl, options),
turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false),
});
} else if (source.modes) {
const modes = source.modes;
@@ -280,18 +279,19 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent
}
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const fastFeature = source.features.list.find(
(feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
const { controls, options, onSetOption } = source.optionControls;
const fastControl = controls.find(
(control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle",
);
const choices =
fastFeature?.type === "toggle"
fastControl?.type === "toggle"
? toggleChoices({
source,
key: "fast",
icon: source.icons.feature(fastFeature),
isOn: fastFeature.value,
turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true),
turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false),
icon: source.icons.control(fastControl),
isOn: readToggleOption(fastControl, options),
turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false),
})
: [];
return {
@@ -1,11 +1,6 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type {
AgentFeature,
AgentMode,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
getAgentFeatureIcon,
@@ -14,6 +9,7 @@ import {
ThinkingIcon,
} from "@/agent-controls/icons";
import { useProviderIcons } from "@/components/provider-icons";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import {
buildAgentControlContributions,
@@ -44,15 +40,15 @@ export interface AgentControlCommandCenterSource {
selectedId: string | null | undefined;
select(modeId: string): void | Promise<void>;
};
features: {
list: readonly AgentFeature[] | undefined;
set?: (featureId: string, value: unknown) => void | Promise<void>;
};
/** Absent when the surface cannot change options (the agent has no client). */
optionControls: ComposerOptionControls | null;
}
function ignoreAgentFeatureChange(): void {
return;
}
const NO_OPTION_CONTROLS: ComposerOptionControls = {
controls: [],
options: {},
onSetOption: () => undefined,
};
function resolveDefaultModeId(
provider: string | null | undefined,
@@ -87,7 +83,7 @@ export function useAgentControlCommandCenterActions(input: {
}): void {
const { t } = useTranslation();
const { controls } = input;
const { features, models, modes, thinking } = controls;
const { optionControls, models, modes, thinking } = controls;
const getProviderIcon = useProviderIcons(controls.serverId);
const actions = useMemo(
() =>
@@ -104,7 +100,7 @@ export function useAgentControlCommandCenterActions(input: {
getCommandCenterIcon(
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
),
feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)),
},
models: {
providers: models.providers,
@@ -123,10 +119,7 @@ export function useAgentControlCommandCenterActions(input: {
modes?.select,
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
),
features: {
list: features.set ? (features.list ?? []) : [],
set: features.set ?? ignoreAgentFeatureChange,
},
optionControls: optionControls ?? NO_OPTION_CONTROLS,
}),
[
getProviderIcon,
@@ -134,8 +127,7 @@ export function useAgentControlCommandCenterActions(input: {
controls.provider,
controls.providerDefinitions,
controls.serverId,
features.list,
features.set,
optionControls,
models.providers,
models.select,
models.selectedModelId,
@@ -41,15 +41,7 @@ export function BranchSwitcher({
const toast = useToast();
const queryClient = useQueryClient();
const {
branchOptions,
isLoadingBranches,
isFetchingBranches,
branchError,
isOpen,
setIsOpen,
handleBranchSelect,
} = useBranchSwitcher({
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
client,
normalizedServerId: serverId,
normalizedWorkspaceId: workspaceId,
@@ -81,22 +73,6 @@ export function BranchSwitcher({
[branchLeadingSlot],
);
let emptyText = t("branchSwitcher.empty");
if (isLoadingBranches) emptyText = t("common.states.loading");
else if (branchError) emptyText = "";
const errorFooter = useMemo(
() =>
branchError ? (
<View style={styles.footer}>
<Text accessibilityRole="alert" style={styles.error}>
{isFetchingBranches ? t("common.states.loading") : t("branchSwitcher.failedToLoad")}
</Text>
</View>
) : undefined,
[branchError, isFetchingBranches, t],
);
if (!currentBranchName) {
return null;
}
@@ -109,7 +85,6 @@ export function BranchSwitcher({
testID={testID}
label={currentBranchName}
open={isOpen}
disabled={!isConnected}
onPress={handleOpen}
accessibilityRole="button"
accessibilityLabel={t("branchSwitcher.currentBranch", {
@@ -128,8 +103,7 @@ export function BranchSwitcher({
searchable
placeholder={t("branchSwitcher.placeholder")}
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
emptyText={emptyText}
footer={errorFooter}
emptyText={t("branchSwitcher.empty")}
title={t("branchSwitcher.title")}
open={isOpen}
onOpenChange={setIsOpen}
@@ -148,8 +122,6 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
minWidth: 0,
},
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
tooltipText: {
color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm,
@@ -13,11 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
TOOLBAR_GLYPH_SIZES,
toolbarContentSlot,
type ToolbarGlyph,
} from "@/components/ui/control-geometry";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -65,7 +61,7 @@ interface CombinedModelSelectorProps {
*/
triggerFill?: boolean;
toolbar?: {
glyph: ToolbarGlyph;
glyphSize: number;
showCaret: boolean;
};
}
@@ -257,13 +253,11 @@ export function CombinedModelSelector({
chevron={toolbar?.showCaret === false ? null : undefined}
>
{selectedProvider.trim().length > 0 ? (
<View style={toolbar ? styles.toolbarGlyph : styles.glyph}>
<View style={toolbar?.glyphSize === 20 ? styles.toolbarGlyph20 : styles.toolbarGlyph16}>
<ModelProviderGlyph
provider={selectedProvider}
serverId={serverId}
size={toolbar?.glyph.size ?? ICON_SIZE.md}
strokeWidth={toolbar?.glyph.strokeWidth}
tone={toolbar?.glyph.tone}
size={toolbar?.glyphSize ?? ICON_SIZE.md}
/>
</View>
) : null}
@@ -299,30 +293,31 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: 0,
},
trigger: {
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
glyph: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
toolbarGlyph16: {
width: 16,
height: 16,
flexShrink: 0,
},
toolbarGlyph: {
width: TOOLBAR_GLYPH_SIZES.label,
height: TOOLBAR_GLYPH_SIZES.label,
toolbarGlyph20: {
width: 20,
height: 20,
flexShrink: 0,
},
triggerPressed: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
},
triggerDisabled: {
opacity: 0.5,
@@ -334,11 +329,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
// A custom trigger, such as a form field, owns its own box: none of the toolbar slot applies.
customTriggerWrapper: {
paddingHorizontal: 0,
paddingVertical: 0,
marginHorizontal: 0,
height: "auto",
},
triggerFill: {
@@ -1,11 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import {
Pressable,
type PressableStateCallbackType,
Text,
View,
useWindowDimensions,
} from "react-native";
import { Pressable, Text, View, useWindowDimensions } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -15,7 +9,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { TOOLBAR_DISC_SIZE, type ToolbarRing } from "@/components/ui/control-geometry";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
@@ -27,8 +21,8 @@ interface ContextWindowMeterProps {
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
/** The ring's box and stroke when it sits in an icon toolbar. */
toolbarRing?: ToolbarRing;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
@@ -77,7 +71,7 @@ function getProgressColor(percentage: number, theme: Theme): string {
return theme.colors.foregroundMuted;
}
function getMeterGeometry(showPercentage: boolean, ring?: ToolbarRing) {
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
@@ -86,8 +80,8 @@ function getMeterGeometry(showPercentage: boolean, ring?: ToolbarRing) {
containerStyle: styles.containerWithLabel,
};
}
const resolvedSize = ring?.size ?? SVG_SIZE;
const resolvedStrokeWidth = ring?.strokeWidth ?? STROKE_WIDTH;
const resolvedSize = glyphSize ?? SVG_SIZE;
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
@@ -157,7 +151,7 @@ export function ContextWindowMeter({
usedTokens,
totalCostUsd,
showPercentage = false,
toolbarRing,
glyphSize,
}: ContextWindowMeterProps) {
const { t, i18n } = useTranslation();
const { width } = useWindowDimensions();
@@ -171,7 +165,7 @@ export function ContextWindowMeter({
const closeSheet = useCallback(() => setIsSheetOpen(false), []);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const geometry = getMeterGeometry(showPercentage, toolbarRing);
const geometry = getMeterGeometry(showPercentage, glyphSize);
const context = useMemo(
() =>
@@ -190,10 +184,6 @@ export function ContextWindowMeter({
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = geometry.containerStyle;
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [containerStyle, pressed && styles.pressed],
[containerStyle],
);
const ring = (
<ContextWindowRing
size={geometry.svgSize}
@@ -217,7 +207,7 @@ export function ContextWindowMeter({
return (
<>
<Pressable
style={pressableStyle}
style={containerStyle}
testID="context-window-meter"
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
@@ -251,7 +241,7 @@ export function ContextWindowMeter({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={pressableStyle}
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={accessibilityLabel}
@@ -317,23 +307,20 @@ export function ContextWindowMeter({
const styles = StyleSheet.create((theme) => ({
container: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
containerWithLabel: {
height: TOOLBAR_DISC_SIZE,
height: TOOLBAR_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
pressed: {
backgroundColor: theme.colors.surface2,
},
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
@@ -72,7 +72,7 @@ function MermaidFenceHostImpl({
() => [
getDiagramBoxStyle(textStyle),
containerStyle,
{ height: Math.min(runtimeHeight, MAX_PREVIEW_HEIGHT), minHeight: MIN_DIAGRAM_BOX_HEIGHT },
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
],
[runtimeHeight, textStyle],
);
@@ -111,22 +111,20 @@ function MermaidFenceHostImpl({
) : null}
</View>
) : null}
<div inert={!diagramVisible} style={boundaryStyle}>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
</div>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
{isFullscreen && visible ? (
<MermaidFullscreenViewer
source={visible.source}
@@ -144,8 +142,6 @@ const MEASURING_SIZE = { width: 240, height: 240 };
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
*/
const MIN_DIAGRAM_BOX_HEIGHT = 56;
const MAX_PREVIEW_HEIGHT = 480;
const boundaryStyle: React.CSSProperties = { display: "contents" };
const sourceContainerStyle: ViewStyle = { position: "relative" };
/**
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
@@ -216,19 +216,17 @@ export function ModelProviderGlyph({
provider,
serverId,
size,
strokeWidth,
tone = "muted",
}: {
provider: string;
serverId: string | null;
size: number;
strokeWidth?: number;
tone?: ProviderGlyphTone;
}) {
const Icon = useProviderIcon(provider, serverId);
const color =
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
return <Icon size={size} strokeWidth={strokeWidth} color={color} />;
return <Icon size={size} color={color} />;
}
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
@@ -20,8 +20,6 @@ import { resolveProviderIconName } from "@/components/provider-icon-name";
export interface ProviderIconProps {
size: number;
color: string;
/** Honoured by outline glyphs such as the fallback; brand marks are filled and ignore it. */
strokeWidth?: number;
}
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
@@ -649,7 +649,6 @@ function WorkspaceRowRightGroup({
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
@@ -676,8 +675,6 @@ function WorkspaceRowRightGroup({
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
@@ -872,7 +872,6 @@ function StatusWorkspaceRowInnerContent({
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
@@ -952,7 +951,6 @@ function StatusWorkspaceRowInnerContent({
trailing={trailing}
trailingPresentation={trailingPresentation}
showKebab={showKebabInSlot}
showArchiveButton={showArchiveButton}
showScrim={showScrim}
reserveSlotWidth={reserveSlotWidth}
isPinned={isPinned}
@@ -984,7 +982,6 @@ function StatusWorkspaceActionSlot({
trailing,
trailingPresentation,
showKebab,
showArchiveButton,
showScrim,
reserveSlotWidth,
isPinned,
@@ -1005,7 +1002,6 @@ function StatusWorkspaceActionSlot({
trailing: SidebarWorkspaceTrailing;
trailingPresentation: SidebarWorkspaceTrailingPresentation;
showKebab: boolean;
showArchiveButton: boolean;
showScrim: boolean;
reserveSlotWidth: boolean;
isPinned?: boolean;
@@ -1030,8 +1026,6 @@ function StatusWorkspaceActionSlot({
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{kebab.showKebab && onArchive ? (
<SidebarWorkspaceMenu

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