Commit Graph
41 Commits
Author SHA1 Message Date
Mohamed Boudra 28c188513e feat: plugin API for agent options, metadata, composer and New workspace (#8744)
* 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).

* 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.

* 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.

* 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.

* 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.

* 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.

* chore(website): regenerate the config schema for profile options

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* docs(server): a rejected apply reports what the provider moved after the call ends

* test(server): a patch key is resolved against nested configured defaults

* test(server): a setting a plugin changes on its own is forwarded as options_changed

* test(server): assert the forwarded plugin setting without nesting callbacks

* test(server): removal of auto_accept returns ACP to asking; Claude keeps opening restrictions across a patch

* 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.

* 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.

* fix(server): a plugin that moves settings and then refuses an apply reports what moved

* refactor(server): the legacy featureValues folds take one named-input object

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* fix(server): an ACP removal reports the choice the select lands on, and the Codex plan tests enable plan mode

A removal asks the select for its empty choice and compared the result against that
same empty choice, so applyOptions returned nothing and the manager stored the removal
as null while the session ran the Default choice. The difference is now computed against
what was asked (null), so the options hold the value the session runs with.

The Codex plan-mode end-to-end tests called the removed session.setFeature through an
optional call and silently skipped plan mode; they apply the option and fail loudly if
the method is gone. The MCP parity wrapper forwards listControls instead of the removed
listFeatures.

* fix(server): plugin option applies run one at a time, and OpenCode reports the Auto Accept legacy full-access turns on

Plugin sessions let two applyOptions overlap. The second finished and re-recorded the
settings the plugin held, which included a change the first had already made and not yet
reported; when the first then failed, nothing was left to report and the stored options
kept the old value while the plugin ran the new one. Applies now queue on the session,
the way the ACP adapter's do, so each reconciles against what the one before left.

OpenCode normalizes the legacy full-access mode to build with Auto Accept on, but the
session never said so, so the options the manager keeps did not hold Auto Accept and a
reopen turned it off. The session now reports it through openOptionsPatch on create and
resume.

* refactor(server): openCodeOpenOptionsPatch takes one named-fields object

* 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.

* 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.

* 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.

* 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.

* 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.

* fix(client): a legacy profile with both featureValues and options keeps both

A legacy daemon keeps an unknown `options` field on a profile it stored
(profiles are passthrough), for example after a downgrade, while its own app
keeps editing `featureValues`. Reading such a profile took `options` and
dropped `featureValues`, so a Fast setting there was lost and the next save
replaced it. Both are merged on read with an explicit `options` key winning,
the same rule the daemon applies when it folds `featureValues`.

The draft form input of draftControlsRequest is a named type.

* fix(app): a draft stops sending a toggle the model in use does not offer

A toggle made on one model stayed in the draft's edits after the form moved
to a model whose controls do not include it, and was sent with the create.
On a legacy daemon that key is not a feature for the new model, so it went
out as a native provider option the daemon rejects. The draft now sends an
edit only while the latest answer draws a control for it, and sends it again
when a control for it returns.

* fix(server): a closed agent's options include the provider defaults, like a live one's

The stored snapshot projected only the options the agent owns, so an agent
that ran with a config.json default (for example Claude fast_mode: true)
showed the option as missing, and its legacy Fast control as off, until it
was resumed. The projection takes the same resolver the live agent uses
(the provider's defaults under the agent's own options).

* fix(app): a draft keeps sending an option no control has drawn

The previous change stopped sending any edit the latest answer drew no control
for, which also dropped options set without a control (a plugin setting a
token budget on the draft). Only an edit of a key an earlier answer for this
draft drew as a control stops applying when a later answer draws none; every
other edit always applies.

* test(server): the MCP test agent manager resolves effective options

* fix(app): a profile stops saving a toggle the model in use does not offer

Switching a profile from Opus to Haiku kept the Fast edit, so the saved
profile carried fast_mode that no control draws and an old daemon's
provider rejects at create. The profile editor now drops a key an earlier
answer drew as a control and the latest one does not, the rule the draft
already follows; options no control ever drew are never touched.

* fix(client): resetting an option on an old daemon writes the default it can take

setAgentOptions({ service_tier: null }) sent a feature write with value
null, which the old Codex setter rejects, leaving the paid Fast tier on.
A toggle now resets to off and a choice to its default; an option with
no control to name a default fails with the host-update message before
anything is written.

* fix(client): applying a config with a reset option on an old daemon writes the default it can take

applyAgentConfig({ options: { service_tier: null } }) reached an old
daemon as featureValues with null, which its Codex setter rejects. The
config now takes the same translation as setAgentOptions: a toggle goes
off, a choice goes to its default, and an option with no named default
fails with the host-update message before anything is sent.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* test(server): an apply to a plugin that reports controls sends its changes as provider options

* 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.

* 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.

* 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.

* 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.

* 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.

* docs(plugins): the hook result comment names metadata as the field that stays

* fix(server): reopening a closed agent keeps metadata saved just before

The loader reads the agent record, then queues a resume. A setMetadata
queued ahead of that resume saves a newer record, and the open hooks saw
it, but registration took metadata from the loader's older read and wrote
it back over the saved patch. Registration now uses the metadata read
inside the lifecycle lane.

* docs(server): name the metadata gap when a handle-less agent is recreated on load

* docs(server): the handle-less recreate gap spans the agent.create.before hook

* docs(plugins): an agent.open.before hook starts writes to its own agent without awaiting

An agent.open.before hook that awaits setMetadata (or any write) on the
agent being opened deadlocks: the write joins the agent's lifecycle lane
behind the open, and the open waits on the hook until it times out. The
hooks reference now says so and shows the supported shape, a write
started without awaiting, which runs right after the open.

Deferred, not in this stack: reject such a write immediately instead of
letting the hook time out. The host knows which plugin hook it is
awaiting for the agent and which channel the write arrives on, so the
rejection can be keyed on "this plugin is inside an open hook for this
agent" without cross-process tagging, and an unrelated client's write
would still wait as it does today.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* test(app): composer item test installation carries the subscribe main added to InstalledPlugin

Main's plugin streaming (#8527) made subscribe a required field of InstalledPlugin; the
composer item model test builds its own installation and predates it.

* 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.

* 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.

* 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.

* 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.

* 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.

* test(plugins): the project-switch spec expects the other project's metadata to be empty

* 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.

* fix(server): a branch the caller named is not renamed from the first agent context

The daemon marked every new branch-off worktree for automatic naming, recording the chosen branch
as the placeholder, so the first agent's prompt could rename a branch a plugin or client had
named through branchName. Only a branch the daemon derived from the worktree slug is a
placeholder now.

* fix(plugins): New workspace items remount when the project or host changes

An item stayed mounted across a project switch, and its Combobox kept the previous project's
results (same query key, cached data) selectable until the next search returned, so an issue from
the old project could set the branch and metadata on the new one. A Combobox cannot detect that
its inline search now means another project, so the item host keys the items by host and project
id. Items and their search results start fresh for the new target, and a mount effect sets up
again after the model clears the branch and metadata.

* docs(plugins): a layout-effect command on mount also races the reset on a project switch

* fix(plugins): a New workspace item's commands stop working once its project or host is left

Every item received the same command functions, which act on the current screen state. A lookup
that finished after the user switched projects could still call setBranch or setMetadata and
overwrite the new project's choices; the reset on a switch only clears what was already written.

The setup model now hands out commands per target with commandsFor({ serverId, projectId }).
They reject with a "moved to another project or host" error when the screen is on another target,
like the lock rejects, so the promise only resolves when the change took effect.

* feat(plugins): reach a running agent's composer by reference

useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open.

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

The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.

* fix(plugins): composers by reference follow the focused agent and reach retained composers

- `{ focused: true }` is the agent Command Center hands to agent items, published by its
  registration through plugins/focused-agent.ts and empty outside a workspace. The composer source
  reads that module instead of the remembered workspace selection, the navigation store and the
  layout store, which also removes the require cycles those imports added.
- A composer the app retains in a workspace the user left has no live input. `insertText` and
  `setPrompt` now store the text with `replaceDraftText`, which asks the mounted input to show it;
  before, the text reached the store but the retained input never displayed it.
- `model` and `thinking` read exactly what the picker resolves before the catalog loads (no
  models), then re-resolve when it lands, instead of reading null.
- The daemon validates a thinking option against the model the agent runs: the runtime model when
  the provider lists it, else the configured one.
- The composer sources subscribe to query-cache updates only. A query another component creates
  during its render is announced as `added`, and a subscriber that updates then made React warn
  about updating a component while rendering a different one. A catalog read no longer starts its
  load synchronously either.

* test(plugins): a Command Center agent item reaches the composer through the real client state

The command-path test now reads workspace and agent through
createPluginClientStateSource over seeded session stores, so an empty
lookup, which makes the Command Center return before calling the item,
would fail it. Also records at the focused-agent publisher that it
publishes in an effect.

* fix(plugins): a running agent's composer validates and refreshes against the agent's own catalog

setAgentModel and setAgentThinkingOption checked the provider's global catalog,
while the picker reads the catalog of the agent's directory, so a model only
the project lists was rejected. The validation input now carries the directory.

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

The shared composer map kept every agent's composer for the session. It is now
dropped when nothing watches it and no composer is mounted.

* fix(plugins): useComposers holds the composer it names, and a composer lives as long as its agent

PluginComposersSource.subscribe watched the stores directly and never the
composer model, so a component using useComposers did not keep the model's
catalog observer alive and reconnect repair skipped it. subscribe now takes the
selector and holds the model it names, following focus.

Dropping a composer when it went idle left a command a plugin had saved calling
a dead model after the tab reopened. A composer is now dropped when its agent
leaves the session and nothing watches it or has one mounted.

* fix(plugins): a composer refreshes a stale catalog on resumed demand, shares its store watches, and is released with its last holder

Watching again, or reading once, after the catalog was invalidated or failed
while nothing watched now starts one deduplicated refresh; the observer
refetches on mount. Reconnect repair only refetches watched queries, so an
unwatched catalog stayed stale.

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

A composer whose agent was removed while it was held was only released on the
next session change. The last holder letting go now checks too, and an archived
agent's idle composer is releasable.

* test(server): live model and thinking switches use values the provider lists

The daemon rejects a model or thinking option its provider does not list, so the live-preferences
e2e can no longer switch to a made-up id. A provider that lists nothing to switch to skips the case.

* test(server): the test providers list two models with thinking options, and the live switches run

The fake providers listed one model and no thinking options, so once the daemon validated
selections against the catalog the live model and thinking switches had nothing valid to switch to
and skipped. Every fake provider now lists two models with thinking options, and the switches run
for all three providers without depending on installed binaries.

* fix(plugins): a retained composer that is not showing takes plugin writes through the draft

On native a retained composer keeps its input mounted under display none, so the ref alone said
the input was live and a plugin insertText replaced the stale selection instead of appending to
the draft. The input is live only while its retained panel is showing.

* test(plugins): the retained input liveness test runs in real Chromium

* fix(plugins): a model or thinking change, an unreported option key, and a draft model reach the provider unchecked

Paseo no longer checks these in front of the provider. A running agent's model or thinking
change no longer waits for the catalog or checks it, a plugin provider is sent every option
key and refuses what it cannot apply, and a draft setModel keeps the id as the model picker
does.

* fix(plugins): a plugin reporting settings is sent every key of an options patch

* Run option, model, thinking and mode changes in the agent's lifecycle lane

The agent manager owns all sequencing for one agent: applyOptions, setModel,
setThinkingOption and setMode now run in the same lane as open, close, archive,
labels and metadata, so a provider session never sees two of them at once.
Provider-side serialization (the applying chains in the ACP, plugin and OpenCode
sessions, and Claude's apply-over-current-options handling) is deleted.

* Reopen a handleless agent inside its lifecycle lane

A stored agent with no resumable session is now reopened by
AgentManager.createAgentFromRecord, which rereads the record inside the agent's
lane before creating the first session. A metadata or label write queued ahead
is in the record it reads; one queued behind applies to the live agent. The
loader no longer passes a record it read outside the lane. createAgent itself
also runs in the lane, like resume.

* Return the draft's options from AgentClient.listControls

listControls now answers { controls, options } like AgentManager.listDraftControls:
the options a session opened from the draft would run with, with config
defaults merged in and anything the provider resolves natively for the ones
nobody set. ACP fills unset feature selects from the probe session's
currentValue, so a fresh Cursor draft shows the Fast selection the agent opens
with instead of no selection. The legacy features response and the MCP draft
listing already project from the manager's result, so they follow.

* Send a profile's options on every write, empty included

The profile form always sends options. The daemon reads a write without them as
one from an app that predates options and folds featureValues over the stored
ones; a write that carries them, empty included, replaces them. A profile left
with no options is stored without the key, so removing the last one persists.

* Read a remembered Codex fast_mode as its service tier

The create-agent preferences convert the legacy key on read, keyed on the
built-in codex provider id, so the Codex draft shows the Fast selection the user
last used. Tagged COMPAT(featureValues) with the other legacy-option shims.

* Document one option change at a time per agent

The agent manager runs option, model, thinking and mode changes in the agent's
lifecycle lane; providers do no sequencing. The hook deadlock warning now covers
agent.create.before and option writes.

* Flatten the profile fold's base selection and drop an unused import

* Carry the New workspace context through portal hosts and the draft's metadata through retry

* Convert legacy options when resolving a stored agent's effective options

* Keep stored-record reads tolerant of a legacy option the conversion rejects

* Keep plugin attachment items separate from the draft's own

* Hand plugin command inputs to the host as its own copies
2026-11-09 10:10:16 +07:00
Mohamed Boudra 680e6077a8 test(app): keep turn footer timing on one line when the chat has room (#9080)
The completed turn footer wrapped "Worked for 26s" onto two lines at full
chat width in builds before #9007, which sized the duration to whichever
label had more characters. A label with fewer characters can paint wider,
so the duration wrapped inside the narrower timestamp's width. #9007 gives
both labels the full label column; this locks that behaviour with a browser
test using real layout.

The browser test project could not load anything that reaches the toast
context: react-native-web has no ToastAndroid, and native ESM rejects the
named import. A react-native test shim re-exports react-native-web and adds
it. The lucide test stub gains the icons the message module imports.
2026-11-08 12:00:08 +07:00
Mohamed Boudra 4f7813d3aa Keep the tapped line in place when chat disclosures expand or collapse, with the native timeline on LegendList (#8717)
* Keep a disclosure's trigger in place on web

Expanding or collapsing a turn summary, tool-call group, tool call details
or todo badge now holds the row that owns the trigger. Rows inside the
timeline get a disclosure context; each toggle runs its state change through
`useDisclosureHold()`, and the viewport's new `holdRow(rowId, apply)` replaces
`holdReadingPosition()`.

On web the hold pins the reading anchor to that row, stops following output,
and resumes following when the change leaves the list at its end. Native keeps
its previous behaviour (detach, then apply) until the anchored toggle lands.

* Render the native chat timeline with LegendList in forward order

The native timeline was an inverted FlatList. Disclosure toggles moved their
trigger, and a cold open settled visibly. It is now a forward-order LegendList
that stays virtualized, and one owner moves the scroll offset at a time:

- Before placement: LegendList's initial scroll, behind its hidden reveal.
- While following: the end pin (native-end-pin.tsx). This is a zero-height first
  child at the bottom of the content, which native maintainVisibleContentPosition
  keeps in place. A content shrink or growth never scrolls from JS; only a
  viewport resize or an explicit jump does.
- During a disclosure hold and while detached: LegendList MVCP with
  shouldRestorePosition (native-disclosure-hold.ts), so a toggle keeps its
  trigger at the same y and paints one layout change.
- Every newly mounted row starts at a per-kind estimate (new-row-estimates.ts).

PaseoScrollView defers React Native's content clamp while MVCP is set. A shrink
below an unmoved anchor no longer draws a frame past the end (RN #58578,
PR #58695). VisibleContentPositionClampTest covers this.

LegendList 3.7.1 patch, one "Paseo patch" comment per hunk:
- The content height comes from React state on the new architecture.
- Rows get a per-row size estimate.
- The MVCP anchor lock runs on native too. Upstream gated it to web in d24a9d93
  without a native reason. Without the lock, the size pass for prepended history
  re-anchors on a prepended row and moves the rows on screen. Native ends the
  lock when its row leaves the view, not on the 300 ms clock.
- Header and footer reveal together with the rows.
- Only lists that keep their end preserve the initial end.
- A remeasure within one pixel plus the 1/8 dp floor of the known size is
  noise. Upstream compares the raw size with the floored one, so the live
  turn's assistant row (1380 -> 1381 px) read as a resize and moved every row
  a frame after the reveal.
- The Android silent initial scroll retry sends its 1 dp nudge and the return
  to the target in one task. Upstream returns on the next frame, but its 16 ms
  check can finish the scroll first and cancel that frame, so the list revealed
  3 px short of its end and moved into place a frame later.
- The top-padding shrink hold applies on iOS only.

Android, emulator. Evidence is in .artifacts/legend-impl.
- J1, J2 at the end, J3, J4, J5, J6, J7: pass.
- J2 while streaming: not reachable on the Ten second stream fixture; its turns
  never fold.
- J8: no blank frames over 10 fresh flings, at host load 32-37.

J4: still. Before these two hunks, 6 of 7 cold opens moved every row one frame
after the reveal. After them, 9 valid cold opens show no shift. 5 of those
were traced through the nudge path. Runs that hit the dev client's startup
SIGSEGV in MountingCoordinator::pullTransaction were left out. That crash is
in the emulator's dropbox from before this work.

* Revise a history row when its turn summary line changes

LegendList renders a row again only when its item changes. The forward-order
timeline in d1f2ae352 folds a finished turn into its host user message, but the
host's item does not change when the fold appears, so a turn that folded live
lost its summary line until something else revised the row.

collapseTurns now reports the hosts whose summary line appeared, changed or went
away since the previous fold (updatedHostIds), and the history row revision
treats them as changed content.

* Stop following the end while a user scroll owns the viewport

The bottom anchor controller kept sticky-bottom for the whole of a drag and its momentum and only
decided at the end. While it is sticky the native list mounts the end pin, so a drag started during
a stream had every new line move the rows under the finger: on the iOS simulator the offset jumped
by each line's height (+22, +35, +22 pt) mid-drag, and Android does the same with the same code.

beginUserScroll now detaches. endUserScroll resumes following when the gesture ends at the end
(scrolling there without animation) and otherwise stays detached and drops any pending anchor, as
before. The re-stick is not animated: on Android a line that mounts during a smooth scroll cancels
it, and the list then followed 40 pt short of the end until the controller detached it.

Measured after the change, frame by frame:
- iOS and Android, drag up mid-stream: the content tracks the finger and momentum with no line
  steps; the stream grows below; tapping the pill re-sticks and follows again.
- A short drag at the end, mid-stream: following resumes; the release lands on the end in one
  frame (the drag distance, here 8 pt on iOS and 21 dp on Android).

A line already mounting when the drag begins can still move the rows once in its first frame,
because the detach reaches native one commit after the touch.

* Correct the iOS scroll view under maintainVisibleContentPosition

Phase 2 of the LegendList timeline. The native timeline relies on maintainVisibleContentPosition
for the end pin and the disclosure hold, and React Native 0.86 on iOS gets two cases wrong.
modules/paseo-scroll now registers PaseoScrollViewComponentView, a subclass of
RCTScrollViewComponentView, for the iOS scroll view, like PaseoScrollView on Android.

- A mount that removes its own anchor: detaching removes the end pin in the same mount that opens
  the tapped disclosure, and the offset jumped by the removed view's frame. The subclass keeps the
  offset when the anchor's tag is no longer registered, the check React Native 0.88 adds in
  https://github.com/react/react-native/pull/57294. That PR's extra superview check is not copied
  because it skips anchors detached by removeClippedSubviews
  (https://github.com/react/react-native/issues/58910).
- A shrink at the end: UIKit clamps the offset to the new end during the mount, then React Native
  adds the anchor's movement, so a turn folding while following landed above the end
  (https://github.com/react/react-native/issues/58578). The subclass restores the pre-mount offset
  for the adjustment and clamps after it, as
  https://github.com/react/react-native/pull/58695 does.

The dev client was rebuilt with the module. Verified on the iPhone 16 Pro simulator, frame by frame:
the summary far up and the latest turn's summary while following each keep the trigger at 0 px
with one layout change per toggle, the todo card the same, the cold open lands still, older
history pages keep the visible rows in place, and the keyboard, send-time shrink and streaming
stay pinned to the end.

Freshly mounted markdown on iOS paints one frame after its layout, because enriched-markdown
renders asynchronously (https://github.com/software-mansion/enriched-markdown/issues/1131). A
synchronous first render removed that frame but raised fling frame time at p90 and p99, so it is
not part of this change.

docs/agent-stream-disclosure.md now describes the clamp correction on both platforms.

* Keep the rows under the finger at a drag's start and its release

Two one-frame row movements remained around a user scroll while the timeline streams.

A drag's first frames. The end pin stays mounted until JS stops following, one round trip after
the drag begins, so a line mounting in those frames scrolled the rows under the finger by its
height. Both scroll view subclasses now skip the content-position correction while the user drags
and the anchor is the end pin, which they recognise by its native ID ("paseo-end-pin"). Android
overrides scrollToPreservingMomentum, the position keeper's only scroll call, from the begin-drag
event until the finger lifts; iOS skips the adjustment while the scroll view is dragging. The skip
is limited to the pin: LegendList's own anchoring for the detached list also uses the native
correction, and a history page landing mid-drag needs it. It starts at the drag rather than at
touch-down: a tap or long press while following would otherwise leave the list stranded short of
its end, still following, once the finger lifts.

The release. A gesture ending within 32 pt of the end re-stuck with an instant scroll, a snap of up
to 56 px. Following now resumes only when the finger lifts at or past the end, or momentum stops
there, within 1 px, the distance web resumes at (strategy-web.tsx). Anything else stays detached with the
pill. The check runs when the gesture is decided, so output that arrived below meanwhile counts. A
release past the end counts as at the end: on iOS it bounces back, and a line landing during the
bounce scrolls in as following would. endUserScroll takes isAtEnd, like releaseDisclosure.

Tests: VisibleContentPositionClampTest gains growth at the end during a drag (red before the
change: the offset moved by the 120 px growth, 352 -> 472) and growth without a touch (the pin
still follows); the module suite passes on emulator-5558, 11 tests.
bottom-anchor-controller.test.ts: a release near but short of the end stays detached.

Device, frame scans while streaming (dev clients rebuilt):
- Android, 8 slow drags with a pill tap between: no frame moves against the finger once a drag
  starts; the stream follows between drags. Three short releases 50 px short of the end stay put
  until the pill; three drags back past the end resume following.
- iOS simulator, 6 drags: no line step during any drag or its momentum. Two short releases stay
  detached with no snap; three drags past the end resume following within 0.5 s of the bounce.

* Document the LegendList timeline and remove what the inverted list left

The native section of docs/agent-stream-disclosure.md described the FlatList hold (anchor
neighbour, native child index, frame spacing, render window). It now covers why the timeline is
forward order, who owns the offset in each state, the hold and its sync layout trigger, user
scroll and release, draw distance numbers, the LegendList patch and the scroll view corrections.

Draw distance stays at 2000. The spike measured it on a chat with a 15,000 px turn expanded: 500
blanked the viewport 1.0 to 1.5 s per fling and 2000 not at all. A re-measure on the collapsed chat
(500, 1000, 1500, 2000, interleaved) blanked at no value and its jank followed host load, which
stayed between 19 and 40, so it could not separate 1000 or 1500 from 2000.

The patch file now opens with a header listing its eight hunks by purpose and linking
LegendApp/legend-list#593, which reports each one upstream. The markdown doc records the iOS
first-paint delay (software-mansion/enriched-markdown#1131).

Removed with the inverted list: PaseoScrollView's child-reveal override (a no-op without the
inverting transform) and ScrollSelectionTest, the invertedWrapper style, and the inverted-list
passages in android.md, native-text-selection.md, mobile-testing.md, floating-panels.md and
agent-stream-performance.md. model.test, layout.test and footer-spacing.test still asserted the
inverted native order and failed; they now assert forward order, and footer-spacing uses the
real strategies instead of an inverted stub.

* fix(app): show a reply's footer when the next prompt arrives on native

LegendList re-renders a mounted row only when its item or extraData changes.
When a new prompt arrives, the previous reply's footer moves from the live
auxiliary slot into that reply's layout item (completedFooter). The reply's
StreamItem keeps its identity, and renderItem is a stable event, so the row never
rendered its new layout and the footer (copy, timing, fork) disappeared until the
row remounted. FlatList had the same gap.

The history renderer is now LegendList's extraData. Its identity changes with the
history layout map, and StreamRow's memo limits the pass to rows whose layout
item changed.

* test(app): run the web disclosure hold cases in a real browser

The three hold cases simulated layout in jsdom: hand-written row heights,
scroll clamping and resize callbacks. They could pass while Chromium paints
the trigger somewhere else. They now run in Vitest browser mode with real
layout, ResizeObserver and scroll clamping. The two hold cases fail when the
hold is bypassed.

The browser project pre-bundles @tanstack/react-virtual, so its first import no
longer reloads the test mid-run. The lucide stub gains ArrowDown, which
pane-find imports.

* fix(app): let a drag end a disclosure hold on native

A drag detached the bottom controller but kept the disclosure hold. Every item
size change restarts the hold's quiet frames, including a streaming row's, so
the hold can outlive the toggle. While it lasts, shouldRestorePosition accepts
only the held row. Once the drag scrolls that row away, a history page arriving
mid-gesture gets no anchor, and the rows the reader was on move by the page's
height.

The drag now cancels the hold without releasing it. The controller keeps no hold
state of its own beyond detached, and the gesture's end decides whether following
resumes.

* perf(app): keep the scroll-to-bottom button's state out of the timeline

Opening a tool call or turn at the end of the chat now holds its trigger in
place, so the timeline detaches and the scroll-to-bottom button appears. That
visibility was useState in AgentStreamView, so showing the button re-rendered
the whole timeline view for a change only the button reads. The viewport now
writes near-bottom into a per-agent store that only the button subscribes to.

* fix(app): show the scroll-to-bottom button on native whenever following has stopped

A gesture now resumes following only when it ends within 1 px of the end, but
the button still hid within 32 px. A release 2-32 px short of the end left the
list detached with no button to return. While detached the button now hides
only at the end; while following it keeps the 32 px tolerance so content
growth that the offset trails for a frame does not flash it. The button also
updates when following starts or stops without a scroll.

* fix(app): stop the timeline jumping when the keyboard opens or closes on native

The keyboard's settled height was committed to the list after the motion, as
paddingTop on Android and contentInset.top on iOS, to keep the top of the
history reachable under the translated dock. While following, the end pin
absorbed it in the same mount. While detached or holding a disclosure, nothing
did: Android's native anchor is LegendList's ScrollAdjust view, which padding
does not move, and LegendList compensates a padding change only on iOS. Every
row jumped by the keyboard height 0.2-1 s after the keyboard settled, and back
after it closed. On send, the same commit raced the jump to the end.

The keyboard now only translates the list with the composer. The cost: while
the keyboard is open, the composer viewport clips the top keyboard-height of
the list, so the start of a short conversation is hidden until it closes.

* chore(app): describe the native timeline as LegendList in stream comments

* fix(app): keep a disclosure's trigger in place when its row is cut off at the top on native

LegendList's idsInView starts at the first fully visible row. When a turn host's prompt bubble
sat above the viewport, a disclosure hold found no anchor in the data pass (shouldRestorePosition
rejects every other row), and the size pass anchored on the row below the host instead, so the
trigger moved by the host's size change. The patch appends the partly visible rows to idsInView
after the fully visible ones, so the held row anchors while every pass that does not restrict the
anchor keeps the first fully visible row.

The browser test renders the patched native build, which vitest now aliases instead of the
package's unpatched web build.

* fix(app): let a turn's summary line collapse again after it opens

The React Compiler cached StreamRow's `renderStreamItem(layoutItem)` on the layout item and the
renderer. The revised item that useRevisedHistoryRows hands a row whose display state changed only
fed the row id, so the row reused its cached content. Opening a turn usually inserts its rows
directly below the host, which also gives the host a new layout item. When another row sits
between them, such as a compaction marker, the host kept its layout item, its summary line stayed
collapsed, and every later tap asked to open the turn again.

The renderer now takes the item it renders, so the compiler's cache keys on the revision. StreamRow
moves to its own module, which the tests compile the way Expo does.

* test(app): alias the patched LegendList build in browser tests only

Unit tests load @legendapp/list outside Vite, so the native build's react-native import skipped
the react-native-web alias and reached React Native's Flow source, failing every unit suite that
imports the native strategy.

* fix(app): keep a size-only disclosure's trigger in place when its row is cut off at the top on native

LegendList's prepareMVCP picks the anchor for a size change with no data change as the first row
in view, without asking shouldRestorePosition. A disclosure that only grows its own row (a todo
card) while that row's top was above the viewport therefore held the row below it, and the trigger
moved by the row's size change. The patch now applies shouldRestorePosition to size changes as it
already does to data changes.

* test(app): render the timeline row revision test in a real browser

The repo's unit tests do not mount components in JSDOM. The test still compiles stream-row.tsx with
the React Compiler, so it fails when a revised row ignores the item it was handed.
2026-11-04 17:21:31 +07:00
1c4400194a Support hardware keyboard shortcuts across native and desktop apps (#8625)
* feat(keyboard): support hardware keyboards on iPad

Register app shortcuts as iOS UIKeyCommands so a paired hardware keyboard
drives the same bindings as web and desktop. Overlays (menus, comboboxes,
modals, command center) get first refusal on bare keys, and a focused
terminal keeps the keys it needs.

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

* docs(keyboard): record iOS hardware key observation spike

With a UITextView focused on iPad Pro M4/iOS 18.6, window and controller observers received Cmd+B, Cmd+P and Escape. Escape also invoked UIKeyCommand when registered, so the stream must be the sole emitter. Use observation for shortcuts and commands only for text-control ownership. Simulator intercepted Cmd+K as HID184; input capture blocked synthetic typing entirely. Keep capture off and verify the toggle via a rebind during simulator QA.

* refactor(keyboard): give overlays one key registration API

Overlay ownership now follows one layer-ordered registry on every runtime. Web retains focus trapping and restoration through its adapter; native registers the same key handler without a DOM scope. Picker navigation and dismissal no longer require a parallel native stack.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): route native hardware keys through the shared resolver

Observe each physical press once, including native modal windows, and derive focus from declared composer and terminal surfaces. Native bindings and rebinds now use the same resolver and overlay precedence as browser and Electron input.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): show shortcuts when hardware keyboards are present

Keep routing independent of compact layout and detect hardware capability for hints and settings. Rebind capture uses the shared overlay interface on native too. Keep the filtered sidebar model available for keyboard navigation while its panel is hidden.

Co-authored-by: Les Stroud <public@lesstroud.com>

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* test(keyboard): allow shortcut hints in compact menu assertions

Compact menus now display hardware keyboard hints. Assert action labels, order and uniqueness without treating a trailing shortcut badge as part of the action label.

* fix(keyboard): retain an overlay's opening layer in state

Capture the registry's current top when an overlay opens and retain that layer through rerenders. React Compiler removed the previous useMemo cache after the registry getter moved across modules, allowing a parent modal to rise above its child and start an infinite focus loop.

Compile this hook in Vitest as Expo does. The new hook regression failed first with layer 220 instead of 20, then passed with explicit opening state. Focused unit and browser tests pass, and the previously failing onboarding E2E completes through the real Expo compiler. Typecheck, lint and format pass.

* refactor(keyboard): let overlays own native hardware input

Remove the TextInput key handlers that duplicate declared overlay arrows and Escape in Add Project and Command Center. Remove Add Project's hidden responder now that native window sources observe keys without it. Keep soft-keyboard submit editing behavior.

* fix(keyboard): close Add Project outside its state updater

Root Escape previously called the parent close action from a React state updater, producing a cross-component render warning when navigation was still queued. Compute the previous page in the event handler and close outside the updater.

The existing Escape/Back E2E now batches navigation and dismissal through the DOM key source. It failed first with the AddProjectFlowHost render warning, then passed after the correction. Typecheck, lint and format pass; native Escape checks pass on Android and iPad.

* fix(keyboard): preserve Android dead-key composition

* fix(keyboard): recognize focused native text inputs

* test(keyboard): replace JSDOM coverage with browser tests

* refactor(menu): spell out arrow navigation branches

* fix(composer): claim fullscreen Escape on native

* refactor(composer): one fullscreen Escape module for every platform

The shared registration lived in fullscreen-exit.shared.ts with two one-line
re-exports (fullscreen-exit.ts and fullscreen-exit.native.ts). Metro needs no
native override now that the Android back button is its own adapter, so the
implementation moves into fullscreen-exit.ts and the browser test imports the
module every platform resolves.

---------

Co-authored-by: Ubuntu <jeroenfrenken@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>
2026-11-03 18:08:27 +07:00
Mohamed Boudra 0a549945b5 fix(terminal): keep native text aligned with terminal cells (#8336)
Shape rows in native Canvas and CoreText, then place clusters on xterm columns so fallback font advances cannot separate text from its cursor. Wait for measured dimensions before attachment and repaint overhanging ink with affected rows.

Use Android platform ICU to avoid bundling approximately 14 MB of data; retain and document the Android 10 conjunct limitation.
2026-10-31 09:45:57 +07:00
Mohamed Boudra d6d5a266ad Open a subagent in place of the subagents list on mobile (#8093)
* fix(app): seat the subagent sheet's parent callout like the composer

The sheet tab body padded the bottom safe-area inset, and the composer dock
padded it again, so the parent callout in a subagent sheet floated a full
navigation-bar height above where the composer sits in a tab.

The sheet body (SheetBottomInset) now owns the inset: it pads it once and
tells its content none is left, so the dock stays host-agnostic. Overlay
sheets (adaptive modal sheet, menu sheet, combobox sheet) read the inset on
the far side of their portal, so ones opened from sheet content still clear
the screen edge.

The callout takes the composer's rail and shows the parent's provider icon
through StatusIcon: no icon box, with the running ring, attention dot or
needs-input alert on the icon.

* fix(app): give the attachment lightbox its own safe area

An RN Modal passes its call site's context straight through, so a lightbox
opened from a sheet tab read the bottom inset the sheet body had already
spent (0) and drew over the navigation bar. The modal is its own window:
it now gets its own SafeAreaProvider, which measures that window, and the
image area reads its insets below it.

floating-panels.md records the rule for overlays: read insets where the
overlay draws.

* feat(app): open a subagent in place of the subagents list on compact

On phones, choosing a subagent from the subagents list now replaces the list
inside the same sheet instead of stacking a second sheet over it. Back returns
to the list, Close closes both, Android Back steps subagent -> list -> closed,
and Open parent lands on the pane the list was opened from (for a nested
subagent, its parent's sheet).

- Menu engine: fill pages (`fill: true`) take the full sheet height, Back pops
  a page via `goBack`, and a page whose definition disappears falls back to
  the list.
- One sheet pane (`screens/workspace/sheet-pane`) renders the header, body,
  bottom inset and focus for both containers: the workspace's detached sheet
  tabs and the in-list menu page. Only the container differs.
- The header drags the sheet through `SheetDragArea`; compact menu sheets use
  the shared `SheetBackground`/`SheetHandle` chrome.
- `selectSheetTab` describes the sheet a reveal would show without opening it.

* fix(app): put the parent callout on one row

The Open parent button already says what the callout is, so the Parent label
goes. The parent's title shrinks to one line before the button does, so Open
parent never wraps or leaves the row.

* refactor(app): register panels once in the workspace screen

Every reader of the panel registry called ensurePanelsRegistered, which
imports every panel module. The panels render those readers (the agent panel's
subagent track and parent callout, the new-tab panel's launcher, the diff
panel's review surface), so each reader closed an import cycle through
register-panels.

The workspace screen, the only route that renders a registry reader, now
registers the panels before anything under it renders, and the readers import
only the leaf panel-registry. The pane content test registers a probe panel for
real instead of mocking the registry.

* test(app): transform the markdown WASM factory when Vite versions its URL

Importing the sheet pane barrel in retained-chat-content.browser.test.tsx made
Vite request md4c.js with a ?v= query, which the CommonJS-to-ESM transform did
not match, so the test file failed to import.
2026-10-28 13:55:41 +07:00
Dong Hyeon An b9f624f72e fix(app): update xterm to prevent shared-atlas glyph corruption (#8013)
Use upstream atlas invalidation fixes and cover ordinary output and shared-atlas clearing with real-WebGL pixel regressions.\n\nRelated to #6506; does not close the broader rendering issue.
2026-10-27 09:22:15 +00:00
Mohamed Boudra 74a7e5803b Choose voice providers and local speech models from a Voice settings page (#7295)
* feat(voice): add a Voice settings page with provider choice and a local model catalog

Each host gets a Voice page under host settings. Dictation and voice mode
each pick a provider (Local or OpenAI) and a model. Provider settings sit in
one group of collapsible rows: local speech-to-text and text-to-speech list
every catalog model with its size, languages and description before download,
and OpenAI takes a write-only API key and an optional base URL.

The local catalog grows from Parakeet/Kokoro to Whisper, SenseVoice, Dolphin,
GigaAM, Parakeet Japanese, multilingual Kokoro, Kitten and Piper voices. Each
entry declares its sherpa engine family, so the recognizer and TTS build the
matching model config and the downloader verifies the files that family needs.
Kitten v0.8 is left out: it crashes the pinned sherpa-onnx 1.12.28.

Voice mode is now opt-in. The default lived only in code (true), never in
config.json, so hosts that never toggled it find it off after upgrading.

OpenAI speech-to-text resolved one model for both features, with voice mode's
choice taking precedence, so dictation silently used voice mode's model. The
resolved config now carries a model per feature; STT_MODEL still overrides both.

Rows revealed under a collapsible settings row are a nested density level set
by the container: tighter padding, smaller text, and xs controls, with
controls taking their size from the row instead of each caller.

* test(app): pre-bundle the JSX dev runtime for browser tests

Compiling test JSX with the automatic runtime imports react/jsx-dev-runtime. Vite discovered it mid-run on a cold cache and reloaded the page, which failed the first browser test file to import it.

* refactor(voice): keep speech provider settings at each use site

Store credentials and endpoint options beside the speech feature that consumes them so saved page values cannot be shadowed by a separate provider block. Normalize existing config on load and mirror the inline shape on config reads and patches.

This is the config, resolver and wire phase; the provider sheet follows separately.

* fix(voice): preserve saved keys when resaving provider settings

Forms send the selected provider on each save but cannot read back its API key. Merge patches for the existing provider so these saves retain credentials; replace the site only when the provider changes.

* feat(voice): configure each speech use site from a provider sheet

The Voice page now lists Speech-to-text, Listening and Speaking as one-line
summaries. Tapping one opens a sheet with the provider choice and only that
provider's settings: the local catalog (size, languages and description
before download, download state per model) or the OpenAI key, base URL,
model and language or voice.

The sheet's form model sends only the fields the user changed, so re-saving
never clears a saved key. Fields an env var decides are read-only with a
"Set by" hint; the API key and base URL stay editable because a saved value
wins over the env fallback. Save failures show a toast and keep the sheet
open with the input.

E2E daemons pin speech providers through env vars to avoid model downloads,
so the browser spec covers the locked-provider path, the key fallback and
the failed save; provider switching is covered by the form model tests.

* refactor(voice): press settings and catalog rows through PressHighlight

Settings rows that lead somewhere and local model rows now use the standard
row press primitive, so native shows the finger-down flash the rest of the
app's rows have. Web hover and press styling are unchanged.

* fix(voice): preserve STT language for local providers

Language belongs to every STT use site. Preserve legacy local language settings through config loading and saving, resolve them without provider gating, and expose them in the provider form.
2026-10-26 14:19:02 +07:00
Alessio Re 16fe07560f fix(app): make question cards link-aware and selectable without accidental answers
Render question bodies and option text with the shared Markdown renderer. Opening links or selecting question and description text leaves answers unchanged. Labels remain answer controls, and double-clicking a label advances only once.

Add browser regression coverage and stabilize Markdown WASM loading in the test harness.

Closes #3164.

Co-authored-by: Alessio Re <NoSync@users.noreply.github.com>
2026-10-26 10:51:44 +07:00
Mohamed Boudra 582beeb10b fix(app): preserve compact sidebar context under sheets (#7693)
Keep compact sidebars open while users inspect Search, Import session, Add project, or file sheets. Reveal the resolved destination when navigation commits, and place compact workspace sheets above both sidebars while preserving shared editing and cache state.
2026-10-23 15:57:12 +07:00
Mohamed Boudra 995ad2f878 feat(plugin): render Markdown content with host UI (#7579)
* feat(plugin): expose Markdown content component

* docs(plugin): translate Markdown component reference
2026-10-21 19:14:17 +07:00
Mohamed Boudra adf27de7d0 Keep Explorer consistent across phone, tablet and desktop layouts (#7424)
* Upgrade to Expo 57 and unify Explorer across layouts

* feat(app): retain Explorer across layout and appearance changes

Keep one workspace Explorer host and destination-aware navigation so folding, theme updates and compact sheets preserve the current panel. Scope pull request discovery and timeline demand to active surfaces.

* fix(app): keep Explorer resize handles stretched through the dock

The shared frame moved the divider into an absolute column, leaving its horizontal handle without height. Restore the row so pointer drags reach it, preserving the existing resize and performance contract.

* fix(app): keep Explorer drag widths out of the shared stylesheet

Carry the existing inline sizing policy into the shared frame so pointer previews do not accumulate CSS rules.

* fix(test): align isolated test runtimes with Expo 57

Hoisted React consumers need the matching DOM renderer at the repository root. Automated Metro processes must not install native DevTools into disposable homes, and development toast overlays must not cover compact actions.

Keep Explorer journeys on the shared host and synchronize setup fixtures on daemon output rather than a one-second file-marker window. A delayed real child start reproduces the readiness failure on every platform.

* fix(test): await retained shell layout after composer resize

The gesture host now stays mounted across breakpoints, so its presence no longer identifies compact layout. Report the current layout on the retained host and synchronize composer journeys with that state.

* fix(app): preserve browser interactions in retained workspace hosts

Initially disabled gesture hosts still registered pointer and wheel listeners, while hover cards used window positions inside a workspace-local sheet portal. Respect initial gesture enablement and position cards relative to their measured floating host.

Keep developer-toast dismissal independent of pointer input and replacement toast visibility so modal, hover, and focus checks retain their input state.

* fix(test): share developer toast handling with Electron browser runs

The standalone Electron browser runner bypassed the browser fixture, allowing developer errors to cover Settings. Use the same non-pointer dismissal in both runners while retaining their console logs.
2026-10-20 11:59:49 +07:00
paseo-bot[bot]andIvan Malison fc775b6db5 Load translations when their language is selected (#7410)
* perf(app): load translations when their language is selected

Eager locale imports evaluated and registered all nine translation bundles on every cold start. Load bundled locales through a synchronous i18next backend so language selection keeps its before-render contract and offline behavior.

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

* test(app): normalize translation entry paths across platforms

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-10-19 21:03:24 +02:00
paseo-bot[bot]andMichael Wu eda3e4318f Clarify and localize provider usage (#7376)
* Localize provider usage UI

* fix: restore compact provider usage meter

* fix: localize compact context percentage

* fix(app): deduplicate context meter refresh

* fix(app): localize provider usage time counts

* fix(app): stabilize compact provider usage row

* fix(app): localize provider usage plurals

* test(app): allow locale-specific plural keys

* fix(app): show compact quota without context usage

* fix(app): preserve context overflow percentage

* fix(app): truncate provider balance reset values

* fix(app): add Korean provider usage translations

* fix(app): refresh provider usage timing

* fix(app): localize provider usage labels

* fix(app): clarify compact usage policies

* fix(app): localize monthly provider usage

* test(app): require Arabic provider usage plurals

* docs(i18n): keep migration guidance focused

* fix(app): hide empty compact usage slot

* fix(app): keep usage in context tooltip

* fix(app): align usage with sidebar reports

* test(app): pass usage timing clock explicitly

* fix(app): restore context usage details

* fix(app): make context usage tooltip interactive

* fix(app): support keyboard tooltip controls

* fix(app): keep focused tooltip controls open

* fix(app): address usage localization review

* fix(app): localize bulk usage refresh

* fix(app): localize Claude session window

* fix(app): complete provider usage localization

* fix(app): keep mounted usage times and translations current

Pass time and translation values into usage rendering so React Compiler invalidates cached labels when they change. Use the same localized window names and percentage meanings in sidebar summaries.

* test(desktop): wait for restored tabs before shortcut input

Document load completes before the workspace reconnects. Wait for the restored selection so Ctrl+1 reaches the mounted workspace rather than the startup screen.

* test(app): allow the shared usage clock minute phase

The browser clock advances independently of daemon timestamps, and the shared ticker samples on wall-minute boundaries. Assert the bounded one-to-two-minute age and matching countdown progress instead of a single phase-dependent value.

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 18:01:45 +02:00
paseo-bot[bot] dc7bc8db12 fix(app): keep typechecking after Expo generates environment types (#6656)
Expo declares NODE_ENV readonly after web startup. Preserve the early test environment setup with Object.assign and exercise the real configuration with Expo types in a failing-first compiler regression.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 02:49:09 +02:00
paseo-bot[bot]andIvan Malison b412374f0c Run React tests when the shell uses NODE_ENV=production (#6572)
* fix(tests): run React tests from production shells

Vitest preserves an inherited NODE_ENV, selecting React without its test APIs. Pin the environment before dependency resolution and worker startup in both test entry points.

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

* test(app): expect zero edits for a pure staged rename

The rename patch correction on main preserves unchanged content. Keep the discard journey intact and update its obsolete added-line assertion.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-10-11 11:52:50 +02:00
paseo-bot[bot] b63a325be3 Let Android devices without a camera install Paseo from Google Play (#6339)
* fix(android): declare the camera optional so camera-less devices can install

Requesting CAMERA makes Android treat android.hardware.camera as a required
feature, so Google Play marks devices without a camera as incompatible.
Pairing works without QR scanning, so declare the camera features optional.

Fixes #6303

* test(app): resolve the manifest with the Expo CLI's prebuild packages

Load @expo/prebuild-config and @expo/config-plugins from @expo/cli instead
of the older hoisted copies, and set the F-Droid flag explicitly for each
build profile case.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-10 09:54:15 +02:00
a3fc59e818 Keep a project in the sidebar when its folder name ends in a space (#5205)
* fix(app): keep sidebar order keys that end in whitespace

A project view key embeds the project's path, so a directory whose name
ends in a space produces a key that ends in a space. The sidebar order
store trimmed every key on write, so the stored key could never match
the key the sidebar looks up. computeSidebarOrderUpdates saw the project
as missing from the order on every pass, appended it, and the store
trimmed it away again, setting a fresh array each time. The effect that
applies those updates re-ran on every render until React gave up with
error #185, and the app failed to load until the folder was renamed.

Keep keys verbatim on the runtime setters, dropping only blank keys and
duplicates. Migration of persisted state still trims, where a stray
space is an artifact of an older format rather than part of the key.

Fixes #4880

* test(app): give AsyncStorage a real in-memory stub instead of mocking it

`docs/testing.md` bans `vi.mock`, and the test added here mocked
`@react-native-async-storage/async-storage` only because the module has no
Node implementation: its default export reaches for the native module, so
every write from the persist middleware rejects and a test that touches a
persisted store drowns in unhandled rejections.

That is exactly the case `test-stubs/` exists for, reached through the
resolve.alias in `vitest.config.ts` the same way react-native-unistyles,
react-native-svg and expo-linking are. The stub is a real in-memory
key-value store with the same contract, so a test can read back what a
store wrote rather than inspect a recording double.

`sidebar-view-store.test.ts` carried the same mock for the same reason and
no longer needs it either.

* test(app): give the AsyncStorage stub the multi-key calls the app uses

`provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
is handed the real AsyncStorage, so it calls `multiGet` and `multiRemove`
during reconciliation and cleanup. The stub stopped at the single-key calls,
which is fine until a test reaches the provider cache and then fails with
`storage.multiGet is not a function`.

`multiSet` comes along with them so the three multi-key calls behave as a set
rather than leaving the next caller to notice the gap the same way.

* test(app): cover a project folder name ending in a space end to end

Adds a browser regression for #4880: the daemon accepts a directory whose
name ends in a space, and the sidebar has to render its project row instead
of tearing the app down with "Maximum update depth exceeded" on every load.

* test(app): move the trailing-space spec setup behind a seeded-project helper

docs/testing.md asks for E2E bodies that read like plain English with the
setup in helpers, and for helpers that own the project record until cleanup.
The spec did its directory creation, daemon seeding, and removal inline.

seedProjectNamedWithTrailingSpace owns the directory and the daemon project
and returns a cleanup, the same shape seedWorkspace returns, which cannot be
used here because mkdtemp appends the random suffix and so cannot produce a
name that ends in a space. The body is now goto, assert the row, assert no
error screen.

The row is still located by its sidebar-project-row test id, which is how
every other sidebar spec and the shared sidebar helpers address a project row.

---------

Co-authored-by: L4XB <lukas.buck@e-mail.de>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-09-22 22:06:24 +02:00
Mohamed Boudra 6830c46e9a fix(app): keep composer layout stable across chat and workspace screens (#4973)
* fix(app): dock the New workspace composer like a chat on phones

Only the composer is bounded by the viewport; the setup fields sit above it in a
bottom-anchored stack that moves up as the draft grows. The keyboard reservation
is retained on every screen, so the composer keeps one height across keyboard
transitions. Removes the per-screen reserve policy.

* fix(app): share composer docking across mobile hosts

Extract chat's retained capacity and platform keyboard motion so chat,
workspace drafts, and New workspace share one sizing and dismissal boundary.
Keep the native drivers and settled portal reconciliation unchanged.

* fix(app): preserve overlays in centered composer docks

Honor the optional overlay child in both layouts so changing dock placement does not silently discard part of its content.

* test(app): resolve keyboard controller through its web entry

The shared keyboard entry point makes stream tests load the provider. Keep its real ESM dependencies inside the Vitest resolver so CommonJS does not bypass the React Native web alias and parse native Flow sources.

* fix(app): keep native chat scrolling responsive

Place keyboard dismissal behind scrollable content so an idle Android Fabric JS responder cannot intercept native drags. Keep decorative form padding transparent to iOS hit testing and exercise idle native scrolling after submission from every composer host.
2026-09-16 23:43:15 +02:00
799c91b39d fix(app): restore Android tablet model picker interactions (#4845)
Restore provider/model selection in Android tablet modal windows by giving both native modal hosts their own gesture-handler root. Preserve phone sheet and web portal behavior.

Include native regression scripts, test dependency resolution, and verified CI test corrections. Generated media and logs are excluded. Refs #4475 and #4377; consolidates #4721 and #4771.

Co-authored-by: Mike P. Kuhl <mkuhl@softmachine.at>
Co-authored-by: Cameron Chin <cjcrjc@users.noreply.github.com>
2026-09-14 19:10:32 +02:00
Mohamed Boudra 17f06443dc test(app): prebundle browser JSX runtime 2026-08-18 01:03:42 +02:00
Mohamed Boudra c859b59c99 fix(app): dismiss the composer track panel when a row is chosen
Track pills became menu surfaces when the trackers moved out of stacked
cards, but ComposerTrackRow kept the raw Pressable it had as an inline
card row. Nothing in that path reaches selectItem, so the popover or
sheet stayed parked over the transcript after opening a subagent, and on
iOS the action fired while UIKit was still tearing the sheet down. Rows
now hand the press to the engine and take closeOnSelect, as MenuItem
does; bulk archive opts out because its progress lands in the panel.

The running mark is the shared StatusRing rather than a still dot the
pill drew for itself, and the remaining dots read their colour from the
one bucket-to-colour map. The slot is sized for the ring in every state
so the label does not step sideways when the last child finishes.

Mounting the real menu engine in a test needed harness work: stubs for
two packages that ship untranspiled Flow, reanimated left out of Vite's
prebundle so its .web files win, and the gaps filled in the unistyles
test theme.
2026-08-17 23:43:01 +02:00
Mohamed Boudra a5184fd08a fix(app): keep browser relay WebSockets protocol-neutral (#2976)
Use the standard browser WebSocket constructor without the Node/React Native options object, while retaining the options-capable factory for supported runtimes and the desktop bridge.
2026-08-07 17:30:43 +08:00
Mohamed Boudra d191fa6685 feat(app): automate iOS beta distribution
Beta tags now reach external testers without per-release dashboard work. Reserve distinct native build slots so successive betas and the promoted stable build can coexist under one app version.
2026-08-04 19:21:22 +02:00
nllptrxandMohamed Boudra a8ebd390fa feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-17 15:03:26 +08:00
Mohamed Boudra 922a93af2f Serialize browser app tests 2026-05-31 22:42:25 +07:00
Mohamed Boudra 53c14d9855 Extract client SDK package (#1052)
* Extract client SDK package

* Polish SDK client identity defaults

* Build client before dependent CI jobs

* Restore daemon client server export

* Extract protocol and client SDK packages

* Fix provider override schema validation

* Fix app test daemon client imports

* Simplify workspace build targets

* Fix CLI test server build bootstrap

* Run SDK package tests in CI

* Fix rebase package split drift

* Restore lockfile registry metadata

* Update SDK config test for prompt default

* Move terminal stream router test to client package

* Fix rebase drift for protocol imports

* Fix SDK agent capability fixture

* Restore legacy server client exports

* Fix server export compatibility test

* Advertise custom mode icon client capability

* Remove server daemon-client exports

* Format rebased mode control import

* Fix rebase drift for protocol imports

Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split
shared/ paths that this branch moves into @getpaseo/protocol. Redirect
those imports to the protocol package so typecheck stays green after the
rebase.
2026-05-28 01:58:18 +08:00
Matteo Pietro Dazzi e58ea31d6d chore: upgrade vitest (#1091)
* chore: upgrade vitest

* chore: sync lock
2026-05-20 12:14:13 +00:00
Mohamed Boudra 79bf5ac3c7 test: add app browser coverage 2026-04-21 20:46:49 +07:00
Mohamed Boudra d9d6509880 fix(test): resolve Metro .web.ts variants in vitest
message-input.tsx imports ./composer-height-mirror, which only exists
as .web.ts / .native.ts / .d.ts. Metro picks the right variant at build
time, but vitest was using Vite's default extension list and failing to
resolve the import — breaking app tests in CI.

Prepend `.web.*` variants to `resolve.extensions` in both the root and
app vitest configs so test runs (which already alias react-native →
react-native-web) pick the web implementation, matching what Metro
does for the web bundle.
2026-04-19 15:11:37 +07:00
Mohamed Boudra 7c18170d6a Cap app vitest workers to 2 forks to reduce CPU pressure
The app package used the default fork count (= CPU cores), which
combined with concurrent agent test runs was starving the machine.
2026-04-12 15:06:10 +07:00
6d4cc53a0a ci: fix all tests to green (#236)
* ci: add CI status tracker for test fix iteration

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

* cli: honor daemon connect timeout

* app: fix e2e helper server path

* e2e: fix helper imports and ws cleanup

* cli: align daemon status tests

* style: autoformat with biome to fix 195 formatting errors

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

* server: fix 4 stale test expectations and setup bug

- logger.test.ts: update expected default level from trace to debug
  matching intentional product change
- session.workspaces.test.ts: only opened worktree reconciles, not
  siblings; add explicit reconcileWorkspaceRecord before owner-change
  assertion
- worktree.test.ts: add explicit git checkout -B main origin/main
  for deterministic CI branch state

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

* server: align daemon-client test expectations with ignoreWhitespace field

normalizeCheckoutDiffCompare() now always emits ignoreWhitespace: false,
so update the two checkout-diff subscribe test assertions to match.

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

* style: format daemon-client test to satisfy biome

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

* cli: update provider test for new providers and model lineup

Update 15-provider test expectations to match current product state:
- Provider count: 3 → 5 (added copilot, pi)
- Claude models: 3 → 4 (added claude-opus-4-6[1m])
- Codex models: replace retired gpt-5.1-* with gpt-5.4/gpt-5.4-mini

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

* app: fix 18 failing test files with vitest setup and stale expectations

Add vitest.setup.ts to define __DEV__, shim Expo globals, mock
react-native-unistyles/expo-linking, and stub @xterm/addon-ligatures.
Update stale test expectations across combined-model-selector,
use-settings, tool-call-display, sidebar-project-row-model,
sidebar-shortcuts, keyboard-shortcuts, host-runtime,
use-agent-form-state, desktop-permissions, and voice-runtime
to match current source behavior.

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

* ci: add missing highlight build step to app-tests job

The app-tests CI job was missing the `npm run build --workspace=@getpaseo/highlight`
step that all other jobs have. This caused diff-highlighter.test.ts to fail with
"Failed to resolve entry for package @getpaseo/highlight" because the dist/ directory
did not exist.

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

* ci: install codex and opencode CLIs in cli-tests job

The 15-provider test expects `provider models codex` and
`provider models opencode` to succeed, which requires the
actual CLI binaries to be on PATH.

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

* app: fix Playwright e2e helpers using import.meta.url in CJS context

Revert dynamic import path resolution from `new URL(..., import.meta.url)`
to `path.resolve(__dirname, ...)` + `pathToFileURL(...)` in three e2e
helpers. Playwright's TS loader emits CommonJS, where import.meta.url
is undefined, causing "exports is not defined in ES module scope" and
blocking all e2e test discovery.

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

* ci: add missing server build step to playwright job

The Playwright e2e helpers dynamically import from
packages/server/dist/server/server/exports.js, but the CI job
only built highlight and relay dependencies. Add the server build
step after relay so the dist artifacts exist when tests run.

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

* test(cli): make codex model assertions resilient to catalog changes

The 15-provider test hard-coded exact model IDs (gpt-5.3-codex-spark,
etc.) which depend on the external codex CLI's model/list endpoint.
Replace with structural checks: all IDs in gpt- family, at least one
codex-optimized model, and all models have required fields.

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

* test(cli): make opencode model assertions resilient to catalog changes

The 15-provider test hard-coded exact model IDs (opencode/gpt-5-nano,
openrouter/openai/gpt-5.3-codex) which break when the external opencode
CLI updates its model catalog. Replace with structural checks: at least
one first-party opencode model, at least one OpenAI-backed model, at
least one codex-optimized model, and all models have required fields.

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

* app: fix Playwright E2E WebSocket errors on Node 20 + fix format

E2E helpers used DaemonClient without a webSocketFactory, which relies
on globalThis.WebSocket — unavailable in Node 20 (CI). Add a shared
node-ws-factory helper using the ws package (matching the CLI pattern)
and inject it in all three E2E connection helpers. Also fix a biome
formatting issue in the cli provider test.

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

* fix: update lockfile signatures and Nix hash

* test(cli): replace OpenAI-specific assertions with generic third-party check

The opencode provider test asserted models with "openai/" or
"openrouter/openai/" prefixes, but these are environment-dependent —
opencode returns whatever providers are connected, and CI may not have
OpenAI configured. Replace with a generic check that at least one
non-opencode/ namespaced model exists.

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

* fix: advance stale schedule nextRunAt on daemon restart

On restart, persisted nextRunAt could be in the past, showing stale
dates in `schedule ls`. Now recoverInterruptedRuns() advances any
past-due nextRunAt forward to the next future tick.

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

* ci: install agent CLIs in playwright job + remove env-dependent opencode test assertions

The Playwright E2E tests (archive-tab, terminal-performance) fail because
the CI job doesn't install Codex/OpenCode binaries that the tests need to
create agents. Add the same install step already used in cli-tests.

The 15-provider CLI test still asserts third-party providers are connected
in OpenCode, which is environment-dependent. Remove that assertion and
lower the minimum model count to 1, keeping only deterministic structural
checks (namespacing, required fields, first-party models).

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

* fix: apply biome formatting to schedule service files

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

* fix(cli): parse localDaemon field in daemon supervisor test assertions

The daemon status JSON uses `localDaemon` not `status`, so the polling
condition was always null and timed out after 120s on CI.

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

* fix: apply biome formatting to daemon supervisor test files

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

* fix(cli): spawn supervisor directly via node --import tsx instead of npx wrapper

The daemon supervisor tests (22, 23, 25) spawned the supervisor through
`npx tsx` which creates a wrapper process. When SIGINT was sent, the npx
wrapper died with signal=SIGINT instead of forwarding it to the actual
supervisor which handles graceful shutdown. Now matches production startup
pattern using process.execPath with --import tsx.

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

* fix(server): acquire pid lock for unsupervised daemon workers

The direct worker path (non-supervisor) was not writing paseo.pid,
so `paseo daemon status` could not detect the running daemon. This
caused test 26-daemon-restart-unsupervised to time out in CI waiting
for the status to become "running".

Acquire the pid lock before daemon creation, update it with the
listen address after start, and release on shutdown/error. Supervision
detection now requires both PASEO_SUPERVISED=1 and an active IPC
channel to avoid misclassification when the env var is inherited.

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

* fix(e2e): correct terminal tab testid and navigation URL in Playwright helper

navigateToTerminal() used the bare workspace route instead of the URL
with ?open=terminal:<id> intent, and looked for testid
"workspace-tab-terminal:<id>" (colon) when the real tab key is
"terminal_<id>" (underscore). Both bugs prevented the terminal surface
from ever appearing, causing terminal-performance tests to timeout.

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

* fix(e2e): wait for open-intent redirect before interacting with terminal surface

The navigateToTerminal helper was racing the workspace layout's ?open= redirect,
which returns null during the useEffect cycle. Now waits for the clean workspace
URL before looking for the terminal tab, and removes the fallback that created
a different terminal via the "New terminal tab" button.

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

* fix: apply biome formatting to terminal-perf.ts

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

* fix(app): ungate host routes from storeReady to preserve deep links

Host routes (workspace, agent, sessions, etc.) were inside
Stack.Protected guard={storeReady}, causing deep links to be rejected
during initial storage hydration and redirected to /welcome. Move them
outside the guard so they can render their own loading state while
stores hydrate.

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

* fix(app): gate open-intent consumption on navigation readiness

After moving host routes outside Stack.Protected, the workspace layout
could mount before the root navigator was ready. router.replace() would
silently fail, but consumedIntentRef was already set, preventing retries.
Gate the effect on rootNavigationState.key so the intent is only consumed
once Expo Router can actually process the replace.

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

* fix(app): use history.replaceState to strip ?open since Expo Router skips query-only changes

Expo Router's findDivergentState ignores search params, so
router.replace with the same pathname but without ?open is a no-op.
Use window.history.replaceState on web to directly strip the query
param, and track intent consumption in component state so
WorkspaceScreen renders once the tab is prepared.

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

* fix: update stale codex model from gpt-5.1-codex-mini to gpt-5.4-mini

The Codex CLI no longer supports gpt-5.1-codex-mini. Update all
references to gpt-5.4-mini, which is available in the current CLI.
This fixes archive-tab Playwright tests that create codex agents
which were erroring due to the unsupported model.

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

* fix(ci): pin codex CLI to 0.105.0 and improve turn_failed diagnostics

Playwright archive-tab tests fail because CI installs @openai/codex@latest
(0.120.0) which has breaking protocol changes vs the known-working 0.105.0.
Pin the version and add diagnostics for future debugging: elevate turn_failed
log from TRACE to WARN, and include error details in test assertions.

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

* fix(e2e): switch archive-tab tests from codex to opencode provider

Codex CLI requires OAuth auth (~/.codex/auth.json) that CI lacks —
OPENAI_API_KEY alone gets 401. These tests verify archive tab behavior,
not any specific provider. Switch to opencode/gpt-5-nano which
authenticates via standard OpenAI API that the CI key supports.

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

* ci: retrigger CI for PR #236

* ci: trigger CI for opencode provider switch

Previous commits (3cbd7976, bdf768d6) were pushed with a token
that did not trigger GitHub Actions workflows. This empty commit
forces a fresh pull_request event.

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

* ci: add workflow_dispatch trigger to CI workflow

Enable manual triggering of the CI workflow. Previous pushes to
ci/fix-tests-green failed to trigger pull_request events for the
CI workflow, so this allows manual dispatch as a fallback.

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

* fix(ci): unblock remaining test lanes

* test(server): allow slower CI Claude model listing

* fix(cli): stabilize unsupervised restart regression

* test(ci): harden restart and archive e2e timing

* test(server): align workspace reconciliation expectation

* test(server): tolerate platform-specific workspace ids

* fix(app): gate host route logic on bootstrap readiness

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-12 09:56:05 +08:00
Mohamed Boudra f6579dbeb4 Switch to double quotes and reformat codebase with Biome 2026-03-21 20:22:15 +07:00
Mohamed Boudra e2f20f0e24 refactor: extract workspace layout actions and pane state logic 2026-03-18 09:39:12 +07:00
Mohamed Boudra 40ebd1b730 fix(tests): iteration 15 2026-03-13 13:43:34 +07:00
Mohamed Boudra 554017b0b8 refactor: move daemon registry into host-runtime store 2026-03-12 19:02:06 +07:00
Mohamed Boudra d6b7c2ed2d refactor(app): centralize perf diagnostics and activity coalescing 2026-02-22 11:52:08 +07:00
Mohamed Boudra 687ea706b1 test(app): add E2E tests and improve form state with test coverage 2026-01-20 12:12:29 +07:00
Mohamed Boudra 868f9fb571 WIP: Large refactor checkpoint
- Refactored daemon client and RPC layer
- Added new client package structure in server
- Removed legacy hooks (use-daemon-request, use-session-rpc, use-websocket)
- Added use-daemon-client hook
- Added e2e test infrastructure with Playwright
- Updated agent providers and session handling
- Removed codex-sdk patches
2026-01-12 13:30:20 +07:00
Mohamed Boudra 37d0fe0637 Run app tests in forked Vitest workers 2025-11-15 17:47:51 +01:00
Mohamed Boudra ddd10f0566 test: move stream harness into vitest 2025-11-15 15:26:49 +01:00