Compare commits

..
Author SHA1 Message Date
Mohamed Boudra baaaaa363d 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-02 09:53:39 +01:00
Mohamed Boudra 16b843949e test(plugins): the project-switch spec expects the other project's metadata to be empty 2026-11-02 09:50:47 +01:00
Mohamed Boudra 4b0d5f5e4c 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-02 09:47:49 +01:00
Mohamed Boudra 4706165690 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-02 09:41:47 +01:00
Mohamed Boudra 67a358128b 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-02 09:25:14 +01:00
Mohamed Boudra 9674fc4d4c 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-02 09:09:37 +01:00
Mohamed Boudra 0d89c51c36 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-02 08:47:57 +01:00
Mohamed Boudra a5fa8a5d83 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-02 07:53:25 +01:00
Mohamed Boudra ca178b6578 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-02 07:20:56 +01:00
Mohamed Boudra b965a3db7c 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-02 07:14:20 +01:00
Mohamed Boudra c1f64d45e1 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-02 06:56:13 +01:00
Mohamed Boudra aa558ab845 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-02 06:45:57 +01:00
Mohamed Boudra a2b76f77f3 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-02 06:09:27 +01:00
Mohamed Boudra ad88797f8d 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-02 06:06:55 +01:00
Mohamed Boudra d92b16afbf 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-02 05:59:32 +01:00
Mohamed Boudra abd4d2c1ea 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-02 05:36:04 +01:00
Mohamed Boudra 77f90d3460 docs(plugins): the hook result comment names metadata as the field that stays 2026-11-02 05:09:28 +01:00
Mohamed Boudra 7a651fceb3 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-02 04:09:28 +01:00
Mohamed Boudra 4095d3784d 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-02 03:50:36 +01:00
Mohamed Boudra 21365c571b 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-02 03:42:12 +01:00
Mohamed Boudra f4dea7413e 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-02 03:27:23 +01:00
Mohamed Boudra 6bf7a47a96 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-02 03:21:11 +01:00
Mohamed Boudra d0a5d4d970 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-02 01:43:01 +01:00
Mohamed Boudra 377b06be0d 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-02 01:10:56 +01:00
Mohamed Boudra 25a3efe0ac 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-02 01:01:24 +01:00
Mohamed Boudra 5b4636b856 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-02 00:52:17 +01:00
Mohamed Boudra 00ec4bc80c 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-02 00:34:19 +01:00
Mohamed Boudra 970c281daf 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-02 00:31:08 +01:00
Mohamed Boudra 389db81784 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-02 00:29:50 +01:00
Mohamed Boudra c3a7547ce5 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-01 23:42:51 +01:00
Mohamed Boudra d86b6dec6e 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-01 23:20:19 +01:00
Mohamed Boudra e4876ed09d 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-01 23:10:18 +01:00
Mohamed Boudra ea8c9e7dc4 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-01 23:06:46 +01:00
Mohamed Boudra ddae0a4c36 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-01 22:56:11 +01:00
Mohamed Boudra 4a8ed8f588 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-01 21:29:07 +01:00
Mohamed Boudra 4d76f2176b 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-01 21:20:09 +01:00
Mohamed Boudra e4a49b414f 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-01 21:09:10 +01:00
Mohamed Boudra fbd7890f78 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-01 21:03:31 +01:00
Mohamed Boudra 83ae20ff06 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-01 20:53:09 +01:00
Mohamed Boudra 9df005f602 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-01 20:18:18 +01:00
Mohamed Boudra d1a1eba23d 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-01 20:07:00 +01:00
Mohamed Boudra 133481d75c 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-01 19:59:10 +01:00
Mohamed Boudra e640fe4281 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-01 19:49:12 +01:00
Mohamed Boudra 43f7dc4ad1 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-01 19:47:11 +01:00
Mohamed Boudra f783c46301 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-01 19:41:34 +01:00
Mohamed Boudra 2fddf0b98c 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-01 19:33:45 +01:00
Mohamed Boudra 593773a381 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-01 18:57:05 +01:00
Mohamed Boudra e4d4ba7489 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-01 18:49:11 +01:00
Mohamed Boudra 1b5731cebe 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-01 18:41:01 +01:00
Mohamed Boudra 0f1ebda942 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-01 18:32:36 +01:00
Mohamed Boudra 7a1eae245b 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-01 17:51:37 +01:00
Mohamed Boudra 3383ee80bc 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-01 17:44:20 +01:00
Mohamed Boudra 5cdbfef48b 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-01 17:30:53 +01:00
Mohamed Boudra 0c378889d1 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-01 17:20:51 +01:00
Mohamed Boudra 8460e5126a 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-01 17:11:02 +01:00
Mohamed Boudra 51a87b98b9 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-01 17:05:09 +01:00
Mohamed Boudra 427c2d0743 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-01 17:01:54 +01:00
Mohamed Boudra a8d539ca7c 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-01 16:42:17 +01:00
Mohamed Boudra 993eef1668 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-01 16:35:18 +01:00
Mohamed Boudra c2677a2690 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-01 16:03:05 +01:00
Mohamed Boudra f3c3461d67 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-01 15:52:08 +01:00
Mohamed Boudra 85edc9da9c 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-01 15:44:24 +01:00
Mohamed Boudra fd2315495f refactor(server): the legacy featureValues folds take one named-input object 2026-11-01 15:34:06 +01:00
Mohamed Boudra 3a3300b69e fix(server): a plugin that moves settings and then refuses an apply reports what moved 2026-11-01 15:34:06 +01:00
Mohamed Boudra 0fef7414fc 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-01 15:34:06 +01:00
Mohamed Boudra c0c181425f 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-01 15:34:06 +01:00
Mohamed Boudra 301b49e381 test(server): removal of auto_accept returns ACP to asking; Claude keeps opening restrictions across a patch 2026-11-01 15:22:56 +01:00
Mohamed Boudra ee936d51c0 test(server): assert the forwarded plugin setting without nesting callbacks 2026-11-01 15:21:02 +01:00
Mohamed Boudra 8e28e3988b test(server): a setting a plugin changes on its own is forwarded as options_changed 2026-11-01 15:20:05 +01:00
Mohamed Boudra 62df2f3253 test(server): a patch key is resolved against nested configured defaults 2026-11-01 15:18:24 +01:00
Mohamed Boudra aba0c78449 docs(server): a rejected apply reports what the provider moved after the call ends 2026-11-01 15:15:20 +01:00
Mohamed Boudra a0754e9f9b 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-01 15:15:20 +01:00
Mohamed Boudra 9d8747cd3a 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-01 15:15:20 +01:00
Mohamed Boudra 13ad4a13ff 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-01 15:10:35 +01:00
Mohamed Boudra 9de8cbf2b8 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-01 15:10:35 +01:00
Mohamed Boudra 2dd83e43cb 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-01 15:10:35 +01:00
Mohamed Boudra ac6b5440e8 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-01 14:55:27 +01:00
Mohamed Boudra 0da2781520 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-01 14:40:11 +01:00
Mohamed Boudra f487316a9d chore(website): regenerate the config schema for profile options 2026-11-01 08:17:11 +01:00
Mohamed Boudra 1ee149c85b 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-01 07:53:29 +01:00
Mohamed Boudra 72d488f17a 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-01 07:53:29 +01:00
Mohamed Boudra 8e83d9346a 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-01 07:53:29 +01:00
Mohamed Boudra 6c22bc72df 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-01 07:53:29 +01:00
Mohamed Boudra bc00edf904 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-01 07:53:29 +01:00
Mohamed Boudra afce426a43 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-01 07:53:28 +01:00
paseo-bot[bot]andDominic Anaeto 14519bdb5b Read archived Pi chats after their working folder is removed (#8442)
* fix(pi): open an archived Pi agent's history after its folder is removed

Opening an archived agent's timeline resumes its provider for history.
Pi's resumeSession never received the resume purpose, so it launched the
Pi RPC process, and its MCP support probe, in the agent's stored working
directory. When that folder had been removed, for example with a merged
worktree, Node failed the spawn with "spawn <command> ENOENT" and the
history could not be read.

Starting the process elsewhere is not enough: with --session, Pi checks
the cwd stored in the session file's header and, outside interactive
mode, exits when that folder is missing. A history resume whose stored
folder is no longer a directory now starts Pi from the home directory on
a temporary copy of the session file whose header names that directory.
The copy streams everything after the header unchanged and is deleted
when the session closes. The agent keeps reporting its stored folder and
original session file, and the original file is not written to.

The directory check moves out of the ACP provider, which already starts
history loads from the home directory (#6228), into a shared helper both
providers use.

Fixes #7969

* test(terminal): keep the argv test child alive through its assertion

The POSIX argv test reads the terminal screen while its child can exit and dispose the parser. Keep the child alive until the assertion completes, as other screen tests do, and let the existing session cleanup terminate it.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:52:48 +00:00
Mohamed Boudra 891e62309c fix(release): use the keychain password for macOS signing setup (#8445)
Backport the pinned packaging tool correction in the workflow so existing release tags can be rebuilt without changing their dependencies.
2026-11-01 13:35:11 +07:00
paseo-bot[bot] 6ae6308cfb test(server): control failed Git clone responses in credential test (#8428)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:01:30 +00:00
Mohamed Boudra ef77b823ae chore(release): reconcile 0.15.0-beta.1 metadata (#8443)
* docs(changelog): prepare 0.15.0-beta.1

(cherry picked from commit 7e5ad53491)

* chore(release): cut 0.15.0-beta.1

(cherry picked from commit 8bd38fb236)

* test(docs): follow nested worktrees page
2026-11-01 12:56:41 +07:00
Mohamed Boudra 60f29a4350 docs(worktrees): add nested worktreeinclude reference 2026-11-01 06:31:01 +01:00
paseo-bot[bot]andDominic Anaeto 8efb21f55c fix(server): close abandoned workspace service connections (#8421)
* fix(server): close the upstream connection when a proxied client leaves

The service proxy piped the service's response to the client but never
destroyed the upstream request when the client went away. The upstream
socket stayed open and unread, so a Server-Sent Events service kept
writing into it and grew in memory. The proxy now destroys the upstream
request when the client response closes before it finished, without
logging that deliberate abort as an unreachable upstream, and a
WebSocket upgrade closes the service socket when the client socket
closes.

Fixes #8149

* test(server): publish terminal activity fixture atomically

File existence did not mean the child had finished writing its JSON. Publish the completed fixture by rename so a concurrent reader cannot parse an empty file.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* test(server): keep rollback listener ports distinct

Selecting both free ports before binding either lets the OS return the same port twice. Hold the intentionally occupied main port before selecting the standalone port so the rollback probe cannot reach the wrong server.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 03:55:48 +00:00
paseo-ai[bot] b2666a0aec fix: update lockfile signatures and Nix hash [skip ci] 2026-11-01 03:43:24 +00:00
Mohamed Boudra 82d5f6c580 fix(app): keep layout sized to the window after rotation (#8377)
A style update racing a window resize could recommit old root constraints. Rebuild the style update from the current root on each commit attempt.
2026-11-01 10:34:06 +07:00
Mohamed Boudra da75aa7a4a Fix terminal emoji size and alignment (#8375)
* fix(terminal): render emoji at their intended width

The native grid scales glyphs to the columns assigned by the terminal buffer. Use grapheme widths across current hosts and clients so emoji fit their slots and replay preserves cursor positions.

* fix(terminal): make width regression test portable

* test(terminal): wait for replay fixture output to finish

A trailing newline could arrive after the readiness marker and change the retained history between replay captures.
2026-11-01 10:33:59 +07:00
86c60d367c fix(claude): keep a resumed subagent on its row after the session reopens (#8408)
SendMessage resumes a finished Claude subagent under its original task_id
but a new tool_use_id, and the child's frames still name the original Task
call as their parent. In the same process the live source already maps the
new tool id onto the original row. After the session reopens, the child is
only known from its transcript, so the resume was declared as a new
subagent keyed by the SendMessage id: a second row, and every child frame
dropped as undeclared. That row never showed the model or the child's
reply.

Replay now hands the live source each restored child's task id, id and
presentation. The first task_started for that task id adopts the restored
row, so the resume takes the same path it takes without a reopen.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 01:05:02 +00:00
paseo-bot[bot] 5afa1a9f8e test(server): isolate cold Git filter from control work (#8397)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 00:01:29 +00:00
paseo-bot[bot]andOkan YILDIRIM 2d7adfc450 fix(app): restore directory subscriptions after demand returns (#8403)
Invalidate refresh satisfaction when subscriptions are released, and retry
if demand returns before the overtaken refresh completes. Otherwise the
same connection source can stay satisfied with no live list listeners.

Cover both demand sources and completion orderings with live delta checks.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
2026-10-31 23:58:14 +00:00
paseo-bot[bot] 7d9741ef77 fix(app): expose the SSL checkbox checked state on web (#8399)
The web renderer does not map accessibilityState.checked onto aria-checked. Supply the checked property explicitly so accessibility clients agree with the checkmark and connection URI.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 22:50:27 +00:00
paseo-bot[bot]andluke caf9300358 fix(server): preserve running status after agent creation (#8396)
* fix(server): forward a created agent's current state, not its pre-prompt copy

create_agent_request forwarded the snapshot createAgent returned when
registration finished, after the initial prompt had already published
the agent as running. Subscribers received that idle copy last, with an
older updatedAt, and providers that report nothing until the turn ends
left the agent listed as idle for the whole turn.

Forward the agent manager's current state instead.

Closes #8022

* test(server): drain agent persistence before fixture cleanup

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 20:52:07 +00:00
f0dfb9134c fix(app): keep the open workspace visible in collapsed groups (#8395)
* fix(app): keep the open workspace visible in collapsed sidebar groups

A collapsed group showed its first seven rows plus working and
needs-attention rows, so an idle open workspace past the limit was
hidden behind Show more and no sidebar row was highlighted.

selectSidebarGroupVisibility now takes the route selection. When the
collapsed limit would hide the open workspace, it is returned as
leadingItem and rendered first in project groups, status groups and
both Pinned sections. Rows the group already shows stay in place,
expanded groups keep group order, and drag lists render the leading
row outside their sortable data, so a reorder never saves its borrowed
position.

Refs #8078

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): keep shortcut targets visible in collapsed groups

The navigation journey still required the selected overflow workspace to stay hidden. Assert its visible selection while preserving collapsed-group navigation and the seven ordinary rows.

Co-authored-by: 0xRoy <1997roylee@gmail.com>

---------

Co-authored-by: 0xRoy <1997roylee@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 20:03:31 +00:00
paseo-bot[bot]andJeong-Uk,Kim 56c051503b fix(server): keep promptless Codex agents usable after reopening (#8393)
* fix(server): report a Codex thread only once Codex can resume it

Codex writes a new thread's rollout at its first turn, but the adapter
starts a thread when the agent is created and reported its ID at once.
AgentManager stored that ID as the persistence handle, so a Codex agent
that was reopened before its first prompt (idle runtime release, daemon
restart, plugin tool reload) failed every prompt with "no rollout found".

The adapter now reports a thread's ID, as runtime info and as a handle,
only once it is resumable: after Codex announces the thread's first
turn, or when the thread was resumed or forked. Until then a reopened
agent starts a new session, as an unprompted Claude agent does.

Document that a persistence handle must be resumable.

* test(server): reopen promptless Codex agents without model requests

Exercise the real app-server before any first turn, so publishing an unsaved persistence handle fails at the actual resume operation.

Co-authored-by: Jeong-Uk,Kim <dnrtoki@gmail.com>

---------

Co-authored-by: Jeong-Uk,Kim <dnrtoki@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 18:56:22 +00:00
paseo-bot[bot] 85354fcb6a test(terminal): verify cleanup by process identity (#8389)
* test(terminal): capture recurring worker publication failures

* test(terminal): diagnose the remaining process cleanup wait

* test(terminal): verify cleanup by process identity

* test(terminal): retain native liveness proof during cleanup checks

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 18:38:48 +00:00
QTomandClaude Opus 5.5 2dd931c8bd fix(app): accept host:port and URLs in the direct connection host field (#8223)
The direct connection form treated any host containing a colon as a bare
IPv6 address, so entering "100.66.69.36:6767" or "tcp://host:6767" built
"tcp://[100.66.69.36:6767]:6767" and surfaced the browser's raw "Invalid URL"
error. Normalize the host input first: strip a leading scheme and any
path/query/fragment, split "host:port" (the inline port wins), and only treat
unbracketed hosts with two or more colons as IPv6.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:06:12 +00:00
QTomandClaude Opus 5.5 58eafd81e5 fix(app): fall back to native terminal copy/paste without the Clipboard API (#8230)
Browsers omit navigator.clipboard outside secure contexts, such as the daemon web UI served over plain http on a Tailscale or LAN address. The non-Mac Ctrl+C/Ctrl+V terminal shortcuts then threw synchronously, and Ctrl+V had already prevented the native paste, so terminal copy/paste broke. When the Clipboard API is unavailable, the shortcuts now leave the default action alone so xterm's native copy/paste event handlers do the work, and the browser clipboard rejects instead of throwing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:01:27 +00:00
paseo-bot[bot]andhuazhongmin eab9ac3b03 fix(acp): include late-discovered skills in initial command lists (#8387)
* fix(providers): wait for the ACP command inventory to settle

An ACP agent publishes its slash-command inventory in batches. MiniMax
Code sends its built-in commands first, then merges in every discovered
skill roughly 350ms later. `listCommands()` settled on the first batch,
so a session that had to discover its commands at creation time offered
the 10 built-in commands instead of the 396 the agent eventually
publishes. Every skill was missing from `/` until the session was
reopened and the cached inventory was read again.

Wait for the inventory to go quiet (600ms without another batch) instead
of for the first batch. An empty batch still settles immediately, and
`initialCommandsWaitTimeoutMs` (1500ms default) still bounds the whole
wait for providers that publish once or never publish at all.

* test(acp): verify late commands through the provider boundary

Exercise initial command discovery over real stdio, including a first batch cached before session creation returns. Keep the later skill and subsequent cached read in the same public session contract.

Co-authored-by: huazhongmin <peanut_hzm@163.com>

---------

Co-authored-by: huazhongmin <peanut_hzm@163.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 16:07:08 +00:00
paseo-bot[bot]andpeanut_hzm e02fa9a5f8 fix(acp): report provider startup exits without waiting for timeout (#8384)
* fix(server): resolve ACP providers installed after the daemon started

Windows hands a new process the PATH its parent had when it started. The
daemon's supervisor therefore keeps the PATH it inherited at launch, and any
CLI installed afterwards stays invisible to it. A provider installed after the
daemon started reported "not installed" forever, even though the binary was
on PATH in a fresh shell.

Refresh the process PATH from the Windows user/machine registry environment
when the daemon worker starts. Existing entries keep their order and take
precedence; registry entries are appended. This runs once at startup so
PATH-isolated availability checks and explicit env overrides stay
authoritative.

Spawn the absolute path the launcher resolver verified rather than the bare
command. The resolver already finds installs outside the daemon's inherited
PATH (Winget packages, directories added after startup); the resolved path
was previously discarded.

An ACP agent that exits during the handshake never rejects the SDK's
initialize request, so the provider refresh waited out its full 120s deadline
and reported "Timed out refreshing <provider> after 120000ms; pending:
initialize" instead of the child's stderr. Race a child-exit rejection
against initialize so the real failure surfaces.

Also repair persisted MiniMax Code commands. The first catalog entry ran
`npx -y @minimax-ai/code@<version> acp`; npx derives the executable name from
the package name (`code`), but the package publishes `mcode`, so npm exited
before the ACP handshake. The catalog was fixed separately, but commands
already persisted by an older client were never migrated.

* fix(acp): report startup exits across probes and sessions

Extend the shared process failure handler so initialize rejects with stderr after the child exits, including session creation. Keep this fix scoped to startup error reporting; remove the separate PATH and persisted-command changes.

Co-authored-by: peanut_hzm <peanut_hzm@163.com>

* fix(acp): validate provider options before starting probes

Reject malformed options before creating a child or its process-failure promise, so cleanup cannot cause an unhandled rejection in the daemon.

---------

Co-authored-by: peanut_hzm <peanut_hzm@163.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 14:55:17 +00:00
b117e3fb0f fix(claude): allow conversation rewind to messages sent during a turn (#8372)
* fix(server): let a message steered into a running Claude turn be rewound to

A steered message never got a provider message id, because Claude takes it
into the running turn without echoing it back, so rewinding to it refused
with "Cannot rewind before the provider acknowledges the submitted prompt".
The steer admission now reports the uuid Claude minted for the message, and
a Claude conversation rewind to it forks at the entry Claude read it after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): keep messages steered into a running Claude turn in the replayed timeline

Claude records a steered message as a queued_command attachment rather than a
user entry, and the history replay skipped it, so every such message, replies
from other agents included, vanished from the timeline after a daemon restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): verify steer identities across lifecycle tests

Match the accepted-steer contract used by conversation rewind and verify that its identity names the message delivered to the provider.

Co-authored-by: tieo <65707274+tieo@users.noreply.github.com>

* fix(claude): release transcript streams after rewind lookup

Closing readline leaves a partially read transcript open after an early match. Destroy the owned stream on exit and cover repeated lookups with unread data.

---------

Co-authored-by: tieo <65707274+tieo@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 13:03:27 +00:00
paseo-bot[bot] 2ac288f4d0 test(plugins): make global switch recovery deterministic (#8342)
* test(plugins): trace recurring global recovery failure

* test(plugins): control cleanup in global switch recovery

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 12:26:32 +00:00
Mohamed Boudra 1008e9fffc Insert suggested commands into the composer (#8371)
* feat(composer): insert suggested commands into drafts

* fix(composer): close suggestions after command insertion

* fix(composer): place caret after inserted commands
2026-10-31 19:25:16 +07:00
Matt CowgerandMohamed Boudra 74613e4bc3 feat(opencode): reuse running OpenCode v2 services (#7435)
* feat(opencode): connect v2 client to an existing server

* feat(providers): reuse running agent servers

Keep shared server sessions isolated and retain native tools while refreshes reuse the running service.

* fix(providers): recognize formatted bridge manifests

Managed plugin ownership must survive harmless JSON formatting changes.

* test(providers): distinguish isolated session bindings

Use separate session identities to verify that dedicated helpers keep their environments isolated.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-31 18:55:24 +07:00
paseo-bot[bot]andOuyang Xingyuan 0e5bcbd9f5 fix(app): open relative chat links inside nested repositories (#8361)
* fix(app): open a nested-repo file when the workspace-root path is missing

A workspace that contains another git repo often gets paths written
relative to that nested repo. `schema/x.md` is really
`repo-a/schema/x.md`. A markdown link was joined onto the workspace
root and opened a missing file. Inline code already asked the daemon
for a suffix match.

Resolved workspace-relative paths that contain a slash now use that
same lookup before opening. An exact hit stays on the original path.
A suffix hit replaces it and keeps the line range. A failed lookup
still opens the original path. Bare names, absolute paths, and `~/`
paths are unchanged.

Example:
- Input: workspace `/repo`, link `schema/x.md`, file at `repo-a/schema/x.md`
- Before: open `/repo/schema/x.md` (missing)
- After: open `/repo/repo-a/schema/x.md`

* fix(app): preserve explicit destinations when finding nested files

Relative chat links can name a file inside a nested repository. Retain the suffix lookup while using the original href and document base to preserve absolute paths, preview links, and folder destinations.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>

* fix(app): keep network share links on their absolute destination

Use the shared absolute-path classifier so network share hrefs retain their destination alongside drive and POSIX paths.

* fix(app): keep nested-file lookup inside ordered path opening

Resolve the original directory before considering suffix matches, and allocate click ordering before asynchronous lookup. The existing opener now owns the complete operation so a slow earlier click cannot replace a later selection.

* test(app): load UI modules before test lifecycle hooks

Cold module transformation exceeded the lifecycle hook deadline before these suites could run. Complete module initialization before registering the tests, retaining their assertions and timeout settings.

---------

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 11:05:06 +00:00
a7d9bb34fc fix(plugins): keep rewound conversations on their retained branch (#8368)
Refresh direct plugin history from authoritative native persistence after conversation/combined rewind, matching the built-in providers' history refresh strategy. Reopen/replay rebuilds translation baselines, rewind tokens and child bindings while preserving outward subscriptions; file-only rewind stays unchanged.

Keep replay internal and replace the manager timeline after it completes. Preserve the committed native handle and close routing across refresh failures so retry and reload recover the retained branch.

The full native session reload is intentionally inefficient, pending a smarter timeline cut that restores translation state, reused IDs, children and permissions without reopening.

Closes #4667
Supersedes #4668 and the rewind-history proposal in #4754.

Verified with 72 targeted tests, green CI, and real OMP browser UI A/B/C -> rewind B -> replacement D with native history/timeline equality and a clean browser reload.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
2026-10-31 17:48:42 +07:00
Mohamed Boudra fd65985e9d fix(android): resize header fades after rotation (#8349)
Clear Android SVG descendant geometry caches when the canvas resizes. Cover growth and shrinkage with a native pixel-rendering regression.
2026-10-31 14:45:13 +07:00
422 changed files with 21284 additions and 4631 deletions
+26
View File
@@ -135,6 +135,32 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Backport electron-builder#10172 to the pinned build tool only.
# Remove this step when app-builder-lib is upgraded to a release containing it.
- name: Repair temporary keychain password in signing tool
shell: bash
run: |
node <<'KEYCHAIN_FIX'
const fs = require('node:fs');
const path = require('node:path');
const manifestPath = require.resolve('app-builder-lib/package.json');
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
}
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
let source = fs.readFileSync(sourcePath, 'utf8');
const replacements = [
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
];
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
source = source.replace(before, after);
}
fs.writeFileSync(sourcePath, source);
KEYCHAIN_FIX
- name: Set desktop package version from tag
shell: bash
run: |
+72 -6
View File
@@ -1,15 +1,81 @@
# Changelog
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
## 0.15.0-beta.1 - 2026-11-01
Added:
Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Improved:
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
### Added
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181))
### Improved
- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132))
- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302))
- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274))
- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276))
- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162))
### Plugins
- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger))
- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc))
### Fixed
- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo))
- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph))
- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan))
- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee))
- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang))
- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding))
- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding))
- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278))
- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu))
- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192))
- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu))
- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019))
- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259))
- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy))
- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819))
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011))
- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107))
- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne))
### Notes
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
## 0.14.0 - 2026-10-29
+73 -7
View File
@@ -1,15 +1,81 @@
# 更新日志
# Changelog
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
## 0.15.0-beta.1 - 2026-11-01
新增:
daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
改进:
worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
### 新增
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181))
### 改进
- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132))
- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302))
- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274))
- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276))
- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162))
### 插件
- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger))
- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc))
### 修复
- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo))
- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph))
- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan))
- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee))
- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang))
- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278))
- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu))
- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192))
- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu))
- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019))
- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259))
- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy))
- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819))
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011))
- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107))
- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne))
### 说明
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
## 0.14.0 - 2026-10-29
+12
View File
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
regression renders real gradient pixels across growing and shrinking layouts, including a nested
group. From `packages/app/android`, run it on your selected emulator:
```bash
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
+30
View File
@@ -65,6 +65,7 @@ per-agent examples and provider-specific keys.
## Table of Contents
- [Extending a built-in provider](#extending-a-built-in-provider)
- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
@@ -107,6 +108,35 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai
---
## OpenCode with an existing v2 server
Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment:
```json
{
"agents": {
"providers": {
"opencode": {
"env": {
"PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
"PASEO_OPENCODE_SERVER_PASSWORD": "<server password>"
}
}
}
}
}
```
`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`.
A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server.
An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package.
OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service.
---
## Z.AI (Zhipu) coding plan
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
+2
View File
@@ -92,6 +92,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) |
@@ -478,6 +479,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. |
+77 -3
View File
@@ -511,13 +511,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;
@@ -636,6 +644,72 @@ Mounted surfaces and command invocations have shorter API lifetimes.
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
## Contribute composer items
`addComposerItem` mounts a plugin component in the agent, draft and New workspace composers. The
public contract is the [composer items reference](../public-docs/plugins/reference.md#composer-items).
The composer is two contexts, provided by two owners (`packages/app/src/composer/session.tsx`).
`Composer` provides `ComposerInput` (prompt and attachments). `AgentControls` and
`DraftAgentControls` provide `ComposerAgent` (provider, model, mode, thinking, options, metadata and
their commands). `toPluginComposer` (`packages/app/src/plugins/composer-items/`) joins them into the
`PluginComposer` an item reads, so a plugin cannot tell which composer a command reaches. Add a
capability to `ComposerAgent` and both implementations; never branch on `context` in the model.
Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all
render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`),
a toolbar icon or a row in the overflow sheet. Model and mode stay specialized but share its trigger,
density and overflow. Plugin items count in the density presence in `layout.ts`, so a composer
condenses to make room for them. A custom component renders wherever its item is placed, and an
item that renders nothing still counts for density; there is no registration for either.
A command runs against the owner's current state, not the render that built the callback. The
draft's form (`openAgentForm`) and options (`openDraftOptions`) are plain stores for that reason,
and `draftComposerAgent` rejects a provider the host does not offer or a model its loaded catalog
does not list. `ComposerControl` owns the pending state and the error toast for callbacks that
return a promise.
The overflow sheet's content portals to the app root, away from the composer. Pass
`useComposerSheetBridge()` as the sheet's `contextBridge`, or `useComposer()` returns nothing and
the sheet draws no plugin rows.
## Contribute New workspace items
`addNewWorkspaceItem` mounts a plugin component on the New workspace screen. The public contract
is the [New workspace items reference](../public-docs/plugins/reference.md#new-workspace-items).
The screen's choices that a plugin may change live in one plain store,
`createNewWorkspaceSetup` (`packages/app/src/screens/new-workspace/setup-model.ts`): the base
picker selection, the manual isolation, the branch name and the workspace metadata. The pickers and
the plugin commands write to it, so a command sees what an earlier command left. Project and host
stay in the screen's hooks (`useNewWorkspaceHostSelector` reconciles them against each other), and
they are read-only to plugins; a command that switched them would unmount the item that called it.
The store is built in the render that creates the screen, with what the screen derives from its
hooks (the lock, the project, whether the project can make worktrees, whether the host supports
metadata), so a command is valid from a child's first effect, which runs before the screen's own
effects. `syncInputs` refreshes those inputs in a layout effect on every commit, and commands read
them when they run, never from the render that built them. The pickers and the commands pick
through the same `chooseBase` and `chooseIsolation`, so a plugin command has the effects of a
pick; the screen injects those effects when it builds the store (syncing the picker's PR attachment,
remembering the isolation), so a command from a mount effect does the same as a click. Closing the
picker stays in the picker's own handler. Add a capability by extending the store;
do not read screen state from a plugin component.
`ensureWorkspace` sends the store's `branch` as `source.branchName` whenever the worktree cuts a
new branch (`startsNewBranch`, which mirrors the daemon: anything but a checkout, including no base
at all on a detached HEAD), and its `metadata` on `workspace.create` when it is not empty. The
server uses `branchName ?? worktreeSlug` as the new branch. `baseRef.name` is the exact ref
creation uses; `branchNameFromRef` is for labels only.
Items render through `ComposerControl` with `surface="field"`: a `FieldTrigger` (a badge on
desktop, a full-width field when compact) and the same pending state, error toast and list as the
composer's controls. `ComposerControlLockProvider` carries the screen's lock to them. Plugin
`Combobox` is a `select` item with `remoteSearch`: the app `Combobox` does not filter locally, and
`PluginCombobox` (`plugins/react-native/composer-controls.tsx`) runs `search` through
`useFetchQuery` with `dataShape: "list"`, which keeps the previous results while a newer query runs.
`plugins/item-host.tsx` is the host shared by composer and New workspace items.
## Contribute timeline items
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
+65 -4
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.
@@ -89,7 +148,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
@@ -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.
@@ -478,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>;
@@ -500,13 +559,15 @@ 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;
}
```
`describePersistence()` returns a handle only once a new provider process could resume it. Reload, idle runtime collection and daemon restart resume whatever handle the agent stored; a stored agent without one starts a new session instead. When there is no handle, `AgentManager` stores `getRuntimeInfo().sessionId` as one, so that ID follows the same rule. Codex, for example, starts a thread when the agent is created but writes its rollout only at its first turn, and `thread/resume` fails for it until then, so its adapter reports neither ID before Codex announces that turn.
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
### Steps
+13 -3
View File
@@ -6,15 +6,25 @@ Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/h
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. xterm usually keeps a conjunct in one cell (see [Character widths](#character-widths)), but a colour change inside it leaves `क्` and `ष` in separate cells that still shape together. Drawing part of a cluster on its own reshapes it: the conjunct comes apart into `क्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions.
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some clusters that span cells. Grouping only merges whole cells, so a conjunct xterm keeps in one cell cannot be split by the OS. Grapheme rules follow the OS ICU version, and we accept that to avoid bundling approximately 14 MB of ICU4J data. Before the buffers used grapheme widths, Android 10 (API 29) split `स्ते` across its two cells. This renderer does not guarantee identical shaping across OS versions.
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native.
## Character widths
The daemon, native and web buffers all load `@xterm/addon-unicode-graphemes`: the daemon through `packages/server/src/terminal/headless-terminal.ts`, native in `createNativeHeadlessTerminal`, web in `terminal-emulator-runtime.ts`. Each emoji cluster takes one two-column cell, including VS16 (`❤️`), skin tones (`👋🏽`), flags (`🇰🇷`) and ZWJ sequences (`👨‍👩‍👧`). That is the width colour-emoji fonts draw at. Under xterm's default Unicode 6 table `😀` got one column, and the native views scaled it to half size to fit.
Every buffer must use the same table. The daemon answers cursor-position reports (`CSI 6n`) from its buffer, and a client re-parses the daemon's ANSI replay with its own table. A disagreement moves the cursor and the later cells of any row that holds a disputed cluster: after replay from a Unicode 6 daemon, the cursor that followed `A😀😀😀B` lands inside the third emoji.
Daemons that use grapheme widths advertise `server_info.features["terminal-grapheme-widths"]`. `terminal-pane.tsx` reads it once and passes `characterWidths` to the emulator. Against an older daemon the app keeps each renderer's previous table: xterm's default on native, Unicode 11 on web. An older app against a newer daemon cannot match it, because every client shares the daemon's one buffer. A terminal kept by a worker from an unreleased pre-grapheme build keeps that worker's table until it exits.
The table follows Unicode grapheme clusters, as Node's `string-width` does for Ink-based agent TUIs. Indic conjuncts follow the same rule: `क्ष` and `स्ते` take one column, and the native views scale them down to fit it. Shells measure with the C library's `wcwidth` instead. glibc counts `❤️` as one column, `👋🏽` as four and `क्ष` as two, so a line editor that measures those disagrees with the terminal.
Use `@xterm/addon-unicode-graphemes` 0.5.0-beta.330 or later. Earlier versions read their width table from the wrong offset when Node decodes it into a pooled `Buffer`, which gives wrong widths or a load error depending on earlier allocations. `headless-terminal.test.ts` forces that case in a fresh process.
## Ink outside the row
+2 -2
View File
@@ -69,8 +69,8 @@ Source coverage still includes interleaved tool updates.
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
agent messages each start a fallback turn. This deterministic fallback is shared by paging
and presentation; it cannot recover historical batching or steering. An underfilled viewport
loads older history until it fills or history is exhausted. Further history is user-driven.
and presentation; it cannot recover historical batching or steering. Older history remains
user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
+1 -1
View File
@@ -1 +1 @@
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
+74 -73
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -14740,16 +14740,52 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.331",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.331.tgz",
"integrity": "sha512-ShBTb3QF0ul/i55lfRdJpcu0hlA4aJdqRdzzHdOCws2WUzSsG0SCfMhLjzaoO1dVUGNj2Z7PNLqiXnlyqOinbg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.336"
}
},
"node_modules/@xterm/addon-serialize": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode-graphemes": {
"version": "0.5.0-beta.330",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode-graphemes/-/addon-unicode-graphemes-0.5.0-beta.330.tgz",
"integrity": "sha512-Zoiv/8tmku4LcU7bEL6zer+DNN6p6F8VCkyviSmbDxpA/W9zSnWDiD/ZpwFDQqegwjSptGzBNMhW2Os1sa+lUw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.336"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.329",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.329.tgz",
"integrity": "sha512-bwqMma7X4cCBfwmWqKuhxNLkIXKW0lJTapaQ0g/9Kq7n6D6VCL4dYQCQ6DoeHv27ncJD4WzeoDTCEeSn0cspfg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.336"
}
},
"node_modules/@xterm/headless": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
"version": "6.1.0-beta.334",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.334.tgz",
"integrity": "sha512-QkwuIeP9dXlk/eOsAbqX8AvtS/W8EhRo6dkl2YYZr+jWAWSfDDMi+zPlegWUflSdsRKMURMvzvvkVZr6GztK4g==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@xterm/xterm": {
"version": "6.1.0-beta.336",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.336.tgz",
"integrity": "sha512-QrvZZsffXsLW4Z3sba9GqimLvVwlb8b3W1NwZ+l6DdRnpH5pg5cFGWg2ceIburmfaGQ07YJHNekwxxlTcHZ3BA==",
"license": "MIT",
"workspaces": [
"addons/*"
@@ -37596,7 +37632,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37626,13 +37662,14 @@
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
"@xterm/addon-fit": "^0.12.0-beta.213",
"@xterm/addon-image": "^0.10.0-beta.213",
"@xterm/addon-image": "0.10.0-beta.331",
"@xterm/addon-ligatures": "^0.11.0-beta.213",
"@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "0.20.0-beta.291",
"@xterm/xterm": "6.1.0-beta.292",
"@xterm/addon-webgl": "0.20.0-beta.329",
"@xterm/xterm": "6.1.0-beta.336",
"buffer": "^6.0.3",
"expo": "~57.0.30",
"expo-asset": "~57.0.20",
@@ -37710,7 +37747,7 @@
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213",
"@xterm/headless": "6.1.0-beta.334",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
@@ -38489,15 +38526,6 @@
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.213.tgz",
"integrity": "sha512-L3QxLkj8g8TioVea9p1oVze52Jr75rRZPPLm1tgZl0X2EGJGFHnQzE3QRsgmYWI7R5j3kwwi3MRAkOAgvOK8uA==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-ligatures": {
"version": "0.11.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.213.tgz",
@@ -38541,34 +38569,6 @@
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.291",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz",
"integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.292"
}
},
"packages/app/node_modules/@xterm/headless": {
"version": "6.1.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.213.tgz",
"integrity": "sha512-D08E4Bl0HRhYZRL2o5PxD4FNQN9qXpI8QSw3CkKrsym5dtTRAqsynVYE8V8echQrxfb+EpR0CB5reM7tEGZ/fA==",
"dev": true,
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"packages/app/node_modules/@xterm/xterm": {
"version": "6.1.0-beta.292",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz",
"integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"packages/app/node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.36.1",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz",
@@ -39800,12 +39800,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/server": "0.15.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -40052,10 +40052,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -40067,7 +40067,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40311,7 +40311,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40357,7 +40357,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40386,15 +40386,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40404,8 +40404,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40427,7 +40427,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40454,7 +40454,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40675,22 +40675,23 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/highlight": "0.15.0-beta.1",
"@getpaseo/plugin": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.0.0",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/headless": "6.1.0-beta.334",
"ai": "5.0.78",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
@@ -41630,7 +41631,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43240,7 +43241,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -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 {
@@ -1,4 +1,5 @@
import { writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
await agent.cleanup();
}
});
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
await page.getByRole("link", { name: linkName, exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
content,
);
}
async function returnToNestedLinks(page: Page): Promise<void> {
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
}
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
page,
}, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "nested-file-link-",
title: "Nested file links",
featureValues: {
mockStreamingAssistantResponse:
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
mockStreamingAssistantIntervalMs: 5,
},
});
try {
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
await writeFile(
path.join(agent.cwd, "repo-a/schema/x.md"),
"# Nested file content\n\nDetails\n",
);
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
await readLinkedGuide(page, "nested guide", "Nested file content");
await returnToNestedLinks(page);
await readLinkedGuide(page, "exact guide", "Exact file content");
await returnToNestedLinks(page);
await page.getByRole("link", { name: "missing guide", exact: true }).click();
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
} finally {
await agent.cleanup();
}
});
@@ -1,5 +1,9 @@
import { expect, test, type Page } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
attachFileFromMenu,
composerLocator,
expectComposerVisible,
} from "../support/helpers/composer";
import {
openAgentRoute,
seedMockAgentWorkspace,
@@ -365,6 +369,56 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
const requests = recordCommandRequestsByAgentId(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "suggested-command-",
title: "Suggested commands",
initialPrompt: "Suggest `/mock` without running it",
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
});
try {
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
await expect(message).toBeVisible();
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
await expect.poll(() => requests.length).toBe(1);
await attachFileFromMenu(page, {
name: "note.txt",
mimeType: "text/plain",
buffer: Buffer.from("attachment"),
});
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
await message.getByRole("link", { name: "/mock", exact: true }).click();
await expect(composerLocator(page)).toHaveValue("/mock ");
await expect(
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
).toHaveCount(0);
await expect(composerLocator(page)).toBeFocused();
await message.getByRole("link", { name: "Run mock" }).click();
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
await expect(
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
).toHaveCount(0);
await expect(composerLocator(page)).toBeFocused();
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
await composerLocator(page).fill("draft");
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
element.setSelectionRange(0, 0),
);
await message.getByRole("link", { name: "/mock", exact: true }).click();
await composerLocator(page).pressSequentially("argument");
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
await composerLocator(page).fill("/");
await expect(
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
).toContainText("/mock");
expect(requests).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
page,
}) => {
@@ -81,6 +81,8 @@ for (const folder of [
"docs/guides",
"docs/guides/",
"docs/指南",
"examples/next.js/",
"examples/next.js",
]) {
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
@@ -92,6 +94,9 @@ for (const folder of [
try {
await mkdir(path.join(session.cwd, folder), { recursive: true });
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
await mkdir(path.dirname(competingFile), { recursive: true });
await writeFile(competingFile, "This is a file, not the requested folder.\n");
await session.client.waitForFinish(session.agentId, 30_000);
await page.addInitScript(() => {
localStorage.setItem(
@@ -0,0 +1,12 @@
import { test } from "../support/fixtures";
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
test.setTimeout(120_000);
await withComposerControlsExample(page, async (composer) => {
await composer.narrowTheWindow();
await composer.openAgentFeatures();
await composer.expectFeatureRow("Needs review");
await composer.expectFeatureRow("Insert checklist");
});
});
@@ -0,0 +1,92 @@
import { expect, test } from "../support/fixtures";
import { withNewWorkspaceIssuesExample } from "../support/helpers/plugin-new-workspace-items";
for (const layout of [
{ name: "desktop", compact: false },
{ name: "compact", compact: true },
]) {
test(`a plugin's New workspace item sets the branch and metadata on ${layout.name}`, async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(page, { compact: layout.compact }, async (screen) => {
await screen.chooseWorktree();
await screen.chooseIssue("PAS-102 Crash on resume");
const created = await screen.createWorkspace();
expect(created.branch).toBe("pas-102-crash-on-resume");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-102" });
});
});
}
test("a branch chosen while Local applies once the user switches to a new worktree", async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(page, { compact: false }, async (screen) => {
await screen.chooseIssue("PAS-101 Slow sidebar");
await screen.chooseWorktree();
const created = await screen.createWorkspace({ prompt: "" });
expect(created.branch).toBe("pas-101-slow-sidebar");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-101" });
});
});
test("a plugin's branch applies when the project has no default base to branch off", async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, detachedHead: true },
async (screen) => {
await screen.chooseWorktree();
await screen.chooseIssue("PAS-101 Slow sidebar");
const created = await screen.createWorkspace();
expect(created.branch).toBe("pas-101-slow-sidebar");
},
);
});
test("a branch and metadata chosen for one project are not sent for another", async ({ page }) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, otherProject: true },
async (screen) => {
await screen.chooseWorktree();
await screen.chooseIssue("PAS-101 Slow sidebar");
await screen.switchToOtherProject();
const created = await screen.createWorkspace();
expect(created.branch).not.toBe("pas-101-slow-sidebar");
expect(created.metadata ?? {}).toEqual({});
},
);
});
test("an isolation set from a mount effect is remembered like a pick", async ({ page }) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, setsOnMount: true },
async (screen) => {
await screen.expectIsolation("worktree");
await screen.reopenWithoutPlugin();
await screen.expectIsolation("worktree");
},
);
});
test("an item can set the branch and metadata from its mount effect", async ({ page }) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, setsOnMount: true },
async (screen) => {
await screen.expectIsolation("worktree");
const created = await screen.createWorkspace();
expect(created.branch).toBe("set-on-mount");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "ON-MOUNT" });
},
);
});
@@ -38,6 +38,9 @@ import {
fillDirectHostUri,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostSslDisabled,
fillDirectHostAddress,
toggleDirectHostSsl,
expectDirectHostUriValue,
expectDirectHostUriHidden,
expectDiagnosticsContent,
@@ -284,6 +287,16 @@ test.describe("Settings sidebar navigation", () => {
await expectAddHostMethodOptions(page);
await selectHostConnectionType(page, "direct");
await fillDirectHostAddress(page, "example.paseo.test", "7443");
await expectDirectHostSslDisabled(page);
await toggleDirectHostSsl(page);
await expectDirectHostSslEnabled(page);
await toggleHostAdvanced(page);
await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true");
await toggleHostAdvanced(page);
await toggleDirectHostSsl(page);
await expectDirectHostSslDisabled(page);
await toggleHostAdvanced(page);
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await toggleHostAdvanced(page);
@@ -426,3 +426,81 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel
await seed.cleanup();
}
});
function workspaceRows(page: Page, ids: string[]) {
return page.locator(
ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "),
);
}
async function openWorkspaceRow(page: Page, workspaceId: string) {
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click();
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
}
test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({
page,
}) => {
test.setTimeout(120_000);
const { seed, ids } = await seedCompactProject();
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await selectWorkspaceSort(page, "Created");
const { rows, group } = compactProjectRows(page, seed);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await revealWorkspaces(group, "Show more");
await openWorkspaceRow(page, ids[9]!);
await expectWorkspaceOrder(rows, ids);
await revealWorkspaces(group, "Show less");
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
).toHaveAttribute("aria-selected", "true");
await reconnectSidebar(page);
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
).toHaveAttribute("aria-selected", "true");
await openWorkspaceRow(page, ids[2]!);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`),
).toHaveAttribute("aria-selected", "true");
} finally {
await seed.cleanup();
}
});
test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({
page,
}) => {
test.setTimeout(120_000);
const { seed, ids } = await seedCompactProject();
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
const projectKey = projectEquivalenceViewKey(seed.projectKey);
await restorePreBetaOrder(
page,
projectKey,
ids.map((id) => `${getServerId()}:${id}`),
);
await selectWorkspaceSort(page, "Manual");
const rows = workspaceRows(page, ids);
const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await toggle.click();
await openWorkspaceRow(page, ids[9]!);
await toggle.click();
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow);
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
await reconnectSidebar(page);
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
await toggle.click();
await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]);
} finally {
await seed.cleanup();
}
});
+7 -59
View File
@@ -407,86 +407,34 @@ async function seedWholeTurnHistory() {
return agent;
}
async function expectInitialFillIsConcealed(page: Page) {
await expect(page.getByTestId("agent-initial-fill")).toBeVisible();
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0");
}
async function expectFilledChatAtBottom(page: Page) {
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
const chat = page.getByTestId("agent-chat-scroll");
await expect(chat).toHaveCSS("opacity", "1");
await expect
.poll(() =>
chat.evaluate((element) => ({
hasOverflow: element.scrollHeight > element.clientHeight,
atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2,
})),
)
.toEqual({ hasOverflow: true, atBottom: true });
}
test("fills collapsed history before revealing a fresh chat", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
await history.expectRequestedPages(1);
await expectInitialFillIsConcealed(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectInitialFillIsConcealed(page);
history.releasePage(2);
await expectFilledChatAtBottom(page);
await page.setViewportSize({ width: 1280, height: 8000 });
await history.expectRequestedPages(3);
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1");
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
history.releasePage(3);
await history.expectSettledWithRequestedPages(3);
history.expectNoRepeatedEntries();
} finally {
await agent.cleanup();
}
});
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectRequestedPages(2);
history.releasePage(2);
await expectFilledChatAtBottom(page);
await expectComposerVisible(page);
const latest = page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 120 commands", exact: true });
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await expect(
page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(3);
history.releasePage(3);
await page.getByTestId("scroll-to-bottom-button").click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
@@ -70,25 +70,31 @@ test("previous and next traverse every visible workspace while digit shortcuts s
workspaceId: order[8],
});
});
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
await expectHiddenWorkspaceShortcuts(page, order);
await test.step("collapsed groups keep shortcut targets selected without expanding", async () => {
await expectCollapsedWorkspaceShortcuts(page, order);
});
} finally {
await project.cleanup();
}
});
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) {
await page.getByLabel("Show less", { exact: true }).click();
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(7);
await expect(rows).toHaveCount(8);
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await expect(rows).toHaveCount(7);
await page.keyboard.press("Alt+9");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
await expect(rows).toHaveCount(8);
await page.keyboard.press("Alt+]");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] });
await expect(rows).toHaveCount(8);
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
0,
);
await page.keyboard.press("Alt+]");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
await expect(rows).toHaveCount(7);
await expect(page.getByLabel("Show more", { exact: true })).toBeVisible();
}
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import xterm from "@xterm/headless";
import { PNG } from "pngjs";
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
@@ -82,6 +83,8 @@ async function fixtureTerminal(size) {
rows: size.rows,
allowProposedApi: true,
});
// The app's buffer uses grapheme widths; the expected cells must too.
terminal.loadAddon(new UnicodeGraphemesAddon());
await new Promise((done) =>
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
);
@@ -0,0 +1,59 @@
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
const PLUGIN_ID = "composer-controls-example";
const NARROW = { width: 500, height: 800 };
/** Runs `run` in an agent's composer with the composer-controls example installed. */
export async function withComposerControlsExample(
page: Page,
run: (composer: {
narrowTheWindow(): Promise<void>;
openAgentFeatures(): Promise<void>;
expectFeatureRow(name: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({
repoPrefix: "composer-items-",
title: "Composer items",
});
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Composer items agent",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("composer-controls");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await gotoAppShell(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await run({
narrowTheWindow: () =>
test.step("narrow the window until the composer folds its controls away", () =>
page.setViewportSize(NARROW)),
openAgentFeatures: () =>
test.step("open the agent features sheet", async () => {
await page.getByRole("button", { name: "Open agent features", exact: true }).click();
}),
expectFeatureRow: (name) =>
test.step(`the sheet has a row for "${name}"`, () =>
expect(page.getByRole("button", { name, exact: true })).toBeVisible()),
});
} finally {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
@@ -0,0 +1,173 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import {
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected,
openNewWorkspaceComposer,
openProjectViaDaemon,
submitNewWorkspacePrompt,
} from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { getServerId } from "./server-id";
import { createTempGitRepo } from "./workspace";
import { waitForSidebarHydration } from "./workspace-ui";
/** Presses Create, typing the prompt first unless the test wants an empty one. */
async function submitCreate(page: Page, prompt: string | undefined) {
if (prompt !== "") return submitNewWorkspacePrompt(page, prompt);
await page.getByTestId("message-input-root").getByRole("button", { name: "Create" }).click();
}
/** An item that sets the branch and metadata from its mount effect, before the user touches anything. */
async function replaceWithMountEffectItem(directory: string) {
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useEffect } from "react";
import { useNewWorkspace } from "@getpaseo/plugin/client";
import { Action } from "@getpaseo/plugin/client/ui";
function OnMount() {
const newWorkspace = useNewWorkspace();
useEffect(() => {
void newWorkspace.setIsolation("worktree");
void newWorkspace.setBranch("set-on-mount");
void newWorkspace.setMetadata({ "new-workspace-issues.issue": "ON-MOUNT" });
}, []);
return <Action title="Mount" icon="Flag" onPress={() => {}} />;
}
export default function contribute(client) {
client.addNewWorkspaceItem({ id: "on-mount", Component: OnMount });
return () => {};
}
`,
);
}
type Client = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
/** The daemon fills in the git runtime after it creates the workspace, so wait for the branch. */
async function readCreatedWorkspace(client: Client, workspaceId: string) {
const deadline = Date.now() + 30_000;
for (;;) {
const { entries } = await client.fetchWorkspaces();
const workspace = entries.find((entry) => entry.id === workspaceId);
const branch = workspace?.gitRuntime?.currentBranch ?? null;
if (workspace && branch) return { branch, metadata: workspace.metadata };
if (Date.now() > deadline) throw new Error(`Workspace ${workspaceId} never reported a branch`);
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
const PLUGIN_ID = "new-workspace-issues-example";
const COMPACT = { width: 500, height: 800 };
/** Runs `run` on the New workspace screen with the new-workspace-issues example installed. */
export async function withNewWorkspaceIssuesExample(
page: Page,
options: {
compact: boolean;
detachedHead?: boolean;
setsOnMount?: boolean;
/** A second project the screen can switch to. */
otherProject?: boolean;
},
run: (screen: {
chooseWorktree(): Promise<void>;
chooseIssue(label: string): Promise<void>;
createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>;
switchToOtherProject(): Promise<void>;
expectIsolation(isolation: "local" | "worktree"): Promise<void>;
/** Removes the plugin, leaves the screen, and opens New workspace for the project again. */
reopenWithoutPlugin(): Promise<void>;
}) => Promise<void>,
) {
const client = await connectNewWorkspaceDaemonClient();
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("new-workspace-issues");
if (options.setsOnMount) await replaceWithMountEffectItem(example.directory);
const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] });
const otherRepo = options.otherProject
? await createTempGitRepo("new-workspace-other-", { branches: ["main"] })
: null;
let createdDirectory: string | null = null;
try {
// A detached HEAD leaves the screen with no default base to branch off.
if (options.detachedHead) execFileSync("git", ["checkout", "--detach"], { cwd: repo.path });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
const project = await openProjectViaDaemon(client, repo.path);
const other = otherRepo ? await openProjectViaDaemon(client, otherRepo.path) : null;
let target = project;
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
// The sidebar rows used to reach the screen exist only at desktop width.
if (options.compact) await page.setViewportSize(COMPACT);
await run({
chooseWorktree: () =>
test.step("choose a new worktree", async () => {
await page.getByTestId("workspace-create-isolation-trigger").click();
await page.getByTestId("workspace-create-isolation-worktree").click();
}),
chooseIssue: (label) =>
test.step(`choose the issue "${label}"`, async () => {
await page.getByRole("button", { name: "Issue", exact: true }).click();
await page.getByText(label, { exact: true }).click();
}),
switchToOtherProject: () =>
test.step("switch to the other project", async () => {
if (!other) throw new Error("Open the example with otherProject to switch projects");
await page.getByRole("button", { name: "Workspace project" }).click();
await page
.getByTestId("combobox-desktop-container")
.last()
.getByText(other.projectDisplayName, { exact: true })
.click();
await expect(page.getByRole("button", { name: "Workspace project" })).toContainText(
other.projectDisplayName,
);
target = other;
}),
expectIsolation: (isolation) =>
test.step(`the screen is on ${isolation}`, () =>
expectWorkspaceIsolationSelected(page, isolation)),
reopenWithoutPlugin: () =>
test.step("reopen the screen without the plugin", async () => {
await client.removePlugin(PLUGIN_ID);
// Leaving the screen drops its in-memory choice, so only the saved preference is left.
await page.goBack();
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
await openNewWorkspaceComposer(page, project);
await expect(page.getByRole("button", { name: "Mount", exact: true })).toHaveCount(0);
}),
createWorkspace: (createOptions) =>
test.step("create the workspace and read what it carries", async () => {
await submitCreate(page, createOptions?.prompt);
const created = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: target.workspaceId,
projectDisplayName: target.projectDisplayName,
// The sidebar is closed at compact width.
assertSidebarRow: !options.compact,
});
createdDirectory = created.workspaceDirectory;
return readCreatedWorkspace(client, created.workspaceId);
}),
});
} finally {
if (createdDirectory) {
await archiveWorkspaceFromDaemon(client, createdDirectory).catch(() => undefined);
}
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await repo.cleanup();
await otherRepo?.cleanup();
}
}
@@ -351,9 +351,24 @@ export async function expectDirectHostFormValues(
}
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).toBeChecked();
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
}
export async function fillDirectHostAddress(page: Page, host: string, port: string): Promise<void> {
await page.getByRole("textbox", { name: "Host", exact: true }).fill(host);
await page.getByRole("textbox", { name: "Port", exact: true }).fill(port);
}
export async function toggleDirectHostSsl(page: Page): Promise<void> {
await page.getByRole("checkbox", { name: "Use SSL", exact: true }).click();
}
export async function expectDirectHostSslDisabled(page: Page): Promise<void> {
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).not.toBeChecked();
await expect(page.getByTestId("direct-ssl-toggle-checked")).toHaveCount(0);
}
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
}
@@ -1,21 +1,21 @@
[
{
"output": "स्ते",
"row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
"row": "4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "क्ष",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "নক্ষত্র",
"row": "1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
"clusterStarts": [0, 1, 3, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
"row": "1,1,3,1,3,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "क्अ",
@@ -31,7 +31,7 @@
},
{
"output": "अ्क",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
}
@@ -1,6 +1,5 @@
package sh.paseo.terminal
import android.os.Build
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
@@ -44,13 +43,6 @@ class TerminalRowLayoutTest {
val output = row.getString("output")
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
// Android 10's platform ICU splits this conjunct. Keep its observed raster baseline
// explicit rather than claiming this supported OS passes the modern grapheme corpus.
if (Build.VERSION.SDK_INT == 29 && output == "स्ते") {
assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE)
assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE)
continue
}
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
}
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.14.0",
"version": "0.15.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -71,13 +71,14 @@
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
"@xterm/addon-fit": "^0.12.0-beta.213",
"@xterm/addon-image": "^0.10.0-beta.213",
"@xterm/addon-image": "0.10.0-beta.331",
"@xterm/addon-ligatures": "^0.11.0-beta.213",
"@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "0.20.0-beta.291",
"@xterm/xterm": "6.1.0-beta.292",
"@xterm/addon-webgl": "0.20.0-beta.329",
"@xterm/xterm": "6.1.0-beta.336",
"buffer": "^6.0.3",
"expo": "~57.0.30",
"expo-asset": "~57.0.20",
@@ -155,7 +156,7 @@
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213",
"@xterm/headless": "6.1.0-beta.334",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
@@ -1,8 +1,16 @@
import type { QueryClient } from "@tanstack/react-query";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import equal from "fast-deep-equal";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
export const sessionCommandQueryPolicy = {
staleTime: 600_000,
gcTime: 1_800_000,
refetchOnMount: true,
refetchOnWindowFocus: false,
} as const;
export interface AgentCommandsDraftConfig {
provider: AgentProvider;
@@ -10,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 {
@@ -21,6 +29,46 @@ export function agentCommandsQueryRoot(serverId: string) {
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
}
/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial
* discovery and unrelated providers; explicit refresh still invalidates every catalog. */
export function invalidateCommandsForProviderChange(input: {
queryClient: QueryClient;
serverId: string;
cwd: string | null;
previous: readonly ProviderSnapshotEntry[] | undefined;
current: readonly ProviderSnapshotEntry[];
}): Promise<void> {
const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry]));
const current = new Map(input.current.map((entry) => [entry.provider, entry]));
const relevant = (entry: ProviderSnapshotEntry | undefined) =>
entry && {
status: entry.status,
enabled: entry.enabled,
source: entry.source,
models: entry.models,
modes: entry.modes,
defaultModeId: entry.defaultModeId,
};
return input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
predicate: (query) => {
const isDraft = query.queryKey[2] === "draft";
const provider =
query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined);
const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined);
if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd)
return false;
if (typeof provider !== "string") return false;
const before = previous.get(provider);
const after = current.get(provider);
if (!before) return after?.status === "ready" && query.state.status === "error";
if (before.status !== "ready")
return after?.status === "ready" && query.state.status === "error";
return !equal(relevant(before), relevant(after));
},
});
}
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
}
@@ -50,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
"thinking",
draftConfig.thinkingOptionId ?? null,
"features",
draftConfig.featureValues ?? null,
draftConfig.providerOptions ?? null,
] as const;
}
+99
View File
@@ -0,0 +1,99 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { ComposerEditingSession } from "@/composer/editing-session";
import { agentCommandsQueryKey } from "./cache";
import {
useAgentCommandsQuery,
type AgentSlashCommand,
type DraftCommandConfig,
} from "./use-query";
export type {
AgentSlashCommand,
DraftCommandConfig,
DraftCommandTarget,
AgentCommandsClient,
} from "./use-query";
export { fetchAgentCommands } from "./use-query";
type CommandQuery = ReturnType<typeof useAgentCommandsQuery>;
const QueryContext = createContext<CommandQuery | null>(null);
export interface CommandHints {
names: ReadonlySet<string>;
insert(command: string): void;
}
const HintsContext = createContext<CommandHints | null>(null);
function useLiveAgentCommands() {
return useContext(QueryContext);
}
export function useAutocompleteCommands(input: {
serverId: string;
agentId: string;
enabled: boolean;
draftConfig?: DraftCommandConfig;
}) {
const live = useLiveAgentCommands();
const fallback = useAgentCommandsQuery({
serverId: input.serverId,
agentId: input.agentId,
enabled: !live && input.enabled,
draftConfig: input.draftConfig,
});
return live ?? fallback;
}
export function useAgentCommandHints() {
return useContext(HintsContext);
}
export function matchCommandLink(
url: string,
sourceType: "markdown" | "text" | "inline-code" | undefined,
names: ReadonlySet<string>,
): string | null {
return (sourceType === "markdown" || sourceType === "inline-code") &&
url.startsWith("/") &&
names.has(url.slice(1))
? url
: null;
}
export function AgentCommandsProvider({
serverId,
agentId,
editingSession,
enabled,
children,
}: {
serverId: string;
agentId: string;
editingSession: ComposerEditingSession;
enabled: boolean;
children: ReactNode;
}) {
const query = useAgentCommandsQuery({ serverId, agentId, enabled });
const queryClient = useQueryClient();
const namesKey = JSON.stringify(
[...new Set(query.commands.map((command) => command.name))].sort(),
);
const names = useMemo<ReadonlySet<string>>(
() => new Set(JSON.parse(namesKey) as string[]),
[namesKey],
);
const insert = useCallback(
(token: string) => {
const commands = queryClient.getQueryData<AgentSlashCommand[]>(
agentCommandsQueryKey({ serverId, agentId }),
);
if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) {
editingSession.insertCommand(token);
}
},
[agentId, editingSession, queryClient, serverId],
);
const hints = useMemo(() => ({ names, insert }), [names, insert]);
return (
<QueryContext.Provider value={enabled ? query : null}>
<HintsContext.Provider value={enabled ? hints : null}>{children}</HintsContext.Provider>
</QueryContext.Provider>
);
}
@@ -1,11 +1,61 @@
import { describe, expect, it } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { fetchQueryOptions } from "@/data/query";
import {
agentCommandsQueryKey,
agentCommandsQueryRoot,
draftAgentCommandsQueryKey,
normalizeAgentCommandsCwd,
sessionAgentCommandsQueryKey,
} from "./agent-commands-query";
sessionCommandQueryPolicy,
} from "./cache";
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } });
const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" });
let calls = 0;
let fail = false;
let resolveRefresh: (value: string[]) => void = () => {};
const refresh = new Promise<string[]>((resolve) => {
resolveRefresh = resolve;
});
const options = fetchQueryOptions({
...sessionCommandQueryPolicy,
staleTimeMs: sessionCommandQueryPolicy.staleTime,
dataShape: "value",
queryKey: key,
retry: false,
queryFn: async () => {
calls++;
if (fail) throw new Error("offline");
return refresh;
},
});
client.setQueryData(key, ["old"]);
const fresh = new QueryObserver(client, options);
const unsubscribeFresh = fresh.subscribe(() => {});
expect(calls).toBe(0);
unsubscribeFresh();
client.setQueryData(key, ["old"], { updatedAt: 1 });
const first = new QueryObserver(client, options);
const second = new QueryObserver(client, options);
const unsubscribeFirst = first.subscribe(() => {});
const unsubscribeSecond = second.subscribe(() => {});
expect(calls).toBe(1);
expect(first.getCurrentResult().data).toEqual(["old"]);
expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000);
resolveRefresh(["new"]);
await refresh;
await first.refetch({ cancelRefetch: false });
expect(second.getCurrentResult().data).toEqual(["new"]);
fail = true;
await client.invalidateQueries({ queryKey: key });
expect(first.getCurrentResult().data).toEqual(["new"]);
expect(first.getCurrentResult().isError).toBe(true);
unsubscribeFirst();
unsubscribeSecond();
client.clear();
});
describe("agent command query keys", () => {
it("shares draft commands across draft tabs with the same provider setup", () => {
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
type AgentCommandsClient,
type DraftCommandConfig,
fetchAgentCommands,
} from "./use-agent-commands-query";
import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query";
type ListCommands = AgentCommandsClient["listCommands"];
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
@@ -38,6 +34,12 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands
}
describe("fetchAgentCommands", () => {
it("rejects a discovery error so a previous catalog is retained", async () => {
const client = createClient({ ...commandsPayload([]), error: "provider unavailable" });
await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow(
"provider unavailable",
);
});
it("loads commands for a draft composer without an agent id", async () => {
const client = createClient(
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
@@ -1,12 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { useFetchQuery } from "@/data/query";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useSessionStore } from "@/stores/session-store";
import { useShallow } from "zustand/shallow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query";
const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY;
const SESSION_COMMANDS_STALE_TIME = 60_000;
import {
agentCommandsQueryKey,
sessionCommandQueryPolicy,
type AgentCommandsDraftConfig,
} from "./cache";
export interface AgentSlashCommand {
name: string;
@@ -41,6 +44,7 @@ export async function fetchAgentCommands(input: {
agentId: input.agentId,
draftConfig: input.draftConfig,
});
if (response.error) throw new Error(response.error);
return response.commands as AgentSlashCommand[];
}
@@ -62,8 +66,16 @@ export function useAgentCommandsQuery({
const queryEnabled = enabled && retainedPanelActive;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const { provider, cwd } = useSessionStore(
useShallow((state) => {
const agent =
state.sessions[serverId]?.agents?.get(agentId) ??
state.sessions[serverId]?.agentDetails?.get(agentId);
return { provider: agent?.provider, cwd: agent?.cwd };
}),
);
const query = useQuery({
const query = useFetchQuery({
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
queryFn: async () => {
if (!client) {
@@ -72,7 +84,15 @@ export function useAgentCommandsQuery({
return fetchAgentCommands({ client, agentId, draftConfig });
},
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME,
dataShape: "value",
meta: {
agentCommandProvider: draftConfig?.provider ?? provider,
agentCommandCwd: draftConfig?.cwd ?? cwd,
},
...(draftConfig
? { immutableWhen: () => true as const }
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
subscribed: queryEnabled,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
});
@@ -84,7 +104,7 @@ export function useAgentCommandsQuery({
return {
commands: query.data ?? [],
isLoading,
isError: query.isError,
isError: query.isError && query.data === undefined,
error: query.error,
};
}
@@ -0,0 +1,149 @@
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 }));
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("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,199 @@
import type {
AgentOptionControl,
AgentSessionConfig,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
/**
* 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;
}
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,
};
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 };
});
}
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,
})),
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 })),
};
}
/**
* 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.
*/
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 own = withoutRemovals({ ...state.seed, ...remembered, ...state.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"
>;
/**
* 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: {
provider: string | null;
cwd: string;
modeId?: string | null;
modelId?: string | null;
thinkingOptionId?: string | null;
},
): 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 -1
View File
@@ -13,6 +13,7 @@ import {
ShieldQuestionMark,
Zap,
} from "lucide-react-native";
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export interface AgentControlIconProps {
@@ -60,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,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;
}
@@ -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";
@@ -173,7 +173,7 @@ describe("openAgentProfileForm", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
options: { webSearch: true },
notes: "Visual work only.",
},
});
@@ -229,10 +229,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 +243,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 +336,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 +368,168 @@ 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 });
});
it("keeps stored feature values while the feature list is still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
featureValues: { webSearch: true },
},
});
model.applyProviderCatalog(ENTRIES);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
});
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 },
]);
expect(model.getState().featureValues).toEqual({ webSearch: true });
expect(model.getState().controls).toEqual([]);
});
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 +562,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,5 +1,6 @@
import type {
AgentFeature,
AgentOptionControl,
ProviderOptions,
AgentMode,
AgentModelDefinition,
AgentSelectOption,
@@ -7,7 +8,6 @@ import type {
} from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
/**
@@ -57,11 +57,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 +77,7 @@ export interface AgentProfileFormDisclosure {
showModelField: boolean;
showModeField: boolean;
showThinkingField: boolean;
showFeaturesField: boolean;
showControlsField: boolean;
}
export interface AgentProfileFormState {
@@ -86,13 +90,18 @@ 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;
providerDisplay: AgentProfileFormDisplay | null;
modelDisplay: AgentProfileFormDisplay | null;
@@ -100,9 +109,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 +127,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 +140,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 +324,29 @@ 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. */
function ownOptionsOf(
state: Pick<AgentProfileFormState, "options" | "optionEdits">,
): ProviderOptions {
return { ...state.options, ...state.optionEdits };
}
/** 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 +358,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 +385,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 +427,27 @@ 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: {},
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,8 +459,10 @@ 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 listeners = new Set<() => void>();
let closed = false;
@@ -436,14 +475,23 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modes,
});
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 +499,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 +528,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 +541,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 +580,24 @@ 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;
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 })),
@@ -572,7 +622,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modeDisplay: null,
thinkingOptionId: "",
thinkingDisplay: null,
featureValues: {},
options: {},
optionEdits: {},
};
}),
setModel: (modelId, display) =>
@@ -598,10 +649,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";
@@ -25,7 +26,7 @@ import type {
} from "../internal/profile-form-model";
import {
useAgentProfileFormCatalog,
useAgentProfileFormFeatures,
useAgentProfileFormControls,
} from "../internal/use-profile-form-inputs";
import {
useAgentProfileFormModel,
@@ -141,7 +142,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>(
() => ({
@@ -328,16 +329,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}
@@ -400,14 +402,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: FieldControlSize;
@@ -418,74 +422,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>
);
@@ -1,238 +1,197 @@
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
createHistoryStartPagination,
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
} from "./history-start-pagination";
const initial: HistoryStartPaginationInput = {
function createArmedHistoryStartPaginationState() {
return rearmHistoryStartPagination(createHistoryStartPaginationState());
}
const visibleHistoryStart: HistoryStartPaginationInput = {
distanceFromHistoryStart: 0,
isUnderfilled: true,
hasOlderHistory: true,
isLoadingOlderHistory: false,
isReady: true,
progressKey: "20",
progressKey: "epoch-1:20",
};
function harness(initiallyVisible = false) {
let geometry = { ...initial };
const requests: Array<(value: boolean) => void> = [];
let settles = 0;
const pagination = createHistoryStartPagination({
initiallyVisible,
read: () => geometry,
load: () => new Promise<boolean>((resolve) => requests.push(resolve)),
changed: () => {},
scheduleSettle: () => {
settles++;
},
});
return {
pagination,
requests,
get settles() {
return settles;
},
set(patch: Partial<HistoryStartPaginationInput>) {
geometry = { ...geometry, ...patch };
pagination.update();
},
};
}
describe("history start pagination", () => {
it("waits for a delayed request and its committed page before settling", async () => {
const h = harness();
h.pagination.update();
h.set({ progressKey: "10" });
expect(h.settles).toBe(0);
h.requests[0](true);
await Promise.resolve();
expect(h.settles).toBe(1);
expect(h.pagination.state.visible).toBe(false);
h.set({ isUnderfilled: false });
h.pagination.settle();
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("waits for the committed page after a request completes", async () => {
const h = harness();
h.pagination.update();
h.requests[0](true);
await Promise.resolve();
expect(h.settles).toBe(0);
h.set({ progressKey: "10" });
expect(h.settles).toBe(1);
h.pagination.dispose();
});
it("continues short pages and reveals on exhaustion", async () => {
const h = harness();
h.pagination.update();
h.set({ progressKey: "10" });
h.requests[0](true);
await Promise.resolve();
h.pagination.settle();
expect(h.requests).toHaveLength(2);
h.set({ hasOlderHistory: false });
h.requests[1](true);
await Promise.resolve();
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("releases a failed or stalled request and allows retry", async () => {
vi.useFakeTimers();
const h = harness();
h.pagination.update();
vi.advanceTimersByTime(15_000);
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
h.pagination.rearm();
expect(h.requests).toHaveLength(2);
h.requests[1](false);
await Promise.resolve();
expect(h.pagination.state.status).toBe("failed");
h.pagination.dispose();
vi.useRealTimers();
});
it("keeps visible history visible while filling", () => {
const h = harness(true);
h.pagination.update();
expect(h.pagination.state.visible).toBe(true);
h.pagination.dispose();
});
it("does not load a full viewport until user intent and respects inactive views", () => {
const h = harness();
h.set({ isUnderfilled: false });
expect(h.requests).toHaveLength(0);
h.set({ isReady: false });
h.pagination.rearm();
expect(h.requests).toHaveLength(0);
h.set({ isReady: true });
expect(h.requests).toHaveLength(1);
h.pagination.dispose();
});
it("preserves the reading position after an anchored page moves away from the start", async () => {
const h = harness(true);
h.set({ isUnderfilled: false });
h.pagination.rearm();
h.set({ progressKey: "10", distanceFromHistoryStart: 300 });
h.requests[0](true);
await Promise.resolve();
h.pagination.settle();
expect(h.requests).toHaveLength(1);
h.set({ distanceFromHistoryStart: 0 });
expect(h.requests).toHaveLength(2);
h.pagination.dispose();
});
});
it("ignores a timed-out response after the reader retries", async () => {
vi.useFakeTimers();
const h = harness();
h.pagination.update();
vi.advanceTimersByTime(15_000);
h.pagination.rearm();
h.requests[0](true);
await Promise.resolve();
expect(h.pagination.state.status).toBe("loading");
expect(h.settles).toBe(0);
h.requests[1](false);
await Promise.resolve();
expect(h.pagination.state.status).toBe("failed");
h.pagination.dispose();
vi.useRealTimers();
});
it("does not repeat a successful page that made no progress", async () => {
vi.useFakeTimers();
const h = harness();
h.pagination.update();
h.requests[0](true);
await Promise.resolve();
h.pagination.update();
expect(h.requests).toHaveLength(1);
vi.advanceTimersByTime(15_000);
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
h.pagination.dispose();
vi.useRealTimers();
});
it("waits until the cursor and active history are available", () => {
const h = harness();
h.set({ isReady: false, progressKey: null });
h.set({ isReady: true, isLoadingOlderHistory: true });
h.set({ isLoadingOlderHistory: false });
expect(h.requests).toHaveLength(0);
h.set({ progressKey: "20" });
expect(h.requests).toHaveLength(1);
h.pagination.dispose();
});
it("reveals immediately on exhausted history without offering retry", () => {
const h = harness();
h.set({ hasOlderHistory: false });
expect(h.requests).toHaveLength(0);
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("keeps a settled page pending while its view is inactive", async () => {
const h = harness();
h.pagination.update();
h.set({ progressKey: "10" });
h.requests[0](true);
await Promise.resolve();
h.set({ isReady: false });
h.pagination.settle();
expect(h.pagination.state.status).toBe("settling");
expect(h.requests).toHaveLength(1);
h.set({ isReady: true, isUnderfilled: false });
h.pagination.settle();
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("keeps cached history visible after synchronization and subsequent pagination", async () => {
const h = harness();
h.pagination.reveal();
h.pagination.update();
h.set({ progressKey: "10" });
h.requests[0](true);
await Promise.resolve();
h.pagination.settle();
expect(h.pagination.state.visible).toBe(true);
expect(h.requests).toHaveLength(2);
h.pagination.dispose();
});
it.each([
[true, 123, 0],
[false, 123, 0],
[true, 0, 123],
[false, 0, 123],
])(
"handles native padding when short history has older=%s",
(hasOlderHistory, topOverlayInset, bottomPadding) => {
const geometry = nativeHistoryStartGeometry({
contentHeight: 825,
viewportHeight: 700,
offsetY: 0,
topOverlayInset,
bottomPadding,
it("treats locally revealed history as progress before remote pagination", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const localReveal = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:20:local-60",
});
let requests = 0;
const pagination = createHistoryStartPagination({
initiallyVisible: false,
read: () => ({ ...initial, ...geometry, hasOlderHistory }),
load: () => {
requests++;
return new Promise<boolean>(() => {});
},
changed: () => {},
scheduleSettle: () => {},
expect(first.shouldLoad).toBe(true);
expect(localReveal.state).toEqual({
status: "settling",
loadedProgressKey: "epoch-1:20:local-60",
});
pagination.update();
expect(requests).toBe(hasOlderHistory ? 1 : 0);
expect(pagination.state.visible).toBe(!hasOlderHistory);
pagination.dispose();
},
);
});
it("waits for user scroll intent before loading", () => {
const dormant = createHistoryStartPaginationState();
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
});
it("waits for anchored page geometry before authorizing another page", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
progressKey: "epoch-1:10",
});
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
true,
false,
false,
]);
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
});
it("loads one page each time anchored geometry leaves and returns to history start", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const pageApplied = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const settledAway = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
progressKey: "epoch-1:10",
});
const returned = evaluateHistoryStartPagination(settledAway.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect([
first.shouldLoad,
pageApplied.shouldLoad,
settledAway.shouldLoad,
returned.shouldLoad,
]).toEqual([true, false, false, true]);
});
it("continues the same loading operation when anchored geometry remains at history start", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const pageApplied = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const continued = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect(continued.shouldLoad).toBe(true);
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
});
it("latches a request that finishes without cursor progress", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
const away = evaluateHistoryStartPagination(duplicate.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
});
expect(finished.state).toEqual({ status: "latched" });
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
false,
false,
false,
]);
expect(away.state).toEqual({ status: "ready" });
});
it("allows another user attempt after a request finishes without progress", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const retried = evaluateHistoryStartPagination(
rearmHistoryStartPagination(failed.state),
visibleHistoryStart,
);
expect(failed.state).toEqual({ status: "latched" });
expect(retried.shouldLoad).toBe(true);
});
it("latches an attempt that becomes invalid before entering flight", () => {
const requested = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
status: "latched",
});
});
it("does not mistake repeated edge observations for a finished request", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
expect(repeated.state).toBe(first.state);
expect(repeated.shouldLoad).toBe(false);
});
it("waits while history loading is unavailable or already active", () => {
const state = createArmedHistoryStartPaginationState();
expect([
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
.shouldLoad,
]).toEqual([false, false, false, false]);
});
});
@@ -1,135 +1,138 @@
export const HISTORY_START_THRESHOLD_PX = 96;
export type HistoryStartPaginationState =
| { status: "dormant" }
| { status: "ready" }
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
| { status: "settling"; loadedProgressKey: string }
| { status: "latched" };
export interface HistoryStartPaginationInput {
distanceFromHistoryStart: number;
isUnderfilled: boolean;
hasOlderHistory: boolean;
isLoadingOlderHistory: boolean;
isReady: boolean;
progressKey: string | null;
}
export interface HistoryStartPaginationState {
status: "dormant" | "ready" | "loading" | "settling" | "failed";
visible: boolean;
export interface HistoryStartPaginationTransition {
state: HistoryStartPaginationState;
shouldLoad: boolean;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { status: "dormant" };
}
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
return state.status === "loading" || state.status === "settling";
}
/** Owns one pagination operation, including the initial viewport reveal. */
export function createHistoryStartPagination(input: {
initiallyVisible: boolean;
read: () => HistoryStartPaginationInput | null;
load: () => Promise<boolean>;
changed: (state: HistoryStartPaginationState) => void;
scheduleSettle: () => void;
}) {
let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible };
let requestedKey: string | null = null;
let completed = false;
let fill = false;
let generation = 0;
let timeout: ReturnType<typeof setTimeout> | undefined;
export function rearmHistoryStartPagination(
state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
}
const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => {
if (status === state.status && visible === state.visible) return;
state = { status, visible };
input.changed(state);
};
const clear = () => {
generation++;
clearTimeout(timeout);
timeout = undefined;
};
const finish = (status: HistoryStartPaginationState["status"]) => {
clear();
publish(status, true);
};
const fail = () => finish("failed");
export function abandonHistoryStartPaginationRequest(
state: HistoryStartPaginationState,
requestedProgressKey: string,
): HistoryStartPaginationState {
if (
state.status !== "loading" ||
state.requestObserved ||
state.requestedProgressKey !== requestedProgressKey
) {
return state;
}
return { status: "latched" };
}
const update = () => {
const geometry = input.read();
if (!geometry) return;
if (state.status === "loading") {
if (!completed) return;
if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) {
publish("settling");
input.scheduleSettle();
} else if (!geometry.hasOlderHistory) {
finish("dormant");
}
return;
export function evaluateHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status === "dormant") {
return { state, shouldLoad: false };
}
if (state.status === "loading") {
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
return {
state: { status: "settling", loadedProgressKey: input.progressKey },
shouldLoad: false,
};
}
if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return;
const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (state.status === "dormant" && !geometry.isUnderfilled) {
publish("dormant", true);
return;
if (input.isLoadingOlderHistory && !state.requestObserved) {
return { state: { ...state, requestObserved: true }, shouldLoad: false };
}
if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return;
if (!geometry.hasOlderHistory) {
finish("dormant");
return;
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
return { state: { status: "latched" }, shouldLoad: false };
}
if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return;
fill = geometry.isUnderfilled;
requestedKey = geometry.progressKey;
completed = false;
publish("loading");
const request = ++generation;
timeout = setTimeout(() => {
if (request === generation) fail();
}, 15_000);
void input.load().then(
(applied) => {
if (request !== generation) return undefined;
if (!applied) {
fail();
return undefined;
}
completed = true;
update();
return undefined;
},
() => {
if (request === generation) fail();
},
);
};
if (
state.requestObserved &&
!input.isLoadingOlderHistory &&
input.progressKey === state.requestedProgressKey
) {
return { state: { status: "latched" }, shouldLoad: false };
}
return { state, shouldLoad: false };
}
if (state.status === "settling") {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (!isAtHistoryStart) {
return state.status === "ready"
? { state, shouldLoad: false }
: { state: { status: "ready" }, shouldLoad: false };
}
if (
state.status === "latched" ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return { state, shouldLoad: false };
}
return {
get state() {
return state;
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
},
update,
settle() {
if (state.status !== "settling") return;
const geometry = input.read();
if (!geometry?.isReady) return;
clear();
if (fill && !geometry.isUnderfilled) {
publish("dormant", true);
} else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
publish("ready", true);
} else if (!geometry.hasOlderHistory) {
publish("dormant", true);
} else {
publish("ready");
update();
}
shouldLoad: true,
};
}
export function settleHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status !== "settling") {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (
!isAtHistoryStart ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return {
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
shouldLoad: false,
};
}
return {
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
},
rearm() {
if (state.status === "dormant" || state.status === "failed") publish("ready", true);
update();
},
reveal() {
publish(state.status, true);
},
reset(visible: boolean) {
clear();
publish("dormant", visible);
},
dispose: clear,
shouldLoad: true,
};
}
@@ -1,13 +0,0 @@
export function nativeHistoryStartGeometry(input: {
contentHeight: number;
viewportHeight: number;
offsetY: number;
topOverlayInset: number;
bottomPadding: number;
}) {
const contentHeight = input.contentHeight - 32 - input.topOverlayInset - input.bottomPadding;
return {
distanceFromHistoryStart: contentHeight - input.viewportHeight - input.offsetY,
isUnderfilled: contentHeight < input.viewportHeight,
};
}
+110 -120
View File
@@ -1,4 +1,12 @@
import { Fragment, type ReactElement, useCallback, useEffect, useMemo, useRef } from "react";
import {
Fragment,
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
FlatList,
Platform,
@@ -11,9 +19,6 @@ import {
} from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Button } from "@/components/ui/button";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useTranslation } from "react-i18next";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -29,11 +34,15 @@ import {
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useHistoryStartPagination } from "./use-history-start-pagination";
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
@@ -56,16 +65,6 @@ const historyStartSlotStyle: ViewStyle = {
flexShrink: 0,
};
const HISTORY_START_SETTLE_FRAMES = 2;
const viewportStyle: ViewStyle = { flex: 1 };
const initialFillStyle: ViewStyle = {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
alignItems: "center",
justifyContent: "center",
};
const NATIVE_STREAM_WINDOW_SIZE = 10;
function keyExtractor(item: { id: string }): string {
@@ -84,8 +83,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
@@ -96,8 +93,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
baseListContentContainerStyle,
strategy,
} = props;
const isActive = useRetainedPanelActive();
const { t } = useTranslation();
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const flatListRef = useRef<FlatList<StreamItem>>(null);
const streamViewportMetricsRef = useRef({
@@ -118,6 +113,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isNativeViewportSettlingRef = useRef(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const historyItems = useMemo(() => {
@@ -133,40 +132,48 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
...nativeHistoryStartGeometry({
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
offsetY: metrics.offsetY,
topOverlayInset,
bottomPadding: streamKeyboardInset.contentContainerPaddingBottom,
}),
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
hasOlderHistory,
isLoadingOlderHistory,
isReady:
isActive &&
isAuthoritativeHistoryReady &&
historyStartReadyRef.current &&
hasMeasuredViewport,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
progressKey: olderHistoryProgressKey,
};
});
const { state: historyStartPaginationState, controller: historyStartPagination } =
useHistoryStartPagination({
initiallyVisible: !isInitialHistoryLoading,
read: getHistoryStartPaginationInput,
load: async () => onNearHistoryStart(),
scheduleSettle: () => scheduleHistoryStartSettle(),
});
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
const resetHistoryStart = useStableEvent(() =>
historyStartPagination.reset(!isInitialHistoryLoading),
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (transition.shouldLoad) {
const requestedProgressKey = olderHistoryProgressKey;
if (requestedProgressKey === null) {
return;
}
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
}
},
);
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
const isHistoryStartSettling = useStableEvent(
() => historyStartPagination.state.status === "settling",
);
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
const evaluateHistoryStart = useStableEvent(() => {
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
});
const scheduleHistoryStartSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -174,9 +181,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
isSettling: () => isHistoryStartSettling(),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
onSettle: () => historyStartPagination.settle(),
onSettle: () => {
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -315,7 +328,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
clearNativeViewportSettling();
isNativeViewportSettlingRef.current = false;
historyStartReadyRef.current = false;
resetHistoryStart();
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -323,18 +338,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [
agentId,
clearNativeViewportSettling,
clearPendingUserScrollEnd,
disposeHistoryStart,
evaluateHistoryStart,
resetHistoryStart,
]);
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
@@ -414,18 +421,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
finishInitialFill();
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll();
historyStartPagination.rearm();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed !== historyStartPaginationStateRef.current) {
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
}
});
useEffect(() => {
if (revealInitialHistory) finishInitialFill();
}, [finishInitialFill, revealInitialHistory]);
// Defer drag end so momentum can take ownership, but capture the terminal
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -499,25 +506,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentHeight: nextContentHeight,
});
evaluateHistoryStart();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
});
useEffect(() => {
evaluateHistoryStart();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartSettle,
isHistoryStartSettling,
]);
const renderItem = useStableEvent(
@@ -562,64 +566,50 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return (
<View style={historyStartSlotStyle} testID="older-history-slot">
{isLoadingOperation && (
{isLoadingOperation ? (
<View testID="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
{t("paneFind.retry")}
</Button>
)}
) : null}
</View>
);
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
}, [historyStartPaginationState]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.
return (
<View style={viewportStyle}>
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
strictMode
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
initialNumToRender={2}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
{!historyStartPaginationState.visible ? (
<View pointerEvents="none" testID="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
strictMode
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
initialNumToRender={2}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
);
}
@@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: async () => true,
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: async () => true,
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
+80 -74
View File
@@ -20,7 +20,6 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useTranslation } from "react-i18next";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
@@ -31,10 +30,15 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./
import { useRevisedHistoryRows } from "./history-row-revision";
import { createStreamStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useHistoryStartPagination } from "./use-history-start-pagination";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
@@ -73,14 +77,6 @@ const historyStartSlotStyle: CSSProperties = {
};
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
const initialFillStyle: CSSProperties = {
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
};
const streamRowStyle: CSSProperties = {
display: "flex",
@@ -302,8 +298,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onReadingPositionChange,
onNearHistoryStart,
@@ -324,9 +318,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
[historyMounted, historyVirtualized, inputSegments],
);
const isActive = useRetainedPanelActive();
const { t } = useTranslation();
const rowsHostRef = useRef<HTMLDivElement | null>(null);
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
@@ -360,6 +351,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const readingAnchor = useMemo(createReadingAnchor, []);
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
@@ -484,39 +479,60 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
return {
distanceFromHistoryStart: scrollContainer.scrollTop,
isUnderfilled:
(rowsHostRef.current?.getBoundingClientRect().height ?? 0) +
(auxiliaryRef.current?.getBoundingClientRect().height ?? 0) <
scrollContainer.clientHeight,
hasOlderHistory,
isLoadingOlderHistory,
// An explicit jump owns the landing position. Revealing another local
// page here changes the virtual index before the target has mounted.
isReady:
historyStartReadyRef.current &&
isAuthoritativeHistoryReady &&
bottomAnchorSettled &&
!isJumpSettling(),
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
progressKey: olderHistoryProgressKey,
};
});
const { state: historyStartPaginationState, controller: historyStartPagination } =
useHistoryStartPagination({
initiallyVisible: !isInitialHistoryLoading,
read: getHistoryStartPaginationInput,
load: async () => onNearHistoryStart(),
scheduleSettle: () => scheduleHistoryStartPrependSettle(),
});
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
const resetHistoryStart = useStableEvent(() =>
historyStartPagination.reset(!isInitialHistoryLoading),
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
return;
}
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
},
);
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
const isHistoryStartSettling = useStableEvent(
() => historyStartPagination.state.status === "settling",
);
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
const evaluateHistoryStart = useStableEvent(() => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
});
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed === historyStartPaginationStateRef.current) {
return;
}
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
});
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -524,12 +540,22 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
isSettling: () => isHistoryStartSettling(),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => {
const input = getHistoryStartPaginationInput();
return !input || input.isLoadingOlderHistory;
},
onSettle: () => historyStartPagination.settle(),
onSettle: () => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -756,7 +782,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
finishInitialFill();
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
@@ -764,10 +789,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
rearmHistoryStartFromUserIntent();
});
useEffect(() => {
if (revealInitialHistory) finishInitialFill();
}, [finishInitialFill, revealInitialHistory]);
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
@@ -806,7 +827,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
useEffect(() => {
resetHistoryStart();
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
readingAnchor.reset();
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
@@ -815,11 +838,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [disposeHistoryStart, evaluateHistoryStart, props.agentId, readingAnchor, resetHistoryStart]);
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -908,18 +930,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics();
}
evaluateHistoryStart();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -958,7 +978,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastObservedViewportGeometryRef.current = nextGeometry;
}
reconcileReadingPosition();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
updateScrollMetrics();
@@ -992,7 +1012,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
forceStickToBottom,
updateScrollMetrics,
measureVirtualizedRows,
@@ -1210,9 +1229,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Detached reading has one owner: reconcileReadingPosition.
overflowAnchor: followOutput ? "auto" : "none",
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
opacity: historyStartPaginationState.visible ? 1 : 0,
};
}, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
const viewportStyle = useMemo(
(): CSSProperties => ({
position: "relative",
@@ -1289,21 +1307,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return (
<div style={historyStartSlotStyle} data-testid="older-history-slot">
{isLoadingOperation && (
{isLoadingOperation ? (
<div data-testid="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</div>
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
{t("paneFind.retry")}
</button>
)}
) : null}
</div>
);
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
}, [historyStartPaginationState]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -1323,7 +1334,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
<div ref={rowsHostRef} style={streamRowsHostStyle}>
<div style={streamRowsHostStyle}>
<div style={virtualRowsContainerStyle} />
{[
...virtualRows.map((virtualRow) => {
@@ -1351,15 +1362,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...mountedRows,
]}
</div>
<div ref={auxiliaryRef}>{liveAuxiliary}</div>
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
{!historyStartPaginationState.visible ? (
<div data-testid="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
{scrollEnabled && !isMobileBreakpoint ? (
<DomOverlayScrollbar
scrollContainerRef={scrollContainerRef}
+1 -3
View File
@@ -87,13 +87,11 @@ export interface StreamRenderInput {
viewportRef: RefObject<StreamViewportHandle | null>;
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
isInitialHistoryLoading?: boolean;
revealInitialHistory?: boolean;
onNearBottomChange: (value: boolean) => void;
// The history row under the top of the viewport, for surfaces that mark where the reader
// is in the transcript. Only the web viewport measures it today.
onReadingPositionChange?: (rowId: string | null) => void;
onNearHistoryStart: () => Promise<boolean>;
onNearHistoryStart: () => boolean | Promise<boolean>;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
@@ -1,33 +0,0 @@
import { useRef, useState } from "react";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
createHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationState,
} from "./history-start-pagination";
export function useHistoryStartPagination(input: {
initiallyVisible: boolean;
read: () => HistoryStartPaginationInput | null;
load: () => Promise<boolean>;
scheduleSettle: () => void;
}) {
const read = useStableEvent(input.read);
const load = useStableEvent(input.load);
const scheduleSettle = useStableEvent(input.scheduleSettle);
const [state, setState] = useState<HistoryStartPaginationState>(() => ({
status: "dormant",
visible: input.initiallyVisible,
}));
const controller = useRef<ReturnType<typeof createHistoryStartPagination> | null>(null);
if (!controller.current) {
controller.current = createHistoryStartPagination({
initiallyVisible: input.initiallyVisible,
read,
load,
scheduleSettle,
changed: setState,
});
}
return { state, controller: controller.current };
}
+3 -13
View File
@@ -281,8 +281,6 @@ export interface AgentStreamViewProps {
turnPresentation: TurnPresentation;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
isInitialHistoryLoading?: boolean;
revealInitialHistory?: boolean;
/** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayTailClearance?: number;
/** Bottom offset required for controls floating above that overlay. */
@@ -339,8 +337,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
turnPresentation,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
isInitialHistoryLoading,
revealInitialHistory,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
@@ -472,6 +468,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
() =>
createInlinePathOpener({
workspaceRoot,
resolveRelativeFiles: true,
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
@@ -1222,8 +1219,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
@@ -1309,7 +1304,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;
}
@@ -1375,12 +1371,6 @@ function agentStreamViewPropsEqual(
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.isInitialHistoryLoading !== right.isInitialHistoryLoading) {
reasons.push("isInitialHistoryLoading");
}
if (left.revealInitialHistory !== right.revealInitialHistory) {
reasons.push("revealInitialHistory");
}
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
reasons.push("bottomOverlayTailClearance");
}
@@ -64,7 +64,10 @@ it("ignores an older probe after a newer file link opens", async () => {
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => {
opened.push(location.path);
},
@@ -91,7 +94,10 @@ it("ignores a pending probe when its view is left", async () => {
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => {
opened.push(location.path);
},
@@ -130,3 +136,45 @@ it("sends folders outside the workspace away from the workspace Files panel", as
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]);
});
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
const { createInlinePathOpener } = await import("./path-open");
const lookup = Promise.withResolvers<{
entries: Array<{ path: string; kind: "file" }>;
error: null;
}>();
const queries: string[] = [];
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
resolveRelativeFiles: true,
client: {
listDirectory: async () => {
throw new Error("Not a directory");
},
getDirectorySuggestions: async ({ query }: { query: string }) => {
queries.push(query);
return lookup.promise;
},
},
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const earlier = opener.open(
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
"side",
);
await Promise.resolve();
expect(queries).toEqual(["schema/x.md"]);
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
await earlier;
expect(opened).toEqual(["guide.md"]);
});
@@ -5,6 +5,7 @@ import {
type OpenFileDisposition,
} from "@/workspace/file-open";
import { isAbsolutePath } from "@/utils/path";
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
interface PathOpenInput {
file?: string;
@@ -50,7 +51,11 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
export function createInlinePathOpener(input: {
workspaceRoot: string;
client: PathOpenInput["client"];
client: {
listDirectory(root: string, path: string): Promise<unknown>;
getDirectorySuggestions: GetDirectorySuggestions;
} | null;
resolveRelativeFiles?: boolean;
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void;
@@ -73,10 +78,21 @@ export function createInlinePathOpener(input: {
} else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) {
const resolvedTarget =
input.resolveRelativeFiles && input.client
? await resolveMissingNestedRelativeFile({
target,
workspaceRoot: input.workspaceRoot,
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
})
: target;
if (ownGeneration !== generation) return;
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
if (!resolved?.file) return;
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
path: resolved.file,
lineStart: resolvedTarget.lineStart,
lineEnd: resolvedTarget.lineEnd,
});
if (location) input.openFile(location, disposition);
}
@@ -7,6 +7,7 @@ import {
classifyForResolution,
fetchDaemonResolution,
getAssistantFileLinkToken,
resolveMissingNestedRelativeFile,
UnresolvedFileLinkError,
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
@@ -424,6 +425,174 @@ describe("fetchDaemonResolution", () => {
});
});
describe("resolveMissingNestedRelativeFile", () => {
const target = {
raw: "schema/x.md:12-14",
path: "/Users/test/project/schema/x.md",
lineStart: 12,
lineEnd: 14,
};
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(searches).toEqual([
{
query: "schema/x.md",
cwd: "/Users/test/project",
matchMode: "suffix",
limit: 1,
},
]);
expect(result).toEqual({
...target,
path: "/Users/test/project/repo-a/schema/x.md",
});
});
it("keeps the exact workspace path when suggestions return that relative path", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(result).toEqual(target);
});
it("keeps the original target when suggestions are empty", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({});
await expect(
resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).resolves.toEqual(target);
});
it("keeps the original target when the daemon throws", async () => {
await expect(
resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions: unavailableSuggestions,
}),
).resolves.toEqual(target);
});
it.each([
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
["a home-relative path", { ...target, path: "~/schema/x.md" }],
])("does not search %s", async (_label, candidate) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target: candidate,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(searches).toEqual([]);
expect(result).toEqual(candidate);
});
it("does not search when the workspace root is blank", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: " ",
getDirectorySuggestions,
});
expect(searches).toEqual([]);
expect(result).toEqual(target);
});
it.each([
["/Users/test/project/schema/x.md", "/Users/test/project"],
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
["C:/repo/schema/x.md", "C:/repo"],
["\\\\server\\share\\schema\\x.md", "//server/share"],
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
await expect(
resolveMissingNestedRelativeFile({
target: absoluteTarget,
workspaceRoot,
getDirectorySuggestions,
}),
).resolves.toEqual(absoluteTarget);
expect(searches).toEqual([]);
});
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
"keeps folder href %s direct",
async (raw) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
});
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
await expect(
resolveMissingNestedRelativeFile({
target: folderTarget,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).resolves.toEqual(folderTarget);
expect(searches).toEqual([]);
},
);
it("normalizes a Windows workspace root before the suffix lookup", async () => {
const windowsTarget = {
raw: "schema/x.md",
path: "C:\\repo\\schema\\x.md",
lineStart: undefined,
lineEnd: undefined,
};
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target: windowsTarget,
workspaceRoot: "C:\\repo\\",
getDirectorySuggestions,
});
expect(searches).toEqual([
{
query: "schema/x.md",
cwd: "C:\\repo\\",
matchMode: "suffix",
limit: 1,
},
]);
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
});
});
describe("getAssistantFileLinkToken", () => {
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect(
@@ -5,6 +5,7 @@ import {
type InlinePathTarget,
} from "./parse";
import { i18n } from "@/i18n/i18next";
import { isAbsolutePath } from "@/utils/path";
export interface AssistantFileLinkSource {
href: string;
@@ -105,6 +106,62 @@ export async function fetchDaemonResolution({
};
}
/** Only file-looking paths nested inside the workspace need suffix search. */
function nestedRelativeLookupQuery(
targetPath: string,
workspaceRoot: string | undefined,
): string | null {
const root = workspaceRoot?.trim();
if (!root) {
return null;
}
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = targetPath.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
if (!normalizedPath.startsWith(prefix)) {
return null;
}
const relative = normalizedPath.slice(prefix.length);
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
return null;
}
return relative;
}
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
* missing, ask the same suffix lookup bare names already use. An exact hit
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
* Lookup failure keeps the original target.
*/
export async function resolveMissingNestedRelativeFile(input: {
target: InlinePathTarget;
workspaceRoot?: string;
getDirectorySuggestions: GetDirectorySuggestions;
}): Promise<InlinePathTarget> {
if (
isAbsoluteInlineCodeToken(input.target.raw) ||
input.target.raw.startsWith("~/") ||
input.target.raw.endsWith("/")
) {
return input.target;
}
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
if (!query) {
return input.target;
}
try {
return await fetchDaemonResolution({
ambiguousQuery: query,
token: input.target.raw,
target: input.target,
workspaceRoot: input.workspaceRoot,
getDirectorySuggestions: input.getDirectorySuggestions,
});
} catch {
return input.target;
}
}
export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
@@ -215,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
}
function isAbsoluteInlineCodeToken(token: string): boolean {
return (
token.startsWith("/") ||
token.toLowerCase().startsWith("file://") ||
/^[A-Za-z]:[\\/]/.test(token)
);
return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
}
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
@@ -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,
+5 -33
View File
@@ -6,15 +6,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import {
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
import { buildDirectConnectionUri } from "./direct-connection-host";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -139,37 +136,11 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error(labels.hostRequired);
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(labels.invalidPort);
}
return serializeConnectionUriForStorage({
host,
port,
isIpv6: isIpv6Host(host),
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
function prepareDirectConnection(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
@@ -420,7 +391,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
advancedTarget.current = new PairingTargetTracker("", true);
try {
setAdvancedUri(
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels),
);
} catch {
setAdvancedUri("");
@@ -505,6 +476,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityRole="checkbox"
accessibilityLabel={t("pairing.direct.fields.useSsl")}
accessibilityState={useTlsAccessibilityState}
aria-checked={useTls}
testID="direct-ssl-toggle"
>
<View style={checkboxStyle}>
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { buildDirectConnectionUri, normalizeDirectConnectionHost } from "./direct-connection-host";
const labels = {
hostRequired: "host required",
invalidPort: "invalid port",
};
function buildUri(host: string, port = "6767"): string {
return buildDirectConnectionUri({ host, port, useTls: false, password: "" }, labels);
}
describe("normalizeDirectConnectionHost", () => {
it.each([
["100.66.69.36", { host: "100.66.69.36", port: null, isIpv6: false }],
[" 100.66.69.36 ", { host: "100.66.69.36", port: null, isIpv6: false }],
["100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
["tcp://100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
["http://100.66.69.36:6767/path?x=1#y", { host: "100.66.69.36", port: "6767", isIpv6: false }],
["HTTPS://example.test", { host: "example.test", port: null, isIpv6: false }],
["my-host.local:7000", { host: "my-host.local", port: "7000", isIpv6: false }],
["[::1]", { host: "::1", port: null, isIpv6: true }],
["[::1]:7000", { host: "::1", port: "7000", isIpv6: true }],
["::1", { host: "::1", port: null, isIpv6: true }],
["fe80::1", { host: "fe80::1", port: null, isIpv6: true }],
["", { host: "", port: null, isIpv6: false }],
])("normalizes %j", (input, expected) => {
expect(normalizeDirectConnectionHost(input)).toEqual(expected);
});
});
describe("buildDirectConnectionUri", () => {
it.each([
["100.66.69.36", "tcp://100.66.69.36:6767"],
["100.66.69.36:6767", "tcp://100.66.69.36:6767"],
["tcp://100.66.69.36:6767", "tcp://100.66.69.36:6767"],
["http://100.66.69.36:6767/path?x", "tcp://100.66.69.36:6767"],
["my-host.local:6767", "tcp://my-host.local:6767"],
["[::1]", "tcp://[::1]:6767"],
["[::1]:7000", "tcp://[::1]:7000"],
["::1", "tcp://[::1]:6767"],
["fe80::1", "tcp://[fe80::1]:6767"],
])("builds a tcp URI from host %j", (host, expected) => {
expect(buildUri(host)).toBe(expected);
});
it("lets a port typed in the host field override the port field", () => {
expect(buildUri("100.66.69.36:7000", "6767")).toBe("tcp://100.66.69.36:7000");
});
it("keeps TLS and password settings", () => {
expect(
buildDirectConnectionUri(
{ host: "tcp://example.test:443", port: "6767", useTls: true, password: "secret" },
labels,
),
).toBe("tcp://example.test:443?ssl=true&password=secret");
});
it.each(["", " ", "tcp://", "http:///path"])("requires a host for %j", (host) => {
expect(() => buildUri(host)).toThrow("host required");
});
it("rejects an invalid port", () => {
expect(() => buildUri("100.66.69.36", "70000")).toThrow("invalid port");
expect(() => buildUri("100.66.69.36:0")).toThrow("invalid port");
});
});
@@ -0,0 +1,70 @@
import { serializeConnectionUriForStorage } from "@/utils/daemon-endpoints";
export interface DirectConnectionHostInput {
host: string;
port: string;
useTls: boolean;
password: string;
}
export interface DirectConnectionHostLabels {
hostRequired: string;
invalidPort: string;
}
export interface NormalizedDirectConnectionHost {
host: string;
/** Port typed into the host field, if any. Overrides the separate port field. */
port: string | null;
isIpv6: boolean;
}
const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
const BRACKETED_IPV6_PATTERN = /^\[([^\]]*)\](?::(\d*))?$/;
const HOST_WITH_PORT_PATTERN = /^([^:]+):(\d+)$/;
/**
* Users often paste `host:port` or a full URL into the host field.
* Reduce it to a bare host (plus an optional port) before building the URI.
*/
export function normalizeDirectConnectionHost(input: string): NormalizedDirectConnectionHost {
let host = input.trim().replace(SCHEME_PATTERN, "");
const suffixIndex = host.search(/[/?#]/);
if (suffixIndex !== -1) {
host = host.slice(0, suffixIndex);
}
const bracketed = BRACKETED_IPV6_PATTERN.exec(host);
if (bracketed) {
return { host: bracketed[1] ?? "", port: bracketed[2] || null, isIpv6: true };
}
const hostWithPort = HOST_WITH_PORT_PATTERN.exec(host);
if (hostWithPort) {
return { host: hostWithPort[1] ?? "", port: hostWithPort[2] ?? null, isIpv6: false };
}
return { host, port: null, isIpv6: host.split(":").length > 2 };
}
export function buildDirectConnectionUri(
draft: DirectConnectionHostInput,
labels: DirectConnectionHostLabels,
): string {
const normalized = normalizeDirectConnectionHost(draft.host);
const port = Number((normalized.port ?? draft.port).trim());
if (!normalized.host) {
throw new Error(labels.hostRequired);
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(labels.invalidPort);
}
return serializeConnectionUriForStorage({
host: normalized.host,
port,
isIpv6: normalized.isIpv6,
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
@@ -35,11 +35,15 @@ export interface MarkdownImageContext {
filePath?: string;
}
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
commandNames?: ReadonlySet<string>,
) {
const occurrenceKey = useId();
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }),
[text, context.workspaceRoot],
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
[text, context.workspaceRoot, commandNames],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
@@ -2,6 +2,8 @@ import { fromMarkdown } from "mdast-util-from-markdown";
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { matchCommandLink } from "@/agent-commands";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
@@ -13,6 +15,28 @@ import { createMarkdownStyles } from "./styles";
import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source";
import { normalizeMarkdownHtml } from "./html";
it("recognizes complete known commands in inline code", () => {
const names = new Set(["code-review", "review:fast"]);
const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, {
commandNames: names,
});
expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true);
expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe(
prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"),
);
const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names });
expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true);
expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review");
});
it("resolves authored root-relative links only when the target is a known command", () => {
const names = new Set(["code-review"]);
expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review");
expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull();
expect(matchCommandLink("/code-review", "text", names)).toBeNull();
expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull();
});
describe("shared Markdown source adaptation", () => {
it("keeps paragraphs, fences and quotes together for the library", () => {
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
@@ -6,6 +6,7 @@ import {
useAssistantFileLinkEnvironment,
} from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event";
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
@@ -48,6 +49,7 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
selectable?: boolean;
streaming?: boolean;
onLinkPress?: (url: string) => unknown;
agentCommands?: boolean;
}
/** All Markdown surfaces share the same engine and source adaptation. */
@@ -59,15 +61,21 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
() => splitStreamingMarkdown(completeLines, true).head.length,
[completeLines],
);
if (!streaming || boundary === 0) return <MarkdownPart {...props} />;
const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart;
if (!streaming || boundary === 0) return <Part {...props} />;
return (
<MarkdownBlockStack>
<MarkdownPart {...props} text={props.text.slice(0, boundary)} />
<MarkdownPart {...props} text={props.text.slice(boundary)} />
<Part {...props} text={props.text.slice(0, boundary)} />
<Part {...props} text={props.text.slice(boundary)} />
</MarkdownBlockStack>
);
});
const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) {
const commandHints = useAgentCommandHints();
return <MarkdownPart {...props} commandHints={commandHints} />;
});
const MarkdownPart = memo(function MarkdownPart({
text,
compact = false,
@@ -75,8 +83,10 @@ const MarkdownPart = memo(function MarkdownPart({
selectable = true,
streaming = false,
onLinkPress,
agentCommands: _agentCommands,
commandHints = null,
...imageContext
}: MarkdownRendererProps) {
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const client =
@@ -84,14 +94,23 @@ const MarkdownPart = memo(function MarkdownPart({
(environment.client?.readFile
? { readFile: environment.client.readFile.bind(environment.client) }
: undefined);
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, {
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
});
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(
text,
{
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
},
commandHints?.names,
);
const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
const command = commandHints && matchCommandLink(url, sourceType, commandHints.names);
if (command) {
commandHints.insert(command);
return;
}
const source = linkSource(url, sourceType);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
+14 -4
View File
@@ -56,7 +56,11 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
};
}
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
function collectInlineCodePaths(
source: string,
workspaceRoot?: string,
commandNames?: ReadonlySet<string>,
): ReadonlySet<string> {
const paths = new Set<string>();
const previousEnds = new Map<number, number>();
// Collect candidates between consecutive runs of equal length. The engine owns
@@ -74,7 +78,11 @@ function collectInlineCodePaths(source: string, workspaceRoot?: string): Readonl
value = value.slice(1, -1);
}
const classification = classifyAssistantFileLink(value, { workspaceRoot });
if (classification && classification.kind !== "external") paths.add(value);
if (
(classification && classification.kind !== "external") ||
(value.startsWith("/") && commandNames?.has(value.slice(1)))
)
paths.add(value);
}
return paths;
}
@@ -112,7 +120,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
export function prepareMarkdownSource(
source: string,
workspaceRoot?: string,
options: { supportsSvg?: boolean } = {},
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
) {
source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = [];
@@ -122,7 +130,9 @@ export function prepareMarkdownSource(
extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
}));
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
);
const edits: { start: number; end: number; text: string }[] = [];
if (
!source.includes("![") &&
+1
View File
@@ -1199,6 +1199,7 @@ export const AssistantMessage = memo(function AssistantMessage({
>
<MarkdownRenderer
text={revealedMessage}
agentCommands
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
client={client}
workspaceRoot={workspaceRoot}
@@ -462,7 +462,7 @@ describe("sidebar group visibility", () => {
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
const items = entries(Array(count).fill("done"));
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
expect(result.current.canToggle).toBe(count > 7);
});
@@ -471,7 +471,9 @@ describe("sidebar group visibility", () => {
"keeps row twelve visible for %s without filling gaps",
(status) => {
const items = entries([...Array(11).fill("done"), status]);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, policy }),
);
expect(names(result.current.visibleItems)).toEqual([
"row-1",
"row-2",
@@ -492,7 +494,11 @@ describe("sidebar group visibility", () => {
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
for (const entriesByKey of [new Map(), hydrated]) {
const { result } = renderHook(() =>
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
useLimitedSidebarGroup({
items,
selection: null,
policy: createWorkspaceVisibilityPolicy(entriesByKey),
}),
);
expect(result.current.canToggle).toBe(true);
expect(names(result.current.visibleItems)).toEqual(
@@ -512,7 +518,7 @@ describe("sidebar group visibility", () => {
"needs_input",
"failed",
]);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(result.current.visibleItems).toEqual(items);
expect(result.current.canToggle).toBe(false);
});
@@ -527,7 +533,7 @@ describe("sidebar group visibility", () => {
"attention",
]);
const { result, rerender, unmount } = renderHook(
({ rows }) => useLimitedSidebarGroup(rows, policy),
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
{ initialProps: { rows: items } },
);
expect(names(result.current.visibleItems)).toEqual([
@@ -568,7 +574,7 @@ describe("sidebar group visibility", () => {
expect(result.current.expanded).toBe(false);
act(() => result.current.toggleExpanded());
unmount();
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(remounted.result.current.expanded).toBe(false);
expect(remounted.result.current.visibleItems).toHaveLength(10);
});
@@ -592,7 +598,12 @@ describe("sidebar group visibility", () => {
lifecycle: "active" as const,
};
const { result, rerender } = renderHook(
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
({ last }) =>
useLimitedSidebarGroup({
items: [...items.slice(0, 11), last],
selection: null,
policy,
}),
{ initialProps: { last: items[11] } },
);
expect(result.current.visibleItems).toHaveLength(7);
@@ -630,7 +641,7 @@ describe("sidebar group visibility", () => {
it("preserves the twenty-row pinned policy regardless of status", () => {
const items = entries(Array(21).fill("running"));
const { result } = renderHook(() => useLimitedSidebarGroup(items));
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
expect(result.current.canToggle).toBe(true);
act(() => result.current.toggleExpanded());
@@ -1598,11 +1598,16 @@ function ProjectBlock({
[workspaceEntriesByKey],
);
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
} = useLimitedSidebarGroup({
items: project.workspaces,
selection: selectionEnabled ? activeWorkspaceSelection : null,
policy: visibilityPolicy,
});
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
@@ -1761,28 +1766,33 @@ function ProjectBlock({
let projectChildren = null;
if (!collapsed) {
if (project.workspaces.length > 0) {
const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null;
projectChildren = (
<>
{manualSort ? (
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
<>
{leadingWorkspaceRow}
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
</>
) : (
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{leadingWorkspaceRow}
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
</View>
)}
@@ -2188,11 +2198,15 @@ function ProjectModeList({
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { pinnedChats, unpinnedProjects } = pinnedGroups;
const {
leadingItem: leadingPinnedChat,
visibleItems: visiblePinnedChats,
expanded: pinnedChatsExpanded,
canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup(pinnedChats);
} = useLimitedSidebarGroup({
items: pinnedChats,
selection: selectionEnabled ? activeWorkspaceSelection : null,
});
const nativeScrollGestureProps = useMemo(
() =>
parentGestureRef
@@ -2395,37 +2409,38 @@ function ProjectModeList({
[renderProjectBlock],
);
const renderPinnedChat = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return (
<MemoWorkspaceRowItem
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
leadingProjectName={workspace.projectName}
leadingProjectIconDataUri={
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
}
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={workspace.projectKind === "git"}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
);
},
const renderPinnedChatRow = useCallback(
(
workspace: SidebarWorkspacePlacement,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => (
<MemoWorkspaceRowItem
key={workspace.workspaceKey}
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
leadingProjectName={workspace.projectName}
leadingProjectIconDataUri={
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
}
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={workspace.projectKind === "git"}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
),
[
activeWorkspaceSelection,
creatingWorkspaceIds,
@@ -2441,6 +2456,22 @@ function ProjectModeList({
],
);
const renderPinnedChat = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return renderPinnedChatRow(workspace, {
drag,
isDragging: isActive,
dragHandleProps,
});
},
[renderPinnedChatRow],
);
const projectBody =
projects.length === 0 ? (
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
@@ -2473,6 +2504,7 @@ function ProjectModeList({
/>
{pinnedCollapsed ? null : (
<>
{leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedChats}
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
type SidebarGroupVisibilityPolicy,
} from "./sidebar-group-visibility";
interface Item extends SidebarGroupItem {
id: number;
statusBucket: SidebarStateBucket;
}
const serverId = "host-a";
function rows(statuses: readonly SidebarStateBucket[], rowServerId = serverId): Item[] {
return statuses.map((statusBucket, index) => ({
id: index + 1,
serverId: rowServerId,
workspaceId: `workspace-${index + 1}`,
statusBucket,
}));
}
const workspacePolicy: SidebarGroupVisibilityPolicy<Item> = {
kind: "workspaces",
getStatusBucket: (item) => item.statusBucket,
};
function selectionFor(item: SidebarGroupItem): ActiveWorkspaceSelection {
return { serverId: item.serverId, workspaceId: item.workspaceId };
}
describe("selectSidebarGroupVisibility", () => {
it("leads with a hidden open row while keeping the baseline order", () => {
const items = rows(Array(9).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: selectionFor(items[8]!),
});
expect(result.leadingItem).toBe(items[8]);
expect(result.visibleItems).toEqual(items.slice(0, 7));
expect(result.canToggle).toBe(true);
});
it("leaves selected rows in place when they are already in the baseline", () => {
const items = rows([...Array(8).fill("done"), "running", "done"]);
const expected = [...items.slice(0, 7), items[8]];
for (const selectedIndex of [2, 8]) {
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: selectionFor(items[selectedIndex]!),
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(expected);
expect(result.canToggle).toBe(true);
}
});
it("shows the whole group without a toggle when the open row is the only hidden row", () => {
const items = rows(Array(8).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: selectionFor(items[7]!),
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items);
expect(result.canToggle).toBe(false);
});
it("keeps expanded groups in their original order", () => {
const items = rows(Array(9).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: true,
policy: workspacePolicy,
selection: selectionFor(items[8]!),
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items);
expect(result.canToggle).toBe(true);
});
it("applies the twenty-row limit to pinned groups", () => {
const items = rows(Array(22).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: { kind: "pinned" },
selection: selectionFor(items[21]!),
});
expect(result.leadingItem).toBe(items[21]);
expect(result.visibleItems).toEqual(items.slice(0, 20));
expect(result.canToggle).toBe(true);
});
it("preserves the current limit when selection is null", () => {
const items = rows(Array(8).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: null,
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items.slice(0, 7));
expect(result.canToggle).toBe(true);
});
it("matches the open row by server and workspace identity", () => {
const items = rows(Array(9).fill("done"), "host-b");
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: { serverId: serverId, workspaceId: items[8]!.workspaceId },
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items.slice(0, 7));
expect(result.canToggle).toBe(true);
});
});
@@ -1,4 +1,28 @@
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
export interface SidebarGroupItem {
serverId: string;
workspaceId: string;
}
export interface SidebarGroupVisibilityInput<T> {
items: readonly T[];
expanded: boolean;
policy: SidebarGroupVisibilityPolicy<T>;
/** The workspace the group's rows highlight as selected. Null when no row can be selected. */
selection: ActiveWorkspaceSelection | null;
}
export interface SidebarGroupVisibility<T> {
/**
* The open workspace when the collapsed limit would hide it. Rendered before `visibleItems`,
* out of group order, outside any drag list. Never also present in `visibleItems`.
*/
leadingItem: T | null;
visibleItems: T[];
canToggle: boolean;
}
export type SidebarGroupVisibilityPolicy<T> =
| { kind: "pinned" }
@@ -16,18 +40,45 @@ export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string
};
}
export function selectSidebarGroupVisibility<T>(
items: readonly T[],
expanded: boolean,
policy: SidebarGroupVisibilityPolicy<T>,
) {
const limit = policy.kind === "pinned" ? 20 : 7;
const baseline = items.filter(
(item, index) =>
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
function isOpenWorkspace(
item: SidebarGroupItem,
selection: ActiveWorkspaceSelection | null,
): boolean {
return (
selection !== null &&
item.serverId === selection.serverId &&
item.workspaceId === selection.workspaceId
);
}
export function selectSidebarGroupVisibility<T extends SidebarGroupItem>(
input: SidebarGroupVisibilityInput<T>,
): SidebarGroupVisibility<T> {
const { items, expanded, policy, selection } = input;
const limit = policy.kind === "pinned" ? 20 : 7;
const baseline: T[] = [];
let leadingItem: T | null = null;
let canToggle = false;
items.forEach((item, index) => {
const inBaseline =
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done");
if (inBaseline) {
baseline.push(item);
} else if (leadingItem === null && isOpenWorkspace(item, selection)) {
leadingItem = item;
} else {
canToggle = true;
}
});
if (expanded || !canToggle) {
return { leadingItem: null, visibleItems: items.slice(), canToggle };
}
return {
visibleItems: expanded ? items.slice() : baseline,
canToggle: baseline.length < items.length,
leadingItem,
visibleItems: baseline,
canToggle,
};
}
@@ -147,6 +147,7 @@ export function SidebarStatusWorkspaceList({
dragGestureHostActive,
}: StatusWorkspaceListProps) {
const { t } = useTranslation();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys,
);
@@ -155,23 +156,27 @@ export function SidebarStatusWorkspaceList({
(state) => state.togglePinnedCollapsed,
);
const {
leadingItem: leadingPinnedWorkspace,
visibleItems: visiblePinnedWorkspaces,
expanded: pinnedWorkspacesExpanded,
canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup(pinnedWorkspaces);
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
: EMPTY_SHORTCUT_INDEX;
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => (
const renderPinnedWorkspaceRow = useCallback(
(
workspace: SidebarWorkspaceEntry,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
@@ -184,9 +189,9 @@ export function SidebarStatusWorkspaceList({
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
),
[
@@ -199,6 +204,20 @@ export function SidebarStatusWorkspaceList({
supportsPinningByServerId,
],
);
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
renderPinnedWorkspaceRow(workspace, {
drag,
isDragging: isActive,
dragHandleProps,
}),
[renderPinnedWorkspaceRow],
);
const content = (
<>
{pinnedWorkspaces.length > 0 ? (
@@ -211,6 +230,7 @@ export function SidebarStatusWorkspaceList({
/>
{pinnedCollapsed ? null : (
<>
{leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedWorkspaces}
@@ -345,12 +365,37 @@ function StatusGroupRows({
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY);
} = useLimitedSidebarGroup({
items: group.rows,
selection: activeWorkspaceSelection,
policy: STATUS_GROUP_VISIBILITY,
});
function renderWorkspaceRow(workspace: SidebarWorkspaceEntry) {
return (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
);
}
return (
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
@@ -360,22 +405,8 @@ function StatusGroupRows({
style={styles.statusWorkspaceListContainer}
testID={`sidebar-status-group-rows-${group.key}`}
>
{visibleWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
{leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null}
{visibleWorkspaces.map(renderWorkspaceRow)}
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
@@ -1,19 +1,32 @@
import { useCallback, useMemo, useState } from "react";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
type SidebarGroupVisibility,
type SidebarGroupVisibilityPolicy,
} from "./sidebar-group-visibility";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
const PINNED_POLICY = { kind: "pinned" } as const;
export function useLimitedSidebarGroup<T>(
items: readonly T[],
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY,
) {
interface LimitedSidebarGroupInput<T> {
items: readonly T[];
selection: ActiveWorkspaceSelection | null;
policy?: SidebarGroupVisibilityPolicy<T>;
}
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
items,
selection,
policy = PINNED_POLICY,
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
expanded: boolean;
toggleExpanded: () => void;
} {
const [expanded, setExpanded] = useState(false);
const visibility = useMemo(
() => selectSidebarGroupVisibility(items, expanded, policy),
[expanded, items, policy],
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
[expanded, items, policy, selection],
);
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
@@ -12,6 +12,7 @@ import type {
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
import type { TerminalFile } from "../terminal/file-paste";
import type { TerminalCharacterWidths } from "../terminal/character-widths";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -32,6 +33,7 @@ export interface TerminalEmulatorProps {
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
supportsTerminalInputModeReplay: boolean;
characterWidths: TerminalCharacterWidths;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
@@ -93,6 +93,7 @@ function NativeTerminalEmulator({
testId = "terminal-surface",
xtermTheme = DEFAULT_XTERM_THEME,
scrollbackLines,
characterWidths,
fontFamily,
fontSize,
keyboardInset = 0,
@@ -373,6 +374,7 @@ function NativeTerminalEmulator({
rows: size.rows,
cols: size.cols,
scrollbackLines,
characterWidths,
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
@@ -387,6 +389,7 @@ function NativeTerminalEmulator({
resetInputModeTracker,
resolveTerminalScreen,
scrollbackLines,
characterWidths,
clearSelection,
]);
@@ -27,6 +27,7 @@ import {
} from "../terminal/runtime/terminal-emulator-runtime";
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
import type { TerminalFile } from "../terminal/file-paste";
import type { TerminalCharacterWidths } from "../terminal/character-widths";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
@@ -96,6 +97,7 @@ interface TerminalEmulatorProps {
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
supportsTerminalInputModeReplay: boolean;
characterWidths: TerminalCharacterWidths;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
@@ -154,6 +156,7 @@ export default function TerminalEmulator({
cursor: "#e6e6e6",
},
scrollbackLines,
characterWidths,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
@@ -183,6 +186,8 @@ export default function TerminalEmulator({
const fontSizeRef = useRef(fontSize);
const scrollbackLinesRef = useRef(scrollbackLines);
scrollbackLinesRef.current = scrollbackLines;
const characterWidthsRef = useRef(characterWidths);
characterWidthsRef.current = characterWidths;
fontFamilyRef.current = fontFamily;
fontSizeRef.current = fontSize;
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
@@ -315,6 +320,10 @@ export default function TerminalEmulator({
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
}, [scrollbackLines]);
useEffect(() => {
runtimeRef.current?.setCharacterWidths({ widths: characterWidths });
}, [characterWidths]);
useEffect(() => {
const root = rootRef.current;
if (!root || !swipeGesturesEnabled) {
@@ -447,6 +456,7 @@ export default function TerminalEmulator({
},
});
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
runtime.setCharacterWidths({ widths: characterWidthsRef.current });
runtime.mount({
root,
host,
@@ -1,5 +1,6 @@
import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
import type { TerminalCharacterWidths } from "@/terminal/character-widths";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -304,6 +305,11 @@ export function TerminalPane({
const supportsTerminalSizeOwnership = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
);
const characterWidths: TerminalCharacterWidths = useSessionStore((state) =>
state.sessions[serverId]?.serverInfo?.features?.["terminal-grapheme-widths"] === true
? "graphemes"
: "legacy",
);
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
@@ -1171,6 +1177,7 @@ export function TerminalPane({
dom={TERMINAL_EMULATOR_DOM_PROPS}
streamKey={terminalStreamKey}
supportsTerminalInputModeReplay={supportsTerminalInputModeReplay}
characterWidths={characterWidths}
testId="terminal-surface"
xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
+9 -1
View File
@@ -89,6 +89,8 @@ export interface ComboboxProps {
}) => ReactElement;
onSearchQueryChange?: (query: string) => void;
searchable?: boolean;
/** The caller answers the search query itself, so the options are shown as given. */
remoteSearch?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
@@ -1292,6 +1294,10 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
);
}
function filtersLocally(input: { searchable: boolean; remoteSearch?: boolean }): boolean {
return input.searchable && !input.remoteSearch;
}
export function Combobox({
options,
value,
@@ -1299,6 +1305,7 @@ export function Combobox({
renderOption,
onSearchQueryChange,
searchable = true,
remoteSearch,
placeholder,
searchPlaceholder,
emptyText,
@@ -1458,7 +1465,7 @@ export function Combobox({
buildVisibleComboboxOptions({
options,
searchQuery,
searchable,
searchable: filtersLocally({ searchable, remoteSearch }),
allowCustomValue,
customValuePrefix,
customValueDescription,
@@ -1470,6 +1477,7 @@ export function Combobox({
customValueKind,
customValuePrefix,
options,
remoteSearch,
searchQuery,
searchable,
],
@@ -0,0 +1,175 @@
import React from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Scan } from "lucide-react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import type { ToastApi } from "@/components/toast-host";
import { ToastApiProvider } from "@/contexts/toast-api-context";
import { ComposerControls, type ComposerControlItem } from "./composer-control";
import { useAgentControlItems } from "./control-items";
import { resolveComposerControlPresentation } from "./layout";
beforeEach(() => vi.stubGlobal("React", React));
afterEach(cleanup);
const base = { id: "x", title: "Context", icon: Scan };
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
function createToast(): RecordingToast {
return { error: vi.fn() } as unknown as RecordingToast;
}
function ignoreOpenChange() {}
function Controls({
items,
surface,
toast,
}: {
items: ComposerControlItem[];
surface: "toolbar" | "sheet";
toast: ToastApi;
}) {
return (
<ToastApiProvider api={toast}>
<ComposerControls
items={items}
surface={surface}
openId={null}
onOpenIdChange={ignoreOpenChange}
/>
</ToastApiProvider>
);
}
function renderControls(items: ComposerControlItem[], surface: "toolbar" | "sheet" = "toolbar") {
const toast = createToast();
render(<Controls items={items} surface={surface} toast={toast} />);
return toast;
}
describe("ComposerControls", () => {
it("shows a select's chosen label, or only its icon when asked", () => {
const select: ComposerControlItem = {
...base,
type: "select",
value: "large",
choices: [
{ value: "small", label: "Small" },
{ value: "large", label: "Large" },
],
onChange: () => undefined,
};
renderControls([select]);
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("Large");
});
it("names the choice on an icon-only select, without drawing it", () => {
renderControls([
{
...base,
type: "select",
value: "large",
iconOnly: true,
choices: [{ value: "large", label: "Large" }],
onChange: () => undefined,
},
]);
const trigger = screen.getByRole("button", { name: "Context: Large" });
expect(trigger.textContent).not.toContain("Large");
});
it("flips a toggle on press in the toolbar", () => {
const onChange = vi.fn();
renderControls([{ ...base, type: "toggle", value: true, onChange }]);
fireEvent.click(screen.getByRole("button", { name: "Context" }));
expect(onChange).toHaveBeenCalledWith(false);
});
it("lists a toggle as On or Off in the sheet", () => {
renderControls([{ ...base, type: "toggle", value: true, onChange: () => undefined }], "sheet");
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On");
});
it("reports a failed command the way a built-in control does, and frees the control", async () => {
let reject: (error: Error) => void = () => undefined;
const onPress = vi.fn(
() =>
new Promise<void>((_resolve, rejectPromise) => {
reject = rejectPromise;
}),
);
const toast = renderControls([{ ...base, type: "action", onPress }]);
const button = screen.getByRole("button", { name: "Context" });
fireEvent.click(button);
// While the command is in flight the control cannot be pressed again.
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
fireEvent.click(button);
expect(onPress).toHaveBeenCalledTimes(1);
reject(new Error("Unknown or unavailable provider: x"));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Unknown or unavailable provider: x"),
);
await waitFor(() => expect(button.getAttribute("aria-disabled")).not.toBe("true"));
});
it("reports a command that throws before it returns", () => {
const toast = renderControls([
{
...base,
type: "action",
onPress: () => {
throw new Error("No such control");
},
},
]);
fireEvent.click(screen.getByRole("button", { name: "Context" }));
expect(toast.error).toHaveBeenCalledWith("No such control");
});
});
const FAST_ON = { fast_mode: true };
describe("provider option controls", () => {
const fastMode: AgentOptionControl = {
type: "toggle",
key: "fast_mode",
label: "Fast",
defaultValue: false,
};
function OptionControls({
options,
onSetOption,
}: {
options: Record<string, unknown>;
onSetOption: (key: string, value: unknown) => void;
}) {
const optionControls = React.useMemo(
() => ({ controls: [fastMode], options, onSetOption }),
[onSetOption, options],
);
const items = useAgentControlItems({
surface: "toolbar",
presentation: resolveComposerControlPresentation("full"),
thinking: null,
optionControls,
});
return <Controls items={items.features} surface="toolbar" toast={TOAST} />;
}
const TOAST = createToast();
function recordWrites(writes: Array<[string, unknown]>) {
return (key: string, value: unknown) => void writes.push([key, value]);
}
it("write the toggled option from the agent's current options", () => {
const writes: Array<[string, unknown]> = [];
const onSetOption = recordWrites(writes);
render(<OptionControls options={FAST_ON} onSetOption={onSetOption} />);
fireEvent.click(screen.getByTestId("agent-feature-fast_mode"));
expect(writes).toEqual([["fast_mode", false]]);
});
});
@@ -0,0 +1,403 @@
import {
createContext,
forwardRef,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactElement,
} from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentControlIcon } from "@/agent-controls/icons";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FieldTrigger } from "@/screens/new-workspace/form/field-trigger";
import { ICON_SIZE } from "@/styles/theme";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { useToast } from "@/contexts/toast-api-context";
import { toErrorMessage } from "@/utils/error-messages";
interface ComposerControlBase {
id: string;
title: string;
icon: AgentControlIcon;
/** Tints the icon; muted when absent. */
highlight?: "blue" | "green" | "yellow";
/** Defaults to the title, or to "title: choice" on an icon-only select. */
tooltip?: string;
/** Defaults to the tooltip. */
accessibilityLabel?: string;
testID?: string;
}
export interface ComposerControlChoice {
value: string;
label: string;
description?: string;
}
/**
* One control, drawn from data. Provider option controls, thinking and plugin controls all come
* through here, so each is an icon in the composer toolbar, a row in its sheet, or, in a New
* workspace item, a field. The name stays from when the composer was the only place it drew.
*/
export type ComposerControlItem =
| (ComposerControlBase & {
type: "toggle";
value: boolean;
onChange: (value: boolean) => void | Promise<void>;
})
| (ComposerControlBase & {
type: "select";
value: string | null;
choices: readonly ComposerControlChoice[];
/** Names a value the choices do not list, such as a pick that a search no longer returns. */
valueLabel?: string;
onChange: (value: string) => void | Promise<void>;
/** The toolbar trigger shows only the icon. */
iconOnly?: boolean;
showCaret?: boolean;
searchable?: boolean;
/** The list answers `onSearchQueryChange` itself, so it is not filtered again. */
remoteSearch?: boolean;
onSearchQueryChange?: (query: string) => void;
emptyText?: string;
desktopMinWidth?: number;
renderOption?: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
})
| (ComposerControlBase & {
type: "action";
onPress: () => void | Promise<void>;
});
/** `field`: a New workspace setup field, a badge on desktop and a full-width field when compact. */
export type ComposerControlSurface = "toolbar" | "sheet" | "field";
const ComposerControlSurfaceContext = createContext<ComposerControlSurface>("toolbar");
export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider;
const ComposerControlLockContext = createContext(false);
/** Disables every control below it, for a surface that is not a composer and has its own lock. */
export const ComposerControlLockProvider = ComposerControlLockContext.Provider;
export function useComposerControlLocked(): boolean {
return useContext(ComposerControlLockContext);
}
/** Where the control being drawn sits: the toolbar, a row in the overflow sheet, or a field. */
export function useComposerControlSurface(): ComposerControlSurface {
return useContext(ComposerControlSurfaceContext);
}
interface ComposerControlProps {
item: ComposerControlItem;
surface: ComposerControlSurface;
disabled?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Runs after a toolbar toggle or action, so the composer can take focus back. */
onActionComplete?: () => void;
}
interface ControlView {
options: ComboboxOption[];
/** The list's selected id. */
selectedId: string;
/** What the trigger shows beside the icon. */
value: string;
iconOnly: boolean;
tooltip: string;
/** Whether pressing the trigger opens a list instead of acting. */
opensList: boolean;
}
function describeControl(
item: ComposerControlItem,
surface: ComposerControlSurface,
t: TFunction,
): ControlView {
const isSheet = surface === "sheet";
if (item.type === "action") {
return {
options: [],
selectedId: "",
value: "",
iconOnly: true,
tooltip: item.tooltip ?? item.title,
opensList: false,
};
}
if (item.type === "toggle") {
const label = item.value ? t("agentControls.features.on") : t("agentControls.features.off");
return {
options: [
{ id: "true", label: t("agentControls.features.on") },
{ id: "false", label: t("agentControls.features.off") },
],
selectedId: String(item.value),
value: label,
iconOnly: true,
tooltip: item.tooltip ?? item.title,
opensList: isSheet,
};
}
const choiceLabel =
item.choices.find((choice) => choice.value === item.value)?.label ??
item.valueLabel ??
item.title;
const iconOnly = item.iconOnly === true;
return {
options: item.choices.map((choice) => ({
id: choice.value,
label: choice.label,
description: choice.description,
})),
selectedId: String(item.value ?? ""),
value: choiceLabel,
iconOnly,
// An icon-only trigger names its value; a sheet row shows the value, so it names the action.
tooltip: iconOnly && !isSheet ? `${item.title}: ${choiceLabel}` : (item.tooltip ?? item.title),
opensList: true,
};
}
type SelectItem = Extract<ComposerControlItem, { type: "select" }>;
/** The list options only a select carries; a toggle's two-choice list has none of them. */
function listProps(select: SelectItem | null) {
if (!select) return { searchable: false };
return {
desktopMinWidth: select.desktopMinWidth,
searchable: select.searchable,
remoteSearch: select.remoteSearch,
onSearchQueryChange: select.onSearchQueryChange,
emptyText: select.emptyText,
renderOption: select.renderOption,
};
}
// A command that returns a promise keeps the control pending until it settles, and a failure
// reaches the user the way a built-in live control's does.
function usePendingRun() {
const toast = useToast();
const [pending, setPending] = useState(false);
const run = useCallback(
(invoke: () => void | Promise<void>) => {
try {
const result = invoke();
if (!result) return;
setPending(true);
result
.catch((error: unknown) => toast.error(toErrorMessage(error)))
.finally(() => setPending(false));
} catch (error) {
toast.error(toErrorMessage(error));
}
},
[toast],
);
return { pending, run };
}
export function ComposerControl({
item,
surface,
disabled = false,
open,
onOpenChange,
onActionComplete,
}: ComposerControlProps) {
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const isSheet = surface === "sheet";
const isField = surface === "field";
const sheetHeader = useMemo<SheetHeader>(() => ({ title: item.title }), [item.title]);
const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]);
const { pending, run } = usePendingRun();
const handlePress = useCallback(() => {
if (item.type === "toggle") run(() => item.onChange(!item.value));
else if (item.type === "action") run(item.onPress);
onActionComplete?.();
}, [item, onActionComplete, run]);
const handleOpenPress = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
const handleSelect = useCallback(
(value: string) => {
if (item.type === "select") run(() => item.onChange(value));
else if (item.type === "toggle") run(() => item.onChange(value === "true"));
},
[item, run],
);
const select = item.type === "select" ? item : null;
const accessibilityLabel = item.accessibilityLabel ?? view.tooltip;
const trigger = isField ? (
<FieldControlTrigger
ref={anchorRef}
item={item}
view={view}
accessibilityLabel={accessibilityLabel}
disabled={disabled || pending}
onPress={view.opensList ? handleOpenPress : handlePress}
/>
) : (
<AgentControlTrigger
ref={anchorRef}
icon={item.icon}
iconColor={item.highlight ? styles[item.highlight].color : undefined}
surface={surface}
label={item.title}
value={view.value}
showToolbarLabel={!view.iconOnly}
showCaret={select?.showCaret === true && !isSheet}
open={view.opensList && open}
disabled={disabled || pending}
onPress={view.opensList ? handleOpenPress : handlePress}
accessibilityLabel={accessibilityLabel}
testID={item.testID}
/>
);
return (
<>
{isSheet || isField ? (
trigger
) : (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
{trigger}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{view.tooltip}</Text>
</TooltipContent>
</Tooltip>
)}
{view.opensList ? (
<Combobox
options={view.options}
value={view.selectedId}
onSelect={handleSelect}
open={open}
onOpenChange={onOpenChange}
anchorRef={anchorRef}
presentation={isSheet ? "push" : undefined}
desktopPlacement={DESKTOP_PLACEMENT[surface]}
{...listProps(select)}
header={isSheet ? sheetHeader : undefined}
/>
) : null}
</>
);
}
/** Where a list opens on desktop; the sheet has its own presentation. */
const DESKTOP_PLACEMENT = {
toolbar: "top-start",
sheet: undefined,
field: "bottom-start",
} as const;
/** What a field shows: a badge has no label, so a toggle names itself beside its state. */
function fieldValue(item: ComposerControlItem, view: ControlView, compact: boolean): string {
if (item.type === "action") return item.title;
if (item.type === "toggle" && !compact) return `${item.title}: ${view.value}`;
return view.value;
}
const FieldControlTrigger = forwardRef<
View,
{
item: ComposerControlItem;
view: ControlView;
accessibilityLabel: string;
disabled: boolean;
onPress: () => void;
}
>(function FieldControlTrigger({ item, view, accessibilityLabel, disabled, onPress }, ref) {
const compact = useIsCompactFormFactor();
const Icon = item.icon;
const iconColor = item.highlight ? styles[item.highlight].color : styles.fieldIcon.color;
const icon = useMemo(() => <Icon size={ICON_SIZE.sm} color={iconColor} />, [Icon, iconColor]);
return (
<FieldTrigger
ref={ref}
variant={compact ? "field" : "badge"}
label={item.title}
icon={icon}
value={fieldValue(item, view, compact)}
tooltip={view.tooltip}
accessibilityLabel={accessibilityLabel}
disabled={disabled}
onPress={onPress}
testID={item.testID}
/>
);
});
interface ComposerControlsProps {
items: readonly ComposerControlItem[];
surface: ComposerControlSurface;
disabled?: boolean;
/** The id of the item whose list is open. */
openId: string | null;
onOpenIdChange: (id: string | null) => void;
onActionComplete?: () => void;
}
/** A row of Paseo-owned controls that share one open list at a time. */
export function ComposerControls({
items,
onOpenIdChange,
openId,
...rest
}: ComposerControlsProps) {
return (
<>
{items.map((item) => (
<ComposerControlSlot
key={item.id}
item={item}
open={openId === item.id}
onOpenIdChange={onOpenIdChange}
{...rest}
/>
))}
</>
);
}
function ComposerControlSlot({
item,
open,
onOpenIdChange,
...rest
}: Omit<ComposerControlsProps, "items" | "openId"> & { item: ComposerControlItem; open: boolean }) {
const handleOpenChange = useCallback(
(next: boolean) => onOpenIdChange(next ? item.id : null),
[item.id, onOpenIdChange],
);
return <ComposerControl item={item} open={open} onOpenChange={handleOpenChange} {...rest} />;
}
const styles = StyleSheet.create((theme) => ({
fieldIcon: { color: theme.colors.foregroundMuted },
blue: { color: theme.colors.palette.blue[400] },
green: { color: theme.colors.palette.green[400] },
yellow: { color: theme.colors.palette.yellow[400] },
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: theme.fontSize.base * 1.4,
},
}));
@@ -0,0 +1,165 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
type ComposerOptionControls,
} from "@/agent-controls/option-controls";
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import {
type ComposerControlItem,
type ComposerControlSurface,
} from "@/composer/agent-controls/composer-control";
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
import { getAgentControlHintKey, getFeatureHighlightColor } from "@/composer/agent-controls/utils";
const SEARCH_THRESHOLD = 6;
function getFeatureHighlight(
control: AgentOptionControl,
options: ProviderOptions,
): ComposerControlItem["highlight"] {
if (!isControlActive(control, options)) return undefined;
const color = getFeatureHighlightColor(control.key);
return color === "default" ? undefined : color;
}
function toFeatureItem(
control: AgentOptionControl,
optionControls: ComposerOptionControls,
): ComposerControlItem {
const { options, onSetOption } = optionControls;
const base = {
id: `feature-${control.key}`,
title: control.label,
icon: getAgentFeatureIcon(control.icon),
highlight: getFeatureHighlight(control, options),
testID: `agent-feature-${control.key}`,
};
if (control.type === "toggle") {
return {
...base,
type: "toggle",
tooltip: getControlTooltip(control),
value: readToggleOption(control, options),
onChange: (value) => onSetOption(control.key, value),
};
}
const iconOnly = control.desktopTrigger === "icon";
return {
...base,
type: "select",
tooltip: getControlTooltip(control),
value: readSelectOption(control, options),
choices: control.choices,
iconOnly,
onChange: (value) => onSetOption(control.key, value),
};
}
interface ThinkingControl {
options: readonly { id: string; label: string }[];
selectedId: string | undefined;
onSelect: (id: string) => void;
/** The select's displayed label, falling back like the compact model sheet. */
displayLabel: string;
}
/** The thinking select and the option controls, as the data `ComposerControl` draws. */
export function useAgentControlItems({
surface,
presentation,
thinking,
optionControls,
}: {
surface: ComposerControlSurface;
presentation: ComposerControlPresentation;
thinking: ThinkingControl | null;
optionControls: ComposerOptionControls | undefined;
}): { thinking: ComposerControlItem[]; features: ComposerControlItem[] } {
const { t } = useTranslation();
const thinkingItem = useMemo(
() => (thinking ? [toThinkingItem({ thinking, surface, presentation, t })] : []),
[presentation, surface, t, thinking],
);
const features = useMemo(
() =>
optionControls
? optionControls.controls.map((control) => toFeatureItem(control, optionControls))
: [],
[optionControls],
);
return { thinking: thinkingItem, features };
}
function toThinkingItem({
thinking,
surface,
presentation,
t,
}: {
thinking: ThinkingControl;
surface: ComposerControlSurface;
presentation: ComposerControlPresentation;
t: TFunction;
}): ComposerControlItem {
const isSheet = surface === "sheet";
return {
id: "thinking",
type: "select",
title: t("agentControls.thinking.title"),
icon: ThinkingIcon,
tooltip: t(getAgentControlHintKey("thinking")),
accessibilityLabel: t("agentControls.thinking.selectWithValue", {
value: thinking.displayLabel,
}),
testID: isSheet ? "agent-controls-thinking" : "agent-thinking-selector",
value: thinking.options.some((option) => option.id === thinking.selectedId)
? (thinking.selectedId ?? null)
: (thinking.options[0]?.id ?? null),
choices: thinking.options.map((option) => ({ value: option.id, label: option.label })),
onChange: thinking.onSelect,
iconOnly: !presentation.showThinkingLabel,
showCaret: presentation.showCarets,
searchable: !isSheet && thinking.options.length > SEARCH_THRESHOLD,
desktopMinWidth: 200,
renderOption: (args) => <ThinkingOption {...args} />,
};
}
function ThinkingOption({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) {
const leadingSlot = useMemo(
() => <ThinkingIcon size={16} color={styles.thinkingIcon.color} />,
[],
);
return (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
const styles = StyleSheet.create((theme) => ({
thinkingIcon: { color: theme.colors.foreground },
}));
File diff suppressed because it is too large Load Diff
@@ -34,6 +34,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
pluginItems: 0,
fontScale: 1,
};
@@ -95,6 +96,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
fontScale: 1,
},
}),
@@ -107,6 +109,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
pluginItems: 0,
fontScale: 1,
},
}),
@@ -119,6 +122,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
fontScale: 1.25,
},
}),
@@ -133,6 +137,7 @@ describe("composer control layout", () => {
hasModel: true,
hasThinking: true,
hasMode: true,
pluginItems: 0,
fontScale: 1,
},
};
@@ -154,6 +159,34 @@ describe("composer control layout", () => {
).toBe("condensed");
});
it("condenses to make room for plugin items and keeps them in the overflow", () => {
const base = {
availableWidth: 430,
currentDensity: "full" as const,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
},
};
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }),
).toBe("full");
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }),
).toBe("condensed");
expect(
resolveComposerControlDensity({
...base,
availableWidth: 250,
controls: { ...base.controls, pluginItems: 2 },
}),
).toBe("tight");
});
it("gives every toolbar control one shell and one platform glyph envelope", () => {
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
controlSize: 28,
@@ -5,6 +5,8 @@ export interface ComposerControlPresence {
hasThinking: boolean;
hasMode: boolean;
features: readonly ComposerFeatureControlPresence[];
/** Plugin composer items. Each is budgeted like a select with a short label. */
pluginItems: number;
fontScale: number;
}
@@ -26,6 +28,7 @@ export const COMPOSER_TOOLBAR_GEOMETRY = {
} as const;
const DENSITY_HYSTERESIS = 12;
const PLUGIN_ITEM_LABEL = "Plugin item";
function normalizedFontScale(fontScale: number): number {
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
@@ -62,7 +65,9 @@ function resolveCondensedFloor(controls: ComposerControlPresence): number {
if (controls.hasModel) widths.push(36 + 60 * fontScale);
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
if (controls.hasMode) widths.push(36 + 96 * fontScale);
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
if (controls.features.length > 0 || controls.pluginItems > 0) {
widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
}
return sumControlWidths(widths);
}
@@ -75,6 +80,11 @@ function resolveFullFloor(controls: ComposerControlPresence): number {
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale));
}
for (let index = 0; index < controls.pluginItems; index++) {
widths.push(
resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale),
);
}
return sumControlWidths(widths);
}
@@ -25,15 +25,19 @@ describe("resolveAgentControlsMode", () => {
onSelectMode: () => undefined,
models: [],
selectedModel: "",
onSelectModel: () => undefined,
onSelectModel: () => true,
isModelLoading: false,
modelSelectorProviders: [],
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
onSelectProviderAndModel: () => true,
options: {},
onSetOption: () => undefined,
thinkingOptions: [],
selectedThinkingOptionId: "",
onSelectThinkingOption: () => undefined,
onApplyAgentProfile: () => undefined,
metadata: {},
onSetMetadata: () => undefined,
}),
).toBe("draft");
});
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { useProviderIcon } from "@/components/provider-icons";
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
@@ -43,6 +44,8 @@ interface CompactModelSheetProps {
serverId?: string | null;
glyphSize: number;
canSwitchProvider: boolean;
/** Carries the composer's contexts into the sheets, which draw away from it. */
contextBridge?: ContextBridge | null;
children: ReactNode;
}
@@ -95,6 +98,7 @@ export function CompactModelSheet({
serverId = null,
glyphSize,
canSwitchProvider,
contextBridge = null,
children,
}: CompactModelSheetProps) {
const { t } = useTranslation();
@@ -299,6 +303,7 @@ export function CompactModelSheet({
snapPoints={SNAP_POINTS}
scrollable={false}
contentStyle={styles.sheetBody}
contextBridge={contextBridge}
testID="agent-controls-model-sheet"
>
<View
@@ -347,6 +352,7 @@ export function CompactModelSheet({
scrollable={false}
presentation="push"
contentStyle={styles.sheetBody}
contextBridge={contextBridge}
testID="agent-controls-model-browser-sheet"
>
<View
@@ -0,0 +1,43 @@
import { useCallback } from "react";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { ComposerControlLayoutProvider, useComposerControlLayout } from "./layout-context";
import {
ComposerAgentProvider,
ComposerInputProvider,
useComposerAgent,
useComposerInput,
} from "@/composer/session";
import { ToastApiProvider, useToast } from "@/contexts/toast-api-context";
/**
* A phone's overflow draws in a sheet whose content lands at the app root, away from the
* composer. This carries across what a control in it reads from the composer: the agent and input
* a plugin's `useComposer()` sits on, the layout, and the toast and query client the controls use.
* Call it from inside the composer, outside any sheet.
*/
export function useComposerSheetBridge(): ContextBridge {
const layout = useComposerControlLayout();
const agent = useComposerAgent();
const input = useComposerInput();
const toast = useToast();
const queryClient = useQueryClient();
return useCallback<ContextBridge>(
(content) => (
<QueryClientProvider client={queryClient}>
<ToastApiProvider api={toast}>
<ComposerControlLayoutProvider value={layout}>
<ComposerAgentProvider value={agent}>
{input ? (
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
) : (
content
)}
</ComposerAgentProvider>
</ComposerControlLayoutProvider>
</ToastApiProvider>
</QueryClientProvider>
),
[agent, input, layout, queryClient, toast],
);
}
@@ -1,9 +1,7 @@
import { describe, expect, it } from "vitest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHintKey,
isFeatureActive,
normalizeModelId,
resolveAgentModelSelection,
} from "./utils";
@@ -17,51 +15,12 @@ describe("getAgentControlHintKey", () => {
});
describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
it("treats a select feature as active when a non-default option is selected", () => {
const speed = {
type: "select" as const,
id: "service_tier",
label: "Speed",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
};
expect(isFeatureActive({ ...speed, value: "default" })).toBe(false);
expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true);
expect(
isFeatureActive({
type: "toggle",
id: "fast_mode",
label: "Fast",
value: true,
}),
).toBe(true);
});
});
describe("normalizeModelId", () => {
@@ -1,4 +1,4 @@
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import {
@@ -35,16 +35,6 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized;
}
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function isFeatureActive(feature: AgentFeature): boolean {
if (feature.type === "toggle") return feature.value;
const selectedOption = feature.options.find((option) => option.id === feature.value);
return selectedOption !== undefined && !selectedOption.isDefault;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case FAST_MODE_FEATURE_ID:
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
draftControlsRequest,
openDraftOptions,
shownDraftOptions,
} from "@/agent-controls/draft-options-model";
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
import { draftComposerAgent } from "./composer-agent";
function definition(id: string, modeId: string): AgentProviderDefinition {
return {
id,
label: id,
description: id,
defaultModeId: modeId,
modes: [{ id: modeId, label: modeId, icon: "ShieldCheck", colorTier: "safe" }],
};
}
function model(provider: string, id: string, isDefault = false): AgentModelDefinition {
return { provider, id, label: id, isDefault };
}
/** The host: two providers with catalogs, and one that is ready but lists no models. */
const catalog: AgentFormCatalog = {
selectableProviders: new Map([
["claude", definition("claude", "default")],
["codex", definition("codex", "auto")],
["opencode", definition("opencode", "build")],
]),
models: new Map([
["claude", [model("claude", "sonnet", true)]],
["codex", [model("codex", "gpt-5.4", true), model("codex", "gpt-5.5")]],
["opencode", []],
]),
providerPreferences: () => undefined,
};
/** A draft composer on the real form and options models. */
function openDraft(context: "draft" | "new-workspace") {
const form = openAgentForm(() => catalog);
const options = openDraftOptions({
provider: null,
currentProvider: () => form.getState().form.provider,
});
// The screen hands the options the provider the form reaches.
form.subscribe(() => options.observeProvider(form.getState().form.provider));
form.setProviderAndModel("claude", "");
function agent() {
const { form: current } = form.getState();
return draftComposerAgent({
context,
selectedProvider: current.provider,
selectedModel: current.model,
selectedMode: current.modeId,
thinking: current.thinkingOptionId || null,
isLocked: () => false,
options: shownDraftOptions(options.getState(), {
provider: current.provider,
requestKey: "",
}),
metadata: {},
onSelectProviderAndModel: form.setProviderAndModel,
onSelectModel: form.setModel,
onSelectMode: () => undefined,
onSelectThinkingOption: () => undefined,
onSetOption: options.setOption,
onSetMetadata: () => undefined,
});
}
/** What the daemon is asked to resolve for the agent this draft would create. */
function request() {
const { form: current } = form.getState();
return draftControlsRequest(options.getState(), {
provider: current.provider,
cwd: "/repo",
modeId: current.modeId,
modelId: current.model,
});
}
return { form, agent, request };
}
describe("a draft's options", () => {
it("belong to the provider the form has now, before the screen has rendered the change", () => {
const form = openAgentForm(() => catalog);
const options = openDraftOptions({
provider: null,
currentProvider: () => form.getState().form.provider,
});
// No subscription: the screen observes the provider in an effect, after the command ran.
form.setProviderAndModel("claude", "");
options.observeProvider("claude");
options.setOption("context", "1m");
form.setProviderAndModel("codex", "");
options.setOption("effort", "high");
expect(options.getState()).toMatchObject({ provider: "codex", edits: { effort: "high" } });
});
});
describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (context) => {
it("applies each awaited command before the next one runs", async () => {
const draft = openDraft(context);
// A plugin keeps the object it was handed across its awaits.
const retained = draft.agent();
await retained.setProvider("codex");
await retained.setModel("gpt-5.5");
await retained.setOptions({ context: "1m" });
const agent = draft.agent();
expect(agent).toMatchObject({
context,
provider: "codex",
model: "gpt-5.5",
options: { context: "1m" },
});
expect(draft.request()).toMatchObject({
provider: "codex",
model: "gpt-5.5",
providerOptions: { context: "1m" },
});
});
it("starts the options over when the provider is chosen by hand", async () => {
const draft = openDraft(context);
const retained = draft.agent();
await retained.setOptions({ context: "1m" });
expect(draft.agent().options).toEqual({ context: "1m" });
await retained.setProvider("codex");
expect(draft.agent().options).toEqual({});
});
it("creates with the provider's default again once a plugin resets an option", async () => {
const draft = openDraft(context);
const retained = draft.agent();
await retained.setOptions({ context: "1m", effort: "high" });
await retained.setOptions({ context: null });
expect(draft.agent().options).toEqual({ effort: "high" });
expect(draft.request()?.providerOptions).toEqual({ effort: "high" });
await retained.setOptions({ effort: null });
expect(draft.agent().options).toEqual({});
expect(draft.request()?.providerOptions).toBeUndefined();
});
it("rejects a provider the host cannot run and a model the catalog does not list", async () => {
const draft = openDraft(context);
const retained = draft.agent();
await expect(retained.setProvider("nope")).rejects.toThrow(
"Unknown or unavailable provider: nope",
);
expect(draft.agent().provider).toBe("claude");
await retained.setProvider("codex");
await expect(retained.setModel("missing")).rejects.toThrow("Unknown model: missing");
expect(draft.agent()).toMatchObject({ provider: "codex", model: "gpt-5.4" });
});
it("keeps a model id as given when the provider lists no catalog", async () => {
const draft = openDraft(context);
const retained = draft.agent();
await retained.setProvider("opencode");
await retained.setModel("anthropic/claude-x");
expect(draft.agent().model).toBe("anthropic/claude-x");
});
});
@@ -0,0 +1,52 @@
import type { ComposerAgent } from "@/composer/session";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
/**
* The agent a draft composer creates, as a plugin sees it. Its commands go to the form and the
* options model, which apply them as they run, so a plugin can `await` one and issue the next.
*/
export function draftComposerAgent(
props: Pick<
DraftAgentControlsProps,
| "context"
| "disabled"
| "selectedProvider"
| "selectedModel"
| "selectedMode"
| "options"
| "metadata"
| "onSelectProviderAndModel"
| "onSelectModel"
| "onSelectMode"
| "onSelectThinkingOption"
| "onSetOption"
| "onSetMetadata"
> & { thinking: string | null; isLocked: () => boolean },
): ComposerAgent {
return {
context: props.context ?? "draft",
locked: props.disabled ?? false,
isLocked: props.isLocked,
provider: props.selectedProvider,
model: props.selectedModel || null,
mode: props.selectedMode || null,
thinking: props.thinking,
options: props.options,
metadata: props.metadata,
// An empty model id picks the provider's default model.
setProvider: async (provider) => {
if (!props.onSelectProviderAndModel(provider, "")) {
throw new Error(`Unknown or unavailable provider: ${provider}`);
}
},
setModel: async (modelId) => {
if (!props.onSelectModel(modelId)) throw new Error(`Unknown model: ${modelId}`);
},
setMode: async (modeId) => props.onSelectMode(modeId),
setThinking: async (thinkingOptionId) => props.onSelectThinkingOption(thinkingOptionId),
setOptions: async (patch) => {
for (const [key, value] of Object.entries(patch)) props.onSetOption(key, value);
},
setMetadata: async (patch) => props.onSetMetadata(patch),
};
}
@@ -1,3 +1,4 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
@@ -20,12 +21,26 @@ export function resolveDraftKey(input: {
export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
optionControls?: DraftAgentControlsProps["optionControls"];
options: DraftAgentControlsProps["options"];
onSetOption: DraftAgentControlsProps["onSetOption"];
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
context: NonNullable<DraftAgentControlsProps["context"]>;
metadata: Record<string, JsonValue>;
onSetMetadata: (patch: Record<string, JsonValue>) => void;
}): DraftAgentControlsProps {
const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
const {
formState,
optionControls,
options,
onSetOption,
onApplyAgentProfile,
onDropdownClose,
context,
metadata,
onSetMetadata,
} = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
@@ -43,13 +58,17 @@ export function buildDraftAgentControls(input: {
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
onApplyAgentProfile,
features,
onSetFeature,
optionControls,
options,
onSetOption,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
onRetryModelProvider: formState.refreshProviderModels,
isRetryingModelProvider: formState.isProviderModelsRefreshing,
modelSelectorServerId: formState.selectedServerId,
context,
metadata,
onSetMetadata,
};
}

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