mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b28aafa1df | ||
|
|
5feb68da4f | ||
|
|
24ef4c48cc | ||
|
|
a7d5f99a1c | ||
|
|
f1e262a142 | ||
|
|
dc7ade5264 | ||
|
|
da8d58bfee | ||
|
|
7c2812dde6 | ||
|
|
b474beedce | ||
|
|
e2f0779379 | ||
|
|
a166a3317a | ||
|
|
efc64a5443 | ||
|
|
7efa14238d | ||
|
|
dbbcb103ba | ||
|
|
7f318bf7cf | ||
|
|
04fe4be40f | ||
|
|
2d75cc80b2 | ||
|
|
bfac132920 | ||
|
|
34f996b32a | ||
|
|
3e83f433da | ||
|
|
674530152e | ||
|
|
919e44b319 | ||
|
|
4fe44038e3 | ||
|
|
9b0542d127 | ||
|
|
8130147426 | ||
|
|
fa89738e3a |
@@ -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: |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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` |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||||
|
|||||||
Generated
+1018
-1295
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));
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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&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 | 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&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&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&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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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%"];
|
||||||
+11
-10
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+47
-30
@@ -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,
|
||||||
|
}));
|
||||||
+5
-5
@@ -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) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-11
@@ -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,
|
||||||
|
}));
|
||||||
+24
-59
@@ -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
|
||||||
@@ -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],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user