Compare commits

..
Author SHA1 Message Date
Mohamed Boudra b28aafa1df fix(forges): isolate repository identity by remote
Revalidate pull request sources when a checkout changes forge servers.
2026-11-02 07:32:03 +01:00
Mohamed Boudra 5feb68da4f fix(workspaces): preserve pull requests across checkout changes
Keep branch associations when the local commit changes and resolve transferred source identities before matching. Wait for the composer to accept input before queueing in browser tests.
2026-11-02 07:18:48 +01:00
Mohamed Boudra 24ef4c48cc fix(forges): retain pull requests after repository renames
Compare the head repository with the forge-resolved identity when its configured path has moved.
2026-11-02 06:55:23 +01:00
Mohamed Boudra a7d5f99a1c fix(forges): retain transferred forks in crowded searches
Resolve the head repository before choosing the bounded fallback so transferred forks keep their pull request association.
2026-11-02 06:42:09 +01:00
Mohamed Boudra f1e262a142 fix(forges): preserve pull request repository identity
Treat bounded discovery as inconclusive and resolve redirected repositories before selecting requests.
2026-11-02 06:35:23 +01:00
Mohamed Boudra dc7ade5264 fix(workspaces): retain merged pull request status
A workspace can still represent a merged pull request after its checkout advances. Match the repository and branch for display, and require a fresh exact-head match before automatic archive.
2026-11-02 06:24:00 +01:00
da8d58bfee fix(plugins): preserve agent message provenance in timeline transforms (#8492)
Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 05:04:23 +00:00
Mohamed Boudra 7c2812dde6 fix(app): add hosts without leaving the current screen (#8512)
* fix(app): add hosts without leaving the current screen

A retained Settings route replayed its addHost query after appearance remounts. Own the connection flow outside screen lifetimes and let each consumer choose its completion navigation.

* fix(app): release the project dialog before adding a host

* fix(app): size the QR scanner viewport on wide layouts

* test(app): align creation and desktop setup with in-place host flow
2026-11-02 11:07:48 +07:00
Mohamed Boudra b474beedce Homepage shows an agent building a plugin live (#8515)
* feat(website): show agents building plugins on the homepage

The plugins section now leads with the workflow instead of the API list:
you ask an agent for a panel and it appears, without a restart. It plays
as a timeline (prompt, tool calls, result) and then slides in the panel
the agent built, followed by the 400+ community plugins and their
categories. It moves above the automation section.

Mock tool calls on the homepage now match the app's collapsed timeline
rows: same labels and icons, muted text in the UI font, and Paseo MCP
tools named by action ("Create agent"). The hero pills are now nouns.

* fix(website): show the finished plugin demo when motion is reduced

The demo staged its items and slid the panel across its width even for visitors who ask for reduced motion. It now starts in its finished state for them, matching the hero mockup.
2026-11-02 10:50:12 +07:00
e2f0779379 Render Markdown tables written directly below prose (#8485)
* fix(app): render tables immediately after paragraphs

Insert a paragraph break before GFM tables adjacent to prose so MD4C
recognizes them, preserving quote/list containers and line endings.

* fix(app): preserve table headers during source adaptation

Replace the preceding line ending when inserting a table separator.
This keeps the separator before the header's source range, so entity
and local image edits cannot consume the paragraph break.

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: thsrite <thsrite@gmail.com>
Co-authored-by: Woosuk Nam <me@suk.kr>
2026-11-02 03:30:52 +00:00
paseo-bot[bot] a166a3317a test(terminal): check original processes after protocol cleanup (#8502)
Startup launches another worker after closing obsolete protocols, so a reused numeric PID does not prove the original process survived. Check the recorded birth identities and require cleanup to be confirmed.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 03:01:36 +00:00
paseo-bot[bot]andNoah efc64a5443 Explain the mode required to create agents on another provider (#8483)
* fix(server): say when create_agent needs an explicit mode

* test(server): verify cross-provider mode discovery through the daemon

Exercise the MCP contract, refusal without a child, provider inspection, and explicit-mode creation through the public daemon interfaces. Qualify unattended advice because targets without an unattended mode still require an explicit choice.

Co-authored-by: Noah <noahguerin20@gmail.com>

* test(server): run mode discovery in the integration suite

The unit command excludes E2E files, and CI integration uses an explicit list. Add the cross-provider MCP regression to that existing list so CI exercises its guidance, refusal, discovery, and retry assertions.

---------

Co-authored-by: Noah <noahguerin20@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 02:08:31 +00:00
7efa14238d fix(claude): report API-error turns as failed (#8480)
* fix(claude): fail a turn that Claude Code ends on an API error

Claude Code reports a turn that stopped on an API error (a session or
usage limit, an invalid key, an overloaded request) as a `success`
result with `is_error: true`, the error text in `result`, after a
synthetic assistant reply carrying the same text and an `error` field.
The provider read only the subtype, so the turn completed with the
error as its answer: the agent went idle with no lastError and showed
as finished in the sidebar.

Treat such a result as a failed turn with the result text as its
error. Hold the synthetic error reply back until the result arrives so
the failure row does not repeat it; a reply the turn moves past, or a
result without is_error, still shows it.

Co-authored-by: RoundRedDot <roundreddot@gmail.com>

* fix(claude): retain final usage when an API-error turn fails

Publish the converted result totals before the failure event so the
existing usage handler preserves work done earlier in the turn.

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 01:07:00 +00:00
paseo-bot[bot] dbbcb103ba fix(opencode): preserve denial reasons when a message supersedes approval (#8477)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 23:50:55 +00:00
Thomas VanandClaude Opus 5.5 7f318bf7cf fix(codex): end an interrupted turn's shell commands (#7525)
`turn/interrupt` ends a Codex turn and leaves the shell commands it started running: a
command the user just stopped runs on to its own end, alongside the next turn.

Once Codex accepts the interrupt, the provider now ends each command the interrupted turn
started that has not exited, by process id, with `thread/backgroundTerminals/terminate`,
and a turn Paseo starts waits for those calls before its `turn/start`.

- Attribution: `thread/backgroundTerminals/list` carries no turn, so the provider records
  each root-thread `commandExecution` process id under the turn of its `item/started`. A
  `completed` item drops its id; a `failed` one keeps it, because Codex reports an
  interrupted turn's still-running command as failed. A turn that ends any way but
  `interrupted` forgets its ids, so what an earlier turn left running on purpose is never
  ended. At most 16 turns are remembered.
- A late command: the turn keeps its accepted interrupt, so a command whose `item/started`
  arrives after Codex accepted the interrupt (one queued behind an earlier notification's
  conversion, for instance) is ended as soon as it is seen. Each process is terminated
  once, and a turn start waits for every pending sweep.
- It never sends `thread/backgroundTerminals/clean`, which ends every terminal on the
  thread. It ends nothing after an already-idle interrupt, nothing on a subagent's thread,
  and nothing after an interrupt of a turn Codex never identified.
- Bounds: each terminate times out after 2 s; a failure is logged
  (`provider.codex.interrupt.terminal_terminate_failed`) and fails nothing. `startTurn`
  waits at most the interrupt's bound plus one terminate's plus 500 ms.

Tests (codex-interrupted-turn-terminals.test.ts) drive a real CodexAppServerAgentSession
against the scripted app-server in codex/test-utils/fake-app-server.ts: exactly the
interrupted turn's processes are terminated, by id, and `clean` is never sent; a turn
Codex starts before acknowledging the interrupt keeps its commands; the next `turn/start`
waits for the terminates; a command announced after the interrupt was accepted is
terminated once and the next turn waits for both sweeps, whichever answers first; a
completed command is forgotten and a failed one kept; only the latest sixteen accepted
interrupts are kept; a failing or unanswered terminate does not block the next turn. The
harness waits for the interrupted turn's `turn_canceled` before it starts the next turn,
as a caller does: notifications are handled asynchronously, so the interrupt's answer can
arrive before the turn has ended.

Fixes #7522


Claude-Session: https://claude.ai/code/session_01Ay568T2kmtotoh23Dtzi5K

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:58:12 +00:00
paseo-bot[bot] 04fe4be40f fix(claude): retain configured model metadata before a turn (#8465)
Rebuilding an idle session cleared its observed model and published null despite retaining an explicit model in its configuration. Fall back to that configuration until the next turn supplies an observed model.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:59:04 +00:00
paseo-bot[bot] 2d75cc80b2 test(server): cover routed permissions in one host journey (#8486)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:34:57 +00:00
paseo-bot[bot]andDominic Anaeto bfac132920 Stop Git polling while workspace folders are missing (#8462)
* fix(server): stop polling git for a workspace whose folder is gone

When a workspace's folder disappears together with its project (the
repository deleted, a volume unmounted), reconciliation leaves the
workspace registered, and the daemon kept polling it: a failed git
command and a warning about once a minute, and a working-tree watcher
retry every minute or two, for as long as the daemon ran.

The degraded poll now checks that the workspace folder exists before
refreshing. A workspace whose folder is gone is suspended once: it is
detached from its repository and working-tree targets, its timers stop,
and one warning is logged. A worktree that shared a repository target
hands the repository's polling and background fetch to a surviving
worktree. Nothing is archived. A working-tree watch that an open diff
still holds skips its consumer notifications and watcher recovery while
its folder is missing.

While suspended, the workspace runs no git. A stat every 60 seconds, a
new subscription, a forced snapshot or a refresh request checks whether
the folder is back; when it is, the workspace reloads its git facts,
refreshes, and rebuilds its observation.

Fixes #7856

* fix(server): keep missing workspaces suspended during refresh

Admit Git refreshes only after the folder has returned. Cover missing-folder polling, explicit refreshes, and restoration with real Git and the Linux observer.

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

* fix(server): preserve workspace snapshots through suspension

Drop queued refreshes and withhold publication when a folder disappears during an active read. Wait for that read before restoring observation so its stale result cannot overwrite the recovered snapshot.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:02:26 +00:00
paseo-bot[bot]andDominic Anaeto 34f996b32a Preserve pending questions and approvals after peer messages (#8453)
* fix(server): keep a recipient's pending permission when another agent messages it

A message from another agent (send_agent_message with sourceAgentId) was
enqueued with clearPendingPermissions: true, so it superseded the
recipient's pending permission request and the request was denied before
a human saw it. Agent-sourced messages now leave pending permissions
alone. Typed messages and sourceless `paseo send` still clear them.

Fixes #7602

* test(server): verify peer messages preserve answerable questions

Exercise message delivery through the daemon and provider interfaces so the regression checks that the question survives and its answer reaches the provider. Retain the human-steer cancellation behavior.

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

* test(terminal): make runtime copy fixture independent of umask

Set the source helper permissions after creation so the copy test measures preservation instead of inheriting the shell file-creation mask. The existing test fails under umask 077 without this setup correction.

Fixes #8175

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 17:11:42 +00:00
3e83f433da Import large Claude sessions without exceeding the string limit (#8457)
* fix(claude): read the session rename from the end of a large transcript

Importing a Claude session read the whole transcript into one UTF-8 string to
look for its `custom-title` record, so a transcript past V8's maximum string
length failed the import with `RangeError: Invalid string length` before the
incremental history reader ever ran. The scan now reads the file backwards a
chunk at a time, which keeps the original intent of not walking the transcript
and never holds it as one string. Records without the needle are not decoded, so
a transcript with no rename costs a byte search per chunk.

Closes #8451

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

* test(app): wait for send readiness in composer recall

The previous prompt can appear and clear its draft before the send acknowledgement arrives. Wait for Send to be enabled before pressing Enter so recall setup does not silently leave the next prompt unsent.

Co-authored-by: qwist1233-cpu <qwist1233@gmail.com>

* fix(claude): scan large transcript records once

Retain unfinished record chunks and assemble each record once instead of copying and rescanning a growing buffer on every backward read. Cover import of a large record after the last explicit rename.

---------

Co-authored-by: qwist1233-cpu <qwist1233@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 15:56:42 +00:00
paseo-bot[bot] 674530152e test(server): cover filename downloads in one workspace journey (#8475)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 15:31:08 +00:00
Dominic 919e44b319 fix(server): start configured plugins concurrently (#8427)
The daemon accepts connections only after its configured plugins have
started, and PluginService.start started them one at a time, so each
plugin's build and process launch added to daemon startup. Configured
plugins now start together. Their providers and usage sources are still
published in config order: a plugin that finishes early waits for the
plugins listed before it, so when two plugins claim the same provider or
usage-source ID, the one listed first keeps it, as before. A failing
plugin still fails on its own.

Fixes #7979
2026-11-01 12:59:34 +00:00
paseo-bot[bot] 4fe44038e3 test(server): check subdirectory diffs before and after commit (#8466)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 12:35:50 +00:00
Dominic 9b0542d127 fix(server): retry a connected host that failed the capability check (#8423)
* fix(server): retry a connected host that failed the capability check

Every failure in a connected host's acceptance step was terminal: the
row went to "error" with no retry, and the status listener ignored
later reconnects. A host rejected with unsupported_capability stayed
unusable after it was updated, until the registration was saved again
or the managing daemon restarted. Capability and other non-identity
acceptance failures now go "offline" with nextRetryAt and retry with
capped backoff (1.5 s doubling to 30 s), so an updated host comes
online by itself. Self, duplicate, changed-identity and authentication
failures stay terminal.

Fixes #7971

* fix(server): keep a retried host's relay security floor and update guidance

A scheduled capability retry built a new transport factory, which
dropped the relay security floor learned from a protected handshake, so
the retry could negotiate legacy encryption and send credentials.
Retries now reuse the registration's transport factory (createHostClient
accepts one). The update guidance also stays visible while a retry
connects, the backoff resets after a successful acceptance, and
disabling or removing a host cancels its pending retry.
2026-11-01 10:59:33 +00:00
Dominic 8130147426 fix(app): stop the terminal from typing its version reply into the shell (#8419)
The browser terminal is a renderer and leaves protocol replies to the
daemon, but it still answered XTVERSION (CSI > q) and DECRQSS
(DCS $ q ... ST). Those replies went out through onData as if the user
had typed them, so a query from a program in the shell left text like
">|xterm.js(6.1.0-beta.292)" on the next input line. Both queries are
now swallowed with the other protocol queries.

Fixes #8111
2026-11-01 10:04:25 +00:00
Mohamed Boudra fa89738e3a chore(desktop): upgrade packaging tool and remove signing backport (#8450)
* chore(desktop): upgrade packaging tool and remove signing backport

* fix(nix): resolve the desktop workspace packaging tool
2026-11-01 14:46:52 +07:00
265 changed files with 10377 additions and 10967 deletions
-26
View File
@@ -135,32 +135,6 @@ jobs:
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Backport electron-builder#10172 to the pinned build tool only.
# Remove this step when app-builder-lib is upgraded to a release containing it.
- name: Repair temporary keychain password in signing tool
shell: bash
run: |
node <<'KEYCHAIN_FIX'
const fs = require('node:fs');
const path = require('node:path');
const manifestPath = require.resolve('app-builder-lib/package.json');
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
}
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
let source = fs.readFileSync(sourcePath, 'utf8');
const replacements = [
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
];
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
source = source.replace(before, after);
}
fs.writeFileSync(sourcePath, source);
KEYCHAIN_FIX
- name: Set desktop package version from tag - name: Set desktop package version from tag
shell: bash shell: bash
run: | run: |
-8
View File
@@ -406,10 +406,7 @@
{ {
"files": [ "files": [
"packages/app/src/app/_layout.tsx", "packages/app/src/app/_layout.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx", "packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/agent-list.tsx", "packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx", "packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx", "packages/app/src/components/attachment-lightbox.tsx",
@@ -428,7 +425,6 @@
"packages/app/src/components/hosts/host-picker.tsx", "packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx", "packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.tsx", "packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/pair-link-modal.tsx",
"packages/app/src/components/plan-card.tsx", "packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx", "packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx", "packages/app/src/components/question-form-card.tsx",
@@ -543,14 +539,10 @@
"packages/app/src/agent-skills/selection-sheet.tsx", "packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx", "packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx", "packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx", "packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx", "packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx", "packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx", "packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx", "packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx", "packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx", "packages/app/src/components/attachment-lightbox.tsx",
+4 -1
View File
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`. Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance. operations; requesting this feature gives update guidance. The registry retries an older destination
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
authentication failures stay in `error` until an update changes the connection, `enabled`, or
`replaceIdentity`, or A restarts.
Disposing a routed API releases its observations and session. It does not remove the saved host or Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
-2
View File
@@ -92,7 +92,6 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | | `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title | | `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) | | `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `agent.metadata.update.request`; `null` in a patch removes a key |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. | | `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
| `lastModeId` | `string?` | Last active mode ID | | `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) | | `config` | `SerializableConfig?` | Agent session configuration (see below) |
@@ -479,7 +478,6 @@ Array of workspace records. A workspace is a specific working directory within a
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. | | `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. | | `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `workspace.metadata.update.request`. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | | `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. | | `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
+4 -4
View File
@@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
Weight has three tiers, applied by role: Weight has three tiers, applied by role:
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`). - **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`). - **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`). - **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still. The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`). `<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these. `<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container. `<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
@@ -114,7 +114,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
## 7. Density and rhythm ## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`). Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest. Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
@@ -278,7 +278,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` | | List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` | | Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` | | Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` | | Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` | | Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` | | Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` | | Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
+6
View File
@@ -58,6 +58,12 @@ restore the same workspace and could repeat the crash.
## App-Wide Route Hops ## App-Wide Route Hops
Keep Add host in `hosts/add-host`, opened through `useAddHost()`. It is an in-place
flow; callers choose what happens after success. Settings opts into the new host’s
Overview. Do not send callers to Settings with an `addHost` query parameter: a
retained route replays that request when appearance changes remount its content.
The flow provider and sheet host stay outside appearance keys so open drafts survive.
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route. branch on its current route.
+20 -11
View File
@@ -97,19 +97,28 @@ Register the adapter in `defaultForgeRegistry` with:
- `matchesHost` from manifest `cloudHosts` - `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported - `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately: Match a change request using the head repository and branch. Within the bounded
candidate window, prefer an open request, otherwise select the most recently
updated terminal request, breaking ties by number. A full inconclusive window is
a failed discovery, so a poll retains its previous status. Gitea does not expose
a source-branch filter: after its bounded open window it also examines one recent
all-state page. An older open request outside both windows cannot take precedence
over a recent terminal request on a cold lookup. When a workspace already shows
an open request, revalidate it before replacing it with another terminal request.
A reused branch can display a historical request
until a new one opens. Display does not depend on checkout HEAD: the checkout can
be behind the remote PR after its branch is updated during merge.
- An open PR/MR belongs to the checkout when its head branch and head repository For mixed SSH and HTTPS ports on one host, require the source repository's native
match. Its remote head SHA may differ because the checkout can be ahead, clone URL to match the normalized checkout endpoint. Different HTTP ports remain
behind, or contain commits that have not been pushed yet. distinct forge instances. Unknown clone URLs do not establish identity.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
Thread the checkout head SHA through adapter cache and poll identities as well Auto-archive has a narrower contract. It requires a fresh, observed open-to-merged
as the lookup itself. Otherwise a commit made on the same branch can inherit the transition and an exact match between the checkout HEAD and the forge's recorded
previous commit's cached terminal status until the cache expires. PR head, a successful upstream safety read, and the workspace safety checks. A
successful read reporting no upstream is distinct from a failed read. Keep
checkout HEAD in cache and poll identities so a checkout change refreshes those
safety facts.
Cloud hosts in the manifest are a bounded public-host list, not a self-host Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
+3 -11
View File
@@ -511,21 +511,13 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state, `send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`. configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`. Route messages, structured commands, steering, and command side effects through `session.prompt`.
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon. the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
Validate and apply it inside the provider; core does not know your option shape. Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics. merge semantics.
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
(`providerOptions`) in `session.config`. A running-agent change arrives as
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
its value into an option and is sent both shapes in `session.configure`; delete it with
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
Providers add an optional veto to daemon baseline cleanup through the per-session Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup). [runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins; The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
+3 -62
View File
@@ -30,65 +30,6 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md) for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples. for native keys and examples.
### Options flow
`options` on the agent snapshot is the only option state. Controls describe how to draw an
option and read `options`; they hold no value of their own. Any option can be read and set with
or without a control.
- **Create and resume.** The provider opens with the options it is given and emits nothing about
them while opening. `openOptionsPatch` carries every option the session ended up with that
differs from what it was given, including a setting nobody asked for (a native default such as
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
daemon stores it before the agent is published. A reopened agent resumes that state, so a
native default the provider later changes does not follow. `options` on the snapshot is what
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
Editing a provider default in `config.json` affects agents the next time they open.
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
there is none; the provider returns the setting to its own default if it can, or throws to
reject. A key the running session cannot apply (one the provider reads only when it opens) is
rejected too, so a stored option always describes what runs. Claude reads most options when it
starts its query, and replacing a started query would end its background work, so after the
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
it ended up with when they differ from the request. The daemon
stores the patch and that result in one step after the call resolves, onto the agent's options
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
before it refused, it reports the moved values once as `options_changed` after the call has
ended, because the stored options must match what runs.
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
behind the provider id, following `derivedFromProviderId`, so a custom provider with
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
and provider-agnostic; it stays at the protocol and legacy edges.
- **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the
agent snapshot for a running or closed agent. A draft and a profile form send everything they
would create with (seed, the provider's last-used control values once the provider has said it
still draws them, and the user's edits) in `provider.controls.list.request` and draw only what
comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once,
by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's
layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile
replaces provider and options in one step, while choosing a provider by hand starts over. A
profile saves its stored options plus the form's edits, so options no control draws survive an
edit. A daemon without `server_info.features.providerOptionControls` sends `features` and
`featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that
turns those into `controls` and `options` and back, so nothing in the app branches on it. A
create that carries options first lists the controls for its own provider, model, mode and
thinking level, since which options are controls differs by model, and sends those as
`featureValues`; callers do not prime anything.
- **Provider-initiated changes.** A setting the provider changes on its own is an
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools. server and tool identity without approving native tools.
@@ -174,7 +115,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading. Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state. Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready. Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
@@ -537,7 +478,7 @@ interface AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly id: string | null; readonly id: string | null;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
readonly controls?: AgentOptionControl[]; readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -559,7 +500,7 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>; listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>; setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>; setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
applyOptions?(options: ProviderOptions): Promise<void>; setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): { tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>; run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null; } | null;
+1 -1
View File
@@ -127,7 +127,7 @@ buildNpmPackage {
# The Nix output is not a distributable DMG, so leave it unsigned and # The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature. # disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \ CSC_IDENTITY_AUTO_DISCOVERY=false \
../../node_modules/.bin/electron-builder \ npm exec --offline -- electron-builder \
--config electron-builder.yml \ --config electron-builder.yml \
--dir \ --dir \
--mac \ --mac \
+1 -1
View File
@@ -1 +1 @@
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU= sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
+1018 -1295
View File
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import path from "node:path";
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import { import {
addProjectFlow, addProjectFlow,
chooseAddHostFromProjectFlow,
addProjectFlowBack, addProjectFlowBack,
addProjectFlowHost, addProjectFlowHost,
addProjectFlowInput, addProjectFlowInput,
@@ -19,6 +20,7 @@ import {
} from "../support/helpers/add-project-flow"; } from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app"; import { gotoAppShell } from "../support/helpers/app";
import { import {
disconnectAllHostsAndReload,
addConnectedHostAndReload, addConnectedHostAndReload,
addConnectedHostsAndReload, addConnectedHostsAndReload,
addOfflineHostAndReload, addOfflineHostAndReload,
@@ -69,6 +71,19 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => { test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 });
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
await gotoAppShell(page);
await disconnectAllHostsAndReload(page);
await openAddProjectHostSelection(page);
const originalUrl = page.url();
await chooseAddHostFromProjectFlow(page);
await expect(addProjectFlow(page)).toBeHidden();
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
});
test("method selection shows the daemon's available project sources without search", async ({ test("method selection shows the daemon's available project sources without search", async ({
page, page,
}) => { }) => {
@@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro
const raw = localStorage.getItem(preferencesKey); const raw = localStorage.getItem(preferencesKey);
if (!raw) return null; if (!raw) return null;
const preferences = JSON.parse(raw) as FormPreferences; const preferences = JSON.parse(raw) as FormPreferences;
return preferences.providerPreferences?.[providerId]?.options ?? null; return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
}, },
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider }, { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
); );
@@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
provider: featureProviderId, provider: featureProviderId,
model: featureModelId, model: featureModelId,
modeId: "bypassPermissions", modeId: "bypassPermissions",
options: { fast_mode: true }, featureValues: { fast_mode: true },
}, },
]); ]);
const provider = await seedModelProvider({ const provider = await seedModelProvider({
@@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
expect(createAgentRequest).toMatchObject({ expect(createAgentRequest).toMatchObject({
config: { config: {
provider: featureProviderId, provider: featureProviderId,
providerOptions: { fast_mode: true }, featureValues: { fast_mode: true },
}, },
}); });
} finally { } finally {
@@ -3,7 +3,7 @@ import {
composerLocator, composerLocator,
expectComposerDraft, expectComposerDraft,
expectComposerVisible, expectComposerVisible,
submitMessage, fillComposerDraft,
typeIntoFocusedComposer, typeIntoFocusedComposer,
} from "../support/helpers/composer"; } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
return page.getByTestId("user-message"); return page.getByTestId("user-message");
} }
async function sendDraftWhenReady(page: Page) {
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await pressInComposer(page, "Enter");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) { async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await submitMessage(page, text); await fillComposerDraft(page, text);
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 }); await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, ""); await expectComposerDraft(page, "");
} }
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp"); await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login"); await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter"); await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 }); await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login"); await expect(userMessages(page).last()).toContainText("add tests for login");
} finally { } finally {
@@ -294,10 +294,17 @@ test("creates, renames, copies, and deletes entries through the file explorer",
await entry("renamed.txt").click({ button: "right" }); await entry("renamed.txt").click({ button: "right" });
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/); const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
const deleteLabelColor = await deleteAction await expect
.getByText("Delete", { exact: true }) .poll(async () => {
.evaluate((element) => getComputedStyle(element).color); const labelColor = await deleteAction
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor); .getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
const iconColor = await deleteAction
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
return labelColor !== "" && iconColor === labelColor;
})
.toBe(true);
const cancelledConfirmation = new Promise<string>((resolve) => { const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => { page.once("dialog", async (dialog) => {
const message = dialog.message(); const message = dialog.message();
@@ -122,10 +122,14 @@ test.describe("New workspace entry points", () => {
}); });
const hostTrigger = page.getByTestId("host-picker-trigger"); const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 }); await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
const originalUrl = page.url();
await hostTrigger.click(); await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible(); await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click(); await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(page).toHaveURL(/\/settings\/general\?addHost=/); await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
} finally { } finally {
await seeded.cleanup(); await seeded.cleanup();
} }
@@ -1,3 +1,8 @@
import {
openGlobalNewWorkspaceComposer,
fillNewWorkspaceDraft,
expectNewWorkspaceDraft,
} from "../support/helpers/new-workspace";
import { readFile, mkdir, rename, rm } from "node:fs/promises"; import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -273,3 +278,71 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
await expectRestartRecoveryPrompt(page, "Continue the interrupted work."); await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working."); await editRestartRecoveryPrompt(page, "Resume working.");
}); });
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
page,
}) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await expect(page).toHaveURL(originalUrl);
await closeAddHostAndChangeTheme(page);
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await expect(page).toHaveURL(originalUrl);
});
async function openSidebarAddHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Hosts", exact: true }).click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
}
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await page.keyboard.press("Control+Alt+t");
}
test("sidebar Add host completes without navigating", async ({ page }) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
});
test("Settings Add host opens the saved host Overview", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
await expectSettingsHeader(page, "Overview");
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
});
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
await gotoAppShell(page);
await openGlobalNewWorkspaceComposer(page);
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
const originalUrl = page.url();
await openNewWorkspaceAddHost(page);
await expect(page).toHaveURL(originalUrl);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
});
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
await page.getByTestId("host-picker-trigger").click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
}
async function connectToTestHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
}
@@ -113,3 +113,7 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
}) })
.toBe(true); .toBe(true);
} }
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
}
@@ -84,6 +84,9 @@ export async function typeIntoFocusedComposer(page: Page, text: string): Promise
} }
export async function sendDraftToQueue(page: Page): Promise<void> { export async function sendDraftToQueue(page: Page): Promise<void> {
await expect(
page.getByRole("button", { name: /^(Send and steer|Send and interrupt)$/ }),
).toBeEnabled({ timeout: 30_000 });
await composerInput(page).press("Control+Enter"); await composerInput(page).press("Control+Enter");
} }
@@ -94,6 +94,9 @@ if (origin === fixtureRemote) {
labels: [], labels: [],
baseRefName: "main", baseRefName: "main",
headRefName: isFork ? "pr-branch-2" : "pr-branch-1", headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
headRefOid: spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout?.trim() || "",
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
headRepository: { name: "local-fixture" },
updatedAt: "2026-01-01T00:00:00Z" updatedAt: "2026-01-01T00:00:00Z"
}; };
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr)); process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
+22
View File
@@ -273,3 +273,25 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
); );
await page.reload(); await page.reload();
} }
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
await page.evaluate(
({ registryKey, nonceKey, disableSeedKey }) => {
const nonce = localStorage.getItem(nonceKey);
if (!nonce) throw new Error("Expected the e2e seed nonce");
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
for (const host of hosts) {
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
host.preferredConnectionId = "offline";
}
localStorage.setItem(registryKey, JSON.stringify(hosts));
localStorage.setItem(disableSeedKey, nonce);
},
{
registryKey: REGISTRY_KEY,
nonceKey: SEED_NONCE_KEY,
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
);
await page.reload();
}
+2 -2
View File
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
} }
export function normalizeAgentCommandsCwd(cwd: string): string { export function normalizeAgentCommandsCwd(cwd: string): string {
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
"thinking", "thinking",
draftConfig.thinkingOptionId ?? null, draftConfig.thinkingOptionId ?? null,
"features", "features",
draftConfig.providerOptions ?? null, draftConfig.featureValues ?? null,
] as const; ] as const;
} }
@@ -1,149 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import { isControlActive } from "./option-controls";
import {
draftControlsRequest,
openDraftOptions,
ownDraftOptions,
viewDraftOptions,
} from "./draft-options-model";
const SPEED: AgentOptionControl = {
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
};
const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" };
function answer(
provider: string,
requestKey: string,
options: Record<string, unknown>,
controls = [SPEED, WEB_SEARCH],
) {
return { provider, requestKey, controls, options };
}
describe("draft options model", () => {
it("draws a legacy Fast profile as the Fast tier the daemon resolved", () => {
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "fast" } });
model.applyResolved(answer("codex", "k1", { service_tier: "priority" }));
const view = viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" });
expect(view?.options).toEqual({ service_tier: "priority" });
expect(view && isControlActive(SPEED, view.options)).toBe(true);
// The daemon still gets the stored value to resolve, and so does create.
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
});
it("applying a profile from another provider keeps its options through the provider change", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", {}));
model.applyProfile({
provider: "codex",
options: { sandbox_mode: "read-only", service_tier: "fast" },
});
// The form follows the profile to its provider.
model.observeProvider("codex");
expect(ownDraftOptions(model.getState())).toEqual({
sandbox_mode: "read-only",
service_tier: "fast",
});
expect(model.getState().resolved).toBeNull();
});
it("choosing another provider by hand starts the options over", () => {
const model = openDraftOptions({ provider: "codex", seed: { sandbox_mode: "read-only" } });
model.setOption("web_search", true);
model.observeProvider("claude");
expect(ownDraftOptions(model.getState())).toBeUndefined();
expect(model.getState().resolved).toBeNull();
});
it("never pairs a manually chosen provider with the previous provider's options", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", { fast_mode: true }));
model.setOption("fast_mode", true);
const form = { provider: "claude", cwd: "/repo" };
expect(draftControlsRequest(model.getState(), form)).toMatchObject({
provider: "claude",
providerOptions: { fast_mode: true },
});
// The form shows OpenCode on its next render; the model has not observed it yet.
expect(draftControlsRequest(model.getState(), { ...form, provider: "opencode" })).toBeNull();
model.observeProvider("opencode");
const request = draftControlsRequest(model.getState(), { ...form, provider: "opencode" });
expect(request).toEqual({ provider: "opencode", cwd: "/repo" });
});
it("keeps a draft's seed when the first provider is chosen", () => {
const model = openDraftOptions({ provider: null, seed: { web_search: true } });
model.observeProvider("claude");
expect(ownDraftOptions(model.getState())).toEqual({ web_search: true });
});
it("sends a remembered value to the daemon, and draws only what comes back", () => {
const model = openDraftOptions({ provider: "codex" });
model.setRemembered({ service_tier: "fast", retired: true });
// Nothing is known about the provider's controls yet.
expect(ownDraftOptions(model.getState())).toBeUndefined();
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
// The answer to the first request does not show the remembered value as if it applied.
expect(
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
).toEqual({ service_tier: "default" });
model.applyResolved(answer("codex", "k2", { service_tier: "priority" }));
expect(
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
).toEqual({ service_tier: "priority" });
});
it("does not let a remembered value override the seed", () => {
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "default" } });
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
model.setRemembered({ service_tier: "fast" });
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "default" });
});
it("lays a toggle over the previous answer until the new one arrives", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", { web_search: false }));
model.setOption("web_search", true);
expect(
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
).toEqual({ web_search: true });
model.applyResolved(answer("claude", "k2", { web_search: true }));
expect(
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
).toEqual({ web_search: true });
});
it("draws nothing for a provider the answer is not for", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", {}));
expect(viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" })).toBe(
undefined,
);
});
});
@@ -1,168 +0,0 @@
import type {
AgentOptionControl,
AgentSessionConfig,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
/**
* The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers
* make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon
* (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias,
* default or legacy key is resolved here.
*
* - `seed`: options the draft started from (a profile, a forked agent).
* - `remembered`: the provider's last-used control values.
* - `edits`: toggles made in this draft.
*
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
* and the options in one step, so the provider change that follows does not clear them.
*/
export interface ResolvedDraftOptions {
provider: string;
/** Names the request that produced this answer; see `viewDraftOptions`. */
requestKey: string;
controls: AgentOptionControl[];
options: ProviderOptions;
}
export interface DraftOptionsState {
provider: string | null;
seed: ProviderOptions;
remembered: ProviderOptions;
edits: ProviderOptions;
resolved: ResolvedDraftOptions | null;
}
export interface DraftOptionsModel {
getState: () => DraftOptionsState;
subscribe: (listener: () => void) => () => void;
/** The provider the draft's form now has. A different one than the model's is a manual choice. */
observeProvider: (provider: string | null) => void;
applyProfile: (profile: { provider: string; options: ProviderOptions }) => void;
setRemembered: (remembered: ProviderOptions) => void;
setOption: (key: string, value: unknown) => void;
applyResolved: (resolved: ResolvedDraftOptions) => void;
}
export function openDraftOptions(input: {
provider: string | null;
seed?: ProviderOptions;
}): DraftOptionsModel {
let state: DraftOptionsState = {
provider: input.provider,
seed: input.seed ?? {},
remembered: {},
edits: {},
resolved: null,
};
const listeners = new Set<() => void>();
function publish(update: (current: DraftOptionsState) => DraftOptionsState): void {
const next = update(state);
if (next === state) return;
state = next;
for (const listener of listeners) listener();
}
return {
getState: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
observeProvider: (provider) =>
publish((current) => {
if (provider === current.provider) return current;
// The first provider a draft picks does not discard what it was opened with.
if (current.provider === null) return { ...current, provider };
return { ...current, provider, seed: {}, edits: {}, resolved: null };
}),
applyProfile: (profile) =>
publish((current) => ({
...current,
provider: profile.provider,
seed: profile.options,
edits: {},
resolved: current.provider === profile.provider ? current.resolved : null,
})),
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
setOption: (key, value) =>
publish((current) =>
Object.is(current.edits[key], value)
? current
: { ...current, edits: { ...current.edits, [key]: value } },
),
applyResolved: (resolved) => publish((current) => ({ ...current, resolved })),
};
}
/**
* Everything the draft would create with, for the daemon to resolve. A last-used value applies
* once the provider has told us it still draws a control for that key, and never over the seed.
*/
export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined {
const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key));
const remembered = Object.fromEntries(
Object.entries(state.remembered).filter(
([key]) => controlKeys.has(key) && !(key in state.seed),
),
);
const own = { ...state.seed, ...remembered, ...state.edits };
return Object.keys(own).length > 0 ? own : undefined;
}
export type DraftControlsRequest = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
>;
/**
* The listing request for the draft's form, built from one snapshot of the model. The form
* reaches a new provider before the model has observed it, and the model still holds the
* previous provider's options until it does; that render has no request, so a request never
* pairs one provider with another's options.
*/
export function draftControlsRequest(
state: DraftOptionsState,
form: {
provider: string | null;
cwd: string;
modeId?: string | null;
modelId?: string | null;
thinkingOptionId?: string | null;
},
): DraftControlsRequest | null {
if (!form.provider || !form.cwd || form.provider !== state.provider) return null;
const providerOptions = ownDraftOptions(state);
return {
provider: form.provider,
cwd: form.cwd,
...(form.modeId ? { modeId: form.modeId } : {}),
...(form.modelId ? { model: form.modelId } : {}),
...(form.thinkingOptionId ? { thinkingOptionId: form.thinkingOptionId } : {}),
...(providerOptions ? { providerOptions } : {}),
};
}
/**
* What the controls draw: the daemon's answer for this provider. The last answer stays up while
* a newer request is in flight, with the toggles made since laid over it, so a switch does not
* wait for the round trip. It never carries a seed or remembered value the daemon has not
* resolved.
*/
export function viewDraftOptions(
state: DraftOptionsState,
current: { provider: string | null; requestKey: string },
): { controls: AgentOptionControl[]; options: ProviderOptions } | undefined {
const { resolved } = state;
if (!resolved || resolved.provider !== current.provider) return undefined;
return {
controls: resolved.controls,
options:
resolved.requestKey === current.requestKey
? resolved.options
: { ...resolved.options, ...state.edits },
};
}
@@ -1,16 +0,0 @@
import { Settings2 } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { getAgentFeatureIcon } from "./icons";
describe("getAgentFeatureIcon", () => {
it("draws the Lucide icon a plugin provider names", () => {
const icon = getAgentFeatureIcon("Mic");
expect(icon).not.toBe(Settings2);
expect(getAgentFeatureIcon("Mic")).toBe(icon);
});
it("falls back to the generic icon for a missing or unknown name", () => {
expect(getAgentFeatureIcon()).toBe(Settings2);
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
});
});
+1 -5
View File
@@ -13,7 +13,6 @@ import {
ShieldQuestionMark, ShieldQuestionMark,
Zap, Zap,
} from "lucide-react-native"; } from "lucide-react-native";
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export interface AgentControlIconProps { export interface AgentControlIconProps {
@@ -61,9 +60,6 @@ export function getAgentModeOptionIcon(
return icon ? MODE_ICONS[icon] : undefined; return icon ? MODE_ICONS[icon] : undefined;
} }
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
export function getAgentFeatureIcon(icon?: string): AgentControlIcon { export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
if (!icon) return Settings2; return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
} }
@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
} from "./option-controls";
const speed: AgentOptionControl = {
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
};
const fast: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
describe("option controls", () => {
it("reads a select from options and falls back to the control's default", () => {
if (speed.type !== "select") throw new Error("select expected");
expect(readSelectOption(speed, { service_tier: "priority" })).toBe("priority");
expect(readSelectOption(speed, {})).toBe("default");
expect(readSelectOption(speed, { service_tier: "unknown-tier" })).toBe("default");
});
it("reads a toggle from options and is off when unset", () => {
if (fast.type !== "toggle") throw new Error("toggle expected");
expect(readToggleOption(fast, { fast_mode: true })).toBe(true);
expect(readToggleOption(fast, {})).toBe(false);
});
it("is active when the option differs from the control's default", () => {
expect(isControlActive(speed, { service_tier: "priority" })).toBe(true);
expect(isControlActive(speed, { service_tier: "default" })).toBe(false);
expect(isControlActive(speed, {})).toBe(false);
expect(isControlActive(fast, { fast_mode: true })).toBe(true);
expect(isControlActive(fast, {})).toBe(false);
});
it("prefers a control's tooltip over its label", () => {
expect(getControlTooltip({ label: "Plan", tooltip: "Toggle plan mode" })).toBe(
"Toggle plan mode",
);
expect(getControlTooltip({ label: "Custom" })).toBe("Custom");
});
});
@@ -1,47 +0,0 @@
import type {
AgentOptionControl,
AgentOptionSelectControl,
AgentOptionToggleControl,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
/**
* The composer's option controls. A control only describes how to draw one option key; the
* value is read from `options`, which the daemon resolved (defaults, aliases, what runs).
*/
export interface ComposerOptionControls {
controls: AgentOptionControl[];
options: ProviderOptions;
onSetOption: (key: string, value: unknown) => void;
}
export function readToggleOption(
control: AgentOptionToggleControl,
options: ProviderOptions,
): boolean {
const value = options[control.key];
return typeof value === "boolean" ? value : (control.defaultValue ?? false);
}
/** The selected choice's value, or the control's default when the option names no choice. */
export function readSelectOption(
control: AgentOptionSelectControl,
options: ProviderOptions,
): string | null {
const value = options[control.key];
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
return value;
}
return control.defaultValue ?? null;
}
/** Whether the option is set to something other than the control's default. */
export function isControlActive(control: AgentOptionControl, options: ProviderOptions): boolean {
if (control.type === "toggle") return readToggleOption(control, options);
const value = readSelectOption(control, options);
return value !== null && value !== control.defaultValue;
}
export function getControlTooltip(control: Pick<AgentOptionControl, "label" | "tooltip">): string {
return control.tooltip ?? control.label;
}
@@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => {
model: "claude-opus-5", model: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { web_search: true }, featureValues: { web_search: true },
}), }),
), ),
).toEqual({ ).toEqual({
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
modelId: "claude-opus-5", modelId: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { web_search: true }, featureValues: { web_search: true },
}); });
}); });
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
modelId: "", modelId: "",
modeId: "", modeId: "",
thinkingOptionId: "", thinkingOptionId: "",
options: {}, featureValues: {},
}); });
}); });
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
model: "claude-opus-5", model: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { web_search: true }, featureValues: { web_search: true },
}), }),
), ),
), ),
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
modelId: "claude-opus-5", modelId: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { web_search: true }, featureValues: { web_search: true },
}); });
}); });
@@ -81,8 +81,8 @@ describe("toAgentConfigApply", () => {
it("omits an empty feature map so the agent keeps its own feature values", () => { it("omits an empty feature map so the agent keeps its own feature values", () => {
expect( expect(
toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))), toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
).not.toHaveProperty("options"); ).not.toHaveProperty("featureValues");
}); });
it("never carries a provider, which a running agent cannot change", () => { it("never carries a provider, which a running agent cannot change", () => {
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
modelId: string; modelId: string;
modeId: string; modeId: string;
thinkingOptionId: string; thinkingOptionId: string;
options: Record<string, unknown>; featureValues: Record<string, unknown>;
} }
function trimmed(value: string | undefined): string { function trimmed(value: string | undefined): string {
@@ -25,7 +25,7 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen
modelId: trimmed(profile.model), modelId: trimmed(profile.model),
modeId: trimmed(profile.modeId), modeId: trimmed(profile.modeId),
thinkingOptionId: trimmed(profile.thinkingOptionId), thinkingOptionId: trimmed(profile.thinkingOptionId),
options: profile.options ?? {}, featureValues: profile.featureValues ?? {},
}; };
} }
@@ -52,6 +52,8 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf
...(profile.modelId ? { modelId: profile.modelId } : {}), ...(profile.modelId ? { modelId: profile.modelId } : {}),
...(profile.modeId ? { modeId: profile.modeId } : {}), ...(profile.modeId ? { modeId: profile.modeId } : {}),
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}), ...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}), ...(Object.keys(profile.featureValues).length > 0
? { featureValues: profile.featureValues }
: {}),
}; };
} }
@@ -1,7 +1,7 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildControlsRequestKey, buildFeatureRequestKey,
openAgentProfileForm, openAgentProfileForm,
type AgentProfileFormModel, type AgentProfileFormModel,
} from "./profile-form-model"; } from "./profile-form-model";
@@ -173,7 +173,7 @@ describe("openAgentProfileForm", () => {
model: "claude-opus-5", model: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { webSearch: true }, featureValues: { webSearch: true },
notes: "Visual work only.", notes: "Visual work only.",
}, },
}); });
@@ -229,11 +229,10 @@ describe("openAgentProfileForm", () => {
selectClaude(model); selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" }); model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" }); model.setThinking("think-hard", { label: "Think hard" });
model.applyControls(model.getState().controlsRequestKey ?? "", { model.applyFeatures(model.getState().featureRequestKey ?? "", [
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], { type: "toggle", id: "webSearch", label: "Web search", value: false },
options: { webSearch: false }, ]);
}); model.setFeatureValue("webSearch", true);
model.setOption("webSearch", true);
model.setProvider("codex", { label: "Codex" }); model.setProvider("codex", { label: "Codex" });
const state = model.getState(); const state = model.getState();
@@ -243,9 +242,8 @@ describe("openAgentProfileForm", () => {
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" }); expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
expect(state.thinkingOptionId).toBe(""); expect(state.thinkingOptionId).toBe("");
expect(state.thinkingDisplay).toBeNull(); expect(state.thinkingDisplay).toBeNull();
expect(state.options).toEqual({}); expect(state.featureValues).toEqual({});
expect(state.optionEdits).toEqual({}); expect(state.features).toEqual([]);
expect(state.controls).toEqual([]);
expect(state.name).toBe("Test writer"); expect(state.name).toBe("Test writer");
}); });
@@ -336,28 +334,23 @@ describe("openAgentProfileForm", () => {
}); });
}); });
const WEB_SEARCH_CONTROLS = { describe("features", () => {
controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }], it("asks for features per provider/model/mode/thinking combination", () => {
options: { webSearch: false },
};
describe("controls", () => {
it("asks for controls per provider/model/mode/thinking combination", () => {
const model = openWithCatalog({ mode: "create" }); const model = openWithCatalog({ mode: "create" });
selectClaude(model); selectClaude(model);
expect(model.getState().controlsRequest).toEqual({ expect(model.getState().featureRequest).toEqual({
provider: "claude", provider: "claude",
model: "claude-opus-5", model: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think", thinkingOptionId: "think",
}); });
const firstKey = model.getState().controlsRequestKey; const firstKey = model.getState().featureRequestKey;
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().controlsRequestKey).not.toBe(firstKey); expect(model.getState().featureRequestKey).not.toBe(firstKey);
expect(model.getState().controlsRequest).toEqual({ expect(model.getState().featureRequest).toEqual({
provider: "claude", provider: "claude",
model: "claude-haiku-4-5", model: "claude-haiku-4-5",
modeId: "plan", modeId: "plan",
@@ -368,168 +361,77 @@ describe("openAgentProfileForm", () => {
it("moves from pending to complete only for the current request", () => { it("moves from pending to complete only for the current request", () => {
const model = openWithCatalog({ mode: "create" }); const model = openWithCatalog({ mode: "create" });
selectClaude(model); selectClaude(model);
const key = model.getState().controlsRequestKey ?? ""; const key = model.getState().featureRequestKey ?? "";
expect(model.getState().controlsResolution).toBe("pending"); expect(model.getState().featureResolution).toBe("pending");
model.applyControls("stale-key", { model.applyFeatures("stale-key", [
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], { type: "toggle", id: "webSearch", label: "Web search", value: false },
options: { webSearch: false }, ]);
}); expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().controlsResolution).toBe("pending"); expect(model.getState().features).toEqual([]);
expect(model.getState().controls).toEqual([]);
model.applyControls(key, { model.applyFeatures(key, [
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], { type: "toggle", id: "webSearch", label: "Web search", value: false },
options: { webSearch: false }, ]);
}); expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().controlsResolution).toBe("complete"); expect(model.getState().disclosure.showFeaturesField).toBe(true);
expect(model.getState().disclosure.showControlsField).toBe(true);
}); });
it("reflects a set option back into the rendered controls", () => { it("reflects a set feature value back into the rendered feature", () => {
const model = openWithCatalog({ mode: "create" }); const model = openWithCatalog({ mode: "create" });
model.setName("Searcher");
selectClaude(model); selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS); model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setOption("webSearch", true); model.setFeatureValue("webSearch", true);
expect(model.getState().controls[0]).toMatchObject({ key: "webSearch" }); expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
expect(model.getState().controlOptions).toEqual({ webSearch: true }); expect(model.getState().featureValues).toEqual({ webSearch: true });
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
}); });
it("keeps stored options while the controls are still pending", () => { it("keeps stored feature values while the feature list is still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "UI work", provider: "claude", options: { webSearch: true } },
});
model.applyProviderCatalog(ENTRIES);
expect(model.getState().controlsResolution).toBe("pending");
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
});
it("keeps options no control draws, so editing a profile does not drop them", () => {
const model = openAgentProfileForm({ const model = openAgentProfileForm({
mode: "edit", mode: "edit",
profile: { profile: {
id: "p1", id: "p1",
name: "UI work", name: "UI work",
provider: "claude", provider: "claude",
options: { webSearch: true, sandbox_mode: "read-only" }, featureValues: { webSearch: true },
}, },
}); });
model.applyProviderCatalog(ENTRIES); model.applyProviderCatalog(ENTRIES);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setOption("webSearch", false);
expect(model.getState().submitValue?.options).toEqual({ expect(model.getState().featureResolution).toBe("pending");
webSearch: false, expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
sandbox_mode: "read-only",
});
}); });
it("draws the options the daemon resolved from the stored ones, then the edits", () => { it("prunes stored values the provider no longer reports", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "Fast", provider: "codex", options: { service_tier: "fast" } },
});
model.applyProviderCatalog(ENTRIES);
// The request carries the stored options so the daemon can resolve the legacy tier.
expect(model.getState().controlsRequest?.providerOptions).toEqual({ service_tier: "fast" });
model.applyControls(model.getState().controlsRequestKey ?? "", {
controls: [
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
},
],
options: { service_tier: "priority" },
});
expect(model.getState().controlOptions).toEqual({ service_tier: "priority" });
// Nothing was edited, so the stored value is saved as it was.
expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" });
});
it("sends an edit to the daemon with the stored options, and draws the answer", () => {
const model = openAgentProfileForm({ const model = openAgentProfileForm({
mode: "edit", mode: "edit",
profile: { profile: {
id: "p1", id: "p1",
name: "Fast", name: "UI work",
provider: "codex", provider: "claude",
options: { service_tier: "fast", sandbox_mode: "read-only" }, featureValues: { webSearch: true, retiredFlag: true },
}, },
}); });
model.applyProviderCatalog(ENTRIES); model.applyProviderCatalog(ENTRIES);
const speed = { model.applyFeatures(model.getState().featureRequestKey ?? "", [
controls: [ { type: "toggle", id: "webSearch", label: "Web search", value: false },
{ ]);
type: "select" as const,
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
},
],
options: { service_tier: "priority", sandbox_mode: "read-only" },
};
model.applyControls(model.getState().controlsRequestKey ?? "", speed);
model.setOption("service_tier", "default"); expect(model.getState().featureValues).toEqual({ webSearch: true });
// The controls stay up while the daemon answers, with the toggle laid over the last answer.
expect(model.getState().controls).toHaveLength(1);
expect(model.getState().controlOptions).toMatchObject({ service_tier: "default" });
// The request carries everything the profile would save, so the daemon resolves it.
expect(model.getState().controlsRequest?.providerOptions).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
expect(model.getState().controlsResolution).toBe("pending");
model.applyControls(model.getState().controlsRequestKey ?? "", {
...speed,
options: { service_tier: "default", sandbox_mode: "read-only" },
});
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().controlOptions).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
});
it("draws no controls for the previous model while another model's answer is pending", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().controls).toEqual([]);
}); });
it("resolves an unavailable feature listing without showing the field", () => { it("resolves an unavailable feature listing without showing the field", () => {
const model = openWithCatalog({ mode: "create" }); const model = openWithCatalog({ mode: "create" });
selectClaude(model); selectClaude(model);
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? ""); model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
expect(model.getState().controlsResolution).toBe("complete"); expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showControlsField).toBe(false); expect(model.getState().disclosure.showFeaturesField).toBe(false);
}); });
}); });
@@ -562,15 +464,15 @@ describe("openAgentProfileForm", () => {
}); });
}); });
describe("buildControlsRequestKey", () => { describe("buildFeatureRequestKey", () => {
it("is null without a provider", () => { it("is null without a provider", () => {
expect(buildControlsRequestKey(null)).toBeNull(); expect(buildFeatureRequestKey(null)).toBeNull();
}); });
it("separates the fields so a value cannot bleed across positions", () => { it("separates the fields so a value cannot bleed across positions", () => {
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||"); expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe( expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildControlsRequestKey({ provider: "claude", model: "a" }), buildFeatureRequestKey({ provider: "claude", model: "a" }),
); );
}); });
}); });
@@ -1,6 +1,5 @@
import type { import type {
AgentOptionControl, AgentFeature,
ProviderOptions,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentSelectOption, AgentSelectOption,
@@ -8,6 +7,7 @@ import type {
} from "@getpaseo/protocol/agent-types"; } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages"; import type { AgentProfile } from "@getpaseo/protocol/messages";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
import { filterSelectableModels } from "@/provider-selection/model-catalog"; import { filterSelectableModels } from "@/provider-selection/model-catalog";
/** /**
@@ -57,15 +57,11 @@ export interface AgentProfileFormSnapshot {
} }
/** /**
* The inputs a controls listing needs. Controls are provider-scoped and change * The inputs a feature listing needs. Features are provider-scoped and change
* with model/mode/thinking, so every one of those is part of the request. The * with model/mode/thinking, so every one of those is part of the request.
* profile's own options ride along so the daemon can resolve them (a legacy
* `service_tier: "fast"` is drawn as the model's Fast tier); they are the same
* for the life of one provider, so they are not part of the key.
*/ */
export interface AgentProfileControlsRequest { export interface AgentProfileFeatureRequest {
provider: string; provider: string;
providerOptions?: ProviderOptions;
model?: string; model?: string;
modeId?: string; modeId?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
@@ -77,7 +73,7 @@ export interface AgentProfileFormDisclosure {
showModelField: boolean; showModelField: boolean;
showModeField: boolean; showModeField: boolean;
showThinkingField: boolean; showThinkingField: boolean;
showControlsField: boolean; showFeaturesField: boolean;
} }
export interface AgentProfileFormState { export interface AgentProfileFormState {
@@ -90,18 +86,13 @@ export interface AgentProfileFormState {
modelId: string; modelId: string;
modeId: string; modeId: string;
thinkingOptionId: string; thinkingOptionId: string;
/** The options the profile was opened with, including ones no control draws. */ featureValues: Record<string, unknown>;
options: ProviderOptions;
/** Changes made in this form, over `options`. */
optionEdits: ProviderOptions;
providerOptions: AgentProfileFormOption[]; providerOptions: AgentProfileFormOption[];
modelOptions: AgentProfileFormOption[]; modelOptions: AgentProfileFormOption[];
modeOptions: AgentProfileFormOption[]; modeOptions: AgentProfileFormOption[];
thinkingOptions: AgentProfileFormOption[]; thinkingOptions: AgentProfileFormOption[];
controls: AgentOptionControl[]; features: AgentFeature[];
/** What the controls show: the options the daemon resolved from `options`, then the edits. */
controlOptions: ProviderOptions;
providerDisplay: AgentProfileFormDisplay | null; providerDisplay: AgentProfileFormDisplay | null;
modelDisplay: AgentProfileFormDisplay | null; modelDisplay: AgentProfileFormDisplay | null;
@@ -109,9 +100,9 @@ export interface AgentProfileFormState {
thinkingDisplay: AgentProfileFormDisplay | null; thinkingDisplay: AgentProfileFormDisplay | null;
catalogResolution: AgentProfileResolutionStatus; catalogResolution: AgentProfileResolutionStatus;
controlsResolution: AgentProfileResolutionStatus; featureResolution: AgentProfileResolutionStatus;
controlsRequest: AgentProfileControlsRequest | null; featureRequest: AgentProfileFeatureRequest | null;
controlsRequestKey: string | null; featureRequestKey: string | null;
disclosure: AgentProfileFormDisclosure; disclosure: AgentProfileFormDisclosure;
isSubmitting: boolean; isSubmitting: boolean;
@@ -127,12 +118,9 @@ export interface AgentProfileFormModel {
/** Late input: the host-scoped provider catalog. Never touches selections. */ /** Late input: the host-scoped provider catalog. Never touches selections. */
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void; applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
/** Late input: the feature list for one request. Stale keys are ignored. */ /** Late input: the feature list for one request. Stale keys are ignored. */
applyControls: ( applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
requestKey: string,
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
) => void;
/** Resolve a request that produced no usable features (provider error). */ /** Resolve a request that produced no usable features (provider error). */
applyControlsUnavailable: (requestKey: string) => void; applyFeaturesUnavailable: (requestKey: string) => void;
setName: (value: string) => void; setName: (value: string) => void;
setAppearance: (value: { icon: string; color: string }) => void; setAppearance: (value: { icon: string; color: string }) => void;
setNotes: (value: string) => void; setNotes: (value: string) => void;
@@ -140,7 +128,7 @@ export interface AgentProfileFormModel {
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void; setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void; setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void; setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
setOption: (key: string, value: unknown) => void; setFeatureValue: (featureId: string, value: unknown) => void;
setSubmitting: (value: boolean) => void; setSubmitting: (value: boolean) => void;
setSubmitError: (value: string | null) => void; setSubmitError: (value: string | null) => void;
} }
@@ -324,29 +312,19 @@ function refineDisplay(
return current ?? { label: selectedId }; return current ?? { label: selectedId };
} }
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null { function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
if (!state.provider) { if (!state.provider) {
return null; return null;
} }
const ownOptions = ownOptionsOf(state);
return { return {
provider: state.provider, provider: state.provider,
...(state.modelId ? { model: state.modelId } : {}), ...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}), ...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(ownOptions).length > 0 ? { providerOptions: ownOptions } : {}),
}; };
} }
/** Everything the profile would save as its options; the daemon resolves them for the form. */ export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
function ownOptionsOf(
state: Pick<AgentProfileFormState, "options" | "optionEdits">,
): ProviderOptions {
return { ...state.options, ...state.optionEdits };
}
/** The part of a request that decides which controls exist; options only change their values. */
function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null {
if (!request) { if (!request) {
return null; return null;
} }
@@ -358,25 +336,12 @@ function buildControlsScopeKey(request: AgentProfileControlsRequest | null): str
].join("|"); ].join("|");
} }
export function buildControlsRequestKey(
request: AgentProfileControlsRequest | null,
): string | null {
if (!request) {
return null;
}
const scope = buildControlsScopeKey(request);
return request.providerOptions
? `${scope}|${JSON.stringify(Object.entries(request.providerOptions).sort(([a], [b]) => a.localeCompare(b)))}`
: scope;
}
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null { function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
const name = state.name.trim(); const name = state.name.trim();
const notes = state.notes.trim(); const notes = state.notes.trim();
if (!name || !state.provider) { if (!name || !state.provider) {
return null; return null;
} }
const options = ownOptionsOf(state);
return { return {
name, name,
...(state.icon ? { icon: state.icon } : {}), ...(state.icon ? { icon: state.icon } : {}),
@@ -385,16 +350,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
...(state.modelId ? { model: state.modelId } : {}), ...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}), ...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(options).length > 0 ? { options } : {}), ...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
...(notes ? { notes } : {}), ...(notes ? { notes } : {}),
}; };
} }
function resolveControlsStatus( function resolveFeatureStatus(
controlsRequestKey: string | null, featureRequestKey: string | null,
featuresAreCurrent: boolean, featuresAreCurrent: boolean,
): AgentProfileResolutionStatus { ): AgentProfileResolutionStatus {
if (controlsRequestKey === null) { if (featureRequestKey === null) {
return "idle"; return "idle";
} }
return featuresAreCurrent ? "complete" : "pending"; return featuresAreCurrent ? "complete" : "pending";
@@ -427,27 +392,25 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
modelId, modelId,
modeId: profile.modeId ?? "", modeId: profile.modeId ?? "",
thinkingOptionId: profile.thinkingOptionId ?? "", thinkingOptionId: profile.thinkingOptionId ?? "",
options: { ...profile.options }, featureValues: { ...profile.featureValues },
optionEdits: {},
providerOptions: [], providerOptions: [],
modelOptions: [], modelOptions: [],
modeOptions: [], modeOptions: [],
thinkingOptions: [], thinkingOptions: [],
controls: [], features: [],
controlOptions: {},
providerDisplay: seedDisplay(provider), providerDisplay: seedDisplay(provider),
modelDisplay: seedDisplay(modelId), modelDisplay: seedDisplay(modelId),
modeDisplay: seedDisplay(profile.modeId), modeDisplay: seedDisplay(profile.modeId),
thinkingDisplay: seedDisplay(profile.thinkingOptionId), thinkingDisplay: seedDisplay(profile.thinkingOptionId),
catalogResolution: "idle", catalogResolution: "idle",
controlsResolution: "idle", featureResolution: "idle",
controlsRequest: null, featureRequest: null,
controlsRequestKey: null, featureRequestKey: null,
disclosure: { disclosure: {
showModelField: false, showModelField: false,
showModeField: false, showModeField: false,
showThinkingField: false, showThinkingField: false,
showControlsField: false, showFeaturesField: false,
}, },
isSubmitting: false, isSubmitting: false,
submitError: null, submitError: null,
@@ -459,10 +422,8 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel { export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
let entries: readonly ProviderSnapshotEntry[] = []; let entries: readonly ProviderSnapshotEntry[] = [];
let catalogResolution: AgentProfileResolutionStatus = "idle"; let catalogResolution: AgentProfileResolutionStatus = "idle";
let resolvedControlsKey: string | null = null; let resolvedFeatureKey: string | null = null;
let resolvedScopeKey: string | null = null; let resolvedFeatures: AgentFeature[] = [];
let resolvedControls: AgentOptionControl[] = [];
let resolvedOptions: ProviderOptions = {};
let listeners = new Set<() => void>(); let listeners = new Set<() => void>();
let closed = false; let closed = false;
@@ -475,23 +436,14 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modes, modes,
}); });
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId); const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
const controlsRequest = buildControlsRequest(next); const featureRequest = buildFeatureRequest(next);
const controlsRequestKey = buildControlsRequestKey(controlsRequest); const featureRequestKey = buildFeatureRequestKey(featureRequest);
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
const featuresAreCurrent = const featuresAreCurrent =
controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey; featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
// A toggle changes only the options, so the controls stay up while the daemon answers and the const features = featuresAreCurrent
// toggle shows over the last answer. A change of provider, model, mode or thinking draws none ? applyFeatureValues(resolvedFeatures, next.featureValues)
// until its own answer arrives. : [];
const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey; const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
const controls = controlsApply ? resolvedControls : [];
let controlOptions: ProviderOptions = next.options;
if (controlsApply) {
controlOptions = featuresAreCurrent
? resolvedOptions
: { ...resolvedOptions, ...next.optionEdits };
}
const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent);
const withOptions: AgentProfileFormState = { const withOptions: AgentProfileFormState = {
...next, ...next,
@@ -499,8 +451,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modelOptions: buildModelOptions(models), modelOptions: buildModelOptions(models),
modeOptions: buildModeOptions(modes), modeOptions: buildModeOptions(modes),
thinkingOptions: buildThinkingOptions(thinking), thinkingOptions: buildThinkingOptions(thinking),
controls, features,
controlOptions,
providerDisplay: refineDisplay( providerDisplay: refineDisplay(
next.providerDisplay, next.providerDisplay,
next.provider, next.provider,
@@ -528,9 +479,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
})(), })(),
), ),
catalogResolution, catalogResolution,
controlsResolution, featureResolution,
controlsRequest, featureRequest,
controlsRequestKey, featureRequestKey,
}; };
// A field appears once the catalog can populate it, and stays visible while // A field appears once the catalog can populate it, and stays visible while
@@ -541,7 +492,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)), showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
showThinkingField: showThinkingField:
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)), hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
showControlsField: hasProvider && controls.length > 0, showFeaturesField: hasProvider && features.length > 0,
}; };
const canSubmit = const canSubmit =
withOptions.name.trim().length > 0 && withOptions.name.trim().length > 0 &&
@@ -580,24 +531,23 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
catalogResolution = "complete"; catalogResolution = "complete";
publish((current) => current); publish((current) => current);
}, },
applyControls: (requestKey, resolved) => { applyFeatures: (requestKey, features) => {
if (requestKey !== state.controlsRequestKey) { if (requestKey !== state.featureRequestKey) {
return; return;
} }
resolvedControlsKey = requestKey; resolvedFeatureKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest); resolvedFeatures = [...features];
resolvedControls = [...resolved.controls]; publish((current) => ({
resolvedOptions = resolved.options; ...current,
publish((current) => current); featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
}));
}, },
applyControlsUnavailable: (requestKey) => { applyFeaturesUnavailable: (requestKey) => {
if (requestKey !== state.controlsRequestKey) { if (requestKey !== state.featureRequestKey) {
return; return;
} }
resolvedControlsKey = requestKey; resolvedFeatureKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest); resolvedFeatures = [];
resolvedControls = [];
resolvedOptions = {};
publish((current) => current); publish((current) => current);
}, },
setName: (value) => publish((current) => ({ ...current, name: value })), setName: (value) => publish((current) => ({ ...current, name: value })),
@@ -622,8 +572,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modeDisplay: null, modeDisplay: null,
thinkingOptionId: "", thinkingOptionId: "",
thinkingDisplay: null, thinkingDisplay: null,
options: {}, featureValues: {},
optionEdits: {},
}; };
}), }),
setModel: (modelId, display) => setModel: (modelId, display) =>
@@ -649,10 +598,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
publish((current) => ({ ...current, modeId, modeDisplay: display })), publish((current) => ({ ...current, modeId, modeDisplay: display })),
setThinking: (thinkingOptionId, display) => setThinking: (thinkingOptionId, display) =>
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })), publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
setOption: (key, value) => setFeatureValue: (featureId, value) =>
publish((current) => ({ publish((current) => ({
...current, ...current,
optionEdits: { ...current.optionEdits, [key]: value }, featureValues: { ...current.featureValues, [featureId]: value },
})), })),
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })), setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })), setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
@@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: {
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) }); tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
} }
const featureCount = Object.keys(input.profile.options ?? {}).length; const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
if (featureCount > 0) { if (featureCount > 0) {
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) }); tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
} }
@@ -117,7 +117,9 @@ export function useAgentProfilePicker(
modelId: resolved.modelId, modelId: resolved.modelId,
modeId: resolved.modeId, modeId: resolved.modeId,
thinkingOptionId: resolved.thinkingOptionId, thinkingOptionId: resolved.thinkingOptionId,
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}), ...(Object.keys(resolved.featureValues).length > 0
? { featureValues: resolved.featureValues }
: {}),
}), }),
).catch((error) => { ).catch((error) => {
console.warn("[useAgentProfilePicker] persist profile selection failed", error); console.warn("[useAgentProfilePicker] persist profile selection failed", error);
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
}, [entries, input.model]); }, [entries, input.model]);
} }
export function useAgentProfileFormControls(input: { export function useAgentProfileFormFeatures(input: {
serverId: string; serverId: string;
model: AgentProfileFormModel; model: AgentProfileFormModel;
state: AgentProfileFormState; state: AgentProfileFormState;
@@ -35,14 +35,14 @@ export function useAgentProfileFormControls(input: {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useHostRuntimeClient(input.serverId); const client = useHostRuntimeClient(input.serverId);
const isConnected = useHostRuntimeIsConnected(input.serverId); const isConnected = useHostRuntimeIsConnected(input.serverId);
const request = input.state.controlsRequest; const request = input.state.featureRequest;
const requestKey = input.state.controlsRequestKey; const requestKey = input.state.featureRequestKey;
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous // `dataShape: "value"` and not `"list"`: a list shape keeps the previous
// page's data while the next request is in flight, which would hand the // page's data while the next request is in flight, which would hand the
// features of one provider/model combination to the key of another. // features of one provider/model combination to the key of another.
const controlsQuery = useFetchQuery({ const featuresQuery = useFetchQuery({
queryKey: ["agentProfileControls", input.serverId, requestKey], queryKey: ["agentProfileFeatures", input.serverId, requestKey],
dataShape: "value", dataShape: "value",
staleTimeMs: 5 * 60 * 1000, staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(client && isConnected && request), enabled: Boolean(client && isConnected && request),
@@ -51,28 +51,28 @@ export function useAgentProfileFormControls(input: {
if (!client || !request) { if (!client || !request) {
throw new Error(t("workspace.terminal.hostDisconnected")); throw new Error(t("workspace.terminal.hostDisconnected"));
} }
const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD }); const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
if (payload.error) { if (payload.error) {
throw new Error(payload.error); throw new Error(payload.error);
} }
return { controls: payload.controls ?? [], options: payload.options ?? {} }; return payload.features ?? [];
}, },
}); });
const { data, isError } = controlsQuery; const { data, isError } = featuresQuery;
const { model } = input; const { model } = input;
useEffect(() => { useEffect(() => {
if (!requestKey || !data) { if (!requestKey || !data) {
return; return;
} }
model.applyControls(requestKey, data); model.applyFeatures(requestKey, data);
}, [data, model, requestKey]); }, [data, model, requestKey]);
useEffect(() => { useEffect(() => {
if (!requestKey || !isError) { if (!requestKey || !isError) {
return; return;
} }
model.applyControlsUnavailable(requestKey); model.applyFeaturesUnavailable(requestKey);
}, [isError, model, requestKey]); }, [isError, model, requestKey]);
} }
@@ -2,8 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement }
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types"; import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
import type { AgentProfile } from "@getpaseo/protocol/messages"; import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -26,7 +25,7 @@ import type {
} from "../internal/profile-form-model"; } from "../internal/profile-form-model";
import { import {
useAgentProfileFormCatalog, useAgentProfileFormCatalog,
useAgentProfileFormControls, useAgentProfileFormFeatures,
} from "../internal/use-profile-form-inputs"; } from "../internal/use-profile-form-inputs";
import { import {
useAgentProfileFormModel, useAgentProfileFormModel,
@@ -142,7 +141,7 @@ function OpenAgentProfileEditModal({
const model = useAgentProfileFormModel(snapshot); const model = useAgentProfileFormModel(snapshot);
const state = useAgentProfileFormState(model); const state = useAgentProfileFormState(model);
useAgentProfileFormCatalog({ serverId, model }); useAgentProfileFormCatalog({ serverId, model });
useAgentProfileFormControls({ serverId, model, state }); useAgentProfileFormFeatures({ serverId, model, state });
const sheetHeader = useMemo<SheetHeader>( const sheetHeader = useMemo<SheetHeader>(
() => ({ () => ({
@@ -329,17 +328,16 @@ function OpenAgentProfileEditModal({
/> />
) : null} ) : null}
{state.disclosure.showControlsField ? ( {state.disclosure.showFeaturesField ? (
<Field <Field
label={t("settings.host.agentProfiles.featuresLabel")} label={t("settings.host.agentProfiles.featuresLabel")}
testID="agent-profile-features-field" testID="agent-profile-features-field"
> >
<View style={styles.featureCard} testID="agent-profile-features-card"> <View style={styles.featureCard} testID="agent-profile-features-card">
{state.controls.map((control, index) => ( {state.features.map((feature, index) => (
<AgentProfileControlRow <AgentProfileFeatureRow
key={control.key} key={feature.id}
control={control} feature={feature}
options={state.controlOptions}
isFirst={index === 0} isFirst={index === 0}
disabled={state.isSubmitting} disabled={state.isSubmitting}
size={controlSize} size={controlSize}
@@ -402,16 +400,14 @@ function OpenAgentProfileEditModal({
); );
} }
function AgentProfileControlRow({ function AgentProfileFeatureRow({
control, feature,
options,
isFirst, isFirst,
disabled, disabled,
size, size,
model, model,
}: { }: {
control: AgentOptionControl; feature: AgentFeature;
options: ProviderOptions;
isFirst: boolean; isFirst: boolean;
disabled: boolean; disabled: boolean;
size: FieldControlSize; size: FieldControlSize;
@@ -422,75 +418,74 @@ function AgentProfileControlRow({
[isFirst], [isFirst],
); );
const handleToggle = useCallback( const handleToggle = useCallback(
(value: boolean) => model.setOption(control.key, value), (value: boolean) => model.setFeatureValue(feature.id, value),
[control.key, model], [feature.id, model],
); );
const handleSelect = useCallback( const handleSelect = useCallback(
(value: string) => model.setOption(control.key, value), (value: string) => model.setFeatureValue(feature.id, value),
[control.key, model], [feature.id, model],
); );
const selectOptions = useMemo<SelectFieldOption<string>[]>( const selectOptions = useMemo<SelectFieldOption<string>[]>(
() => () =>
control.type === "select" feature.type === "select"
? control.choices.map((choice) => ({ ? feature.options.map((option) => ({
id: choice.value, id: option.id,
value: choice.value, value: option.id,
label: choice.label, label: option.label,
...(choice.description ? { description: choice.description } : {}), ...(option.description ? { description: option.description } : {}),
testID: `agent-profile-feature-option-${control.key}-${choice.value}`, testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
})) }))
: [], : [],
[control], [feature],
); );
const selectedValue = control.type === "select" ? readSelectOption(control, options) : null;
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => { const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
if (control.type !== "select" || !selectedValue) { if (feature.type !== "select" || !feature.value) {
return null; return null;
} }
const choice = control.choices.find((candidate) => candidate.value === selectedValue); const option = feature.options.find((candidate) => candidate.id === feature.value);
return { label: choice?.label ?? selectedValue }; return { label: option?.label ?? feature.value };
}, [control, selectedValue]); }, [feature]);
if (control.type === "select") { if (feature.type === "select") {
return ( return (
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}> <View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<SelectField <SelectField
label={control.label} label={feature.label}
value={selectedValue} value={feature.value}
selectedDisplay={selectedDisplay} selectedDisplay={selectedDisplay}
options={selectOptions} options={selectOptions}
onChange={handleSelect} onChange={handleSelect}
placeholder={control.label} placeholder={feature.label}
emptyText={control.label} emptyText={feature.label}
disabled={disabled} disabled={disabled}
{...(control.description ? { hint: control.description } : {})} {...(feature.description ? { hint: feature.description } : {})}
title={control.label} title={feature.label}
size={size} size={size}
testID={`agent-profile-feature-field-${control.key}`} testID={`agent-profile-feature-field-${feature.id}`}
triggerTestID={`agent-profile-feature-trigger-${control.key}`} triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
/> />
</View> </View>
); );
} }
return ( return (
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}> <View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<View style={styles.featureMeta}> <View style={styles.featureMeta}>
<Text style={styles.featureLabel} numberOfLines={1}> <Text style={styles.featureLabel} numberOfLines={1}>
{control.label} {feature.label}
</Text> </Text>
{control.description ? ( {feature.description ? (
<Text style={styles.featureDescription} numberOfLines={2}> <Text style={styles.featureDescription} numberOfLines={2}>
{control.description} {feature.description}
</Text> </Text>
) : null} ) : null}
</View> </View>
<Switch <Switch
value={readToggleOption(control, options)} value={feature.value === true}
onValueChange={handleToggle} onValueChange={handleToggle}
disabled={disabled} disabled={disabled}
accessibilityLabel={control.label} accessibilityLabel={feature.label}
testID={`agent-profile-feature-switch-${control.key}`} testID={`agent-profile-feature-switch-${feature.id}`}
/> />
</View> </View>
); );
+1 -2
View File
@@ -1304,8 +1304,7 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent)
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) { if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId"); reasons.push("agent.runtimeInfo.thinkingOptionId");
} }
if (left.options !== right.options) reasons.push("agent.options"); if (left.features !== right.features) reasons.push("agent.features");
if (left.controls !== right.controls) reasons.push("agent.controls");
return reasons; return reasons;
} }
+9 -6
View File
@@ -1,3 +1,4 @@
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar"; import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
@@ -465,6 +466,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const surface = ( const surface = (
<View style={layoutStyles.surfaceFill}> <View style={layoutStyles.surfaceFill}>
{workspaceChrome} {workspaceChrome}
<AddHostFlowHost />
<AppearanceStyleBoundary> <AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left"> <WindowChromeRegion corners="top-left">
@@ -805,7 +807,6 @@ function RootStack() {
<Stack.Screen name="open-project" /> <Stack.Screen name="open-project" />
<Stack.Screen name="sessions" /> <Stack.Screen name="sessions" />
<Stack.Screen name="schedules" /> <Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected> </Stack.Protected>
<Stack.Screen name="h/[serverId]" /> <Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" /> <Stack.Screen name="settings/hosts/[serverId]/index" />
@@ -890,11 +891,13 @@ function RootAppTree() {
<GestureHandlerRootView style={flexStyle}> <GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}> <View style={layoutStyles.surfaceFill}>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}> <LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<RootProviders> <AddHostProvider>
<RuntimeProviders> <RootProviders>
<AppShell /> <RuntimeProviders>
</RuntimeProviders> <AppShell />
</RootProviders> </RuntimeProviders>
</RootProviders>
</AddHostProvider>
</LucideProvider> </LucideProvider>
</View> </View>
</GestureHandlerRootView> </GestureHandlerRootView>
+2 -3
View File
@@ -35,10 +35,9 @@ function SettingsDaemonRedirect() {
} }
export default function SettingsSectionRoute() { export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string; addHost?: string }>(); const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : ""; const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general"; const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]); const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08. // COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
@@ -50,5 +49,5 @@ export default function SettingsSectionRoute() {
); );
} }
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />; return <SettingsScreen view={view} />;
} }
@@ -40,7 +40,16 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
cwd: "/repo", cwd: "/repo",
model: "agent-model", model: "agent-model",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { "web-search": true, effort: "high" }, features: [
{ type: "toggle", id: "web-search", label: "Web search", value: true },
{
type: "select",
id: "effort",
label: "Effort",
value: "high",
options: [{ id: "high", label: "High" }],
},
],
parentAgentId: null, parentAgentId: null,
labels: {}, labels: {},
...overrides, ...overrides,
@@ -105,7 +114,7 @@ describe("buildDraftAgentSetup", () => {
modeId: "mode-current", modeId: "mode-current",
model: "agent-model", model: "agent-model",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
providerOptions: { featureValues: {
"web-search": true, "web-search": true,
effort: "high", effort: "high",
}, },
@@ -65,12 +65,17 @@ export function resolveClientSlashCommand(input: {
} }
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup { export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return { return {
provider: agent.provider, provider: agent.provider,
cwd: agent.cwd, cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null, model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
providerOptions: { ...agent.options }, featureValues,
}; };
} }
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
AgentMode,
AgentOptionControl,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import type { CommandCenterContribution } from "./contributions"; import type { CommandCenterContribution } from "./contributions";
import { import {
@@ -35,7 +31,7 @@ const ICONS: AgentControlContributionSource["icons"] = {
thinking: TestIcon, thinking: TestIcon,
planMode: TestIcon, planMode: TestIcon,
mode: () => TestIcon, mode: () => TestIcon,
control: () => TestIcon, feature: () => TestIcon,
}; };
function provider(input: { function provider(input: {
@@ -69,8 +65,8 @@ function provider(input: {
}; };
} }
function toggleControl(key: string, icon?: string): AgentOptionControl { function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
return { type: "toggle", key, label: key, icon }; return { type: "toggle", id, label: id, value, icon };
} }
function makeSource( function makeSource(
@@ -79,7 +75,7 @@ function makeSource(
models?: Partial<AgentControlContributionSource["models"]>; models?: Partial<AgentControlContributionSource["models"]>;
thinking?: Partial<AgentControlContributionSource["thinking"]>; thinking?: Partial<AgentControlContributionSource["thinking"]>;
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>; modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
optionControls?: Partial<AgentControlContributionSource["optionControls"]>; features?: Partial<AgentControlContributionSource["features"]>;
} = {}, } = {},
): AgentControlContributionSource { ): AgentControlContributionSource {
return { return {
@@ -108,11 +104,10 @@ function makeSource(
select: () => undefined, select: () => undefined,
...overrides.modes, ...overrides.modes,
}, },
optionControls: { features: {
controls: [], list: [],
options: {}, set: () => undefined,
onSetOption: () => undefined, ...overrides.features,
...overrides.optionControls,
}, },
}; };
} }
@@ -138,7 +133,7 @@ describe("Command Center agent-control contributions", () => {
const contributions = buildAgentControlContributions({ const contributions = buildAgentControlContributions({
...makeSource({ ...makeSource({
models: { providers: [provider({ id: "claude", label: "Claude" })] }, models: { providers: [provider({ id: "claude", label: "Claude" })] },
optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } }, features: { list: [toggleFeature("plan_mode", true)] },
}), }),
modes: undefined, modes: undefined,
}); });
@@ -328,10 +323,9 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default", selectedId: "default",
defaultModeId: "default", defaultModeId: "default",
}, },
optionControls: { features: {
controls: [toggleControl("plan_mode", "list-todo")], list: [toggleFeature("plan_mode", true, "list-todo")],
options: { plan_mode: true }, set: (id, value) => {
onSetOption: (id, value) => {
calls.push([id, value]); calls.push([id, value]);
}, },
}, },
@@ -347,10 +341,9 @@ describe("Command Center agent-control contributions", () => {
const calls: [string, unknown][] = []; const calls: [string, unknown][] = [];
const contributions = buildAgentControlContributions( const contributions = buildAgentControlContributions(
makeSource({ makeSource({
optionControls: { features: {
controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")], list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
options: { fast_mode: false, auto_accept: true }, set: (id, value) => {
onSetOption: (id, value) => {
calls.push([id, value]); calls.push([id, value]);
}, },
}, },
@@ -383,7 +376,7 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default", selectedId: "default",
defaultModeId: "default", defaultModeId: "default",
}, },
optionControls: { controls: [toggleControl("fast_mode")], options: {} }, features: { list: [toggleFeature("fast_mode")] },
}), }),
); );
const ranks = new Map<string, number>(); const ranks = new Map<string, number>();
@@ -1,10 +1,9 @@
import type { import type {
AgentFeature,
AgentMode, AgentMode,
AgentOptionControl,
AgentProvider, AgentProvider,
AgentSelectOption, AgentSelectOption,
} from "@getpaseo/protocol/agent-types"; } from "@getpaseo/protocol/agent-types";
import { readToggleOption, type ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { import {
@@ -62,7 +61,7 @@ export interface AgentControlContributionIcons {
thinking: CommandCenterIcon; thinking: CommandCenterIcon;
planMode: CommandCenterIcon; planMode: CommandCenterIcon;
mode(modeId: string): CommandCenterIcon; mode(modeId: string): CommandCenterIcon;
control(control: AgentOptionControl): CommandCenterIcon; feature(feature: AgentFeature): CommandCenterIcon;
} }
interface CommandCenterChoice { interface CommandCenterChoice {
@@ -106,7 +105,10 @@ export interface AgentControlContributionSource {
defaultModeId: string | null; defaultModeId: string | null;
select(modeId: string): void | Promise<void>; select(modeId: string): void | Promise<void>;
}; };
optionControls: ComposerOptionControls; features: {
list: readonly AgentFeature[];
set(featureId: string, value: unknown): void | Promise<void>;
};
} }
function buildContributions( function buildContributions(
@@ -240,19 +242,18 @@ function toggleChoices(input: {
} }
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls; const planFeature = source.features.list.find(
const planControl = controls.find( (feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
(control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle",
); );
let choices: CommandCenterChoice[] = []; let choices: CommandCenterChoice[] = [];
if (planControl?.type === "toggle") { if (planFeature?.type === "toggle") {
choices = toggleChoices({ choices = toggleChoices({
source, source,
key: "plan", key: "plan",
icon: source.icons.control(planControl), icon: source.icons.feature(planFeature),
isOn: readToggleOption(planControl, options), isOn: planFeature.value,
turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true), turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false), turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false),
}); });
} else if (source.modes) { } else if (source.modes) {
const modes = source.modes; const modes = source.modes;
@@ -279,19 +280,18 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent
} }
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls; const fastFeature = source.features.list.find(
const fastControl = controls.find( (feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
(control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle",
); );
const choices = const choices =
fastControl?.type === "toggle" fastFeature?.type === "toggle"
? toggleChoices({ ? toggleChoices({
source, source,
key: "fast", key: "fast",
icon: source.icons.control(fastControl), icon: source.icons.feature(fastFeature),
isOn: readToggleOption(fastControl, options), isOn: fastFeature.value,
turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true), turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false), turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false),
}) })
: []; : [];
return { return {
@@ -1,6 +1,11 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types"; import type {
AgentFeature,
AgentMode,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { import {
getAgentFeatureIcon, getAgentFeatureIcon,
@@ -9,7 +14,6 @@ import {
ThinkingIcon, ThinkingIcon,
} from "@/agent-controls/icons"; } from "@/agent-controls/icons";
import { useProviderIcons } from "@/components/provider-icons"; import { useProviderIcons } from "@/components/provider-icons";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { import {
buildAgentControlContributions, buildAgentControlContributions,
@@ -40,15 +44,15 @@ export interface AgentControlCommandCenterSource {
selectedId: string | null | undefined; selectedId: string | null | undefined;
select(modeId: string): void | Promise<void>; select(modeId: string): void | Promise<void>;
}; };
/** Absent when the surface cannot change options (the agent has no client). */ features: {
optionControls: ComposerOptionControls | null; list: readonly AgentFeature[] | undefined;
set?: (featureId: string, value: unknown) => void | Promise<void>;
};
} }
const NO_OPTION_CONTROLS: ComposerOptionControls = { function ignoreAgentFeatureChange(): void {
controls: [], return;
options: {}, }
onSetOption: () => undefined,
};
function resolveDefaultModeId( function resolveDefaultModeId(
provider: string | null | undefined, provider: string | null | undefined,
@@ -83,7 +87,7 @@ export function useAgentControlCommandCenterActions(input: {
}): void { }): void {
const { t } = useTranslation(); const { t } = useTranslation();
const { controls } = input; const { controls } = input;
const { optionControls, models, modes, thinking } = controls; const { features, models, modes, thinking } = controls;
const getProviderIcon = useProviderIcons(controls.serverId); const getProviderIcon = useProviderIcons(controls.serverId);
const actions = useMemo( const actions = useMemo(
() => () =>
@@ -100,7 +104,7 @@ export function useAgentControlCommandCenterActions(input: {
getCommandCenterIcon( getCommandCenterIcon(
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions), getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
), ),
control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)), feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
}, },
models: { models: {
providers: models.providers, providers: models.providers,
@@ -119,7 +123,10 @@ export function useAgentControlCommandCenterActions(input: {
modes?.select, modes?.select,
resolveDefaultModeId(controls.provider, controls.providerDefinitions), resolveDefaultModeId(controls.provider, controls.providerDefinitions),
), ),
optionControls: optionControls ?? NO_OPTION_CONTROLS, features: {
list: features.set ? (features.list ?? []) : [],
set: features.set ?? ignoreAgentFeatureChange,
},
}), }),
[ [
getProviderIcon, getProviderIcon,
@@ -127,7 +134,8 @@ export function useAgentControlCommandCenterActions(input: {
controls.provider, controls.provider,
controls.providerDefinitions, controls.providerDefinitions,
controls.serverId, controls.serverId,
optionControls, features.list,
features.set,
models.providers, models.providers,
models.select, models.select,
models.selectedModelId, models.selectedModelId,
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -92,7 +93,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store"; import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes"; import { buildNewWorkspaceRoute } from "@/utils/host-routes";
interface AddProjectFlowProps { interface AddProjectFlowProps {
request: AddProjectFlowRequest; request: AddProjectFlowRequest;
@@ -325,6 +326,7 @@ function setPageStatus(
// The product flow is intentionally one cohesive page-stack state machine. // The product flow is intentionally one cohesive page-stack state machine.
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const addHost = useAddHost();
const hosts = useHosts(); const hosts = useHosts();
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds); const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
@@ -616,9 +618,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
icon: Plus, icon: Plus,
testID: "add-project-flow-add-host", testID: "add-project-flow-add-host",
select: () => { select: () => {
// The native project Modal must release its window before the root sheet opens.
onClose(); onClose();
usePanelStore.getState().showMobileAgent(); addHost();
router.push(buildSettingsAddHostRoute(Date.now()));
}, },
}); });
} }
@@ -717,6 +719,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
directoryPaths, directoryPaths,
githubQuery.data, githubQuery.data,
host, host,
addHost,
onClose, onClose,
openAddedProject, openAddedProject,
page, page,
+4 -9
View File
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router"; import { router } from "expo-router";
import { import {
CircleGauge, CircleGauge,
@@ -62,7 +63,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation"; import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage"; import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -177,14 +178,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
router.push(buildSettingsRoute()); router.push(buildSettingsRoute());
}, []); }, []);
const handleAddHostMobile = useCallback(() => { const handleAddHostMobile = useAddHost();
showMobileAgent(); const handleAddHostDesktop = handleAddHostMobile;
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleOpenHostSettingsMobile = useCallback( const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => { (serverId: string) => {
@@ -130,6 +130,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
); );
}); });
it("renders a table directly after a paragraph through the library parser", async () => {
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const flags = { hardSoftBreaks: true, latexMath: false };
const expected = await parseMarkdown(separated, flags);
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
});
it.each([
[
"a quote",
"> Notes\n> | Item |\n> | --- |\n> | A |",
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item",
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quoted list item",
"> - Notes\n> | Item |\n> | --- |\n> | A |",
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
],
[
"a quote inside a list item",
"- > Notes\n > | Item |\n > | --- |\n > | A |",
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
],
[
"aligned columns without outer pipes",
"Notes\nItem | Detail\n:--- | ---:\nA | one",
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
["Document", "Paragraph", "Table"],
],
[
"an entity at the start of a header without outer pipes",
"Notes\nAT&amp;T | Detail\n--- | ---\nA | one",
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"a local image at the start of a header without outer pipes",
"Notes\n![Local](assets/a.png) | Detail\n--- | ---\nA | one",
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"CRLF lines",
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
["Document", "Paragraph", "Table"],
],
[
"CR lines",
"Notes\r| Item |\r| --- |\r| A |",
"Notes\r\r| Item |\r| --- |\r| A |",
["Document", "Paragraph", "Table"],
],
[
"a quote with CRLF lines",
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CRLF lines",
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quote with CR lines",
"> Notes\r> | Item |\r> | --- |\r> | A |",
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CR lines",
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a non-interrupting ordered marker with tildes",
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
["Document", "Paragraph", "Table"],
],
[
"a non-interrupting ordered marker with backticks",
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
["Document", "Paragraph", "Table"],
],
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const flags = { hardSoftBreaks: true, latexMath: false };
function blocks(node: ASTNode): unknown {
if (node.type === "Paragraph" || node.type === "Table") return node.type;
return [node.type, ...(node.children ?? []).map(blocks)];
}
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
});
it.each([
[
"a literal backslash",
"Notes\\\n| Item |\n| --- |\n| A |",
"Notes\\\n\n| Item |\n| --- |\n| A |",
"Notes\\",
],
[
"an entity and literal backslash with LF lines",
"AT&amp;T\\\n| Item |\n| --- |\n| A |",
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CRLF lines",
"AT&amp;T\\\r\n| Item |\r\n| --- |\r\n| A |",
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CR lines",
"AT&amp;T\\\r| Item |\r| --- |\r| A |",
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
"AT&T\\",
],
])(
"preserves paragraph text before an interrupting table in %s",
async (_name, source, separated, text) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
},
);
it.each([
"```yaml\n---\nkey: value\n---\n```\n",
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
"> ```md\n> | Item |\n> | --- |\n> ```\n",
"> ```md\n> Code\n",
"- ```md\n Code\n",
" ```\n\n",
"```not`a-fence\n",
])("renders a table after a fence without changing its code: %s", async (prefix) => {
const table = "| Item |\n| --- |\n| A |";
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
});
it.each([
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item |\n| --- |\n| A |",
"| Item |\n| --- |\n| A |",
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
"```yaml\n---\nkey: value\n---\n```",
"```yaml\n---\nkey: value",
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
"````md\n```\n| Item |\n| --- |\n```\n````",
" Notes\n | Item |\n | --- |\n | A |",
"Notes\n| Item | Detail |\n| --- |\n| A |",
])("leaves separated tables and table-like text unchanged: %s", (source) => {
expect(prepareMarkdownSource(source).render()).toEqual(source);
});
it.each([ it.each([
"src/components/message.tsx", "src/components/message.tsx",
"C:\\repo\\file.ts", "C:\\repo\\file.ts",
+24 -5
View File
@@ -138,6 +138,10 @@ export function prepareMarkdownSource(
!source.includes("![") && !source.includes("![") &&
!source.includes("||") && !source.includes("||") &&
!source.includes("&") && !source.includes("&") &&
// Match content, header, and delimiter lines; quote-only lines count as blank.
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
source,
) &&
!(workspaceRoot && source.includes("](")) !(workspaceRoot && source.includes("]("))
) { ) {
return { images, ...metadata, render: () => source }; return { images, ...metadata, render: () => source };
@@ -212,11 +216,25 @@ export function prepareMarkdownSource(
if (lineEnd < end) split(lineEnd); if (lineEnd < end) split(lineEnd);
} }
} }
function visit(node: Nodes) { function visit(node: Nodes, previous?: Nodes) {
const start = node.position?.start.offset; const position = node.position;
const end = node.position?.end.offset; if (position?.start.offset !== undefined && position.end.offset !== undefined) {
if (start !== undefined && end !== undefined) { const start = position.start.offset;
const end = position.end.offset;
if (node.type === "paragraph") splitStandaloneImages(node, start, end); if (node.type === "paragraph") splitStandaloneImages(node, start, end);
if (
node.type === "table" &&
previous?.type === "paragraph" &&
previous.position?.end.line === position.start.line - 1
) {
// MD4C only recognizes a table header on the first line of a paragraph.
const lineStart =
Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1;
const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, "");
const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd();
const boundary = lineStart - newline.length;
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
}
if (node.type === "link") { if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot); const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null) if (title !== null)
@@ -244,7 +262,8 @@ export function prepareMarkdownSource(
if (text !== source.slice(start, end)) edits.push({ start, end, text }); if (text !== source.slice(start, end)) edits.push({ start, end, text });
} }
} }
if ("children" in node) node.children.forEach(visit); if ("children" in node)
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
} }
visit(getTree()); visit(getTree());
return { return {
+7 -48
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useAddHost } from "@/hosts/add-host";
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native"; import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
@@ -12,11 +13,7 @@ import {
Terminal, Terminal,
} from "lucide-react-native"; } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version"; import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -162,20 +159,13 @@ function useAnyHostOnline(serverIds: string[]): string | null {
); );
} }
export interface WelcomeScreenProps { export function WelcomeScreen() {
onHostAdded?: (profile: HostProfile) => void;
}
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion); const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const hosts = useHosts(); const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
@@ -196,23 +186,10 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
router.push("/settings"); router.push("/settings");
}, [router]); }, [router]);
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []); const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []); const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []); const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []); const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
router.push("/pair-scan?source=onboarding");
}, [router]);
const handleHostSaved = useCallback(
({ profile }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding();
},
[onHostAdded, finishOnboarding],
);
const actions: WelcomeAction[] = const actions: WelcomeAction[] =
isWeb || isFdroidBuild isWeb || isFdroidBuild
@@ -316,24 +293,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
</Button> </Button>
</View> </View>
<Text style={styles.versionLabel}>{appVersionText}</Text> <Text style={styles.versionLabel}>{appVersionText}</Text>
<AddHostModal
visible={isDirectOpen}
onClose={handleCloseDirect}
onSaved={handleHostSaved}
/>
<AddRemoteSshHostModal
visible={isRemoteSshOpen}
onClose={handleCloseRemoteSsh}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={handleClosePasteLink}
onSaved={handleHostSaved}
/>
</ScrollView> </ScrollView>
</View> </View>
); );
+224 -211
View File
@@ -25,13 +25,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { Settings2 } from "lucide-react-native"; import { Settings2 } from "lucide-react-native";
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons"; import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
type ComposerOptionControls,
} from "@/agent-controls/option-controls";
import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { CombinedModelSelector } from "@/components/combined-model-selector"; import { CombinedModelSelector } from "@/components/combined-model-selector";
@@ -54,8 +47,7 @@ import {
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { import type {
AgentOptionControl, AgentFeature,
ProviderOptions,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentProvider, AgentProvider,
@@ -63,6 +55,8 @@ import type {
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { import {
getFeatureHighlightColor, getFeatureHighlightColor,
isFeatureActive,
getFeatureTooltip,
getAgentControlHintKey, getAgentControlHintKey,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/composer/agent-controls/utils"; } from "@/composer/agent-controls/utils";
@@ -107,7 +101,6 @@ interface AgentControlOption {
type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = []; const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
const EMPTY_OPTIONS: ProviderOptions = {};
interface ControlledAgentControlsProps { interface ControlledAgentControlsProps {
provider: string; provider: string;
@@ -129,7 +122,8 @@ interface ControlledAgentControlsProps {
onEditAgentProfiles?: () => void; onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
onEditAgentProfile?: (profileId: string) => void; onEditAgentProfile?: (profileId: string) => void;
optionControls?: ComposerOptionControls; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void; onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void; onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -156,7 +150,8 @@ export interface DraftAgentControlsProps {
selectedThinkingOptionId: string; selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void; onSelectThinkingOption: (thinkingOptionId: string) => void;
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"]; onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
optionControls?: ComposerOptionControls; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void; onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void; onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -217,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
} }
function getFeatureIconColor( function getFeatureIconColor(
control: AgentOptionControl, feature: AgentFeature,
options: ProviderOptions,
palette: { palette: {
blue: { 400: string }; blue: { 400: string };
green: { 400: string }; green: { 400: string };
@@ -226,11 +220,11 @@ function getFeatureIconColor(
}, },
foregroundMuted: string, foregroundMuted: string,
): string { ): string {
if (!isControlActive(control, options)) { if (!isFeatureActive(feature)) {
return foregroundMuted; return foregroundMuted;
} }
switch (getFeatureHighlightColor(control.key)) { switch (getFeatureHighlightColor(feature.id)) {
case "blue": case "blue":
return palette.blue[400]; return palette.blue[400];
case "green": case "green":
@@ -248,20 +242,20 @@ function resolveHasAnyControl({
providerOptions, providerOptions,
canSelectModel, canSelectModel,
thinkingOptions, thinkingOptions,
controls, features,
hasMode, hasMode,
}: { }: {
providerOptions: AgentControlOption[] | undefined; providerOptions: AgentControlOption[] | undefined;
canSelectModel: boolean; canSelectModel: boolean;
thinkingOptions: AgentControlOption[] | undefined; thinkingOptions: AgentControlOption[] | undefined;
controls: readonly AgentOptionControl[] | undefined; features: AgentFeature[] | undefined;
hasMode: boolean; hasMode: boolean;
}) { }) {
return ( return (
Boolean(providerOptions?.length) || Boolean(providerOptions?.length) ||
canSelectModel || canSelectModel ||
Boolean(thinkingOptions?.length) || Boolean(thinkingOptions?.length) ||
Boolean(controls?.length) || Boolean(features?.length) ||
hasMode hasMode
); );
} }
@@ -399,8 +393,7 @@ type AgentControlsSlice = {
cwd: string | null; cwd: string | null;
runtimeModelId: string | null; runtimeModelId: string | null;
model: string | null | undefined; model: string | null | undefined;
options: ProviderOptions | undefined; features: AgentFeature[] | undefined;
controls: AgentOptionControl[] | undefined;
runtimeThinkingOptionId: string | null; runtimeThinkingOptionId: string | null;
thinkingOptionId: string | null | undefined; thinkingOptionId: string | null | undefined;
lastUsage: unknown; lastUsage: unknown;
@@ -420,8 +413,7 @@ function selectAgentControlsSlice(
cwd: currentAgent.cwd, cwd: currentAgent.cwd,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null, runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model, model: currentAgent.model,
options: currentAgent.options, features: currentAgent.features,
controls: currentAgent.controls,
runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null, runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null,
thinkingOptionId: currentAgent.thinkingOptionId, thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage, lastUsage: currentAgent.lastUsage,
@@ -501,7 +493,8 @@ function ControlledAgentControls({
onEditAgentProfiles, onEditAgentProfiles,
onCreateAgentProfile, onCreateAgentProfile,
onEditAgentProfile, onEditAgentProfile,
optionControls, features,
onSetFeature,
onDropdownClose, onDropdownClose,
onModelSelectorOpen, onModelSelectorOpen,
onRetryModelProvider, onRetryModelProvider,
@@ -553,21 +546,20 @@ function ControlledAgentControls({
providerOptions, providerOptions,
canSelectModel, canSelectModel,
thinkingOptions, thinkingOptions,
controls: optionControls?.controls, features,
hasMode: modeControl !== null && modeControl !== undefined, hasMode: modeControl !== null && modeControl !== undefined,
}); });
const featureControls = useMemo( const featureControls = useMemo(
() => () =>
(optionControls?.controls ?? []).map((control) => { (features ?? []).map((feature) => {
if (control.type === "toggle") return { type: "toggle" as const }; if (feature.type === "toggle") return { type: "toggle" as const };
const selected = readSelectOption(control, optionControls?.options ?? {}); const selectedOption = feature.options.find((option) => option.id === feature.value);
const choice = control.choices.find((candidate) => candidate.value === selected);
return { return {
type: "select" as const, type: "select" as const,
label: choice?.label ?? control.label, label: selectedOption?.label ?? feature.label,
}; };
}), }),
[optionControls], [features],
); );
const controlPresence = useMemo( const controlPresence = useMemo(
() => ({ () => ({
@@ -751,7 +743,8 @@ function ControlledAgentControls({
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
thinkingOptions={formattedThinkingOptions} thinkingOptions={formattedThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId} selectedThinkingOptionId={selectedThinkingOptionId}
optionControls={optionControls} features={features}
onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile} onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles} onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile} onCreateAgentProfile={onCreateAgentProfile}
@@ -799,7 +792,8 @@ function ControlledAgentControls({
provider={provider} provider={provider}
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
selectedThinkingOptionId={selectedThinkingOptionId} selectedThinkingOptionId={selectedThinkingOptionId}
optionControls={optionControls} features={features}
onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile} onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles} onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile} onCreateAgentProfile={onCreateAgentProfile}
@@ -844,7 +838,8 @@ interface DesktopAgentControlsContentProps {
selectedModelId?: string; selectedModelId?: string;
thinkingOptions?: AgentControlOption[]; thinkingOptions?: AgentControlOption[];
selectedThinkingOptionId?: string; selectedThinkingOptionId?: string;
optionControls?: ComposerOptionControls; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void; onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void; onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
@@ -905,7 +900,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
selectedModelId, selectedModelId,
thinkingOptions, thinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
optionControls, features,
onSetFeature,
onApplyAgentProfile, onApplyAgentProfile,
onEditAgentProfiles, onEditAgentProfiles,
onCreateAgentProfile, onCreateAgentProfile,
@@ -1061,7 +1057,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
{modeControl ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null} {modeControl ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null}
{presentation.aggregateFeatures && optionControls?.controls.length ? ( {presentation.aggregateFeatures && features?.length ? (
<> <>
<Pressable <Pressable
onPress={handleOpenFeatures} onPress={handleOpenFeatures}
@@ -1081,29 +1077,27 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onClose={handleCloseSheet} onClose={handleCloseSheet}
testID="agent-features-sheet" testID="agent-features-sheet"
> >
{optionControls.controls.map((control) => ( {features.map((feature) => (
<SheetFeatureItem <SheetFeatureItem
key={`feature-${control.key}`} key={`feature-${feature.id}`}
control={control} feature={feature}
options={optionControls.options}
disabled={disabled} disabled={disabled}
openSelector={openSelector} openSelector={openSelector}
handleOpenChange={handleNestedOpenChange} handleOpenChange={handleNestedOpenChange}
onSetOption={optionControls.onSetOption} onSetFeature={onSetFeature}
/> />
))} ))}
</AdaptiveModalSheet> </AdaptiveModalSheet>
</> </>
) : ( ) : (
optionControls?.controls.map((control) => ( features?.map((feature) => (
<DesktopFeatureItem <DesktopFeatureItem
key={`feature-${control.key}`} key={`feature-${feature.id}`}
control={control} feature={feature}
options={optionControls.options}
disabled={disabled} disabled={disabled}
openSelector={openSelector} openSelector={openSelector}
handleOpenChange={handleOpenChange} handleOpenChange={handleOpenChange}
onSetOption={optionControls.onSetOption} onSetFeature={onSetFeature}
onActionComplete={onDropdownClose} onActionComplete={onDropdownClose}
/> />
)) ))
@@ -1116,7 +1110,8 @@ interface SheetAgentControlsContentProps {
provider: string; provider: string;
selectedModelId?: string; selectedModelId?: string;
selectedThinkingOptionId?: string; selectedThinkingOptionId?: string;
optionControls?: ComposerOptionControls; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void; onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void; onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
@@ -1159,7 +1154,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
provider, provider,
selectedModelId, selectedModelId,
selectedThinkingOptionId, selectedThinkingOptionId,
optionControls, features,
onSetFeature,
onApplyAgentProfile, onApplyAgentProfile,
onEditAgentProfiles, onEditAgentProfiles,
onCreateAgentProfile, onCreateAgentProfile,
@@ -1192,7 +1188,6 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
} = props; } = props;
const thinkingAnchorRef = useRef<View | null>(null); const thinkingAnchorRef = useRef<View | null>(null);
const sheetOptions = optionControls?.options ?? EMPTY_OPTIONS;
const hasThinking = comboboxThinkingOptions.length > 0; const hasThinking = comboboxThinkingOptions.length > 0;
@@ -1243,15 +1238,14 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
{modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null} {modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null}
{(optionControls?.controls ?? []).map((control) => ( {(features ?? []).map((feature) => (
<SheetFeatureItem <SheetFeatureItem
key={`feature-${control.key}`} key={`feature-${feature.id}`}
control={control} feature={feature}
options={sheetOptions}
disabled={disabled} disabled={disabled}
openSelector={openSelector} openSelector={openSelector}
handleOpenChange={handleOpenChange} handleOpenChange={handleOpenChange}
onSetOption={optionControls?.onSetOption} onSetFeature={onSetFeature}
/> />
))} ))}
</View> </View>
@@ -1285,24 +1279,22 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
} }
function DesktopFeatureItem({ function DesktopFeatureItem({
control, feature,
options,
disabled, disabled,
openSelector, openSelector,
handleOpenChange, handleOpenChange,
onSetOption, onSetFeature,
onActionComplete, onActionComplete,
}: { }: {
control: AgentOptionControl; feature: AgentFeature;
options: ProviderOptions;
disabled: boolean; disabled: boolean;
openSelector: AgentControlSelector | null; openSelector: AgentControlSelector | null;
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
onSetOption?: (key: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
onActionComplete?: () => void; onActionComplete?: () => void;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const featureSelector: AgentControlSelector = `feature-${control.key}`; const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null); const featureAnchorRef = useRef<View>(null);
const handleFeatureOpenChange = useMemo( const handleFeatureOpenChange = useMemo(
@@ -1315,117 +1307,120 @@ function DesktopFeatureItem({
); );
const handleTogglePress = useCallback(() => { const handleTogglePress = useCallback(() => {
if (control.type === "toggle") { if (feature.type === "toggle") {
onSetOption?.(control.key, !readToggleOption(control, options)); onSetFeature?.(feature.id, !feature.value);
onActionComplete?.(); onActionComplete?.();
} }
}, [control, onActionComplete, onSetOption, options]); }, [feature, onActionComplete, onSetFeature]);
const handleSelectOption = useCallback( const handleSelectOption = useCallback(
(choiceValue: string) => { (optionId: string) => {
onSetOption?.(control.key, choiceValue); onSetFeature?.(feature.id, optionId);
}, },
[control.key, onSetOption], [feature.id, onSetFeature],
); );
const comboboxOptions = useMemo<ComboboxOption[]>( const comboboxOptions = useMemo<ComboboxOption[]>(
() => () =>
control.type === "select" feature.type === "select"
? control.choices.map((choice) => ({ id: choice.value, label: choice.label })) ? feature.options.map((option) => ({ id: option.id, label: option.label }))
: [], : [],
[control], [feature],
); );
const FeatureIcon = getAgentFeatureIcon(control.icon); if (feature.type === "toggle") {
const iconColor = getFeatureIconColor( const FeatureIcon = getAgentFeatureIcon(feature.icon);
control,
options,
theme.colors.palette,
theme.colors.foregroundMuted,
);
if (control.type === "toggle") {
return ( return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref"> <TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger <AgentControlTrigger
icon={FeatureIcon} icon={FeatureIcon}
iconColor={iconColor} iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar" surface="toolbar"
label={control.label} label={feature.label}
showToolbarLabel={false} showToolbarLabel={false}
disabled={disabled} disabled={disabled}
onPress={handleTogglePress} onPress={handleTogglePress}
accessibilityLabel={getControlTooltip(control)} accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${control.key}`} testID={`agent-feature-${feature.id}`}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getControlTooltip(control)}</Text> <Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
} }
const selectedValue = readSelectOption(control, options); if (feature.type === "select") {
const selectedChoice = control.choices.find((choice) => choice.value === selectedValue); const FeatureIcon = getAgentFeatureIcon(feature.icon);
const iconOnly = control.desktopTrigger === "icon"; const selectedOption = feature.options.find((o) => o.id === feature.value);
const tooltip = iconOnly const iconOnly = feature.desktopTrigger === "icon";
? `${control.label}: ${selectedChoice?.label ?? control.label}` const tooltip = iconOnly
: getControlTooltip(control); ? `${feature.label}: ${selectedOption?.label ?? feature.label}`
return ( : getFeatureTooltip(feature);
<> return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <>
<TooltipTrigger asChild triggerRefProp="ref"> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<AgentControlTrigger <TooltipTrigger asChild triggerRefProp="ref">
ref={featureAnchorRef} <AgentControlTrigger
icon={FeatureIcon} ref={featureAnchorRef}
iconColor={iconColor} icon={FeatureIcon}
surface="toolbar" iconColor={getFeatureIconColor(
label={control.label} feature,
value={selectedChoice?.label ?? control.label} theme.colors.palette,
showToolbarLabel={!iconOnly} theme.colors.foregroundMuted,
open={openSelector === featureSelector} )}
disabled={disabled} surface="toolbar"
onPress={handleSelectPress} label={feature.label}
accessibilityLabel={tooltip} value={selectedOption?.label ?? feature.label}
testID={`agent-feature-${control.key}`} showToolbarLabel={!iconOnly}
/> open={openSelector === featureSelector}
</TooltipTrigger> disabled={disabled}
<TooltipContent side="top" align="center" offset={8}> onPress={handleSelectPress}
<Text style={styles.tooltipText}>{tooltip}</Text> accessibilityLabel={tooltip}
</TooltipContent> testID={`agent-feature-${feature.id}`}
</Tooltip> />
<Combobox </TooltipTrigger>
options={comboboxOptions} <TooltipContent side="top" align="center" offset={8}>
value={String(selectedValue)} <Text style={styles.tooltipText}>{tooltip}</Text>
onSelect={handleSelectOption} </TooltipContent>
open={openSelector === featureSelector} </Tooltip>
onOpenChange={handleFeatureOpenChange} <Combobox
anchorRef={featureAnchorRef} options={comboboxOptions}
desktopPlacement="top-start" value={String(feature.value)}
/> onSelect={handleSelectOption}
</> open={openSelector === featureSelector}
); onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
desktopPlacement="top-start"
/>
</>
);
}
return null;
} }
function SheetFeatureItem({ function SheetFeatureItem({
control, feature,
options,
disabled, disabled,
openSelector, openSelector,
handleOpenChange, handleOpenChange,
onSetOption, onSetFeature,
}: { }: {
control: AgentOptionControl; feature: AgentFeature;
options: ProviderOptions;
disabled: boolean; disabled: boolean;
openSelector: AgentControlSelector | null; openSelector: AgentControlSelector | null;
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
onSetOption?: (key: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const featureSelector: AgentControlSelector = `feature-${control.key}`; const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null); const featureAnchorRef = useRef<View>(null);
const handleFeatureOpenChange = useMemo( const handleFeatureOpenChange = useMemo(
@@ -1436,71 +1431,97 @@ function SheetFeatureItem({
() => handleFeatureOpenChange(openSelector !== featureSelector), () => handleFeatureOpenChange(openSelector !== featureSelector),
[featureSelector, handleFeatureOpenChange, openSelector], [featureSelector, handleFeatureOpenChange, openSelector],
); );
const sheetHeader = useMemo<SheetHeader>(() => ({ title: control.label }), [control.label]); const sheetHeader = useMemo<SheetHeader>(() => ({ title: feature.label }), [feature.label]);
const handleSelectOption = useCallback( const handleSelectOption = useCallback(
(choiceValue: string) => { (optionId: string) => {
onSetOption?.(control.key, control.type === "toggle" ? choiceValue === "true" : choiceValue); onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId);
}, },
[control.key, control.type, onSetOption], [feature.id, feature.type, onSetFeature],
); );
const comboboxOptions = useMemo<ComboboxOption[]>(() => { const comboboxOptions = useMemo<ComboboxOption[]>(() => {
if (control.type === "select") { if (feature.type === "select") {
return control.choices.map((choice) => ({ id: choice.value, label: choice.label })); return feature.options.map((option) => ({ id: option.id, label: option.label }));
} }
return [ return [
{ id: "true", label: t("agentControls.features.on") }, { id: "true", label: t("agentControls.features.on") },
{ id: "false", label: t("agentControls.features.off") }, { id: "false", label: t("agentControls.features.off") },
]; ];
}, [control, t]); }, [feature, t]);
const FeatureIcon = getAgentFeatureIcon(control.icon); if (feature.type === "toggle") {
const iconColor = getFeatureIconColor( const FeatureIcon = getAgentFeatureIcon(feature.icon);
control, return (
options, <>
theme.colors.palette, <AgentControlTrigger
theme.colors.foregroundMuted, ref={featureAnchorRef}
); icon={FeatureIcon}
let selectedValue: string; iconColor={getFeatureIconColor(
let displayValue: string; feature,
if (control.type === "toggle") { theme.colors.palette,
const isOn = readToggleOption(control, options); theme.colors.foregroundMuted,
selectedValue = String(isOn); )}
displayValue = isOn ? t("agentControls.features.on") : t("agentControls.features.off"); surface="sheet"
} else { label={feature.label}
const selected = readSelectOption(control, options); value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
selectedValue = String(selected); open={openSelector === featureSelector}
displayValue = disabled={disabled}
control.choices.find((choice) => choice.value === selected)?.label ?? control.label; onPress={handleSelectPress}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
<Combobox
options={comboboxOptions}
value={String(feature.value)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
searchable={false}
header={sheetHeader}
/>
</>
);
} }
return (
<> if (feature.type === "select") {
<AgentControlTrigger const FeatureIcon = getAgentFeatureIcon(feature.icon);
ref={featureAnchorRef} const selectedOption = feature.options.find((o) => o.id === feature.value);
icon={FeatureIcon} return (
iconColor={iconColor} <>
surface="sheet" <AgentControlTrigger
label={control.label} ref={featureAnchorRef}
value={displayValue} icon={FeatureIcon}
open={openSelector === featureSelector} iconColor={getFeatureIconColor(
disabled={disabled} feature,
onPress={handleSelectPress} theme.colors.palette,
accessibilityLabel={getControlTooltip(control)} theme.colors.foregroundMuted,
testID={`agent-feature-${control.key}`} )}
/> surface="sheet"
<Combobox label={feature.label}
options={comboboxOptions} value={selectedOption?.label ?? feature.label}
value={selectedValue} open={openSelector === featureSelector}
onSelect={handleSelectOption} disabled={disabled}
open={openSelector === featureSelector} onPress={handleSelectPress}
onOpenChange={handleFeatureOpenChange} accessibilityLabel={getFeatureTooltip(feature)}
anchorRef={featureAnchorRef} testID={`agent-feature-${feature.id}`}
presentation="push" />
searchable={control.type === "toggle" ? false : undefined} <Combobox
header={sheetHeader} options={comboboxOptions}
/> value={String(feature.value)}
</> onSelect={handleSelectOption}
); open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
header={sheetHeader}
/>
</>
);
}
return null;
} }
function ThinkingComboboxOption({ function ThinkingComboboxOption({
@@ -1528,18 +1549,6 @@ function ThinkingComboboxOption({
); );
} }
function useAgentOptionControls(
agent: { controls?: AgentOptionControl[]; options?: ProviderOptions } | null | undefined,
onSetOption: ComposerOptionControls["onSetOption"],
): ComposerOptionControls {
const controls = agent?.controls;
const options = agent?.options;
return useMemo(
() => ({ controls: controls ?? [], options: options ?? EMPTY_OPTIONS, onSetOption }),
[controls, onSetOption, options],
);
}
export const AgentControls = memo(function AgentControls({ export const AgentControls = memo(function AgentControls({
agentId, agentId,
serverId, serverId,
@@ -1691,8 +1700,8 @@ export const AgentControls = memo(function AgentControls({
[activeModelId, agentId, agentProvider, client, toast, updatePreferences], [activeModelId, agentId, agentProvider, client, toast, updatePreferences],
); );
const handleSetOption = useCallback( const handleSetFeature = useCallback(
(key: string, value: unknown) => { (featureId: string, value: unknown) => {
if (!client || !agentProvider) { if (!client || !agentProvider) {
return; return;
} }
@@ -1701,24 +1710,22 @@ export const AgentControls = memo(function AgentControls({
preferences: current, preferences: current,
provider: agentProvider, provider: agentProvider,
updates: { updates: {
options: { featureValues: {
[key]: value, [featureId]: value,
}, },
}, },
}), }),
).catch((error) => { ).catch((error) => {
console.warn("[AgentControls] persist option preference failed", error); console.warn("[AgentControls] persist feature preference failed", error);
}); });
void client.setAgentOptions(agentId, { [key]: value }).catch((error) => { void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentControls] setAgentOptions failed", error); console.warn("[AgentControls] setAgentFeature failed", error);
toast.error(toErrorMessage(error)); toast.error(toErrorMessage(error));
}); });
}, },
[agentId, agentProvider, client, toast, updatePreferences], [agentId, agentProvider, client, toast, updatePreferences],
); );
const agentOptionControls = useAgentOptionControls(agent, handleSetOption);
const commandCenterControls = useMemo<AgentControlCommandCenterSource>( const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
() => ({ () => ({
serverId, serverId,
@@ -1737,18 +1744,21 @@ export const AgentControls = memo(function AgentControls({
select: handleSelectThinkingOption, select: handleSelectThinkingOption,
}, },
modes: commandCenterModes, modes: commandCenterModes,
optionControls: client ? agentOptionControls : null, features: {
list: agent?.features,
set: handleSetFeature,
},
}), }),
[ [
activeModelId, activeModelId,
agentOptionControls, agent?.features,
client,
agentId, agentId,
agentModelSelectorProviders, agentModelSelectorProviders,
agentProvider, agentProvider,
commandCenterModes, commandCenterModes,
handleSelectCommandCenterModel, handleSelectCommandCenterModel,
handleSelectThinkingOption, handleSelectThinkingOption,
handleSetFeature,
modeProviderDefinitions, modeProviderDefinitions,
modelSelection.selectedThinkingId, modelSelection.selectedThinkingId,
modelSelection.thinkingOptions, modelSelection.thinkingOptions,
@@ -1797,7 +1807,8 @@ export const AgentControls = memo(function AgentControls({
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined} thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={handleSelectThinkingOption} onSelectThinkingOption={handleSelectThinkingOption}
optionControls={agentOptionControls} features={agent.features}
onSetFeature={handleSetFeature}
isModelLoading={snapshotIsLoading || selectedProviderIsLoading} isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={handleModelSelectorOpen} onModelSelectorOpen={handleModelSelectorOpen}
onRetryModelProvider={handleRetryModelProvider} onRetryModelProvider={handleRetryModelProvider}
@@ -1829,7 +1840,8 @@ export function DraftAgentControls({
selectedThinkingOptionId, selectedThinkingOptionId,
onSelectThinkingOption, onSelectThinkingOption,
onApplyAgentProfile, onApplyAgentProfile,
optionControls, features,
onSetFeature,
onDropdownClose, onDropdownClose,
onModelSelectorOpen, onModelSelectorOpen,
onRetryModelProvider, onRetryModelProvider,
@@ -1915,7 +1927,8 @@ export function DraftAgentControls({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption} selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption} onSelectThinkingOption={onSelectThinkingOption}
optionControls={optionControls} features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen} onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider} onRetryModelProvider={onRetryModelProvider}
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
getFeatureHighlightColor, getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHintKey, getAgentControlHintKey,
isFeatureActive,
normalizeModelId, normalizeModelId,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "./utils"; } from "./utils";
@@ -15,12 +17,51 @@ describe("getAgentControlHintKey", () => {
}); });
describe("feature metadata helpers", () => { describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => { it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow"); expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("service_tier")).toBe("yellow"); expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue"); expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default"); expect(getFeatureHighlightColor("other")).toBe("default");
}); });
it("treats a select feature as active when a non-default option is selected", () => {
const speed = {
type: "select" as const,
id: "service_tier",
label: "Speed",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
};
expect(isFeatureActive({ ...speed, value: "default" })).toBe(false);
expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true);
expect(
isFeatureActive({
type: "toggle",
id: "fast_mode",
label: "Fast",
value: true,
}),
).toBe(true);
});
}); });
describe("normalizeModelId", () => { describe("normalizeModelId", () => {
@@ -1,4 +1,4 @@
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types"; import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import { import {
@@ -35,6 +35,16 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized; return normalized;
} }
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function isFeatureActive(feature: AgentFeature): boolean {
if (feature.type === "toggle") return feature.value;
const selectedOption = feature.options.find((option) => option.id === feature.value);
return selectedOption !== undefined && !selectedOption.isDefault;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor { export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) { switch (featureId) {
case FAST_MODE_FEATURE_ID: case FAST_MODE_FEATURE_ID:
@@ -20,11 +20,12 @@ export function resolveDraftKey(input: {
export function buildDraftAgentControls(input: { export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult; formState: UseAgentFormStateResult;
optionControls?: DraftAgentControlsProps["optionControls"]; features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"]; onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"]; onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
}): DraftAgentControlsProps { }): DraftAgentControlsProps {
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input; const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
return { return {
providerDefinitions: formState.providerDefinitions, providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider, selectedProvider: formState.selectedProvider,
@@ -42,7 +43,8 @@ export function buildDraftAgentControls(input: {
selectedThinkingOptionId: formState.selectedThinkingOptionId, selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser, onSelectThinkingOption: formState.setThinkingOptionFromUser,
onApplyAgentProfile, onApplyAgentProfile,
optionControls, features,
onSetFeature,
onDropdownClose, onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale, onModelSelectorOpen: formState.refetchProviderModelsIfStale,
onRetryModelProvider: formState.refreshProviderModels, onRetryModelProvider: formState.refreshProviderModels,
+25 -16
View File
@@ -8,7 +8,7 @@ import {
type CreateAgentInitialValues, type CreateAgentInitialValues,
type UseAgentFormStateResult, type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state"; } from "@/hooks/use-agent-form-state";
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { import {
buildDraftAgentControls, buildDraftAgentControls,
hasDraftContent, hasDraftContent,
@@ -35,7 +35,7 @@ type AttachmentUpdater =
interface AgentInputDraftComposerOptions { interface AgentInputDraftComposerOptions {
initialServerId: string | null; initialServerId: string | null;
initialValues?: CreateAgentInitialValues; initialValues?: CreateAgentInitialValues;
initialProviderOptions?: Record<string, unknown>; initialFeatureValues?: Record<string, unknown>;
isVisible?: boolean; isVisible?: boolean;
lockedWorkingDir?: string; lockedWorkingDir?: string;
} }
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveModeId: string; effectiveModeId: string;
effectiveModelId: string; effectiveModelId: string;
effectiveThinkingOptionId: string; effectiveThinkingOptionId: string;
providerOptions: Record<string, unknown> | undefined; featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps; agentControls: DraftAgentControlsProps;
commandDraft: DraftCommandTarget; commandDraft: DraftCommandTarget;
}; };
@@ -274,25 +274,26 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
); );
const { const {
optionControls: draftOptionControls, features: draftFeatures,
options: draftOptions, featureValues: draftFeatureValues,
applyProfile: applyProfileOptions, setFeatureValue: setDraftFeatureValue,
} = useDraftProviderOptions({ applyProfileFeatureValues,
} = useDraftAgentFeatures({
serverId: formState.selectedServerId, serverId: formState.selectedServerId,
provider: formState.selectedProvider, provider: formState.selectedProvider,
cwd: workingDir, cwd: workingDir,
modeId: formState.selectedMode, modeId: formState.selectedMode,
modelId: effectiveModelId, modelId: effectiveModelId,
thinkingOptionId: effectiveThinkingOptionId, thinkingOptionId: effectiveThinkingOptionId,
initialOptions: composerOptions?.initialProviderOptions, initialFeatureValues: composerOptions?.initialFeatureValues,
}); });
const applyDraftAgentProfile = useCallback( const applyDraftAgentProfile = useCallback(
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => { (profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
formState.applyProfileFromUser(profile); formState.applyProfileFromUser(profile);
applyProfileOptions({ provider: profile.provider, options: profile.options }); applyProfileFeatureValues(profile.featureValues);
}, },
[applyProfileOptions, formState], [applyProfileFeatureValues, formState],
); );
const commandDraft = useMemo( const commandDraft = useMemo(
@@ -302,9 +303,15 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
cwd: workingDir, cwd: workingDir,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
providerOptions: draftOptions, featureValues: draftFeatureValues,
}), }),
[effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir], [
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
providerSelection,
workingDir,
],
); );
const composerState = useMemo<DraftComposerState | null>(() => { const composerState = useMemo<DraftComposerState | null>(() => {
@@ -318,10 +325,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId, effectiveModeId,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
providerOptions: draftOptions, featureValues: draftFeatureValues,
agentControls: buildDraftAgentControls({ agentControls: buildDraftAgentControls({
formState, formState,
optionControls: draftOptionControls, features: draftFeatures,
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile, onApplyAgentProfile: applyDraftAgentProfile,
}), }),
commandDraft, commandDraft,
@@ -332,10 +340,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId, effectiveModeId,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
draftOptionControls, draftFeatures,
draftOptions, draftFeatureValues,
applyDraftAgentProfile, applyDraftAgentProfile,
formState, formState,
setDraftFeatureValue,
workingDir, workingDir,
]); ]);
@@ -1,4 +1,3 @@
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -66,7 +65,7 @@ interface AutoSubmitConfig {
modeId: string | null; modeId: string | null;
model: string | null; model: string | null;
thinkingOptionId: string | null; thinkingOptionId: string | null;
providerOptions: Record<string, unknown>; featureValues: Record<string, unknown>;
} }
function resolveAutoSubmitConfig( function resolveAutoSubmitConfig(
@@ -75,7 +74,7 @@ function resolveAutoSubmitConfig(
modeId?: string | null; modeId?: string | null;
model?: string | null; model?: string | null;
thinkingOptionId?: string | null; thinkingOptionId?: string | null;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
} | null, } | null,
): AutoSubmitConfig | null { ): AutoSubmitConfig | null {
if (!pending) return null; if (!pending) return null;
@@ -84,7 +83,7 @@ function resolveAutoSubmitConfig(
modeId: pending.modeId ?? null, modeId: pending.modeId ?? null,
model: pending.model ?? null, model: pending.model ?? null,
thinkingOptionId: pending.thinkingOptionId ?? null, thinkingOptionId: pending.thinkingOptionId ?? null,
providerOptions: pending.providerOptions ?? {}, featureValues: pending.featureValues ?? {},
}; };
} }
@@ -132,7 +131,7 @@ async function submitDraftCreateRequest(input: {
effectiveModeId: string; effectiveModeId: string;
effectiveModelId: string | null; effectiveModelId: string | null;
effectiveThinkingOptionId: string | null; effectiveThinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined; featureValues: Record<string, unknown> | undefined;
}; };
hostDisconnectedMessage: string; hostDisconnectedMessage: string;
selectModelMessage: string; selectModelMessage: string;
@@ -171,7 +170,7 @@ async function submitDraftCreateRequest(input: {
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined), model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId: thinkingOptionId:
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined), autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
providerOptions: autoSubmitConfig?.providerOptions ?? composerState.providerOptions, featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
}); });
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
@@ -205,7 +204,7 @@ function buildDraftAgentSnapshot(input: {
effectiveThinkingOptionId: string | null; effectiveThinkingOptionId: string | null;
effectiveModeId: string; effectiveModeId: string;
selectedProvider: string | null; selectedProvider: string | null;
agentControls: { optionControls?: ComposerOptionControls }; agentControls: { features?: Agent["features"] };
}; };
selectModelMessage: string; selectModelMessage: string;
}): Agent { }): Agent {
@@ -242,8 +241,7 @@ function buildDraftAgentSnapshot(input: {
title: "Agent", title: "Agent",
cwd: workspaceDirectory, cwd: workspaceDirectory,
model, model,
options: composerState.agentControls.optionControls?.options, features: composerState.agentControls.features,
controls: composerState.agentControls.optionControls?.controls,
thinkingOptionId, thinkingOptionId,
parentAgentId: null, parentAgentId: null,
labels: {}, labels: {},
@@ -335,7 +333,7 @@ export function WorkspaceDraftAgentTab({
composer: { composer: {
initialServerId: serverId, initialServerId: serverId,
initialValues: draftInitialValues, initialValues: draftInitialValues,
initialProviderOptions: draftSetup?.providerOptions, initialFeatureValues: draftSetup?.featureValues,
isVisible: true, isVisible: true,
lockedWorkingDir: draftWorkingDirectory ?? undefined, lockedWorkingDir: draftWorkingDirectory ?? undefined,
}, },
@@ -353,7 +351,8 @@ export function WorkspaceDraftAgentTab({
const draftModeOptions = composerState.modeOptions; const draftModeOptions = composerState.modeOptions;
const draftSelectedMode = composerState.selectedMode; const draftSelectedMode = composerState.selectedMode;
const draftSetMode = composerState.setModeFromUser; const draftSetMode = composerState.setModeFromUser;
const draftOptionControls = composerState.agentControls.optionControls; const draftFeatures = composerState.agentControls.features;
const draftOnSetFeature = composerState.agentControls.onSetFeature;
const clearDraftInput = draftInput.clear; const clearDraftInput = draftInput.clear;
const replaceDraftText = draftInput.replaceText; const replaceDraftText = draftInput.replaceText;
@@ -522,7 +521,10 @@ export function WorkspaceDraftAgentTab({
selectedId: draftSelectedMode, selectedId: draftSelectedMode,
select: draftSetMode, select: draftSetMode,
}, },
optionControls: draftOptionControls ?? null, features: {
list: draftFeatures,
set: draftOnSetFeature,
},
}, },
}); });
const isReadyForPendingAutoSubmit = Boolean( const isReadyForPendingAutoSubmit = Boolean(
@@ -80,7 +80,7 @@ describe("create agent preferences", () => {
modelId: "gpt-5.5", modelId: "gpt-5.5",
modeId: "full-access", modeId: "full-access",
thinkingOptionId: "high", thinkingOptionId: "high",
options: { fast_mode: true }, featureValues: { fast_mode: true },
}), }),
); );
@@ -95,7 +95,7 @@ describe("create agent preferences", () => {
model: "gpt-5.5", model: "gpt-5.5",
mode: "full-access", mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" }, thinkingByModel: { "gpt-5.5": "high" },
options: { fast_mode: true }, featureValues: { fast_mode: true },
}, },
}, },
}); });
@@ -119,7 +119,7 @@ describe("create agent preferences", () => {
model: "gpt-5.6", model: "gpt-5.6",
mode: undefined, mode: undefined,
thinkingByModel: undefined, thinkingByModel: undefined,
options: undefined, featureValues: undefined,
}, },
}), }),
).toEqual({ ).toEqual({
@@ -167,13 +167,13 @@ describe("create agent preferences", () => {
modelId: "one-minute-stream", modelId: "one-minute-stream",
modeId: "approval-test", modeId: "approval-test",
thinkingOptionId: "", thinkingOptionId: "",
options: {}, featureValues: {},
}), }),
).toEqual({ ).toEqual({
provider: "mock", provider: "mock",
providerPreferences: { providerPreferences: {
pi: { model: "anthropic/sonnet" }, pi: { model: "anthropic/sonnet" },
mock: { model: "one-minute-stream", mode: "approval-test", options: {} }, mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} },
}, },
}); });
}); });
@@ -208,29 +208,6 @@ describe("create agent preferences", () => {
}); });
}); });
it("reads preferences saved as featureValues as options", () => {
expect(
parseFormPreferences({
provider: "codex",
providerPreferences: { codex: { featureValues: { service_tier: "priority" } } },
}),
).toEqual({
provider: "codex",
providerPreferences: { codex: { options: { service_tier: "priority" } } },
});
});
it("keeps options that are not booleans or strings, such as a profile's sandbox settings", () => {
const options = {
sandbox_workspace_write: { network_access: true, writable_roots: ["/tmp"] },
max_turns: 3,
};
expect(
parseFormPreferences({ provider: "codex", providerPreferences: { codex: { options } } }),
).toEqual({ provider: "codex", providerPreferences: { codex: { options } } });
});
it("rejects unknown persisted fields outside the explicit legacy shape", () => { it("rejects unknown persisted fields outside the explicit legacy shape", () => {
expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({}); expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({});
}); });
@@ -1,14 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
const optionsSchema = z.record(z.string(), z.json()); const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()]));
export interface ProviderPreferences { export interface ProviderPreferences {
model?: string; model?: string;
mode?: string; mode?: string;
thinkingByModel?: Record<string, string>; thinkingByModel?: Record<string, string>;
/** Last-used values of the provider's option controls, by option key. */ featureValues?: Record<string, unknown>;
options?: Record<string, unknown>;
} }
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string }; export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
@@ -21,23 +20,12 @@ export interface FormPreferences {
launchTarget?: LaunchTarget; launchTarget?: LaunchTarget;
} }
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Preferences written const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.strictObject({
// before the rename stored the same record as `featureValues`. model: z.string().optional(),
function renameLegacyFeatureValues(value: unknown): unknown { mode: z.string().optional(),
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value; thinkingByModel: z.record(z.string(), z.string()).optional(),
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>; featureValues: featureValuesSchema.optional(),
return "options" in rest ? rest : { ...rest, options: featureValues }; });
}
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.preprocess(
renameLegacyFeatureValues,
z.strictObject({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string(), z.string()).optional(),
options: optionsSchema.optional(),
}),
);
const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [ const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("chat") }), z.strictObject({ kind: z.literal("chat") }),
@@ -128,7 +116,7 @@ function applyProviderPreferenceUpdates(
): ProviderPreferences { ): ProviderPreferences {
const next: ProviderPreferences = { ...existing }; const next: ProviderPreferences = { ...existing };
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel); const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
const nextOptions = mergeDefinedRecord(existing.options, updates.options); const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues);
if (updates.model !== undefined) { if (updates.model !== undefined) {
next.model = updates.model; next.model = updates.model;
@@ -141,8 +129,8 @@ function applyProviderPreferenceUpdates(
if (nextThinkingByModel !== undefined) { if (nextThinkingByModel !== undefined) {
next.thinkingByModel = nextThinkingByModel; next.thinkingByModel = nextThinkingByModel;
} }
if (nextOptions !== undefined) { if (nextFeatureValues !== undefined) {
next.options = nextOptions; next.featureValues = nextFeatureValues;
} }
return next; return next;
@@ -173,7 +161,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
modelId?: string | null; modelId?: string | null;
modeId?: string | null; modeId?: string | null;
thinkingOptionId?: string | null; thinkingOptionId?: string | null;
options?: Record<string, unknown>; featureValues?: Record<string, unknown>;
}): FormPreferences { }): FormPreferences {
if (!args.provider) { if (!args.provider) {
return args.preferences; return args.preferences;
@@ -182,7 +170,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
const modelId = args.modelId?.trim() ?? ""; const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? ""; const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? ""; const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
const options = optionsSchema.safeParse(args.options); const featureValues = featureValuesSchema.safeParse(args.featureValues);
return mergeProviderPreferences({ return mergeProviderPreferences({
preferences: args.preferences, preferences: args.preferences,
@@ -191,7 +179,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
model: modelId || undefined, model: modelId || undefined,
mode: args.modeId === undefined ? undefined : modeId || null, mode: args.modeId === undefined ? undefined : modeId || null,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(options.success ? { options: options.data } : {}), ...(featureValues.success ? { featureValues: featureValues.data } : {}),
}, },
}); });
} }
@@ -204,7 +192,7 @@ export function applyAgentProfilePreferences(args: {
modelId: string; modelId: string;
modeId: string; modeId: string;
thinkingOptionId: string; thinkingOptionId: string;
options: Record<string, unknown>; featureValues: Record<string, unknown>;
}): FormPreferences { }): FormPreferences {
let next = args.preferences; let next = args.preferences;
if (args.previousProvider) { if (args.previousProvider) {
@@ -227,7 +215,7 @@ export function applyAgentProfilePreferences(args: {
...(args.modelId && args.thinkingOptionId ...(args.modelId && args.thinkingOptionId
? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } } ? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } }
: {}), : {}),
options: args.options, featureValues: args.featureValues,
}, },
}); });
} }
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolveFeatureValues } from "./feature-preferences";
describe("feature-preferences", () => {
const features = [
{
type: "toggle" as const,
id: "fast_mode",
label: "Fast",
value: false,
},
{
type: "toggle" as const,
id: "plan_mode",
label: "Plan",
value: false,
},
];
it("restores persisted values for available features", () => {
expect(
resolveFeatureValues({
features,
persistedFeatureValues: {
fast_mode: true,
unknown_feature: true,
},
localFeatureValues: {},
}),
).toEqual({
fast_mode: true,
});
});
it("prefers local values over persisted values", () => {
expect(
resolveFeatureValues({
features,
persistedFeatureValues: {
fast_mode: true,
plan_mode: false,
},
localFeatureValues: {
fast_mode: false,
},
}),
).toEqual({
fast_mode: false,
plan_mode: false,
});
});
});
@@ -0,0 +1,60 @@
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
export function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
for (const [featureId, value] of Object.entries(featureValues)) {
if (!allowedFeatureIds.has(featureId)) {
changed = true;
continue;
}
next[featureId] = value;
}
return changed ? next : featureValues;
}
export function applyFeatureValues(
features: AgentFeature[],
featureValues: Record<string, unknown>,
): AgentFeature[] {
if (Object.keys(featureValues).length === 0) {
return features;
}
return features.map((feature) => {
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
return feature;
}
return {
...feature,
value: featureValues[feature.id],
} as AgentFeature;
});
}
export function resolveFeatureValues(args: {
features: AgentFeature[];
persistedFeatureValues: Record<string, unknown>;
localFeatureValues: Record<string, unknown>;
}): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const feature of args.features) {
if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) {
next[feature.id] = args.localFeatureValues[feature.id];
continue;
}
if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) {
next[feature.id] = args.persistedFeatureValues[feature.id];
}
}
return next;
}
@@ -128,14 +128,14 @@ function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig
const modeId = draftConfig.modeId?.trim() ?? ""; const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? ""; const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const providerOptions = draftConfig.providerOptions; const featureValues = draftConfig.featureValues;
return { return {
provider: draftConfig.provider, provider: draftConfig.provider,
cwd, cwd,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(model ? { model } : {}), ...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}),
...(providerOptions && Object.keys(providerOptions).length > 0 ? { providerOptions } : {}), ...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
}; };
} }
@@ -352,7 +352,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
modelId: model, modelId: model,
modeId, modeId,
thinkingOptionId, thinkingOptionId,
options: profile.options, featureValues: profile.featureValues,
}); });
}).catch((error) => { }).catch((error) => {
console.warn("[useAgentFormState] persist profile preference failed", error); console.warn("[useAgentFormState] persist profile preference failed", error);
@@ -1,6 +1,9 @@
import { useRef } from "react"; import { useRef } from "react";
import type { AgentCapabilityFlags, AgentProvider } from "@getpaseo/protocol/agent-types"; import type {
import type { Agent } from "@/stores/session-store"; AgentCapabilityFlags,
AgentFeature,
AgentProvider,
} from "@getpaseo/protocol/agent-types";
import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync"; import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync";
export interface AgentScreenAgent { export interface AgentScreenAgent {
@@ -19,8 +22,7 @@ export interface AgentScreenAgent {
modeId?: string | null; modeId?: string | null;
thinkingOptionId?: string | null; thinkingOptionId?: string | null;
} | null; } | null;
options?: Agent["options"]; features?: readonly AgentFeature[];
controls?: Agent["controls"];
lastError?: string | null; lastError?: string | null;
projectPlacement?: { projectPlacement?: {
projectKey?: string; projectKey?: string;
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { AgentProvider, AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
import {
applyFeatureValues,
pruneFeatureValues,
resolveFeatureValues,
} from "./feature-preferences";
type DraftFeatureConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
>;
export function useDraftAgentFeatures(input: {
serverId: string | null | undefined;
provider: AgentProvider | null;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
initialFeatureValues?: Record<string, unknown>;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
input;
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
() => initialFeatureValues ?? {},
);
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || "";
const normalizedProvider = provider ?? null;
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
const persistedFeatureValues = useMemo(
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
[preferences.providerPreferences, provider],
);
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedProvider || !normalizedCwd) {
return null;
}
return {
provider: normalizedProvider,
cwd: normalizedCwd,
...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
const featuresQuery = useQuery({
queryKey: [
"providerFeatures",
serverId ?? null,
normalizedProvider,
normalizedCwd || null,
modeId ?? null,
modelId ?? null,
thinkingOptionId ?? null,
],
enabled: Boolean(serverId && client && isConnected && draftConfig),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderFeatures(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
},
});
const availableFeaturesRaw = featuresQuery.data;
const availableFeatures = useMemo(() => availableFeaturesRaw ?? [], [availableFeaturesRaw]);
const featureValues = useMemo(
() =>
resolveFeatureValues({
features: availableFeatures,
persistedFeatureValues,
localFeatureValues,
}),
[availableFeatures, localFeatureValues, persistedFeatureValues],
);
const features = useMemo(() => {
return applyFeatureValues(availableFeatures, featureValues);
}, [availableFeatures, featureValues]);
useEffect(() => {
const previousProvider = previousProviderRef.current;
previousProviderRef.current = normalizedProvider;
if (previousProvider === null) {
return;
}
if (previousProvider !== normalizedProvider) {
setLocalFeatureValues({});
}
}, [normalizedProvider]);
useEffect(() => {
if (availableFeaturesRaw === undefined) {
return;
}
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
if (next !== localFeatureValues) {
setLocalFeatureValues(next);
}
}, [availableFeatures, availableFeaturesRaw, localFeatureValues]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback(
(featureId: string, value: unknown) => {
setLocalFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
return { ...current, [featureId]: value };
});
if (!provider) {
return;
}
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider,
updates: {
featureValues: {
[featureId]: value,
},
},
}),
).catch((error) => {
console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
});
},
[provider, updatePreferences],
);
const applyProfileFeatureValues = useCallback((values: Record<string, unknown>) => {
setLocalFeatureValues(values);
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
applyProfileFeatureValues,
};
}
@@ -1,109 +0,0 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AgentProvider, ProviderOptions } from "@getpaseo/protocol/agent-types";
import {
draftControlsRequest,
openDraftOptions,
ownDraftOptions,
viewDraftOptions,
} from "@/agent-controls/draft-options-model";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
/**
* Adapter between a draft's options model and the screen: it sends the model's complete options
* to the daemon, hands the answer back, and persists the user's toggles as last-used values.
*/
export function useDraftProviderOptions(input: {
serverId: string | null | undefined;
provider: AgentProvider | null;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
initialOptions?: ProviderOptions;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || "";
const persisted = preferences.providerPreferences?.[provider ?? ""]?.options;
useEffect(() => model.observeProvider(provider), [model, provider]);
useEffect(() => model.setRemembered(persisted ?? {}), [model, persisted]);
const ownOptions = ownDraftOptions(state);
const draftConfig = useMemo(
() =>
draftControlsRequest(state, {
provider,
cwd: normalizedCwd,
modeId,
modelId,
thinkingOptionId,
}),
[modeId, modelId, normalizedCwd, provider, state, thinkingOptionId],
);
const queryKey = useMemo(
() => ["providerControls", serverId ?? null, draftConfig],
[draftConfig, serverId],
);
const requestKey = JSON.stringify(queryKey);
const { data } = useFetchQuery({
queryKey,
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(serverId && client && isConnected && draftConfig),
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderControls(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return { controls: payload.controls ?? [], options: payload.options ?? {} };
},
});
useEffect(() => {
if (provider && data) model.applyResolved({ provider, requestKey, ...data });
}, [data, model, provider, requestKey]);
const setOption = useCallback(
(key: string, value: unknown) => {
model.setOption(key, value);
if (!provider) return;
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider,
updates: { options: { [key]: value } },
}),
).catch((error) => {
console.warn("[useDraftProviderOptions] persist option preference failed", error);
});
},
[model, provider, updatePreferences],
);
const view = viewDraftOptions(state, { provider, requestKey });
const viewControls = view?.controls;
const viewOptions = view?.options;
const optionControls = useMemo<ComposerOptionControls | undefined>(
() =>
viewControls && viewOptions
? { controls: viewControls, options: viewOptions, onSetOption: setOption }
: undefined,
[setOption, viewControls, viewOptions],
);
return { optionControls, options: ownOptions, applyProfile: model.applyProfile };
}
+7 -2
View File
@@ -36,7 +36,7 @@ export type ForkAgentSource = Pick<
| "model" | "model"
| "thinkingOptionId" | "thinkingOptionId"
| "runtimeInfo" | "runtimeInfo"
| "options" | "features"
| "projectPlacement" | "projectPlacement"
>; >;
@@ -95,13 +95,18 @@ function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | u
return undefined; return undefined;
} }
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return { return {
provider: agent.provider, provider: agent.provider,
cwd: agent.cwd, cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null, model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
providerOptions: { ...agent.options }, featureValues,
}; };
} }
@@ -63,7 +63,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: { providerPreferences: {
codex: { codex: {
model: "gpt-5.4", model: "gpt-5.4",
options: { featureValues: {
fast_mode: true, fast_mode: true,
}, },
}, },
@@ -71,7 +71,7 @@ describe("mergeProviderPreferences", () => {
}, },
provider: "codex", provider: "codex",
updates: { updates: {
options: { featureValues: {
plan_mode: true, plan_mode: true,
}, },
}, },
@@ -81,7 +81,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: { providerPreferences: {
codex: { codex: {
model: "gpt-5.4", model: "gpt-5.4",
options: { featureValues: {
fast_mode: true, fast_mode: true,
plan_mode: true, plan_mode: true,
}, },
+89
View File
@@ -0,0 +1,89 @@
import { createContext, useCallback, useContext, useState, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useStore } from "zustand";
import { AddHostMethodModal } from "./internal/add-host-method-modal";
import { AddHostModal } from "./internal/add-host-modal";
import { AddRemoteSshHostModal } from "./internal/add-remote-ssh-host-modal";
import { PairLinkModal } from "./internal/pair-link-modal";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { PairScanContent } from "./internal/scan";
import { createAddHostFlow, type AddHostOptions, type AddHostSession } from "./internal/model";
const Context = createContext<ReturnType<typeof createAddHostFlow> | null>(null);
/** Owns the flow outside screen and appearance lifetimes. Callers own success navigation. */
export function AddHostProvider({ children }: { children: ReactNode }) {
const [flow] = useState(createAddHostFlow);
return <Context.Provider value={flow}>{children}</Context.Provider>;
}
export function AddHostFlowHost() {
const flow = useContext(Context);
if (!flow) throw new Error("AddHostFlowHost requires AddHostProvider");
const session = useStore(flow.store, (state) => state.session);
return session ? <AddHostSessionView key={session.id} session={session} /> : null;
}
export function useAddHost({ method, onAdded }: AddHostOptions = {}) {
const flow = useContext(Context);
if (!flow) throw new Error("useAddHost requires AddHostProvider");
return useCallback(() => flow.open({ method, onAdded }), [flow, method, onAdded]);
}
function AddHostSessionView({ session }: { session: AddHostSession }) {
const { t } = useTranslation();
const method = useStore(session.step, (state) => state.method);
const back = useCallback(() => session.choose(null), [session]);
const direct = useCallback(() => session.choose("direct"), [session]);
const ssh = useCallback(() => session.choose("ssh"), [session]);
const link = useCallback(() => session.choose("link"), [session]);
const scan = useCallback(() => session.choose("scan"), [session]);
const scanned = useCallback((serverId: string) => session.complete({ serverId }), [session]);
const header = useMemo(
() => ({ title: t("pairing.scan.title"), back: { onPress: back } }),
[t, back],
);
return (
<>
<AddHostMethodModal
visible={method === null}
onClose={session.close}
onDirectConnection={direct}
onRemoteSsh={ssh}
onPasteLink={link}
onScanQr={scan}
/>
<AddHostModal
visible={method === "direct"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<AddRemoteSshHostModal
visible={method === "ssh"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<PairLinkModal
visible={method === "link"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
{method === "scan" ? (
<AdaptiveModalSheet
header={header}
visible
onClose={session.close}
scrollable={false}
snapPoints={SCAN_SNAP_POINTS}
desktopHeight="85%"
>
<PairScanContent onConnected={scanned} onClose={back} />
</AdaptiveModalSheet>
) : null}
</>
);
}
const SCAN_SNAP_POINTS = ["90%"];
@@ -1,9 +1,10 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { isFdroidBuild } from "@/constants/build-profile"; import { isFdroidBuild } from "@/constants/build-profile";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
@@ -84,7 +85,7 @@ export function AddHostMethodModal({
onClose={onClose} onClose={onClose}
testID="add-host-method-modal" testID="add-host-method-modal"
> >
<Pressable <PressHighlight
style={styles.option} style={styles.option}
onPress={handleDirect} onPress={handleDirect}
accessibilityRole="button" accessibilityRole="button"
@@ -98,10 +99,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.direct.description")} {t("pairing.connectionMethods.direct.description")}
</Text> </Text>
</View> </View>
</Pressable> </PressHighlight>
{isElectronRuntime() ? ( {isElectronRuntime() ? (
<Pressable <PressHighlight
style={styles.option} style={styles.option}
onPress={handleRemoteSsh} onPress={handleRemoteSsh}
accessibilityRole="button" accessibilityRole="button"
@@ -115,11 +116,11 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.remoteSsh.description")} {t("pairing.connectionMethods.remoteSsh.description")}
</Text> </Text>
</View> </View>
</Pressable> </PressHighlight>
) : null} ) : null}
{isNative && !isFdroidBuild ? ( {isNative && !isFdroidBuild ? (
<Pressable <PressHighlight
style={styles.option} style={styles.option}
onPress={handleScan} onPress={handleScan}
accessibilityRole="button" accessibilityRole="button"
@@ -132,10 +133,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.scanQr.description")} {t("pairing.connectionMethods.scanQr.description")}
</Text> </Text>
</View> </View>
</Pressable> </PressHighlight>
) : null} ) : null}
<Pressable <PressHighlight
style={styles.option} style={styles.option}
onPress={handlePaste} onPress={handlePaste}
accessibilityRole="button" accessibilityRole="button"
@@ -149,7 +150,7 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.pasteLink.description")} {t("pairing.connectionMethods.pasteLink.description")}
</Text> </Text>
</View> </View>
</Pressable> </PressHighlight>
</AdaptiveModalSheet> </AdaptiveModalSheet>
); );
} }
@@ -1,17 +1,22 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native"; import { Alert, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints"; import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "./connection-failure-copy"; import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials"; import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { buildDirectConnectionUri } from "./direct-connection-host"; import { buildDirectConnectionUri } from "@/components/direct-connection-host";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -188,7 +193,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
} }
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const daemons = useHosts(); const daemons = useHosts();
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations(); const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
@@ -207,10 +211,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true)); const advancedTarget = useRef(new PairingTargetTracker("", true));
const connectIcon = useMemo( const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []); const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []); const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo( const checkboxStyle = useMemo(
@@ -415,8 +416,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsAdvancedOpen(false); setIsAdvancedOpen(false);
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]); }, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight; const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye; const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
@@ -430,7 +431,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={styles.portRow}> <View style={styles.portRow}>
<View style={hostFieldStyle}> <View style={hostFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text> <Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<AdaptiveTextInput <ThemedAdaptiveTextInput
testID="direct-host-input" testID="direct-host-input"
nativeID="direct-host-input" nativeID="direct-host-input"
accessibilityLabel={t("pairing.direct.fields.host")} accessibilityLabel={t("pairing.direct.fields.host")}
@@ -438,7 +439,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-${inputResetKey}`} resetKey={`direct-host-${inputResetKey}`}
onChangeText={setHost} onChangeText={setHost}
placeholder="localhost" placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input} style={styles.input}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -449,7 +449,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View> </View>
<View style={portFieldStyle}> <View style={portFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text> <Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<AdaptiveTextInput <ThemedAdaptiveTextInput
testID="direct-port-input" testID="direct-port-input"
nativeID="direct-port-input" nativeID="direct-port-input"
accessibilityLabel={t("pairing.direct.fields.port")} accessibilityLabel={t("pairing.direct.fields.port")}
@@ -457,7 +457,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-port-${inputResetKey}`} resetKey={`direct-port-${inputResetKey}`}
onChangeText={setPort} onChangeText={setPort}
placeholder="6767" placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input} style={styles.input}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -469,7 +468,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View> </View>
</View> </View>
<Pressable <PressHighlight
style={styles.checkboxRow} style={styles.checkboxRow}
onPress={handleToggleUseTls} onPress={handleToggleUseTls}
disabled={isSaving} disabled={isSaving}
@@ -482,17 +481,17 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={checkboxStyle}> <View style={checkboxStyle}>
{useTls ? ( {useTls ? (
<View testID="direct-ssl-toggle-checked"> <View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.accentForeground} /> <ThemedCheck size={14} />
</View> </View>
) : null} ) : null}
</View> </View>
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text> <Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</Pressable> </PressHighlight>
<View style={styles.field}> <View style={styles.field}>
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text> <Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}> <View style={styles.passwordRow}>
<AdaptiveTextInput <ThemedAdaptiveTextInput
testID="direct-password-input" testID="direct-password-input"
nativeID="direct-password-input" nativeID="direct-password-input"
accessibilityLabel={t("pairing.direct.fields.password")} accessibilityLabel={t("pairing.direct.fields.password")}
@@ -500,7 +499,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-password-${inputResetKey}`} resetKey={`direct-password-${inputResetKey}`}
onChangeText={setPassword} onChangeText={setPassword}
placeholder={t("pairing.direct.fields.optional")} placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle} style={passwordInputStyle}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -509,7 +507,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
returnKeyType="done" returnKeyType="done"
onSubmitEditing={handleSubmitEditing} onSubmitEditing={handleSubmitEditing}
/> />
<Pressable <PressHighlight
style={styles.iconButton} style={styles.iconButton}
onPress={handleTogglePasswordVisibility} onPress={handleTogglePasswordVisibility}
disabled={isSaving} disabled={isSaving}
@@ -521,13 +519,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
} }
testID="direct-password-visibility-toggle" testID="direct-password-visibility-toggle"
> >
<PasswordIcon size={18} color={theme.colors.foregroundMuted} /> <PasswordIcon size={18} />
</Pressable> </PressHighlight>
</View> </View>
</View> </View>
<View style={styles.field}> <View style={styles.field}>
<Pressable <PressHighlight
style={styles.advancedToggle} style={styles.advancedToggle}
onPress={handleToggleAdvanced} onPress={handleToggleAdvanced}
disabled={isSaving} disabled={isSaving}
@@ -537,11 +535,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
} }
testID="direct-host-advanced-toggle" testID="direct-host-advanced-toggle"
> >
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} /> <AdvancedIcon size={16} />
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text> <Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable> </PressHighlight>
{isAdvancedOpen ? ( {isAdvancedOpen ? (
<AdaptiveTextInput <ThemedAdaptiveTextInput
testID="direct-host-uri-input" testID="direct-host-uri-input"
nativeID="direct-host-uri-input" nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")} accessibilityLabel={t("pairing.direct.fields.connectionUri")}
@@ -549,7 +547,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-uri-${inputResetKey}`} resetKey={`direct-host-uri-${inputResetKey}`}
onChangeText={handleChangeAdvancedUri} onChangeText={handleChangeAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true" placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input} style={styles.input}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -585,3 +582,23 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</AdaptiveModalSheet> </AdaptiveModalSheet>
); );
} }
const ThemedLink2 = withUnistyles(Link2, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedChevronDown = withUnistyles(ChevronDown, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
@@ -1,7 +1,7 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Pressable,
Text, Text,
View, View,
type PressableStateCallbackType, type PressableStateCallbackType,
@@ -23,7 +23,7 @@ import {
import type { EditingTextInputHandle } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal); const ThemedTerminal = withUnistyles(Terminal);
@@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({
} }
clear(); clear();
onClose();
onSaved?.({ onSaved?.({
...result, ...result,
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId), isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
}); });
onClose();
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]); }, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
const handleTargetChange = useCallback((value: string) => { const handleTargetChange = useCallback((value: string) => {
targetRef.current = value; targetRef.current = value;
@@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({
onSubmitEditing={handleSubmit} onSubmitEditing={handleSubmit}
/> />
</View> </View>
<Pressable <PressHighlight
style={iconButtonStyle} style={iconButtonStyle}
onPress={handleTogglePasswordVisibility} onPress={handleTogglePasswordVisibility}
disabled={isSaving} disabled={isSaving}
@@ -273,7 +273,7 @@ export function AddRemoteSshHostModal({
testID="remote-ssh-password-visibility-toggle" testID="remote-ssh-password-visibility-toggle"
> >
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />} {isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
</Pressable> </PressHighlight>
</View> </View>
</Field> </Field>
<View style={styles.actions}> <View style={styles.actions}>
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { createAddHostFlow } from "./model";
describe("add host flow", () => {
it("closes before notifying the consumer and ignores duplicate completion", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({
onAdded: (serverId) => {
expect(flow.store.getState().session).toBeNull();
added.push(serverId);
},
});
const session = flow.store.getState().session!;
session.choose("direct");
session.complete({ serverId: "new-host" });
session.complete({ serverId: "new-host" });
expect(added).toEqual(["new-host"]);
});
it("cancels without a success action and accepts a fresh request", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({ onAdded: (id) => added.push(id) });
const cancelled = flow.store.getState().session!;
cancelled.choose("scan");
cancelled.close();
expect(flow.store.getState().session).toBeNull();
flow.open({ method: "link" });
const current = flow.store.getState().session!;
cancelled.complete({ serverId: "late-result" });
cancelled.close();
expect(flow.store.getState().session).toBe(current);
expect(current.step.getState()).toEqual({ method: "link" });
expect(added).toEqual([]);
current.complete({ serverId: "new-host" });
expect(flow.store.getState().session).toBeNull();
});
it("returns to method selection within the same request", () => {
const flow = createAddHostFlow();
flow.open();
const session = flow.store.getState().session!;
session.choose("ssh");
session.choose(null);
expect(flow.store.getState().session).toBe(session);
expect(session.step.getState()).toEqual({ method: null });
});
});
@@ -0,0 +1,47 @@
import { createStore, type StoreApi } from "zustand/vanilla";
export type AddHostMethod = "direct" | "ssh" | "link" | "scan";
export interface AddHostOptions {
method?: AddHostMethod;
onAdded?: (serverId: string) => void;
}
export interface AddHostSession {
id: number;
step: StoreApi<{ method: AddHostMethod | null }>;
choose(method: AddHostMethod | null): void;
close(): void;
complete(result: { serverId: string }): void;
}
export function createAddHostFlow() {
const store = createStore<{ session: AddHostSession | null }>(() => ({ session: null }));
let nextId = 0;
function createSession(options: AddHostOptions) {
const step = createStore<{ method: AddHostMethod | null }>(() => ({
method: options.method ?? null,
}));
const session = {
id: ++nextId,
step,
choose(method: AddHostMethod | null) {
step.setState({ method });
},
close() {
if (store.getState().session === session) store.setState({ session: null });
},
complete({ serverId }: { serverId: string }) {
if (store.getState().session !== session) return;
session.close();
options.onAdded?.(serverId);
},
};
return session;
}
return {
store,
open(options: AddHostOptions = {}) {
store.setState({ session: createSession(options) });
},
};
}
@@ -1,16 +1,20 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native"; import { Alert, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native"; import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime"; import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints"; import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer"; import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection"; import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
import { PairingTargetTracker } from "./pair-link-credentials"; import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input";
@@ -100,7 +104,6 @@ function PairLinkModalContent({
onCancel, onCancel,
onSaved, onSaved,
}: PairLinkModalProps) { }: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const daemons = useHosts(); const daemons = useHosts();
const { beginLinkPairing } = useHostMutations(); const { beginLinkPairing } = useHostMutations();
@@ -126,10 +129,7 @@ function PairLinkModalContent({
resetPasswordInput(); resetPasswordInput();
}, []); }, []);
const pairIcon = useMemo( const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
() => <Link size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (isSaving) return; if (isSaving) return;
@@ -212,7 +212,7 @@ function PairLinkModalContent({
<View style={styles.field}> <View style={styles.field}>
<Text style={styles.label}>{t("pairing.link.label")}</Text> <Text style={styles.label}>{t("pairing.link.label")}</Text>
<AdaptiveTextInput <ThemedAdaptiveTextInput
ref={inputRef} ref={inputRef}
initialValue={initialUrl} initialValue={initialUrl}
testID="pair-link-input" testID="pair-link-input"
@@ -220,7 +220,6 @@ function PairLinkModalContent({
accessibilityLabel={t("pairing.link.label")} accessibilityLabel={t("pairing.link.label")}
onChangeText={handleChangeOfferUrl} onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..." placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input} style={styles.input}
autoFocus autoFocus
autoCapitalize="none" autoCapitalize="none"
@@ -235,7 +234,7 @@ function PairLinkModalContent({
<Text style={styles.label}> <Text style={styles.label}>
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })} {t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
</Text> </Text>
<AdaptiveTextInput <ThemedAdaptiveTextInput
testID="pair-link-password-input" testID="pair-link-password-input"
resetKey={`pair-link-password-${passwordResetKey}`} resetKey={`pair-link-password-${passwordResetKey}`}
accessibilityLabel={t("pairing.hostPassword.label")} accessibilityLabel={t("pairing.hostPassword.label")}
@@ -274,3 +273,9 @@ function PairLinkModalContent({
</AdaptiveModalSheet> </AdaptiveModalSheet>
); );
} }
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
@@ -1,17 +1,14 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native"; import { Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera"; import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera"; import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { openPairScan } from "@/hosts/pair-scan-model"; import { openPairScan } from "./pair-scan-model";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header"; import { PairLinkModal } from "./pair-link-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
@@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({
}, },
})); }));
export default function PairScanScreen() { export function PairScanContent({
const { theme } = useUnistyles(); onConnected,
onClose,
}: {
onConnected: (serverId: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams<{
source?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const navigateToPairedHost = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId));
return;
}
router.replace(buildSettingsHostRoute(serverId));
},
[router, source],
);
const [scan] = useState(() => const [scan] = useState(() =>
openPairScan({ openPairScan({
importConnectionLink: (link) => importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
getHostRuntimeStore().importConnectionLink( onConnected,
link,
source === "onboarding" ? "hostRoot" : "hostSettings",
),
onConnected: navigateToPairedHost,
}), }),
); );
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState); const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
useEffect(() => () => scan.close(), [scan]); useEffect(() => () => scan.close(), [scan]);
const closeToSource = useCallback(() => {
try {
router.back();
} catch {
router.replace("/" as Href);
}
}, [router]);
useEffect(() => { useEffect(() => {
if (isWeb) return; if (isWeb) return;
if (permission && permission.granted) return; if (permission && permission.granted) return;
@@ -169,35 +142,29 @@ export default function PairScanScreen() {
[scan], [scan],
); );
const handleRouterBack = useCallback(() => router.back(), [router]);
const savePasswordPairing = useCallback( const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId), ({ serverId }: { serverId: string }) => onConnected(serverId),
[navigateToPairedHost], [onConnected],
); );
const handleRequestPermission = useCallback(() => { const handleRequestPermission = useCallback(() => {
void requestPermission(); void requestPermission();
}, [requestPermission]); }, [requestPermission]);
const bodyStyle = useMemo( const bodyStyle = useMemo(
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }], () => [styles.body, { paddingBottom: insets.bottom + 24 }],
[insets.bottom, theme.spacing], [insets.bottom],
);
const helperTextStyle = useMemo(
() => [styles.helperText, { color: theme.colors.foreground }],
[theme.colors.foreground],
); );
if (isWeb) { if (isWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
<View style={bodyStyle}> <View style={bodyStyle}>
<View style={styles.permissionCard}> <View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text> <Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text> <Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
<Pressable style={styles.permissionButton} onPress={closeToSource}> <Button style={styles.permissionButton} onPress={onClose}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text> <Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
</Pressable> </Button>
</View> </View>
</View> </View>
</View> </View>
@@ -208,16 +175,14 @@ export default function PairScanScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
<View style={bodyStyle}> <View style={bodyStyle}>
{!granted ? ( {!granted ? (
<View style={styles.permissionCard}> <View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text> <Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text> <Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}> <Button style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text> <Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
</Pressable> </Button>
</View> </View>
) : ( ) : (
<View style={styles.cameraWrap}> <View style={styles.cameraWrap}>
@@ -235,10 +200,10 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBR]} /> <View style={[styles.corner, styles.cornerBR]} />
</View> </View>
{scanState.status === "pairing" ? ( {scanState.status === "pairing" ? (
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> <Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
) : null} ) : null}
{scanState.status === "stopped" && scanState.error ? ( {scanState.status === "stopped" && scanState.error ? (
<Text style={helperTextStyle}>{scanState.error}</Text> <Text style={styles.helperText}>{scanState.error}</Text>
) : null} ) : null}
{scanState.status === "stopped" ? ( {scanState.status === "stopped" ? (
<Button <Button
+4 -11
View File
@@ -1,12 +1,11 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { import {
EditingTextInput as TextInput, EditingTextInput as TextInput,
type EditingTextInputHandle, type EditingTextInputHandle,
} from "@/components/ui/text-input"; } from "@/components/ui/text-input";
import { router } from "expo-router";
import { Server } from "lucide-react-native"; import { Server } from "lucide-react-native";
import { create } from "zustand"; import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -20,7 +19,6 @@ import {
} from "@/lib/overlay-root"; } from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection"; import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean; type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>; type HostChoiceHandler = (serverId: string) => void | Promise<void>;
@@ -65,6 +63,7 @@ function matchesHostQuery(host: HostProfile, query: string): boolean {
} }
export function useHostChooser() { export function useHostChooser() {
const addHost = useAddHost();
const hosts = useHosts(); const hosts = useHosts();
const localServerId = useLocalDaemonServerId(); const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open); const open = useHostChooserStore((state) => state.open);
@@ -76,13 +75,7 @@ export function useHostChooser() {
); );
if (availableHosts.length === 0) { if (availableHosts.length === 0) {
( (input.onNoHosts ?? addHost)();
input.onNoHosts ??
(() => {
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
})
)();
return false; return false;
} }
@@ -98,7 +91,7 @@ export function useHostChooser() {
}); });
return true; return true;
}, },
[hosts, localServerId, open], [hosts, localServerId, open, addHost],
); );
} }
+4 -8
View File
@@ -118,8 +118,7 @@ interface ChatAgentStateShape {
model?: Agent["model"]; model?: Agent["model"];
thinkingOptionId?: Agent["thinkingOptionId"]; thinkingOptionId?: Agent["thinkingOptionId"];
runtimeInfo?: Agent["runtimeInfo"]; runtimeInfo?: Agent["runtimeInfo"];
options?: Agent["options"]; features?: Agent["features"];
controls?: Agent["controls"];
lastError?: Agent["lastError"] | null; lastError?: Agent["lastError"] | null;
} }
@@ -178,8 +177,7 @@ function selectChatAgentState(
model: agent.model, model: agent.model,
thinkingOptionId: agent.thinkingOptionId, thinkingOptionId: agent.thinkingOptionId,
runtimeInfo: agent.runtimeInfo, runtimeInfo: agent.runtimeInfo,
options: agent.options, features: agent.features,
controls: agent.controls,
lastError: agent.lastError ?? null, lastError: agent.lastError ?? null,
archivedAt: agent.archivedAt ?? null, archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false, requiresAttention: agent.requiresAttention ?? false,
@@ -206,8 +204,7 @@ function buildChatAgentFromState(
model: state.model, model: state.model,
thinkingOptionId: state.thinkingOptionId, thinkingOptionId: state.thinkingOptionId,
runtimeInfo: state.runtimeInfo, runtimeInfo: state.runtimeInfo,
options: state.options, features: state.features,
controls: state.controls,
lastError: state.lastError ?? null, lastError: state.lastError ?? null,
projectPlacement, projectPlacement,
}; };
@@ -640,8 +637,7 @@ function AgentPanelBody({
model: agentState.model, model: agentState.model,
thinkingOptionId: agentState.thinkingOptionId, thinkingOptionId: agentState.thinkingOptionId,
runtimeInfo: agentState.runtimeInfo, runtimeInfo: agentState.runtimeInfo,
options: agentState.options, features: agentState.features,
controls: agentState.controls,
lastError: agentState.lastError ?? null, lastError: agentState.lastError ?? null,
projectPlacement, projectPlacement,
} }
-5
View File
@@ -25,11 +25,6 @@ function findLucideIcon(name: string): LucideIcon | null {
return isComponent(candidate) ? (candidate as LucideIcon) : null; return isComponent(candidate) ? (candidate as LucideIcon) : null;
} }
/** Whether `name` is a Lucide icon, so a caller can fall back before drawing nothing. */
export function isLucideIconName(name: string): boolean {
return findLucideIcon(name) !== null;
}
function isComponent(value: unknown): boolean { function isComponent(value: unknown): boolean {
return ( return (
typeof value === "function" || typeof value === "function" ||
@@ -14,6 +14,45 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
} }
describe("plugin timeline projection", () => { describe("plugin timeline projection", () => {
it.each(["message", "finished", "errored", "permission-required", "closed"] as const)(
"preserves immutable agent-message provenance for %s cards",
(event) => {
const agentMessage = {
event,
sender: { id: "remote::reviewer", title: "QA question-timing #7014/#7000/#6809" },
text: "The original message, including **Markdown**.",
};
const source: StreamItem = {
kind: "tool_call",
id: "message-1",
timestamp: new Date("2026-01-01T00:00:00.000Z"),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "delivery-1",
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: agentMessage.text, icon: "bot" },
agentMessage,
},
},
};
const inspect: TimelineItemTransform = ({ item }) => {
expect(item.type).toBe("tool_call");
if (item.type !== "tool_call") throw new Error("Expected a tool call");
expect(item.agentMessage).toEqual(agentMessage);
expect(item.agentMessage).not.toBe(agentMessage);
expect(Object.isFrozen(item.agentMessage)).toBe(true);
expect(Object.isFrozen(item.agentMessage?.sender)).toBe(true);
return undefined;
};
expect(projectPluginTimelineItems([source], inspect)).toEqual([source]);
expect(Object.isFrozen(agentMessage.sender)).toBe(false);
},
);
it("does not expose mutable tool detail from stream state", () => { it("does not expose mutable tool detail from stream state", () => {
const detail = { type: "read" as const, filePath: "/repo/original.ts" }; const detail = { type: "read" as const, filePath: "/repo/original.ts" };
const source: StreamItem = { const source: StreamItem = {
@@ -22,13 +22,14 @@ function cloneAndFreeze<T>(value: T): T {
} }
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem { function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
const { callId, name, status, error, detail, metadata } = data; const { callId, name, status, error, detail, metadata, agentMessage } = data;
const base = { const base = {
type: "tool_call" as const, type: "tool_call" as const,
callId, callId,
name, name,
detail, detail,
...(metadata ? { metadata } : {}), ...(metadata ? { metadata } : {}),
...(agentMessage ? { agentMessage } : {}),
}; };
switch (status) { switch (status) {
case "running": case "running":
@@ -312,7 +312,7 @@ export function buildDraftCommandTarget(input: {
cwd: string; cwd: string;
effectiveModelId: string; effectiveModelId: string;
effectiveThinkingOptionId: string; effectiveThinkingOptionId: string;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
}): DraftCommandTarget { }): DraftCommandTarget {
const cwd = input.cwd.trim(); const cwd = input.cwd.trim();
if (!cwd) { if (!cwd) {
@@ -332,7 +332,7 @@ export function buildDraftCommandTarget(input: {
...(input.effectiveThinkingOptionId ...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId } ? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}), : {}),
...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), ...(input.featureValues ? { featureValues: input.featureValues } : {}),
}; };
return { status: "ready", config }; return { status: "ready", config };
} }
@@ -284,7 +284,7 @@ describe("mergeSelectedComposerPreferences", () => {
codex: { codex: {
mode: "full-access", mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" }, thinkingByModel: { "gpt-5.4-mini": "medium" },
options: { fast_mode: true }, featureValues: { fast_mode: true },
}, },
claude: { model: "claude-sonnet-4-6" }, claude: { model: "claude-sonnet-4-6" },
}, },
@@ -299,7 +299,7 @@ describe("mergeSelectedComposerPreferences", () => {
model: "gpt-5.4", model: "gpt-5.4",
mode: "full-access", mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" }, thinkingByModel: { "gpt-5.4-mini": "medium" },
options: { fast_mode: true }, featureValues: { fast_mode: true },
}, },
claude: { model: "claude-sonnet-4-6" }, claude: { model: "claude-sonnet-4-6" },
}, },
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import type { import type {
CreateAgentRequestOptions, CreateAgentRequestOptions,
CreateWorkspaceRequestOptions, CreateWorkspaceRequestOptions,
@@ -8,7 +9,6 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { router } from "expo-router";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
@@ -62,7 +62,6 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useFormPreferences } from "@/hooks/use-form-preferences";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
import { import {
getHostProjectId, getHostProjectId,
getWorktreeSupportForHostProject, getWorktreeSupportForHostProject,
@@ -271,7 +270,7 @@ interface WorkspaceDraftSubmissionConfig {
modeId: string | null; modeId: string | null;
model: string | null; model: string | null;
thinkingOptionId: string | null; thinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined; featureValues: Record<string, unknown> | undefined;
target: WorkspaceTabTarget; target: WorkspaceTabTarget;
} }
@@ -374,7 +373,7 @@ function buildWorkspaceDraftSetupFromComposer(input: {
modeId: input.composerState.effectiveModeId || null, modeId: input.composerState.effectiveModeId || null,
model: input.composerState.effectiveModelId || null, model: input.composerState.effectiveModelId || null,
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
providerOptions: input.composerState.providerOptions ?? {}, featureValues: input.composerState.featureValues ?? {},
}; };
} }
@@ -454,7 +453,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
modeId: composerState.effectiveModeId || undefined, modeId: composerState.effectiveModeId || undefined,
model: composerState.effectiveModelId || undefined, model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
providerOptions: composerState.providerOptions, featureValues: composerState.featureValues,
}, },
initialPrompt: text, initialPrompt: text,
clientMessageId: `${input.draftId}:initial-message`, clientMessageId: `${input.draftId}:initial-message`,
@@ -534,7 +533,7 @@ function buildComposerConfig(input: {
return { return {
initialServerId: serverId || null, initialServerId: serverId || null,
initialValues: buildComposerInitialValues({ initialSetup }), initialValues: buildComposerInitialValues({ initialSetup }),
initialProviderOptions: initialSetup?.providerOptions, initialFeatureValues: initialSetup?.featureValues,
isVisible: true, isVisible: true,
lockedWorkingDir: workingDir, lockedWorkingDir: workingDir,
}; };
@@ -567,7 +566,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
modeId: initialSetup.modeId, modeId: initialSetup.modeId,
model: initialSetup.model, model: initialSetup.model,
thinkingOptionId: initialSetup.thinkingOptionId, thinkingOptionId: initialSetup.thinkingOptionId,
providerOptions: initialSetup.providerOptions, featureValues: initialSetup.featureValues,
target: { kind: "draft", draftId, setup: initialSetup }, target: { kind: "draft", draftId, setup: initialSetup },
}; };
} }
@@ -577,7 +576,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
modeId: composerState.effectiveModeId || null, modeId: composerState.effectiveModeId || null,
model: composerState.effectiveModelId || null, model: composerState.effectiveModelId || null,
thinkingOptionId: composerState.effectiveThinkingOptionId || null, thinkingOptionId: composerState.effectiveThinkingOptionId || null,
providerOptions: composerState.providerOptions, featureValues: composerState.featureValues,
target: { kind: "draft", draftId }, target: { kind: "draft", draftId },
}; };
} }
@@ -635,7 +634,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
...(submission.modeId ? { modeId: submission.modeId } : {}), ...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}), ...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}), ...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
...(submission.providerOptions ? { providerOptions: submission.providerOptions } : {}), ...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true, allowEmptyText: true,
agentCreation: input.agentCreation, agentCreation: input.agentCreation,
}); });
@@ -1166,9 +1165,7 @@ export function NewWorkspaceScreen({
openAddProjectPicker(selectedServerId); openAddProjectPicker(selectedServerId);
}, [openAddProjectPicker, selectedServerId]); }, [openAddProjectPicker, selectedServerId]);
const handleAddHost = useCallback(() => { const handleAddHost = useAddHost();
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const openPicker = useCallback(() => { const openPicker = useCallback(() => {
setPickerOpen(true); setPickerOpen(true);
@@ -69,7 +69,7 @@ describe("createWorkspaceAgentInBackground", () => {
modeId: null, modeId: null,
model: null, model: null,
thinkingOptionId: null, thinkingOptionId: null,
providerOptions: {}, featureValues: {},
}, },
}); });
}); });
@@ -89,7 +89,7 @@ describe("createWorkspaceAgentInBackground", () => {
modeId: null, modeId: null,
model: null, model: null,
thinkingOptionId: null, thinkingOptionId: null,
providerOptions: {}, featureValues: {},
}, },
}); });
+8 -97
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAddHost } from "@/hosts/add-host";
import { useCallback, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react"; import type { ComponentType, ReactNode } from "react";
import { import {
Alert, Alert,
@@ -74,10 +75,6 @@ import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
import { PageLayout } from "@/components/page-layout"; import { PageLayout } from "@/components/page-layout";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section"; import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section"; import { EditorSection } from "@/screens/settings/editor-section";
import { FilesSection } from "@/screens/settings/files-section"; import { FilesSection } from "@/screens/settings/files-section";
@@ -1089,21 +1086,15 @@ function SettingsSidebar({
export interface SettingsScreenProps { export interface SettingsScreenProps {
view: SettingsView; view: SettingsView;
openAddHostIntent?: string | null;
} }
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) { export default function SettingsScreen({ view }: SettingsScreenProps) {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const voiceAudioEngine = useVoiceAudioEngineOptional(); const voiceAudioEngine = useVoiceAudioEngineOptional();
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isRemoteSshVisible, setIsRemoteSshVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false); const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null); const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime(); const isDesktopApp = isElectronRuntime();
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion); const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1164,50 +1155,10 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
} }
}, [isPlaybackTestRunning, t, voiceAudioEngine]); }, [isPlaybackTestRunning, t, voiceAudioEngine]);
const closeAddConnectionFlow = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
setIsAddHostMethodVisible(true);
}, []);
const handleAddHost = useCallback(() => {
setIsAddHostMethodVisible(true);
}, []);
useEffect(() => {
if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) {
return;
}
lastOpenedAddHostIntentRef.current = openAddHostIntent;
handleAddHost();
}, [handleAddHost, openAddHostIntent]);
const handleSelectDirectConnection = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
}, []);
const handleSelectRemoteSsh = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsRemoteSshVisible(true);
}, []);
const handleSelectPasteLink = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
}, []);
const handleHostAdded = useCallback( const handleHostAdded = useCallback(
({ serverId }: { serverId: string }) => { (serverId: string) => {
const target = buildSettingsHostSectionRoute(serverId, "connections"); selectSettingsHost(serverId);
const target = buildSettingsHostSectionRoute(serverId, "host");
if (isCompactLayout) { if (isCompactLayout) {
router.push(target); router.push(target);
} else { } else {
@@ -1217,6 +1168,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[isCompactLayout, router], [isCompactLayout, router],
); );
const handleAddHost = useAddHost({ onAdded: handleHostAdded });
const handleSelectSection = useCallback( const handleSelectSection = useCallback(
(section: SettingsSectionSlug) => { (section: SettingsSectionSlug) => {
const target = buildSettingsSectionRoute(section); const target = buildSettingsSectionRoute(section);
@@ -1272,14 +1225,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[activeHostServerId, handleAddHost, isCompactLayout, router], [activeHostServerId, handleAddHost, isCompactLayout, router],
); );
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: { source: "settings" },
});
}, [closeAddConnectionFlow, router]);
const handleHostRemoved = useCallback(() => { const handleHostRemoved = useCallback(() => {
const fallback = buildSettingsSectionRoute("general"); const fallback = buildSettingsSectionRoute("general");
if (isCompactLayout) { if (isCompactLayout) {
@@ -1388,37 +1333,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
); );
} }
const addHostModals = (
<>
<AddHostMethodModal
visible={isAddHostMethodVisible}
onClose={closeAddConnectionFlow}
onDirectConnection={handleSelectDirectConnection}
onRemoteSsh={handleSelectRemoteSsh}
onPasteLink={handleSelectPasteLink}
onScanQr={handleScanQr}
/>
<AddHostModal
visible={isDirectHostVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<AddRemoteSshHostModal
visible={isRemoteSshVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<PairLinkModal
visible={isPasteLinkVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
</>
);
// Mobile root: full-screen sidebar-as-list. // Mobile root: full-screen sidebar-as-list.
if (isCompactLayout && view.kind === "root") { if (isCompactLayout && view.kind === "root") {
return ( return (
@@ -1436,7 +1350,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
layout="mobile" layout="mobile"
/> />
</ScrollView> </ScrollView>
{addHostModals}
</View> </View>
); );
} }
@@ -1447,7 +1360,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
<PageLayout title={detailTitle} onBack={handleBackFromDetail}> <PageLayout title={detailTitle} onBack={handleBackFromDetail}>
{content} {content}
</PageLayout> </PageLayout>
{addHostModals}
</View> </View>
); );
} }
@@ -1482,7 +1394,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
</View> </View>
</WindowChromeRegion> </WindowChromeRegion>
</View> </View>
{addHostModals}
</View> </View>
); );
} }
@@ -6,7 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
}): AgentSessionConfig { }): AgentSessionConfig {
return { return {
provider: input.provider, provider: input.provider,
@@ -14,6 +14,6 @@ export function buildWorkspaceDraftAgentConfig(input: {
...(input.modeId ? { modeId: input.modeId } : {}), ...(input.modeId ? { modeId: input.modeId } : {}),
...(input.model ? { model: input.model } : {}), ...(input.model ? { model: input.model } : {}),
...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}), ...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}),
...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), ...(input.featureValues ? { featureValues: input.featureValues } : {}),
}; };
} }
+2 -4
View File
@@ -26,8 +26,7 @@ import type { ComposerAttachment } from "@/attachments/types";
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentOptionControl, AgentFeature,
ProviderOptions,
AgentProvider, AgentProvider,
AgentMode, AgentMode,
AgentCapabilityFlags, AgentCapabilityFlags,
@@ -92,8 +91,7 @@ export interface Agent {
cwd: string; cwd: string;
workspaceId?: string; workspaceId?: string;
model: string | null; model: string | null;
options?: ProviderOptions; features?: AgentFeature[];
controls?: AgentOptionControl[];
thinkingOptionId?: string | null; thinkingOptionId?: string | null;
requiresAttention?: boolean; requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null; attentionReason?: "finished" | "error" | "permission" | null;
@@ -23,7 +23,7 @@ export interface PendingWorkspaceDraftSubmission {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
allowEmptyText?: boolean; allowEmptyText?: boolean;
} }
@@ -95,53 +95,6 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra
); );
}); });
it("keeps every saved layout when a draft tab's setup carries a nested provider option", async () => {
const values = new Map<string, string>();
const storage: StateStorage = {
getItem: (name) => values.get(name) ?? null,
setItem: (name, value) => {
values.set(name, value);
},
removeItem: (name) => {
values.delete(name);
},
};
const store = createWorkspaceLayoutStore(undefined, storage);
await store.persist.rehydrate();
store.getState().openTab({
workspaceKey: "server:kept",
target: { kind: "agent", agentId: "agent-1" },
intent: "reveal",
});
store.getState().openTab({
workspaceKey: "server:forked",
target: {
kind: "draft",
draftId: "draft-1",
setup: {
provider: "codex",
cwd: "/repo",
modeId: null,
model: null,
thinkingOptionId: null,
providerOptions: { sandbox_workspace_write: { network_access: true }, max_turns: 3 },
},
},
intent: "reveal",
});
const restored = createWorkspaceLayoutStore(undefined, storage);
await restored.persist.rehydrate();
expect(Object.keys(restored.getState().layoutByWorkspace).sort()).toEqual([
"server:forked",
"server:kept",
]);
expect(JSON.stringify(restored.getState().layoutByWorkspace["server:forked"])).toContain(
"network_access",
);
});
it.each([1, 2])( it.each([1, 2])(
"restores the agent pane after parked focus in storage version %i", "restores the agent pane after parked focus in storage version %i",
async (version) => { async (version) => {
@@ -1,25 +1,14 @@
import { z } from "zod"; import { z } from "zod";
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions"; import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Layouts saved before const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
// the rename stored the same record as `featureValues`. provider: z.string(),
function renameLegacyFeatureValues(value: unknown): unknown { cwd: z.string(),
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value; modeId: z.string().nullable(),
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>; model: z.string().nullable(),
return "providerOptions" in rest ? rest : { ...rest, providerOptions: featureValues }; thinkingOptionId: z.string().nullable(),
} featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
});
const WorkspaceDraftTabSetupStorageSchema = z.preprocess(
renameLegacyFeatureValues,
z.strictObject({
provider: z.string(),
cwd: z.string(),
modeId: z.string().nullable(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
providerOptions: z.record(z.string(), z.json()),
}),
);
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("new_tab") }), z.strictObject({ kind: z.literal("new_tab") }),
z.strictObject({ z.strictObject({
@@ -4551,6 +4551,7 @@ describe("compact workspace sheet", () => {
title: agent.id, title: agent.id,
cwd: "/repo", cwd: "/repo",
model: null, model: null,
features: undefined,
thinkingOptionId: undefined, thinkingOptionId: undefined,
requiresAttention: false, requiresAttention: false,
attentionReason: null, attentionReason: null,
@@ -63,6 +63,7 @@ const AGENT_DEFAULTS: Agent = {
cwd: WORKSPACE_DIRECTORY, cwd: WORKSPACE_DIRECTORY,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
model: null, model: null,
features: undefined,
thinkingOptionId: undefined, thinkingOptionId: undefined,
requiresAttention: false, requiresAttention: false,
attentionReason: null, attentionReason: null,
@@ -41,6 +41,7 @@ const AGENT_DEFAULTS: Agent = {
title: "Agent", title: "Agent",
cwd: "/tmp/project", cwd: "/tmp/project",
model: null, model: null,
features: undefined,
thinkingOptionId: undefined, thinkingOptionId: undefined,
requiresAttention: false, requiresAttention: false,
attentionReason: null, attentionReason: null,
@@ -60,6 +60,7 @@ const AGENT_DEFAULTS: Agent = {
cwd: "/repo/worktree", cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
model: null, model: null,
features: undefined,
thinkingOptionId: undefined, thinkingOptionId: undefined,
requiresAttention: false, requiresAttention: false,
attentionReason: null, attentionReason: null,
@@ -717,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => {
{ name: "DA1-zero", bytes: "\x1b[0c" }, { name: "DA1-zero", bytes: "\x1b[0c" },
{ name: "DA2", bytes: "\x1b[>c" }, { name: "DA2", bytes: "\x1b[>c" },
{ name: "DA3", bytes: "\x1b[=c" }, { name: "DA3", bytes: "\x1b[=c" },
{ name: "XTVERSION", bytes: "\x1b[>q" },
{ name: "DSR-5", bytes: "\x1b[5n" }, { name: "DSR-5", bytes: "\x1b[5n" },
{ name: "DSR-6", bytes: "\x1b[6n" }, { name: "DSR-6", bytes: "\x1b[6n" },
{ name: "DSR-?6", bytes: "\x1b[?6n" }, { name: "DSR-?6", bytes: "\x1b[?6n" },
{ name: "DECRQM", bytes: "\x1b[1$p" }, { name: "DECRQM", bytes: "\x1b[1$p" },
{ name: "DECRQM-?", bytes: "\x1b[?1$p" }, { name: "DECRQM-?", bytes: "\x1b[?1$p" },
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" }, { name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" }, { name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" }, { name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
@@ -647,6 +647,7 @@ export class TerminalEmulatorRuntime {
terminal.parser.registerCsiHandler({ final: "c" }, () => true); terminal.parser.registerCsiHandler({ final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
terminal.parser.registerCsiHandler({ final: "n" }, () => true); terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true); terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true); terminal.parser.registerCsiHandler({ final: "R" }, () => true);
@@ -655,6 +656,7 @@ export class TerminalEmulatorRuntime {
{ prefix: "?", intermediates: "$", final: "p" }, { prefix: "?", intermediates: "$", final: "p" },
() => true, () => true,
); );
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
for (const code of [10, 11, 12]) { for (const code of [10, 11, 12]) {
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?"); terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
} }
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { isControlActive, readSelectOption } from "@/agent-controls/option-controls";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots";
function createSnapshot( function createSnapshot(
@@ -33,39 +32,10 @@ function createSnapshot(
persistence: input.persistence ?? null, persistence: input.persistence ?? null,
title: input.title ?? null, title: input.title ?? null,
labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"], labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"],
...(input.controls ? { controls: input.controls } : {}),
...(input.options ? { options: input.options } : {}),
}; };
} }
describe("normalizeAgentSnapshot", () => { describe("normalizeAgentSnapshot", () => {
it("keeps a closed agent's stored options and controls, so its Fast control draws", () => {
const speed = {
type: "select" as const,
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
};
const agent = normalizeAgentSnapshot(
createSnapshot({
status: "closed",
controls: [speed],
options: { service_tier: "priority", sandbox_mode: "read-only" },
}),
"server-1",
);
expect(agent.status).toBe("closed");
expect(agent.controls).toEqual([speed]);
expect(agent.options).toEqual({ service_tier: "priority", sandbox_mode: "read-only" });
expect(readSelectOption(speed, agent.options ?? {})).toBe("priority");
expect(isControlActive(speed, agent.options ?? {})).toBe(true);
});
it("round-trips identified active turns through the canonical snapshot boundary", () => { it("round-trips identified active turns through the canonical snapshot boundary", () => {
const snapshot = createSnapshot({ const snapshot = createSnapshot({
status: "running", status: "running",
+2 -4
View File
@@ -64,8 +64,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
cwd: agent.cwd, cwd: agent.cwd,
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
model: agent.model, model: agent.model,
...(agent.options ? { options: agent.options } : {}), ...(agent.features ? { features: agent.features } : {}),
...(agent.controls ? { controls: agent.controls } : {}),
thinkingOptionId: agent.thinkingOptionId ?? null, thinkingOptionId: agent.thinkingOptionId ?? null,
createdAt: agent.createdAt.toISOString(), createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(), updatedAt: agent.updatedAt.toISOString(),
@@ -128,8 +127,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
cwd: snapshot.cwd, cwd: snapshot.cwd,
workspaceId: snapshot.workspaceId, workspaceId: snapshot.workspaceId,
model: snapshot.model ?? null, model: snapshot.model ?? null,
options: snapshot.options, features: snapshot.features,
controls: snapshot.controls,
thinkingOptionId: snapshot.thinkingOptionId ?? null, thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false, requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null, attentionReason: snapshot.attentionReason ?? null,

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